Skip to content

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: 24pxthe largest space step in the system, so every vertical rhythm on a 1440px page was capped at it
--text-body-size: 15pxright 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 breakpointfor 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/.

mobileweb baseweb ≥48rem
body15/2216/2617/29
display26/3130/3646/50
largest space step24px64px88px

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/design exists, holds only plain data, and imports nothing.
  • theme.ts composes from it; apps/mobile renders identically — 158 tests pass untouched.
  • apps/web has space steps above 24px and a type ramp of its own.
  • A raw colour or font size under apps/web/src still fails the gate — now including web.css.
  • web-tokens.mjs imports the primitives rather than parsing theme.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

SeedBitHow
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

Terminal window
pnpm --filter @tabletop/design test # 6
pnpm --filter @tabletop/mobile test # 158, unchanged
pnpm --filter @tabletop/web test # 37
pnpm tokens && git diff --exit-code # generated output committed
pnpm gate

Known 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.