wm-primitives · the house system

Sixlaws.Everysurface.

Type, corners, circles, space, and color, each decided once and written down here — so a Glyphs plugin, a React proofing page, and wordmark.nyc round the same corners, spend the same space, and mean the same thing by a color. This is the long form: every law in one scroll, with the outline on the left. Each parameter earns its own page as the system grows.

6laws, one page
24columns, a 3px line
7apps, every push
G²corners, as drawn
WIP primitivespages to decide from →

The promise0

The package

One dial, one switch, one set of tokens, built once and proven in every app before it counts as shipped.

A dial in font-proofer, in ReCal, in opsz-proofer and in Kernpare is the same file, and a change to it reaches all four only after every one of them has been built, driven with a finger, and screenshotted against a baseline. If something here is inconsistent, that is a bug with a test missing, not a style choice.

This page is in the order you meet things. Everything it links to is the source; if this page and a linked file disagree, the file is right.

1What you get

COMPONENTS.md — every export, one line each, which apps draw it, and where its spec lives. Generated from index.ts, never hand-kept.

The ones people come for:

  • AxisSlider — the dial. One number on one axis; rail, field, stepper; four variants. Spec DIAL.md, promises GESTURES.md.
  • ThemeSwitch — Auto / Light / Dark, one engine, two looks.
  • Icon and Chevron — the one mark, the one chevron.
  • dist/dial.js — the dial for a page with no React, same source, as a script tag.
  • Tokens, in four sheets that style nothing until you use them: color.css, type.css (TYPOGRAPHY.md), space.css, motion.css.
  • The grid — grid.css + gridSnap.js: 24 columns, a 3px line, one margin. A standard, with a lint and a CI check: GRID.md.

2What you must supply

HOST-CONTRACT.md — the tokens the consuming app defines, what each is, and what breaks without it. Two have no safe fallback: without --border the rail is invisible; without a theme stamped as data-theme a dark page is black on black. Both were found by wiring a bare app and are the first two steps of the HOWTO.

3How to wire it in, and add a dial

HOWTO.md. Part A: a submodule at shared/, the peer deps, the three token sheets, the contract, data-theme, one dial rendered — ten steps, each one a trap that was hit once. Part B: a new dial design is a variant, and how the receiving app takes it.

4How to override

Every rule this package writes is inside @layer wm.*. An unlayered rule in your app beats any of them, at any specificity, first try. Theme through tokens (the house signal is --signal / --signal-ink, defaulted in color.css; define --accent to override it), choose behavior through props, and reach in with a selector only as a last resort — where the layer guarantees you win. The header of src/AxisSlider.css is the long form.

5How you know it shipped

Push to main. consumers.yml checks out every consumer, puts your commit in its shared/, runs its lint and build, drives the gestures in Chromium and WebKit, screenshots every row, and only then tells the apps to deploy. Each app's deploy then waits until wordmark.nyc serves the bundle it built. Green means live, not pushed. EVAL.md is the methodology; tests/README.md the suites.

Tags. A change to what a consumer can see — a prop, a token name in HOST-CONTRACT.md, wmDial's API, a CSS class a host is told to use — gets a tag: v0.<minor>.<patch>, minor for a contract change, patch for anything else that ships, and the CHANGELOG entry names it. The deploys build against main today (git submodule update --remote), which is why "what is live" needed its own check; a consumer that wants to stand still pins the tag instead. v0.1.0 is the contract as of 2026-09-19.

The rest

  • CHANGELOG.md — what changed and what it cost. Most entries are a fix for a fix; the wrong version is the useful part.
  • SLIDERS.md — the census. Fifty-nine sliders, none a copy.
  • BUTTONS.md — six button families to decide from, what each demonstrates and cannot do, and what none of them has had yet. None chosen.
  • NEXT.md — the checklist from here to a system a stranger is fluent in.
  • The system, illustrated — type, corners, circles, space, color as live CSS. A showing, not a consumer.

Consumed as a git submodule, not an npm package; each app's Vite/tsc compiles the TSX directly. Typed TSX; token-based CSS with fallbacks; font identity only — props carry family and axes, never proofing size.

Seven roles1

Type

Seven sizes with their leadings, every one on the 3px line, and a sixth signal, geom, that only the face can give.

By the numbers
44sizes before
7text roles
5poster steps
3inks, from four

The budget

Some changes the type requires to stay itself at a new setting. They carry no meaning and cost nothing. Others are made in order to say something, and those are the ones we ration. There are six.

Compensations
  • opsztracks the size, whatever it is.
  • trackingcaps only, one value. Size is opsz’s job.
  • leadinganswers to size and measure.
Signals
  • sizethis is a different order of thing.
  • casethis is a different kind of thing.
  • inkthis is subordinate to what surrounds it.
  • weightthis is the one to read first.
  • rulea hairline under the words. The quietest of them.
  • geoma different construction of the same letters. Cal Sans only.

One signal per distinction. Compensations ride along free.

Never ink + size + case together. Three ways of saying “minor” is not emphasis — it is a lack of confidence.

Two signals
Cal Sans

Heavier and a different hue. Two ways of saying “this is the heading” — and the pair is the habit hardest to notice, because each half looks reasonable alone.

One signal
Cal Sans

Same size, same weight, same color, same case. Only a rule beneath it. The cheapest distinction available, and the one most proof sheets already reach for.

A geometric sans with an optical size axis, drawn for interfaces at small to medium sizes.

The sixth signal: geom

Same size, weight, color, and case — the letters are simply built differently. Four landings, at 0 · 25 · 50 · 100. Each sits in the middle of a pad the font holds flat, so the landing is a plateau rather than a point — which is what makes it a decision you can return to instead of a number you happened to drag to.

The pads are the font’s, not ours: A11y holds to 10, UI runs 16–34, Base 40–60, Geo from 80 up. Inside a pad the drawing barely moves, so precision buys nothing. Between pads it moves fastest, which is why a value like 37 is the worst of both — it belongs to no landing and reads as drift.

A11yGEOM 0
agjty Il1 0O
const l1 = O0.filter(Il);

Maximum differentiation, so the characters that collide in a code string stop colliding: capital I, lowercase l, digit one; zero against O. The landing for anything a reader has to transcribe rather than read — which is why every code pill in this section is set in the Face, instead of importing a monospace to say the same thing much louder.

UIGEOM 25
agjty Il1 0O
Drawn for interfaces at small to medium sizes, and still legible after an hour of it.

The default, and the one that survives being read for a long time at a small size. It carries running copy and every piece of chrome, which is why it is the value pinned into --ui-fvs.

BaseGEOM 50
agjty Il1 0O
Annual report

Rounder construction for headings, sitting above UI copy without touching size, weight, or color. Seven letters redraw on the way here: the a at 34, then g u and the flattened extenders of f t j y at 40. Everything else on the line is the same letter it was.

GeoGEOM 100
agjty Il1 0O
Now in eleven scripts

The capitals and figures live here and nowhere else: C c M 1 5 0 hold one drawing all the way to 80, then change once. Which is why this is the landing for a capitalized eyebrow, a wordmark, or a number, and why at paragraph length it stops being a voice and starts being a costume.

A typeface in four voices
Gage City’s quick joint frugality

Four landings on one page, and not one of them announces itself. The eyebrow is Geo, the heading is Base, this paragraph is UI, and the string below is A11y.

GEOM 100 50 25 0 · Il1 0O

Geom rides along with a distinction you were making anyway. It is never the thing doing the work alone.

The whole axis, drawn

CAL SANS · THE GEOM AXIS · FOUR LANDINGS, THREE TRANSITIONS GEOM = 25 / 100 Gage City’s quick joint frugality. A11y UI Base Geo 0 10 16 34 40 60 80 100 GEOM 0–10 10–100 0–10 10–100 0–13 13–34 34–100 0–15 15–100 0–40 76–100 40–76 0–40 40–74 74–100 0–40 40–74 74–100 0–40 40–59 59–100 0–59 59–100 0–80 80–100 0–80 80–100 0–80 80–100 0–80 80–100 0–80 80–100 0–80 80–100 I I l l a a a g g f f t t t j j j y y y u u C C c c M M 1 1 5 5 0 0 25 0–10 A11y 16–34 UI 40–60 Base 80–100 Geo 15 GROUPS OF GLYPHS

Every glyph Cal Sans swaps across GEOM, and the range each swap holds for. Read down a column and you are reading one landing’s alphabet; 37 is the gap between two of them. A figure drawn to scale: its lettering belongs to the chart, not to a role.

