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: 24px | the largest space step in the system | every vertical rhythm on a 1440px page is capped at 24px |
--text-body-size: 15px | right at 390px | small on a laptop at reading distance |
--text-display-size: 26px | 34px above 48rem, the one sanctioned divergence | a page title at 1200px wants roughly twice that |
main { max-width: 52rem } | narrower than the 64rem header above it | the content column is misaligned with its own chrome |
| one desktop breakpoint | 48rem, for the display step alone | there 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/designholds what genuinely is shared and platform-neutral: the palette, the radii, and the 4pt space unit. These are brand facts.apps/mobile/src/theme.tscomposes its own scale from them, keepingPlatform.select(), RN weight strings, unitless sizes andHIT_SLOP— none of which the web can use.apps/webcomposes 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.mjskeeps failing the gate when a raw colour or font size appears underapps/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/webgrows 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/designmust therefore hold plain data and no platform imports — noreact-native, noPlatform, no CSS. If it cannot be imported by Node, the gate cannot read it, which is what forcedtheme.tsto be parsed textually rather than imported. theme.tsstops being parsed textually. Once the primitives are a plain module,web-tokens.mjscan 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.