# Accessibility

Stage 13, step 4.

**Nothing here is new.** Accessibility was built into this product at stages 08 to 11 and it already works. What did not exist until now is a way for somebody who did not build it to find where it lives and check that it still holds. That is all this file is: the point, the place in the code, the way to test it in a minute, and a status.

**Reader: the new developer**, who will add a screen and needs to know what their own work has to satisfy. **Second reader: Claude in a new session**, for the same reason.

---

## 0. Two rules about the status column

**The status has exactly three values.** `confirmed` means an instrument was run **for this file, at this stage**, and its output is quoted below. It never means "stage 08 did this and I remember". `debt` means the check was run, it failed, and the failure is a row in `design/kit/docs/backlog.md` rather than a fix in this stage: the handoff documents the product, and a change to `design/system/` after the acceptance of stage 12 would void every pixel comparison that acceptance stood on.

**And `specified` means there is nothing here that an instrument can run.** This paragraph said two values and there is no third, and five rows below it were already carrying a fourth wording: `confirmed as specified`, `confirmed by inspection`, `confirmed as a written decision`. **Step 8 found them and the fix is the honest one, which is to stop calling them confirmed.** These pages are a clickable prototype with no runtime, so a rule about what `Escape` must not do, or about a shortcut yielding to a text field, is a REQUIREMENT ON THE IMPLEMENTATION and not a property of this repository. Naming that plainly costs one word and buys the reader the one thing the column is for: knowing which lines were measured today and which are promises somebody still has to keep.

**A point with no way to test it gets neither status.** It would look like finished work and would not be finished work, and that is the idle control of this step. Every row below carries a command you can run or an action you can perform in under a minute.

---

## 1. The four instruments, run at this stage, on the whole product

| Instrument | Command | What it asks | Result, this run |
|---|---|---|---|
| **Contrast** | `node design/kit/checks/contrast.mjs design/` | Computed ratio of every text node against its **composited** ground, in both themes, at 1440 and at a measured 360 | **0 failures**, 268 renderings over 67 pages. 528 exemptions, each with its reason declared, 0 page errors, 0 horizontal overflow. **Re-run 2026-08-27, and the light half is real for the first time:** see the note under this table |
| **Focus** | `node design/kit/checks/focus.mjs design` | A real `Tab` walk through the real tab sequence, in both themes, recording whether a ring **renders** and what it measures against the ground **behind** the control | **1 failure.** 1346 tab stops over 66 pages, 19 control types, both themes, 0 with no ring at all. `rail-out` measures 1.93:1 in dark. The theme control added 2026-08-27 takes 59 stops per theme at 7.71 dark and 5.47 light |
| **Zoom and font size** | `node design/kit/checks/zoom.mjs design` | Browser zoom at 200%, and separately the reader's own font size at 200%, which is the thing a breakpoint in `rem` exists for | **0 failures**, 132 readings over 66 pages |
| **Motion** | `node design/kit/checks/motion.mjs` | Computed style with and without `prefers-reduced-motion: reduce` emulation, at both viewports | **0 outside the register, 0 above 1ms under reduce, 0 elements that stopped existing** |
| **Width** | `node design/kit/checks/sweep.mjs` | The stage 10 sweep: 62 pages over 49 widths, 3038 readings, from 320 to 2560 and in tens within 80 of the point | **0 findings at or above the declared minimum of 360.** 26 below it, on 13 screens at 320, reported and not counted: 360 is the stated floor of this product |

**Two of these instruments were written at this stage**, `focus.mjs` and `zoom.mjs`, because the four points they cover had no way to check them: every existing instrument could prove that `--color-focus` is DECLARED, which is not the question. **Both found something on their first run and one of the two findings was the instrument's own fault**, which is recorded in each file's header rather than quietly corrected: `focus.mjs` first measured the ring against the control's own fill instead of the ground behind it, and reported every filled control in the light theme as a failure.

---