The seven roles leading 12 · 15 · 15 · 24 · 27 · 30 · 51

Each role at its real size and leading. Every role spends size and nothing else — except label, which spends case instead and stays at chrome size in full ink. Every leading is a whole number of 3px lines, so baselines of different sizes still meet on one set of lines. Sizes are declared in rem against a 16px root, so the scale follows the reader’s own text size; we quote px because that is how type is talked about.

micro · 9 / 12 · 4 units
glyph captions · size

latin small letter a with ring above

label · 12 / 15 · 5 units
run-in words, tags, controls · case

Geometric form

ui · 12 / 15 · 5 units
all chrome · the norm

Weight 400 · Optical size auto · Reset to defaults

body · 16 / 24 · 8 units
prose, docs · the norm

The optical size axis governs how the font behaves as size changes — stroke contrast, spacing, detail. A token that carries only a pixel value throws that away.

lede · 18 / 27 · 9 units
intros · size

Tokens govern the Face. The Specimen belongs to the user.

title · 26 / 30 · 10 units
panel and page titles · size

Optical size is part of the token

display · 45 / 51 · 17 units
largest chrome · size

Face & Specimen

The poster scale

The seven roles stop at 45px — the largest size chrome has any business being. Above it is a different instrument: the monster sample. And up there the naming rule inverts. At text sizes you name the job, because size alone doesn’t tell a reader what a thing is. At display sizes the job is magnitude — 96px isn’t a different kind of thing from 136px, it is the same thing, larger. So here a ladder is honest, where in the text scale it would have been a dodge.

poster-148px · 1.05
Handgloves
poster-268px · 1.02
Optical size
poster-396px · 1.00 · 2× poster-1
Geometric
poster-4136px · 0.98
Ascender
poster-5192px · 0.96 · 2× poster-3
Mea

Ratio √2 — the paper ratio, the one the A-series is built on, so every second step doubles exactly: 48 → 96 → 192. Tailwind’s 5xl–9xl walks 1.25, 1.20, 1.33, 1.33 — four intervals pretending to be a scale.

Leading tightens as the steps climb — a compensation. Tracking does not, and that is the rule: hand-tracking display type is what you do when a font has no optical size axis. Ours has one, so the fit tightens by itself.

Set on the Face
Face

A hero headline on wordmark.nyc.

Set on the Spec
Spec

The monster sample. The scale offers a step; size, leading, and axes stay the user’s to drive. It sets setting, never the typeface — which is how one ladder serves both sides of the boundary without breaking it.

Ink

Gray is a signal, so there are three of it, not a gradient. Each is expressed as opacity on the text color, so they compose over any ground and stay honest in both themes.

--ink-full The text is the text. 1.00
--ink-quiet Subordinate: captions, annotations, spec data. 0.62
--ink-faint Structural only: rules, disabled, watermarks. 0.38

An arbitrary alpha is not an ink. All three rows above are one size, because quiet comes from ink at the size the context calls for — never from a smaller role and a lighter gray.

--signal Hue is not an ink, and not one of the six signals. #eeff41 · #96d500 · #557b00

The acid yellow on dark, and its olive on light, are doing one job: pointing at the thing under discussion — the value being explained, the word the sentence is about. It never marks hierarchy and it never means “important.” The moment a color means important, it can no longer mean anything else, and every future use of it has to fight the one before.

Why opsz is free

Both lines are set at the title size in the same font. Only the optical size differs. This is what the type does to stay itself across sizes — a compensation, not a statement. Which is precisely why it must ride inside the token and never count against the budget.

Handgloves at eight
26px · opsz 8 — drawn for small sizes: open spacing, sturdier strokes. Set large, it reads clumsy.
Handgloves at forty-five
26px · opsz 45 — drawn for display: tighter fit, finer joins. Set small, it falls apart.

The one exception: opsz pinned while the size changes is a deliberate mismatch. That is no longer a compensation. It is a signal, and it is spent.

The boundary

The one rule that keeps a type tool honest: the system dresses the interface, never the artifact under test.

Face — governed
Weight · opsz · GEOM

Chrome. One vocabulary across wordmark.nyc, ReCal and font-proofer.

Specimen — free
Weight · opsz · GEOM

The proofed font. Its size, leading, and axes are the user’s parameter space — tokenizing them would systematize the subject of the tool. Set here at the title size only to sit beside the Face.

The grid 24 columns, a 3px line

Twenty-four columns, a 3px line and one margin for every page — a standard the way InDesign has one, not a convention. Text beside text shares a first baseline, a two-line label meets its sample by its last line, a component moves whole. Everything on this page outside a stage is on the line, and on the columns too: the rail spans five of the 24, one stays empty, and the copy starts on the seventh and runs to the margin (below 1024 the rail is a bar and the copy takes all 12). A stage — a demo with type of its own, like the sheets below — keeps its own room, and is never half on the line. GRID.md is the standard in words; the bench is where it was cut.

Columns 24, and 12 below 1024

24It halves, thirds, quarters, sixths, and eighths, and the Cal Sans case study splits its sections 5:7 three times, which is 10 and 14 in whole columns. 12 below 1024, because the phone's count has to divide the desktop's: a half is still a half and a third still a third when the columns fold. The gutter is the pad scale's 24, 12 on a phone; the margin is the case study's, and it is everyone's. A card is a box on the columns, and its contents step in one column and one gutter.

1440, drawn to scale · 24 columns, the cards take 10 and 14

900, drawn to scale · 12 columns, the cards take 5 and 7

390, drawn to scale · the cards stack, and the inset stays one column and one gutter

Baseline 3px, the leading subdivided

The line is a unit every leading divides into, not a leading itself — the way 12/15 sits on a 3pt grid. Body is 16/24, eight units. Each block's first line is nudged down onto the next line, and every line after it is a whole number of units behind, so a long paragraph stays on the grid its first line was put on.

Body copy is where the grid is decided. At 24 every line of a long paragraph lands on a line. At 24.8 the first line still does — the page puts it there — and each line after it falls another 0.8px behind, until by the fourth it sits a full unit off and the paragraph beside it no longer shares a single line with this one. Only first lines are moved, and on purpose: a leading that is not whole units is a fault in the stylesheet.

Big type beside small

Set every lead in whole 3px lines, so a lead of 24, 27, or 48 is eight, nine, or sixteen of them and a block's later lines stay on the grid its first line was put on.

In a row of side-by-side text, take the smallest lead as the row's step, and size the big text so its lead is a whole multiple of it — a size one token larger, never a loosened lead. Each headline line then lands on every other caption line.

Keep one step of white around big type, put rules and marks on the step, and when a row's leads cannot divide, change the size, not the grid. The full list is in GRID.md.

A row meets side by side, one baseline

Items that start in one row have their first line moved to the lowest first baseline among them. An item can meet the row by its last line instead, so a two-line caption ends on the big word's baseline rather than hanging below it. Only the block being aligned moves; the rest of each card keeps its own snap, so text set at the bottom of a card stays on its line. A shift larger than the text it moves is refused: that is a row that matched the wrong text.

24

columns from 1024px

3px

the line

12 below 1024,
to divide 24.

A component moves whole words stay with their control

Snap a label alone and it leaves the thing it labels. The pair below sits under a block that is off the unit on purpose, so both rows start between lines and something has to move. On the left it moves only the words, and the words slide off the pink line through the thumb. On the right the row is a component, and it travels as one: words, track, and thumb stay level. Step the block above from 48 to 52px and watch which side walks.

51px above, off the unit on purpose

label snapped alone

—

component moves whole

—

Breakers and tools a page has a margin, a tool has none

A page keeps its margin; a tool has none, ever. wordmark.nyc and its Cal Sans case study are pages. font-proofer and ReCal are tools: their columns run edge to edge and the gutter stays, and a box that touches the window holds its text one gutter in from it, never flush. A breaker is the page's way out of its margin: a box that reaches the window's edge while its words stay at the margin. A picture breaker is all box.

A macOS window screenshot carries its drop shadow as a transparent margin, so the window sits inside the image and lands short of the columns. Flagged as a macOS shot, the shadow is cut out of the layout, and a file scaled after capture says by how much. Below, the window's edges sit on the lines of columns 11 and 24.

A breaker: the ground meets the window, the words stay on the margin.the padding it took back is the margin

The window, not the image, on the columns.columns 11 to 24

font-proofer's window, a macOS screenshot with its shadow

A page, drawn to scale · a breaker strip, a text breaker, and a window screenshot whose edges meet columns 11 and 24

Press for the columns, for the baseline — anywhere on the page.

