utword DS · v3.18.9
Outword · Design System
fig.00 — the swipe, spelling the name

The design language of a fast multiplayer word game. A symmetric light/dark system where roles swap, two hues carry every meaning, and each screen is quiet around one thing — the letter tile.

Sourceapp.jsx · v3.18.9
Grounds2  dark default · light swap
Hues2 semantic + 1 system (focus)
Color56 base values · 2 modes

01  Principles

Principles

Five beliefs, in priority order — when two conflict, the higher one wins. The mechanical choices under them aren't separate rules; they're these, made concrete. Written to be used in a critique, not admired on a wall.

01

The experience is the whole point

Judge every decision by how the round plays — never how it looks or how clever it is. Speed and clarity are the biggest levers inside the experience, but the small joys — a word landing, the trace snapping shut — are part of it too, and don't get traded away to shave milliseconds.

so "does this make the round better to play?" is the only question that settles an argument. Pretty, novel, and distinctive are all downstream of it.

02

Fast and clear, before anything on screen

The player is racing a clock: every read instant, every control obvious, nothing between them and the next word. When speed or legibility fights beauty, novelty, or personality, speed wins — every time.

so this governs the look, never the feel in 01 — a treatment that costs even a beat of legibility loses, however good it looks.

03

Calm does double duty — speed and feel

A quiet interface keeps fewer things between the player and their word, and gives the game a composed, premium calm. Low stimulation isn't restraint for its own sake; it's how Outword stays fast and feels good at once.

so a muted palette, one thing moving at a time, and nothing on screen competing with the board for the eye.

04

Craft is restraint; over-design is the failure

Craft here is precision, not richness — the truest colour, the tightest spacing, the cleanest state. The quickest way to be wrong is a flourish that's trying. If it looks "designed," cut it back.

so the strongest move is usually to remove something — new emphasis competes with existing emphasis, and more signal is rarely more clarity.

05

Unmistakably Outword — earned, never decorated

It reads as itself the instant you see it: the tile, the swipe-trace, the two grounds. Identity is the last thing to protect in a conflict — clarity always outranks it — but it's always present, and it comes from the mechanic, not from ornament.

so distinctiveness is a byproduct of the game's own shapes, never a treatment added to look unique.

How they show up

The recurring mechanical choices in this document — each one is a driver above, made concrete.

  • The board is the hero; everything else stays quiet. One primary action per screen, controls in outline, and no decoration that pulls the eye off the letters.
  • One hue, one meaning · one pill, one live value. Green is earned, rose is danger, plus one system hue for focus; a pill holds a single changing number, never a static label.
  • Motion belongs to menus, not gameplay. Ambient movement on idle screens; the instant a round goes live, everything freezes.
  • Two grounds, symmetric. Light and dark are one system with the roles swapped; every colour is a token, so a hardcoded value can't survive a mode flip.

02  Foundations

Color

Two grounds. The color that is the page in one mode is the ink in the other. The ground/ink pair is a true inversion; the softer tiers — soft, faint, lit — are hand-tuned per mode rather than blindly swapped, so each reads correctly.

Tokens are built in three tiers: a base reference (--dk-* / --lt-*) holding the only literal values; an active layer (--ground, --fg…) that components reference; and a mode swap on :root[data-mode="light"] that repoints the active layer. Flipping that one attribute is the whole mechanism — a hardcoded hex can't follow it, which is why it's forbidden.

Aa
ground #202845 · fg #F2F0EA
Aa
ground #F2F0EA · fg #202845

fig.01 — the same two roles, grounds swapped

The two semantic hues

Green (--earned) and rose (--danger) are the only colors that carry meaning — and the only ones that don't invert. Each means exactly one thing. Shown in context, on the word pill they live on:

SPARROW QXZ PLOVER TRAC

fig.02 — earned · danger · dup · neutral

The one hard rule

Type on any semantic fill is always --ground — never white, never black. In dark mode --danger is a light rose, so white on it lands at ≈1.7:1 — unreadable — while --ground ink clears 8:1. Green is positive only; rose is negative only. If green ever also meant "selected," the system would be broken.


