Custom themes override nine of twenty-one token roles #202

Closed
opened 2026-08-24 09:09:18 +00:00 by nalum · 1 comment
Owner

Follow-on from #170, which took design/tokens.yaml from nine roles per mode
to twenty-one. A household's custom theme still overrides only the original
nine, so the twelve new roles fall back to the family theme's values — which can
be from the wrong end of the light/dark axis entirely.

The defect

A custom theme is a map of overrides stored on the user record
(UserSettings.custom_tokens, map<string, string>) and applied over a base
family variant in three places:

Where What it lists
web/src/theme/theme.tsx:144 — TOKEN_NAMES the nine colours plus --radius, by hand
web/src/pages/Settings.tsx:1361 — WORKSHOP_TOKENS the editor's curated set, with anchor / derive rules
android/.../ui/Theme.kt:100 — applyCustomTokens the same nine, field by field

None of them knows about surface-raised, surface-sunken, ink-muted,
border, border-strong, error, error-surface, destructive,
on-destructive, warning, on-warning or on-celebrate.

So a household that builds a dark custom theme on top of a light family keeps
that family's light surface-raised and error-surface. An in-place edit
box would be a pale panel on a dark card, and the invalid-row ring would sit on
a background it was never checked against. The generator guarantees WCAG AA for
every pair it emits; a custom theme currently escapes that guarantee for twelve
of twenty-one roles, and the failure is silent.

Nothing is broken today only because no component consumes the new roles yet.
The first UI ticket that does — #171's row grammar, which needs
surface-raised, border-strong and the error pair — makes it visible.

What to build

The machinery already exists and is good: WORKSHOP_TOKENS marks a few tokens
as anchor (the person picks them) and derives the rest with small functions —
mixHex for stepping a colour toward another, bestTextOn for picking readable
ink over a fill. Extend that rather than growing the editor.

  1. Derive rules for all twelve, in the workshop's style, from the existing
    anchors. The surfaces step off --surface / --card the way --card
    already steps off --surface; the borders walk --line toward --ink;
    error and destructive come from --danger; the on-fill inks go through
    bestTextOn. --warning is the one with no anchor to derive from — decide
    whether it becomes a fourth anchor the person picks, or is derived from
    --celebrate's hue while staying a separate value (it must never alias
    celebrate; that is the rule #170 was built on).
  2. Keep the editor curated. The twelve should be derived and invisible by
    default. The workshop asks a household for a handful of decisions, not
    twenty-one.
  3. Enforce contrast at runtime. The generator's AA gate runs at build time
    over authored values and cannot see a custom theme. A person can pick any
    two colours, so the derive rules have to clamp: an on-warning derived over
    a chosen warning must clear 4.5:1, and border-strong over card must
    clear the 3:1 non-text floor #170 introduced. Where a clamp cannot reach the
    threshold, say so in the editor rather than shipping an unreadable pair.
  4. All three call sites, plus the Android overlay, so a custom theme looks
    the same on both surfaces.
  5. theme.tsx's comment above TOKEN_NAMES describes it as "the token
    vocabulary defined in design/tokens.yaml" — that is now inaccurate whichever
    way this lands.

Not a data problem

custom_tokens is a string map, so new keys need no proto change and no
migration: an old record simply carries fewer keys and the derive rules fill the
rest. Existing custom themes keep their nine chosen values.

Acceptance

  • A custom dark theme over a light family produces dark values for all twelve.
  • Every derived on-fill pair clears the same thresholds the generator enforces.
  • The workshop still asks for a small number of decisions.
  • Web and Android agree on the result for the same stored map.
