diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
index aedd964a578..0c196c9c8c0 100644
--- a/.github/workflows/docs-build.yml
+++ b/.github/workflows/docs-build.yml
@@ -41,6 +41,10 @@ jobs:
with:
fetch-depth: 0 # the git-revision-date plugin reads each page's history
+ # The design system in bindings/python/docs/brand is vendored, never edited here: it must still match the hash in its VERSION.md.
+ - name: Check the vendored design system copy
+ run: bash bindings/python/docs/brand/verify.sh
+
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
diff --git a/bindings/python/docs/brand/VERSION.md b/bindings/python/docs/brand/VERSION.md
index c5d20ea3660..90368c2dd8b 100644
--- a/bindings/python/docs/brand/VERSION.md
+++ b/bindings/python/docs/brand/VERSION.md
@@ -3,6 +3,6 @@
Do not edit these files here. Change them in github.com/humemai/design-system,
then vendor again. `./verify.sh` checks this copy against the hash below.
-Commit: 79b1a8b2318ada932dcdebdf57efb414d35b3119
-Date: 2026-11-06
-Hash: 0071e50a6c4a2714d4b8cf3b3edb75211214b2781bf17468712f4005b9acdf65
+Commit: 19edee040a025ba0551bd48dcb30d8b35046afa1
+Date: 2026-11-11
+Hash: 9d9b6f2c78a75ec59c0cfafc6b7984c258804c97cc8d2c66af98e23b6528af41
diff --git a/bindings/python/docs/brand/css/base.css b/bindings/python/docs/brand/css/base.css
new file mode 100644
index 00000000000..1483851fe28
--- /dev/null
+++ b/bindings/python/docs/brand/css/base.css
@@ -0,0 +1,71 @@
+/* HumemAI base rules every page wants and none should write twice. Opt-in:
+ load it after tokens.css; nothing here sets a colour, a font or a margin on
+ an element of its own. The focus ring was written twice (the Humem Tower's
+ stylesheet and the website's globals.css), and the Tower's skip link and its
+ motion rule had no counterpart on the site (humemai/design-system#14). The
+ structure follows Stateloop's base and motion files; the skip link is a
+ HumemAI original, since Stateloop's design system has none. */
+
+/* The focus ring: 2px in --hm-focus, which is 3:1 on every page surface
+ (scripts/check.py), pulled 2px off the edge so it never covers a border. */
+:focus-visible {
+ outline: 2px solid var(--hm-focus);
+ outline-offset: 2px;
+}
+
+/* A page with a sticky header sets --hm-sticky-offset on :root to the header's
+ height, so a jump to #main or any anchor lands below the header and a focused
+ control is not scrolled under it (WCAG 2.4.11). */
+html {
+ scroll-padding-block-start: var(--hm-sticky-offset, 0px);
+}
+
+/* Skip link: the first focusable thing on the page, Skip to content, and the target needs id="main". Hidden
+ until a keyboard focuses it, so it never shows to a mouse. Focused, it is
+ fixed to the top left corner with a high z-index, which puts it above a sticky
+ header (the website's is 40) instead of beneath it, in link ink on the
+ surface with a focus-colour border. */
+.hm-skip:not(:focus) {
+ position: absolute;
+ inline-size: 1px;
+ block-size: 1px;
+ padding: 0;
+ border: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ clip-path: inset(50%);
+}
+
+.hm-skip:focus {
+ position: fixed;
+ inset-block-start: var(--hm-space-2);
+ inset-inline-start: var(--hm-space-2);
+ z-index: 100;
+ padding: var(--hm-space-2) var(--hm-space-4);
+ background: var(--hm-surface);
+ border: 2px solid var(--hm-focus);
+ border-radius: var(--hm-radius-sm);
+ color: var(--hm-link);
+ font-weight: var(--hm-weight-semibold);
+ text-decoration: underline;
+ text-underline-offset: 0.2em;
+}
+
+/* Reduced motion: a reader who asked for less gets no transition and no
+ animation, whoever wrote them. A duration of 0.01ms and not 0s, so an
+ animation still ends in its last frame and its animationend still fires.
+ Hover lifts that a component writes as a transform are zeroed beside the
+ component (.hm-card, in components.css). */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-delay: 0s !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ transition-delay: 0s !important;
+ scroll-behavior: auto !important;
+ }
+}
diff --git a/bindings/python/docs/brand/css/components.css b/bindings/python/docs/brand/css/components.css
index 5a8d6b3c857..bcbd570fe77 100644
--- a/bindings/python/docs/brand/css/components.css
+++ b/bindings/python/docs/brand/css/components.css
@@ -49,4 +49,230 @@
.hm-card {
transition: none;
}
+
+ .hm-card:hover,
+ .hm-card:focus-visible {
+ transform: none;
+ }
+}
+
+/* Hint: a term's short explanation. A small ? beside the word, opened by hover
+ with a mouse and by click, tap or Enter everywhere (humemai/design-system #12,
+ mirrored from Stateloop's .stl-hint of 2026-11-06). A native title="" tooltip
+ never shows on a touch screen and a keyboard cannot reach it. The button is a
+ circle, the one round shape here: a help mark is an icon people recognise by
+ that shape, not a container. It is --hm-tap-min (24px, the WCAG 2.5.8 floor)
+ on both sides, and its minimum sizes are set too, because an app's own
+ button { min-height } would otherwise stretch the circle into an oval. At
+ 999px and narrower it keeps its 24px mark and grows a 44px hit area (the
+ ::after), not its layout. The border is --hm-border-control, 3:1 on every page
+ surface; hover, focus and the open state turn the border and the mark to the
+ brand colour, and a keyboard gets the focus ring. The bubble is a popover in
+ the top layer, so a scrolling table or a sheet cannot clip it; js/hint.js
+ places it under its button (above it near the bottom) and keeps it on screen,
+ and it stays hidden until placed so it never flashes in a corner. The hint
+ sits beside its term, often inside an all-caps, letter-spaced column head, so
+ the button and the bubble reset every inherited text style: the definition
+ must read as body text. Only for a word a person cannot act without knowing;
+ anything needed to act stays visible. */
+.hm-hint {
+ box-sizing: border-box;
+ display: inline-grid;
+ place-items: center;
+ inline-size: var(--hm-tap-min);
+ block-size: var(--hm-tap-min);
+ min-inline-size: var(--hm-tap-min);
+ min-block-size: var(--hm-tap-min);
+ margin-inline-start: var(--hm-space-1);
+ padding: 0;
+ border: 1px solid var(--hm-border-control);
+ border-radius: var(--hm-radius-pill);
+ background: transparent;
+ color: var(--hm-text-muted);
+ font-family: var(--hm-font-text);
+ font-size: var(--hm-text-xs);
+ font-style: normal;
+ font-weight: var(--hm-weight-semibold);
+ line-height: 1;
+ letter-spacing: normal;
+ text-transform: none;
+ vertical-align: middle;
+ cursor: help;
+}
+
+.hm-hint:hover,
+.hm-hint:focus-visible,
+.hm-hint[aria-expanded="true"] {
+ border-color: var(--hm-brand);
+ color: var(--hm-brand);
+}
+
+.hm-hint:focus-visible {
+ outline: 2px solid var(--hm-focus);
+ outline-offset: 2px;
+}
+
+.hm-hint__bubble {
+ box-sizing: border-box;
+ inset: auto;
+ margin: 0;
+ max-inline-size: min(calc(var(--hm-measure) / 2), calc(100vw - 2 * var(--hm-space-4)));
+ padding: var(--hm-space-2) var(--hm-space-3);
+ border: 1px solid var(--hm-border-strong);
+ border-radius: var(--hm-radius-md);
+ background: var(--hm-surface-raised);
+ color: var(--hm-text);
+ font-family: var(--hm-font-text);
+ font-size: var(--hm-text-sm);
+ font-style: normal;
+ font-weight: var(--hm-weight-regular);
+ line-height: 1.45;
+ text-align: start;
+ text-indent: 0;
+ text-transform: none;
+ letter-spacing: normal;
+ word-spacing: normal;
+ white-space: normal;
+ box-shadow: var(--hm-shadow-md);
+}
+
+.hm-hint__bubble:not([data-placed]) {
+ visibility: hidden;
+}
+
+.hm-hint__bubble a {
+ color: var(--hm-link);
+ text-decoration: underline;
+ text-underline-offset: 0.15em;
+}
+
+.hm-hint__bubble a:hover {
+ color: var(--hm-link-hover);
+}
+
+@media (max-width: 999px) {
+ .hm-hint {
+ position: relative;
+ }
+
+ .hm-hint::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: var(--hm-tap-comfort);
+ height: var(--hm-tap-comfort);
+ transform: translate(-50%, -50%);
+ }
+}
+
+/* Table: a data table inside a scroller that a keyboard can reach. Mirrored
+ from the Humem Tower's tables (humemai/design-system#14); Stateloop's .stl-table
+ has no scroller, but its rule holds here too: a scroller has a name and a way
+ in from the keyboard. Wrap the table in .hm-table-scroll and give the wrapper
+ role="region", tabindex="0" and an aria-label naming the table; without them
+ a keyboard cannot scroll a wide table (the benchmark scroller on humem.ai
+ lacked all three). Only the wrapper scrolls, so the page never does. The
+ table is border-collapse: separate with no spacing, not collapse, because a
+ collapsed table drops the borders of a sticky header cell when it scrolls.
+ Text is --hm-text-sm (14px), the system's table size. Column widths are the
+ page's own: the Tower sets them by column role, the website by layout, and
+ neither belongs here. */
+.hm-table-scroll {
+ margin: 0 0 var(--hm-space-4);
+ overflow-x: auto;
+ background: var(--hm-surface);
+ border: 1px solid var(--hm-border);
+ border-radius: var(--hm-radius-md);
+}
+
+.hm-table {
+ inline-size: 100%;
+ margin: 0;
+ border-collapse: separate;
+ border-spacing: 0;
+ font-size: var(--hm-text-sm);
+ line-height: 1.4;
+}
+
+.hm-table th,
+.hm-table td {
+ padding: var(--hm-space-1) var(--hm-space-2);
+ border-bottom: 1px solid var(--hm-border);
+ text-align: left;
+ vertical-align: top;
+ overflow-wrap: break-word;
+}
+
+.hm-table th {
+ background: var(--hm-surface-sunken);
+ border-bottom: 2px solid var(--hm-border-strong);
+ font-weight: var(--hm-weight-semibold);
+}
+
+.hm-table tbody tr:last-child td {
+ border-bottom: 0;
+}
+
+/* Optional: the header row stays in view while the table scrolls down. It
+ sticks to the wrapper, so the wrapper needs a height of its own, for example
+ max-block-size: 70vh; a wrapper as tall as its table has nothing to scroll. */
+.hm-table--sticky-head thead th {
+ position: sticky;
+ inset-block-start: 0;
+ z-index: 1;
+}
+
+/* A numeric column: against the right edge, so units and decimal points line
+ up, and in DM Mono. Schibsted Grotesk's default figures are proportional (the
+ Tower measured "1" at 5.8px and "0" at 10.4px at 17px), and its tabular
+ figures also widen the punctuation ("1 . 6 h"), so no tabular-nums here: the
+ cell takes the mono face. The header cell aligns right and keeps its own face. */
+.hm-table .hm-num {
+ text-align: right;
+ white-space: nowrap;
+}
+
+.hm-table td.hm-num {
+ font-family: var(--hm-font-mono);
+}
+
+/* Badge: a state or a label as a pill. The word is always there; the fill and a
+ symbol are a second cue, never colour alone. Three variants, because the brand
+ has one voice and one warning: brand (filled, the active or running thing),
+ danger (the failed or overdue thing), tint (the held, paused or stale thing).
+ With no variant it is a quiet outline. The pairings (text on its own fill,
+ border on the page) are in scripts/check.py. A symbol is the consumer's: put it
+ in a ::before with an empty alt, content: "\25B6\FE0E" / "", so a screen reader
+ reads the word, not the shape (the Tower maps its nine states this way). */
+.hm-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--hm-space-1);
+ padding: 0 var(--hm-space-2);
+ background: var(--hm-surface);
+ border: 1px solid var(--hm-border-control);
+ border-radius: var(--hm-radius-pill);
+ color: var(--hm-text-muted);
+ font-size: var(--hm-text-sm);
+ font-weight: var(--hm-weight-semibold);
+ line-height: 1.5;
+ white-space: nowrap;
+}
+
+.hm-badge--brand {
+ background: var(--hm-brand);
+ border-color: var(--hm-brand);
+ color: var(--hm-on-brand);
+}
+
+.hm-badge--danger {
+ border-color: var(--hm-danger);
+ color: var(--hm-danger);
+}
+
+.hm-badge--tint {
+ background: var(--hm-tint);
+ border-color: var(--hm-tint-text);
+ color: var(--hm-tint-text);
}
diff --git a/bindings/python/docs/brand/css/tokens.css b/bindings/python/docs/brand/css/tokens.css
index 12fa32c43bb..1b84fe63b44 100644
--- a/bindings/python/docs/brand/css/tokens.css
+++ b/bindings/python/docs/brand/css/tokens.css
@@ -93,6 +93,15 @@
--hm-space-20: 5rem;
--hm-space-24: 6rem;
+ /* Tap targets. 24px is the WCAG 2.5.8 floor, for a mouse; at 999px and
+ narrower a control is 44px (the Behaviour section of the README). */
+ --hm-tap-min: 1.5rem; /* 24 */
+ --hm-tap-comfort: 2.75rem; /* 44 */
+ /* The height of a button, input or select: --hm-tap-comfort (44) at 999px and
+ narrower, 36 from 1000px (the media block below), where a pointer is enough.
+ A control sets min-height: var(--hm-control-h) and pads the rest. */
+ --hm-control-h: var(--hm-tap-comfort);
+
--hm-radius-sm: 0.375rem;
--hm-radius-md: 0.625rem;
--hm-radius-lg: 1rem;
@@ -104,6 +113,7 @@
--hm-surface: var(--hm-neutral-0);
--hm-surface-muted: var(--hm-neutral-50);
--hm-surface-sunken: var(--hm-neutral-100);
+ --hm-surface-raised: var(--hm-neutral-0); /* what floats above the page: a hint bubble; the border and shadow lift it in light */
--hm-text: var(--hm-neutral-900);
--hm-text-muted: var(--hm-neutral-600);
--hm-text-subtle: var(--hm-neutral-500); /* on --hm-bg and --hm-surface only */
@@ -119,11 +129,13 @@
--hm-tint-text: var(--hm-oxblood-700);
--hm-focus: var(--hm-oxblood-600);
--hm-danger: var(--hm-danger-light);
+ --hm-shadow-md: 0 3px 10px color-mix(in srgb, var(--hm-neutral-900) 16%, transparent);
}
@media (min-width: 1000px) {
:root {
--hm-text-base: 1.0625rem; /* 17 */
+ --hm-control-h: 2.25rem; /* 36 */
}
}
@@ -136,6 +148,7 @@
--hm-surface: var(--hm-neutral-900);
--hm-surface-muted: var(--hm-neutral-900);
--hm-surface-sunken: var(--hm-neutral-950);
+ --hm-surface-raised: var(--hm-neutral-800);
--hm-text: var(--hm-neutral-50);
--hm-text-muted: var(--hm-neutral-400);
--hm-text-subtle: var(--hm-neutral-450);
@@ -151,6 +164,7 @@
--hm-tint-text: var(--hm-oxblood-300);
--hm-focus: var(--hm-oxblood-400);
--hm-danger: var(--hm-danger-dark);
+ --hm-shadow-md: 0 3px 10px color-mix(in srgb, var(--hm-neutral-950) 70%, transparent);
}
}
@@ -160,6 +174,7 @@
--hm-surface: var(--hm-neutral-900);
--hm-surface-muted: var(--hm-neutral-900);
--hm-surface-sunken: var(--hm-neutral-950);
+ --hm-surface-raised: var(--hm-neutral-800);
--hm-text: var(--hm-neutral-50);
--hm-text-muted: var(--hm-neutral-400);
--hm-text-subtle: var(--hm-neutral-450);
@@ -175,4 +190,5 @@
--hm-tint-text: var(--hm-oxblood-300);
--hm-focus: var(--hm-oxblood-400);
--hm-danger: var(--hm-danger-dark);
+ --hm-shadow-md: 0 3px 10px color-mix(in srgb, var(--hm-neutral-950) 70%, transparent);
}
diff --git a/bindings/python/docs/brand/js/hint.js b/bindings/python/docs/brand/js/hint.js
new file mode 100644
index 00000000000..29b13b577bd
--- /dev/null
+++ b/bindings/python/docs/brand/js/hint.js
@@ -0,0 +1,182 @@
+/**
+ * Explain a term in place: `.hm-hint` buttons open their `.hm-hint__bubble`
+ * popover (humemai/design-system #12; mirrored from Stateloop's hint.js).
+ *
+ *
+ * One sentence.
+ *
+ * The browser's Popover API does the opening and the dismissing: a click, tap
+ * or Enter shows it, and a tap outside or Esc closes it. This adds what it
+ * lacks: placing the bubble by its button and keeping it on screen, following
+ * it while the page or a panel scrolls, and hover with a mouse (it opens on
+ * enter and closes on leave, unless it was clicked, which pins it). The
+ * listeners sit on the document, so hints rendered later -- by an app that
+ * re-renders with innerHTML -- work with no per-render wiring. Call it once.
+ *
+ * @param {Document} [doc]
+ * @returns {() => void} cleanup
+ */
+const GAP = 6;
+const EDGE = 8;
+let bound = null;
+
+export function bindHints(doc = document) {
+ if (bound) return bound;
+ const win = doc.defaultView;
+ let open = null;
+
+ const bubbleOf = (trigger) => doc.getElementById(trigger.getAttribute("popovertarget") || "");
+ const triggerOf = (bubble) =>
+ Array.from(doc.querySelectorAll(".hm-hint")).find((t) => t.getAttribute("popovertarget") === bubble.id);
+ const hintAt = (target) => (target && target.closest ? target.closest(".hm-hint") : null);
+ const isBubble = (el) => el && el.classList && el.classList.contains("hm-hint__bubble");
+ const isOpen = (bubble) => bubble.matches(":popover-open");
+
+ function place(bubble) {
+ const trigger = triggerOf(bubble);
+ if (!trigger) return;
+ const t = trigger.getBoundingClientRect();
+ const b = bubble.getBoundingClientRect();
+ const vw = doc.documentElement.clientWidth;
+ const vh = win.innerHeight;
+ const left = Math.max(EDGE, Math.min(t.left + t.width / 2 - b.width / 2, vw - b.width - EDGE));
+ let top = t.bottom + GAP;
+ if (top + b.height > vh - EDGE && t.top - GAP - b.height >= EDGE) top = t.top - GAP - b.height;
+ bubble.style.left = `${Math.round(left)}px`;
+ bubble.style.top = `${Math.round(top)}px`;
+ bubble.dataset.placed = "";
+ }
+
+ // `toggle` does not bubble, but a capturing listener on the document sees it.
+ function onToggle(e) {
+ const bubble = e.target;
+ if (!isBubble(bubble)) return;
+ const trigger = triggerOf(bubble);
+ if (e.newState === "open") {
+ open = bubble;
+ place(bubble);
+ if (trigger) trigger.setAttribute("aria-expanded", "true");
+ } else {
+ if (open === bubble) open = null;
+ delete bubble.dataset.placed;
+ delete bubble.dataset.pinned;
+ if (trigger) trigger.setAttribute("aria-expanded", "false");
+ }
+ }
+
+ // A click pins it: leaving with the mouse no longer closes it.
+ function onClick(e) {
+ const trigger = hintAt(e.target);
+ const bubble = trigger && bubbleOf(trigger);
+ if (bubble) bubble.dataset.pinned = "";
+ }
+
+ // Hover is for a mouse only: a touch screen has no hover, and its tap is the click.
+ function onOver(e) {
+ if (e.pointerType !== "mouse") return;
+ const trigger = hintAt(e.target);
+ const bubble = trigger && bubbleOf(trigger);
+ if (bubble && !isOpen(bubble)) bubble.showPopover();
+ }
+
+ function onOut(e) {
+ if (e.pointerType !== "mouse") return;
+ const trigger = hintAt(e.target);
+ if (!trigger || trigger.contains(e.relatedTarget)) return;
+ const bubble = bubbleOf(trigger);
+ if (bubble && isOpen(bubble) && !("pinned" in bubble.dataset)) bubble.hidePopover();
+ }
+
+ function follow() {
+ if (open) place(open);
+ }
+
+ doc.addEventListener("toggle", onToggle, true);
+ doc.addEventListener("click", onClick, true);
+ doc.addEventListener("pointerover", onOver, true);
+ doc.addEventListener("pointerout", onOut, true);
+ win.addEventListener("scroll", follow, true);
+ win.addEventListener("resize", follow);
+
+ bound = () => {
+ doc.removeEventListener("toggle", onToggle, true);
+ doc.removeEventListener("click", onClick, true);
+ doc.removeEventListener("pointerover", onOver, true);
+ doc.removeEventListener("pointerout", onOut, true);
+ win.removeEventListener("scroll", follow, true);
+ win.removeEventListener("resize", follow);
+ bound = null;
+ };
+ return bound;
+}
+
+/**
+ * The "?" and its bubble for a glossary term, so no app keeps a private copy.
+ *
+ * const GLOSSARY = { fit: { term: "the fit score", text: "How well they match, 1 to 5.", learnMore: "https://example.com/fit" } };
+ * header.innerHTML = `Fit ${termHint(GLOSSARY, "fit", "the fit score")}`; // an app that renders with innerHTML
+ * header.append("Fit ", termHintElement(GLOSSARY, "fit", "the fit score")); // an app with an h() helper
+ *
+ * Both give the markup in the header of this file: `button.hm-hint` (`aria-label` "What is ?") and `span.hm-hint__bubble`,
+ * with a fresh id (`hm-hint-1`, `hm-hint-2`, ...) from one counter in this module. A key the glossary lacks throws an Error that
+ * names it: a silent empty string hides a typo and drops the explanation. `learnMore` (an http(s) or site-relative URL) adds a last
+ * "Learn more" line to the bubble. `term` defaults to the entry's own `term`.
+ */
+let hintSeq = 0;
+
+function entryFor(glossary, key) {
+ const entry = glossary && Object.prototype.hasOwnProperty.call(glossary, key) ? glossary[key] : undefined;
+ if (!entry || typeof entry.text !== "string" || !entry.text) {
+ throw new Error(`termHint: no glossary entry for key "${key}"`);
+ }
+ if (entry.learnMore != null && !/^(https?:\/\/|\/(?!\/))/.test(entry.learnMore)) {
+ throw new Error(`termHint: learnMore of "${key}" must be an http(s) or site-relative URL`);
+ }
+ return entry;
+}
+
+function esc(s) {
+ return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
+}
+
+const MORE = "Learn more";
+
+export function termHint(glossary, key, term) {
+ const entry = entryFor(glossary, key);
+ const id = `hm-hint-${++hintSeq}`;
+ const label = esc(`What is ${term ?? entry.term ?? key}?`);
+ const more = entry.learnMore ? ` ${MORE}` : "";
+ return ``
+ + `${esc(entry.text)}${more}`;
+}
+
+export function termHintElement(glossary, key, term, doc = globalThis.document) {
+ const entry = entryFor(glossary, key);
+ const id = `hm-hint-${++hintSeq}`;
+ const button = doc.createElement("button");
+ button.type = "button";
+ button.className = "hm-hint";
+ button.setAttribute("popovertarget", id);
+ button.setAttribute("popovertargetaction", "show");
+ button.setAttribute("aria-label", `What is ${term ?? entry.term ?? key}?`);
+ button.setAttribute("aria-describedby", id);
+ button.textContent = "?";
+ const bubble = doc.createElement("span");
+ bubble.id = id;
+ bubble.className = "hm-hint__bubble";
+ bubble.setAttribute("popover", "");
+ bubble.setAttribute("role", "tooltip");
+ bubble.append(entry.text);
+ if (entry.learnMore) {
+ const a = doc.createElement("a");
+ a.href = entry.learnMore;
+ a.target = "_blank";
+ a.rel = "noopener";
+ a.textContent = MORE;
+ bubble.append(doc.createElement("br"), a);
+ }
+ const frag = doc.createDocumentFragment();
+ frag.append(button, bubble);
+ return frag;
+}
diff --git a/bindings/python/docs/brand/verify.sh b/bindings/python/docs/brand/verify.sh
index bffb1eb6323..9c56c4eec7a 100755
--- a/bindings/python/docs/brand/verify.sh
+++ b/bindings/python/docs/brand/verify.sh
@@ -4,7 +4,7 @@
set -euo pipefail
cd "$(dirname "$0")"
claimed=$(sed -n 's/^Hash:[[:space:]]*//p' VERSION.md)
-actual=$(find css logo assets export -type f | LC_ALL=C sort | xargs sha256sum | sha256sum | awk '{print $1}')
+actual=$(find css js logo assets export -type f | LC_ALL=C sort | xargs sha256sum | sha256sum | awk '{print $1}')
if [ "$claimed" != "$actual" ]; then
echo "✗ brand files differ from VERSION.md ($claimed vs $actual)."
echo " Change them in humemai/design-system and vendor again; don't edit the copy."
diff --git a/bindings/python/docs/development/ci-setup.md b/bindings/python/docs/development/ci-setup.md
index ab66d9cd16f..ec5533ed53e 100644
--- a/bindings/python/docs/development/ci-setup.md
+++ b/bindings/python/docs/development/ci-setup.md
@@ -88,7 +88,7 @@ A newer push to a pull request cancels the older run of this workflow; runs on `
### `docs-build.yml`
-Named `Docs Build`. Runs `mkdocs build --strict` (Python 3.12, pinned `mkdocs`, `mkdocs-material` and plugin versions, run with `uvx`; `deploy-python-docs.yml` runs `mike` with the same versions) on pull requests and pushes to `main` that change `bindings/python/docs/**`, `bindings/python/mkdocs.yml` or the workflow, and on manual dispatch. It does not run the code blocks in the pages. See [Documentation](documentation.md#build-documentation).
+Named `Docs Build`. First checks that the vendored design system in `bindings/python/docs/brand/` still matches the hash in its `VERSION.md` (`brand/verify.sh`), so a hand edit of the copy fails. Then runs `mkdocs build --strict` (Python 3.12, pinned `mkdocs`, `mkdocs-material` and plugin versions, run with `uvx`; `deploy-python-docs.yml` runs `mike` with the same versions) on pull requests and pushes to `main` that change `bindings/python/docs/**`, `bindings/python/mkdocs.yml` or the workflow, and on manual dispatch. It does not run the code blocks in the pages. See [Documentation](documentation.md#build-documentation).
### `build-engine-jars.yml`
diff --git a/bindings/python/docs/development/documentation.md b/bindings/python/docs/development/documentation.md
index e20bbfd17d5..ffc2cc26eb1 100644
--- a/bindings/python/docs/development/documentation.md
+++ b/bindings/python/docs/development/documentation.md
@@ -322,7 +322,7 @@ The Material theme is mobile-responsive by default. Test by:
## Continuous Integration
-The `Docs Build` workflow (`docs-build.yml`) runs `mkdocs build --strict` on every pull request
+The `Docs Build` workflow (`docs-build.yml`) first runs `brand/verify.sh`, which fails when the vendored design system in `docs/brand/` was edited by hand, then runs `mkdocs build --strict` on every pull request
and every push to `main` that changes `bindings/python/docs/**`, `bindings/python/mkdocs.yml` or the workflow.
`deploy-python-docs.yml` runs only on a version tag or a manual dispatch, and it runs
`mike deploy`, not a strict build. Run the strict build locally before you push a docs