# The quality pass, 19 to 20 September 2026

<!-- Role: the dated record of Stream AW7's integration pass (proposal item I1) against what
     deploy D4 put on staging. One table per task; every finding carries its evidence and
     either the commit that fixed it or the running-list id that holds it. Dated facts and
     counts belong here and never in main/docs/. -->

**What was measured:** `https://dutyfreeprofessor.demoing.info`, `/api/health` printing
`{"status":"ok","version":"0.52.0","database":"ok"}` at 2026-09-19 23:42 PDT. The version string
lags the branch on purpose (rian bumps it at a release, not at a deploy), so the build under test
is identified by the branch instead: `claude/collectors-live-page-plan-8951e2` at `f8f0af3`, which
is everything through AW6 including the D4 deploy. `/api/health` named no `role`, so the site
reads as the staging environment, which is what it is.

**Scope note:** the deploy after AW6 (D5) had not completed when the pass began, so this run sees
AW1 through AW6 as D4 deployed them; AW6's `SITE_ROLE` line is on the branch and exercised here by
the sweep's `--live` expectation table rather than against a live host, because there is no live
host yet.

**The verdict in one line:** the internal set is closed, no page breaks at either width, four
accessibility defects were found and fixed, and the cost that is left is the two server-rendered
pages' reads rather than anything a browser downloads. The long form, with the index of every
finding and what carries it, is the last section.

---

## AW7.1 The full run and the unauthenticated sweep

### The full run

| What | Result |
|---|---|
| `bash main/check.sh` in the worktree | **checks passed**; 2,509 tests passed, 18 warnings, in 77.66 s (pytest); 1 m 42 s wall for the whole script |
| `bash main/docs-check.sh -q` (inside check.sh) | 0 fail, 11 warn |
| `bash main/docs-check.sh --strict` in the worktree | **11 fail** (see below) |
| `bash main/docs-check.sh --strict` in the main checkout | **10 fail** (the same, less the `backups/` line) |

Strict findings, grouped by kind. None was fixed: the rule for this pass is that a stale-doc
warning is repaired only where the document is wrong on a point the code changed, never to move a
timestamp.

| Kind | Lines | Verdict |
|---|---|---|
| Stale doc | `ARCHITECTURE.md` older than `main/app/`, `main/app/routers/`, `main/app/services/`, `main/web/src/` | Timestamp ordering. AW5 and AW6 last wrote the doc, then wrote code in the same run; the module map inside it is generated and was regenerated green by this pass. Nothing in the prose contradicts the tree. |
| Stale doc | `CLIENT-SURFACES.md` older than `import/` | Timestamp ordering. `import/` changes every time `/plan` is written to, including by this stream. A source that every plan update touches can never be older than its doc; a later session may want to narrow that source line. |
| Stale doc | `DATA-MODEL.md` older than `main/app/models/` | Timestamp ordering. The table and relationship blocks in it are generated and regenerate clean. |
| Stale doc | `QUALITY.md` older than `main/app/services/verify.py` | Real change, doc still correct. `6ce158b` made a 404 or 410 a `GONE` check instead of a refusal; the `GONE` and `BLOCKED` rows already read that way, and `BLOCKED` never claimed a missing page. Left alone; AW7.5 adds a section to the same file, which re-dates it as a side effect. |
| Stale doc | `VOCABULARY.md` older than `main/app/models/catalog.py`, `main/app/services/decisions/` | Timestamp ordering. The words did not change; the pictures work and the featured pin added columns and a decision kind under names the page already defines. |
| Missing source | `RUNBOOK.md` names `backups/`, which does not exist in a worktree | A false warning in a worktree only (the folder is gitignored and lives in the main checkout). It is the one difference between the two runs. Filed, not fixed. |
| Long handoff entry | 10 entries over 25 non-blank lines, the longest 56 | Not trimmed. A handoff entry is the record of what a session did; editing old ones to pass a gate is the opposite of what the gate is for. |

### The unauthenticated sweep