Follow-on from #170, which took `design/tokens.yaml` from nine roles per mode to twenty-one. A household's **custom** theme still overrides only the original nine, so the twelve new roles fall back to the family theme's values — which can be from the wrong end of the light/dark axis entirely. ## The defect A custom theme is a map of overrides stored on the user record (`UserSettings.custom_tokens`, `map<string, string>`) and applied over a base family variant in three places: | Where | What it lists | |---|---| | `web/src/theme/theme.tsx:144` — `TOKEN_NAMES` | the nine colours plus `--radius`, by hand | | `web/src/pages/Settings.tsx:1361` — `WORKSHOP_TOKENS` | the editor's curated set, with `anchor` / `derive` rules | | `android/.../ui/Theme.kt:100` — `applyCustomTokens` | the same nine, field by field | None of them knows about `surface-raised`, `surface-sunken`, `ink-muted`, `border`, `border-strong`, `error`, `error-surface`, `destructive`, `on-destructive`, `warning`, `on-warning` or `on-celebrate`. So a household that builds a dark custom theme on top of a light family keeps that family's **light** `surface-raised` and `error-surface`. An in-place edit box would be a pale panel on a dark card, and the invalid-row ring would sit on a background it was never checked against. The generator guarantees WCAG AA for every pair it emits; a custom theme currently escapes that guarantee for twelve of twenty-one roles, and the failure is silent. Nothing is broken today only because no component consumes the new roles yet. The first UI ticket that does — #171's row grammar, which needs `surface-raised`, `border-strong` and the error pair — makes it visible. ## What to build The machinery already exists and is good: `WORKSHOP_TOKENS` marks a few tokens as `anchor` (the person picks them) and derives the rest with small functions — `mixHex` for stepping a colour toward another, `bestTextOn` for picking readable ink over a fill. Extend that rather than growing the editor. 1. **Derive rules for all twelve**, in the workshop's style, from the existing anchors. The surfaces step off `--surface` / `--card` the way `--card` already steps off `--surface`; the borders walk `--line` toward `--ink`; `error` and `destructive` come from `--danger`; the on-fill inks go through `bestTextOn`. `--warning` is the one with no anchor to derive from — decide whether it becomes a fourth anchor the person picks, or is derived from `--celebrate`'s hue while staying a separate value (it must never *alias* `celebrate`; that is the rule #170 was built on). 2. **Keep the editor curated.** The twelve should be derived and invisible by default. The workshop asks a household for a handful of decisions, not twenty-one. 3. **Enforce contrast at runtime.** The generator's AA gate runs at build time over authored values and cannot see a custom theme. A person can pick any two colours, so the derive rules have to clamp: an `on-warning` derived over a chosen `warning` must clear 4.5:1, and `border-strong` over `card` must clear the 3:1 non-text floor #170 introduced. Where a clamp cannot reach the threshold, say so in the editor rather than shipping an unreadable pair. 4. **All three call sites**, plus the Android overlay, so a custom theme looks the same on both surfaces. 5. `theme.tsx`'s comment above `TOKEN_NAMES` describes it as "the token vocabulary defined in design/tokens.yaml" — that is now inaccurate whichever way this lands. ## Not a data problem `custom_tokens` is a string map, so new keys need no proto change and no migration: an old record simply carries fewer keys and the derive rules fill the rest. Existing custom themes keep their nine chosen values. ## Acceptance - A custom dark theme over a light family produces dark values for all twelve. - Every derived on-fill pair clears the same thresholds the generator enforces. - The workshop still asks for a small number of decisions. - Web and Android agree on the result for the same stored map.
Author
Owner

Decided: --warning becomes a fourth anchor.

The workshop asks the household for it alongside background, text and accent,
rather than deriving it. Deriving loses exactly where it matters: a family whose
--celebrate is pink or blue has nothing warm to turn toward, which is why the
built-in themes needed hand-tuning for cvd-red-teal and mono in #170.

A picked colour still needs clamping — on-warning over it must clear 4.5:1,
and the editor says so when a choice cannot get there, rather than shipping an
unreadable pair.

The other eleven roles stay derived and invisible: the workshop asks for four
decisions, not twenty-one.

**Decided: `--warning` becomes a fourth anchor.** The workshop asks the household for it alongside background, text and accent, rather than deriving it. Deriving loses exactly where it matters: a family whose `--celebrate` is pink or blue has nothing warm to turn toward, which is why the built-in themes needed hand-tuning for `cvd-red-teal` and `mono` in #170. A picked colour still needs clamping — `on-warning` over it must clear 4.5:1, and the editor says so when a choice cannot get there, rather than shipping an unreadable pair. The other eleven roles stay derived and invisible: the workshop asks for four decisions, not twenty-one.
nalum closed this issue 2026-08-26 18:33:53 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
eagraiclainne/app#202
No description provided.