# Stream AW7: the quality pass

**Status 19 Sep 2026 10:30 PDT:** written by the orchestrating session for the away run
(`AWAY-2026-09-19.md`, sections 1 and 7 bind). Runs last, after deploy D4, only if time remains.
Base: `05941b4` on `claude/collectors-live-page-plan-8951e2`; staging runs 0.52.0 (2,047 tests).
Stream W never landed (its six tasks are `todo` on `/plan`), so the sweep script is built here.

## Goal
The wave-4 integration pass the proposal calls I1, run against what D4 deployed: the full test
run, the doc gates in strict mode, the unauthenticated sweep proving the internal set is closed
and the page-status badge never leaks, the nine pages at desktop and phone width, the
accessibility of the new components, the response times of the heaviest pages and the bundle
sizes, all written into one dated report. AW7 fixes only what is small and safe; everything
larger becomes a running-list item carrying the evidence. Nothing is deployed by this stream.

## What already exists, so you build on it
- `main/check.sh`: tests, vendor check, the typed client regenerated, `tsc`, then `docs-check.sh -q`.
- `main/docs-check.sh [--strict]`: FAIL tier (agents.md budget, stale generated blocks, version
  agreement) and WARN tier (a doc older than a named source, the issues sweep stamp, handoff
  entries over 25 lines); `--strict` makes every warn a fail. On 19 Sep: 0 fail, 28 warn.
- `main/app/services/access.py`: the four classes (`PUBLIC_ALWAYS`, `PUBLIC_WHEN_OPEN`, `MEMBER`,
  `PERMISSION`, plus `SPA_*`), `classify()`, `decide()`, `HTML_KEYS`. Members mode answers an
  anonymous page request with 302 to `/login` and an anonymous API read with 401 JSON; machine
  files answer 404; `/api/bw/me` and `/api/health` answer 200 to anyone by design.
- `main/tests/test_route_inventory.py`: `walk()`, `every_key()`, `spa_paths()` read every route from
  the app and every `<Route path>` from `App.tsx`; `tests/fixtures/open_routes.json` pins the open set.
- `main/web/src/App.tsx`: the route table (storefront, `/collectors` and `/sources` behind
  `sources_manage`, `/review` owner only, the client pages behind `client_view`, `/plan` and `/issues`).
- `main/tests/test_tokens_contrast.py`: `contrast()` over `web/src/styles/tokens.css` and the
  `PAIRS` list of drawn foreground and background pairs with their WCAG bars. Extend the list;
  do not write a second contrast function.
- `main/tests/test_house_style.py`: no em dash in any SPA string or served title; American
  spelling in identifiers (the shape for a source-reading test).
- `main/web/src/components/Badge.tsx` and `Badge.css`: the one badge component, six tones; the
  page-status badge AW5 adds should be a tone here, not a second badge.
- `main/web/src/lib/useMediaQuery.ts`: `matchMedia` as a hook; `(prefers-reduced-motion: reduce)`
  is what a ticking number reads before it animates. Four stylesheets already honour it in CSS.
- `main/web/src/pages/CollectorsPage.tsx`: `TABS` begins with `live`; AW4 builds the live page
  on `GET /api/collectors/live` (`routers/collectors.py`, permission `sources_manage`).
- Headless Chrome: `google-chrome --headless=new --no-sandbox --disable-gpu --window-size=W,H
  --screenshot=<file> <url>` works on this host (Chrome 143; probed 19 Sep). `websockets` 17 and
  `httpx` are in `.venv-dev`; Playwright and axe are not installed and are not to be added.
