# Accessibility - Tendd

**This file introduces nothing.** Accessibility in this product was built at stages 08 to 11: a
focus token with a pair in both themes, contrast checked by role, the two width points written
in `rem`, and a reduced-motion block that overrides the motion tokens. What was missing was not
the work but the PROOF, and a handoff that lists the work and calls it a proof has written a
plan.

**13 of the 15 rows are confirmed, one is a boundary and one is the founder's.** So every row
here carries four things, and the fourth is only ever one of two words:

**item → where it is in the code → HOW TO CHECK IT → confirmed / debt**

**"Confirmed" means a run on 2026-08-23 with the instrument named in the row.** Not a memory of
stage 08 doing it. A row for which no instrument can be named never gets the word at all, and a
row that fails says "debt" and carries its `design/kit/docs/backlog.md` line.

**Nothing was fixed WHEN THIS FILE WAS WRITTEN**, because the product was accepted at stage 12
and an edit would have voided every pixel comparison it stands on. Two rows were real failures
and both went to the backlog as debts. **The founder lifted the freeze the same day and both are
closed**, which is why they now read "confirmed" with a second date and keep their old text
underneath: a row that was a debt for six hours is still a row that was a debt.

---

## The instruments

| Instrument | What it answers | Run |
|---|---|---|
| `design/kit/screens/a11y13.cjs` | contrast, focus, motion, zoom. Written for this stage, because these four claims had no instrument | `node design/kit/screens/a11y13.cjs --json` |
| `design/kit/screens/aria13.cjs` | six semantics questions over every page in both corpora | `node design/kit/screens/aria13.cjs` |
| `design/kit/screens/quality13.cjs` | six render questions, including tap-target size | `npm run check:quality` |
| `design/kit/screens/width12.cjs`, `edges13.cjs`, `pages13.cjs` | the width sweep and the right edges | `npm run check:width`, `check:edges`, `check:pages` |

**The output of `a11y13.cjs` is checked in beside it** as `a11y13.txt` and `a11y13.json`, so the
next person can disagree with the numbers below by re-running rather than by arguing.

### One thing about the instrument itself, because it changes how you read every contrast row

The first two full runs of `a11y13.cjs` reported catastrophic contrast failures, sixteen in one
run and twenty-eight in the next, **on different screens each time**. Neither set reproduced
when those screens were opened alone. The cause is in `base.css`: every interactive element
transitions `background-color`, `border-color` and `color` over `--dur-state`, so asserting the
theme starts a 150ms cross-fade across the whole page, and a sample taken inside it compares one
theme's ink with a surface halfway to the other.

The fix is in the instrument and it is worth knowing: **the contrast and focus passes run with
`prefers-reduced-motion` emulated**, which makes the product's own reduce block zero every
duration, so colours are final the moment they are set. It changes no colour, because motion has
no theme axis. The motion pass then needs a context of its own, since measuring the baseline
inside a reduced one would report the reduce block as the product's motion, which is a check
that always passes and proves nothing. **Two consecutive full runs now return byte-identical
output**, and that determinism is part of what "confirmed" means here.

---

## The checklist

