# Design system

Every value on the page comes from a token in the `:root` block at the top of
`report.css`. `build.py` writes one small block ahead of it that sets the five
theme colors from `report.json` and nothing else. If you want to change how the
report looks, change a token. If you find yourself typing a raw size or color
into a rule, the system is missing a token and the rule is a bug.

## Color

The theme carries five colors: `--paper`, `--ink`, `--accent`, `--secondary`,
`--wash`. Everything else is mixed from them with `color-mix()` in oklab, so a
new theme moves the whole page and never leaves a stranded gray behind.

| Token | Mix | Default | On paper | Use |
|---|---|---|---|---|
| `--ink-soft` | ink 80% on paper | `#4d4c47` | 7.76:1 | standfirst, secondary reading text |
| `--muted` | ink 62% on paper | `#706f69` | 4.55:1 | labels, captions, footnotes, byline date, the stage progress fill |
| `--rule` | ink 16% on paper | `#d2cfc8` | 1.40:1 | quiet hairlines and dividers |
| `--rule-strong` | ink 52% on paper | `#84837d` | 3.43:1 | any edge that is a control's only affordance |
| `--card-border` | ink 28% on paper | `#b7b5ae` | 1.85:1 | card and panel borders |
| `--line` | secondary 70% with ink | `#3e5548` | 7.29:1 | connection lines in the artwork |
| `--ink-strong` | ink 88% with black | `#21211d` | 14.57:1 | primary button on hover |
| `--focus` | accent | `#a33e36` | 5.74:1 | focus ring |
| `--select` | accent 22% on paper | `#e7cbc1` | 9.53:1 behind ink | `::selection` |
| `--scrim` | paper 70%, transparent | | | veil behind the contents panel |

Text holds 4.5:1 or better everywhere, captions included. `--rule` is meant to
be quiet, not invisible; anything a reader can click or tab to gets
`--rule-strong` so its boundary clears 3:1. The methods band sits on `--wash`,
so it redefines `--muted` locally as ink 66% on wash, which keeps that text at
4.81:1 on its own background.

`--rule-strong` is the border of anything a reader can click, and it holds
3.43:1 on paper. It is not the stage progress fill: that fill sits on `--wash`
rather than on paper, where the same mix reads 2.99:1, so the fill takes
`--muted` and holds 3.96:1 against its own track.

The ruling inside a drawing is ink at 16 percent, the same value `--rule`
carries, so the grid under the marks reads as ruled paper rather than as a
haze. It dissolves under a radial mask instead of stopping at a rectangle.

Every page declares `color-scheme: light`, so a reader whose system is set to
dark gets scrollbars and form chrome that match the paper instead of chrome
painted for a theme the page does not have.

Color-mix in oklab is required. That means a 2023 or newer browser. The icon is
the one color that is not mixed by the stylesheet, because it is a file rather
than a rule; `build.py` mixes it in oklab itself, so it lands on the same value
a `color-mix()` would give.

## Type

Two families, no third. `--font-serif` reads: body, headings, the quote.
`--font-sans` labels: header chrome, buttons, nav, eyebrows, captions, byline,
step card headings, footnotes. `--font-numeral` is Times, used only for large
figures because it has lining numerals that share a baseline; it always runs
with `font-variant-numeric: lining-nums tabular-nums`.

`--font-code` is the one exception and it is not used by a report at all. The
guide prints command lines a reader has to transcribe exactly, and a
proportional face makes a flag, a path and a line continuation hard to read, so
`pre` and `code` on that page run in a mono stack.

| Token | Size | Line height | Role |
|---|---|---|---|
| `--t-1` | 13px | `--lh-label` 1.5 | eyebrows, labels, captions, controls, footer |
| `--t0` | 15px | `--lh-label` | contents rows, small copy |
| `--t1` | 17px | `--lh-body` 1.55 | body, step card headings |
| `--t2` | 22px | `--lh-subhead` 1.45 | running subheads, `h3`, brand mark |
| `--t3` | 28px | `--lh-title` 1.29 | section titles, quote |
| `--t4` | 40px to 61px | `--lh-display` 1.2 | cover title, findings numerals |