- Earlier browser passes: `.logs/verification/airport-page-2026-09-18/` (the record's shape).
- `main/docs/QUALITY.md`, `main/docs/SEO.md` (what renders server-side and how to verify it),
  `main/docs/RUNBOOK.md` step 18, which already names `main/scripts/route-sweep.sh` (never written).
- Restored copies: `dfp-devdb` on `127.0.0.1:5433`, `docker exec -i dfp-devdb pg_restore ...`
  from `/srv/apps/dutyfreeprofessor/backups/` (RUNBOOK, Backups and restore). `plan-set.py`,
  `items.py`, `srv-gw notify --from "AW7"`.

## Tasks, in order (commit prefix `AW7:`; `main/check.sh` green before every commit)

### AW7.1 The full run and the unauthenticated sweep (about 2.5 h)
- Run `main/check.sh` in the worktree and record the test count and the time. Run
  `bash main/docs-check.sh --strict` in the worktree AND in the main checkout; put every line in
  the report grouped by kind (stale doc, long handoff entry, sweep stamp, missing source). Fix a
  stale-doc warn only where the doc is wrong on a point the code changed (one paragraph, re-read
  the source first); never touch a doc only to move its timestamp. Handoff entries are not trimmed.
- Write `main/scripts/route-sweep.py <base-url>` (Stream W's W4, never built): it imports
  `app.services.access` and reuses `test_route_inventory.walk`/`every_key`/`spa_paths` (move those
  three into `access.py` or a small `app/services/route_walk.py` if importing a test is unclean;
  one home). For every key it substitutes a sample path parameter (`x` for slugs, `LHR` for
  `{iata}`, `1` for ids), curls it with no cookie, and prints one line: key, class, status,
  expected. Expected per class in members mode: `public_always` 200; `public_when_open` 302 (HTML
  keys) or 401 (API) or 404 (machine files); `member` and `permission` 302 or 401. Exit non-zero
  on any internal key answering 200 or 3xx to a non-login location. A `--open` flag states the
  public-mode expectations for go-live day. Reads only; no session; never a POST.
- Test `main/tests/test_route_sweep.py`: the expectation table is derived from `access.decide()`
  itself for every key (no typed list), and a fake `PERMISSION` entry that answers 200 makes the
  script's exit code non-zero.
- Run it against `https://dutyfreeprofessor.demoing.info` and store the output in the report.
  Then by hand: `/api/collectors/live`, `/api/review/sheets`, `/api/plan`, `/collectors`, and one
  page carrying AW5's badge fetched anonymously and as the server body on the local copy
  (AW7.2's server, `curl` with no cookie): the badge's text and class name appear nowhere in the
  anonymous answer. Every answer must carry `via: 1.1 Caddy` and `cache-control: no-store`; a 302
  to `auth.bowden.works` means the id-auth gate answered, not the app, and the report says so.
- RUNBOOK step 18 names `route-sweep.py`; `plan-set.py W4 done "Stream AW7" --note`.

### AW7.2 Cross-device: nine pages at 1440 and 390 (about 3 h)
- Serve a fresh copy locally: `docker exec dutyfreeprofessor-db pg_dump -U dfp -Fc dfp >`
  `/srv/apps/dutyfreeprofessor/backups/dfp-aw7-<date>.dump` (a read of staging), restore into
  `dfp_aw7` on `dfp-devdb`, `vite build` in `main/web`, then uvicorn from `main/` with
  `DATABASE_URL` at the copy, `SITE_ACCESS=public`, `ACCOUNT_OWNER=aw7`, `LINE_PAGES` as staging
  has it. The throwaway owner: `accounts create` then `accounts set-password` fed from a shell
  variable that is never echoed; the cookie comes from `httpx.post('/api/auth/login', json=...,
  headers={'Origin': base})`. Nothing about it is printed or committed; drop `dfp_aw7` at the end.
- Write `main/scripts/page-probe.py <url> --width 1440|390 [--cookie-file] [--shot file]
  [--ax] [--tab N]`: launches Chrome with `--remote-debugging-port=0` and a scratch
  `--user-data-dir`, speaks the DevTools Protocol over `websockets` (`Network.setCookie`,
  `Emulation.setDeviceMetricsOverride`, `Page.navigate`, `Page.captureScreenshot`,
  `Accessibility.getFullAXTree`, `Input.dispatchKeyEvent`), and prints JSON: the document's
  horizontal overflow (`scrollWidth > innerWidth`), every element wider than the viewport with its
  selector, the landmark and heading outline, and the focus path after N Tabs. Stdlib plus
  `websockets`; no third-party browser driver.
- The nine pages: `/`, `/airports/heathrow-lhr-london`, `/alcohol/whisky`, one product line
  `/products/<slug>`, the same line with `?variant=<id>` (the product variant view), one brand
  `/brands/<slug>`, `/articles`, one article, `/collectors` (with the cookie); each at 1440 and
  390. Screenshots stay in the scratchpad; the report lists each break as page, width, selector
  and what happens (overflow, overlap, clipped text, an unreachable control).
- Fix a break only when it is one rule in one component's stylesheet; the stylesheet is imported
  by that component. Anything else is an `issue` on the running list owned by the stream that
  built the component, with the page, width and selector in `--detail`.

### AW7.3 Accessibility of the new components (about 2.5 h)
- Landmarks and heading order on the nine pages from the probe's outline: one `<main>`, one
  `<header>`, one `<footer>`, `<nav>` with `aria-label`, exactly one `h1`, no level skipped. The
  served HTML is checked the same way with JavaScript off: a gap there is `seo.py`'s owner's issue.
- Contrast: add the new pairs to `test_tokens_contrast.PAIRS` (the page-status badge's tone, the
  article centre's surfaces, the live page's states), each with its bar (4.5 for text, 3.0 for
  large text and UI parts). A pair drawn with a literal colour instead of a token is a finding.
- Keyboard: with `--tab N`, every new control (the badge if it acts, the article centre's
  controls, the live page's start, pause, resume and stop) is reached by Tab in reading order,
  shows a visible focus ring, and works with Enter or Space; Escape closes what Escape should.
- Reduced motion: the ticking numbers on the live page read `useMediaQuery("(prefers-reduced-motion:
  reduce)")` and render the final value at once when it matches; verified by the probe with
  `Emulation.setEmulatedMedia` features `prefers-reduced-motion: reduce`. If AW4 shipped no ticking
  numbers, say so and skip.
- Fix the small ones (an `aria-label`, a focus style from the tokens, a heading level, the media
  query); each fix becomes a test where a test can pin it (a source-reading test in the style of
  `test_house_style.py` for `aria-label` on every `<nav>`, the contrast pairs). The rest are issues.

### AW7.4 Performance: the three heaviest pages and the bundle (about 2 h)
- The three: `/` (with `GET /api/products/featured-savings` and `GET /api/trip/featured`),
  `/products` (`GET /api/products`, the price aggregation), and the airport page
  `/airports/heathrow-lhr-london` (`airport_detail` and `airport_body`); `/alcohol/whisky` if all fast.
- On staging, anonymously: `curl -w '%{time_total}'` five times each for `/api/health` and the
  302 and 401 answers (the middleware's cost, no handler). The handlers themselves are timed in
  the app container read only: `docker exec dutyfreeprofessor-app python -c` timing
  `catalog_queries.list_product_variants`, `featured_savings`, `airport_detail` over a session
  from `app.db.SessionLocal`, five runs each, median and max. No write, no session row.
- On the AW7.2 copy served locally: the full HTTP path for the three pages and their API reads,
  signed out, five runs each; then `docker exec dfp-devdb psql -U dfp -d dfp_aw7 -c ANALYZE`
  and the same runs again; report both columns.
- Bundle: `vite build` output plus `gzip -c | wc -c` for every file in `main/web/dist/assets`;
  the entry chunk, the largest lazy chunk, the CSS, and the total.
- Thresholds: the standards state none (`frontend.md`, `react.md` checked 19 Sep), so the report
  uses AW7's own, marked as such: a page or API read p50 under 500 ms and max under 1 s on the copy
  after ANALYZE; entry JavaScript at most 250 kB gzipped; any lazy chunk at most 200 kB gzipped.
  Anything over is listed with the number; a missing index EXPLAIN shows is a migration, so an issue.

### AW7.5 The report, the handoff, I1 on /plan (about 1 h)
- The report lives at `.logs/verification/quality-pass-<date>.md`: one table per task
  (sweep, layout, accessibility, performance) with the finding, the evidence, and either the
  commit that fixed it or the running-list id that carries it. Dated facts and counts are fine
  there; nothing of the kind goes into `main/docs/`.
- `main/docs/QUALITY.md` gains a short section "The pass before a launch": what the sweep script
  proves, how the browser pass is run and recorded, how the heaviest reads are timed; no counts,
  no dates, no present state.
- The handoff entry (25 lines at most): what landed, what was verified, the open issues by id,
  what rian must look at himself (the signed-in pass on staging). Then
  `plan-set.py I1 done "Stream AW7" --note "<the report path and the one-line verdict>"`.

## Working assumptions (rian's to overturn)
- **The sweep script is AW7's to write** (W4 never landed; RUNBOOK already names it). Overturn
  costs nothing: the script stays.
- **The signed-in browser pass and the full-path timings run on a restored copy served locally**,
  because staging is members-only and no credential enters a transcript; staging itself is
  measured for its anonymous answers and its handlers from inside the container. To overturn:
  rian opens the nine pages on staging himself, or hands a throwaway account; one hour to rerun.
- **Performance thresholds are AW7's, provisional, in the report only**; the standards are not
  edited by this stream. To overturn: change the numbers in the report's table.
- **Small and safe means one rule, one attribute, one test or one paragraph, in one file, with
  no data change and no route class change.** `access.py`, `seo.py`, the collectors, the ledger
  and every migration are never small. An internal route answering 200 anonymously is a
  `critical` decide item and a Telegram line at once, not a fix.
- **AW7 starts after D4**; started earlier, the report names the version `/api/health` printed
  and which streams' work it did not see.

## Questions for rian
1. May AW7 create a throwaway account on staging for a signed-in pass? Assumption: no; the local
   copy stands in. Not critical.
2. Should the AW7 thresholds become the standard in `frontend.md` later? Assumption: recorded in
   the report only; a standards session decides. Not critical.
3. If the sweep finds an internal route open, may the orchestrator's fixer edit `access.py`
   before the answer? Assumption: no; the item is raised `blocking` and the route's page is
   named in the Telegram line. Critical.

## Owns
`.logs/planning/streams/AW7-quality-pass.md`, `.claude/commands/stream-aw7.md`,
`main/scripts/route-sweep.py`, `main/scripts/page-probe.py`, `main/tests/test_route_sweep.py`,
`main/tests/test_tokens_contrast.py` (PAIRS only), a new `main/tests/test_landmarks.py`,
`.logs/verification/quality-pass-<date>.md`, `main/docs/QUALITY.md` (the new section),
`main/docs/RUNBOOK.md` step 18 (the script's name only), `main/CHANGELOG.md` Unreleased,
one-rule fixes in `main/web/src/components/*.css`, `main/web/src/pages/*.css` and the
components AW4 and AW5 built (their streams have finished), `import/progress.json` through
`plan-set.py`, `import/items.json` through `items.py`, `.logs/handoff.md` prepend only.

## Must not touch
`main/app/services/access.py` and `tests/fixtures/open_routes.json`; `main/app/services/seo.py`;
the collectors and `fetch.py`; the ledger, migrations and every backfill; `.env`, `.app.env`,
`.bw-auth.env`; staging's and production's databases (a `pg_dump` is the one read); the vendored
kits under `main/app/vendor/` and `main/web/src/vendor/`; `deploy/production.sh`; the main
checkout's dirty paths (`.logs/runs/sweep-2026-09-19b/`, `.logs/runs/uptime.log`, `notes/`);
`/srv/projects/standards/`; other streams' briefs; `agents.md` and `brief.md`.

## Rules that bite here
- **Never deploy; never run a network collection.** The only network this stream uses is HTTPS
  reads of staging and the local server. A robots.txt read is not needed and not made.
- **A GET must never write.** The sweep and the probe send no POST except the local login on
  the throwaway copy.
- **A bug that shipped becomes a test** with the real page and what it cost in the docstring.
- **Doc purity:** the report and the handoff may carry dates and counts; `main/docs/` may not.
  No stream, task or status words in a doc a reader keeps.
- **One word per concept** (`VOCABULARY.md`): product line, product variant, brand; never bare
  "product" in anything new, the report included.
- **Client-facing copy:** no em dash, never "cheap" or "free". No em dash in this brief or the
  report either.
- **Import a component's stylesheet in the component**; a fix that adds a class adds it to the
  stylesheet the component already imports. A new static page (none expected) is verified by a
  string only it contains.
- **The two-hour question protocol** (`AWAY-2026-09-19.md` section 2): a decide item with
  `--assumption`; carry on with what does not depend on it.

## Out of scope, noted
- Stream W's other five tasks are still `todo`; the sweep will show `/assets/*` public in
  members mode (W2's question, unanswered): the report says so without fixing it.
- No test forbids "cheap" and "free" in client-facing strings; `test_house_style.py` checks only
  the em dash and spelling. `docs-check.sh` reports `backups/` as a missing source in a worktree
  (gitignored); a false warn a later session may remove.
- Server-rendered bodies carry a `<main>` only if `seo.py`'s helpers emit one; AW7.3 measures
  and files, and `seo.py`'s owner decides.