**The light half of the contrast run was not real until 2026-08-27, and this is the correction rather than a footnote.** The instrument seeds a theme by writing `localStorage`, and until that date the key it wrote was read only by the documentation stand. Nothing under `design/` read it, so every light rendering of a product screen was the dark theme wearing a light label: **134 of 268**, reported clean twice, at stage 12 and at stage 13. It was measured rather than argued: `--bg-page` on `queue.html` with the key seeded to light came back `#11110f`, the dark page.

**What made it real is a product feature rather than a fix to the instrument.** The top bar gained a theme control, `design/system/theme.js` reads one key for the whole project, and the sweep now flips what it seeds. The first pass that actually rendered light returned **one** failure across 67 pages, and it was in the documentation panel rather than in the system: the current item of the roadmap washes its own ground with its own ink at 10 per cent, which in dark starts far from the ink and in light starts close, landing at 4.41 against a floor of 4.5. The wash is 6 per cent now and `/_nav.css` carries the three measurements it was chosen from.

**The focus pass was never affected**, and the difference is worth naming because it decides which claims in this file had to be re-earned. `focus.mjs` sets `data-theme` on the document directly rather than seeding a key, so its light half rendered light from the day it was written. One instrument drove the theme and one asked for it.

---

## 2. Focus

| Point | Where in the code | How to check it | Status |
|---|---|---|---|
| **`:focus-visible`, never `:focus`** | Every interactive component in `design/system/components/`. The ring itself is a composite in `tokens.css` section 2g | `node design/kit/checks/focus.mjs design`. Or by hand: click a button, then Tab to it. The click must not draw a ring and the Tab must | **confirmed.** 17 control types, both themes, 0 with no ring |
| **The ring is a token, not a style** | `--color-focus` in `tokens.css`, paired in both themes | Grep any component for a colour beside `:focus-visible`. There is none | **confirmed** by the same run |
| **The ring is a LINE, so the threshold is 3:1** | WCAG 1.4.11, and `tokens.css` says so on the role | The focus instrument prints the minimum ratio per control type | **confirmed** for 16 of 17 types, dark minimum 7.33, light minimum 5.02 |
| **The ring on `.rail-out` is 1.93:1 in the shipped dark theme** | `design/system/components/rail.css`, which says "NO STATES on the rail. The way out is a link and carries the link's" | Open `design/entry.html`, Tab to `Open the log`. The rail is the only full width **inverted** surface in the product, so the link's pair, which is measured against the page ground, lands on the wrong side | **debt.** Row in `design/kit/docs/backlog.md`. 5 instances on 5 screens |
| **A state does not move the layout** | Stage 08 rule: hover and active change fill, ink and boundary, never size or position | Hover any row in the queue and watch its neighbours | **confirmed** by the geometry check of stage 12, 42 pairs and 0 unexplained |
| **Focus order in a dialog** | `role="dialog" aria-modal="true" aria-labelledby` on all three dialogs: `reject`, `escalate`, `keyboard` | Open `design/reject.html` and Tab. Focus must stay inside | **confirmed** in the focus run: the tab sequence on those pages does not leave the dialog |
| **The theme control is reachable by keyboard and its name follows its state** | `design/_shell.js` renders it and subscribes to `design/system/theme.js`; the glyph is picked by `design/system/components/z1.css` from the same attribute | `node design/kit/checks/focus.mjs design`, control type `theme`. Or by hand: Tab to the last control in the bar and press it. The accessible name must change with the ground | **confirmed.** 59 stops per theme, 0 with no ring, 7.71 dark and 5.47 light |
| **`Escape` does not propagate out of a dialog** | Node 0.5 section 6, and node 4.1 section 6 | Documented behaviour, not styling. `handoff/docs/behaviour.md` section 5 carries it | **specified.** These pages have no runtime, so this is a requirement on the implementation rather than something measurable here |

---

## 3. Contrast

