WI-092: The web gets a scale of its own
WI-092: The web gets a scale of its own
Problem
Everything on the site was sized for a phone, because
ADR-0025 made
apps/mobile/src/theme.ts the single source of truth and allowed the web
exactly one divergence. Read straight off the generated stylesheet:
--space-xl: 24px | the largest space step in the system, so every vertical rhythm on a 1440px page was capped at it |
--text-body-size: 15px | right at fourteen inches, small at two feet |
main { max-width: 52rem } | under a 64rem header — the content column was narrower than its own chrome, and both numbers were hand-written |
| one desktop breakpoint | for the display step alone |
And the brand face was named but never shipped: font-family: "Outfit", …
with no webfont, because WI-046 ruled one out
against the mobile bundle’s 48KB of headroom. apps/web is a Worker with
no bundle budget, so it had inherited a constraint that was never about it —
and almost every reader saw system-ui.
ADR-0029 is the decision. This is the mechanism.
What shipped
packages/design — the palette, the radii, the 4pt unit, the shared space
steps and the card shadow’s parts. Plain JavaScript with a hand-written
index.d.ts, because the gate imports it under bare node and Metro bundles
it; a .ts source would need a loader in one and a resolution rule in the
other, and Metro resolution is where this repo has been bitten most.
apps/web/tokens.config.mjs — the web’s own ramp, which is what ADR-0029
exists to allow. It sits outside src/ for the same reason the marks do: it is
full of the literal values the raw-value check exists to keep out of src/.
| mobile | web base | web ≥48rem | |
|---|---|---|---|
| body | 15/22 | 16/26 | 17/29 |
| display | 26/31 | 30/36 | 46/50 |
| largest space step | 24px | 64px | 88px |
Three named widths replace two hand-written ones: --page for the chrome,
--content for the column records sit in, --measure for prose.
Outfit, self-hosted. One variable file covering 400–700, latin subset,
32KB, font-display: swap, served from our own origin so no third party
sees the reader. The OFL travels with it.
The check survives and widens. web-tokens.mjs no longer parses
theme.ts textually — ADR-0025 had to, because that file imports
react-native — it imports the config. And src/styles/ used to be skipped
wholesale by the raw-value scan, which meant web.css, the one hand-written
stylesheet on the site, was never checked at all. Only the two generated files
are exempt now.
Out of scope
- The layout itself — two-column game page, cover rail, uncropped box art. WI-093. This item delivers the vocabulary; that one composes with it.
- Dark mode. Possible for the first time; its own judgement and its own diff.
- Any mobile redesign. Mobile’s rendered output is unchanged, and that is a DoD line rather than an aspiration.
Definition of done
-
packages/designexists, holds only plain data, and imports nothing. -
theme.tscomposes from it;apps/mobilerenders identically — 158 tests pass untouched. -
apps/webhas space steps above 24px and a type ramp of its own. - A raw colour or font size under
apps/web/srcstill fails the gate — now includingweb.css. -
web-tokens.mjsimports the primitives rather than parsingtheme.ts. - Outfit is served from our own origin, subset, with a real fallback stack.
- The mobile bundle is within 1KB and still under its tripwire — 1813KB of 1855KB.
- Each check proven able to fail.
- Gate green.
Seeded failures
| Seed | Bit | How |
|---|---|---|
A resolvable import added to index.js | ✅ | imports nothing at all: expected ‘import path from “node:path”…’ not to match |
import { Platform } from "react-native" | ✅ | the suite fails to load — see below |
| A colour role removed from the palette | ✅ | declares every colour role the clients use |
| A three-digit hex in the palette | ✅ | gives every colour as a six-digit hex |
| A space step off the 4pt grid | ✅ | keeps every space step on the grid |
tokens.css edited by hand | ✅ | drift check: does not match apps/web/tokens.config.mjs |
A raw hex written into web.css | ✅ | now caught; was not caught before this item |
One nuance worth recording. Seeding the obvious import — react-native —
turns the suite red for the wrong reason: the module cannot resolve from
packages/design, so the file fails to load and the assertion never runs. The
gate goes red either way, but a seed that proves the harness works is not a
seed that proves the check works. Re-run with node:path, which resolves,
the assertion itself fires. That distinction is the whole point of seeding.
The last row of the table is the other one worth reading. src/styles/ was excluded from the
scan, so the single hand-written stylesheet on the site could carry any colour
it liked — the exact hole ADR-0025 was written to close, left open in the one
file most likely to exploit it.
Verification
pnpm --filter @tabletop/design test # 6pnpm --filter @tabletop/mobile test # 158, unchangedpnpm --filter @tabletop/web test # 37pnpm tokens && git diff --exit-code # generated output committedpnpm gateKnown limit
The latin subset covers U+2000–206F, which includes the ellipsis and the en
dash, and U+2191/U+2193 — but not U+2192, the right arrow the site
uses in “Browse the catalogue →”. That one glyph falls back to the system face.
It is a single character at body size and the fallback renders it acceptably;
fixing it means either a custom subset or different copy, and neither is worth
a build step today. Recorded so the next person does not rediscover it as a
mystery.