0013: Light and dark themes from shared design tokens
- Status: Accepted
- Date: 2026-10-13
Context
The owner asked for a serious light/dark mode on the landing page, the hub and some games, without forcing it on
every future game: some games have one look by nature. Colours were hard-coded everywhere (about 130 Tailwind
palette classes in the hub alone), so dark mode added with dark: variants would miss elements and drift over
time. Some light colours also failed WCAG AA: white text on the #f97316 buttons is 2.8:1.
Decision
- One palette, two themes.
packages/design-tokens/tokens.cssdefines tokens named by role (page,surface,ink,ink-soft,brand,danger…) for light, the default, and dark (data-theme="dark"on<html>). The dark palette is warm charcoal, not black or navy, so Tò He keeps its toy-like warmth. UI code uses token colours only: the hub removes Tailwind’s default palette, and a test rejects raw palette classes. - Contrast is tested, not reviewed.
- A unit test checks that the token pairs meet WCAG AA in both themes.
- The e2e smoke test runs axe-core’s contrast check on every hub screen in both themes.
- White on
brandpasses only as large text, so text onbrandis at least 1.25rem bold.
- The choice: system, light or dark, per device, default system.
- It is stored in the
tohe-themecookie; no cookie means system. Cookies are shared by every port of a host, so the site follows the same choice as the hub. - “System” follows the device live.
- A blocking same-origin script applies the choice before the first paint, so a dark screen never flashes
white at night. The browser’s UI colour (
theme-color) follows the theme.
- It is stored in the
- Games opt in.
game.jsongainsthemes:["light"](the default),["dark"], or both.- The protocol’s
settingsgainstheme, the theme the hub is showing, sent at start and whenever it changes. - A game with one look keeps it; the hub’s frame around the game follows the hub.
- Old game builds ignore the new field, because the game-side SDK does not validate settings.
- The contract change ships with its docs and conformance checks in both themes, in the same PR.
Consequences
- Dark mode is a token change, not a component change: screens built from tokens get it for free.
- The light theme shifts slightly where contrast required it:
- primary buttons are
#ea580c(was#f97316), with labels of 1.25rem; - secondary text is a solid
ink-softinstead of translucent brown.
- primary buttons are
- The choice belongs to the device, not to a profile. A theme per profile would need settings stored on the server (with M2).
- The cookie reaches everything on the host, including the games origin, which ignores it. If production ever puts
the hub and the site on separate subdomains, the cookie needs a
Domainattribute.
Alternatives considered
dark:variants on every element: easy to miss elements, doubles every class list, and leaves no single place to tune contrast.- A theme per profile: would let kids sharing a device choose differently, but needs server-side settings first (M2).
- localStorage instead of a cookie: not shared between origins, so the site and the hub could disagree.
- A neutral or navy dark palette: the familiar default, but it loses the warm Tò He identity.