Six steps, and only two things on the page are allowed to be large: the cover
title and the findings numerals. Nothing is uppercase. Labels take
`--track-label` at 0.01em; display type takes normal tracking, because the
serif already closes up at size. Measure is `--reading`, 30em of the body size,
so it follows the type instead of guessing at it. The target is 60 to 72
characters a line on the named faces: Iowan Old Style and Palatino Linotype
hold about 60, Georgia and Charter about 68. A reader who has none of them
installed falls through to Times, which is narrower than every named face and
runs to about 73. The fixed 640px this replaced was borrowed from a wider face
and held 93.

## Space

`--s1` 4px, `--s2` 8px, `--s3` 12px, `--s4` 16px, `--s5` 24px, `--s6` 32px,
`--s7` 48px, `--s9` 96px, `--s10` 128px. The scale keeps its numbering with 64px
missing, so the day something needs it, `--s8` goes back in its place and no
other name moves. Paragraph gap is `--s5`, heading to body is `--s6`, a section
heading clears its section by `--s7`.

Three lengths sit off that scale because they are the size of one thing:
`--quote-rule` 40px opens a pull quote, `--card-w` 416px is the step card and
the column it sits in, and `--panel-w` is the contents panel. `--band-skirt` is
`--s7` under another name: the soft edge below the phone band, which the step
slots clear as well as the band itself.

## Grid

One gutter and three role widths. A role always uses its own width, so the page
reads as disciplined without every element sharing one left edge.

| Token | Value | Role | Left edge at 1440 | At 390 |
|---|---|---|---|---|
| `--wrapper` | 1400px | header, cover, notice, footer | 84px | 32px |
| `--figure` | 1092px | scrolly stage, findings grid | 174px | 32px |
| `--reading` | 30em, 510px at 17px | prose, quote, cover deck, section headings that open prose | 465px | 32px |
| `--gutter` | `clamp(32px, 8vw - 24px, 64px)` | page margin | 64px | 32px |

A section heading takes the width of what it opens, not a width of its own: it
is on `--reading` over prose and on `--figure` over the findings grid and the
scrolly, so a heading always starts on the left edge of the block under it. The
methods band is two columns inside `--figure`, the note on `--reading` and the
citations beside it.

`--pad-wrapper` is the one formula that puts a full-bleed element on the
wrapper: it is the gutter, or half the space outside 1400px plus the gutter,
whichever is larger. The contents panel uses the same value for its right edge,
so it lands on the header's own right edge.

Section rhythm is flat: `--section` gives every section
`clamp(40px, 5vw, 64px)` top and bottom, whatever it holds. The two bookends,
the cover and the footer, use `--bookend`, which runs to 128px on a tall
screen and backs off on a short one. `--header` is 68px and feeds the sticky
header, the stage offset and `scroll-padding-top`, so those three can never
drift apart. `--cover-art` caps the cover drawing at 48svh so the whole cover,
scroll cue included, stays in one screen.

`--band` is the height of the pinned stage once it becomes a band,
`clamp(220px, 38svh, 320px)`, and the step slots offset their cards by it.
`--reading-print` is the measure on paper, 27em of the printed body size. It is
not a fourth role width; nothing on screen uses it.

Smooth scrolling is off. A contents jump can cross thousands of pixels, and
animating that runs the reader through every scrolly state on the way, so
jumps are instant and `scroll-padding-top` puts the landing under the header.

## Controls, states and hit targets

One control component: `.button` and `.control` share centered content,
`--radius-control` 6px, a `--rule-strong` border, sans at `--t-1`, and
`--tap` 44px of minimum height. Cards and panels use `--radius-card` 8px.
Those are the only two radii.

Two links are the size of their own type and cannot take that height: the
brand in the header, 22px, and the template link in the notice, 20px. Each
carries its hit area behind it in a `::before` inset by half of what the link
is missing, `calc((var(--tap) - 100%) / -2)` top and bottom. The target is
44px at every width; the type and the focus ring stay the size the reader
sees.

Hover has two patterns and no third. Text links move `text-decoration-color`
from `--rule-strong` to `--accent` over `--dur-hover` 140ms. Blocks, meaning
buttons, contents rows and the contents summary, fill with `--wash`; buttons
add a 1px lift over `--dur-control` 160ms and contents rows add a 2px accent
inset rule.