03  Foundations

Typography

Three families as tokens — display, body, and word — the same split this document is set in. Arial Black carries the loud moments; a system face carries the reading; the word face is the tiles.

RSTL
Room code · 54px display · +14px
Create game
Heading · 16px display · caps +1px
SPARROW
Word pill · 17px / 800 · +2px
plover — 8 pts
Body / list · 16px / 600
Longest word
Eyebrow · 11px / 700 · caps +2px

Weights: 500, 600, 700, 800 — nothing else. Live numbers use tabular-nums. The eyebrow (uppercase, +2px, --fg-soft) is the recurring structural label — reuse it, don't invent section headers.


04  Foundations

Radius

Three radii carry the system. Reach past them only with a reason.

12 · buttons · fields · cards
999 · pills
14 · tiles · rows

Also present: 8 (brand die), 10 (segmented), 18 (sheet top), 2 (rails). Prefer 12 / 999 / 14.


05  Foundations

Spacing

A 4px base grid. Common gaps: 8 / 12 / 16 / 20 / 24. The modal head is the reference rhythm — 20 above the title, 4 to the sub, 16 to the rule, 12 under.

8 · 12 · 16 · 20 · 24

06  Foundations

Motion

  • Mode cross-fade — the whole app eases grounds over 0.5s.
  • Tile state 0.08s · button press 0.07s · chevron 0.15s.
  • Brand-mark trace — the ambient 5700ms cycle (see Brand mark).
prefers-reduced-motion

Respected everywhere. Every animated rule has a reduced-motion branch that removes movement, keeping only a dim or fade where feedback is needed. The mark and tiles go static.


07  Brand

Brand mark

A filled disc with a 3×3 lattice, and a line that traces a legal board path across it — the disc is the leading O of "utword." SVG on a 0 0 100 100 viewBox; geometry locked to the approved reference.

utword

fig.06 — the live mark, tracing legal board paths · disc --fg · dots --mark-dot · trace --ground

PartSpec
disccx/cy 50, r 34.2, fill --fg
lattice9 dots, r 3.15, --mark-dot, spaced by R_CELL 0.234 × diameter 68.4
tracepolyline, --ground, width 3.9, round caps, pathLength=1
motiondraw 1400 → hold 2800 → retract 1000 → pause 500 (5700ms), eased

Legal traces

The trace only ever draws a real board path — cells that touch, edges or corners, and never the same one twice. It's the exact rule you swipe by in a round: a word is just a legal trace with letters on it.

The rule allows 10,256 legal paths of 3 to 9 cells. Drawing them at random throws the occasional awkward shape at logo size, so the mark instead rotates through a curated set — the twelve below, chosen to read cleanly — starting from the canonical hook (1·4·7·6·3), which is also the app icon and the still frame under reduced-motion.

1·4·7·6·3 · canonical
0·1·2
0·4·8
0·3·6·7
2·1·4·7
0·4·8·5·2
6·4·2·1·0
6·3·1·5·8
0·1·2·5·8
8·4·0·1·2
6·3·0·1·2·5·8
0·1·2·5·4·3·6·7·8

the rotation set · the live mark above cycles these in order · hook leads and is canonical

Motion — the lattice holds, the trace moves

Each trace runs the same beat: draw 1400ms → hold 2800ms → retract 1000ms → pause 500ms — a 5700ms cycle, eased cubic in-out — then the next in the rotation. The nine dots never move; only the line does. It's the game's own swipe gesture, slowed to an ambient pace.

Still during play

The instant a round starts, live=false and the mark freezes on its resting hook. Nothing moves while someone is racing the clock — ambient motion belongs to menus, not to the board, where it would only pull the eye off the tiles. It also pauses when scrolled off-screen (IntersectionObserver) and holds completely still under prefers-reduced-motion.

The NameGrid is the same primitive — as of v3.14.1 its letters recede, so its trace line stays visible while the game board's is hidden.