G lays the house columns over the page in pink — 24 from 1024, 12 below — from the same three tokens as these stripes, so they land on them.

L draws a hairline every 3px down the page. The words beside this lead at twice this column’s lead, so each of their lines lands on every other one of these. Press again to hide.

No margin, ever — but never flush.

A tool, on this page's own columns, numbered as the page numbers them · columns to the window's edges, and text one gutter in where a box touches an edge

The favicons WM Mono, light and dark

The drawn WM over two letters. Every app’s icon is the drawn WM above its own two letters, set in WM Mono at one scale and each letter widened to its cell — mostly the square set 1.

The capitals

  • A
  • B
  • C
  • D
  • E
  • F
  • G
  • H
  • I
  • J
  • K
  • L
  • M
  • N
  • O
  • P
  • Q
  • R
  • S
  • T
  • U
  • V
  • W
  • X
  • Y
  • Z

Stylistic sets, marked by the rule above a letter: set 6 (ss06) for K M N R V W X, whose M and W are the drawn WM; set 1 (ss01) for A B C D G J O P Q S U; the rest default.

The lockups

Light and dark are the two fixed colorways, each shown on the ground it is for. A page links one file, which turns itself white under a dark scheme.

  • WMFP, light
    WMFP, dark
    lightdark
    font-prooferF default, P set 1
  • WMGD, light
    WMGD, dark
    lightdark
    GliffDiffG set 1, D set 1
  • WMOP, light
    WMOP, dark
    lightdark
    opsz-prooferO set 1, P set 1
  • WMPR, light
    WMPR, dark
    lightdark
    wm-primitivesP set 1, R set 6
  • WMWM, light
    WMWM, dark
    lightdark
    WORDMAKEW default, M set 5
  • WMCS, light
    WMCS, dark
    lightdark
    ReCalC set 1, S set 1

One curve2

The corner law

Every corner rounds the way Cal Sans is drawn, superellipse at 1.2 on a ladder of radii, and the true circles are counted so nothing else can pass for one.

A rounded rectangle meets its straight edge at a curvature break: the cap is bending at 1/r, the side at zero, and the eye reads the seam even when it cannot name it. superellipse(1.2) eases curvature to zero before the join instead — G2 continuity, which is how Cal Sans itself is drawn. The UI rounds the way the letterforms do.

The parameter is not the exponent. CSS folds superellipse(1) to round and superellipse(2) to squircle, so the real exponent is 2k — k 1.2 is ≈ 2.30, just past a circle. Scope below is ReCal only, the COSS kit excluded: of 168 corners, 87 take the law, 13 are circles carved out, and 32 sit in the frozen App.css. Live surface: 42.

k

The 5 pills 999px in situ — k comes from the toggle

zone-chipshell.css
ssd-chipStyleScopeDropdown.css
alllocal
state-tagshell.css
STOCK
preview-resetshell.css
pill-reset-btnApp.css

The radius ladder every swatch 2:1 — the proportion these radii sit on

999px12 · pill
14px4
12px1
11px2
10px10
9px6
8px16
7px9
6px10
5px6
4px14
3px7
2px10
1px4

Asymmetric corners the law applies per corner; square ones stay square

8px 0 0 8pxsegment left
0 8px 8px 0segment right
4px 4px 0 0tab top

Corners — ReCal only

The COSS demo kit is out: it was 81 of the original 168. What remains is 87 corners — 13 circles excluded, 74 taking the law, and of those 32 are in the frozen App.css. The live surface is 42.

k circles

The 13 circles excluded — every one a true circle on a square box

slider thumb
AxisSlider.css·89 · shell.css·617 · scenes.css·162
6 decl
.preview-dot
shell.css
1 decl
.mode-throb
shell.css
1 decl
.gg-geom-dot
App.css
1 decl
.vslider-thumb
App.css
1 decl
.spinner
App.css
1 decl
radio input
App.css·2197
2 decl

The 5 pills your 2:1 rule — min-width = 2 × height

zone-chipshell.css
today
2:1
ssd-chipStyleScopeDropdown.css
today
alllocal
2:1
alllocal
state-tagshell.css
today
STOCK
2:1
STOCK
preview-resetshell.css
today
2:1
pill-reset-btnApp.css
today
2:1

The button the primary and the secondary, chosen 2026-10-08

Two of the six graduated on 2026-10-08: the primary and the secondary, in src/button-sets.css. They are not a pair — each has its own place, and one panel never holds both. Beside .wm-btn rather than on top of it, so nothing that ships today moves.

The primary .wm-menu .wm-menu--row .wm-menu-list

An enclosure means chosen, not pressable. It is what font-proofer’s rail already does, and what that is, is a menu — preview modes of equal weight, picked between rather than read, which is the one place where a mark earns its space. A row has no edge at rest — a mark, a label, and the full width of the column as its target — and the ground arrives when it is the one that is on.

Already shipping · font-proofer, Preview Mode

font-proofer's Preview Mode menu, dark: Big Word, Paragraph chosen on a ground, UI, Type Scale, Glyphs font-proofer's Preview Mode menu, light

This is the primary as font-proofer already draws it (.mode-group of .mode-btn, .mode-btn-row where a panel toggle trails). Adopting the primitive there is a rename, not a redraw.

Menu

States

rest hover held focus chosen disabled

Buttons

The primary is the giveaway: it spends its enclosure on chosen, so the one action that matters can only borrow the same ground. There is no louder left to go.

Editable

measure620px
size18
tracking0.000

In situ

Headline20 ≤ char
Ours. Always Cal Sans at Base.

Screamer

And here is the family’s ceiling: chosen IS the loudest thing it owns.

What it costsA resting row states nothing on its own — and the family survives that only on two conditions it cannot enforce for itself: every row carries a mark, and every row is the full width of its column. Strip either and this becomes the unformatted list. It also needs somewhere to be a menu: the row shape wants 200px and a left edge to sit against. And because the enclosure is spent on chosen, there is nothing left to spend on primary — a destructive action here looks like every other row.

And it does not do prose. The editable mark is drawn on the text, so on a value it is four characters wide and on a wrapped sentence it is two full lines of underline. .slider-number was written for a number in a rail and it is right there.

every figure in this chapter, one theme

The secondary .wm-rule

The type is the control. No box in any state. An action is ink-full with a solid rule at 1px, thickening to the same two numbers the press law uses — 1.2 under the pointer, 1.8 held. An editable is the identical rule, dashed. Solid means it happens; dashed means it is yours. The condition is absolute: nothing else in the document may be underlined.

Buttons

No icon-only formThere is nothing to draw it in. Those actions borrow the boxed button.

States

rest hover held focus chosen disabled

Editable

Turn any page into a booking portal Track a no-show without asking who joined Reassign a booking when someone calls out

In situ

Headline20 ≤ char
Ours. Always Cal Sans at Base.

Menu

And this is where the family runs out. The rule scopes to the text, so in a row the leading mark, the label, and the trailing mark are three objects at the same height with nothing holding them together — an underline cannot reach across a flex gap, and the row never becomes one thing. Which is why a list of rows is the primary’s, never this.

Screamer

No box at any size. The rule thickens with the type instead of staying 1px.

What it costsThree things. The 44px floor is bought entirely with invisible padding, so two stacked actions are 88px of line box or they overlap. Two actions side by side read as one sentence unless something separates them. And the whole family depends on a document-wide prohibition that no component can enforce — the first underlined caption someone adds takes the affordance down with it.

Why these two, in the study’s own words.

06 for the menu, where an enclosure means chosen and the row does the pointing … Which one, Six ways to say press
The type is the control. No box in any state. … the condition is absolute: nothing else in the document may be underlined. 03 Rule, Six ways to say press

Why they never share a panel. The study paired the menu with the boxed button and named the rule the one to reject, and its worry about the pairing was one panel holding two meanings of a box at once. That objection is now the rule: a panel is a menu or it is a run of text, and it uses one set. An enclosure only ever means chosen, and a rule only ever means press, because nothing else is underlined.

All six, with every state and every cost: the primary is formerly 06 and the secondary formerly 03 in Six ways to say press.

One dial3

Interface

Five ways to draw the same dial, the icons that travel with it, and the chips, toggles, and zoom that share its grammar.

Five ways to draw one dial

The first four carry the same three controls — one with a unit, one with a tag, one with neither — so the labeling question stays visible while the shapes are compared, and they share one state. What every field here has in common is what AxisSlider ships: a single type=text field, so the value can print a real minus where a number input is stuck with a hyphen; drawn chevrons rather than the browser's stepper; and a clamp on the field as well as the track. 05 is the odd one — three numbers of one parameter rather than one number of three. Nothing here changes value on scroll.

