ADR 0005: Dark mode via semantic tokens and a per-device cookie
ADR 0005: Dark mode via semantic tokens and a per-device cookie
- Status: Accepted
- Date: 2026-08-17
Context
The app had no dark mode: no dark: utility anywhere, no color-scheme declaration, and roughly a
thousand hard-coded palette utilities (text-gray-500 alone appeared 95 times) spread over some 78
components.
Attendees were reaching for browser extensions instead.
#802 reported that under
Dark Reader a voted-on proposal became indistinguishable from an unvoted
one, because the only cue was a pale blue fill against white and the extension collapsed both to
near-identical darks. That specific bug is fixed separately — the state is now also carried by
aria-pressed and a check mark — but it is the symptom of not owning the question.
Constraints that shape the decision:
- The site has surfaces that anonymous visitors see: the password gate, the login screen, the guest picker, the kiosk display. Anything that lives behind “select your name first” cannot be the only way to change the theme.
- A wrong first paint is worse than no dark mode. Whatever resolves the theme has to run before the page is painted.
- The schedule renders location colours as dynamic Tailwind classes (
bg-${color}-500), kept alive by a@source inline(...)safelist. Doubling that safelist for a second theme is a cost worth avoiding.
Decision
The preference is per device, in a cookie
sb-theme holds system, light or dark, and the root layout turns it into class="light" /
class="dark" on <html> — system sets no class at all.
Per device, rather than on the guest record, because:
app/legacy/(site)/settings/page.tsxrequires a selected guest, and the header menu that reaches it only renders once a name is chosen (header-user-select.tsx:62). A guest-record setting would be unreachable from exactly the screens a first-time visitor sees.- Ambient light genuinely differs per device. One attendee on a phone at night and on a laptop in a bright room wants two different answers; a single stored preference can only give one.
- Switching the current user is unauthenticated, so a guest-record preference would be writable by anyone claiming that name.
The control sits in the footer, which is on every page including anonymous ones and already carries the low-salience global strip (version, help, report a bug). It is mirrored on the settings page, labelled as applying to this device, for discoverability — both write the same cookie.
“System” is resolved in CSS, not in an inline script
The server cannot know the OS preference, so the usual fix is a blocking inline script that reads it
and sets a class before paint. We avoid the script entirely: an explicit choice is a class on
<html>, and system is the absence of one, handled by a media query inside the dark variant.
@custom-variant dark {
&:where(.dark, .dark *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:where(html:not(.light), html:not(.light) *) {
@slot;
}
}
}
.dark wins outright; otherwise the OS decides unless light pinned it. No script, no flash, and
it still works with JavaScript off. The cost is that “which theme am I actually in” is not readable
from JS — nothing needs it.
A variant on its own would only reach utilities that are written dark:…, and the whole point of
the tokens below is that components never write one. So the token rule applies the variant to
itself:
:root {
color-scheme: light;
--surface: #ffffff;
@variant dark {
color-scheme: dark;
--surface: #16181d;
}
}
Tailwind expands that into the two branches above and flattens the nesting in the production build,
so the browser sees plain :root:where(.dark, .dark *) and @media (prefers-color-scheme: dark)
rules. Writing the dark values once, under the one definition of “am I dark”, is what keeps the
class and the media query from drifting apart. color-scheme rides along in the same block, which
is what gives native controls, scrollbars and form autofill the right theme — the layout only has to
set the class.
Reading the cookie makes the root layout dynamic. The site is already effectively dynamic (a
settings read in generateMetadata, cookies() in (site)/layout.tsx), so this costs nothing in
practice.
Semantic tokens, not dark: on every utility
Components name the role a colour plays — bg-surface, text-fg-muted, border-line, bg-brand —
and the token rule in styles/tokens.css is the only place a colour is picked. Its @variant dark
block lists only the tokens that change, so what the two themes disagree about is readable at a
glance and a colour that is meant to be the same in both cannot drift.
Annotating each existing utility with a dark: counterpart would have been a diff of the same size
that left the problem permanently open: every new component would be free to forget, and nothing
would notice. With tokens, dark mode is correct by default for code that hasn’t been written yet.
@theme inline is required for the swap to work — a plain @theme bakes the value into the
generated utility at build time, so .dark could never override it.
The dark theme is not an inversion
Two things change shape rather than value:
- Elevation. Light mode raises a card with a shadow; on a dark ground a shadow is nearly
invisible, so
--surface-raisedis lighter than the page and--surface-sunkenis darker. The contrast test asserts this ordering. - Tints.
bg-rose-50and friends become deep desaturated grounds (--brand-tint: #3b1220) with a light accent on top, not darkened pastels.
Neither theme uses the extremes: the dark page is #16181d rather than black and its text #e8eaee
rather than white, because pure white on pure black blooms at reading sizes.
The contrast contract is a test
tests/unit/theme-contrast.test.ts parses the tokens out of styles/tokens.css and asserts a WCAG
ratio for every (foreground, background) pair that occurs in the UI, in both themes — 7:1 for body
copy, 4.5:1 for secondary and semantic text, 3:1 for control borders and state indicators per WCAG
2.2 1.4.11. It reads the dark block the way the cascade does, light values with the overrides on
top, so a token that is only wrong once it inherits is still caught.
This is what stops dark mode from quietly rotting: “looks a bit washed out” is otherwise nobody’s bug. Row dividers and hover fills are deliberately exempt — they are neither text nor controls, and holding them to 3:1 would mean drawing boxes everywhere.
The list of pairs doubles as the rule for which token belongs on which ground: --fg-subtle and
--line clear the three page grounds but not the filled ones (--surface-muted,
--surface-hover), where --fg-muted and --line-strong take over.
Enforcing the contract moved a few light-mode colours, which is a deliberate consequence rather than a side effect (see below).
Location colours are derived, not safelisted twice
A location’s colour stays a palette name. loc-<name> puts that hue into a --loc custom property
and each role — block fill, dimmed fill, border, RSVP badge, tag, admin swatch — is a color-mix()
of it with the current --surface or --fg, so one class works in both themes. The alternative, a
second set of safelisted dark:bg-*-900 classes, would have doubled the @source inline(...) list;
as it turns out the 22 loc-* rules are plain CSS, so the safelist is gone entirely.
Deriving also fixes a light-mode bug we would otherwise have carried over: the old block was
bg-${color}-500 with text-white, which is 1.8:1 on yellow-500. Because the fill is now mixed
with the page, the title is --fg on a tint of the hue and stays legible whichever of the 22
colours a location has. tests/unit/location-colors.test.ts recomputes every hue in both themes —
mixing in oklab as the browser does — rather than trusting that.
The visible cost is that a scheduled session is a tinted card with a saturated border instead of a solid block of colour.
Consequences
Positive
- Dark mode works before a name is selected and on the kiosk.
- Correct on first paint, with no inline script and no flash.
- Native controls, scrollbars and autofill follow the theme, because
color-schemeis set by the same rule as the tokens. - New components get dark mode for free by naming roles.
- The contrast test makes readability regressions fail the build.
- #802’s class of bug is harder to reintroduce: state is no longer encoded as a bare palette shade.
Negative
- A large mechanical migration touching most components, and one that reviewers have to read as “renames” rather than logic.
text-whiteneeded judgment per call site — it splits into--on-brand,--on-danger,--on-infoand--fg-inversedepending on what it sits on.- The token vocabulary is one more thing to learn before writing a component.
Neutral
- The schedule grid looks different in light mode: session blocks are tinted rather than solid (see above). Locations are still told apart by hue, now carried by the border as much as the fill.
- Light mode shifts slightly where the contrast contract required it, most visibly: the several
grays doing “muted text” collapse to three levels, so
text-gray-400text darkens to a readable--fg-subtle(it was 2.9:1 on white); primary rose buttons deepen from rose-400/500 to rose-600, because white on rose-400 was 2.7:1; and form-control borders darken to meet the 3:1 of 1.4.11. - Emails, the hand-written landing page in
www/, and the docs site are out of scope. Mail-client support forprefers-color-schemeis too patchy to be worth the divergence.