08  Components

Tiles

The heart of the UI; everything else is quiet around it. Square by construction, radius 14, 2.5px border, box-sizing border-box so a state change never shifts the grid.

Dark · resting + lit
S
P
A
R

lit = --select + --select-stroke + --select-ink

Light · resting + lit
S
P
A
R

resting is a solid fill — an outline read as "unfinished"

Trace-line rule · v3.12 / v3.14.1

On the game board the trace line is hidden — filled tiles carry the word, feedback lives in the pill. The name board is the sole exception: its letters recede, so the line earns its place. Tiles are the one control with no press-dip; it would fight the trace.

Name board

On the home screen your name is spelled across a mini board — the reward for typing a real name. It's the same tile, the same selected treatment, and the same polyline as gameplay, so it reads as a board rather than decoration. The name sits on a random legal path (adjacent cells, no reuse); the rest is filler. The name letters wear the selected/recede treatment (--select fill + --select-stroke) while filler stays the bright resting tile — the inverse emphasis of gameplay, which is exactly why the trace line shows here.

Name board · types on, tap to re-lay

5 columns · tile radius 9 · trace --trace, width 2.4, 50% · draws at 190ms/letter

How it lays out

The board is 5 wide, 2 rows up to 10 letters and 3 beyond. A self-avoiding walk places the name; leftover cells take filler from a fixed set. Each open types the name on — 260ms in, then a tile every 190ms with the trace growing behind it — and every tap re-lays a fresh random arrangement.


09  Components

Buttons & controls

One primary per screen, and it's the action people are most likely to take — not the one listed first. Most players join rather than host, so Join is the filled primary and Create is the outline. Everything else is an outline too; there is no third style — a screen that seems to need one has too many actions.

Dark

primary · secondary · icon (44×44)

Light

icon buttons are always 44×44 · radius 12 · --fg-soft

ControlHeightRadiusFill / borderType
primary5212fill --fg · ink --ground · lift16 / 700
secondary52121.5px --rule outline15 / 600
icon-secondary4412--rule outline · --fg-soft14 / 600
icon44×4412--rule outline · --fg-soft20 glyph
segmented4410on-state fills --fg13 / 600
Icon-button exception

Icon buttons are 44×44 / radius 12 / --fg-soft — the modal close included; no size exceptions. Press: scale 0.98 + brightness 0.94 over 0.07s, on every button except tiles. Focus: 2px --gold (=--fg) ring, offset 2px — the home/mode icon button is the one that rings in --focus.

States

Primary · every state

default · pressed (scale .98 + brightness .94) · focus (2px --gold ring, offset 2) · disabled (.4 opacity)

Focus · most controls

2px --gold (=--fg) ring — 5.3:1, clears the 3:1 non-text target

Focus · mode toggle (exception)

the home/mode icon button rings in --focus (powder blue)


10  Components

Toggle

Two toggles. The segmented control switches between named options — the on segment fills with --fg, so the choice reads as loud as a primary. The mode toggle is a single icon button that flips the ground.

Segmented · dark

on = --fg fill · --ground ink · 44px · radius 10

Segmented · light

off segments are --fg-soft on transparent

Mode toggle · on dark

home-icon-btn · 44×44 · a half-filled disc reads as "appearance"

Mode toggle · on light

one glyph, not a sun/moon — only the ground flips (0.5s cross-fade)

One selected, always

The segmented control has no empty state — one option is always on. It's a choice between peers, not an on/off switch; there is no sliding-thumb switch in the system, and adding one would introduce a second selection metaphor competing with the tile's lit state.


11  Components

Pills

Radius 999, reserved for a single live value — the clock, the score, the word being traced. Not for recaps or badges; those use weight and ink.

1:240:07128

fig.10 — timer · urgent (rose, ground ink) · score

The word pill's resting state is boxless, so touching a tile has nothing to collapse; min-height holds the slot. Arriving fades grey→green; leaving is instant (.empty is transition:none) so no green blob lingers after the word is gone.


12  Components

Cards & sheets