| # | Item | Where it is in the code | How to check it | Status |
|---|---|---|---|---|
| **1** | **One focus ring for the whole system**, on `:focus-visible` rather than `:focus`, so it is for the keyboard and not for the mouse | `design/system/base.css`, the `:where(a, button, input, select, textarea, summary):focus-visible` rule. `--ring` and `--color-focus` in `tokens.css`, the role paired in `:root` and in `[data-theme="dark"]` | `a11y13.cjs`, check 2: a real TAB pass, both themes, reading the computed outline at every stop | **confirmed.** 908 stops, 20 control types, **0 with no indicator** |
| **2** | **The ring is visible where it lands**, WCAG 1.4.11, 3:1 against the surface behind it | the same two tokens; the light and dark values differ by direction | the same pass, computing the ring against the composited surface | **confirmed.** 0 stops under 3:1. Worst ring in the product: **5.22:1 light, 6.15:1 dark** |
| **3** | **A ring that is not on the focused element still counts**: a preference switch stands its own ring down and the ROW draws it | `design/system/components/switch-row.css`, `:has(input:focus-visible)`; `checkbox.css` stands its own ring down | the same pass looks up to three ancestors and names the element that carries the ring | **confirmed.** The six switch stops per theme find their ring on the row. Reported as absent by a check that looked only at the focused element, which is why this row exists |
| **4** | **Text contrast in both themes**: 4.5:1 body ink, 3:1 large ink (>=24px, or >=18.66px at 700+) | the semantic ink roles in `tokens.css`, each paired under `[data-theme="dark"]` | `a11y13.cjs`, check 1: every visible text node, its effective ink against the **first opaque surface behind it**, composited through every translucent layer, by the WCAG formula | **confirmed.** **3100 text nodes** across 57 screens by 2 themes, **0 below threshold** |
| **5** | **Non-text contrast**, WCAG 1.4.11, 3:1 | the line and fill roles in `tokens.css` | the same check, and **the scope is the point**: the border of an interactive element or a form control, and a meaningful mark painted as a background (the destination icons, the nav row's chevron, the alerts dot). A divider between two list rows is decoration and is not asked | **confirmed.** **572 control borders and marks**, **0 below 3:1** |
| **6** | **Reduced motion removes the movement, not the appearance** | `design/system/base.css`, the `@media (prefers-reduced-motion: reduce)` block: durations to 0.01ms, iteration count to 1, `scroll-behavior: auto`. Global, and outside every `[data-theme]` | `a11y13.cjs`, check 3: the same screens under emulated reduce, computed style read back | **confirmed.** With motion allowed, **53 elements carry a duration and 17 declare an infinite iteration**. Under reduce, **0 still move** |
| **7** | **Exactly one carrier of top-level navigation at any width**: a bar on a phone, a rail on a desktop, never both | `design/system/components/tab-bar.css` and `app-shell.css`, both asking a `@container` against `body` | a visibility count of `.tabbar` inside `.app`, at 360, 390, 760, 900, 1440 and 1920 | **confirmed.** **336 screen-by-width checks, 0 screens carrying more than one** |
| **8** | **Semantics and accessible names**: no unnamed group, no `aria-label` on an element ARIA discards it on, no wait without `aria-busy`, no unnamed control or link, no skipped heading level | across both corpora | `aria13.cjs`, six checks | **confirmed.** 114 pages swept, **0 of 6** |
| **9** | **Tap targets**: no control under 40px tall | the `--tap` and `--row` tokens, read by every control | `quality13.cjs`, the `small-target` check, 57 screens by two widths by two themes | **confirmed.** **0 findings.** The only kind that reports at all is `flush-text`, 24 occurrences deduped to two shapes, and the instrument's own header names both as legitimate |
| **10** | **Every image carries an alt attribute**, and in the product every one is deliberately empty: a merchant logo stands beside its own name in text, so announcing it twice is noise | the markup of the coloured screens | a count over `design/*.html` | **confirmed.** **172 of 172 carry `alt`**, 0 missing. The 9 non-empty ones are on `overview.html`, the stand, where a screenshot genuinely carries information |
| **11** | **No sideways scroll at ordinary sizes** | the container queries, and the reading measures on the line rather than on the box | `quality13.cjs` `page-overflow`, `width12.cjs` over 58 widths, `pages13.cjs` over the pages that are not product screens | **confirmed.** 0 |
| **12** | **Text scales to 200%**, WCAG 1.4.4. This is the check the two `rem` points of stage 10 exist to pass | `--bp-tablet` and `--bp-desktop` in `tokens.css`, both `rem`; the type scale in `rem`; `clamp()` with a `rem` addend | `a11y13.cjs`, check 4: the root font size set to 32px, every screen asked whether it now scrolls sideways, at 390 and at 1440 | **confirmed, 2026-08-23.** **0 of 114.** It was 9 when this file was written, all of them at 390; the three causes and their fixes are below |
| **13** | **A focusable element wears the system's ring** | `base.css` names the ring for `a, button, input, select, textarea, summary` | the tab pass, reading `outline-style` at every stop | **confirmed, 2026-08-23.** `[tabindex]` joined the six element names in `base.css` the same day, so the trend chart wears the system's ring like everything else. The tab pass reads it as `div.chart`, 2 stops a theme, 5.78:1 light and 6.40:1 dark - the same numbers, and now they belong to this product rather than to Chromium |
| **14** | **A skip link over the bar and into the content** | nowhere | reading the markup | **DEBT, and already the founder's.** Stage 12 closed naming it as one of three things left, on the ground that a skip link is a new product STRING and a string is Voice's before it is anybody's |
| **15** | **Focus order inside a dialog** | `design/system/components/dialog-sheet.css` | not applicable in this package, and that is the honest answer rather than a pass | **BOUNDARY.** Every state here is its own HTML file, so a dialog is a page and there is nothing to trap focus inside. A real build owes a trap, a restore on close, and Escape. It is named in the package boundary on `handoff/handoff.html` rather than counted as a hole here |

---

## The two debts, and how they closed the same day

### 12. Nine screens scrolled sideways at 200% text zoom, and none does now

**Closed 2026-08-23**, when the founder lifted the freeze. Three causes, and each already had a
solved form somewhere in the product:

- **A `nowrap` that is right at every ordinary size and impossible at twice the text.** A button
  label that may not fold, and a figure that may not fold, cannot fit a phone once their own font
  has doubled. Released by a container query written in `rem` against `body` - which reads the
  viewport in units of the reader's text, so one threshold below 20rem fires under zoom and
  nowhere in the product's own width sweep, which starts at 320px, or 20rem exactly. **This is
  the mechanism stage 10 refused to put in `px` for, and the first time that refusal paid.**
- **A flex item that cannot shrink below its own content.** `min-width: 0` on the segments of the
  range picker.
- **A cap that could exceed its own container.** `max-width: min(20rem, 100%)` on the track, and
  `minmax(0, 1fr)` where `.grid.roomy` had a bare `1fr` - because `1fr` is `minmax(auto, 1fr)`
  and `auto` refuses to shrink a column below its content.

**0 of 114 scroll sideways**, and the box fingerprint says **zero boxes moved at 360 or at
1440**: every one of the three changes is inert at every width where the content already fits.

### 12a. What the failure looked like, kept because the shape is the lesson

Measured with the root font size at 32px, twice the browser default, which is what WCAG 1.4.4
asks of text and what a person with low vision actually does. **At 1440 nothing fails.** At 390:

| Screen | Over by |
|---|---|
| `cancel-win.html` | 83px |
| `history-trends-locked.html` | 46px |
| `upgrade-renewal-failed.html` | 39px |
| `upgrade-payment-failed.html` | 28px |
| `cancel-guide.html` | 20px |
| `history-trends.html`, `history-trends-loading.html` | 6px |
| `alerts.html`, `alerts-empty.html` | 4px |

Read the shape rather than the list: the four worst are screens whose content is a fixed
horizontal composition (a won-back figure, a locked range control, a payment notice), and the
four smallest are a hairline over the edge. **This is the width sweep's own lesson from stage 10
arriving one axis later**: the sweep moved the viewport and held the text, and a person can move
the text and hold the viewport.

Row for `design/kit/docs/backlog.md`, addressed to the founder, not fixed here.

### 13. The chart's focus ring was the browser's and is the system's now

`design/system/behaviour.js` sets `tabIndex = 0` on the chart box so the keyboard can reach the
cursor it builds. The box is a `div`, and `base.css` names its ring for
`a, button, input, select, textarea, summary`. So the one interactive element in the product
that is not a native control is also the one wearing an outline the system does not own.

It passed in Chromium at 5.78:1 light and 6.40:1 dark, and `outline: auto` is drawn by the
engine, so those numbers belonged to the browser. **Closed 2026-08-23: `[tabindex]` joined the
six element names in the rule**, so anything this product deliberately makes focusable wears the
same ring. The numbers did not move; what changed is who owns them.

---

## What this file does not cover, and where those questions live

- **Screen reader behaviour end to end.** `aria13.cjs` proves that names and roles exist; it
  cannot prove that a journey reads well. That needs a person with a screen reader, and it is
  named here so its absence is a known gap rather than an assumed pass.
- **Colour as the only carrier of meaning.** The product's own decisions cover it (status is a
  quiet grey badge, a price change is amber, an error is clay, and each carries a word), but
  no instrument asserts it. `design/kit/docs/architecture.md` holds the usage rules.
- **Cognitive load, reading level, plain language.** `voice/docs/voice.md` owns them, and its
  principle 3 is the accessibility rule that matters most to this audience.