Focus is one rule everywhere: `--focus-width` 2px solid `--focus` at
`--focus-offset` 3px. The ring follows each element's own radius, so it never
cuts a corner or overlaps a neighboring row.

Every section heading is a link to its own section, so a reader can take the
address of the part they are reading. The link wraps the title rather than
sitting beside it as a glyph: a permalink beside the title contributes its own
name to the heading's, and every heading would then be announced with four
words that are not the heading. It carries no decoration at rest, so a heading
still reads as a heading, and the underline arrives on hover in the accent the
way it does on every other text link. The same `::before` hit box sits behind
it, so a one line heading is a full `--tap` target without a 44px line box.

## Motion

Two curves. `--ease` `cubic-bezier(.32,.72,0,1)` carries every arrival and
reflow. `--ease-draw` `cubic-bezier(.33,1,.68,1)` carries anything that draws
or erases a stroke. Durations are `--dur-hover` 140ms for pointer feedback,
`--dur-control` 160ms for buttons, `--dur-panel` 180ms for the contents panel.

Reduced motion and the paused state run the same code path: transitions and
animations drop to `.01ms` with no delay instead of being removed, so every end
state is still correct and nothing has to branch. The media query is gated on
`:root:not(.motion-on)`, which report.js sets when the reader turns motion back
on with the header control. Without that gate the control would flip its own
label and change nothing, which is worse than not offering it.

The stage has its own durations, and they are tokens like everything else:
`--dur-travel` 620ms for a mark moving between layouts, `--dur-draw` 240ms for
one stroke or one line being drawn, `--dur-erase` 140ms for lines clearing out,
`--dur-color` 340ms for a mark taking or releasing the accent, `--dur-ring`
380ms for the orbit ring and its label, `--dur-progress` 300ms for the progress
rule and `--dur-caption` 320ms for the caption. `--draw-start` 434ms is when a
new line starts drawing, about seventy percent of the way through the travel,
so the lines arrive with the marks rather than after them. Per-element timing
rides on custom properties build.py or report.js writes: `--md` is a mark's own
travel delay, `--d` a line's draw delay, `--ring-delay` holds the ring back
until the lines finish. The stagger those last two come from is stated once, in
tokens: `--line-delay-first` 14ms and `--line-delay-last` 110ms across the lines
of one state, `--intro-mark-step` 30ms and `--intro-stroke-step` 12ms across the
marks and the strokes of the cover intro. report.js reads all of them, and every
duration it waits on, out of the stylesheet at startup, so a timing cannot be
changed in one file and left behind in the other.

The intro draws each stroke with a dash of one length. Strokes are not the same
length, so every stroke carries `pathLength` from build.py: the dash is then the
stroke, whatever its own arc length is, and nothing is left drawn short. The
delay is one rule over `--i`, the mark's index, and `--j`, the stroke's index in
its mark, both written into the markup, so a mark of any stroke count staggers
without a rule of its own.

Only `transform`, `opacity`, `stroke-dashoffset` and `stroke` ever animate. The
progress rule changes width with `scaleX`, not `width`, so no transition on the
page can force a layout.

## The scrolly

The stage is `position: sticky; top: 0; height: 100svh` with `padding-top` of
`--header` plus 4px, so the pinned frame clears the header. `--stage-h-safe` is
the viewport minus the header, written by report.js at load and on a width
change; the drawing's width is capped by it, so the art always fits the frame.

The steps column is led in by one stage height and pulled back by half a step
(`padding-top: 100svh; margin-top: -56svh`), each slot is `112svh` with its card
centered, and an `80svh` tail follows the last one. One passive scroll listener,
throttled with `requestAnimationFrame`, makes active the last card whose top has
passed a line at 0.88 of the viewport height; one `IntersectionObserver` at
`rootMargin: "25% 0px"` decides whether that listener does any work. Resize
recomputes on a width change only, so a mobile browser hiding its own chrome
cannot fire a state change.

Each step card carries the caption of the state it fires, as visually hidden
text. That copy is the only description of the drawing in the document: the
stage's own `figcaption` is the sighted duplicate and is hidden from assistive
technology, so hiding this one too would leave a screen reader with no idea what
the drawing shows. It is hidden from the eye rather than from the page, in
reading order, inside the card it belongs to.