try

Icons

Two families, and the split is not taste. Material Symbols for anything that is a symbol: it is a variable font, so an icon answers the pointer by pressing harder rather than by swapping drawings, and it themes on GRAD without moving its own metrics. The house chevron for every chevron, because Material’s opsz axis bottoms out at 20 and a dial’s stepper is six pixels tall — a glyph drawn for 20px used at 6 is the 20px drawing scaled down, stroke and all. A stroked path has no floor.

Used

Drawn by at least one app. The shipped face is a 96-name subset in fonts/, cut by scripts/cut-icon-subset.py, and lint-icons.py fails an app on a name outside it. Both lists are read from the font’s ligature table and the apps’ own sources by scripts/gen-index.mjs, never kept by hand; hover an icon to see which apps draw it. (WORDMAKE still ships the whole family for itself.) The stepper’s arrows are not here: a stepper is the house chevron, not a Material icon.

Ready

In the face, drawn by no app yet, so one can be reached for without a font rebuild. Each moves up to Used the day an app draws it.

Chevrons

One drawing, one angle (34.6°), three sizes. The stroke is derived from the width rather than fixed — optical sizing by another name: at one absolute stroke a small chevron carries more ink per unit of mark and reads heavier.

The chips

One of a set, every option visible, the chosen one filled — so the state reads at rest and nothing waits to be discovered under the pointer. ReCal’s zone chips gave the grammar (an outline in the option’s own hue, a fill for the chosen one), font-proofer’s preset bar the size. A chip chooses; .wm-btn does. Click any of these. src/chip.css.

A set. One is chosen, and data-label reserves each chip’s width, so choosing never shifts the row.

Each in its own hue, from --chip-color.

Small, over a canvas, with a switch beside it: --small and --switch.

context

The zoom control

The page’s own zoom, as a control that sits under the theme switch: zoom_out, the rail of the value-in-the-handle dial with the percentage riding in the lozenge, zoom_in, and the mark, which is the default view: press it and the page is back at 100. It applies CSS zoom to a target anchored top-left, so the target grows right and down and scrolls, nothing reflows, and type stays type. 50–400%, steps of 10, kept in localStorage. Drag the lozenge, press the marks, or focus the rail and press + − 0. src/zoomControl.js + .css; GESTURES.md §12.

The morph. One glass leaves the box as it fills and zips to the lozenge’s place; it splits into − and +, which pull the rule out to the ends; the lozenge appears as a dot and grows round 1, 10, 100, 100%. Closing folds it back. Composed from the control’s own keyframes by scripts/zoom-morph-svg.mjs: open 600ms, hold, close 300ms, hold.

The zoom control opening out of its mark and closing back into it

At rest, one mark, with data-collapse. Press it and the control opens leftwards out of it while the mark stays put and fills. Press the mark again, Escape, or anywhere else, and it folds and keeps the zoom: shut at a zoom, the value stays on show beside the filled mark. The reset is a press on the lozenge, or 0. The mark is data-icon.

Left. .wm-zoom--left in a .wm-theme-row opens leftwards over its own row, from a 27px footprint. The marks, the action, and the line below never move.

Paragraph comparison

Hamburgefonstiv

Zoomed by the control in the title row, which captures the browser’s zoom over this card (data-capture): pinch on a trackpad, or ⌘/Ctrl + − 0, with the pointer here. Elsewhere on the page the browser zooms as ever.

Stack. .wm-theme-stack rests as one mark at the right edge under the theme chips, and opens under them: one width and one right edge from one grid column. The rail’s 10rem floor makes it the wider row.

Hamburgefonstiv

A sample card, zoomed by the control above. At 200% every length in it is twice as long and every baseline twice as far down; past the card’s edge it scrolls.

The markup, mounted at DOMContentLoaded or by wmZoom.mount(el). Without data-collapse it is always open, the mark is only the reset, and it needs no stack.

Left
<span class="wm-theme-row">
  <span class="wm-zoom wm-zoom--left" data-collapse data-target="#page"></span>
  <span class="wm-theme">…the three marks…</span>
</span>
Stack
<div class="wm-theme-stack">
  <div class="wm-chip-row" role="group" aria-label="Color scheme">…Auto Light Dark…</div>
  <div class="wm-zoom" data-collapse data-icon="pageview" data-target="#page"></div>
</div>
data-min / max / step50 / 400 / 10
data-collapserest as one mark
data-open="true"start open
data-capturetake over pinch and ⌘/Ctrl + − 0 over the target, or a selector
data-keythe localStorage key, wm-zoom: "170 open"
data-iconpageview | feature_search
data-keys="local"keys only with focus inside
eventswm-zoom, detail the percent · wm-zoom-open, detail true / false · both bubble
keys+ or = in · − or - out · 0 back to 100 · arrows / Home / End on the rail · Escape folds, keeps the zoom · never with Cmd / Ctrl / Alt
the markshut: opens · open: folds, keeps the zoom · without data-collapse: back to 100
the pillpress: back to 100, it counts down and slides home · drag: drags

The theme stack, vertical

The phone’s version of the cluster: Auto, Light, Dark, and the zoom’s mark, top to bottom, fixed to the top right of the screen so it never pans with the page, floating over the text with no bar and no ground. The chosen mark is the house active mark — the PQ swatch on a dark ground (HDR white; plain white on an SDR screen), full ink on light. Opt-in dismissal, data-hide: scroll hides it on the way down and shows it on the way up (24px before a direction counts); swipe dismisses it on an upward swipe that starts on it, and a tap at the top edge or a scroll up brings it back; both are on by default. A host that covers the corner sets data-hidden (or calls wmThemeStack.hide(el, true)) and the stack stays away until it is removed. With top chrome, data-below names the last row and the stack rests under it, never on it. The zoom opens down from its mark, and a pinch on the text (data-capture, touch too) opens it and drives it instead of zooming the page. .wm-theme-stack--vertical (themeSwitch.css), src/themeStack.js; GESTURES.md §13.

ParagraphBig WordGlyphs
A Tale of Two CitiesHamlet

Hamburgefonstiv

The stack floats over this text with no bar behind it. Scroll down and it slides off the top; scroll up and it is back. Swipe up on it and it goes; a tap at the top edge returns it.

Second paragraph, so the box scrolls. Sphinx of black quartz, judge my vow; pack my box with five dozen liquor jugs.

Third paragraph. How vexingly quick daft zebras jump; the five boxing wizards jump quickly.

Fourth. Jackdaws love my big sphinx of quartz. Waltz, bad nymph, for quick jigs vex.

Fifth. Glib jocks quiz nymph to vex dwarf. Bright vixens jump; dozy fowl quack.

Sixth, the foot of the box. Quick zephyrs blow, vexing daft Jim. Two driven jocks help fax my big quiz.

.wm-zoom--down, the stack’s only layout: the rail runs down from the mark, max at the top; the lozenge does not rotate. data-capture: pinch the text (trackpad or fingers) and it opens

The markup · the class is the HOST’s to put on, under its own phone rule; CSS cannot add a class, so it is the app’s mobile branch or a matchMedia

<div class="wm-theme-stack wm-theme-stack--vertical" data-hide="scroll swipe" data-below="header .row">
  <div class="wm-theme" role="group" aria-label="Color scheme">…the three marks…</div>
  <div class="wm-zoom wm-zoom--down" data-collapse data-capture data-target="#page"></div>
</div>

const phone = matchMedia('(max-width: 768px), (pointer: coarse)')
const orient = () => stack.classList.toggle('wm-theme-stack--vertical', phone.matches)
orient(); phone.addEventListener('change', orient)

data-hide       scroll | swipe | "scroll swipe"   (none: it stays)
data-scroller   only this scroller counts (default: any vertical scroll on the page)
data-below      the host's top chrome (a selector, commas allowed): rests 6px under the LOWEST match,
                on the line, re-measured as rows come and go; nothing matching, the default 6px
--stack-top     the same by hand (a multiple of 3), for a host with no selector to give.
                Here: 90px, under the frame's two 42px rows -- a frame's rows are not the viewport's
--zoom-rail     the down rail at most (126px); it shortens itself so the box ends 12px inside the viewport
                or the data-scroller box, 45px at least
data-fit        (on the zoom or the stack) a lower box to end inside instead -- a row below the stage
                that is free under the zoom column; the lowest match wins, nothing matching falls back