Exactly three elevation levels — depth comes from the tile's inset lip, not stacked shadows: --ground (page), --raise (on it), --lit (a tile in the trace).

Leaderboard rows
1 Ru longestsparrow 24
2 Sam 17
3 wordstap word to show on board
quartzunique ×218 pts
plover8 pts
otter5 pts

rank · name · longest pill · points · tap to expand → per-word points

Earned state · fill + stroke
sparrow SPARROW
--earned-card-bg fill · --earned-card-border stroke

green takes fill AND border — solid pills match; tinted surfaces keep a solid --earned stroke

Green all the way to the edge

An earned (green) or danger (rose) element takes the hue on both fill and border — never a coloured fill left with a neutral --rule stroke. Solid states match border to fill (--earned on --earned); tinted surfaces pair an --earned-wash/--earned-card-bg fill with a solid --earned-card-border stroke, so the edge still reads green.

Bottom sheet

The app's modal rises from the bottom — that's where thumbs already are. The scrim dismisses; the ✕ is for people who don't know that. Every gap in the head is a multiple of 4: 20 above the title, 4 to the sub, 16 to the rule, 12 under.

Create game

Set it up, then read the code out to everyone.

Mode
Board
Round
PartSpec
scrimfixed inset 0, rgba(6,16,32,.72), fade-in 160ms
sheetmax-width 520, bg --ground, 1px --rule, radius 18 18 0 0, shadow 0 -8px 40px --shadow, max-height 88vh
risesheet-up 220ms cubic-bezier(.22,1,.36,1) — none under reduced-motion
headtitle 800/17, sub 500/12.5, close 44×44 outline
footborder-top --rule, padding respects env(safe-area-inset-bottom)

fig.11 — scrim dismisses · sheet rises from the thumb line · 18px top radius only


13  Components

Fields

One field style. The name field is the default; the join field is the same field with a .code modifier — centered, uppercase, +6px — for typing a host's 4-letter code.

Dark

name + join input · height 52 · radius 12 · fill --raise · code +6px, centered

Light

same tokens, swapped ground

Codes aren't boxed

That .code field is only for entering a code to join a game. The room's own code — the one the host reads out — is never in a field. It's open display text: --font-display, 54px, +14px.

Room code · open display text
RSTL

States

Validation runs on submit, never while typing. The field itself doesn't change on error — the message does the work: names show an inline .field-err, the join code shows an .err banner. Fields never disable.

Name field · error is a message, not a border

Names need at least 2 letters.

Pick a different name.

default · focus (--gold ring) · too short (<2) · blocked (profanity). Also: "Use at least two different letters."

Join code · .code field

Get the 4-letter code from your host.

Room codes are 4 letters.

uppercase · +6px · centered · exactly 4 letters. 4 but unknown → "No game found with that code."

Where word validation lives

The field checks above are for names and codes. A traced word is validated in the word pill (see Pills): under the minimum length (3, climbing to 4/5 in Survival) reads short; not-a-word or a blocklisted word reads bad; already-found reads dup. Same profanity BAD_SET keeps bad words off the board and the leaderboard.


14  Patterns

System states

Outword is realtime and multiplayer, so waiting and failure are core surfaces, not corners. Every state below ships in the app — the copy is verbatim, and it's where the Voice principles actually earn their keep.

Loading

the primary swaps its label and disables while Firebase is pending (create / join)

Offline

No connection. Playing solo works anyway — the board and the dictionary are on your phone.

multiplayer actions disable; solo stays live — board and dictionary are on-device

Errors · verbatim

Room codes are 4 letters.

No game found with that code. Double-check the letters with your host, or pick one from Public games below.

That room is full. 12 players max.

That game already started. Ask the host for a new round.

specific, actionable, never apologetic — always naming the next move

Empty

Nobody's scored yet this month

Nothing open right now. Ask your host for a code, or start your own game.

Looking for games…

leaderboard · public list (empty) · public list (loading) — each points to an action

The rule