With scripting off there is no state to show and nothing to pin, so the scrolly
is not a staged column at all: the card's caption is shown, `.scrolly` stops
being a grid, the drawing sits
once at `--figure` under the same `--cover-art` cap the cover uses, and the
steps run down the `--reading` column in the order they are written. The lead-in,
the slots, the tail, the progress rule and the stage caption all go. A card
keeps no chrome but a hairline above it, because nothing here can become
active.

## The contents panel

The panel is a native `<details>`, so it opens with scripting off. `initContents`
in `report.js` adds four things and nothing else: it marks the reader's current
section with `aria-current` when the panel opens, closes on a click outside or
on the scrim, closes on Escape and returns focus to the summary, and moves
focus to the landing section after a jump. A click on the scrim reports the
`<details>` itself as the event target, which is how the handler tells a scrim
click from a click on the panel. Quote sections carry no title and are left out
of the contents.

## Sections

The findings grid is `--figure` wide and takes its column count from the item
count: one item fills the row, two or four sit as a pair, and everything else
runs three across. A numeral is one line of `--font-numeral` at `--t4` with
`--lh-numeral`, so a row of statistics shares one baseline. The optional `unit`
sits under the numeral and the optional `note` under the text, both sans at
`--t-1` in `--muted`. Neither is emitted when the author leaves it out.

The quote sits on the reading column, left aligned, opened by `--quote-rule` in
the accent. Its attribution is a paragraph with a `<cite>` in it, not a
`<footer>`: a blockquote is not sectioning content, so a footer inside one maps
to a second contentinfo landmark and the page reports two. That rule is a border and not a background, so it survives a
printer with background graphics turned off. The column is its only cap: at
`--t3` it holds under 40 characters a line, which is already display measure,
and `text-wrap: balance` evens the lines out.

Sources are a citation list. `counter-reset` numbers them with a leading zero
in sans tabular figures in the accent, each row opens with a `--rule` hairline,
the label is the link and is `--tap` tall, and `build.py` writes the host of
the URL under it so a reader can see where a citation points before following
it. The band redefines `--muted` against `--wash`, so that host still holds
4.5:1.

The methods band is the one section with two columns: the note on `--reading`
and the citation list beside it, both inside `--figure`. One column of prose
centered in a full bleed tint leaves a third of the band empty on each side.
Below 1000px the two columns become one.

The footer is a colophon: organization, series and date on the left, the
canonical URL and the two controls on the right, both on `--wrapper`.

## Responsive

Two breakpoints, each with one job.

At 1000px the stage stops being a column and becomes a band: `--band` tall,
sticky under the header, full bleed across the section, with the drawing, the
caption and the progress rule inside it. Below 1000px a stage column and a
readable card cannot share a row. The steps column loses its lead-in and its
tail, each slot becomes one screen at `100svh`, and every card is pushed down
by the header plus `--band`, so it rests in the middle of what the band leaves
rather than in the middle of the slot. That is the difference between a card
the reader can read whole and a card cut in half by the band. The trigger does
not change: the active step is still the last card whose top has passed 0.88 of
the viewport.

At 760px the page itself stacks. The cover goes to one column with the copy
first, the findings grid goes to one column, the footer columns become one, and
the step card padding drops to `--s4`.

At 420px the header sheds the organization name and the motion label, and the
contents panel runs full width between the gutters.

The step card never grows past `min(416px, 100%)` at any width. On the band
layout it centers instead of widening, so a card is one width everywhere and
the band above it always overhangs it.

Nothing scrolls sideways from 1440 down to 320, and nothing interactive is
shorter than `--tap`.

## Print

Paper is white and ink is black. The print block redefines `--paper`, `--ink`
and `--wash`, and everything derived from them follows, so the report prints as
a document rather than as a picture of a screen. `--rule` is mixed harder for
paper, at 30 percent, because a 16 percent hairline does not survive a printer.

What goes away: the grain, the skip link, the header controls, the progress
rule, the cover buttons and footnotes, the whole sticky stage, the back to top
link and the print button. Nothing new arrives; everything printed is already
in the document.

The header keeps the brand and its hairline and hands the title to the cover
under it. The footer keeps the colophon and the canonical URL, which is what a
printed copy is passed around with.

