# Independent review: lane-demo (synthetic shared shopping list)

Reviewer: independent verifier, fresh context, did not build this. Date: 2026-10-09. Node v24.15.0 (the only Node installed).
Scope: `README.md`, `packet.json`, `before/*`, `after/*`, `build-preview.js`, `capture.js` and `check-browser.js` (read, not run), `evidence/*`.

**Verdict: PASS, with one Medium and several Low/Info findings, none of which makes a packet claim false.**
This verdict covers only what Node and static inspection can show. See "What I could not verify" before relying on it for the visual, browser and screen-reader claims.

## Method and limits

Rules I worked under: no browser or headless anything, no network, no git state changes, no viewing of image or video files, only this file edited. Scratch scripts lived in `/tmp/review/` (`fakedom-test.js`, `probe-logic.js`, `contrast.js`, `webm-meta.js`) and a rebuilt copy in `/tmp/ld-copy/`, `/tmp/ld-red/`. They are not part of the packet and may be deleted. For the PNGs and the video I read container headers only (`file`, and a small EBML reader that reads Segment Info, Tracks and block timestamps without decoding any frame). I did not look at any pixels.

Finding status words: CONFIRMED means I reproduced it by running code. CSS-STATIC means it follows from the CSS rules but I could not render it. OBSERVATION means there is no failing input. UNVERIFIED means I could not test it.

## What I could not verify

- Anything that needs a layout engine or browser: the rendered look of the preview and the three screenshots, the screenshot captions and alt text against the pixels, the video content against its caption, and the 21 browser checks (I did not re-run `check-browser.js`; it needs Chromium).
- Real screen-reader behaviour (VoiceOver, NVDA, TalkBack), other browsers, touch devices. The packet says the same (AC4 `not_run`).
- My fake DOM (a hand-written minimal one) tests the page's script logic and wiring. It does not test HTML parsing, CSS, `<label>`-to-checkbox click behaviour, or real focus rules for disabled buttons. Treat it as corroboration of the author's browser check, not a replacement.
- README line 52 says `node --test` "needs only Node 20 or later". Only Node 24 is installed here, so Node 20 is untested.
- The history of the red and green runs (that `after/list.test.js` was unchanged between them) cannot be proved after the fact. Evidence for it is in check 1 below.

## Commands and results

Paths are relative to the hq root unless stated. D = `opportunities/catalogue/samples/lane-demo`.

### 1. Real tests, and the saved outputs

| Command | Result |
|---|---|
| `node --test D/after/list.test.js` | exit 0, tests 16, pass 16, fail 0 |
| `node --test D/before/list.test.js` | exit 0, tests 5, pass 5, fail 0 |
| `cd D/after && node --test` | exit 0, tests 16, pass 16, fail 0 |
| Red run reproduced: copy `after/list.test.js` and `before/list.js` into `/tmp/ld-red`, then `cd /tmp/ld-red && node --test` | exit 1, tests 16, pass 5, fail 11; every failure is `TypeError: <fn> is not a function` (clearBought x6, remainingLabel x2, remaining x2, canClear x1) |

Consistency with the saved files:

- `tests-before.txt`: the 16 test names with pass/fail marks are identical to my red run. The failing-test locations (`list.test.js:43:1, 49, 53, 58, 63, 70, 74, 81, 96, 101, 107`) are identical. The `at TestContext` stack lines (`list.test.js:44:19, 50:20, 55:20, 60:20, 65:3, 71:16, 76:16, 83:16, 97:16, 102:16, 109:19`) are identical apart from the path. Header says exit 1, 11 of 16 failed, 5 pass: matches. Node line `node: v24.15.0`: matches `node --version`. Only timings differ (expected).
- `tests-after.txt`: the 16 passing test names are identical to my run; header says exit 0, 16 of 16, `node: v24.15.0`: matches. Only timings differ.
- Test-first ordering is consistent with file modification times: `after/list.test.js` 11:41:48, then `after/list.js` 11:42:01, then `after/index.html` 11:42:05. The identical red-run line numbers show the tests file had the same layout when the red run was saved. Content equality at that moment is not provable.
- No absolute paths remain: `grep -rIn '/Users/|/home/' D` found none (`<repo>` is used in `tests-before.txt`).

### 2. `change.patch`

