Tokens as data, components with no surprises

The run explorer and the assistant are built on a small system: semantic colour tokens, a theme that never flashes, and a handful of components. It is small on purpose; this is about the details that decide whether a system can be trusted.

15
colour tokens, checked in every theme by a test
3
themes: light, dark, and follow the system
0
frames of the wrong theme on reload
1
Tab stop per control group

01Decisions

What I chose, and what it cost

Name colours by job, and let a test enforce it

Chose
Tokens named by role, such as surface, muted and accent, with their values in CSS variables. A test reads the stylesheet and fails if any token is missing from any theme or from Tailwind.
Instead of
Raw palette classes (gray-100, indigo-600) in components.
Because
With roles, a theme is just data, and components never need dark-mode variants. The test means half a theme can never ship: when I added four tokens for the run explorer, it checked all of them in every theme automatically.
Trade-off
Dark values are written twice, once for “follow the system” and once for an explicit Dark choice. The test also checks that the two copies match.

See every token, in the current theme, on the Tokens page.

No flash of the wrong theme

Chose
A tiny script in the page’s head applies the saved theme before the first paint; React reads the preference through useSyncExternalStore.
Instead of
Reading the saved theme after the page loads, or storing it in a cookie for the server.
Because
Reading it after load paints the default theme first and then flips: a white flash in a dark room. Cookies would make every page dynamic, and these pages are static.
Trade-off
The server doesn’t know your choice, so the theme switcher’s indicator starts on Auto and glides to Light or Dark after the page loads. The page itself is already in the right colours.

Loading keeps the button’s size and its name

Chose
While loading, the label fades to opacity: 0 and a spinner sits on top; the button keeps its width and sets aria-busy.
Instead of
Swapping the label for a spinner, or hiding the label with visibility: hidden.
Because
Swapping changes the width, so everything next to the button jumps. Hiding with visibility removes the label from the accessibility tree, so a screen reader hears a button with no name.
Trade-off
Every button carries two layers, the label and the spinner, even when it never loads.
Both buttons, same label

Label hidden with opacity (shipped)

Text left for a screen reader: Save changes

Label hidden with visibility

Text left for a screen reader: Save changes

Start loading. The left button keeps its name; the right one is left with nothing. The text is read from the page itself (innerText skips anything hidden with visibility, as the accessibility tree does).

Controlled or uncontrolled, like native inputs

Chose
Each component works either way through one small hook, useControllableState: pass a value and an onChange, or just a default.
Instead of
Components that only work one way.
Because
Native inputs work both ways, and people expect components to behave like them. Simple forms don’t need state plumbing; complex screens need full control.
Trade-off
Two modes to test. One test checks the tricky case: a controlled switch stays put when the parent doesn’t update it.

One Tab stop per group

Chose
The segmented control is a radio group with a roving tab index: Tab enters it once, and the arrow keys move between options.
Instead of
A row of separate buttons, one Tab stop each.
Because
It is one choice, so it should be one stop, as native radio buttons are. The run explorer’s list and step tree follow the same rule.
Trade-off
The arrow keys select immediately, as native radios do. That suits switching views, but it would be wrong for options that start something expensive.

02What review caught

Four things I got wrong first

A test caught the first, I noticed the second while building, and a script that measures every page at phone width caught the last two.

  1. 01 The switch announced its label and description as one name

    I wrapped the description inside the switch’s label, so a screen reader would hear “Email Weekly summary” as the control’s name. A test that checks the accessible description failed on its first run, before I saw anything on screen.

    Fix: Moved the description out of the label and wired it with aria-describedby: the name is “Email”, the description “Weekly summary”.

  2. 02 Tailwind’s dark: variant ignored the theme switcher

    The dark: variant follows the operating system’s setting, not the site’s Light / Dark / Auto choice, so a dark-only style would have shown up in the wrong theme.

    Fix: Used a semantic token (ring-border) instead. Tokens already switch with the theme, so no component needs its own dark styles.

  3. 03 The header kept outgrowing phones

    Every new page link pushed the header past 390 px: first by 10 px, later by 3 px. A script that measures scroll width on every page at phone size caught both.

    Fix: A compact mark replaces the wordmark on phones, and the link spacing tightens; the Tokens link moved off the header.

  4. 04 Token cards overflowed at 390 px

    Longer role descriptions added for the run explorer stopped the token cards from shrinking, so the Tokens page scrolled sideways on phones.

    Fix: Let the cards shrink (min-width: 0) so the descriptions truncate as designed.

03What’s next

What the system needs next

  • Harder components: a combobox, a ⌘K command menu and popovers that stay on screen. They are where accessibility usually breaks.
  • Visual regression tests, so a token change that breaks a screen fails the build, not just a missing token.