data-hidden     the HOST's: hidden whatever the scroll says, until removed (a sheet over the corner)
                wmThemeStack.hide(el, true | false) toggles just that; the script's own state is data-stowed
wmThemeStack.mount(el)   for a stack rendered after load; returns .hide() .show() .hidden(), the scroll state
events          wm-theme-stack (detail: { hidden }), bubbling
marks           27px squares, 33 on touch · the zoom's mark --spacing-02 under the third
place           fixed, --spacing-02 from the top, --spacing-04 from the right, past the safe areas
the target      pans sideways in its own overflow-x: auto box, or a phone widens the viewport and the stack moves

A press that worked blinks: off for 0–80, on for 80–160, then it settles.

Two chips: one is pressed, its press look goes off, its fill comes on, and it settles; then the other

:active acknowledges a press. When it worked, the control confirms it, like a macOS menu item: its own press look off, then on, then the result. One keyframe set, one call, wmConfirm(el). GESTURES.md §14.

Every control here acknowledges first, so the off phase has something to undo: the menu row and the rule take the press stroke, the chip's edge goes to full ink over a wash, the mark steps up to its hover rung. Hold a press to see it.

primary · menu
secondary · rule
chip
mark

· slowed ×8 until then

Accent buttons AccentButton

Two acid buttons, each a pill (the Download button) or a chip (a Try-bar toggle that is on). The light is a pre-rendered, dithered image behind a near-black face, never a CSS gradient or blur, so it keeps its long falloff without banding. Q5 is underlit: a rim along the bottom edge, a spill beneath it, and a light that deepens slowly on hover while the face never brightens. Q6 to N4 rests as a pinprick of rim light and, on hover, moves the light from beneath to behind: it travels along the bottom edge, round the lower corners and up the sides, and the top edge lights all at once.

Q5 · underlit
pill · rest
pill · hover
pill · pressed
pill · disabled
chip · rest
chip · hover
chip · pressed
chip · disabled
pill · in a 264px rail · live
In your downloadBy downloading you accept the OFL 1.1

Checking which path this browser takes…

pill · JPEG XL (transparent) · live
pill · AVIF fallback (opaque, white floor) · live
JPEG XL · one light on five grounds (this theme)

This browser has no JPEG XL, so nothing is shown here: the AVIF fallback bakes its ground into the light, and on any other ground it shows as a box.

white
grey
black
gradient
checker
Q6 to N4 · the light travels
pill · rest
pill · hover
pill · pressed
pill · disabled
chip · rest
chip · hover
chip · pressed
chip · disabled
JPEG XL · pill and chip on five grounds (this theme)

This browser has no JPEG XL, so nothing is shown here: the AVIF fallback bakes its ground into the light, and on any other ground it shows as a box.

white
grey
black
gradient
checker

Disabled is unlit and neutral, a dark face with a faint hairline and a grey label, never a dimmed acid. A pressed button sits one pixel down. The pill is a full render at its own size, 225 × 32 for Q5 and 227 × 33 for Q6 to N4, so its box is fixed and the host places it. The chip is 32 tall with an 8px radius, and its light is a 9-slice of two end pieces and a stretched middle, so one set serves any width. Dark ground only: the face is --surface.

Q5 has an HDR light. On an HDR display, in the dark theme, its spill, its rim, and its label go past SDR white: each lit layer is a PQ image whose value is the light itself, floor plus glow, peaking at 589 nits, and the label is acid at that same peak, painted through the glyphs. On an SDR screen it is exactly the button above. The images bake the #1a1a1a ground they sit on. In the light theme Q5 is ink on paper: an ink face, an apple light below it, and an apple label. On an HDR display the light theme's Q5 is lit in acid instead, a broad light that peaks at 405 nits on the white ground, with an acid label at 605.

Q6 to N4 has the same HDR light, dark and light: the spill, the traveling rim, the streak, and the label, whose resting ink thins to show acid as the light comes on. Where the browser decodes JPEG XL, both looks' light is transparent, so it sits on any ground, as the five grounds show. The traveling light steps one frame at a time instead of cross-fading. Both looks show their HDR only on an HDR display.

To contain the light, give its box overflow: clip. Never a mask, overflow: hidden on a scroller, opacity, or transform on an ancestor. The pill in a rail above is ReCal's Download, at the foot of its rail.

<AccentButton look="q5" shape="pill">Download</AccentButton>, or look="q6n4" shape="chip". The images are in assets/accent/, and tools/accent/ renders them.

LookShapeBoxLight
q5pill225 × 32one 329 × 108 render, scaled on hover
q5chipany × 329-slice, 96px reference
q6n4pill227 × 3348-frame sprite, cross-faded additively
q6n4chipany × 32end pieces and a stretched rim column

WIP primitives pages to decide from

A pad scale4

Space

Twelve steps of padding read off the live surface, a cap at the radius, and a rule for when a row may spend a step.

The padding scale

Derived from the live surface, not invented: 117 padding numbers across 21 distinct values. This scale snaps 78 exactly, moves 39, and moves nothing more than 25%. Structure is the poster scale's — every second step doubles.

The steps 4 → 8 → 16 → 32 → 64, with 6 · 12 · 24 · 48 between

1pxhairline
2pxhairline
3pxhairline
4px--spacing-01
6px--spacing-02
8px--spacing-03
12px--spacing-04
16px--spacing-05
24px--spacing-06
32px--spacing-07
48px--spacing-08
64px--spacing-09

The cap rule padding-x ≥ 0.6 × radius

A rounded end is shorter near the edge, so a letter placed there has less material above and below it than the padding number implies. Blue is the height that actually exists where the text starts. At corner-k 1.2 the shape reaches 95% of its height by 27px — 0.6r, marked by the ticks. The remaining 5% costs the other 17px, which is why the floor is not the full radius.

10px padding-x 62 of 88px — 70%
padding-x
STOCK
0.6r
20px padding-x 78 of 88px — 88%
padding-x
STOCK
0.6r
28px padding-x 84 of 88px — 96% · clears
padding-x
STOCK
0.6r
40px padding-x 88 of 88px — 100% · clears
padding-x
STOCK
0.6r
pillheightradiuspadding-x
zone-chip28px14px 12pxshort 2px
ssd-chip20px10px 6pxshort 4px
state-tag20px10px 12pxclears
preview-reset24px12px 12pxclears

Use a step or change the design, not the number

The scale is a vocabulary, not a target to migrate toward. A value that is close to a step is the expensive kind of wrong: it reads as the scale to everyone who comes after, so it gets copied, and the copy is what drifts.

Do
  • Sit on a step. 4 · 6 · 8 · 12 · 16 · 24 · 32 · 48 · 64. Every second one doubles, so the scale stays legible when it is read as a series rather than a list.
  • Let steps compose. Nested padding adds up: a 4 inside a container plus an 8 on the row is the 12 the trigger already uses. Alignment falls out for free.
  • Spend the hairlines optically. 1, 2 and 3 are for a ring, a nudge, a bar — alignment, not spacing. They are outside the doubling on purpose.
  • Clear 0.6 × the radius on anything with a round cap, so the letters keep their full height where the shape is shortest.
Don’t
  • Don’t use a near-miss. 5, 7, 9, 10, 14, 20, 28, 40, and 54 all look like the scale at a glance and are not on it. Each is within 20% of a real step, which is exactly why nobody catches it.
  • Don’t tune a number to fix alignment. If two things do not line up, the sum of the nesting is wrong — a step is not.
  • Don’t pad a pill to satisfy the cap rule. Both live pills already clear it. The rule is a floor to check against, not a size to design to.
  • Don’t invent a step for one component. If nothing on the scale fits, the component is the thing to change.

The primitives and the instrument chrome already sit on the scale. Two places deliberately do not, and neither is an oversight: App.css is frozen, and the UI-kit board is a gallery of COSS components rather than house UI — nothing in it aligns against our chrome, so it keeps its own numbers.

The audit5

Color

Six hundred and ninety-two declarations, a hundred and eighty values, and the three ramps every one of them has to come from.

Color audit

Every color literal in the three style bases — 692 of them, 180 distinct values — with what each one does and where to find it.

#e8e8e828#11182728#fff26#1a1a1a20#e5e7eb20#11118#55518#38383817#44416#37415116#f3f4f616#66615#2a2a2a15#1e1e1e14

The signal

The signal marks a state or a control, never “important”. In light it is two tokens, --signal for fills (handles, dots, chosen pills) and --signal-ink for anything thin or typographic (text, rules, rings, hairlines); in dark both are the acid.

