Skip to content

ADR-0025: Design tokens on the web

ADR-0025: Design tokens on the web

  • Status: Accepted
  • Date: 2026-08-06
  • Supersedes: —
  • Superseded by: ADR-0029

Context

WI-045 made the app’s values consistent and WI-046 gave them a point of view — warm greys, the green of a card table, five type sizes each with a line height. check-design-tokens.mjs keeps it that way, and its comment is the argument: thirteen hex values across a hundred and fifty-two uses “made every screen look like it had been built by somebody who had not seen the others”.

apps/web was never inside that check. It scans apps/mobile only. So the site accumulated ten hand-written hex values in Base.astro, and they were the pre-WI-046 cold blue-greys — the palette WI-046 rejected for reading “like software”. It was half-migrated: theme-color and two values had been copied across by hand, which reads worse than either extreme. Web also had no line heights beyond a blanket 1.5, the thing WI-046 called “the most common reason an interface reads as unfinished”.

This matters more than a shared look. The product has three moments, and they happen at different times and often on different devices: what shall we play, we played this and it was X, and what did we play last month, was it any good. The middle one is a phone at a table. The first and third are as likely to be a laptop. A site that looks like a different product from the app taxes the two thirds that are not the logging moment.

Decision

apps/mobile/src/theme.ts remains the single source of truth.

scripts/web-tokens.mjs reads it and generates two committed files:

  • apps/web/src/styles/tokens.css — every colour, space step, radius, the five type sizes with their line heights and weights, and the card shadow, as CSS custom properties.
  • apps/web/src/styles/tokens.ts — the same palette as a module, for the few places a stylesheet cannot reach. <meta name="theme-color"> is the only one today.

Run without --write, the same script fails the gate when either file drifts from theme.ts, and when a raw colour or font size appears anywhere under apps/web/src. That second half is the one apps/web never had.

The web adds exactly one deliberate divergence: a larger display size above 48rem, in web.css. 26px is right on a 390px phone and undersized as a page title at 1200px. The weight, the negative tracking and the ratio of size to line height are unchanged — only the step differs.

Rationale

Generating beats sharing a package here because theme.ts cannot be shared whole. elevation is a Platform.select(), weights are RN’s strings, sizes are unitless points, and there is a HIT_SLOP. A package could only ever hold the primitives, so mobile would keep a layer on top and the “single source” would be partial — which is what we already had. Generating gets the same primitives across with no new package, no Metro resolution risk, and no pretence.

Committing the output rather than generating at build time follows ADR-0022: the values are in the diff where a reviewer can see them change.

The check matters more than the mechanism. The values were only ever wrong because nothing looked.

Consequences

  • theme.ts is parsed textually, not imported. It is TypeScript and it imports react-native, so neither Node nor the gate can load it. The parser asserts the shape it expects — five type sizes, at least ten colours, at least four space steps — and throws rather than silently emitting less. A theme refactor that moves those objects will break the build, loudly, which is the intended failure.
  • Mobile keeps the crown for a historical reason. The moment the web needs a token mobile has no concept of — a hover state, a wide-viewport spacing step — it has nowhere principled to live. At that point this should be revisited, and a package becomes the right answer rather than a speculative one.
  • Two generated files must stay committed and in step. Forgetting pnpm tokens fails the gate, which is the point, but it is one more thing that can be forgotten.
  • Hover, focus rings and breakpoints are web-only and unenforced. Nothing checks that they are used consistently, because mobile has no equivalent to compare against.
  • The two type scales are no longer identical, by choice. No check can prove the desktop step is “the same family”; that is a judgement, recorded here so the next person knows it was one.

Alternatives considered

A shared token package. Rejected for the reason above — it cannot hold the whole system, so it buys a partial single source at the cost of a new package in Metro’s resolution path, which is where this repo has repeatedly been bitten. Worth promoting to if apps/web grows enough to deserve equal standing.

Hand-copying the values into web CSS with no check. This is what the site already had, informally, and it is exactly how it ended up on the palette WI-046 rejected. A drift check is most of the work of a generator, so paying for the check and not the generation is the worst trade of the three.

Letting the web have its own design language. Defensible — a 1200px viewport genuinely wants different density, and hover and focus have no mobile analogue. Rejected because nothing can enforce “consistent in spirit”, and accumulated judgement with no check is precisely what produced the drift being fixed. The one divergence that is genuinely justified is taken explicitly instead.