`main/scripts/route-sweep.py` is new (Stream W's W4, never built). It walks every route the app
declares and every route the policy names, substitutes a sample path parameter, fetches each one
with no cookie, and compares the answer with what `access.decide()` says an anonymous caller must
get. The expectations are read out of `decide()` for every key, so the checker cannot come to
disagree with the policy it checks. It never sends anything but a GET.

```
../.venv-dev/bin/python scripts/route-sweep.py https://dutyfreeprofessor.demoing.info
route-sweep: 243 routes, members mode, staging role; 154 ok, 89 skipped
exit 0
```

| Answer | Count | Meaning |
|---|---|---|
| 401, as expected | 88 | every API read behind the members floor, the member routes and the permission routes |
| 302 to `/login`, as expected | 50 | every page and the SPA paths the catch-all serves |
| 404, as expected | 3 | `sitemap.xml`, `feed.xml`, `llms.txt`: a crawler learns no shape while the site is members-only |
| 200 from the handler | 7 | `/api/health`, `/api/bw/me`, `/robots.txt` and the four sign-in pages, all `public_always` |
| 404 from the handler | 6 | the six static mounts fetched at their own root, which serve no index |
| skipped | 89 | every route whose method is not GET; a sweep that posts is not a sweep |
| **LEAK** | **0** | no internal route answered 200, and none redirected anywhere but the sign-in page |
| **MISMATCH** | **0** | |

By hand, signed out, reading the headers:

| Address | Status | `via` | `cache-control` | `x-robots-tag` | Body |
|---|---|---|---|---|---|
| `/api/collectors/live` | 401 | `1.1 Caddy` | `no-store` | `noindex, nofollow` | 74 bytes of JSON |
| `/api/review/sheets` | 401 | `1.1 Caddy` | `no-store` | `noindex, nofollow` | 74 bytes of JSON |
| `/api/plan` | 401 | `1.1 Caddy` | `no-store` | `noindex, nofollow` | 74 bytes of JSON |
| `/collectors` | 302 to `/login?next=/collectors` | `1.1 Caddy` | `no-store` | `noindex, nofollow` | empty |
| `/api/pages/status` (the badge's read) | 401 | `1.1 Caddy` | `no-store` | `noindex, nofollow` | 74 bytes of JSON |
| `/` | 302 to `/login` | `1.1 Caddy` | `no-store` | `noindex, nofollow` | empty |

**The app answered, not the site gate.** No redirect went to `auth.bowden.works` in any of the 243
fetches, so every line above is this application's own policy speaking. The sweep reports a
redirect to the sign-on host as `gate` rather than as a pass, precisely so a gated host can never
hide an open route behind someone else's refusal.

**The page-status badge (AW5) never leaks.** Its class name `page-status` and its four words appear
in no server-rendered answer: not in the built shell (`web/dist/index.html`), not in `seo.py`, and
in no page body a server renders; the only files that carry it are the client bundle and stylesheet
under `/assets`, which is where a React component belongs, and two Python comments. Anonymously on
staging every page answers 302 with an empty body, so there is nothing to leak into. The same check
against a **server-rendered body with `SITE_ACCESS=public`** needs the local copy AW7.2 stands up,
and is carried into that task.

### Findings

| # | Finding | Evidence | Carried by |
|---|---|---|---|
| 1 | `docs-check.sh --strict` reports `backups/` as a source that does not exist when run in a worktree | the 11th fail in the worktree, absent in the main checkout | `issue-docs-check-strict-calls-backups-a-missing-source-when-ru` |
| 2 | `CLIENT-SURFACES.md` names `import/` as a source, and `import/` is rewritten by every `/plan` update, so the doc is stale by construction under `--strict` | both runs | `issue-client-surfaces-md-is-stale-by-construction-import-is-re` |
| 3 | Ten handoff entries exceed the 25-line gate | both runs | not a defect: deliberately not trimmed |
| 4 | Nothing else. The internal route set is closed on staging. | the sweep, exit 0 | |

### What this task changed

- `main/app/services/route_walk.py` (new): the walk over routes, mounts and SPA paths, moved out
  of `tests/test_route_inventory.py` so the test and the script read one list.
- `main/scripts/route-sweep.py` (new), `main/tests/test_route_sweep.py` (new, 9 tests).
- `main/docs/RUNBOOK.md` step 18 now names the script that exists, with the two flags that make it
  right on the live host.
- The suite after this task: **2,518 tests**, green (`main/check.sh` prints checks passed). Moving
  the walk broke two tests in `test_review_sheet.py` that imported it from the inventory test; they
  now import the one home, which is the point of moving it.

---

## AW7.2 Cross-device: nine pages at 1440 and 390

### What was measured, and on what

A copy of staging, served here, signed out and signed in. Staging itself is members-only and no
credential goes into a transcript, so the browser pass runs against a restored copy on the dev
database, where a throwaway owner can exist for an hour and be dropped.

| Step | What was done |
|---|---|
| The copy | `docker exec dutyfreeprofessor-db pg_dump -U dfp -Fc dfp > backups/dfp-aw7-2026-09-19.dump` (19,199,262 bytes, a read of staging and the only touch it gets), restored into `dfp_aw7` on `dfp-devdb` (127.0.0.1:5433). 25,732 listings; schema head `aw5d1e2f3a4b`, which is what D4 and D5 left |
| The shell | `npm run build` in `main/web`. The app mounts its shell from `main/static`, which the Dockerfile fills with `web/dist`; without it every page answers 403 `ROUTE_NOT_CLASSIFIED`, because no route matches at all. A symlink there is the obvious answer and is the wrong one (see the finding below): the pass mounts the built shell after import instead, the way `scripts/route-sweep.py` does |
| The server | `uvicorn serve_app:app` on `127.0.0.1:8099` with `DATABASE_URL` at the copy, `SITE_ACCESS=public`, `ACCOUNT_OWNER=aw7`, `APP_ENV=production`, `UPLOADS_DIR` at the workspace's `uploads/`, and `LINE_PAGES` left at its code default, which is what staging runs |
| The owner | `accounts create --username aw7 --level admin` then `accounts set-password`, the password generated into a shell variable, fed to the prompt and never printed, logged or written to a file. The cookie came from one `POST /api/auth/login` and lives in a 0600 file in the scratchpad. Nothing about it is committed, and `dfp_aw7` is dropped when the pass ends |
| Staging | Untouched. `/api/health` on it reads `{"status":"ok","version":"0.52.0","database":"ok","role":"staging"}`, which is D5 in place: AW7.1 ran before that deploy finished and recorded no `role` |

### The tool

`main/scripts/page-probe.py` (new) draws one page at one width in headless Chrome on an
ephemeral debugging port with a scratch profile, speaks the DevTools Protocol over a websocket,
and prints JSON. It measures four things a `curl` cannot: what sticks out past the right edge of
the viewport and whether anything scrolls to it, what text a box cuts off, the landmark and
heading outline as drawn, and what a given number of Tab presses reaches. `--ax` adds the
accessibility tree and `--reduced-motion` redraws the page under
`prefers-reduced-motion: reduce`, both for AW7.3. It never posts, and the only cookie it sets is
the one handed to it.

Two things it learned the hard way, both now pinned by `main/tests/test_page_probe.py` (11 tests):

- **The session cookie is `__Host-`prefixed,** and that prefix is a rule the browser enforces: no
  `domain`, path `/`, `secure`. Copied out of the login with a domain attached, Chrome answered
  `Sanitizing cookie failed` and the signed-in page was simply absent from the first pass.
- **A shelf that scrolls sideways is a design, not a break.** The first run reported four breaks
  on the airport page that were the featured row and the page's own jump links doing exactly what
  their stylesheets say to do below 720px. Each offender now names the ancestor that actually
  scrolls to it, and only one with none is a finding. Without that rule this report would have
  carried thirteen false breaks.

### The nine pages, both widths

`no` in the third column means the document itself does not scroll sideways at that width.
"Wider than the viewport" counts the outermost elements whose right edge is past it;
"unreachable" is the subset with nothing scrolling to them, which is the break that matters.

| page | width | page scrolls sideways | wider than the viewport | unreachable | hard cut text | truncated on purpose |
|---|---|---|---|---|---|---|
| `/` | 1440 | no | 0 | 0 | 0 | 3 |
| `/` | 390 | no | 0 | 0 | 0 | 4 |
| `/airports/heathrow-lhr-london` | 1440 | no | 0 | 0 | 0 | 4 |
| `/airports/heathrow-lhr-london` | 390 | no | 4 | **0** | 0 | 1 |
| `/alcohol/whisky` | 1440 | no | 0 | 0 | 0 | 4 |
| `/alcohol/whisky` | 390 | no | 0 | 0 | 0 | 0 |
| `/products/<line>` | 1440 | no | 0 | 0 | 0 | 0 |
| `/products/<line>` | 390 | no | 0 | 0 | 0 | 0 |
| `/products/<line>?variant=<id>` | 1440 | no | 0 | 0 | 0 | 0 |
| `/products/<line>?variant=<id>` | 390 | no | 1 | **0** | 0 | 0 |
| `/brands/<brand>` | 1440 | no | 0 | 0 | 0 | 0 |
| `/brands/<brand>` | 390 | no | 0 | 0 | 0 | 0 |
| `/articles` | 1440 | no | 0 | 0 | 0 | 0 |
| `/articles` | 390 | no | 0 | 0 | 0 | 0 |
| `/articles/<slug>` | 1440 | no | 0 | 0 | 0 | 0 |
| `/articles/<slug>` | 390 | no | 0 | 0 | 0 | 0 |
| `/collectors` (signed in) | 1440 | no | 0 | 0 | 0 | 0 |
| `/collectors` (signed in) | 390 | no | 8 | **0** | 0 | 0 |

The pages were `/`, Heathrow, whisky, the product line `jean-paul-gaultier-le-male`, the same
line with `?variant=12134` (the product variant view), the brand `guerlain`, the article centre,
the article `how-airport-pricing-works`, and the collectors page with the cookie. Every one drew
in under 2.5 seconds and every screenshot is in the pass's scratch folder, one PNG per page per
width, full length.

**No page breaks at either width.** Every element wider than the viewport sits inside something
that scrolls to it:

| page | width | what is wide | how far past | what scrolls to it | by design in |
|---|---|---|---|---|---|
| airport | 390 | three of the four featured picks | 184 to 775 px | `.product-grid--row` | `AirportFeatured.css`, below 720px the row is a snap shelf |
| airport | 390 | the fourth jump link | 94 px | `nav.airport-jump` | `AirportJump.css`, one line that scrolls rather than a block that pushes the facts down |
| product variant | 390 | `table.price-table` (`min-width: 560px`) | 211 px | `div.scroll-x` | `app.css`, the shared table wrapper |
| collectors | 390 | eight `table.qa-table` | 254 to 264 px | `section.qa-card` | `CollectorsPage.css`, `overflow-x: auto` on the card |

"Truncated on purpose" is a line clamp or an ellipsis, counted apart from a hard cut: the product
card's two-line name clamp, the home strips, the article excerpts. The header's airport button at
390 reports as a 1px clipped box, which is the visually-hidden idiom (`MyAirportsButton.css` keeps
the words for a screen reader and shows the plane alone below 480px); the probe skips those and
counts none of them as findings.

### The page-status badge, signed out and signed in (AW7.1's carried check)

AW7.1 proved the badge is absent from the built shell, from `seo.py` and from every anonymous
answer on staging, but staging answers every page 302 with an empty body, so there was no
server-rendered body to look at. There is now:

| Body fetched from the local copy, `SITE_ACCESS=public` | Bytes | `page-status` or its four words |
|---|---|---|
| `/` | 2,560 | none |
| `/airports/heathrow-lhr-london` | 105,211 | none |
| `/alcohol/whisky` | 96,041 | none |
| `/products/<line>` | 31,560 | none |
| `/products/<line>?variant=<id>` | 33,094 | none |
| `/brands/<brand>` | 50,240 | none |
| `/articles` | 30,542 | none |
| `/articles/<slug>` | 15,383 | none |
| the same eight signed in as the owner | | none |

The one match for `noindex` in any of them is the page's own
`<meta name="robots" content="noindex, follow" />`, which is the indexing state the page is in,
not the badge. Drawn in the browser as the owner, the badge appears where its stylesheet puts it:
a fixed chip at the bottom left, with its note and its fact hidden below 600px. **The check is
closed: the badge is client-side only and reaches no served body.**

### Findings

| # | Finding | Evidence | Carried by |
|---|---|---|---|
| 1 | On a phone the price table's third column, which holds the CHEAPEST pill, begins past the right edge; nothing is unreachable, but the shopper has to discover the sideways scroll to see the one signal the page is for | `/products/<line>?variant=<id>` at 390: `table.price-table` 560 wide, 211 px past the viewport, scroller `div.scroll-x` | `issue-on-a-phone-the-price-table-s-cheapest-marker-starts-off` (info, owner Stream S) |
| 2 | Every page carries one `<nav>` with no `aria-label` at 1440 and none at 390, so the count is the desktop menu | the probe's outline, all nine pages | AW7.3 (one attribute, its task) |
| 3 | `/articles`, `/articles/<slug>` and `/collectors` draw two `<header>` elements, and `/articles` and `/collectors` skip a heading level once | the probe's outline | AW7.3 (a nested `<header>` inside an article card is legitimate; the skip is the one to judge) |
| 4 | A symlink at `main/static`, the obvious way to serve the built shell locally, fails three tests in `test_access.py`: the suite stubs a static directory and the real mount wins, so `/logo.png` answers the app's not-found instead of the stub | `check.sh` red on 3 of 2,529 tests with the symlink, green without it | `issue-serving-the-built-shell-locally-breaks-three-access-test` (info, owner Stream Docs) |
| 5 | No layout break at either width on any of the nine pages | the table above | nothing to carry |

### What this task changed

- `main/scripts/page-probe.py` (new) and `main/tests/test_page_probe.py` (new, 11 tests).
- One line under `## Unreleased` in `main/CHANGELOG.md`.
- No stylesheet was touched: there was no break small enough to fix, because there was no break.
- The suite after this task: **2,529 tests**, green (`main/check.sh` prints checks passed).

### What is left standing for AW7.3 and AW7.4

The copy, the server and the cookie stay up for the next two tasks and are dropped by AW7.5:
`dfp_aw7` on `dfp-devdb`, the server on `127.0.0.1:8099` (restart it with `serve.sh` in the pass's
scratch folder, which sets the environment and mounts the shell through `serve_app.py`), and the
0600 cookie file beside it. The working tree carries nothing of the pass: no `main/static`.
The collectors page opens on its Collectors tab, not the live one, so AW7.3's keyboard pass on
the live controls has to switch tabs first.

---

## AW7.3 Accessibility of the new components

### What was measured, and how

The same restored copy served locally that AW7.2 stood up (`dfp_aw7` on `dfp-devdb`,
`SITE_ACCESS=public`, the throwaway owner's cookie in a 0600 file in the scratchpad), the same
nine pages, and `scripts/page-probe.py` with `--ax` and `--tab`, which reads Chrome's own
accessibility tree and walks the focus path. Each page was drawn twice: once before the fixes
below and once after, so every line in the tables is a measurement rather than a reading of the
source. The served bodies were fetched separately with `curl` and no JavaScript ever run.

Three things a browser cannot be asked for here, and what stood in for each:

| Not measurable | Why | What was done instead |
|---|---|---|
| Pause, Resume, Stop, Stop now, Mark as ended on the live page | They are drawn only for a collector in that state, and starting one is a network collection, which this stream never runs | Read at the source: all five are the same `<button type="button" className="qa-btn">` the switch in `CollectorRow.tsx` emits for Start and the details toggle, both of which were driven by keyboard and are in the table below |
| Escape closing the Start menu | The probe presses Tab and nothing else | Pinned at the source by `tests/test_landmarks.py`, against the four existing implementations of the same pattern |
| A figure ticking up under reduced motion | Nothing was running on the copy, so no counter changed between two polls | The page was drawn under `prefers-reduced-motion: reduce` and compared with the normal draw; the query and the two stylesheet blocks are pinned by test |

### Landmarks and heading order, the nine pages as drawn

The accessibility tree, not the tag names. This matters: AW7.2 reported `/articles`,
`/articles/<slug>` and `/collectors` drawing two `<header>` elements, and the tree says all three
draw **one** `banner`. A `<header>` inside `<main>` or inside an `<article>` is a generic group,
never a second banner, so the page head and the article head are correct as written. AW7.2's
finding 3 closes with nothing to fix.

| page | banner | main | contentinfo | navigations | unnamed landmark, before | after | heading skip, before | after |
|---|---|---|---|---|---|---|---|---|
| `/` 1440 | 1 | 1 | 1 | 4 | 1 | 0 | none | none |
| `/` 390 | 1 | 1 | 1 | 3 | 0 | 0 | none | none |
| `/airports/heathrow-lhr-london` 1440 | 1 | 1 | 1 | 7 | 1 | 0 | none | none |
| `/alcohol/whisky` 1440 | 1 | 1 | 1 | 7 | 1 | 0 | none | none |
| `/products/<line>` 1440 | 1 | 1 | 1 | 4 | 1 | 0 | none | none |
| `/products/<line>?variant=<id>` 1440 | 1 | 1 | 1 | 4 | 1 | 0 | none | none |
| `/brands/<brand>` 1440 | 1 | 1 | 1 | 5 | 1 | 0 | none | none |
| `/articles` 1440 | 1 | 1 | 1 | 5 | 1 | 0 | **h1 to h3** | none |
| `/articles` 390 | 1 | 1 | 1 | 4 | 0 | 0 | **h1 to h3** | none |
| `/articles/<slug>` 1440 | 1 | 1 | 1 | 4 | 1 | 0 | none | none |
| `/collectors#live` 1440 (signed in) | 1 | 1 | 1 | 4 | 1 | 0 | **h1 to h3** | **h1 to h3** |
| `/collectors#live` 390 (signed in) | 1 | 1 | 1 | 3 | 0 | 0 | **h1 to h3** | **h1 to h3** |

Exactly one `h1` on every page at both widths, before and after. The single unnamed landmark on
every page at 1440 was the same one: the site header's own menu, which the phone width hides,
which is why it read as clean at 390 and why no earlier pass caught it. The footer's three
columns have carried labels since they were written.

`/collectors` keeps its skip: the fix there is a heading per tab panel, seven call sites rather
than one rule, and no shopper reaches the page. Filed.

### The served bodies, JavaScript never run

Fetched with `curl` from the local copy with `SITE_ACCESS=public`, so a real body comes back
rather than a redirect. This is what a crawler and a reader with JavaScript off get, and it is
`app/services/seo.py`'s work, not the SPA's, so nothing here was fixed by this task.

| served body | bytes | main | header | footer | nav | nav with no label | h1 | heading skip |
|---|---|---|---|---|---|---|---|---|
| `/` | 2,561 | 0 | 0 | 0 | 0 | 0 | 0 | none: the shell alone, no server-rendered body |
| `/airports/heathrow-lhr-london` | 105,128 | 1 | 1 | 0 | 4 | **1** | 1 | none |
| `/alcohol/whisky` | 95,968 | 1 | 1 | 0 | 4 | **1** | 1 | none |
| `/products/<line>` | 31,538 | 1 | 1 | 0 | 1 | **1** | 1 | none |
| `/products/<line>?variant=<id>` | 33,074 | 1 | 1 | 0 | 1 | **1** | 1 | none |
| `/brands/<brand>` | 50,164 | 1 | 1 | 0 | 2 | **1** | 1 | none |
| `/articles` | 30,515 | 1 | 2 | 0 | 2 | **1** | 1 | **h1 to h3** |
| `/articles/<slug>` | 15,376 | 1 | 2 | 1 | 1 | **1** | 1 | none |

Three gaps, all in `seo.py`, all filed against its owner: the unnamed `<nav class="site-nav">` it
writes at line 886, the article index going from its h1 to the cards' h3 (`articles_body`, the
card heading at line 2842), and no `<footer>` in any served body but the article page, so the
shell a crawler reads has a banner and a main and no contentinfo. The home page serves no body at
all, which is what `docs/SEO.md` says of it.

### Contrast

Every pair the new components draw, read out of `tokens.css` by `tests/test_tokens_contrast.py`.
Fourteen pairs were added to `PAIRS`; all pass, and all pass under each of the nine colour schemes
as well, checked once by hand here (the test reads the default set, which is a gap worth a later
session, not a finding about these components).

| drawn by | pair | bar | ratio |
|---|---|---|---|
| the page-status chip's words | `--ink-soft` on `--surface-card` | 4.5 | 5.29 |
| its note | `--ink` on `--surface-card` | 7.0 | 15.55 |
| its Review link | `--accent-ink` on `--surface-card` | 4.5 | 5.13 |
| its badge | `--ink-inverse` on `--navy-deep` | 4.5 | 14.74 |
| the article card's headline | `--navy` on `--surface-page` | 4.5 | 9.83 |
| its excerpt, its tag chips, the index lede | `--ink-muted` on `--surface-page` | 4.5 | **4.81** |
| the same card on a white panel | `--ink-muted` on `--surface-card` | 4.5 | 5.33 |
| the live page's note after an action | `--good` on `--surface-card` | 4.5 | 6.47 |
| its refusal | `--bw-danger` on `--surface-card` | 4.5 | 6.54 |
| its blocked start and "not refreshed since" | `--warn` on `--surface-card` | 4.5 | 5.84 |
| the freeze banner | `--warn` on `--warn-surface` | 4.5 | 5.22 |
| the missing-hours line | `--warn` on `--surface-page` | 4.5 | 5.27 |
| the progress fill in its track | `--navy` on `--surface-sunken` | 3.0 | 8.84 |
| the paused fill | `--info` on `--surface-sunken` | 3.0 | 7.11 |
| the stalled fill, the memory line's first mark | `--warn` on `--surface-sunken` | 3.0 | 4.74 |
| the dead fill, the shed mark | `--bw-danger` on `--surface-sunken` | 3.0 | 5.30 |

`--ink-muted` on cream clears its bar by 0.31, and it is what every article card's excerpt and
every tag chip is written in, so a darkening of the page surface or a lightening of the muted ink
drops the whole index at once. That is why the pair is in the test rather than in a note.

The state chip on the live page is the vendored pack's, and its colours are a `color-mix`
rather than a pair of tokens, so the test cannot hold it. Computed here: the text mixes the
state's tone 75 per cent with the ink and sits on the same tone at 12 per cent over white, giving
6.90 for `--good`, 8.45 for `--info`, 6.41 for `--warn`, 7.04 for `--bw-danger`, 5.99 for
`--ink-muted` and 5.93 for `--ink-soft`, all against a 4.5 bar; the dot beside each word clears
3.0 with 4.52 at worst. Nothing to fix, nothing the test can hold.

**No colour pair in the new components is drawn with a literal.** The two literal values in their
stylesheets are a drop shadow and a dot texture, neither of which is text on a ground. One literal
pair does exist elsewhere and is filed: `.btn--primary:hover, .btn--primary:focus-visible` in
`app.css` sets `#B0431F`, a coral that is in no scheme, so under five of the nine schemes the
primary button rests in the scheme's accent and turns coral the moment a reader tabs to it. White
on it is 5.72, so no bar fails; what fails is the scheme.

### Keyboard

Every control reached, in reading order, with the focus ring drawn and the element in view.

| page | width | Tab presses | controls reached | no focus ring | unnamed to the tree |
|---|---|---|---|---|---|
| `/` | 390 | 10 | 10 | 0 | 0 of 119 nodes |
| `/articles` | 1440 | 26 | 26, the last seven the tag rail | 0 | 0 of 109 |
| `/articles/<slug>` | 1440 | 16 | 16 | 0 | 0 of 104 |
| `/collectors#live` | 1440 | 40 | 40, the last twelve four collector rows | 0 | 0 of 423 |
| `/collectors#live` | 390 | 26 | 26, the last nine three collector rows | 0 | 0 of 411 |

The live page's rows were idle on the copy, so what the keyboard actually drove was Start, Choose
the mode and Show details, three times over at 390 and four at 1440, each named, each ringed. The
ring is Chrome's own: no stylesheet gives `.qa-btn` a focus rule, which is acceptable and worth
knowing, because a browser that draws a weaker default would weaken the whole internal area at
once.

Two things the path shows that a count of controls does not:

- **Twenty-one Tab presses at 1440 before the page's own content**, nineteen signed out. There is
  no skip link, so a sighted keyboard reader walks the announcement strip, the logo, the search
  box, the airports button, the account menu, the bell and eight menu links on every page. The
  landmarks now being named answers this for a screen reader; it does not answer it for a keyboard.
  Filed: the fix is a link, a rule and an id on the main element, three files rather than one.
- **The header's logo link reads as unnamed in the focus path and is named in the tree**, because
  the probe's focus reader looks at `aria-label` and text and the name comes from the picture's
  alt text. AW7.2 saw the same. Not a finding: the tree is what a screen reader reads.

Everything in the new components is a native control. No `onClick` sits on an element a keyboard
cannot reach, which is why Enter and Space need no separate proof, and a test now says so.

### Reduced motion

`useCountUp` is what ticks the figures on the live page. It reads
`matchMedia("(prefers-reduced-motion: reduce)")` at the moment a target changes and sets the value
outright when it matches, and it snaps downward always, because a count that fell is a new run
rather than something to animate. It reads the query itself rather than through `lib/useMediaQuery.ts`,
which is one home fewer than the project prefers; the behaviour is right and the query's spelling is
now pinned by a test, so this is recorded rather than filed.

`CollectorRow.css` and `LiveBoard.css` each answer the query too: the pulse on a running chip stops
and the bars stop sliding. Drawn under `Emulation.setEmulatedMedia` with
`prefers-reduced-motion: reduce`, the live page is identical to the normal draw at both widths:
8,630 characters of text at 1440 and 8,538 at 390 in both, 423 and 411 tree nodes in both, no
element out past the edge in either. Nothing was running on the copy, so no figure was mid-tick;
the query and the two stylesheet blocks are the part a test can hold, and it does.

### Findings

| # | Finding | Evidence | Carried by |
|---|---|---|---|
| 1 | The site header's own menu was an unnamed landmark on all nine pages: the first navigation a reader jumps to, announced as nothing | the tree at 1440, one unnamed `navigation` per page, none at 390 | fixed, `SiteHeader.tsx`; pinned by `tests/test_landmarks.py` |
| 2 | The article index skipped a heading level, h1 straight to the cards' h3, because the filter rail between them is a nav | the probe's outline, `/articles` at both widths | fixed, `ArticlesPage.tsx` and its stylesheet; pinned |
| 3 | The live page's Start menu did not close on Escape, unlike every other pop-out on the site | `CollectorRow.tsx` at `8b42af3` against four other implementations | fixed, `CollectorRow.tsx`; pinned |
| 4 | The airport and brand skeleton's crumbs were an unnamed nav, inside an `aria-hidden` block so no reader met it | source | fixed for the flat rule, `HubSkeleton.tsx` |
| 5 | The served bodies keep all three of the above, in `seo.py`, which this stream must not touch | the curl table above, with `seo.py`'s line numbers | `issue-the-served-bodies-carry-the-same-three-accessibility-gap` (info, owner Stream B) |
| 6 | `/collectors` jumps h1 to h3 | the probe's outline at both widths | `issue-the-collectors-page-jumps-from-its-h1-to-the-cards-h3` (info, owner Stream AW4) |
| 7 | Twenty-one Tab presses before the page's own content; no skip link | the focus path on `/collectors` at 1440 | `issue-no-way-past-the-header-twenty-one-tab-presses-before-the` (info, owner Stream C) |
| 8 | The primary button's hover and focus colour is a literal, so it leaves the scheme | `app.css` line 124, `#B0431F`, white on it 5.72 | `issue-the-primary-button-s-hover-and-focus-colour-is-a-literal` (info, owner Stream C) |
| 9 | AW7.2's "two `<header>` elements" is not a defect: the tree draws one banner on every page | the tree, all nine pages | closed, nothing to fix |
| 10 | No contrast pair in the new components fails, at the default scheme or at any of the nine | the table above | nothing to carry |

### What this task changed

- `main/web/src/components/SiteHeader.tsx`: `aria-label="Main"` on the site menu.
- `main/web/src/components/HubSkeleton.tsx`: `aria-label="Breadcrumb"` on the skeleton crumbs.
- `main/web/src/pages/ArticlesPage.tsx` and `ArticlesPage.css`: the group heading the index
  speaks and does not draw, in the stylesheet the component already imports.
- `main/web/src/components/collectors/CollectorRow.tsx`: Escape and a click away close the
  Start menu, in the same shape as the site's four other pop-outs.
- `main/tests/test_tokens_contrast.py`: fourteen pairs added to `PAIRS`.
- `main/tests/test_landmarks.py` (new, 6 tests): every nav is named, the index names its group,
  the Start menu closes on Escape, the tick asks about motion and both stylesheets answer, and
  nothing clickable is a `div`.
- One line under `## Unreleased` in `main/CHANGELOG.md`.
- The suite after this task: **2,535 tests**, green (`main/check.sh` prints checks passed).

### What is left standing for AW7.4

The copy, the local server and the cookie stay up and are dropped by AW7.5. The server was
restarted onto a fresh `vite build` for the after measurements, so `main/web/dist` on disk is the
build with these four fixes in it, which is the one AW7.4 should measure.

---

## AW7.4 Performance: the three heaviest pages and the bundle

### What was measured, and where

Three vantage points, because each answers a different question, and the bundle from the build
on disk.

| Vantage | What it answers | How |
|---|---|---|
| Staging over the internet, no cookie | what the access decision costs when no handler runs | `curl -w '%{time_total}'`, five runs per target, against `https://dutyfreeprofessor.demoing.info` |
| Staging's handlers, inside the app container | what a read costs against the real data, with no HTTP, no serialisation and no network | `docker exec dutyfreeprofessor-app python -` over one `app.db.SessionLocal`, one warm call then five timed, median and max. Reads only: the session is rolled back and closed, and nothing was written |
| The AW7.2 copy, served here | the full path a signed-out reader pays, end to end | `curl` against `127.0.0.1:8099` (`dfp_aw7` on `dfp-devdb`, `SITE_ACCESS=public`), five runs per target, before and after `ANALYZE` |

The data under all of it: 18,343 product variants, 25,732 listings, 77,370 price observations.
The featured order can draw on 11,251 records, of which 2,376 are comparisons (a product variant
priced at two or more airports).

### Staging, anonymously: the middleware with no handler behind it

| Target | Answer | p50 | max |
|---|---|---|---|
| `/api/health` | 200 | 97 ms | 110 ms |
| `/` | 302 | 94 ms | 108 ms |
| `/airports/heathrow-lhr-london` | 302 | 91 ms | 106 ms |
| `/api/products` | 401 | 90 ms | 103 ms |
| `/api/products/featured-savings` | 401 | 90 ms | 112 ms |

About 90 ms is the round trip plus Caddy plus the access decision, and the four internal answers
cost the same as the health read: in members mode the classification answers before a handler
is reached, which is what AW7.1's sweep proves and this measures. Nothing here is a finding.

### The handlers, timed inside the app container

Five runs each after one warm call, over one session, reads only.

| Read | median | max |
|---|---|---|
| `catalog_queries.category_detail("Whisky")` | 1,236 ms | 1,598 ms |
| `catalog_queries.list_product_variants(limit=24, sort="featured")` | 952 ms | 1,112 ms |
| `catalog_queries.airport_detail("LHR")` | 902 ms | 1,108 ms |
| `catalog_queries.featured_savings(total=8)` | 609 ms | 698 ms |
| `seo.airport_body(detail)`, the render alone | 0.6 ms | 0.6 ms |

The body render is not the cost of a server-rendered page: `airport_body` over a detail already
in hand is under a millisecond against `airport_detail`'s 902.

### Where the time goes

Each part timed the same way, five runs in the container.

**The full list at `/products`, first page**

| Part | median | max |
|---|---|---|
| the count over `_filtered_summary` | 279 ms | 304 ms |
| `featured_records`, the 2,376 comparisons | 489 ms | 545 ms |
| `featured_records`, all 11,251 records | 1,189 ms | 1,302 ms |
| `featured_rules.order` over 11,251 in Python | 60 ms | 66 ms |
| the fetch of 24 rows with their cards | 29 ms | 32 ms |

`_featured_ids` already short-circuits the whole list: the first page ranks the comparisons only
and never pays the 1,189 ms, exactly as its docstring says. What is left is one count and one
whole-catalogue aggregate per request.

**The airport page**

| Part | median | max |
|---|---|---|
| `_airport_rows`, 459 rows | 392 ms | 484 ms |
| `list_product_variants` for the page of 24 | 229 ms | 253 ms |
| `list_product_variants` for the exclusives sample | 119 ms | 190 ms |
| `airport_shops` | 6.5 ms | 6.8 ms |
| `_airport_shop_views` | 2.4 ms | 2.5 ms |
| `_airport_summary` | 0.8 ms | 1.2 ms |

**The category page**

| Part | median | max |
|---|---|---|
| `category_page_summaries` for the siblings | 306 ms | 610 ms |
| `_category_rows` | 289 ms | 486 ms |
| `list_product_variants` for the page of 24 | 206 ms | 269 ms |
| `list_product_variants` for the exclusives sample | 181 ms | 188 ms |
| `list_product_variants` for the savings sample | 179 ms | 242 ms |
| `_category_brands` | 16 ms | 38 ms |

One shape runs through all three: a handler calls `list_product_variants` two or three times, and
each call opens with its own count over the filtered set before it selects a row. The category
page counts the catalogue three times to draw one page.

### The copy, served here, signed out

Five runs per target. `ANALYZE` on `dfp_aw7` took 1.4 s.

| Target | Answer | bytes | p50 before | max before | p50 after | max after |
|---|---|---|---|---|---|---|
| `/` | 200 | 2,561 | 2 ms | 3 ms | 3 ms | 4 ms |
| `/products` | 200 | 2,072 | 2 ms | 3 ms | 4 ms | 4 ms |
| `/airports/heathrow-lhr-london` | 200 | 105,212 | 510 ms | 514 ms | 513 ms | 533 ms |
| `/alcohol/whisky` | 200 | 96,042 | 653 ms | 741 ms | 706 ms | 718 ms |
| `/api/products/featured-savings?total=8` | 200 | 6,402 | 384 ms | 466 ms | 398 ms | 470 ms |
| `/api/trip/featured` | 200 | 3,115 | 395 ms | 427 ms | 388 ms | 428 ms |
| `/api/products?limit=24&offset=0&sort=featured` | 200 | 18,696 | 535 ms | 588 ms | 554 ms | 628 ms |
| `/api/airports/LHR?sort=featured&limit=24&offset=0` | 200 | 54,442 | 524 ms | 633 ms | 527 ms | 626 ms |
| `/api/categories/whisky?sort=featured&limit=24&offset=0` | 200 | 32,446 | 727 ms | 802 ms | 717 ms | 747 ms |

**`ANALYZE` moved nothing.** Every difference is inside the run-to-run spread, and two targets
read slower after it. A `pg_restore` leaves the planner enough statistics on this data, so the
before and after columns are one measurement, not two, and the thresholds below are read against
the after column as the brief asks.

Two of the three pages the brief names are the shell and nothing else. `/` and `/products` are
2.5 kB of HTML in 3 ms, because neither is server-rendered: their cost is in the reads the
browser makes next. The airport page and the category page are rendered by `seo.py` and carry
their bodies, which is where their half-second sits.

**What the home page actually reads.** The brief names `/api/trip/featured` as one of the home
page's two reads; nothing in the SPA calls it (`api/queries.ts` has no reference), so it is timed
above as a route, not as part of the page. The home page fires three reads in parallel plus the
stops list:

| Read | p50 | max |
|---|---|---|
| `/api/products/featured-savings?total=8` | 398 ms | 470 ms |
| `/api/products?exclusives_only=true&sort=featured&limit=6` | 271 ms | 285 ms |
| `/api/products?awarded_only=true&sort=featured&limit=6` | 149 ms | 178 ms |
| `/api/trip/stops` | 9 ms | 10 ms |

They run together, so the home page's data is there in about 400 ms, its slowest read. The home
page is the fastest of the three.

### Is anything missing an index

`EXPLAIN (ANALYZE, BUFFERS)` on the copy, on the statements behind the two slowest paths.

| Statement | planning | execution | rows | buffers |
|---|---|---|---|---|
| the count over `_filtered_summary` | 3.4 ms | 153 ms | 11,251 groups | 205,700 shared hits, no reads |
| `_filtered_summary` itself | 2.0 ms | 231 ms | 11,251 | 205,700 shared hits |
| `_airport_rows` for LHR | 5.7 ms | 170 ms | 459 | 272,113 shared hits |

**No index would change these plans, so nothing here is a migration.** The only sequential scans
are `price_observations` (77,370 rows in 4.5 ms), `shops` (16 rows) and `shop_places` (24 rows),
and reading a small table end to end is the right plan, not a missing index. Every buffer is a
cache hit: no disk read appears in any plan. What the numbers say instead is volume: a quarter of
a million buffer touches to answer one page, because the whole catalogue is aggregated on every
request.

One planner note, not a defect today: the row estimates are about seventy-five times low (150
estimated against 15,012 actual on the count, 6 against 459 on the airport rows), because the
publish and visibility filters are correlated subqueries the planner cannot see through. At this
size it still picks a workable plan. At ten times the data it may not, and the first symptom
would be a nested loop that should have been a hash join.

### The bundle

`npm run build` in `main/web`, then `gzip -9 -c | wc -c` over every file in `main/web/dist/assets`
(43 files). The figures below are that gzip; vite's own column differs by a few hundred bytes.

| File | raw | gzipped |
|---|---|---|
| `index-DQUAh4t7.js`, the entry | 470,012 | 137,953 |
| `index-R03dmFwE.css`, the entry | 155,137 | 25,512 |
| `CollectorsPage-eKu0w3Gx.js`, the largest lazy chunk | 78,656 | 20,306 |
| `ReviewPage-sW9f77AY.js` | 62,743 | 16,843 |
| `StructurePage-BKC76axK.js` | 48,748 | 14,602 |
| `AdminPage-DOC3CYbO.js` | 27,006 | 8,016 |
| `DiscussionPage-BzhbcJzK.js` | 22,894 | 8,204 |

| Total | raw | gzipped |
|---|---|---|
| first load, every page (entry JavaScript and entry CSS) | 625,149 | 163,465 |
| the heaviest lazy route, all its files (`CollectorsPage`) | 96,051 | 24,815 |
| all JavaScript | 796,536 | 239,458 |
| all CSS | 268,903 | 51,694 |
| everything in `assets/` | 1,065,439 | 291,152 |

`dist/index.html` is 1,117 bytes. The five heaviest lazy chunks are all internal pages
(collectors, review, structure, admin, discussion), so a reader who never signs in downloads the
entry pair and nothing else.

### Against AW7's own thresholds

The standards state none (`frontend.md` and `react.md`, checked 19 Sep), so these are this
stream's, provisional, recorded here and nowhere else. Measured on the copy after `ANALYZE`.

| Threshold | Result |
|---|---|
| a page or API read p50 under 500 ms | **5 of 9 over**: `/alcohol/whisky` 706, `/api/categories/whisky` 717, `/api/products` 554, `/api/airports/LHR` 527, `/airports/heathrow-lhr-london` 513 |
| max under 1 s | **pass**, every target; the slowest single run of the pass was 802 ms |
| entry JavaScript at most 250 kB gzipped | **pass**, 137,953 bytes, 55 per cent of the bar |
| any lazy chunk at most 200 kB gzipped | **pass**, largest 20,306 bytes, 10 per cent of the bar |

Inside the app container, where the network and the browser are out of the picture, the same
reads are slower than on the copy (`category_detail` 1,236 ms, `list_product_variants` 952 ms,
`airport_detail` 902 ms): staging's database serves a live site and the copy serves this pass
alone. The container figures are the honest ones for what a reader meets on staging today, and
two of them exceed a second.

### Findings

| # | Finding | Evidence | Carried by |
|---|---|---|---|
| 1 | The category page is the site's heaviest read: `category_detail` 1,236 ms median, 1,598 ms max in the container; `/alcohol/whisky` 706 ms p50 served | four whole-catalogue passes in one handler, timed part by part above | `issue-the-category-page-is-the-site-s-heaviest-read-four-whole` (P2, owner Stream B) |
| 2 | The first page of the full list costs half a second before a card is drawn: 554 ms p50 served, 952 ms median in the container | the count 279 ms plus the ranking of 2,376 comparisons 489 ms | `issue-the-product-list-s-first-page-costs-half-a-second-before` (P2, owner Stream AW2) |
| 3 | The airport page recomputes every card figure over the whole catalogue: 902 ms median in the container, 513 ms p50 served | `_airport_rows` 392 ms for 459 rows, two more list calls after it | `issue-the-airport-page-recomputes-every-card-figure-over-the-w` (P2, owner Stream B) |
| 4 | `list_product_variants` counts the whole filtered set even when the caller wants six cards and discards the total | five call sites do exactly that; the unfiltered count alone is 279 ms | `issue-the-product-list-counts-the-whole-filtered-set-even-when` (P3, owner Stream AW2) |
| 5 | No missing index: three `EXPLAIN (ANALYZE, BUFFERS)` plans, all cache hits, sequential scans only on tables of 16, 24 and 77,370 rows | the table above | closed, nothing to carry; no migration comes out of this pass |
| 6 | `ANALYZE` on a freshly restored copy changes nothing measurable | both columns above | closed; a later pass can measure once |
| 7 | The bundle is well inside both thresholds, and every heavy lazy chunk is an internal page | the bundle tables | nothing to carry |
| 8 | `/api/trip/featured` is a route the SPA never calls | no reference in `web/src/api/queries.ts` | not filed: it is the trip comparison's own shape with a default route, reachable and harmless, and deciding its future is not this pass's call |

Nothing in this task was small and safe to fix. Every number above is a query shape or a caching
decision across several call sites, which the brief puts outside a quality pass; the four that
matter are filed with their parts timed, so whoever takes them starts from the measurement.

### What this task changed

- `.logs/verification/quality-pass-2026-09-19.md`: this section.
- Four issues on the running list, and `.logs/issues.md` regenerated by `items.py`.
- No code. No test: nothing shipped a bug here, and a timing assertion in a suite that runs on
  an in-memory SQLite would pin nothing real.
- Staging was read and never written: the container runs were one rolled-back session, and the
  only statements were selects and `EXPLAIN`, the latter on the copy alone.

### What is left standing for AW7.5

The copy (`dfp_aw7`), the local server on `127.0.0.1:8099` and the 0600 cookie file are still up
and are AW7.5's to drop. `main/web/dist` was rebuilt for the bundle measurement and is identical
in content to AW7.3's build, so the server still serves the build the accessibility fixes are in.

---

## AW7.5 The verdict, the index of findings, and what is left standing

### The verdict

**The internal set is closed, nothing a shopper meets is broken at either width, and the site's
cost is in its reads rather than in its bundle.** Of the four parts of the pass, three came back
with nothing to fix and one came back with four accessibility defects, all four fixed and pinned
by tests in this stream. Nothing found here blocks a launch. The one number outside its bar is
the response time of the two server-rendered pages, which is a query shape rather than a defect,
filed with its parts timed.

| Part | Verdict | What carries the rest |
|---|---|---|
| The full run and the doc gates | Green: the suite passes, the gates fail on nothing, and every strict warning is a timestamp rather than a wrong sentence | two items on the running list, both about the gate's own sources |
| The unauthenticated sweep | **Closed**: 243 routes walked and the 154 readable ones fetched with no cookie; none answered 200 that should not, and none redirected anywhere but the sign-in page | nothing |
| Nine pages at two widths | **No break**: every element past the right edge sits in something that scrolls to it | one item, a phone reading of the price table |
| Accessibility of the new components | Four defects, four fixed; the served bodies keep three of them, in a module this stream must not touch | four items, one of them the served bodies |
| The heaviest reads and the bundle | The bundle is at half its bar and a quarter of its lazy bar; five of nine reads are over the pass's own 500 ms p50 line and none over a second | four items, each with its parts timed |

### Every finding in one place

Twenty-seven rows across the four parts: four defects fixed here, twelve carried by the running
list with their evidence, two the same defect read twice and fixed in the later row, and nine
closed as not defects. Nothing is unaccounted for.

| Part | # | Finding | Fixed by, or carried by |
|---|---|---|---|
| AW7.1 | 1 | `docs-check.sh --strict` calls `backups/` a missing source in a worktree | `issue-docs-check-strict-calls-backups-a-missing-source-when-ru` (Docs) |
| AW7.1 | 2 | `CLIENT-SURFACES.md` is stale by construction: `import/` is rewritten by every plan update | `issue-client-surfaces-md-is-stale-by-construction-import-is-re` (Docs) |
| AW7.1 | 3 | Ten handoff entries over the 25-line gate | closed: a record is not edited to pass a gate |
| AW7.1 | 4 | No internal route answers 200 or a stray redirect on staging | closed: `584011e`, exit 0 |
| AW7.2 | 1 | On a phone the price table's cheapest marker begins past the right edge | `issue-on-a-phone-the-price-table-s-cheapest-marker-starts-off` (S) |
| AW7.2 | 2 | An unnamed `<nav>` on every page at 1440 | carried to AW7.3 finding 1, fixed there |
| AW7.2 | 3 | Two `<header>` elements on three pages | carried to AW7.3 finding 9, closed there |
| AW7.2 | 4 | A symlink at `main/static` fails three access tests | `issue-serving-the-built-shell-locally-breaks-three-access-test` (Docs) |
| AW7.2 | 5 | No layout break at either width on any of the nine pages | closed |
| AW7.3 | 1 | The site header's menu was an unnamed landmark on all nine pages | fixed, `4d8b33c`, pinned |
| AW7.3 | 2 | The article index skipped a heading level | fixed, `4d8b33c`, pinned |
| AW7.3 | 3 | The live page's Start menu did not close on Escape | fixed, `4d8b33c`, pinned |
| AW7.3 | 4 | The hub skeleton's crumbs were an unnamed nav | fixed, `4d8b33c` |
| AW7.3 | 5 | The served bodies keep the unnamed nav, the heading skip and no `<footer>` | `issue-the-served-bodies-carry-the-same-three-accessibility-gap` (B) |
| AW7.3 | 6 | `/collectors` jumps h1 to h3 | `issue-the-collectors-page-jumps-from-its-h1-to-the-cards-h3` (AW4) |
| AW7.3 | 7 | Twenty-one Tab presses before the page's own content, no skip link | `issue-no-way-past-the-header-twenty-one-tab-presses-before-the` (C) |
| AW7.3 | 8 | The primary button's hover colour is a literal, so it leaves the scheme | `issue-the-primary-button-s-hover-and-focus-colour-is-a-literal` (C) |
| AW7.3 | 9 | AW7.2's two-header reading is not a defect | closed |
| AW7.3 | 10 | No contrast pair in the new components fails, at any of the nine schemes | closed; fourteen pairs now in the test |
| AW7.4 | 1 | The category page is the heaviest read: four whole-catalogue passes in one handler | `issue-the-category-page-is-the-site-s-heaviest-read-four-whole` (B, P2) |
| AW7.4 | 2 | The list's first page costs half a second before a card is drawn | `issue-the-product-list-s-first-page-costs-half-a-second-before` (AW2, P2) |
| AW7.4 | 3 | The airport page recomputes every card figure over the whole catalogue | `issue-the-airport-page-recomputes-every-card-figure-over-the-w` (B, P2) |
| AW7.4 | 4 | `list_product_variants` counts the whole filtered set for a fixed sample | `issue-the-product-list-counts-the-whole-filtered-set-even-when` (AW2, P3) |
| AW7.4 | 5 | No missing index behind either slow path | closed: no migration comes out of this pass |
| AW7.4 | 6 | `ANALYZE` on a fresh copy changes nothing measurable | closed |
| AW7.4 | 7 | The bundle is inside both thresholds, every heavy lazy chunk an internal page | closed |
| AW7.4 | 8 | `/api/trip/featured` is a route the SPA never calls | recorded, not filed: harmless, and its future is not this pass's call |

### What the pass put in the tree

| Commit | What it added |
|---|---|
| `584011e` | `app/services/route_walk.py`, `scripts/route-sweep.py`, `tests/test_route_sweep.py` (9), RUNBOOK step 18 naming the script that now exists |
| `8b42af3` | `scripts/page-probe.py`, `tests/test_page_probe.py` (11) |
| `4d8b33c` | The four accessibility fixes, `tests/test_landmarks.py` (6), fourteen contrast pairs |
| `bf42dc8` | The performance section and its four items; no code, by choice |
| this one | This section, and `docs/QUALITY.md`'s "The pass before a launch" |

The suite went from 2,509 tests to 2,535 and `main/check.sh` printed "checks passed" before every
one of those commits. No migration, no backfill, no data change, no route class change.

### What rian looks at himself

1. **The signed-in pass on staging.** Everything here that needed a session was measured on a
   restored copy served locally, because a credential does not go into a session transcript. The
   copy is a dump of what D4 deployed, so the finding set should hold, but only rian can open the
   nine pages on the deployed site as himself. Half an hour.
2. **Five reads are over the pass's own p50 line** and two of them take more than a second inside
   the container. The four items say where the time goes, part by part. Whether any of that is
   worth doing before Monday is his call, not the pass's.
3. **The two Docs items about the gate's own sources** are one-line edits to `docs-check.sh` and
   a doc's source list, and they would take `--strict` from eleven fails to a handful.
4. **The pass's thresholds are provisional** and live in this file alone. If they should become
   the standard, that is a standards session, which this stream is not.

### The copy, and what is gone

The pass's own footprint is closed: the local server on `127.0.0.1:8099` is stopped, `dfp_aw7` is
dropped from `dfp-devdb`, and the throwaway owner went with it, its cookie file deleted. No
credential was printed, logged or committed at any point. `backups/dfp-aw7-2026-09-19.dump` (the
`pg_dump` that made the copy, and the only touch staging's database got) stays in the backups
folder with the nightly dumps, gitignored as they are. The screenshots and the probe's JSON stay
in the pass's scratch folder and are not committed: this report carries the findings, not the
files. Staging was never written, nothing was deployed, no collection ran, and no `robots.txt`
was read.