The signal, in both lights
Handle
PillChosenNot chosen
Rule, textSignalReset all axes
Dot, ringLiveApply
Tokens--signal--signal-ink

The vocabulary 68

Every token DEFINITION. These are the names everything else should be spending.

font-proofer / index.css 41

valuewhat it doespropertyselectorwhere
#242424the page ground--bg:rootindex.css
#2e2e2eraised panel fill--bg-elevated:rootindex.css
#383838hover active fill--bg-hover:rootindex.css
#3a3a3avisible hairline color--border:rootindex.css
#2f2f2fbarely-there divider--border-subtle:rootindex.css
#ffffffbody text color--text:rootindex.css
#888888secondary text color--text-muted:rootindex.css
#555555tertiary text color--text-dim:rootindex.css
#ffffffselection emphasis color--accent:rootindex.css
#ffffffbody text duplicate--ink:rootindex.css
rgba(255,255,255,0.42)secondary text alpha--muted:rootindex.css
#2b2b2bunfilled slider track--track-empty:rootindex.css
rgba(255,255,255,0.28)editing affordance bar--edit-rail:rootindex.css
rgba(var(--text-rgb)hover active fill--surface-hi:rootindex.css
rgb(... / a)hover active fill--surface-hi:rootindex.css
#f5faffthe page ground--bg[data-theme="light"]index.css
#eaf3fbraised panel fill--bg-elevated[data-theme="light"]index.css
#ddeaf6hover active fill--bg-hover[data-theme="light"]index.css
#c4d8ebvisible hairline color--border[data-theme="light"]index.css
#d4e5f3barely-there divider--border-subtle[data-theme="light"]index.css
#111111body text color--text[data-theme="light"]index.css
rgba(17,17,17,0.55)secondary text color--text-muted[data-theme="light"]index.css
rgba(17,17,17,0.35)tertiary text color--text-dim[data-theme="light"]index.css
#111111selection emphasis color--accent[data-theme="light"]index.css
#111111body text duplicate--ink[data-theme="light"]index.css
rgba(17,17,17,0.45)secondary text alpha--muted[data-theme="light"]index.css
#d4e5f3unfilled slider track--track-empty[data-theme="light"]index.css
rgba(17,17,17,0.30)editing affordance bar--edit-rail[data-theme="light"]index.css
#f5faffthe page ground--bg[data-theme="auto"]index.css
#eaf3fbraised panel fill--bg-elevated[data-theme="auto"]index.css
#ddeaf6hover active fill--bg-hover[data-theme="auto"]index.css
#c4d8ebvisible hairline color--border[data-theme="auto"]index.css
#d4e5f3barely-there divider--border-subtle[data-theme="auto"]index.css
#111111body text color--text[data-theme="auto"]index.css
rgba(17,17,17,0.55)secondary text color--text-muted[data-theme="auto"]index.css
rgba(17,17,17,0.35)tertiary text color--text-dim[data-theme="auto"]index.css
#111111selection emphasis color--accent[data-theme="auto"]index.css
#111111body text duplicate--ink[data-theme="auto"]index.css
rgba(17,17,17,0.45)secondary text alpha--muted[data-theme="auto"]index.css
#d4e5f3unfilled slider track--track-empty[data-theme="auto"]index.css
rgba(17,17,17,0.30)editing affordance bar--edit-rail[data-theme="auto"]index.css

ReCal / tokens.css 24

valuewhat it doespropertyselectorwhere
#000proofing stage black--stage-deep:roottokens.css
#0f0f0fthe page ground--bg:roottokens.css
#111the page ground--bg:roottokens.css
#1a1a1araised panel fill--surface:roottokens.css
#252525hover active fill--surface-hi:roottokens.css
#e8e8e8body text color--text:roottokens.css
#999secondary text color--text-muted:roottokens.css
#666tertiary text color--text-dim:roottokens.css
#444faintest text color--text-faint:roottokens.css
rgba(var(--text-rgb)faintest text color--text-faint:roottokens.css
rgb(... / a)faintest text color--text-faint:roottokens.css
#333visible hairline color--border:roottokens.css
#222barely-there divider--border-subtle:roottokens.css
rgba(255, 255, 255, 0.28)editing affordance bar--edit-rail:roottokens.css
#e8e8e8selection emphasis color--accent:roottokens.css
#999preview value marker--marker-preview:roottokens.css
#c97050stock crop warning--state-stock:roottokens.css
#d9a441frozen axis gold--freeze-gold:roottokens.css
rgba(217, 164, 65, .16)frozen wash tint--freeze-gold-dim:roottokens.css
#c97050GEOM a11y zone--zone-a11y:roottokens.css
#999GEOM ui zone--zone-ui:roottokens.css
#fffui zone ongrid--zone-ui-on-grid:roottokens.css
#4a7fd4GEOM base zone--zone-base:roottokens.css
#4aad5cGEOM geo zone--zone-geo:roottokens.css

ReCal / holo.css 2

valuewhat it doespropertyselectorwhere
#545454app-specific token--holo-metal-dark:rootholo.css
#b5b5b5app-specific token--holo-metal-light:rootholo.css

ReCal / shell.css 1

valuewhat it doespropertyselectorwhere
rgba(201, 112, 80, .6)app-specific token--info-color.info-panelshell.css

Literal colors outside App.css 81

A color written directly instead of through a token. This is the actionable cleanup list.

ReCal / holo.css 34

valuewhat it doespropertyselectorwhere
rgba(255,30,168,0)surface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
#ff1ea8surface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
#ffe600surface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
#21ff6asurface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
#1a3cffsurface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
#ffffffsurface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
rgba(255,30,168,.85)surface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
rgba(255,230,0,.85)surface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
rgba(26,60,255,.85)surface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
rgba(26,60,255,0)surface fillbackground-image.rail-seam--down .rail-seam-lblholo.css
#ff2e6esurface fillbackground-image.mc-wordholo.css
#ffb02esurface fillbackground-image.mc-wordholo.css
#ffe600surface fillbackground-image.mc-wordholo.css
#ff2ec4surface fillbackground-image.mc-wordholo.css
#b05cffsurface fillbackground-image.mc-wordholo.css
#5c7affsurface fillbackground-image.mc-wordholo.css
#7a8affsurface fillbackground-image.mc-wordholo.css
#ff2e6esurface fillbackground-image.mc-wordholo.css
#ff2e6esurface fillbackground-image.holo-loadingholo.css
#ffb02esurface fillbackground-image.holo-loadingholo.css
#ffe600surface fillbackground-image.holo-loadingholo.css
#ff2ec4surface fillbackground-image.holo-loadingholo.css
#b05cffsurface fillbackground-image.holo-loadingholo.css
#5c7affsurface fillbackground-image.holo-loadingholo.css
#7a8affsurface fillbackground-image.holo-loadingholo.css
#ff2e6esurface fillbackground-image.holo-loadingholo.css
#2a2a2asurface fillbackground-image.holo-loadingholo.css
#2a2a2asurface fillbackground-image.holo-loadingholo.css
#ff2e6esurface fillbackground-image.holo-onceholo.css
#ffb02esurface fillbackground-image.holo-onceholo.css
#ffe600surface fillbackground-image.holo-onceholo.css
#ff2ec4surface fillbackground-image.holo-onceholo.css
#b05cffsurface fillbackground-image.holo-onceholo.css
#5c7affsurface fillbackground-image.holo-onceholo.css

ReCal / shell.css 18

valuewhat it doespropertyselectorwhere
rgba(201, 112, 80, .85)otherfont-weight.info-hshell.css
rgba(201, 112, 80, .5)text colorcolor.info-kshell.css
rgba(201, 112, 80, .4)text colorcolor.info-arrowshell.css
rgba(201, 112, 80, .35)hairline / borderborder-bottom.info-nameinputshell.css
rgba(0, 0, 0, .45)ring or shadowbox-shadow.dock-downloadshell.css
rgba(0, 0, 0, .4)ring or shadowbox-shadow.type-panelshell.css
rgba(0, 0, 0, .5)ring or shadowbox-shadow.style-menu-listshell.css
rgba(0, 0, 0, .35)text colorcolor.style-menu-item:hover .style-badgeshell.css
#b9c4d6otherbottom.rail-seam--downshell.css
rgba(232,232,232,.3)othertop.rail-seam--upshell.css
rgba(232,232,232,.6)text colorcolor.rail-seam--up:hover:not(:disabled)shell.css
rgba(255,255,255,.2)otherposition.matrix-axis-tickshell.css
#000mask stop-webkit-mask-image.matrix-geom-shadeshell.css
#000mask stop-webkit-mask-image.matrix-geom-shadeshell.css
#000mask stopmask-image.matrix-geom-shadeshell.css
#000mask stopmask-image.matrix-geom-shadeshell.css
#fffsurface fillbackground.matrix-handle:hovershell.css
rgba(0,0,0,.4)surface fillbackground.matrix-valshell.css

primitives / UiKitBoard.css 10

valuewhat it doespropertyselectorwhere
#46c07atext colorcolor.dot-okUiKitBoard.css
#e0a23atext colorcolor.dot-okUiKitBoard.css
#e0603atext colorcolor.dot-okUiKitBoard.css
#e0603aotherdisplay.ui-alert-icUiKitBoard.css
#46c07aotherfont-size.ui-deltaUiKitBoard.css
rgba(0, 0, 0, .35)ring or shadowbox-shadow.ui-toastUiKitBoard.css
rgba(255, 255, 255, .07)surface fillbackground.ui-sk::afterUiKitBoard.css
rgba(0, 0, 0, .45)surface fillbackground.coss-loadingUiKitBoard.css
rgba(0, 0, 0, .55)surface fillbackground.ui-menuUiKitBoard.css
#e0603atext colorcolor.ui-menu-delUiKitBoard.css

ReCal / coss.css 10

valuewhat it doespropertyselectorwhere
#46c07atext colorcolor.dot-okcoss.css
#e0a23atext colorcolor.dot-okcoss.css
#e0603atext colorcolor.dot-okcoss.css
#e0603aotherdisplay.ui-alert-iccoss.css
#46c07aotherfont-size.ui-deltacoss.css
rgba(0, 0, 0, .35)ring or shadowbox-shadow.ui-toastcoss.css
rgba(255, 255, 255, .07)surface fillbackground.ui-sk::aftercoss.css
rgba(0, 0, 0, .45)surface fillbackground.coss-loadingcoss.css
rgba(0, 0, 0, .55)surface fillbackground.ui-menucoss.css
#e0603atext colorcolor.ui-menu-delcoss.css

primitives / type.css 5

valuewhat it doespropertyselectorwhere
rgba(var(--text-rgb)otherours(root)type.css
rgb(… / α)otherours(root)type.css
rgba(var(--text-rgb)text colorcolor.ink-fulltype.css
rgba(var(--text-rgb)text colorcolor.ink-quiettype.css
rgba(var(--text-rgb)text colorcolor.ink-fainttype.css

primitives / GlyphPicker.css 1

valuewhat it doespropertyselectorwhere
#111otherdark-first.gp-nameGlyphPicker.css

primitives / InlineEmphasisBubble.css 1

valuewhat it doespropertyselectorwhere
rgba(0, 0, 0, 0.35)ring or shadowbox-shadow.inline-emph-menuInlineEmphasisBubble.css

primitives / StyleScopeDropdown.css 1

valuewhat it doespropertyselectorwhere
rgba(0, 0, 0, 0.35)ring or shadowbox-shadow.ssd-listStyleScopeDropdown.css

ReCal / tokens.css 1

valuewhat it doespropertyselectorwhere
#0f0f0fothersurface:roottokens.css

The GEOM zones 3 palettes

Not in the CSS audit above, because these live in an .svg. The same four landings carry three different sets of values that have never been reconciled: ReCal's muted in-app zones, the saturated marketing hues in the GEOM map, and the lightness-lifted dark-mode set derived from them. Hue angle is close across all three; saturation and lightness are not.

landingReCal --zone-*GEOM map · lightGEOM map · dark
A11y#c9705016° 53% 55%#ff6b3516° 100% 60%#ff855716° 100% 67%
UI#9999990° 0% 60%neutral — no hueneutral — no hue
Base#4a7fd4217° 62% 56%#00a0ff202° 100% 50%#4db5ff205° 100% 65%
Geo#4aad5c131° 40% 48%#00c700120° 100% 39%#33e033120° 74% 54%

Derived shades on top of these: the zone washes at .07 opacity in light and .11 in dark (a flat tint at 7% reads on paper but vanishes when inverted onto a dark ground). Plus ReCal's --state-stock #c97050, which is the A11y orange reused for crop warnings, and --freeze-gold #d9a441 with its .16 wash.

Only three of the four landings carry a hue. UI is deliberately neutral — #999 in ReCal, the plain ink in the map — because it is the default and a default should not announce itself.

Ramps

One curve, two channels. A scrim's alpha and a blend's color are one cubic Bézier applied to two quantities: one sampler, two emitters, no second engine. The blur that used to be the third is one radius, and the page says why. Every gradient here is a string src/gradient.ts emitted; nothing is typed in, so nothing can go stale on its own.

The curve cubic-bezier(0.416, 0.657, 0.695, 1)

Two CodePens make this fade by hand: seven stops, each typed in. A cubic-bezier() fitted to those seven lands within 0.0012 of every one, RMS 0.00071 — a third of one step in 8-bit. The fit is measured, not asserted, and it means the clothoid is a preset here, not a code path: the hand-written fade and the curve are the same fade. The white dots are the eight default stops. They crowd toward the transparent end because the curve is sampled by its own parameter, not at even positions — which is where the pens' own stops crowd too.

alpha against position · dots = the engine's stops
atthe pensthe curveΔ
0.00%1.00001.0000+0.0000
50.00%0.30000.2988−0.0012
65.00%0.15000.1509+0.0009
75.50%0.07500.0747−0.0003
82.85%0.03700.0367−0.0003
88.00%0.01900.0179−0.0011
100.00%0.00000.0000+0.0000
the pens' seven stops against the fitted curve · residual per stop

The edge where a fade announces itself

A straight alpha ramp does not read as straight. Perceived lightness moves fastest at the transparent end, so a linear fade announces itself where it starts and then crawls; on live text that is a visible line across the second row of type, and it is the failure this curve was fitted to remove. The unveiled panel is the control: both scrims fade the same color over the same text across the same distance, and only the curve differs.

Hamburgefonstiv — the tail of the work goes on

past the point where the reader can still be

sure of it, which is the whole affordance: a

hard cut reads as the end of the specimen

rather than the end of what has loaded so far.

The fade is not decoration. It is the signal

that there is more, and it has to arrive without

announcing itself, or it becomes a panel laid

over the text instead of the ink thinning out.

A flat swatch forgives any curve. Type will not.

Hamburgefonstiv — the tail of the work goes on

past the point where the reader can still be

sure of it, which is the whole affordance: a

hard cut reads as the end of the specimen

rather than the end of what has loaded so far.

The fade is not decoration. It is the signal

no scrim · the control

Hamburgefonstiv — the tail of the work goes on

past the point where the reader can still be

sure of it, which is the whole affordance: a

hard cut reads as the end of the specimen

rather than the end of what has loaded so far.

The fade is not decoration. It is the signal

that there is more, and it has to arrive without

announcing itself, or it becomes a panel laid

over the text instead of the ink thinning out.

A flat swatch forgives any curve. Type will not.

Hamburgefonstiv — the tail of the work goes on

past the point where the reader can still be

sure of it, which is the whole affordance: a

hard cut reads as the end of the specimen

rather than the end of what has loaded so far.

The fade is not decoration. It is the signal

linear · the line shows on row two

Hamburgefonstiv — the tail of the work goes on

past the point where the reader can still be

sure of it, which is the whole affordance: a

hard cut reads as the end of the specimen

rather than the end of what has loaded so far.

The fade is not decoration. It is the signal

that there is more, and it has to arrive without

announcing itself, or it becomes a panel laid

over the text instead of the ink thinning out.

A flat swatch forgives any curve. Type will not.

Hamburgefonstiv — the tail of the work goes on

past the point where the reader can still be

sure of it, which is the whole affordance: a

hard cut reads as the end of the specimen

rather than the end of what has loaded so far.

The fade is not decoration. It is the signal

clothoid · the default
8

Where you mix the midpoint, four spaces

The received wisdom is that oklab rescues a gradient from the gray sRGB puts at its midpoint. It does not. For the pair above, the midpoint's saturation is 11% in sRGB and 14% in oklab: a straight line between opposite hues crosses the neutral axis in any rectangular space, because that is where the axis is. What oklab buys is even lightness — the red→green midpoint sits at luma 111 against sRGB's 101, so the ramp no longer dips dark in the middle. Only oklch holds chroma, 63% at the same midpoint, by treating hue as an angle and going around the axis instead of through it; it pays with a hue nobody picked. The default stays oklab, the predictable one. The first draft of this page had the claim the wrong way round.

srgb
oklab
oklch
lab

Steering a channel Mass Driver's schema

One curve on the interpolation re-spaces the stops along a fixed path through color space. A curve per channel moves the path. Left: the same two colors with one channel steered at a time — the ramp leaves the straight line between its endpoints, which is how a ramp escapes sRGB's gray without changing space. Right: the three channels of #F65030 → #3050F6, each on its own axis. R falls, B rises, and G does not move. That flat channel is why the pair never grays, and why steering G on it does nothing at all. channelBlend() reproduces Mass Driver's published output byte for byte; the equality is a test.

none
R steered
G steered
B steered
R 246 → 48
G 80 → 80
B 48 → 246

The blur one radius, not a ramp

One backdrop-filter, one radius, nothing masked. This chapter was a progressive blur until 2026-09-20 and every way of building one over live text failed the same way. Hard bands: no ghost, but the radius jumps at each boundary and the staircase shows. Feathered bands: no steps, but at partial mask alpha the compositor blends the blurred copy over the still-sharp original — when it was tried, one full-cover layer at half alpha measured a peak edge of 108 against 217 unblurred and 19 fully blurred, so half the sharp text survives in every feather zone and the words double. That construction is not on this page any more; the figures are kept with their method so the decision stays checkable. Content copies: the same defect, measured worse. A stepless blur that varies down the page needs the text rasterized to a canvas, which costs the live text, and live text is the point. So: a uniform blur, whose row-to-row sharpness change across the panel below is 0.002, and a slider on its radius. The first draft of this chapter blamed the mask for smearing the whole panel; the mask was never broken. The page's corner-shape: superellipse drops a mask on any layer whose host has a non-round corner, and only the host, which is why resetting the layers changed nothing. Found by lifting the panel up its ancestors until masking worked.

Hamburgefonstiv — the tail of the work goes on

past the point where the reader can still be

sure of it, which is the whole affordance: a

hard cut reads as the end of the specimen

rather than the end of what has loaded so far.

The fade is not decoration. It is the signal

that there is more, and it has to arrive without

announcing itself, or it becomes a panel laid

over the text instead of the ink thinning out.

A flat swatch forgives any curve. Type will not.

untouched · the reference

Hamburgefonstiv — the tail of the work goes on

past the point where the reader can still be

sure of it, which is the whole affordance: a

hard cut reads as the end of the specimen

rather than the end of what has loaded so far.

The fade is not decoration. It is the signal

that there is more, and it has to arrive without

announcing itself, or it becomes a panel laid

over the text instead of the ink thinning out.

A flat swatch forgives any curve. Type will not.

the same lines, one uniform backdrop-filter · nothing masked, so nothing to ghost or step
3

It blurs pixels. It does not redact. The words stay in the DOM — selectable, copyable, findable, and read aloud in full by a screen reader, which sees no blur. Never use it to withhold anything.

Banding 8-bit, and what no curve can fix

Eight bits give 256 levels of alpha, and one step between neighbors is about the same faint edge wherever it sits — 0.365 ΔL* at the dark end of the rows below, 0.351 at the light end — and one edge that size is under what an eye resolves. Banding is not the edge. It is the repetition: steps close enough to read as a pattern, far enough apart not to fuse. Visibility peaks; it is not a slope. The control crosses 217 levels at 5px each and fuses into a smooth ramp. The dark fade crosses 76 at 14px each and bands unmistakably. Push the same steps out to 155px apart, as the previous draft of this chapter did, and the field goes flat and the edges vanish with it. More stops cannot help: an 8-stop ramp and a 2-stop ramp quantize to the same levels and band identically, and nothing in CSS asks for more output bits. Tone matters on its own: the third row has the same 76 levels at the same 14px and a per-step ΔL* within a twentieth of the dark row's, and it is clean. CIELAB is uniform for small patches, not for a one-level edge run across a wide smooth field, which is why every banding complaint is about the dark end.

Mostly you do not need it. Skia dithers a background-image gradient and very nearly does not dither a mask-image: the same ramp measures a per-pixel deviation of 2.98 as a background against 0.22 as a mask. So a scrim is dithered for you, a mask over a flat ground is not, and that mask is the one place in this package that bands. .wm-dither goes after the quantizer, on the wrapper: inside the masked element the noise modulated the ink before the mask touched it and measurably did nothing. After the quantizer is also its limit. It composites over the steps rather than preventing them, so it breaks the widest identical run from 35px to 23px and hides the edge, but the one-level step survives in the column mean, 1.29 against the mask's 0.99, and turning it up adds noise faster than it removes step. The background row is the one that halves the step itself, to 0.49, because Skia dithers before it quantizes. The first draft of this chapter said dither removes the staircase; the second said wide terraces are what make it visible. Both were wrong, and both were measured wrong before they were written wrong. Nothing here makes a 76-level fade at this density disappear. The fix that works is a ramp that fuses or a scrim Skia dithers, not a mask across a wide dark field. Judge this chapter on a real display at 100%: a downscaled screenshot averages the steps away.

the control · alpha 0 → 1 · 217 levels at 5px · too dense to separate · fuses
the dark fade as a mask over flat ground · 76 levels at 14px · column step 0.99 · bands
the same 76 levels at the same 14px, at the light end · ΔL* per step 0.351 against the dark row's 0.365 · clean
the same endpoints as a background-image · Skia dithers before it quantizes · the step itself halves, to 0.49
the mask with .wm-dither on the wrapper · widest run 35px → 23px, the step survives at 1.29 · hidden, not removed
.18

Three durations6

Motion

Three durations and one curve, so a change reads as the same gesture wherever it happens, and the confirm that proves a press landed.

The durations motion.css

Every transition reads a token; a duration spelled out in a rule is a lint failure. The values are ReCal’s, written down in its spec, not the fallbacks that had accumulated beside them.

TokenValueFor
--dur-fast140msa state change on one element: hover, press, focus, a color
--dur-med240mssomething resizing or revealing: a collapse, a panel
--dur-layout300msa large surface moving: the sidebar, the mobile sheet
--dur-slow400mssomething crossing the screen
--dur-hint3snot motion: how long a one-time hint stays
--dur-confirm80msone phase of the confirm; a blink, so off the ladder

Three stepsThree steps carry nearly everything. A mark’s rollover runs at --icon-dur, 90ms, under --dur-fast on purpose: at 140 the mark lagged the row it sits in, and a rollover should feel like the pointer, not an animation about it. A state change is fast, 140. A reveal is medium, 240.

The curve --ease

One curve, cubic-bezier(0.2, 0.7, 0.2, 1), the Cal Sans case study tester’s unlock curve: quick off the mark, long settle. It was typed out three times with a comment calling it the house easing; it is a token now, --ease in motion.css, and the three read it.

ReaderDurationWhat moves
zoomControl.js, the morph600 / 300msthe zoom control opening out of its mark, and closing
zoomControl.js, the reset--dur-medthe lozenge sliding home to 100
themeSwitch.css, the stow180msthe vertical theme stack leaving the top edge

Acknowledge and confirm GESTURES §14

A press is answered twice. :active acknowledges it while the pointer is down. When the press worked, the control’s script adds .wm-confirm: its press look goes off for 80ms, on for 80ms, then the control settles into its result. A blink, steps(1), paint only: no box moves.

A chip is chosen and confirms: its fill blinks off, then on, then it settles
t--confirm-offWhat shows
0–80ms1the press look off: the control’s rest paint
80–160ms0the press look on
160ms—the class comes off; the control is in its result state

The morph GESTURES §12

The zoom control opens leftwards out of its mark in one 600ms timeline, 2.5 × --dur-med, and closes in 300, the open folded back. Layout jumps to the end state at once; only the drawing travels.

The zoom control opening out of its mark and closing back into it

No reflowThe morph animates three properties and nothing else, so no frame reflows the page: a magnifier leaves the mark as it fills, zips to where the lozenge will sit, and the rule opens from it by its clip. The theme stack’s stow is a translate, the confirm a custom property that only paint reads.

Reduced motion prefers-reduced-motion: reduce

Every file that moves carries its own rule, and each says the same thing: the end state, at once.

WhereWith reduced motion
motion.css, confirm.jsno class, no blink: straight to the result (G85)
zoomControl.jsthe two states swapped with no morph (G68); the reset jumps (G72)
themeSwitch.cssthe stack stows and returns without the translate
collapse.cssthe box opens and shuts without its height transition
icon, chip, button, chevron, slider CSStheir transitions off

Appendix

Which app draws which control, read from the source, so a change knows every place it lands before it ships.