`cd D && diff -ruN before after > /tmp/regen.patch` (diff exit 1, meaning "differs"). `cmp /tmp/regen.patch D/evidence/change.patch` reports identical; both SHA-256 `de87f4213226e60a16539fdc293dfee3b757c2f6afd7a105319e014081f5efd9`. The patch has relative `before/...` and `after/...` headers, so there is no absolute-path noise to ignore. Even the timestamps in the headers match, which also shows the three changed source files have not been modified since the patch was written. It covers exactly `index.html`, `list.js`, `list.test.js` (headers at patch lines 1, 62, 95). CONFIRMED.

### 3. Preview vs `after/`

- Rebuilt the preview in a scratch copy (`cp -R before after build-preview.js /tmp/ld-copy/; node /tmp/ld-copy/build-preview.js`, exit 0, 7259 bytes). `cmp` against `D/evidence/shopping-list-preview.html`: byte-identical. I did not run `build-preview.js` in place, because it would overwrite the evidence.
- `diff after/index.html evidence/shopping-list-preview.html` shows only: a `noindex` robots meta, a "Generated by" comment, a `.trader-footer` rule, a `<footer>` with the company name and Sheffield address, and `after/list.js` inlined verbatim in place of `<script src="list.js">` (verified with `String.includes`). Markup, CSS and page script are otherwise identical to `after/index.html`. See finding F5.
- Fake-DOM harness `node /tmp/review/fakedom-test.js D/evidence/shopping-list-preview.html`: exit 0, 35 of 35 checks pass. Same harness on `D/after/index.html` (loads `list.js` from disk): 35 of 35, and the PASS lines are identical to the preview's. The harness extracts the two inline scripts, runs them in `vm` with a fake `document`, and asserts that every id the script reads exists in the HTML. What it showed:
  - AC1: on the seeded list, clear leaves `Sourdough loaf | Cherry tomatoes | Ground coffee`, all unchecked, ids 2, 4, 5, in order. Clearing again with nothing bought changes nothing.
  - AC2: load reads `3 items left to buy`. Tick gives 2, tick gives `1 item left to buy` (singular), add gives 2, ticking all gives `0 left to buy`, unticking one gives `1 item left to buy`. The counter text is set in `render()`, which runs after add, toggle and clear.
  - AC3: clearing the last bought items gives zero rows, counter `0 left to buy`, button `disabled === true`, empty note shown, focus moved to `#new-item`. A click on the disabled button does nothing. Adding an item re-enables the button and hides the note.
  - Whitespace-only and empty add are ignored and the field is cleared. Duplicate names get distinct ids and ticking one duplicate ticks only that one. HTML in an item name (`<img src=x onerror=alert(1)>`) is stored and shown as text: the script has no `innerHTML` write (0 writes at runtime, and a source grep finds 0 uses of `innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write`, `eval`).
