# Accessibility - what is confirmed, what is a debt, and how to re-ask both

Stage 13, step 4. **This checklist introduces nothing.** Every rule in it was decided at stages 08 to
11; what was missing is a single place that says which of them is TRUE TODAY and by what command.

**«Confirmed» means a run on 2026-08-25, not a memory of the stage that wrote the rule.** Nine of the
rows below were true when they were written and three of them are not true now, which is the whole
argument for measuring rather than citing.

**A row whose way of checking cannot be named never gets «confirmed».** That is the one rule the
document is built on: a point nobody can re-ask is neither a confirmation nor a plan - it looks like
work that was done and is not, and it is the most expensive shape a document can take.
`node tools/handoff.mjs` reads the last two columns of every table below and fails if the «how to
check» cell is empty, if the command it names is not a file in `tools/`, or if the status is anything
other than one of exactly two words. A third status - «mostly», «partly» - is how a debt becomes
invisible.

**Nothing here is fixed.** A hole goes to `design/kit/docs/backlog.md`, whose owner is stage 08, and
stands here as a debt with the count that would have to go to zero. Stage 13 documents; the product
was accepted at stage 12 and `design/system/**` is not this stage's to edit.

---

## A. Focus

| # | Point | Where in the code | How to check | Status |
|---|---|---|---|---|
| A1 | the ring is the system's and fires on `focus-visible`, never on `focus` | `--color-focus` in `design/system/tokens.css`, applied in `design/system/base.css` | `node tools/focus.mjs --all` | **підтверджено.** 141 screens x 2 widths, **33816 controls tabbed**, in the browser's own blue ring **0**, with no ring at all **0** |
| A2 | and the same in the DARK theme | the same, plus the `[data-theme="dark"]` block | `node tools/focus.mjs --all --dark` | **підтверджено.** Same corpus, same two numbers: **0** and **0**. This is the case the instrument was written about - the user agent's blue does not follow the theme - and until 13.4 it was the one case the instrument could not see |
| A3 | focus never lands on an invisible element, and no positive `tabindex` exists | `design/system/base.css`, and the shell components | `node tools/tab-walk.mjs index checkout coach-session account listing` | **підтверджено.** 5 screens x 2 widths: focus on an invisible element **0**, `tabindex > 0` **0**, focus order matches visual order |
| A4 | a dialog traps focus, and the page behind leaves the tab order | `inert` on the page behind, per component | `node tools/modal-trap.mjs` | **підтверджено.** **0 of 27** dialog screens leave the page behind reachable by Tab |

---

## B. Contrast

Thresholds by surface, not one number: **4.5:1** for ink, **3:1** for large ink, **3:1** for a fill or
a line. Read from the computed style of the pair and the WCAG formula, in both themes.

| # | Point | Where in the code | How to check | Status |
|---|---|---|---|---|
| B1 | every semantic role has both halves | the `SEMANTIC - roles` block of `design/system/tokens.css` | `node tools/theme.mjs --source` | **підтверджено.** 98 roles in `:root`, 98 in `[data-theme="dark"]`, none missing a half |
| B2 | ink passes its own composited ground in both themes | `design/system/components/*.css` | `node tools/theme.mjs` | **борг.** **37 shapes**, re-taken over 334 pages on 25.08.2026: 1 broken by the theme, 36 failing in BOTH. **25 of the 37 are one pair** - Signal Orange against white, at exactly 3.13 from either end - and they are RULED, not open: A10 took the pale-ground direction on 12.08.2026 and the owner took the fill direction on 25.08.2026, so white stays on `.btn--accent`. **The number that has to reach zero is the other 12**: 8 sit on stand pages, 4 on product screens, and 2 of those 4 are the word «фото» inside an image placeholder a real photograph replaces. The residue that is real product text is **2 shapes, both on `search-suggest`**. Recorded in `backlog.md` |
| B3 | no component reads a colour primitive directly | `design/system/components/*.css` | `node tools/theme.mjs --source` | **борг.** **One**, and it is new: `quiz.css` reads `--scrim-ink-10` for the backdrop behind the card while every other scrim in the system reads the role `--scrim-overlay`. Introduced at 12.11, caught here because the stage-12 gate was not re-run after its last batch. Recorded in `backlog.md`; the cure is a new semantic role, which is the system owner's call |
| B4 | a dark fill or line carries the family tint rather than a flat grey | the `[data-theme="dark"]` block | `node tools/theme.mjs --source` | **підтверджено.** None flat; 5 exempted by measurement, because both halves hold the same value |

---

## C. Width, and the two kinds of zoom

