Skip to content

ADR-0029: The web owns its own scale

ADR-0029: The web owns its own scale

  • Status: Accepted
  • Date: 2026-09-01
  • Supersedes: ADR-0025
  • Superseded by: —

Context

ADR-0025 made apps/mobile/src/theme.ts the single source of truth for both clients, gave the web generated CSS custom properties, and allowed the web exactly one deliberate divergence — a larger display step above 48rem.

It was the right decision at the time. The site had drifted onto ten hand-written hex values in the palette WI-046 had explicitly rejected, and nothing was looking. A drift check was the fix.

It also wrote down the condition under which it should be revisited:

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.

That moment has arrived, and it is measurable rather than a matter of taste. Read off the generated tokens.css and web.css as they stand:

--space-xl: 24pxthe largest space step in the systemevery vertical rhythm on a 1440px page is capped at 24px
--text-body-size: 15pxright at 390pxsmall on a laptop at reading distance
--text-display-size: 26px34px above 48rem, the one sanctioned divergencea page title at 1200px wants roughly twice that
main { max-width: 52rem }narrower than the 64rem header above itthe content column is misaligned with its own chrome
one desktop breakpoint48rem, for the display step alonethere is no desktop layout anywhere — no grid, no two-column, no rail

The typeface is the same failure in a second place. web.css sets font-family: "Outfit", "Poppins", system-ui, … and ships no webfont, because WI-046 ruled one out against the mobile bundle’s 48KB of headroom. The web is server-rendered on a Worker and has no bundle budget at all, so it inherited a constraint that was never about it — and the result is that the brand face is named everywhere and rendered almost nowhere.

Underneath all of it is a framing problem. Three decisions in a row made the site downstream of the phone — WI-066 is titled “A web view that matches the app”, ADR-0025 gave mobile the crown, and the site’s only call to action on every page is “Log a play in the app”. But ADR-0009 exists because the site is the acquisition surface, and ADR-0026’s framing has two of the product’s three moments happening on a laptop as often as a phone. The surface that strangers arrive on cannot be a projection of the surface they have not installed.

The direction was decided by Simon on 2026-09-01, after using the deployed site: the web is to be a product in its own right rather than a view of the app. This ADR records the mechanism.

Decision

The primitives move to packages/design. Each client owns the scale it builds on them.

  • packages/design holds what genuinely is shared and platform-neutral: the palette, the radii, and the 4pt space unit. These are brand facts.
  • apps/mobile/src/theme.ts composes its own scale from them, keeping Platform.select(), RN weight strings, unitless sizes and HIT_SLOP — none of which the web can use.
  • apps/web composes its own: a space scale that continues past 24px, a type scale sized for a reading distance of two feet rather than fourteen inches, and the states mobile has no concept of — hover, focus-visible, breakpoints.
  • The drift check survives, and widens. web-tokens.mjs keeps failing the gate when a raw colour or font size appears under apps/web/src; it stops asserting that the web’s scale equals mobile’s, because that is no longer the claim.
  • The web may ship a subset webfont. Mobile’s bundle budget is unchanged and does not apply to it.

Rationale

The check was the load-bearing part, not the crown. ADR-0025’s own closing line is “the values were only ever wrong because nothing looked”. Nothing about that argument requires mobile to own the scale — it requires something to look. Moving the primitives into a package keeps every raw value out of both clients while letting each express a scale suited to the distance it is read from.

ADR-0025 anticipated this exact shape in its alternatives, and rejected it only on timing:

A shared token package. … Worth promoting to if apps/web grows enough to deserve equal standing.

This is not a reversal. It is the promotion that ADR named, on the trigger that ADR specified.

A shared scale was never enforceable anyway. ADR-0025 already conceded that no check can prove the desktop display step is “the same family” as the phone’s — that judgement was recorded rather than tested. Extending one exception to a whole scale changes the quantity of judgement, not its kind.

Consequences

  • Two scales must stay coherent by judgement, not by check. This is the real cost, and it is the cost ADR-0025 paid once already for the display step. The palette and radii are still mechanically shared, so the two clients cannot disagree about what green means — only about how much air a heading gets.
  • A new package in Metro’s resolution path. This is the risk ADR-0025 named and the one this repo has repeatedly been bitten by. packages/design must therefore hold plain data and no platform imports — no react-native, no Platform, no CSS. If it cannot be imported by Node, the gate cannot read it, which is what forced theme.ts to be parsed textually rather than imported.
  • theme.ts stops being parsed textually. Once the primitives are a plain module, web-tokens.mjs can import them. The brittle shape-assertion parser ADR-0025 introduced goes away, which removes a whole class of “a refactor broke the build” failure.
  • The web gets a font request. One subset woff2, font-display: swap, self-hosted so no third party sees the reader. Measured against the bundle tripwire it does not touch, because the tripwire is mobile’s.
  • The three unmerged web PRs land on a moving floor. WI-087 and WI-088 both touch this surface and are open. They should merge before this work starts, not be rebased through it.
  • Dark mode becomes possible for the first time. The palette has one ground; a second is a web-only concern today and had nowhere to live under ADR-0025.

Alternatives considered

Keep ADR-0025 and add wide-viewport tokens to theme.ts. Cheapest, and it keeps one source. Rejected because it puts tokens with no meaning on the platform that owns them: a --space-3xl of 64px and a hover colour are nonsense in a React Native theme, and a source of truth full of values one consumer must ignore is not a source of truth — it is a junk drawer with a check on it.

Let the web have a wholly independent design language. ADR-0025 rejected this and was right to; nothing has changed. The palette is a brand fact and the brand pack (WI-085) is the authority for it. Sharing primitives while splitting scales takes the part that is genuinely common and no more.

Do nothing until the site is redesigned. Considered, because the redesign is the point and the tokens are only its substrate. Rejected because the redesign cannot be written without somewhere to put a 48px space step, and web-tokens.mjs fails the gate the moment one is written by hand. The token work is not a preliminary to the redesign; it is the thing currently blocking it.