- Static accessibility markup (grep and reading `after/index.html` and the preview):
  - `<html lang="en">` (line 2) and `<meta name="viewport" content="width=device-width, initial-scale=1">` (line 5); no `user-scalable` or `maximum-scale`.
  - `<label for="new-item">` pairs with `<input id="new-item">` (lines 40 and 42). Each checkbox sits inside its own `<label>` with the item text, so the accessible name is the item text. The Playwright tree in `browser-check.txt` shows the same names.
  - Counter: `<p id="remaining" aria-live="polite" aria-atomic="true">` (line 49), filled by script.
  - Button: real `<button type="button" id="clear" class="secondary">Clear bought items</button>` (line 50), accessible name from its text. No `role=`, no `tabindex` anywhere (0 matches). Tab order is DOM order: field, Add, five checkboxes, Clear, which matches "7 Tab presses from the field" in `browser-check.txt`.
  - Touch targets from CSS: buttons and text input `min-height: 44px` (lines 18, 19); list rows `min-height: 48px` with the whole row as the label hit area (line 23); checkbox 24x24 inside it (line 24). All at or above 24 px (WCAG 2.5.8) and the 44 px used by the author.
  - Contrast, computed from the CSS hex values (`node /tmp/review/contrast.js`, sanity-checked: #000/#fff = 21.00, #767676/#fff = 4.54):

    | Pair | Ratio | Needs |
    |---|---|---|
    | ink #1c2a33 on white / on page #f3f5f4 | 14.70 / 13.43 | 4.5 |
    | muted #54606a on white (.sub, empty note, bought items) | 6.45 | 4.5 |
    | muted #54606a on page #f3f5f4 (13px footer) | 5.89 | 4.5 |
    | Add button #fff on #1f6f5c | 6.02 | 4.5 |
    | Clear button #1f6f5c on #fff | 6.02 | 4.5 |
    | demo label #5c4500 on #fff4d6 (13px) | 8.31 | 4.5 |
    | input border #7d8a93 on white | 3.54 | 3 (non-text) |
    | focus outline #1b5fd1 on white / page | 5.83 / 5.32 | 3 |
    | disabled Clear text #5b6770 on #eef1ef | 5.10 | exempt (inactive) |

    All required pairs pass. Disabled-button border (1.81) and fill (1.14) against white are low but inactive controls are exempt. The placeholder colour is not set in CSS (browser default), so I did not compute it.
- Screenshot and video metadata only: `file` reports `before-desktop.png` and `after-desktop.png` as 1280 x 800, `after-mobile.png` as 780 x 1688 (= 390 x 844 at 2x). These match the packet and README captions' stated sizes.

### 4. Edge cases in the pure logic (`node /tmp/review/probe-logic.js D/after/list.js`, exit 0)

| Input | Observed |
|---|---|
| Duplicate names | allowed; ids 1, 2; toggle 1 flips only the first |
| `add(base,'   ')`, `'\t\n'`, `null`, `undefined` | returns the same array reference |
| `add([], 123)`, `{}`, `['a','b']` | text `"123"`, `"[object Object]"`, `"a,b"` (coerced with `String`) |
| NBSP / BOM padding, ideographic space only | trimmed / ignored |
| `add([], '\u200b')` (zero-width space) | CONFIRMED: accepted as an item with invisible text (F3) |
| `add(null,'x')`, `remaining(undefined)` | throw `TypeError` (callers must pass arrays; page always does) |
| 150, 100,000 and 5,000,000 `W` | stored untruncated, no length limit (0.01 to 0.43 ms) |
| HTML in a name | stored verbatim by logic; the page inserts it via `textContent` (see section 3) |
| `clearBought` with nothing bought | deepEqual to input but a new array; `canClear` stays true, so the button is enabled and the click is a silent no-op (F6) |
| `clearBought([])` | `[]` |
| Ids after clear | survivors keep ids (b=2, d=4) and the same object identity. Clearing the highest id then adding reuses it (`[1,2]` then add gives id 3); emptying the list restarts at id 1 (F9) |
| Mutation | all six functions run on a deep-frozen input without throwing, so none mutates its input. Claim holds. |
| Labels | n=0 `0 left to buy`, 1 `1 item left to buy`, 2, 3, 11, 101 `N items left to buy` |

### 5. Packet against artefacts

- `node -e "...evidence.validate(p,{repoFile:r=>fs.existsSync(r)})"` (README command): `[]`, no errors. `readiness(p)` returns `ready:false` with "independent review is not provided yet" and "not every test has passed yet" (the second is by design: the historical red run is recorded as `fail` and AC4 as `not_run`). With the outcome's required items, only "review must be provided" is reported.
- All 12 `ref`, `output_ref` and `source` paths in `packet.json` exist.
- `kind` is `synthetic-sample`, `visibility` public, `notice` (line 7) says made-up, not a customer delivery, not proof of past work. The preview and the README say it too. `outcome` `ship-one-feature-with-running-preview` exists as `opportunities/catalogue/products/ship-one-feature-with-running-preview.json`. `packet.json` hash starts `7c2ec4deff7f39ff`, the same as the hash recorded in `research/quality/2026-10-09-outcome-commerce-review-request.md`, so it is unchanged since that request.
- Secrets and private data: a case-insensitive grep of every text file in D for paths, emails, key and token patterns, IP and phone patterns found only the README sentence "holds no secrets or personal data". The validator's own credential patterns pass. The only address anywhere is the company trading address in the preview footer (the public one required on every page, D31). I could not inspect the pixels of the screenshots or video for private content.
- Claim by claim:
  - `implementation.summary` (line 32): true, with a wording nit (F11).
  - Red-run entry (line 55): true (reproduced).
  - AC1 summary (line 62): "removes every bought item, keeps the other items in order with ids intact, handles all-bought and none-bought, does not change the list": true; the last point is true but its test is weak (F2).
  - AC2 summary (line 69) and AC3 summary (line 76): match `after/list.test.js` lines 70 to 93 and 96 to 113.
  - Browser entry (line 82): matches `browser-check.txt` (21 PASS lines; Tab, Space, Enter; accessible name; `aria-live="polite"`). Not re-run by me.
  - AC4 `not_run` (line 88): honest. The browser check covers the attribute, name and keyboard parts only.
  - Video caption (line 122), "8.28-second" and "No audio": CONFIRMED from container metadata (`node /tmp/review/webm-meta.js D/evidence/walkthrough.webm`): WebM, Info Duration 8280 at timecode scale 1,000,000 ns = 8.280 s, last block starts at 8.240 s (40 ms frame), one track only: video, V_VP8, 640 x 700, 207 blocks, zero audio tracks. This matches README line 60 ("640x700, no sound, about 8 seconds"). The caption's action sequence (add Butter, check Sourdough loaf, clear) matches the script in `capture.js` lines 58 to 73, but I did not view the video.
  - Limitations (lines 129 to 136): each is true. Limitation 8 is the long-text overflow (see F1).
  - `README.md` commands and counts (11 of 16, 16 of 16, 21 checks, 5 before-tests): match.

### 6. Known issue: long unbroken item text (see F1)

Read from the CSS alone: confirmed as a real defect, not rendered.

## Findings

### F1 (Medium, pre-existing, disclosed; CSS-STATIC plus CONFIRMED input) Long unbroken item text is not wrapped

- Where: `after/index.html:23` (`.items label { display: flex; ... }`), `:24` (`.items input`), `:25` (`.items .bought span`, colour and line-through only). There is no rule for `.items span`, and no `overflow-wrap` or `word-break` anywhere in the file. The only `min-width: 0` is on the text input (`after/index.html:18`), not on the item text span (grep of `after/index.html` and the preview: those are the only matches). Same lines in `before/index.html:23-25` (so it pre-dates the change), and `evidence/shopping-list-preview.html:25-27`. The input has no `maxlength` (`after/index.html:42`) and `add` has no length cap (`after/list.js:6-11`).
- Input: add one item of 150 `W` characters (or a long URL, or a long compound word).
- Expected: the text breaks inside the card and the page does not scroll sideways at 390 px (WCAG 1.4.10 Reflow).
- Actual (from CSS): the text `<span>` is a flex item of the `display:flex` label. A flex item's default `min-width: auto` is its min-content width, which for an unbroken word is the whole word. With no wrapping property it cannot shrink, so it overflows the label. Nothing sets `overflow` on the card, so the text draws outside the card and the page gets a horizontal scroll. Estimate, not measured: text area is about 264 px wide at a 390 px viewport (358 content, minus card border and 48 padding, minus 44 for checkbox, gap and label padding) and about 394 px at 1280 px; 150 `W` at 16 px is roughly 2,270 px. So it also affects desktop, for any unbroken run over roughly 30 (mobile) to 45 (desktop) lowercase characters, not only "narrow screens" and not only the 150-`W` case. Text containing spaces wraps normally.
- Reproduced: the input is accepted and stored untruncated (150, 100,000 and 5,000,000 characters). The layout overflow itself was not reproduced (no layout engine); I agree with the previous reviewer's report on CSS evidence alone.
- Why Medium and not blocking: the packet already discloses it (`packet.json:136`), it is outside the requested feature, and no claim is false. But the preview page invites visitors to "try anything" (text in `evidence.js` `renderBody`), and a one-line fix exists, so I recommend fixing it before the sample is promoted. If it is left, the disclosure should say it also affects desktop widths.
- Fix suggestion: `.items span { min-width: 0; overflow-wrap: anywhere; }` in `before/` and `after/` (or in `after/` only, noting it in the patch), optional `maxlength` on `#new-item`. Re-run `build-preview.js`, regenerate `change.patch`, re-run `check-browser.js`, and re-capture the screenshots and video only if you want the evidence to match the final source byte for byte.

### F2 (Low, CONFIRMED) Weak test for "does not change the list it was given"

- Where: `after/list.test.js:63-67` asserts only `items.length === 5` after `clearBought(items)`; `packet.json:62` claims the tests show it "does not change the list it was given".
- Expected: the test would fail if `clearBought` modified item flags or order in place.
- Actual: it would pass for such a mutation. The claim itself is true (all six functions run on deep-frozen input without throwing; `filter`/`map` return new arrays), but the test does not prove it. Fix: compare against a snapshot or freeze the input in the test.

### F3 (Low, CONFIRMED) A zero-width-space-only name is accepted as an invisible item

- Where: `after/list.js:7-8` (`String(...).trim()` does not remove U+200B).
- Input: `add([], '\u200b')`. Expected: ignored like other blank text. Actual: one item with 1-character invisible text, counter `1 item left to buy`, and, inferred from the page code and not rendered, an invisible row whose checkbox has an effectively empty accessible name. The logic part is reproduced; the page part is not. Rare and harmless to the acceptance criteria.

### F4 (Low, CONFIRMED) Wrong video duration in the review request

- Where: `evidence/review-request.md:12` says "8.24-second". The container Duration is 8.280 s (8.240 s is the start of the last frame). `packet.json:122` ("8.28-second"), `research/quality/2026-10-09-outcome-commerce-review-request.md` and the README ("about 8 seconds") are right. Fix the request line.

### F5 (Low, CONFIRMED) The build description omits what `build-preview.js` adds

- Where: `build-preview.js:19-23` adds a `noindex` meta, a "Generated by" comment and a company-address footer. `README.md:23` and `packet.json:32` describe only inlining `list.js`. Also `capture.js:20-21` renders `before-desktop.png` from `before/index.html` (no footer) and the two `after-*` screenshots from the preview (with footer), so before and after differ by more than the feature (not visually checked). Suggest one README sentence. Not a false claim.

### F6 (Info, CONFIRMED behaviour) Clear is a silent no-op when nothing is bought

- Where: `after/list.js:36-38`, `canClear = items.length > 0`. With a non-empty list and nothing ticked the button is enabled and clicking it does nothing, with no message. This meets AC3 as written (disabled only for an empty list). Optional: disable it when nothing is bought, or say "Nothing to clear".

### F7 (Info, UNVERIFIED) Clearing may not be announced to a screen reader

- Where: `after/index.html:86` sets `counter.textContent` to the same string when the cleared items were all bought (the seeded list reads 3 before and after, as does the recorded walkthrough). Whether the live region announces an unchanged replacement depends on the browser and screen reader; I could not test. A user may get no confirmation that items were removed. AC4's "announced by a screen reader" stays `not_run`, as the packet says. Optional: put a short message such as "Cleared 2 bought items" in the live region.

### F8 (Info, UNVERIFIED) List semantics under `list-style: none`

- `after/index.html:21` removes the list marker on a `<ul>`. Safari with VoiceOver has historically dropped list semantics in this case unless `role="list"` is set. Not tested here. Optional.

### F9 (Info, CONFIRMED) Ids are reused and duplicate names are ambiguous

- `after/list.js:9` computes `max id + 1`, so clearing the highest-id item and adding another reuses its id, and an emptied list restarts at 1. Harmless with no persistence or external references. Duplicate names are allowed and give checkboxes with identical accessible names. Not a defect for this sample.

### F10 (Info) Node 20 claim untested

- `README.md:52`. Only Node v24.15.0 was available. The test code uses only `node:test` and `node:assert/strict`, so it is plausible but unverified.

### F11 (Info) Wording nit

- `packet.json:32`: "the page calls them after add, check and clear". The page calls `clearBought`, `remainingLabel` and `canClear`; `remaining` is only called inside `remainingLabel`.

### F12 (for the lead) Items that go stale once this review is recorded

- `packet.json:89` (AC4 summary ends "Independent review has not yet run"), `:124-127` (`review` is `not_captured`), `:134` (limitation "Independent review is still to be run"), `:139-142` (acceptance reason) and `evidence/review-request.md` line 3 all describe a state this file changes. If you record this review, update them together. AC4 should stay `not_run`: nothing here replaces a screen-reader test.

## Verdict

**PASS** for what I could check. Every claim in `packet.json` that Node and static inspection can reach is true: 16/16 tests pass and the red run reproduces exactly, `change.patch` is byte-identical to `diff -ruN before after`, the preview is a byte-identical rebuild of `after/` plus a documented footer, the acceptance logic and page wiring behave as stated under a fake DOM, required colour contrasts and target sizes pass, the packet validates, refs exist, the video is 8.28 s with no audio track, and there are no secrets or private data in text files.

Not verified: rendering, the screenshots and video content, the 21 browser checks, any screen reader, other browsers, touch, Node 20.

Open items: F1 (Medium) is real by CSS analysis and affects desktop too; fix it or keep the disclosure and widen it. F2 to F5 are cheap Low fixes. F6 to F11 are optional.
