diff --git a/web/.gitignore b/web/.gitignore index 6b72902c8..ba613f3b9 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -3,3 +3,5 @@ dist/ coverage/ *.tsbuildinfo *.local +test-results/ +playwright-report/ diff --git a/web/CLAIMS.md b/web/CLAIMS.md new file mode 100644 index 000000000..28b7f5e81 --- /dev/null +++ b/web/CLAIMS.md @@ -0,0 +1,186 @@ +# Claim ledger + +Every user-visible claim this app and its README make about the placement session, +mapped to the code that implements it and the test or committed evidence that proves +it. Claims the app deliberately does not make are at the end — they are part of the +contract too. + +How to reproduce the evidence: `pnpm install && pnpm test` (unit) and +`pnpm test:e2e` (browser). Both run fully offline against the in-process contract +fake (`src/lib/contract/contract-fake.ts`) served by the loopback adapter +(`evidence/loopback-server.mjs`, bound to `127.0.0.1` only); no test or evidence run +touches a real network endpoint — `tests/helpers.ts` aborts every non-loopback +request and asserts the block list stayed empty. + +## Claims the app makes + +### 1. A result is bound to the upload's SHA-256 + +- **Said where:** README intro; the app shows "Bound to upload ``" under the + result and in the inspector (e.g. `28f11aae87d8…`). +- **Code:** the server returns the binding; the client displays it verbatim from the + result (`src/components/OutcomePanel.tsx` "Bound to upload" + `result.stats.input_sha256`; + same in `src/components/Inspector.tsx`), and the upload bytes are hashed server-side + per the contract (`src/lib/contract/`). +- **Proves it:** + - Unit: contract tests assert `input_sha256` is the SHA-256 of the scene text sent + (`src/lib/contract/*.test.ts`). + - Browser: `tests/session-journey.spec.ts` "picks a scene, submits…" computes the + scene text's SHA-256 in the test and asserts that exact hash is visible in a + `code` element; the export/import journey re-checks the same hash after a + round-trip through a file. + - Screenshot: `evidence/screenshots/02-bound-result.png`. + +### 2. The bearer token is never persisted, exported, or logged + +- **Said where:** README ("stays in memory for the next submission only — never + persisted and never logged"); the app's own notice under the token field ("A token + is set. It stays in this page's memory only: never stored, never logged…"). +- **Code:** `src/components/ServerFields.tsx` keeps the token in an uncontrolled + input wired only to the store's in-memory field; `src/lib/session/persist.ts` + serializes the session record from receipts and outcomes — the token is not part + of the record shape, so no persistence or export path can include it. +- **Proves it:** + - Unit: `src/lib/session/persist.test.ts` round-trips the record and asserts the + token never appears; the reopen tests assert no token is restored. + - Browser: the reopen journey fills a synthetic token, then reads + `localStorage` and asserts the stored record contains neither the token nor the + word "authorization" (`tests/session-journey.spec.ts` "reopens the persisted + session…"); the export journey downloads the export file and makes the same two + assertions on the raw file bytes. + - The export test then imports that file into a brand-new browser context: the + token notice is back to "Optional…", i.e. nothing was restored. + +### 3. An export (and the saved record) contains exactly the documented redacted shape + +- **Said where:** README section "Saved sessions: reopen, export, import" — + `format`/`version`/`savedAt`, `scene` with the file's exact text, `session` with + attempt history, receipts (URL, method, content types, byte lengths, request and + body hashes, capped excerpt, truncation flag, timing), outcomes, witnesses; and + the excludes list (token, HTTP headers, body beyond the capped excerpt, anything + outside the one record). +- **Code:** `src/lib/session/persist.ts` (record shape and redaction), export button + in `src/components/SessionPanel.tsx`. +- **Proves it:** + - Unit: `src/lib/session/persist.test.ts` asserts the record and export shape, + byte-length/hash fields, the capped excerpt, and absence of headers and token. + - Browser: the export journey parses the downloaded JSON and asserts `format`, + `version`, `savedAt`, `scene.fileName`, `scene.text` equal to the synthetic + scene text, two attempts, and no token or "authorization" anywhere in the file + (`tests/session-journey.spec.ts` "exports the redacted record…"). + - Screenshot: `evidence/screenshots/08-export-download.png`. + +### 4. History is never displayed as the current answer + +- **Said where:** the inspector's own copy — history entries say "Its result stays + in history and is not displayed as the answer", mismatched results are "kept as a + witness, never displayed", and stale attempts get ". Not the current answer.". +- **Code:** `src/components/Inspector.tsx` renders only the newest attempt's bound + result as the answer (labelled "The result's own decision:", never bare + "Decision:" — that heading is reserved for the current outcome panel); stale, + mismatched, unreadable and late attempts are rendered as history and witnesses. + The store's `display` state picks the current outcome; history entries cannot + promote themselves (`src/lib/session/store.ts`). +- **Proves it:** + - Unit: `src/lib/session/store.test.ts` (re-verification on restore, newest + attempt selection) and `src/lib/session/persist.test.ts` (interrupted in-flight + attempt reopens as cancelled, never as a settled answer). + - Browser: the mismatch journey asserts the word "Decision:" appears **zero** + times after a mismatched answer is recorded (`getByText("Decision:").toHaveCount(0)`) + while the witness text "the request sent … the answer claims …" is visible; the + inspector journey asserts the newest bound result is labelled current and the + earlier attempt shows as history. + - Screenshot: `evidence/screenshots/06-inspector-open.png`. + +### 5. Result claims are quoted as the server's own verdict + +- **Said where:** the outcome panel prints `Decision: `, the + inspector labels its copy "The result's own decision:", and refusals show "The + server said (, HTTP ): ". +- **Code:** `src/components/OutcomePanel.tsx` (decision, refusal quote), + `src/components/Inspector.tsx` ("The result's own decision:"), the refusal + detail with the server's envelope message and JSON pointer + (`src/components/OutcomePanel.tsx`, fed by the contract's 422 envelope). +- **Proves it:** + - Unit: contract tests pin the 422 envelope shape the UI quotes + (`src/lib/contract/*.test.ts`). + - Browser: the refusal journey asserts the server's message text + ("invalid_scene, HTTP 422") is quoted on the page + (`tests/session-journey.spec.ts` "sees the refusal…"), and the mismatch journey + keeps "Decision:" off the page while a witness records what the server *claimed*. + - Screenshots: `evidence/screenshots/04-refusal-422.png`, `05-mismatch-witness.png`. + +### 6. A closed tab does not end the session — and reopening restores the server too + +- **Said where:** README "Closing the tab does not end the session: the next page + load offers to reopen it"; interrupted in-flight attempts reopen as `cancelled`. +- **Code:** `src/lib/session/persist.ts` (versioned browser-local record under one + key, `basescanning-web.session.v1`), `src/lib/session/store.ts` (reopen re-intakes + the saved scene text through the same intake as a fresh upload, re-verifies the + newest bound result, marks a mid-flight attempt cancelled, and — since this + branch — restores the placement server origin from the newest request receipt so + the next attempt goes to the server the session actually talked to). +- **Proves it:** + - Unit: `src/lib/session/persist.test.ts` — reopen with two attempts intact + (history length 2), interrupted attempt becomes cancelled, corrupt record is + reported with bytes kept, and the record's own server origin is adopted on + reopen (while the request path never leaks into the base URL). + - Browser: the reopen journey fills the token, submits twice (cancel + retry + history), reloads the page, reopens, and asserts history, no token, and that a + fresh submission lands on the loopback server (it would `ERR_CONNECTION_REFUSED` + against any other origin); the export/import journey proves the same record + restores standalone in a brand-new context. + - Screenshot: `evidence/screenshots/07-reopened-session.png`. + +### 7. The journey is operable without a mouse, has no reducible motion, and fits a phone + +- **Said where:** this ledger and the PR description (the README makes no motion + claim — see "not made"). +- **Proves it:** + - Keyboard: `tests/session-journey.spec.ts` "every control is operable from the + keyboard alone" walks the full tab order (`server-base-url` → `server-token` → + `scene-file` → Export → import → Run placement — the exact DOM order) and runs + the whole submission with only Tab and Enter. + - Reduced motion: the reduced-motion test scans every stylesheet rule for + `animation` and `transition` properties; the app ships none (the single rule in + `src/styles/app.css` is `overflow-wrap` for long hashes). The status line's + "in flight → bound" progression is text, not animation. + - Responsive: the 390×844 journey asserts no horizontal overflow at any point of + the full flow; screenshot `evidence/screenshots/10-mobile-390x844.png`. + - Accessibility: axe scans of empty, bound, refusal and inspector states in light + and dark emulation recorded in `evidence/axe/*.json` — **zero violations of any + impact level in all eight scans** (verified by `tests/axe-states.spec.ts`, which + fails the suite on any violation). + +## Claims the app deliberately does NOT make + +### No safe-placement guarantee + +The app reports the server's verdict and the evidence trail; it never claims a +placement is safe, correct or allowed. Words like "safe", "pass is correct", or +"verified" do not appear over results — the strongest statement the UI makes is +what the server said, bound to which bytes. The mismatch journey pins this: a +server answer that names different bytes is recorded as a witness, and the page +still shows no decision. + +### Capture stays in the iOS app + +The web client is a zero-install *experiment* surface: pick a scene file you +already have, aim it at a placement server, read the answer. There is no camera, +photo picker, or capture flow here — the README says so and the scene input only +accepts a `.json` file. Any claim about capturing homes belongs to the BaseScanning +iOS app, not to this client. + +### No motion/animation claim in the README + +The README does not advertise reduced-motion support because there is nothing to +reduce: the app ships no animations or transitions (verified by the stylesheet +scan above). If animation is ever added, this becomes a claim that needs its own +tests. + +### No network privacy claim beyond redaction + +The client claims receipts record sizes, hashes and timings — not headers, not +bodies beyond the capped excerpt. It does not claim the placement server is +trustworthy, that HTTPS is enforced for you, or that the loopback evidence setup +models real-network behavior beyond the contract. diff --git a/web/README.md b/web/README.md index 9ede33fb9..271a2095f 100644 --- a/web/README.md +++ b/web/README.md @@ -1,6 +1,12 @@ # web -Browser app for zero-install capture experiments. Right now it holds a placeholder page and `src/lib/units.ts`, which converts meters (what ARKit reports) to feet and inches for display. +Browser app for zero-install capture experiments. The home page runs a placement +session: pick a `scene.json`, aim it at a placement server, run the attempt (with a +live status line and cancellation), and read the answer. The state layer lives in +`src/lib/session/store.ts` on top of the placement contract in `src/lib/contract/`; +the contract's test double (`contract-fake.ts`) is also the only server surface the +tests use. The optional bearer token stays in memory for the next submission only — +it is never persisted and never logged. ## Setup @@ -24,6 +30,59 @@ pnpm install CI (`.github/workflows/web.yml`, job "Web checks") runs `pnpm install --frozen-lockfile` and then `pnpm run check`. +## Saved sessions: reopen, export, import + +The app keeps one session record in browser-local storage (`localStorage`) and +publishes a snapshot there after every change. Closing the tab does not end the +session: the next page load offers to reopen it. Reopening re-intakes the saved +scene text through the same intake a fresh upload takes, recomputes the newest +bound result from the attempt history, and starts with **no bearer token** — the +token is held in memory for the app's lifetime only and is never written to +storage. An attempt recorded mid-flight ("submitting") can never settle, so +reopening marks it `cancelled` with a cancelled outcome; a new attempt can start +immediately. Export (button "Export session (redacted JSON)") and import write and read the same +redacted shape in a standalone file. + +**What an export (and the saved record) contains** + +- `format` and `version` (`basescanning-session-record` v1) and a `savedAt` time. +- `scene`: the file name and the file's **exact text** — re-intaking it reproduces + the original upload bytes and their SHA-256. +- `session`: the attempt history — per attempt the status, the request receipt + (URL, method, content type, byte length, `requestSha256`, started time), the + response receipt where one exists (status, content type, body byte length, + `bodySha256`, a capped `bodyExcerpt`, truncation flag, timing), the outcome + (including any bound result), and the witnesses (refusals, mismatched answers, + unreadable bodies, late arrivals) with their reasons. + +**What it excludes** + +- The **bearer token** — never stored, never exported, never logged; it lives in + memory between selection and the next submission only. +- HTTP **headers**, including any `Authorization` header — receipts record the + URL, sizes, hashes and timings, not the wire headers. +- The response **body beyond the capped excerpt** — long bodies are truncated by + the client, and the excerpt is bounded in the record. +- Anything outside this app's session: no other tab's or app's storage, no + browser state beyond the single record under one key. + +Because the scene text travels in full, treat an export file as you treat the +original `scene.json`: it is a capture record, and the repository's rule about +synthetic data applies to it too. + +## Browser evidence + +`pnpm test:e2e` drives the real production build through the whole session journey — +pick a scene, submit, cancel, retry, refusal, mismatch witness, inspector history, +reopen, export/import, 390×844, keyboard-only, reduced motion — plus axe scans of the +empty, bound, refusal and inspector states in light and dark emulation. Everything +runs against the contract fake served over loopback HTTP (`evidence/loopback-server.mjs`, +bound to `127.0.0.1` only); no test or evidence run touches a real network endpoint, +and the shared helper aborts and asserts the absence of any non-loopback request. +Committed evidence lives in `evidence/screenshots/` and `evidence/axe/` (the JSON +records assert zero violations of any impact level in all eight scans), and +`CLAIMS.md` maps every user-visible claim to the code and evidence that proves it. + ## Conventions - Lengths are stored in meters. Convert to feet and inches only for display, using `src/lib/units.ts`. diff --git a/web/biome.json b/web/biome.json index 71d70060e..7d40276f7 100644 --- a/web/biome.json +++ b/web/biome.json @@ -6,7 +6,8 @@ "useIgnoreFile": true }, "files": { - "ignoreUnknown": false + "ignoreUnknown": false, + "includes": ["**", "!evidence/axe"] }, "formatter": { "enabled": true, diff --git a/web/evidence/axe/bound-dark.json b/web/evidence/axe/bound-dark.json new file mode 100644 index 000000000..2e4af8129 --- /dev/null +++ b/web/evidence/axe/bound-dark.json @@ -0,0 +1,8523 @@ +{ + "state": "bound", + "colorScheme": "dark", + "testEngine": { + "name": "axe-core", + "version": "4.13.0" + }, + "testRunner": { + "name": "axe" + }, + "testEnvironment": { + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", + "windowWidth": 1280, + "windowHeight": 800, + "orientationAngle": 0, + "orientationType": "landscape-primary" + }, + "timestamp": "2026-10-11T03:05:30.176Z", + "url": "http://127.0.0.1:4301/", + "toolOptions": { + "reporter": "v1" + }, + "inapplicable": [ + { + "id": "accesskeys", + "impact": null, + "tags": [ + "cat.keyboard", + "best-practice" + ], + "description": "Ensure every accesskey attribute value is unique", + "help": "accesskey attribute value should be unique", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/accesskeys?application=playwright", + "nodes": [] + }, + { + "id": "area-alt", + "impact": null, + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag244", + "wcag412", + "section508", + "section508.22.a", + "TTv5", + "TT6.a", + "EN-301-549", + "EN-9.2.4.4", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-1.1.2" + ], + "description": "Ensure elements of image maps have alternative text", + "help": "Active elements must have alternative text", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/area-alt?application=playwright", + "nodes": [] + }, + { + "id": "aria-braille-equivalent", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "EN-301-549", + "EN-9.4.1.2" + ], + "description": "Ensure aria-braillelabel and aria-brailleroledescription have a non-braille equivalent", + "help": "aria-braille attributes must have a non-braille equivalent", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-braille-equivalent?application=playwright", + "nodes": [] + }, + { + "id": "aria-command-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "TTv5", + "TT6.a", + "EN-301-549", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-11.9.1" + ], + "description": "Ensure every ARIA button, link and menuitem has an accessible name", + "help": "ARIA commands must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-command-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-dialog-name", + "impact": null, + "tags": [ + "cat.aria", + "best-practice" + ], + "description": "Ensure every ARIA dialog and alertdialog node has an accessible name", + "help": "ARIA dialog and alertdialog nodes should have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-dialog-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-hidden-focus", + "impact": null, + "tags": [ + "cat.name-role-value", + "wcag2a", + "wcag412", + "TTv5", + "TT6.a", + "EN-301-549", + "EN-9.4.1.2", + "RGAAv4", + "RGAA-10.8.1" + ], + "description": "Ensure aria-hidden elements are not focusable nor contain focusable elements", + "help": "ARIA hidden element must not be focusable or contain focusable elements", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-hidden-focus?application=playwright", + "nodes": [] + }, + { + "id": "aria-input-field-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "TTv5", + "TT5.c", + "EN-301-549", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-11.1.1" + ], + "description": "Ensure every ARIA input field has an accessible name", + "help": "ARIA input fields must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-input-field-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-meter-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag111", + "EN-301-549", + "EN-9.1.1.1", + "RGAAv4", + "RGAA-11.1.1" + ], + "description": "Ensure every ARIA meter node has an accessible name", + "help": "ARIA meter nodes must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-meter-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-progressbar-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag111", + "EN-301-549", + "EN-9.1.1.1", + "RGAAv4", + "RGAA-11.1.1" + ], + "description": "Ensure every ARIA progressbar node has an accessible name", + "help": "ARIA progressbar nodes must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-progressbar-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-required-children", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag131", + "EN-301-549", + "EN-9.1.3.1", + "RGAAv4", + "RGAA-9.3.1" + ], + "description": "Ensure elements with an ARIA role that require child roles contain them", + "help": "Certain ARIA roles must contain particular children", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-required-children?application=playwright", + "nodes": [] + }, + { + "id": "aria-required-parent", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag131", + "EN-301-549", + "EN-9.1.3.1", + "RGAAv4", + "RGAA-9.3.1" + ], + "description": "Ensure elements with an ARIA role that require parent roles are contained by them", + "help": "Certain ARIA roles must be contained by particular parents", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-required-parent?application=playwright", + "nodes": [] + }, + { + "id": "aria-tab-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "TTv5", + "TT5.c", + "EN-301-549", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-7.1.1" + ], + "description": "Ensure every ARIA tab node has an accessible name", + "help": "ARIA tab nodes must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-tab-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-text", + "impact": null, + "tags": [ + "cat.aria", + "best-practice" + ], + "description": "Ensure role=\"text\" is used on elements with no focusable descendants", + "help": "\"role=text\" should have no focusable descendants", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-text?application=playwright", + "nodes": [] + }, + { + "id": "aria-toggle-field-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "TTv5", + "TT5.c", + "EN-301-549", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-7.1.1" + ], + "description": "Ensure every ARIA toggle field has an accessible name", + "help": "ARIA toggle fields must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-toggle-field-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-tooltip-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "EN-301-549", + "EN-9.4.1.2" + ], + "description": "Ensure every ARIA tooltip node has an accessible name", + "help": "ARIA tooltip nodes must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-tooltip-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-treeitem-name", + "impact": null, + "tags": [ + "cat.aria", + "best-practice" + ], + "description": "Ensure every ARIA treeitem node has an accessible name", + "help": "ARIA treeitem nodes should have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-treeitem-name?application=playwright", + "nodes": [] + }, + { + "id": "blink", + "impact": null, + "tags": [ + "cat.time-and-media", + "wcag2a", + "wcag222", + "section508", + "section508.22.j", + "TTv5", + "TT2.b", + "EN-301-549", + "EN-9.2.2.2", + "RGAAv4", + "RGAA-13.8.1" + ], + "description": "Ensure elements are not used", + "help": " elements are deprecated and must not be used", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/blink?application=playwright", + "nodes": [] + }, + { + "id": "bypass", + "impact": null, + "tags": [ + "cat.keyboard", + "wcag2a", + "wcag241", + "section508", + "section508.22.o", + "TTv5", + "TT9.a", + "EN-301-549", + "EN-9.2.4.1", + "RGAAv4", + "RGAA-12.7.1" + ], + "description": "Ensure each page has at least one mechanism for a user to bypass navigation and jump straight to the content", + "help": "Page must have means to bypass repeated blocks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/bypass?application=playwright", + "nodes": [] + }, + { + "id": "definition-list", + "impact": null, + "tags": [ + "cat.structure", + "wcag2a", + "wcag131", + "EN-301-549", + "EN-9.1.3.1", + "RGAAv4", + "RGAA-9.3.3" + ], + "description": "Ensure
elements are structured correctly", + "help": "
elements must only directly contain properly-ordered
and
groups,