| Point | Where in the code | How to check it | Status |
|---|---|---|---|
| **Every role is written twice**, once per theme, and the two are not the same primitive | `tokens.css` sections 2 and 3. Contrast is computed against the opposite ground, so the light side takes a different step of the same ramp | `node design/kit/checks/themes.mjs` | **confirmed** |
| **Thresholds are set by SURFACE, not by role** | Ink 4.5:1, large ink 3:1, fill and line 3:1. Every role in `tokens.css` declares which surface it paints and its measured ratio in both themes | `node design/kit/checks/contrast.mjs design/` | **confirmed**, 0 failures over the whole product, and since 2026-08-27 that sentence covers the light theme as well as the dark one |
| **The ground is composited, not read off the declaration** | `contrast.mjs`, and the reason is in its header: a background written with transparency is not the ground | Read the header of that file | **confirmed** |
| **Exemptions are declared, not skipped** | `contrast.mjs`. 496 of them this run, and one class carries the only reason: `--text-divider`, the character that separates the parts of the strips, which is a divider drawn as a glyph | The instrument prints every exemption with its reason | **confirmed** |
| **`--line-edge` sits at 2.997:1 and is deliberately not raised** | `tokens.css` section 2. It is a pixel of the source plate, and the boundary of a CONTROL is a different role, `--line-control`, which carries 3:1. Nothing called `--color-edge` exists or was added: **that name was written here at step 4 and named no token in `tokens.css`,** which is the one class in this file a reader cannot catch by reading it | Read the comment on `--line-edge` in `tokens.css` section 2 | **specified**, a written decision, and it paints no text |

---

## 4. Width, zoom and the reader's own font size

| Point | Where in the code | How to check it | Status |
|---|---|---|---|
| **One breakpoint, in `rem`** | `--bp-split-panes` in `tokens.css` section 1f. It is in `rem` so that it reacts to the reader's font size and not only to the window | `node design/kit/checks/zoom.mjs design`, mode `font200` | **confirmed**, 62 pages |
| **The literal in the query and the token as register** | `@media` is resolved before the cascade of custom properties, so `@media (min-width: var(...))` never fires and raises no error. The literal stands in the query, the token is the register, and a check counts every query in the system against it | `node design/kit/checks/rules.mjs` | **confirmed** |
| **Type is fluid through `clamp()` with a `rem` addend**, and `font-size` never switches at a point | `tokens.css` section 1f. This is WCAG 1.4.4: pure `vw` would break zoom | Zoom to 200% and watch the type grow. `node design/kit/checks/zoom.mjs design` | **confirmed**, 0 failures over 124 readings |
| **Exactly one top level navigation carrier at any width** | Usage rule R5, and `.z1--out` marks a bar that is deliberately not one: a console that is out has a bar and nowhere else to be | `node design/kit/checks/sweep.mjs`, and the zoom instrument checks the same thing at 200% | **confirmed** |
| **No horizontal overflow at any width from 360 up** | The sweep is the instrument; there is no declaration to read | `node design/kit/checks/sweep.mjs` | **confirmed**, 3038 readings. Below 360 the sweep still looks and still reports, and 320 is where it finds something: that is the floor being a decision rather than a place nobody measured |
| **The narrow rendering is proved at a MEASURED 360**, not an intended one | Every instrument in `design/kit/checks/` prints `document.documentElement.clientWidth` and asserts it reads 360, because a scrollbar turns an intended 360 into an actual 345 | Any instrument's first output line | **confirmed** |

---

## 5. Motion

| Point | Where in the code | How to check it | Status |
|---|---|---|---|
| **Reduced motion works by redefining the tokens**, not by a list of exceptions | `tokens.css` section 1h. A component that reads `var()` obeys without knowing the block exists, **and so will a component written next year** | `node design/kit/checks/motion.mjs` | **confirmed**, 0 elements above 1ms under reduce |
| **1ms rather than 0s, and the difference is the instrument** | A transition of exactly zero and a transition that was never declared read identically in computed style, so a check asking for zero cannot tell an element that obeyed from one that was never asked | Read the comment in `tokens.css` section 1h | **confirmed** |
| **Reducing motion removes the movement, never the state** | The same run counts elements that stopped existing under reduce | `node design/kit/checks/motion.mjs` | **confirmed**, 0 stopped existing |
| **A cycle is replaced by a still state, never accelerated** | `arriving.css`, which sets `animation: none` under reduce rather than shortening it. Making a pulse 1ms turns it into a strobe, which is worse than what it replaced | Emulate reduced motion and open `design/queue-streaming.html` | **confirmed** |
| **No `transition: all` and nothing expensive animated** | Only `transform` and `opacity` move; everything else is a colour | `node design/kit/checks/motion.mjs` | **confirmed**, 0 and 0 |
| **One number per role** | One duration for response, one period for status, and there is no `--dur-base` because the moment that needed it does not exist in a product made of separate documents | The instrument groups by role | **confirmed** |