Every waiting state disables and relabels rather than spinning a generic loader; every error is specific and names the fix; every empty state invites the next step instead of setting a mood. Nothing in this product is allowed to fail silently or block the one thing that always works — solo play.


15  Patterns

Help & recovery

Two systems from 3.18: a swipeable help deck that teaches the game in the game's own language, and a recovery code so a cleared browser never loses your name and rank.

Help sheet

Opened from the header ? — a swipeable deck across two sections: Playing and Competing. The chooser lists them with a thumbnail board that spells each name on a trace; open one and the cards demonstrate on a real mini board — the same tile and trace as gameplay — so the tutorial is the game, not a diagram of it. It auto-opens once on a true first visit, and never during a live round, where the clock would keep running behind it.

Help chooser
P
L
A
R
E
Y
O
S
T
PlayingTracing, real words, the clock. View →
C
O
M
T
E
P
E
R
S
CompetingDaily boards, the monthly climb. View →

the chooser · each thumbnail spells its section on a trace board · cards teach on real mini boards

Recovery

Identity lives under a hidden ID. The recovery code is a human-readable encoding of it — two words from a fixed list plus the raw id (otter-cobalt-a1b2c3d4) — so it round-trips exactly. It's shown as open code text, never a boxed input (the same rule as the room code). A key icon in the header is the only way in (there is no help card for it): tap it to view your code — “This code is your account — save it to get your name and rank back if you reset, or to play as you on another phone.” — or, if the ID looks wiped, it opens straight to restore.

Recovery code · view
otter-cobalt-a1b2c3d4

.rec-codebox on --raise, 1.5px --rule-strong · code 17/500 · copy · share · save image → then "Saved it"

Header key · opens the sheet

home-icon-btn (44×44), home screen only · "lost" flag routes straight to restore · restore pastes the code into a field


16  Standards

The rules, short

  1. Never hardcode a color — always a token, so it survives a mode flip.
  2. Type on --earned / --danger fills is --ground. Never white or black.
  3. Green means positive; rose means negative. One meaning each.
  4. An earned/danger element carries the hue on fill and border — never a coloured fill with a neutral --rule stroke.
  5. Pills are for one live value. Recaps use weight and ink — not pills, not green.
  6. One primary per screen; everything else is an outline. No third style.
  7. Icon buttons are 44×44 / radius 12 / --fg-soft — modal close included, no exceptions.
  8. Three elevation levels: ground, raise, lit. Depth from the tile lip, not shadows.
  9. Radius: 12 (most), 999 (pills), 14 (tiles). Past these only with a reason.
  10. Tiles never press-dip. Trace hidden on the game board; name board excepted.
  11. Respect prefers-reduced-motion on anything that moves.

17  Standards

Do & don't

The rules above, shown. Each pair is a real component rendered right and wrong — the reason matters more than the rule, because it's what lets you judge the cases this doc doesn't cover.

Do
SPARROW
Green fill, green border. The edge carries the state too.
Don't
SPARROW
Hue fill with a neutral --rule stroke reads as a mistake up close and mutes the signal.
Do
128
A pill holds one live value — a score, a clock. It signals "this is changing."
Don't
Longest word
A static label in a pill makes people wait for a number that never comes. Labels are the eyebrow's job.
Do
One primary per screen — and it's the action people take most. Most players join, so Join is filled and Create is the outline.
Don't
Two primaries is no primary — they compete, and the screen loses its answer to "what now?"
Do
PLOVER
Green appears at the moment a word scores — that's the only thing it means.
Don't
14 words found
Green in a recap stops meaning "earned" and starts meaning "green." Recaps use weight and ink.

18  Standards

Accessibility

Target: WCAG 2.1 AA — 4.5:1 for body text, 3:1 for large text (≥24px, or ≥19px bold) and non-text UI. Measured from the tokens, not asserted; the failures are disclosed below, not hidden.