The scrolly cannot scroll, so `report.js` builds one static copy of the stage
per step at load time, into the `.print-figure` container `build.py` emits, and
lays each one out with the same function the screen uses. The copies sit two by
two above the step texts and carry the captions, so the card's own caption is
hidden and no caption prints twice. Two by two rather than four across because
at a quarter of the text block a state is too small to read and the word inside
the orbit ring disappears. A copy cannot measure its own label, since
`getComputedTextLength` returns 0 inside a hidden container, so it takes the fit
the live stage worked out. Each copy renames its gradient
and its mask so several drawings can share a page without sharing an id, and
the print block turns off the line drawing animation, which never runs inside a
hidden container and would otherwise print as blank paper.

Measure on paper is `--reading-print`, 27em, which holds the longest line at 68
characters with the narrowest serif. The printed body is 11pt and `@page`
margins are 18mm top and bottom and 26mm at the sides, which puts the text block
at about 620px: the prose column is then two thirds of it rather than half, and
the findings row and the small multiples fill the rest. The measure cannot grow
to meet them without passing 68 characters, so the sheet keeps two widths on one
left edge. Every printed block starts at that margin, the step blocks included,
which open with a hairline the way a finding does rather than with an indent. A
label, a heading and the block under it never split across sheets, and a
findings row, a step and a quote never break inside.

One trap to remember: a printed page is about 816px wide, so the 1000px rules
apply on paper as well. The print block overrides each rule it cares about
instead of trying to switch the query off.

## The icon

`favicon.svg` is the kit's only icon and its drawing: three accent strokes on a
paper tile, the cover mark reduced to one shape. Three, not five, because a
16px tile at 1x turns five 2.5 unit strokes into 1.25px of ink with 1.5px
between them; three strokes at 3.2 units land on 1.6px of ink with 3.1px
between them, which survives the reduction.

The file is drawn in the default palette and is the source, not the output.
`build.py` paints the report's own theme into it at build time, swapping paper,
ink and accent for the theme's, and mixing the dark scheme stroke from the
theme's own accent and paper. The default export carries the painted icon as a
`data:` URI, so it still opens from disk with its icon. The hosted export links
`favicon.svg` beside `report.css`, but only while the theme is the palette that
file is drawn in: one shared icon cannot be five palettes, so a themed report
carries its own in the page. A build with no `favicon.svg` next to `build.py`
stops instead of shipping a page with no icon, and so does one whose icon no
longer carries the colors `build.py` knows how to swap.

The tile turns to ink under `prefers-color-scheme: dark`, and the strokes lift
with it. The accent alone holds 2.29:1 on that tile and goes muddy at 16px, so
the dark stroke is the accent with 40 percent paper mixed into it. On the
default theme that is `#c9877c`, which holds 5.03:1 and still reads as the
accent.

## The guide

`guide.html` is generated, like the report. `guide.template.html` is its source
and holds only the copy and the guide's own layout; every value that comes from
`report.json` or from the generator is a `{{name}}` placeholder that
`render_guide()` in `build.py` fills: the theme block, the icon, the hero
drawing, the three mark specimens, the palette with its hexes and roles, the
organization, the date, the canonical URL, the social image, the mark count and
the layout names. `render_guide()` refuses to build if the template asks for a
value it does not have, or if it holds a value the template does not use, and a
test compares the committed `guide.html` against a fresh render.

That is the point of the page. The kit's promise is that five colors in a JSON
file drive everything, and the page a visitor sees first is the first place that
promise can be false. It links `report.css` and `report.js`, so the specimen
lays out and draws in exactly as the cover does, and with scripts off it shows
the same ring the report shows.

## Do not

Do not add a font family, a radius, or a third easing curve. `--font-code` is
the exception already taken, and it is for the guide's code blocks only.
Do not type a raw hex, px size, or duration into a rule; add or reuse a token.
Do not add a fourth content width, or center something at a width no role owns.
Do not give a heading a width the block under it does not have.
Do not use uppercase labels, letter-spaced display type, or a drop cap.
Do not put muted text on `--wash` without the local override; it drops below 4.5:1.
Do not give an interactive edge `--rule`; it needs `--rule-strong`.
Do not make anything interactive shorter than `--tap`.
Do not turn on global smooth scrolling.
