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,mutedandaccent, 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.
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
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.
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”.
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.
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.
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.