PairDarkLightTarget
--fg on --ground12.70 ✓12.70 ✓4.5 body
--fg-soft on --ground6.36 ✓4.78 ✓4.5 body
--fg-faint on --ground3.19 ✕2.29 ✕4.5 body
--fg-faint on --raise2.55 ✕2.48 ✕4.5 body
--fg-soft on --raise5.07 ✓5.17 ✓4.5 body
--ground on --earned8.98 ✓5.71 ✓4.5 body
--ground on --danger8.52 ✓7.54 ✓4.5 body
--focus vs --ground5.27 ✓5.38 ✓3.0 non-text
--rule vs --ground1.65 —1.30 —3.0 non-text
Known contrast deviation · --fg-faint

--fg-faint is the faintest tertiary ink (fig captions, hints like "tap word to show on board"). It does not meet 4.5:1 in either mode, and in light mode misses even the 3:1 large-text floor. It's a real gap, flagged not buried — the fix is to darken --dk-fg-faint / --lt-fg-faint until both clear 4.5:1, then re-verify. --rule (hairlines) sits below 3:1 by design — decorative, never load-bearing, and marked "—" not "✕".

  • Hit targets — 44px on every interactive element, the modal close included. No exceptions.
  • Focus — always-visible :focus-visible ring, 2px --gold (=--fg), offset 2px; the home/mode icon button rings in --focus. Passes 3:1 non-text (5.3:1).
  • Keyboard & ATknown gap. Spelling a word is a pointer-driven swipe across tiles; there is no keyboard or assistive-tech path to trace a word today. It's a real limitation for a swipe-native game, disclosed here rather than omitted; a keyboard model (arrow-to-move, space-to-select) is the open item.
  • Reduced motion — honored throughout; the mark and tiles hold still under prefers-reduced-motion.
  • Live numbers — tabular-nums so they don't shift width as they tick.

19  Standards

Voice & content

Plain verbs, sentence case, active voice. A control says what happens and keeps the same name through the flow. Errors don't apologise and name the fix; empty states invite the next step rather than set a mood. The shipped examples live in System states.

Instead ofOutword says
"Error: room capacity exceeded"That room is full. 12 players max.
"Submit"Create game
"No data"Nobody's scored yet this month
"Network error"No connection. Playing solo works anyway…
"Invalid input"Room codes are 4 letters.
One name per thing · a gap to close

The product currently mixes game and room: the button reads "Create game," its confirm reads "Create room," and errors use both ("That game already started" · "That room is full"). That breaks the rule above. Pick one mapping — room for the shared space, game for a single round — and use it end to end. This is a product fix, flagged here so the doc doesn't paper over it.


20  System

Governance

The system has one owner and one source of truth. That's not a limitation of being small — it's the thing that keeps two years of changes from turning into a pile of one-off exceptions.

Source of truth
app.jsx at its current APP_VERSION. This document is generated from it; where they disagree, the code wins and the doc is the bug. The built app.js is output — never edited by hand except for a verified one-value string change.
Add vs. reuse
Reuse first. A pattern earns a component only once it appears in roughly three places; before that it stays local. A new token or component needs a name, a one-line reason, and an entry here — no silent additions.
Naming
Tokens are semantic, not literal — --earned, not --green; --ground, not --navy. The name says the job, so the value can change without the name lying.
Versioning
Minor for features, patch for fixes. Every release bumps four things together — APP_VERSION, app.js?v=, sw.js VERSION, and CHANGELOG.md — or the service worker serves a stale shell and the change never ships.
Deprecation
Nothing is deleted out from under a caller. Old names resolve to canonical through the alias layer and are marked "don't add new ones"; superseded artifacts (the old design-system.html) are labelled stale rather than removed until nothing points at them.
Review
One change per release, and a visual mock is approved before any UI code is written. Unrelated changes never ride along unflagged.

Recent changes

3.18
Recovery codes (readable word-word-id, view/restore sheet reached from the header key icon). The help sheet stays two sections — Playing, Competing.
3.14.6
Brand-mark motion → curated rotation of twelve legal traces (was four).
3.14.1
Name-board letters recede, so its trace line stays visible while the game board's is hidden.
3.12
Game-board trace line hidden — filled tiles carry the word; feedback moved to the pill.

