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 600d9af5e..090a021ff 100644 --- a/bin/design-tokens.mjs +++ b/bin/design-tokens.mjs @@ -95,13 +95,50 @@ 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)) .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/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. 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 9d1b7c670..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); @@ -609,7 +606,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..a9c6fd3ea 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 { @@ -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); @@ -228,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, @@ -390,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 { @@ -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 { @@ -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] { @@ -977,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, @@ -1012,11 +1027,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); @@ -1051,7 +1065,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); @@ -1250,7 +1264,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 { @@ -1398,9 +1412,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; @@ -1449,8 +1467,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); } @@ -1471,7 +1487,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; } @@ -1498,10 +1514,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; @@ -1598,18 +1610,10 @@ 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; } -.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; @@ -1729,10 +1733,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 { @@ -1790,7 +1797,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 { @@ -1811,21 +1818,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; @@ -1856,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); @@ -1864,14 +1856,17 @@ 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: '↗'; 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 { @@ -1892,11 +1887,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; @@ -1932,7 +1922,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; } @@ -2328,8 +2318,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 +2356,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) { @@ -2441,7 +2422,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; } @@ -2461,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; @@ -2564,7 +2549,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/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 90d10e2c8..a29f6ddc4 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([]); @@ -72,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(