Skip to content

Define colours in terms of role-based tokens - #99

Open
microbit-matt-hillsdon wants to merge 8 commits into
mainfrom
role-tokens
Open

Define colours in terms of role-based tokens#99
microbit-matt-hillsdon wants to merge 8 commits into
mainfrom
role-tokens

Conversation

@microbit-matt-hillsdon

@microbit-matt-hillsdon microbit-matt-hillsdon commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

I'm sure we'll end up revisiting this but it's a start. There are draft app PRs that can be linked with it to explore it in that context.

Recipes and components consume roles, not ramp stops. Phase 3 of the
focus-and-colour plan; the design note is ui-private's docs/role-tokens.md.

Roles are grouped by the property they belong to — fg, surface, fill,
border — so a foreground role cannot be used as a background. The
surface/fill split (Polaris's, and Carbon and Primer draw the same line
in different words) is what makes the two grey state ladders coherent:
rows on a raised surface hover to gray.50, a grey button fill hovers to
gray.300. They were never inconsistent, just on different axes.

button.* survives as a component group rather than dissolving into the
roles, restructured to button.primary.bg / button.secondary.border and
so on. Primer and Carbon both keep button tokens out of their functional
layer, and the family needs it: half the apps are brand-coloured and half
black-on-white, and the two idioms differ in structure, not just hue —
one darkens the border and keeps the background clear, the other holds
the border and washes the background. No numeric ramp carries both.
languageDialog.* and closeButton.* join it for the same reason.

The ramps stay public and unrenamed, so raw var(--colors-gray-*) in
consumers' plain CSS (python-editor's CodeMirror themes) is unaffected.

Value-preserving, measured: Panda's generated CSS resolved through every
var() chain shows 0 changed declarations across 916 selectors, and every
renamed atomic class resolves to a byte-identical value. Two deliberate
exceptions in LanguageDialog, made rather than adding two roles for one
call site each — the "help translate" link moves brand.500 to fg.link
(brand.600, the link colour everywhere else) and the English-name label
moves gray.700 to fg.muted.

The two tests updated asserted the old token names, not behaviour.
Two live defects, both because a role can carry an _onDark condition and
a raw ramp stop cannot. Phase 1 built the condition for focus; this is
the rest of it.

- `ghost` was black text with a black wash: invisible on dark chrome.
  That is why python-editor carries its own `sidebar` variant. It now
  renders white with a white wash inside a tagged surface. This does not
  retire `sidebar` — its hover is a solid white pill and this is a 0.08
  wash, which is a design change rather than a substitution — but the
  variant is now optional rather than necessary.
- The Toast close button's blackAlpha.100 hover darkened a dark teal
  toast, reading as a hole. The card was already tagged, so the flip
  fixes it with no call-site change.

Alphas are not mirrored. A white wash reads weaker than a black one at
equal alpha, so the on-dark values are two ramp steps up: 0.08/0.16
against 0.04/0.06 for `ghost`, 0.16/0.24 for the close buttons. 0.08 is
Material's hover figure. These want an eyeball in Storybook.

`toolbar` had to be pulled out of the roles to get here. It is a white
pill that lives ON dark chrome, so it sits inside the tag while being
light itself, and a flipping `fg` role rendered its label white on white.
It now reads a static buttonToolbar.* component group, and the rule
generalises: anything painting its own contrasting fill must opt out of
the flips.

The whole mechanism is seven custom-property redefinitions in one
[data-surface="dark"] block. Resolved-CSS diff against main: still 0
changed declarations in the light context; the dark context is Phase 1's
focus flips plus exactly five new ones.
The old comment described how each consumer resolves the token, which was
accurate but left the impression the value was arbitrary. It descends from
a Chakra-era `language` button variant that @microbit/ui-patterns' shared
LanguageDialog replaced, and both overriding presets carried its wording
forward with it (corrected separately in data-microbit-org and
ml-trainer-microbit).

The divergence is real regardless of where it came from, and worth stating
in its own terms: data-microbit-org uses black because black is its
emphasis colour throughout, while its links stay blue — which is exactly
why this must not fold into fg.link.

The exception is fgHover, which has no live justification: it dates from
when this was a text link with no background, and the card now hovers its
own background. Both overriding presets set it equal to fg. Kept because
dropping it is a visible change in the three apps still on the default,
and the decision is to preserve the current look per app.
The contract still described the pre-role-token world. It named
`languageText`, `statusBarBg`, `toast*Bg` and `controlCheckedBg` as the
extension points a brand preset overrides — all four deleted in the role
token work — and gave `focusBorder` a base of brand.700, which is 600.

That is not just stale. A brand preset author following it writes a key
Panda accepts and silently ignores, which is how python-editor-v3-microbit
ended up with four dead toast overrides and its brand build falling back
to the library's teal.

So the section now says what the layers are rather than listing leaves:
ramps versus roles, the four property-namespaced role groups and what the
surface/fill split buys, the four component groups and why an idiom gets
one, and a pointer to base-preset.ts as authoritative for the inventory.
A list here goes stale; that is the failure being fixed, so it should not
be replaced with another list.

Adds the two silent failures explicitly — an unknown `semanticTokens` key
and a missed `colorPalette` stop — with the instruction that a green
typecheck does not verify a preset change, and the cssgen command that
does.

Lists all seven `{ base, _onDark }` tokens rather than the two focus ones,
and records the corollary: a component painting its own contrasting fill
must not consume them, which is what `buttonToolbar.*` is for.

Completes the last Phase 3 exit criterion.
Panda accepts an unknown `semanticTokens` key without complaint: it
typechecks, it generates, and the override never applies — the token keeps
this preset's value with no error at build or runtime. So a rename here
silently un-does every downstream override of the old name.

That has now happened twice. data-microbit-org's `button.*` block was
nearly missed when the flat keys became nested, which would have reverted
its black buttons to brand blue. python-editor-v3-microbit's four
`toast*Bg` overrides actually were missed, and its brand build's toasts
fell back to the library's teal until this week. Neither was visible to a
typecheck; only a resolved-CSS comparison found them.

`unknownSemanticTokens(preset, { introduces })` returns the leaf paths a
preset sets that this library does not define and that the preset has not
declared as its own. It reads `theme.semanticTokens` and
`theme.extend.semanticTokens`, and stops at Panda's `value` key so a
`{ base, _onDark }` condition object counts as one leaf rather than two.

It cannot be a Panda plugin: the check has to run per consumer, because
the library cannot see the presets merged after it. So it ships as an
export and each consumer asserts in a test.

Verified against the real defect — checked out at the commit before the
fix, it names all four dead keys.
# Conflicts:
#	packages/ui/src/Avatar.recipe.ts
The role-token sweep converted every recipe but left globalCss painting
the body with gray.800/white and *::placeholder with gray.500 — the
canonical fg.default, surface.canvas and fg.placeholder call sites, and
the ones a dark mode most needs on roles. The full-size Modal's
safe-area gradient similarly hardcoded white next to
token(colors.surface.statusBar); it now uses surface.raised, the
dialog's own surface, so the gradient can't diverge from the panel it
caps.

Value-identical throughout: every role referenced resolves to the ramp
stop it replaces.
unknownSemanticTokens covers unknown names, but the other silent
preset failure is one door over: overriding a { base, _onDark } token
with a flat value (or a condition object missing a key) merges
wholesale and silently loses the flip. droppedConditionTokens flags
any override of a base-preset condition token that does not keep all
of its condition keys; a deliberate no-flip is stated with both keys
equal. Documented beside the seven-token list in the README, which
this check also keeps from going stale.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant