Skip to content
Merged
1 change: 1 addition & 0 deletions bin/design-tokens.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
39 changes: 38 additions & 1 deletion bin/design-tokens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,50 @@ export function semanticLiterals(source) {
css = removeFunctions(css);
return [
...css.matchAll(
/(?<![\w-])(font(?:-size)?|padding(?:-[\w]+)?|margin(?:-[\w]+)?|gap|border-radius)\s*:\s*([^;{}]+)/g,
// Logical properties are hyphenated more than once
// (`padding-inline-start`, `margin-block-end`). The character class has to
// accept every hyphen, not just the first: with `[\w]+` the name matched
// only `padding-inline`, the following `:` never arrived, and the whole
// declaration went unchecked -- 3 literal insets hid behind that gap.
/(?<![\w-])(font(?:-size)?|padding(?:-[\w-]+)?|margin(?:-[\w-]+)?|gap|border-radius)\s*:\s*([^;{}]+)/g,
),
]
.filter((match) => [...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).
*/
Expand Down
11 changes: 11 additions & 0 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
104 changes: 104 additions & 0 deletions docs/explorations/2026-10-18-assistant-design-audit.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 2 additions & 3 deletions packages/chat-ui/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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; }
Expand All @@ -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; }
}
`;
Expand Down
9 changes: 1 addition & 8 deletions public/home.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
5 changes: 1 addition & 4 deletions public/landing.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading