From bd97fb96b6365cff717a9b556a0835c64200a09b Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 18 Oct 2026 20:22:16 +0800 Subject: [PATCH 1/9] fix(design): read logical properties in the semantic-literal contract The property pattern stopped at the first hyphen: `padding-inline-start` matched as `padding-inline`, the `:` it required next never arrived, and the whole declaration was skipped. Logical properties are the form this codebase actually writes, so the rule was blind wherever it mattered. Accept every hyphen in the class, assert the form in the unit contract, and correct the three literal insets it surfaced: 12px and 8px in the assistant, 22px in the landing list. Validation: site-token-consistency 12/12. --- bin/design-tokens.mjs | 7 ++++++- public/landing.css | 2 +- styles/assistant.css | 4 ++-- test/unit/site-token-consistency.test.ts | 6 ++++++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/bin/design-tokens.mjs b/bin/design-tokens.mjs index 600d9af5e..eb7a147e9 100644 --- a/bin/design-tokens.mjs +++ b/bin/design-tokens.mjs @@ -95,7 +95,12 @@ export function semanticLiterals(source) { css = removeFunctions(css); return [ ...css.matchAll( - /(? [...match[2].matchAll(/(-?\d+(?:\.\d+)?)px/g)].some((value) => Math.abs(Number(value[1])) > 1)) diff --git a/public/landing.css b/public/landing.css index 9d1b7c670..674cd182a 100644 --- a/public/landing.css +++ b/public/landing.css @@ -609,7 +609,7 @@ footer .theme-switch { color: var(--ran-color-text-secondary); } .doc ol { - padding-inline-start: 22px; + padding-inline-start: var(--ran-space-6); margin: var(--ran-space-4) 0; } .doc hr { diff --git a/styles/assistant.css b/styles/assistant.css index 3500345dd..491021a80 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -1871,7 +1871,7 @@ r-dropdown[role='listbox'] { .agent-panel .agent-task-action::after { content: '↗'; margin-inline-start: auto; - padding-inline-start: 12px; + padding-inline-start: var(--ran-space-3); color: var(--ran-color-text-secondary); } .agent-panel .agent-task-action:hover { @@ -2564,7 +2564,7 @@ r-dropdown[role='listbox'] { } .agent-artifact-presentation details > button, .agent-artifact-data-preview > button { - margin-inline-end: 8px; + margin-inline-end: var(--ran-space-2); } .agent-artifact-presentation summary { color: var(--ran-color-text-secondary); diff --git a/test/unit/site-token-consistency.test.ts b/test/unit/site-token-consistency.test.ts index 90d10e2c8..c1b4fbc53 100644 --- a/test/unit/site-token-consistency.test.ts +++ b/test/unit/site-token-consistency.test.ts @@ -64,6 +64,12 @@ it('rejects raw semantic metrics while allowing fluid geometry and real standalo const { semanticLiterals, designTokenSources, authoredStyles } = await import('../../bin/design-tokens.mjs'); expect(semanticLiterals('a{padding:8px 16px;font-size:13px;border-radius:12px}')).toHaveLength(3); expect(semanticLiterals('a{padding:var(--ran-space-2,8px);font-size:clamp(24px,4vw,40px);margin:-1px}')).toEqual([]); + // Logical properties carry more than one hyphen; the property name has to be + // read through all of them or the declaration is never checked at all. + expect(semanticLiterals('a{padding-inline-start:12px;margin-block-end:8px}')).toEqual([ + 'padding-inline-start: 12px', + 'margin-block-end: 8px', + ]); const { generate } = await import('../../bin/build-pages.mjs'); for (const file of designTokenSources(process.cwd())) { expect(semanticLiterals(authoredStyles(file, read(file))), file).toEqual([]); From 7918977042a68e825422294fd6141f10745e60ad Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 18 Oct 2026 20:22:32 +0800 Subject: [PATCH 2/9] feat(design): never transition a palette property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS cannot tell why a colour changed, so a palette property in a transition also fades when the theme flips its token: each surface lands at its own duration while the page around it snaps. DESIGN §5 bans the default and nothing was checking it. `paletteTransitions()` judges each comma-separated transition by its first token — a timing value there means the property was omitted, i.e. `all` — and the unit contract runs it over every authored stylesheet and every freshly generated page. A lone `var(--…-transition)` stays the opt-in hook. It found twelve declarations in five files. Four were bare durations (`transition: 0.15s ease`, which is `all`) on the home and landing navigation; eight named palette properties only. Every hover and focus now snaps, which is also what §5 asks of frequent feedback. --- bin/design-tokens.d.mts | 1 + bin/design-tokens.mjs | 32 ++++++++++++++++++++++++ packages/chat-ui/src/styles.ts | 5 ++-- public/home.css | 9 +------ public/landing.css | 3 --- styles/assistant.css | 14 +++++------ styles/history.css | 12 +++------ test/unit/site-token-consistency.test.ts | 21 ++++++++++++++++ 8 files changed, 67 insertions(+), 30 deletions(-) diff --git a/bin/design-tokens.d.mts b/bin/design-tokens.d.mts index c6ec1ebc6..50599347d 100644 --- a/bin/design-tokens.d.mts +++ b/bin/design-tokens.d.mts @@ -7,4 +7,5 @@ export function designTokenSources(root: string): string[]; export function authoredStyles(file: string, source: string): string; export function auditDesignTokens(root: string, generated?: Array<{ rel: string; html: string }>): TokenAuditError[]; export function semanticLiterals(source: string): string[]; +export function paletteTransitions(source: string): string[]; export function invalidTokenArithmetic(source: string): string[]; diff --git a/bin/design-tokens.mjs b/bin/design-tokens.mjs index eb7a147e9..090a021ff 100644 --- a/bin/design-tokens.mjs +++ b/bin/design-tokens.mjs @@ -107,6 +107,38 @@ export function semanticLiterals(source) { .map((match) => `${match[1]}: ${match[2].trim()}`); } +/** + * A palette property in a `transition` also animates when the *theme* flips its + * token, because CSS cannot tell why a value changed. Every surface then fades at + * its own duration while the page around it snaps -- see DESIGN.md §5. Motion + * properties (`transform`, `opacity`, box geometry) do not follow the theme and + * are fine. `all` and bare durations mean `all`, so they include palette props. + * + * Each comma-separated transition is judged by its *first* token: a timing value + * there means the property was omitted, i.e. `all`. A lone `var(--…-transition)` + * is the documented opt-in hook and is skipped -- its value is the consumer's. + */ +export function paletteTransitions(source) { + const palette = + /^(?:all|background|background-color|color|border|border-color|border-[\w-]*color|box-shadow|fill|stroke|outline-color|text-decoration-color)$/; + const timing = + /^(?:[\d.]+m?s|ease|linear|ease-in|ease-out|ease-in-out|step-start|step-end|cubic-bezier\(.*\)|steps\(.*\))$/; + const found = []; + for (const match of clean(source).matchAll(/(?:^|[;{])\s*transition(?:-property)?\s*:\s*([^;}]+)/g)) { + const value = match[1].replace(/var\([^()]*\)/g, 'VAR').trim(); + if (value === 'VAR') continue; + const offends = value.split(',').some((part) => { + const first = part.trim().split(/\s+/)[0]; + if (!first) return false; + // A hook variable in first position is indistinguishable from a duration; + // only a named property or a literal timing value is decidable. + return first !== 'VAR' && (timing.test(first) || palette.test(first)); + }); + if (offends) found.push(`transition: ${value.replace(/\s+/g, ' ')}`); + } + return found; +} + /** CSS functions cannot take a prefixed unary sign: negate their numeric value * inside calc(), e.g. calc(-1 * var(--space)), instead of -var(--space). */ diff --git a/packages/chat-ui/src/styles.ts b/packages/chat-ui/src/styles.ts index 1b05c4cbe..47ea46e68 100644 --- a/packages/chat-ui/src/styles.ts +++ b/packages/chat-ui/src/styles.ts @@ -60,7 +60,7 @@ export const CHAT_UI_CSS = ` .cui-table th { background:var(--cui-user-bg); font-weight:var(--ran-text-label-weight, 500); font-size:var(--ran-text-label-2, 13px); } .cui-table tr:last-child td { border-bottom:0; } .cui-message-actions { display:flex; align-items:center; flex-wrap:wrap; gap:var(--ran-space-2, 8px); } -.cui-message-actions button { border:0; background:transparent; color:var(--cui-muted); cursor:pointer; font:inherit; font-size:var(--ran-text-label-3, 12px); min-height:var(--ran-size-7, 32px); padding:var(--ran-space-1, 4px) var(--ran-space-2, 8px); border-radius:var(--ran-radius-sm, 6px); transition:background-color var(--ran-motion-duration-fast, .15s), color var(--ran-motion-duration-fast, .15s); } +.cui-message-actions button { border:0; background:transparent; color:var(--cui-muted); cursor:pointer; font:inherit; font-size:var(--ran-text-label-3, 12px); min-height:var(--ran-size-7, 32px); padding:var(--ran-space-1, 4px) var(--ran-space-2, 8px); border-radius:var(--ran-radius-sm, 6px); } .cui-message-actions button:hover:not(:disabled) { color:var(--cui-text); background:var(--cui-user-bg); } .cui-message-actions button:disabled { opacity:.5; cursor:default; } .cui-message-actions .cui-apply { color:var(--cui-text); font-weight:var(--ran-text-label-weight, 500); } @@ -101,7 +101,7 @@ export const CHAT_UI_CSS = ` .cui-context:empty { display:none; } .cui-input { min-width:0; width:100%; box-sizing:border-box; resize:none; overflow-y:auto; border:0; outline:none; background:transparent; padding:0; font:inherit; line-height:1.5; color:inherit; max-height:160px; min-height:1.5em; } .cui-input::placeholder { color:var(--cui-muted); } -.cui-send, .cui-stop { flex:0 0 auto; width:var(--ran-size-7, 32px); height:var(--ran-size-7, 32px); box-sizing:border-box; padding:0; display:flex; align-items:center; justify-content:center; border:0; border-radius:var(--ran-radius-sm, 6px); cursor:pointer; background:var(--cui-accent); color:var(--cui-accent-contrast); transition:background-color var(--ran-motion-duration-fast, .15s), color var(--ran-motion-duration-fast, .15s); } +.cui-send, .cui-stop { flex:0 0 auto; width:var(--ran-size-7, 32px); height:var(--ran-size-7, 32px); box-sizing:border-box; padding:0; display:flex; align-items:center; justify-content:center; border:0; border-radius:var(--ran-radius-sm, 6px); cursor:pointer; background:var(--cui-accent); color:var(--cui-accent-contrast);; } .cui-send:hover:not(:disabled) { background:color-mix(in srgb, var(--cui-accent) 85%, var(--cui-bg)); } .cui-root .cui-stop:hover:not(:disabled) { background:var(--cui-user-bg); border-color:var(--cui-muted); } .cui-send:disabled, .cui-stop:disabled { background:var(--cui-surface); color:var(--cui-muted); opacity:.5; cursor:default; } @@ -116,7 +116,6 @@ export const CHAT_UI_CSS = ` @keyframes cui-loading-turn { to { transform:rotate(360deg); } } @keyframes cui-answer-pulse { 0%,100% { opacity:.45; transform:scale(.85); } 50% { opacity:1; transform:scale(1.15); } } @media (prefers-reduced-motion:reduce) { - .cui-message-actions button, .cui-send, .cui-stop { transition:none; } .cui-root[data-activity] .cui-status::before,.cui-root[data-activity] .cui-streaming .cui-bubble::after { animation:none; transform:none; opacity:1; } } `; diff --git a/public/home.css b/public/home.css index 5f0753fb3..a6f1c124e 100644 --- a/public/home.css +++ b/public/home.css @@ -161,7 +161,6 @@ body { min-height: calc(22px + var(--ran-space-2) * 2); color: var(--ran-color-text-secondary); cursor: pointer; - transition: 0.15s ease; } #landing-hero .lang-menu:hover .lang-trigger, #landing-hero .lang-menu[open] .lang-trigger { @@ -284,7 +283,6 @@ body { different boxes in one row. 22px matches the theme switch beside them. */ line-height: 22px; color: var(--ran-color-text-secondary); - transition: 0.15s ease; } #landing-hero .bar nav .navlink:hover { color: var(--ran-color-text); @@ -734,9 +732,6 @@ body { border-bottom: 1px solid var(--ran-color-border); border-radius: var(--ran-radius-sm); color: inherit; - transition: - background 0.15s ease, - border-color 0.15s ease; } #landing-hero .index .row:hover { background: var(--ran-color-bg-hover); @@ -761,9 +756,7 @@ body { justify-self: end; font: 400 var(--ran-text-copy-2)/1 var(--ran-font-mono); color: var(--ran-color-text-secondary); - transition: - transform 0.15s ease, - color 0.15s ease; + transition: transform 0.15s ease; } #landing-hero .index .row:hover .go { transform: translateX(3px); diff --git a/public/landing.css b/public/landing.css index 674cd182a..e2e5b4280 100644 --- a/public/landing.css +++ b/public/landing.css @@ -71,7 +71,6 @@ header.bar nav a { line-height: 22px; color: var(--ran-color-text-secondary); text-decoration: none; - transition: 0.15s ease; } header.bar nav a:hover { color: var(--ran-color-text); @@ -107,7 +106,6 @@ header.bar nav a:hover { min-height: calc(22px + var(--ran-space-2) * 2); color: var(--ran-color-text-secondary); cursor: pointer; - transition: 0.15s ease; } .lang-menu:hover .lang-trigger, .lang-menu[open] .lang-trigger { @@ -363,7 +361,6 @@ header.bar nav a:hover { line-height: 1.45; color: var(--ran-color-text-secondary); text-decoration: none; - transition: color 0.15s ease; } .side-block a:hover { color: var(--ran-color-text); diff --git a/styles/assistant.css b/styles/assistant.css index 491021a80..75549233e 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -1864,9 +1864,12 @@ r-dropdown[role='listbox'] { color: var(--ran-color-text); font: inherit; cursor: pointer; - transition: - background-color 120ms, - border-color 120ms; + /* Palette properties must not transition: they also fade when the theme + flips, each surface at its own duration while the page snaps (DESIGN.md §5). + A repeated hover does not need motion, so the default is none; the hook + stays for a host that wants one. */ + --agent-task-action-transition: none; + transition: var(--agent-task-action-transition); } .agent-panel .agent-task-action::after { content: '↗'; @@ -1892,11 +1895,6 @@ r-dropdown[role='listbox'] { .agent-staged-guidance { margin: 0; } -@media (prefers-reduced-motion: reduce) { - .agent-panel .agent-task-action { - transition: none; - } -} .agent-conversation-snippet { display: -webkit-box; diff --git a/styles/history.css b/styles/history.css index b5b28a4fc..3d51bd317 100644 --- a/styles/history.css +++ b/styles/history.css @@ -145,9 +145,10 @@ border: 1px solid var(--ran-color-border); position: relative; cursor: pointer; - transition: - background var(--ran-motion-duration-fast, 120ms) ease, - border-color var(--ran-motion-duration-fast, 120ms) ease; + /* The track's fill is a palette property, so it snaps on a theme flip; the + knob's transform still animates (DESIGN.md §5). */ + --history-switch-transition: none; + transition: var(--history-switch-transition); } .history-switch::after { content: ''; @@ -234,7 +235,6 @@ margin: 0 calc(-1 * var(--ran-space-3)); border-bottom: 1px solid var(--ran-color-border); border-radius: var(--ran-radius-sm); - transition: background var(--ran-motion-duration-fast, 120ms) ease; } .history-row:hover { background: var(--ran-color-bg-hover); @@ -380,10 +380,6 @@ comes out 31px and the two sit half a pixel apart in the same row. */ min-height: var(--app-control-height); cursor: pointer; - transition: - background var(--ran-motion-duration-fast, 120ms) ease, - color var(--ran-motion-duration-fast, 120ms) ease, - border-color var(--ran-motion-duration-fast, 120ms) ease; } .history-filter:hover { color: var(--ran-color-text); diff --git a/test/unit/site-token-consistency.test.ts b/test/unit/site-token-consistency.test.ts index c1b4fbc53..a29f6ddc4 100644 --- a/test/unit/site-token-consistency.test.ts +++ b/test/unit/site-token-consistency.test.ts @@ -78,6 +78,27 @@ it('rejects raw semantic metrics while allowing fluid geometry and real standalo expect(semanticLiterals(authoredStyles(rel, html)), rel).toEqual([]); } }); +it('never transitions a palette property, so a theme flip is instant', async () => { + // CSS cannot tell why a colour changed, so a palette property in a transition + // also fades when the theme flips its token — each surface at its own + // duration while the page around it snaps. DESIGN.md §5 bans the default. + const { paletteTransitions, designTokenSources, authoredStyles } = await import('../../bin/design-tokens.mjs'); + expect(paletteTransitions('a{transition:0.15s ease}')).toHaveLength(1); + expect(paletteTransitions('a{transition:all .2s}')).toHaveLength(1); + expect(paletteTransitions('a{transition:background .15s, color .15s}')).toHaveLength(1); + expect(paletteTransitions('a{transition:transform .15s, color .15s}')).toHaveLength(1); + expect(paletteTransitions('a{transition:transform .15s, opacity .2s}')).toEqual([]); + expect(paletteTransitions('a{transition:none}')).toEqual([]); + // The documented opt-in hook is the consumer's value, not ours to judge. + expect(paletteTransitions('a{transition:var(--ran-input-transition, none)}')).toEqual([]); + const { generate } = await import('../../bin/build-pages.mjs'); + for (const file of designTokenSources(process.cwd())) { + expect(paletteTransitions(authoredStyles(file, read(file))), file).toEqual([]); + } + for (const { rel, html } of generate({ outDir: null })) { + expect(paletteTransitions(authoredStyles(rel, html)), rel).toEqual([]); + } +}); it('does not borrow application roles when an HTML consumer omits the role stylesheet', async () => { const { auditDesignTokens } = await import('../../bin/design-tokens.mjs'); expect( From c5b2ab3b1b94c929add4a5eb01d443a64a5187e1 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 18 Oct 2026 20:22:37 +0800 Subject: [PATCH 3/9] fix(assistant): give the current conversation its own fill The row marked `aria-current` was painted with the hover fill, and `:hover` painted the deeper active fill on top of it. The persistent state therefore shared the transient one's colour and lost to it on hover: "you are here" read as "the pointer is here". Hover now takes `--ran-color-bg-hover` and the current row takes `--ran-color-bg-active`, declared after the hover rule at equal specificity so hovering the current conversation keeps the stronger fill. --- styles/assistant.css | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/styles/assistant.css b/styles/assistant.css index 75549233e..b2e1192fa 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -1729,10 +1729,13 @@ r-dropdown[role='listbox'] { margin-inline: calc(-1 * var(--ran-space-3)); border-radius: var(--ran-radius-sm); } -.agent-conversation-row:has([aria-current='true']) { +/* Hover is transient, the current conversation is persistent state. They must + not share a fill, and the persistent one has to be the stronger of the two -- + otherwise "you are here" reads as "the pointer is here" (DESIGN.md §11). */ +.agent-conversation-row:hover { background: var(--ran-color-bg-hover); } -.agent-conversation-row:hover { +.agent-conversation-row:has([aria-current='true']) { background: var(--ran-color-bg-active); } .agent-conversation-open { From f0bc7d20fc017f570a195fb22635fc2d58e21f4d Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 18 Oct 2026 20:22:48 +0800 Subject: [PATCH 4/9] fix(assistant): one target box for every header control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header is one row, so unequal boxes read as misalignment rather than as hierarchy. The gear, quote and close controls were 30px, the quote 28px and the clear button 32px; below 480px the buttons became 34x40 while clear and quote stayed 44px. All icon-only header controls now take `--ran-size-7` and grow together to `--app-control-touch` on coarse pointers; the per-width geometry is gone. Two declarations that the shared header rule already outranked are removed with it: `.agent-panel-close` claimed `color: text-disabled` — a disabled-looking close button — and the gear declared `margin-left/right` and then `margin: 0`. --- styles/assistant.css | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/styles/assistant.css b/styles/assistant.css index b2e1192fa..fc808e366 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -86,13 +86,12 @@ body.agent-panel-resizing #app iframe { background: var(--ran-color-bg-hover); color: var(--ran-color-text); } +/* Box, border, background, colour and cursor come from the shared + `.agent-panel-header button` rule, which outranks this class; only the glyph + sizing belongs here. */ .agent-panel-close { - border: none; - background: none; font-size: var(--ran-text-heading-3); line-height: 1; - cursor: pointer; - color: var(--ran-color-text-disabled); } .agent-panel-close:hover { color: var(--ran-color-text); @@ -417,13 +416,17 @@ r-dropdown[role='listbox'] { } /* Document assistant: conversation, configuration and review are distinct surfaces. */ +/* Every icon-only control in the header shares one target box. They sit in a + single row, so unequal boxes read as misalignment rather than as hierarchy + (DESIGN.md §11, "Alignment is structure"). Coarse pointers grow them + together via --app-control-touch. */ .agent-panel-header button, .agent-panel-quote { display: inline-flex; align-items: center; justify-content: center; - width: 30px; - height: 30px; + width: var(--ran-size-7); + height: var(--ran-size-7); padding: 0; border: 0; border-radius: var(--ran-radius-sm); @@ -437,14 +440,13 @@ r-dropdown[role='listbox'] { color: var(--ran-color-text); } .agent-panel-settings-toggle { - margin-left: auto; - margin-right: var(--ran-space-2); + /* The title owns flex:1, so the gear already sits against the trailing + controls; a margin here would fight the close button beside it. */ border: none; background: none; font-size: var(--ran-text-copy-1); line-height: 1; cursor: pointer; - color: var(--ran-color-text-disabled); margin: 0; } .agent-panel-header button:focus-visible, @@ -485,8 +487,6 @@ r-dropdown[role='listbox'] { gap: var(--ran-space-2); } .agent-panel-quote { - width: 28px; - height: 28px; margin-left: auto; } .agent-plan-heading { @@ -1012,11 +1012,10 @@ r-dropdown[role='listbox'] { .agent-panel-header { padding: var(--ran-space-2) var(--ran-space-4); } - .agent-panel-header button { - width: 34px; - height: 40px; - flex: 0 0 34px; - } + /* No per-width button geometry: the shared 32px target stays, and the + coarse-pointer block grows every header control to --app-control-touch + together. A 34x40 box here made the row ragged against the 44px clear + button beside it. */ .agent-panel-settings, .agent-history-view { padding: var(--ran-space-4); From 7423232a7a20771a5e6c2d028d1eb37ee6b44d7e Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 18 Oct 2026 20:22:58 +0800 Subject: [PATCH 5/9] fix(assistant): one focus ring across the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nineteen `:focus-visible` rules used three different colours: primary twelve times, `--ran-color-link` five, `--ran-blue-700` twice. Two of the link rules also carried a light-only `#0070f3` fallback. DESIGN §7 allows either the `--ran-focus-ring` shadow or an outline in the primary colour. The outline form was already the majority, so every ring is now `2px solid var(--ran-color-primary)` with the same 2px offset. --- styles/assistant.css | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/styles/assistant.css b/styles/assistant.css index fc808e366..4e4824bba 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -66,7 +66,7 @@ body.agent-panel-resizing #app iframe { display: none; } .agent-panel-clear:focus-visible { - outline: 2px solid var(--ran-blue-700); + outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } .agent-panel-clear { @@ -227,7 +227,7 @@ body.agent-panel-resizing #app iframe { color: var(--ran-color-success); } .agent-panel .cui-message-actions .cui-apply:focus-visible { - outline: 2px solid var(--ran-blue-700); + outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } .agent-panel .cui-msg-tool .cui-bubble, @@ -1249,7 +1249,7 @@ r-dropdown[role='listbox'] { margin-bottom: var(--ran-space-4); } .agent-panel :is(button, summary, r-select, r-input, r-button):focus-visible { - outline: 2px solid var(--ran-color-link, #0070f3); + outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } .agent-quote-context { @@ -1470,7 +1470,7 @@ r-dropdown[role='listbox'] { opacity: 0.6; } .agent-speech :is(button, r-select):focus-visible { - outline: 2px solid var(--ran-color-link); + outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } @@ -1597,7 +1597,7 @@ r-dropdown[role='listbox'] { } .agent-plan-preview:focus-visible, .agent-plan-preview :is(summary, textarea, r-button):focus-visible { - outline: 2px solid var(--ran-color-link); + outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } @@ -1792,7 +1792,7 @@ r-dropdown[role='listbox'] { .agent-conversation-open:focus-visible, .agent-conversation-delete:focus-visible, .agent-history-management > summary:focus-visible { - outline: 2px solid var(--ran-color-link); + outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } .agent-history-empty { @@ -1932,7 +1932,7 @@ r-dropdown[role='listbox'] { background: var(--ran-color-bg-hover); } .agent-history-more:focus-visible { - outline: 2px solid var(--ran-color-link); + outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } @@ -2441,7 +2441,7 @@ r-dropdown[role='listbox'] { font-weight: 500; } .agent-result-choice button:focus-visible { - outline: 2px solid var(--ran-color-link, #0070f3); + outline: 2px solid var(--ran-color-primary); outline-offset: 2px; } From 07fcf1f6f34d3631efec4f5246d33fdea34bd0e4 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 18 Oct 2026 20:23:03 +0800 Subject: [PATCH 6/9] fix(assistant): make the last two colour fallbacks theme-safe `var(--ran-color-border, #ddd)` and `var(--ran-color-primary, #2563eb)` keep their light value when the token is missing, so a missing token would paint a light border and a blue ring in dark mode. Both now fall back to a foundation role that flips with the theme. The `#2563eb` case was worse than a light-only literal: it also named a blue that the monochrome primary never takes, so the fallback misrepresented the token it stood in for. --- styles/assistant.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/styles/assistant.css b/styles/assistant.css index 4e4824bba..c446a8e42 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -389,13 +389,13 @@ r-dropdown[role='listbox'] { max-width: 100%; box-sizing: border-box; padding: var(--ran-space-2) var(--ran-space-3); - border: 1px solid var(--ran-color-border, #ddd); + border: 1px solid var(--ran-color-border, var(--ran-gray-400)); border-radius: var(--ran-radius-sm); color: var(--ran-color-text); cursor: pointer; } .agent-panel-gguf-choose:focus-within { - outline: 2px solid var(--ran-color-primary, #2563eb); + outline: 2px solid var(--ran-color-primary, var(--ran-gray-1000)); outline-offset: 2px; } .agent-panel-gguf-files { From a46648552939ff8bdfa01375f57ca9831d6e61a0 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 18 Oct 2026 20:23:28 +0800 Subject: [PATCH 7/9] refactor(assistant): one declaration per element, one name per shared inset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight selectors were declared twice in this file. The later block silently won, so the earlier `font-size: 20px` on the empty state, the flex row on its actions, the `flex-basis` on the welcome hint, the 96px fixed language lane and two scroll caps were dead code that still read as intent — the next person to change one of them would have edited the copy that does nothing. Each is now a single block, verified by comparing the effective declaration map per selector before and after: only the three intended value changes moved. The 20px inset shared by the model status, readiness and write-destination lines was `calc(space-4 + space-1)` in three places that must agree. It is named once on `.agent-panel` as `--agent-composer-inset`, because it is the footer padding plus the composer's own padding — the status text aligns to the composer's text, not its edge. --- styles/assistant.css | 91 +++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 56 deletions(-) diff --git a/styles/assistant.css b/styles/assistant.css index c446a8e42..cc31880d7 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -509,6 +509,8 @@ r-dropdown[role='listbox'] { padding: var(--ran-space-1) 0; background: transparent; border-radius: 0; + max-height: 240px; + overflow: auto; } .agent-plan-original { font-size: var(--ran-text-label-3); @@ -553,8 +555,8 @@ r-dropdown[role='listbox'] { overflow-wrap: anywhere; color: var(--ran-color-text-secondary); font-size: var(--ran-text-label-3); - margin: 0 calc(var(--ran-space-4) + var(--ran-space-1)) var(--ran-space-3); - padding: 0 0 0 calc(var(--ran-space-4) / 2 + var(--ran-space-1) / 2); + margin: 0 var(--agent-composer-inset) var(--ran-space-3); + padding: 0 0 0 calc(var(--agent-composer-inset) / 2); border-left: 2px solid var(--ran-color-primary); line-height: 1.55; } @@ -563,28 +565,28 @@ r-dropdown[role='listbox'] { gap: var(--ran-space-4); line-height: 1.75; } +/* One block per element: the welcome surface used to declare these twice, and + the later copy silently won -- the earlier font-size and the flex row were + dead code that still read as intent. */ .agent-panel .cui-empty { width: 100%; max-width: none; margin: auto 0; box-sizing: border-box; - padding: calc(var(--ran-space-4) + var(--ran-space-1)) 0; - font-size: var(--ran-text-heading-3); + padding: var(--ran-space-6) 0; + font-size: var(--ran-text-heading-2); font-weight: 500; - letter-spacing: -0.035em; - line-height: 1.4; + letter-spacing: -0.04em; + line-height: 1.25; } .agent-panel .cui-empty-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0 var(--ran-space-3); - margin-top: calc(var(--ran-space-4) / 2 + var(--ran-space-1) / 2); + display: block; + margin-top: var(--ran-space-3); } .agent-welcome-hint { - flex: 0 0 100%; - margin: 0 0 calc(var(--ran-space-4) + var(--ran-space-1)); - font-size: var(--ran-text-label-3); + /* The actions row is a block, so a flex-basis here would be inert. */ + margin: 0 0 var(--ran-space-6); + font-size: var(--ran-text-label-2); font-weight: 400; line-height: 1.65; letter-spacing: 0; @@ -752,6 +754,12 @@ r-dropdown[role='listbox'] { /* Product surfaces: each view owns the available space. */ .agent-panel { + /* Status lines beside the composer (model state, readiness, write + destination) line up with the composer's *text*, not its edge: footer + padding plus the composer's own padding. Three rules must agree, so the + sum is named once here instead of repeated as arithmetic (DESIGN.md §11, + "When a value must equal another value, name it"). */ + --agent-composer-inset: calc(var(--ran-space-4) + var(--ran-space-1)); position: fixed; top: 0; right: 0; @@ -955,11 +963,16 @@ r-dropdown[role='listbox'] { margin: 0; } .agent-readiness { + display: flex; + align-items: baseline; + flex-wrap: wrap; flex: 0 0 auto; order: 3; - padding: 0 calc(var(--ran-space-4) + var(--ran-space-1)) var(--ran-space-3); + gap: var(--ran-space-2); + padding: 0 var(--agent-composer-inset) var(--ran-space-3); margin: 0; font-size: var(--ran-text-label-3); + line-height: 1.6; color: var(--ran-color-text-secondary); } .agent-readiness[hidden] { @@ -1050,7 +1063,7 @@ r-dropdown[role='listbox'] { .agent-panel-write-destination { flex: 0 0 auto; order: 4; - padding: 0 calc(var(--ran-space-4) + var(--ran-space-1)) var(--ran-space-3); + padding: 0 var(--agent-composer-inset) var(--ran-space-3); font-size: var(--ran-text-label-3); line-height: 1.6; color: var(--ran-color-text-secondary); @@ -1397,9 +1410,13 @@ r-dropdown[role='listbox'] { .agent-speech-mic[aria-pressed='true'] svg:last-child { display: block; } +/* One lane for the language label: it yields before it can cover Stop/Send, + so it is declared once, with the select's public hooks. */ .agent-speech-language { - flex: 0 0 96px; - width: 96px; + flex: 1 1 96px; + width: auto; + min-width: 0; + max-width: 96px; --ran-select-background-color: transparent; --ran-select-border: 1px solid transparent; --ran-select-hover-border: 1px solid transparent; @@ -1448,8 +1465,6 @@ r-dropdown[role='listbox'] { } .agent-speech-privacy { flex: 0 0 auto; -} -.agent-speech-privacy { --ran-popover-host-display: block; --ran-popover-content-padding: var(--ran-space-3); } @@ -1497,10 +1512,6 @@ r-dropdown[role='listbox'] { font-size: var(--ran-text-label-3); line-height: 1.6; } -.agent-plan-content { - max-height: 240px; - overflow: auto; -} .agent-plan-comparison { max-height: 280px; overflow: auto; @@ -1601,14 +1612,6 @@ r-dropdown[role='listbox'] { outline-offset: 2px; } -.agent-readiness { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: var(--ran-space-2); - font-size: var(--ran-text-label-3); - line-height: 1.6; -} .agent-prepare-action { border: 0; background: transparent; @@ -1813,21 +1816,6 @@ r-dropdown[role='listbox'] { } /* Contextual document tasks share the transcript and composer's leading edge. */ -.agent-panel .cui-empty { - margin: auto 0; - font-size: var(--ran-text-heading-2); - line-height: 1.25; - letter-spacing: -0.04em; -} -.agent-panel .cui-empty-actions { - display: block; - margin-top: var(--ran-space-3); -} -.agent-welcome-hint { - margin: 0 0 var(--ran-space-6); - font-size: var(--ran-text-label-2); - line-height: 1.65; -} .agent-task-entry { font-weight: 400; letter-spacing: 0; @@ -2328,8 +2316,6 @@ r-dropdown[role='listbox'] { .agent-artifacts-list { display: flex; flex-direction: column; -} -.agent-artifacts-list { list-style: none; padding: 0; margin: 0; @@ -2368,13 +2354,6 @@ r-dropdown[role='listbox'] { font-weight: 600; } -/* The language picker yields space before it can cover Stop/Send. */ -.agent-speech-language { - flex: 1 1 96px; - width: auto; - min-width: 0; - max-width: 96px; -} /* A narrow toolbar keeps a real language label when Stop joins Send. Use the select's public spacing hooks rather than losing its text lane. */ @container agent-panel (max-width: 360px) { From 529d2629e3d13e916c20c68ceb133a3763ae5852 Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 18 Oct 2026 20:23:39 +0800 Subject: [PATCH 8/9] fix(assistant): put the remaining values back on the ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `calc(var(--ran-radius-sm) / 2)` produced a 3px corner that exists nowhere else in the product, and `min-height: 40px` sat between the compact and page action sizes without belonging to either. Both now come from the existing scales. `margin: 0.5rem 0` was a spacing decision hiding as a rem; the citation marker's `0.15em` is genuinely optical — it has to scale with the glyph — so it became one named local token instead of an anonymous inset. --- styles/assistant.css | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/styles/assistant.css b/styles/assistant.css index cc31880d7..a9c6fd3ea 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -990,7 +990,9 @@ r-dropdown[role='listbox'] { } .agent-panel .cui-msg-user .cui-bubble { padding: var(--ran-space-3) var(--ran-space-4); - border-radius: var(--ran-radius-md) var(--ran-radius-md) calc(var(--ran-radius-sm) / 2) var(--ran-radius-md); + /* Radius comes from the ladder; 3px (radius-sm/2) was a fifth step that + existed nowhere else in the product (DESIGN.md §4). */ + border-radius: var(--ran-radius-md) var(--ran-radius-md) var(--ran-radius-sm) var(--ran-radius-md); background: var(--ran-color-bg-muted); } .agent-panel .cui-send, @@ -1846,7 +1848,7 @@ r-dropdown[role='listbox'] { display: flex; align-items: center; text-align: start; - min-height: 40px; + min-height: var(--ran-size-7); padding: var(--ran-space-2) 0; border: 0; border-radius: var(--ran-radius-sm); @@ -2440,15 +2442,19 @@ r-dropdown[role='listbox'] { color: var(--ran-color-text-secondary); max-height: 16rem; overflow: auto; - margin: 0.5rem 0; + margin: var(--ran-space-2) 0; } .agent-source-preview small { color: var(--ran-color-text-secondary); } +/* An inline citation marker is superscript-scale, not a button inset: its + optical padding has to scale with the glyph, so it is one named local token + rather than a spacing step (DESIGN.md §11, optical corrections). */ .agent-source-citation { + --agent-citation-inset: 0.15em; display: inline; border: 0; - padding: 0 0.15em; + padding: 0 var(--agent-citation-inset); background: none; color: var(--ran-color-primary); font: inherit; From 3cc0460717c6173575ce5fb9941b2be59e45eabe Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 18 Oct 2026 20:23:45 +0800 Subject: [PATCH 9/9] docs: record the assistant design audit and the two new contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit log states what was found, what was fixed and what was deliberately left alone, with the rendered measurements rather than a claim that it looks right. `docs/design-system.md` gains the two rules that are now mechanical — logical properties count as spacing, and a palette property never appears in a transition — because both fail silently in review. --- docs/design-system.md | 11 ++ .../2026-10-18-assistant-design-audit.md | 104 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 docs/explorations/2026-10-18-assistant-design-audit.md diff --git a/docs/design-system.md b/docs/design-system.md index 67581b234..76b358b68 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -198,3 +198,14 @@ validated token references. Static icon artwork and vendor assets are separate o The browser suite verifies real component parts, page families, seven locales, both themes, narrow widths and coarse-pointer targets. Token audit success alone does not prove component API or interaction correctness. + +Two rules are mechanical because both fail silently in review: + +- **Logical properties count as spacing.** The property pattern has to read through + every hyphen (`padding-inline-start`, not just `padding-inline`); when it stopped at + the first one, three literal insets were invisible to the contract. +- **A palette property never appears in a `transition`.** CSS cannot tell why a colour + changed, so `background`, `color`, `border-*-color`, `box-shadow`, `fill` and `stroke` + in a transition also fade on a theme flip — each surface at its own duration while the + page snaps. Bare durations (`transition: 0.15s`) mean `all` and fail the same way. The + documented `var(--…-transition)` hook is the opt-in escape hatch and is not judged. diff --git a/docs/explorations/2026-10-18-assistant-design-audit.md b/docs/explorations/2026-10-18-assistant-design-audit.md new file mode 100644 index 000000000..7eceb7755 --- /dev/null +++ b/docs/explorations/2026-10-18-assistant-design-audit.md @@ -0,0 +1,104 @@ +# Assistant surface audit against the ranui design standard + +Date: 2026-10-18 +Scope: the whole first-party surface, weighted to the AI assistant (`styles/assistant.css`, +`packages/chat-ui`, `lib/agent-plugin/ui`). +Standard: `ran`'s `packages/ranui/docs/DESIGN.md` (§1–§12), `INTERACTION.md`, and this +repository's own `docs/design-system.md`. + +The assistant's behaviour layer already holds up: `ChatView` follows the transcript only +while the reader is at the bottom, suspends on real reader intent, restores position after +a re-render, and keeps a jump-to-latest control that gives focus back. Every icon-only +control carries an accessible name, the enable switch is a real `role="switch"` with a +synced `aria-checked`, and failure copy answers what happened, whether anything changed +and what to do next. The defects were in the layer that is hardest to review by reading — +the stylesheet — and in the checker that was supposed to be watching it. + +## The checker was not watching + +`semanticLiterals()` in `bin/design-tokens.mjs` is what makes "no raw semantic metrics" +true rather than aspirational. Its property pattern was `padding(?:-[\w]+)?`: the class +accepts letters, digits and `_`, but **not a hyphen**. `padding-inline-start` therefore +matched as `padding-inline`, the `:` that the pattern required next never arrived, and the +declaration was skipped entirely. Logical properties are the form this codebase actually +writes, so the rule was blind wherever it mattered: + +| File | Declaration | +| ---------------------- | ---------------------------- | +| `styles/assistant.css` | `padding-inline-start: 12px` | +| `styles/assistant.css` | `margin-inline-end: 8px` | +| `public/landing.css` | `padding-inline-start: 22px` | + +The character class now accepts hyphens (`[\w-]`), and the unit contract asserts a logical +property directly so the gap cannot reopen. Three values were corrected: `12px` → `--ran-space-3`, +`8px` → `--ran-space-2`, `22px` → `--ran-space-6`. + +DESIGN §5 ("Never animate a theme switch") had no mechanical owner at all. `paletteTransitions()` +now reads every `transition` declaration and judges each comma-separated part by its first +token: a timing value in first position means the property was omitted, i.e. `all`; a named +palette property (`background`, `color`, `border-*-color`, `box-shadow`, `fill`, `stroke`) +is banned. A lone `var(--…-transition)` is the documented opt-in hook and is skipped. + +It found twelve declarations in five files. Four were bare durations — `transition: 0.15s ease`, +which is `all` — on the home and landing navigation links. Eight named palette properties only. +All twelve are gone; every hover and focus now snaps, which is also what §5 asks for on +frequent feedback. + +## The assistant panel + +| # | Finding | Owner | Standard | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -------- | +| 1 | The current conversation was filled with `--ran-color-bg-hover` while `:hover` used `--ran-color-bg-active` — the persistent state shared the transient one's fill, and hover was the _stronger_ of the two. | conversation list | §11 | +| 2 | `.agent-task-action` transitioned `background-color` and `border-color` at a literal `120ms`, so the welcome task rows faded on a theme flip while the page snapped. | welcome empty state | §5 | +| 3 | Header icon targets were 30px, 28px (quote) and 32px (clear) in one row; at ≤480px the buttons became `34×40` while clear and quote stayed 44. | panel header | §11 | +| 4 | `calc(var(--ran-radius-sm) / 2)` — a 3px corner that exists nowhere else in the product. | user message bubble | §4 | +| 5 | Four colour fallbacks were light-only literals (`#ddd`, `#2563eb`, `#0070f3` ×2), so the declaration would keep a light value in dark mode. | GGUF picker, focus rings | §1 | +| 6 | `margin: 0.5rem 0` and `padding: 0 0.15em` bypassed the scale; the citation inset is genuinely optical, so it is now a named local token rather than an anonymous em. | source preview, citations | §2 | +| 7 | Eight selectors were declared twice. The later block silently won, so the earlier `font-size: 20px`, the flex row on the empty-state actions and the `flex-basis` on the welcome hint were dead code that still read as intent. | throughout | §11 | +| 8 | `.agent-panel-settings-toggle` set `margin-left/right` and then `margin: 0` in the same block. | panel header | §11 | +| 9 | `.agent-panel-close` declared `border`, `background`, `cursor` and `color: text-disabled` — all outranked by the shared header rule. The colour was the dangerous one: it claimed a disabled close button. | panel header | §1 | +| 10 | Nineteen focus rings used three different colours: `--ran-color-primary` ×12, `--ran-color-link` ×5, `--ran-blue-700` ×2. | throughout | §7 | +| 11 | A 20px inset was fabricated as `calc(space-4 + space-1)` in three rules that must agree — the status lines align to the composer's text, not its edge. | composer status lanes | §11 | + +All eleven are fixed. Where a fix changed a value rather than a name, it moved onto the +scale: 20px → `--ran-space-6`, 3px → `--ran-radius-sm`, 40px → `--ran-size-7`. + +## Evidence + +Static, in CI: + +- `test/unit/site-token-consistency.test.ts` — 12 passing. Two new assertions cover the + logical-property form and the palette-transition ban; both are also run over every + authored stylesheet and every freshly generated page. +- `verify:design` (ranui's nine mechanical rules) against `styles/` and `public/`: + `dark-unsafe-fallback` and `bare-colour` are now clean. `spacing-scale` and `sizing-scale` + still report the `clamp()` gaps in `home.css`/`landing.css`/`history.css` and the pill + height `calc(22px + space-2 * 2)` — all explicitly allowed by this repository's own + contract (fluid composition, hairline/optical geometry) and unchanged. + +Rendered, in Chromium at 1280×900 against a production build: + +| Measured | Before | After | +| ---------------------------------- | ---------------------------------------- | -------------------------------------- | +| Header icon boxes | 30, 28, 32, 32, 32 (and 34×40 at ≤480px) | 32×32 ×5, radius 6px | +| Current conversation fill | `--ran-color-bg-hover` `#ebebeb` | `--ran-color-bg-active` `#e6e6e6` | +| Hover fill | `--ran-color-bg-active` `#e6e6e6` | `--ran-color-bg-hover` `#ebebeb` | +| `.cui-send` / `.cui-stop` | `background-color, color` 0.15s | `all` 0s (no transition) | +| `.agent-task-action` | `background-color, border-color` 120ms | `none` | +| Focus ring on a header button | `rgb(0,110,254)` (blue-700) for some | `rgb(23,23,23)` 2px solid, all | +| Theme flip (`data-ran-theme=dark`) | — | `#171717` → `#ededed`, panel `#1a1a1a` | + +The selection fix is verified in the built cascade, not only in source: the hovered rule +declares `--ran-color-bg-hover` and the `[aria-current]` rule declares `--ran-color-bg-active`, +with the persistent rule second at equal specificity, so hovering the current row keeps the +stronger fill. + +## Reviewed and deliberately unchanged + +- `clamp()` gaps in the page families, and the `min-height: calc(22px + …)` nav pill: both + are documented exceptions in `docs/design-system.md`, not spacing decisions. +- 96px language lane, 160/240/280px scroll caps: intrinsic dimensions, not the spacing scale. +- No `aria-live` region around the transcript. `aria-busy` already marks the phase, and + announcing streamed tokens would break INTERACTION §3's "do not announce every streamed + character". Adding one needs a design decision about what a polite announcement says when + the answer is still being written.