| # | Point | Where in the code | How to check | Status |
|---|---|---|---|---|
| C1 | two breakpoints, both in `rem`, no `var()` inside a media query, no query in a screen file | `design/system/tokens.css` registry + `design/kit/docs/responsive.md` | `node tools/bp.mjs` | **підтверджено.** 116 queries in the system, every one resolving to a registry number; **0** screen files carrying a query; **0** `var()` inside a query; container thresholds **4 in the code = 4 in the registry** |
| C2 | nothing scrolls sideways at 360, measured rather than eyeballed | every screen | `node tools/accept.mjs 360` | **підтверджено.** **343 screens, 0 failures**, with `clientWidth` asserted at 360 |
| C3 | and nothing breaks BETWEEN the points, 320 to 1600 | the same | `node tools/width-sweep.mjs` | **підтверджено.** 141 screens x **129 widths each**, step 10 with bisection to the pixel. Above the declared floor of 360: document scrolls sideways **0**, element past the screen edge **0**, one entry in two carriers **0**. The 21 «past the edge but INSIDE A RAIL» are a rail doing its job and are classed apart. Below 360 the numbers are printed and do not fail the run, because the product never promised to work there |
| C6 | a line of prose does not exceed the reading measure | `--container-text` in `ch`, in `design/system/tokens.css` | `node tools/width-sweep.mjs` | **борг.** **2 places**: `system` at 671 wide runs to 128.5ch against a measure of 68, and `content-reviews` at 829 to 88.4ch. Both are prose blocks that never took the text container. Recorded in `backlog.md` |
| C4 | browser zoom at 200% | - | `node tools/width-sweep.mjs` | **підтверджено, by C3 and not by a run of its own.** Browser zoom at 200% halves the CSS viewport and changes nothing else, so a 1280 window becomes a 640 one - a width the sweep already walks. Said out loud rather than counted twice |
| C5 | **text-only zoom at 200%** - a reader who has raised the default font size, which is a different thing from C4 | the type ramp in `rem`, moved value for value at stage 10 for exactly this | `node tools/accept.mjs 1280 --text200` and `node tools/accept.mjs 360 --text200` | **борг, and the largest one in this document.** With the root font size doubled: **142 of 343 screens overflow horizontally at 1280**, and **284 of 343 at 360**. Stage 10 moved the ramp to `rem` for this reader and nothing had ever asked whether it worked. Recorded in `backlog.md` |

---

## D. Motion

| # | Point | Where in the code | How to check | Status |
|---|---|---|---|---|
| D1 | «less motion» works through overriding the SAME tokens, so a component written tomorrow obeys without being told | the `@media (prefers-reduced-motion: reduce)` block in `design/system/tokens.css` | `node tools/motion.mjs --output --reduce` | **підтверджено.** 340 pages, **8446 elements move in the normal pass**, and under the emulation **0 of them stay above 1ms** |
| D2 | and it is true WITHOUT a safety net on `*` | no blanket motion reset on `*` exists in `design/system/base.css` | `node tools/motion.mjs --output --reduce` | **підтверджено**, and this is the half that matters: a blanket rule would make the measurement green on an empty page, because under it no component that ignores the tokens could ever be seen |
| D3 | a cycle is replaced by a static state at reduce rather than sped up | `@keyframes` readers in `design/system/components/*.css` | `node tools/motion.mjs --output --reduce` | **підтверджено** by the same zero: a cycle running at 1ms would read as a flicker and would fail this measurement, not pass it |

---

## E. Structure - the heading ladder

New instrument, written for this step: `tools/headings.mjs`. It reads the rendered DOM AT REST,
because in this product **every state is already its own document** - `coach-client-edit.html` IS the
screen with the dialog open - so opening panels on top of one would measure a state the product does
not have.