21  System

Deliberately omitted

What the system refuses to build is as load-bearing as what it does. Each omission traces to a principle; adding any of these would cost more in clarity than it returns.

  • Nothemes beyond the two grounds
    Light and dark already cover it. A third theme is surface area with no job — and every extra theme is another set of contrast pairs to keep honest. (Principle 3, 4)
  • Noshadow / elevation stack
    Depth comes from the tile's inset lip and three flat surface levels — ground, raise, lit. Stacked shadows would add rendering cost and visual noise for a hierarchy that's already legible. (Principle 3, 4)
  • Nofourth hue
    Two semantic hues (earned, danger) plus one system hue (focus) is the whole palette. A genuinely new hue would start diluting meaning and break "one hue, one meaning." (Principle 2, 4)
  • Noicon library
    A handful of 20px stroked glyphs, drawn to one spec. More icons means more to misread and more to keep consistent, for a game that needs almost none. (Principle 4)
  • Nocomponent-count target
    A small, opinionated set beats a large generic one. Growth is demand-driven (the rule of three), never a roadmap goal. (Principle 4)
  • Noanimated mode switch
    Changing mode is a quiet 0.5s cross-fade, not a moment. Celebrating it would contradict low stimulation. (Principle 3)
This is a point of view, not a parts bin

Like the systems it's modelled on, this one isn't a reason to make every screen identical, and it isn't a substitute for product thinking. It removes the small decisions so the real ones get the attention.


22  Tokens

Reference

Each active role resolves, per mode, to a value in the base tier (--dk-* / --lt-*) — the only hardcoded hexes in the system. The table shows those resolved values: 28 roles × 2 modes = 56 base literals. Components reference the active role, never a literal. Only colour is tokenised; durations, spacing and radii are raw constants (see those sections). Legacy aliases resolve to canonical in one place, so nothing breaks — prefer canonical names in new work.

TokenDarkLightRole
--ground#202845#F2F0EAthe page
--raisefg .08#FAF9F6cards, fields, rows
--fg#F2F0EA#202845primary text / fill
--fg-soft#A5ABC4#5F6987secondary text
--fg-faint#6C7597#98A0B8tertiary text
--rulefg .17ink .14hairlines, outlines
--rule-strongfg .32ink .30emphasised borders
--tile#F2F0EA#202845resting tile face
--tile-ink#202845#F2F0EAletter on resting tile
--die-edge#7E828F#9496A0tile edge detail
--select#202845#F2F0EAselected tile fill
--select-ink#F2F0EA#202845letter on selected tile
--select-strokefg .5ink .5selected tile border
--lit#3D445C#D5D4D3lit surface (non-tile)
--lit-ink#F2F0EA#202845ink on lit surface
--lit-edge#2D354F#E5E3E0lit edge detail
--trace#F2F0EA#202845connecting trace line
--earned#7BE0A3#276A43positive — valid / win
--danger#F2B8BC#8E2430negative — invalid / error
--focus#7E9BE0#2F58D0focus ring
--earned-washearned .12earned .10green tint bg
--danger-washdanger .14danger .10rose tint bg
--earned-card-bgearned .06#E6EEE3earned card fill
--earned-card-borderearned .45#9FC2A9earned card border
--mark-dot#9698A0#686D7Dbrand-mark lattice dots
--shadow#0A0E22 .38ink .16drop shadow
--shadow-lift#0A0E22 .28ink .20button lift shadow
--scrim#0A0E22 .72ink .55modal scrim
Legacy aliases — resolve to canonical, don't add new ones

--tray→--ground · --tray-raise→--raise · --tray-line→--rule · --die→--tile · --ink→--tile-ink · --paper→--fg · --paper-dim→--fg-soft · --gold→--fg · --ok-green→--earned · --bad-fill→--danger · --cta→--fg · --urgent→--danger · --error-surface→--danger-wash · --error-border→--danger · --error-text→--danger · --valid→--fg