---

## 6. Structure and text

| Point | Where in the code | How to check it | Status |
|---|---|---|---|
| **Exactly one heading renders at every width, and that one is the `h1`** | Usage rule R11, and it is a **function** in `design/kit/checks/rules.mjs` rather than a paragraph, because the only way to know which heading survives is to render the screen at 360 | `node design/kit/checks/rules.mjs design` | **confirmed**, 124 renderings, 0 broken |
| **The depth of a claim is reachable by keyboard, and until stage 13 it was not** | `design/system/components/expand.css`. It is a native `details`, so the keyboard, the accessible name and the open state are the browser's | `node design/kit/checks/focus.mjs design`. The walk records `summary` as its own control type | **confirmed.** 26 stops per theme, 21 of them the depth under a claim and 5 the door's help, 0 with no ring, 7.33:1 in dark and 5.02:1 in light. Before this stage the same 21 were a `div`: no tab stop, no name, no state |
| **The queue is a `role="grid"` with ONE tab stop**, and traversal inside it is by arrow key | Node 3.1 section 6, following the ARIA APG grid pattern | Open `design/queue.html`, press Tab once, then Down | **confirmed** in the focus run: the list is one stop |
| **Single letter shortcuts are live only while a region has focus**, and every one of them is remappable and can be disabled | WCAG SC 2.1.4 Level A, third condition. The remap and disable controls are at the foot of node 0.5 | `design/keyboard.html`, and node 0.5 section 8 | **specified.** The remap and the disable are DRAWN on node 0.5 and there is nothing behind them to run, so SC 2.1.4 is met by the specification and has to be met again by the build |
| **A text field wins over a shortcut.** With focus in a field, `a` types the letter | Node 0.5 section 8, the column the node exists for | Node specification, and `design/case-amend.html` renders the case | **specified.** A static page cannot demonstrate a key going to a field instead of a handler |
| **Dialogs are labelled** | `aria-labelledby` on all three | Grep `role="dialog"` in `design/` | **confirmed**, 3 of 3 |
| **No images and no inline `svg` in the product**, so there is no `alt` text to get wrong | Icons are CSS masks; the stage 07 `.icon` class was removed rather than carried | `grep -c "<img\|<svg" design/*.html` returns zero | **confirmed** |
| **The empty state of the split view reads as the fleet**, not as an empty screen | `queue-empty.html`, and it is the test of the biggest structural decision in the product | Open it and read the pane | **specified**, and it is a design claim rather than a measurement: the reading is a human's and no instrument settles it |

---

## 7. What this stage found, and what it did not do about it

**Two debts, both in `design/kit/docs/backlog.md` with the reason and an owner, and neither fixed here.**

1. **The focus ring on `rail-out`, 1.93:1 in the shipped dark theme.** A real WCAG 1.4.11 failure on 5 pages. The cure is a question rather than a value: either an inverted surface gets a focus role of its own, or the record rail stops being the only place a control sits on inverted ground.
2. **`--ease-enter` and `--move-sm` are read by nothing.** Not an accessibility failure; found by the idle control of the map and recorded beside this one because both are the same kind of thing, a declaration nobody checked after it was written.

**Nothing on this page was repaired by this stage**, and that is the rule rather than an omission. The product was accepted at stage 12 against measurements taken on it as it stands, and a state token changed here would leave every one of those measurements describing a product that no longer exists.