| # | Point | Where in the code | How to check | Status |
|---|---|---|---|---|
| E1 | every screen has exactly one `h1` a screen reader can reach | the screen files | `node tools/headings.mjs` | **борг.** **35 of 141 screens have none**, in three clear classes: **17 dialog states** where the page behind is inert and the dialog carries no heading of its own (this is the roll-out's class 8, which had found 2 of the 17); **16 loading, error and overlay states** whose headings are all reachable but none is an `h1`; and **2 screens with no heading in the markup at all** - `checkout-declined` and `checkout-loading`. Recorded in `backlog.md` |
| E2 | the ladder skips no rung | the shell components and the screen files | `node tools/headings.mjs` | **борг, and it is ONE cause on 97 screens.** The footer's newsletter block is an `h4` standing directly after an `h2` (after an `h1` on 4 screens). One component, 97 of 141 screens; the fix is a single heading level in one file, which is a system change and therefore the owner's. Recorded in `backlog.md` |
| E3 | no heading is empty | the screen files | `node tools/headings.mjs` | **підтверджено.** **0** of 141 |

---

## F. Text and marks

| # | Point | Where in the code | How to check | Status |
|---|---|---|---|---|
| F1 | a mark the system draws never stays a letter or an emoji in the accessibility tree | `design/system/icons.js` + `design/system/marks.js` | `node tools/glyphs.mjs` | **борг, and the row said the opposite until 25.08.2026.** It read «5 marks left, every one on a stand page. On the 141 product screens: 0» - and the run its own column names had already printed ten product rows on 24.08 at 18:16, the day this register was written. Re-taken over 343 pages after the quiz was repaired: **11 marks the map KNOWS are still drawn by the font, 18 occurrences on 7 product screens**, and **19 pictographs with no map row at all, 29 occurrences on 11**. Together **11 product screens**: `content-about`, `content-contacts`, `content-delivery`, `content-faq`, `content-guarantee`, `content-promo`, `content-returns`, `content-reviews`, `maintenance`, `search-suggest`, `system`. `quiz` was the twelfth and is closed. The two declared exemptions are printed with their counts so the coverage cannot go quietly empty |
| F2 | a link whose only text is the photo placeholder is not left unnamed | `design/_nav.js`, the accessible-name pass | `node tools/accept.mjs 390` | **підтверджено.** The `ph` column is **0** across all 343 screens |
| F3 | one apostrophe form, no em dash, the three dashes each doing their own job | the whole tree | `node tools/typo.mjs` | **підтверджено.** 747 files opened; every remaining occurrence is a declared quotation of the rule itself |
| F4 | an in-page index says which section you are in, and a jump lands where you can see it | `design/system/page-index.js` + `base.css` `scroll-padding-top` | `node tools/page-index.mjs` | **підтверджено 01.09.2026.** 345 pages walked, 10 carry an index, exactly one entry is current at every sampled scroll position and every jump lands in the open. The mark is `aria-current="location"` - ARIA's own value for a table-of-contents entry, not `page`. Before this run four product screens marked the current tab with a class TYPED IN THE MARKUP, and every tab on them parked its heading behind the shelf that took you there |

---

## G. Navigation

| # | Point | Where in the code | How to check | Status |
|---|---|---|---|---|
| G1 | exactly one carrier of top-level navigation at the wide width | `header.css` and `tabbar.css`, at the shell point | `node tools/tab-walk.mjs index checkout coach-session account listing` | **підтверджено.** At 1280 the header is the only carrier - 17 entries - and the tab bar yields **0** focusable stops, because the bar is hidden outright, which takes it out of the accessibility tree along with the pixels |
| G2 | at the narrow width there are two carriers BY ROLE - the bar is navigation, the header is utilities | the same | `node tools/tab-walk.mjs index checkout coach-session account listing` | **борг, and it is an IA question rather than a css one.** At 360 both carriers stand, and **two entries appear in both**. «Кошик» is a decision `navigation.md` states outright; «Обране» is not in the specification. Decided at stage 03a, so it cannot be repaired in the visual layer - it is already recorded in `responsive.md` |
| G3 | the seven screens that drop the shell drop it deliberately | `checkout.html` and its states, `500.html`, `maintenance.html` | `node tools/map.mjs --write` (section A1 of `map.md`) | **підтверджено.** 8 components render on all 141 screens; the header, tab-bar, footer and mega-menu on 134. The seven exceptions are the focused checkout and the two pages that mean the site is down - and `404` keeps the full shell, because there the site works and only the address is wrong |

---

## The count

**26 rows: 18 confirmed, 8 debts.** Not one row without a way to check, and not one status outside
the two words - `node tools/handoff.mjs` asks all three of those and fails on any of them.

**Every debt has a number that would have to reach zero**, and every one of them is in
`design/kit/docs/backlog.md` with the owner of stage 08 named. In order of size:

| Debt | Today | Zero looks like |
|---|---|---|
| C5 text-only zoom at 200% | 142 of 343 at 1280, 284 of 343 at 360 | no screen overflows with the root font size doubled |
| E2 the ladder skips a rung | 97 of 141 | one heading level changed in the footer |
| B2 ink against its ground | 12 shapes. 25 of the original 37 were the Signal Orange pair and are ruled, not open | the two real shapes on `search-suggest`; the 25 never reach zero |
| E1 no reachable `h1` | 35 of 141 | a heading inside each dialog, and two on the checkout states |
| F1 a mark still a letter | 11 marks + 19 unmapped pictographs, on 11 product screens. The «5, all on stand pages» that stood here was never true | every mark on a product screen comes from the set |
| B3 a component reading a primitive | 1 | a semantic role for the light backdrop |
| G2 an entry in both carriers at 360 | 2 | an IA decision, not a css one |
| C6 a line past the reading measure | 2 | both blocks take the text container |

---

## Who reads this file

| Reader | What they come here for |
|---|---|
| a new developer | the commands. Every row is a line they can run before their first commit, and the numbers say what a clean run looks like. **A new screen cannot be asked to add nothing to these debts, and the first exam of the handoff proved it:** E2 is written by the footer of the frozen grey runtime, so every screen that carries a footer inherits the skipped rung the moment it exists. The rule a new screen CAN meet is «add no new CAUSE» - measure yours against its nearest neighbour and show the two match |
| you in a year | which of the nine debts is still open, and what the number was when it was written down |
| Claude in a new session | «confirmed» is never taken on trust here - each row names the run that produced it, so the whole document is re-derivable in nine commands |
