v1.1: harden recovery and control boundaries
WP-D and WP-E complete the planned v1.1 implementation packages. WP-D — recovery honesty: - backups verify the completed copy with PRAGMA integrity_check - corruption missed by quick_check is detected by the full check - existing good backups remain protected - oversized exports are still delivered but declare whether this version can import them, while the 20 MB import limit remains unchanged - backup was exercised through the real browser UI on both the normal campaign database and a campaign-shaped database over 100 MB WP-E — control-boundary contrast: - interactive control boundaries meet the WCAG 1.4.11 3:1 target - the contrast audit is now a failing gate rather than an advisory - rendered browser measurements pass for the composer, controls, tabs and nav - text contrast and focus visibility remain intact - owner reviewed and approved the before/after screenshots Reports: - planning/reports/v1.1/V1.1-WP-D-REPORT.md - planning/reports/v1.1/V1.1-WP-E-REPORT.md All planned v1.1 work packages A-E are now complete. Release validation has not yet begun.
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
# v1.1 WP-D — Recovery Honesty
|
||||
|
||||
**Status:** COMPLETE — **PASS**. The decision, and what was deliberately not
|
||||
claimed, is in §P.
|
||||
|
||||
---
|
||||
|
||||
## A. Repository baseline
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Branch | `v1.1-development` |
|
||||
| HEAD at start | `59b5ebc2d85bdf53d38b6bcf347c496dd3432be1` — *v1.1 WP-C: browser release coverage*, signed by the owner (good signature, RSA key `02C9BF7D…`) |
|
||||
| Its ancestry | `0c1ba83` (WP-B.2), `beb17ad` (WP-B.1), `d63804f` (WP-A1/A2), `432f041` (v1.0.0) |
|
||||
| Working tree at start | clean; nothing staged |
|
||||
| WP-E | not started when WP-D was implemented |
|
||||
|
||||
---
|
||||
|
||||
## B. Existing backup behaviour
|
||||
|
||||
`backend/app/backup.py`, as v1 shipped it. The six questions the brief asks:
|
||||
|
||||
| # | Question | Answer (before WP-D) |
|
||||
| --- | --- | --- |
|
||||
| 1 | How is the backup file created? | SQLite's **online backup API** (`sqlite3.Connection.backup`, `pages=-1`), from the live database opened read-only through a `mode=ro` URI. The destination is a temporary file `….db.partial` **in the destination directory**, so the rename below is atomic |
|
||||
| 2 | Where does validation happen? | `_verify()`, on the finished copy, opened as its own read-only connection — not on the source, and not through the connection that wrote it |
|
||||
| 3 | When does the final filename appear? | Only after verification: `os.replace(working, target)`. A failed or interrupted run never leaves a file wearing a backup's name |
|
||||
| 4 | How are failures cleaned up? | `_discard()` removes the partial file, and `BackupError` is raised with what went wrong. The source is untouched |
|
||||
| 5 | Could an existing good backup be overwritten? | **No.** `_unused_name()` stamps each backup with the time and adds a counter if that name (or its `.partial`) exists |
|
||||
| 6 | Was the check on the source or the copy? | The copy |
|
||||
|
||||
So the mechanism was already right. The one gap was the **strength** of the check:
|
||||
`PRAGMA quick_check`, which reads every page and every record but skips the
|
||||
cross-check between a table and its indexes.
|
||||
|
||||
---
|
||||
|
||||
## C. `integrity_check` implementation
|
||||
|
||||
One function changed:
|
||||
|
||||
```python
|
||||
# app/backup.py, _verify()
|
||||
rows = connection.execute("PRAGMA integrity_check").fetchall() # was quick_check
|
||||
```
|
||||
|
||||
- It still runs on the **finished copy**, opened as its own read-only
|
||||
connection, before the rename.
|
||||
- A failure still raises `BackupError` naming what was wrong, still discards the
|
||||
partial file, and still leaves the source and every earlier backup untouched.
|
||||
- The returned `integrity` field, the filename, the directory, the reported
|
||||
fields (`filename`, `bytes`, `pages`, `seconds`, `integrity`) and the API are
|
||||
unchanged.
|
||||
- The module docstring and `_verify`'s docstring now record why the trade M9
|
||||
made (speed over the index cross-check) was not needed at these sizes, with
|
||||
the measurements in §E.
|
||||
|
||||
**No scheduled backups were added.** There is still no restore endpoint, no
|
||||
retention policy and no timer: a backup happens when the reader asks for one.
|
||||
|
||||
---
|
||||
|
||||
## D. Corruption fixture
|
||||
|
||||
`tests/test_v11_d_recovery.py: build_corrupt_copy()`.
|
||||
|
||||
A small database with `t(id, k, filler)` and an index `i_t_k ON t(k)`, 400 rows
|
||||
with keys `k000000`…`k000399`. `dbstat` names the index's own leaf pages, and one
|
||||
digit inside one indexed key on the first of them is changed (`k000144` →
|
||||
`k000944`). Every page stays structurally sound and every record still parses:
|
||||
what is broken is only the agreement between the index and its table.
|
||||
|
||||
**Proved before it is used as evidence**
|
||||
(`test_the_fixture_is_the_difference_between_the_two_checks`):
|
||||
|
||||
| Pragma | Result |
|
||||
| --- | --- |
|
||||
| `PRAGMA quick_check` | **`ok`** |
|
||||
| `PRAGMA integrity_check` | **`row 145 missing from index i_t_k`** |
|
||||
|
||||
That is the difference the package rests on: not a database both checks reject,
|
||||
but one the old check called healthy.
|
||||
|
||||
---
|
||||
|
||||
## E. Backup performance measurements
|
||||
|
||||
Each pragma was run in its **own fresh process**, alternating, three times, because
|
||||
a first measurement warms the page cache and a naive ordering makes whichever
|
||||
check runs second look faster. (It did: an early single-pass measurement showed
|
||||
`integrity_check` at 1.9 ms against `quick_check` at 65.5 ms, purely from cache
|
||||
warmth.)
|
||||
|
||||
**The real campaign database** — the M11 100-turn evidence campaign,
|
||||
`m04-final/campaign.db`, 2,367,488 bytes (2.26 MB):
|
||||
|
||||
| | Run 1 | Run 2 | Run 3 |
|
||||
| --- | --- | --- | --- |
|
||||
| `quick_check` | 5.0 ms | 3.4 ms | 7.6 ms |
|
||||
| `integrity_check` | 3.5 ms | 5.7 ms | 5.3 ms |
|
||||
|
||||
At this size the two are indistinguishable. A whole backup through
|
||||
`backup.create()` — copy, full check and rename — took **70.8 ms** and **27.4 ms**
|
||||
on two runs (578 pages).
|
||||
|
||||
**An index-heavy synthetic database of 105.2 MB** (105,160,704 bytes; 25,674
|
||||
pages of 4,096 bytes), shaped to give the cross-check real work: `actions` with
|
||||
152,000 rows and `memories` with 15,200, under three indexes
|
||||
(`i_actions_adv_depth`, `i_actions_kind`, `i_memories_adv`):
|
||||
|
||||
| | Run 1 | Run 2 | Run 3 |
|
||||
| --- | --- | --- | --- |
|
||||
| `quick_check` | 172.6 ms | 92.4 ms | 97.1 ms |
|
||||
| `integrity_check` | 227.8 ms | 196.0 ms | 227.4 ms |
|
||||
|
||||
A whole backup of it through `backup.create()`: **0.58 s** for 25,674 pages,
|
||||
`integrity = ok`.
|
||||
|
||||
**A campaign-shaped database of 117.4 MB** (117,403,648 bytes; 2,200 actions),
|
||||
built through the application's own models so the product path can run against
|
||||
it (§E.1):
|
||||
|
||||
| | Run 1 | Run 2 | Run 3 |
|
||||
| --- | --- | --- | --- |
|
||||
| `quick_check` | 89.5 ms | 74.2 ms | 64.4 ms |
|
||||
| `integrity_check` | 90.0 ms | 82.9 ms | 75.9 ms |
|
||||
|
||||
**Reading.** The cost of the cross-check tracks **rows and index entries, not
|
||||
bytes**. On the campaign schema — 2,200 fat rows — the full check costs about
|
||||
7 ms more than the quick one at 117 MB. On the index-heavy synthetic — 167,200
|
||||
rows under three indexes at a similar size — it costs about 96 ms more. Both sit
|
||||
inside a backup of about half a second. There is no performance requirement in
|
||||
this project and WP-D does not invent one; the measurements are here because the
|
||||
M9 trade was made on a speed argument, and at these sizes that argument does not
|
||||
hold.
|
||||
|
||||
### E.1 Through the endpoint the button calls — implementation evidence only
|
||||
|
||||
**This does not satisfy acceptance criterion 3, and an earlier draft of this
|
||||
report wrongly said it did.** The criterion asks for the backup to *complete
|
||||
through the UI*; a reader does not call an endpoint. What follows is evidence
|
||||
about the implementation — the procedure, its result and its cost — and it is
|
||||
kept for that reason. The acceptance evidence is in **§E.2**.
|
||||
|
||||
`POST /api/backups` is the only call the **Back up now** button makes, and it
|
||||
takes no parameters, so it was driven directly against a **copy** of each
|
||||
database — the evidence database is evidence and is not written to:
|
||||
|
||||
| Database | Result |
|
||||
| --- | --- |
|
||||
| Evidence campaign, 2,367,488 bytes | **201** in 0.117 s wall — `pages: 578`, `seconds: 0.038`, `integrity: ok`; `GET /api/backups` then lists 1 backup |
|
||||
| Campaign-shaped, 117,403,648 bytes | **201** in 0.536 s wall — `pages: 28,663`, `seconds: 0.452`, `integrity: ok` |
|
||||
|
||||
**Why a second large database exists.** The index-heavy synthetic has no
|
||||
application schema, and the server runs its migrations at startup, so the app
|
||||
will not start against it: driving the endpoint there fails in a migration that
|
||||
renumbers `actions`, before any backup is attempted. It remains valid evidence
|
||||
for the pragma comparison, which is a property of SQLite and not of this schema,
|
||||
but criterion 3 needs a database the product can actually open — hence the
|
||||
campaign-shaped one, which §E.2 then drives through the browser.
|
||||
|
||||
Artefacts stay under `$HOME` (`v11-evidence/wp-d/`), not in the repository.
|
||||
|
||||
---
|
||||
|
||||
### E.2 Through the UI — the acceptance evidence for criterion 3
|
||||
|
||||
The reader-facing **Back up now** control, clicked in a real Firefox, on the
|
||||
production build served by FastAPI: the WP-C path, with no Vite dev server, no
|
||||
inference, and no sleep used as an assertion. Every wait is on a condition the
|
||||
page or the filesystem can show.
|
||||
|
||||
`backend/tools/wpd_backup_ui.py`, run as
|
||||
`python -m tools.wpd_backup_ui --out $HOME/v11-evidence/wp-d/ui --case both`.
|
||||
**34 checks, 34 passed, 0 failed** —
|
||||
`$HOME/v11-evidence/wp-d/ui/backup-ui-report.json`.
|
||||
|
||||
The route a reader takes is the route the tool takes: open the application, click
|
||||
**Settings** in the navigation, open *Back up everything on this machine* (a
|
||||
`<details>` that loads what is on disk when it opens), then press the button.
|
||||
|
||||
| | **Case 1 — real campaign database** | **Case 2 — ≥100 MB application database** |
|
||||
| --- | --- | --- |
|
||||
| Source | the M11 evidence campaign, copied | the campaign-shaped database of §E, copied |
|
||||
| **Database size** | **2,367,488 bytes** (2.3 MB) | **117,403,648 bytes** (112.0 MB) |
|
||||
| Schema | the application's own, `user_version` 94 | the application's own, `user_version` 94 |
|
||||
| **Browser action** | click **Back up now** (twice — see below) | click **Back up now** |
|
||||
| **Observable UI result** | toast: *"Backup written: adventure-storyteller-20260915-225000.db (2.3 MB)."*, no error toast, the control returns from *Backing up…*, and the file appears in the panel's list | toast: *"Backup written: adventure-storyteller-20260915-225007.db (112.0 MB)."*, no error toast, control returns, file listed |
|
||||
| **Backup path** | `…/ui/real/data/backups/adventure-storyteller-20260915-225000.db` | `…/ui/large/data/backups/adventure-storyteller-20260915-225007.db` |
|
||||
| **Backup file size** | **2,367,488 bytes** | **117,403,648 bytes** |
|
||||
| **`integrity_check` result** | **`ok`** | **`ok`** |
|
||||
| **Integrity-check elapsed** | **5.8 ms** | **78.5 ms** |
|
||||
| **Total elapsed backup** | **0.108 s** (click → the UI says it is done) | **0.719 s** |
|
||||
|
||||
"Total elapsed" is measured from the click to the rendered result, so it is what
|
||||
the reader waits, not what the server reports. The `integrity_check` above is an
|
||||
independent second opinion, run here on the artefact the UI produced — the
|
||||
application had already verified the copy before keeping it.
|
||||
|
||||
**The size the UI reports is the size on disk.** "112.0 MB" in the toast is
|
||||
117,403,648 bytes shown in the page's own units; the file matches the source
|
||||
database byte for byte.
|
||||
|
||||
**Existing-backup protection, established through the UI itself** (Case 1's
|
||||
second press, rather than a backup planted by a library call):
|
||||
|
||||
| Claim | Result |
|
||||
| --- | --- |
|
||||
| A second press writes a *different* file — `…-225000-2.db`, the counter `_unused_name` adds when two backups land in the same second | PASS |
|
||||
| The first backup still exists afterwards | PASS |
|
||||
| And is byte-for-byte what it was — 2,367,488 bytes, `sha256:7d45555566…` | PASS |
|
||||
| And still passes `integrity_check` | PASS |
|
||||
|
||||
No corruption was fabricated through the browser; rejection behaviour is already
|
||||
proved deterministically in §D.
|
||||
|
||||
**Supplemental endpoint evidence.** The servers' own logs corroborate that the
|
||||
button drove each backup: Case 1 logged two `POST /api/backups → 201 Created`,
|
||||
each followed by `GET /api/backups → 200 OK` as the panel reloaded its list;
|
||||
Case 2 logged one of each. Screenshots and logs are beside the report JSON.
|
||||
|
||||
**A harness defect this run found, in my own check and not in the product.** The
|
||||
first pass reported six failures. The toast renders a decorative mark before its
|
||||
message — `<span class="toast-mark" aria-hidden="true">❖</span><span>…</span>` —
|
||||
so the button's `textContent` begins with `❖`, and my assertion matched
|
||||
`textContent.startswith("Backup written:")`. In that same run the UI had shown a
|
||||
non-error toast naming the file, the file was on disk, its name was in the
|
||||
panel's list, and the copy verified: the product was right and the assertion was
|
||||
reading the mark. The check now reads the message span. **No product code was
|
||||
changed** — the expected change for this work was none, and none was needed.
|
||||
|
||||
---
|
||||
|
||||
## F. Existing export/import limit behaviour
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Import ceiling | `limits.MAX_IMPORT_BODY_BYTES` = 20 MB, **unchanged by WP-D** |
|
||||
| Where it is enforced | `BodySizeLimitMiddleware`, on the declared `Content-Length`, before the body is read |
|
||||
| Refusal | HTTP **413**, "Request too large (limit 20 MB)." — it already named the limit |
|
||||
| Export before WP-D | `GET /adventures/{id}/export` returned the bundle. Nothing compared its size with the ceiling, so a campaign could be exported and then refused by its own importer |
|
||||
|
||||
---
|
||||
|
||||
## G. Oversized-export warning implementation
|
||||
|
||||
**Where the answer comes from.** `limits.import_limit_label()` and
|
||||
`limits.oversized_export_warning(size)` derive the sentence from
|
||||
`MAX_IMPORT_BODY_BYTES`. A test changes the constant and asserts the wording
|
||||
follows, so no number is written twice.
|
||||
|
||||
> This export is larger than this version's 20 MB import limit (21,230,000 bytes).
|
||||
> The file was exported successfully, but this version cannot import it.
|
||||
|
||||
**Where it travels: headers, not the body.** The export response *is* the bundle —
|
||||
the browser saves exactly those bytes as the file — so a warning inside it would
|
||||
become part of a portable story file and of every checksum taken over one. The
|
||||
route returns the same body with:
|
||||
|
||||
```text
|
||||
X-Export-Bytes the serialised size
|
||||
X-Import-Limit-Bytes MAX_IMPORT_BODY_BYTES
|
||||
X-Importable-By-This-Version true / false
|
||||
X-Export-Warning only when false
|
||||
```
|
||||
|
||||
**Which size is measured.** The compact serialisation (`separators=(",", ":")`,
|
||||
`ensure_ascii=False`), which is what this response sends *and* what the browser
|
||||
POSTs back on import — the bytes `BodySizeLimitMiddleware` weighs. The
|
||||
pretty-printed file the reader downloads is larger and is not what import reads.
|
||||
The route serialises once and returns those bytes, so `X-Export-Bytes` is the
|
||||
length of the body actually sent.
|
||||
|
||||
**The bundle is unchanged.** No key was added to it (§I), and the format stays
|
||||
`ai-dnd-adventure-v3`.
|
||||
|
||||
---
|
||||
|
||||
## H. Oversized export evidence
|
||||
|
||||
`test_an_oversized_export_is_still_delivered_and_says_it_cannot_come_back`
|
||||
writes real rows into a campaign until its bundle genuinely exceeds the ceiling —
|
||||
nothing is mocked, and the export serialises all of it.
|
||||
|
||||
| Claim | Result |
|
||||
| --- | --- |
|
||||
| The response body is larger than 20 MB | PASS |
|
||||
| It parses, and is `ai-dnd-adventure-v3` with its actions | PASS |
|
||||
| `X-Importable-By-This-Version: false` | PASS |
|
||||
| `X-Export-Warning` names the limit ("20 MB"), says the export succeeded, and says this version cannot import it | PASS |
|
||||
| `X-Export-Bytes` equals the body length | PASS |
|
||||
| The same bundle is refused by import, with the limit named (§J) | PASS |
|
||||
| A normal campaign's export carries **no** warning, and `X-Importable-By-This-Version: true` | PASS |
|
||||
| The warning follows the constant (changed to 50 MB in a test: the sentence says 50 MB, and no longer says 20 MB) | PASS |
|
||||
|
||||
---
|
||||
|
||||
## I. Normal-export compatibility
|
||||
|
||||
The same campaign database (`m04-final/campaign.db`) exported through a worktree
|
||||
at `59b5ebc` (before WP-D) and through the WP-D tree:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Before | 2,699,076 bytes |
|
||||
| After | 2,699,076 bytes |
|
||||
| Comparison | **identical** — the parsed documents compare equal, key for key |
|
||||
|
||||
No timestamp allowance was needed: this campaign's bundle carries no field that
|
||||
moves between exports. The format is `ai-dnd-adventure-v3` in both.
|
||||
|
||||
`test_the_bundle_itself_never_carries_the_warning` additionally asserts that no
|
||||
key of an oversized bundle mentions the warning, the limit or importability.
|
||||
|
||||
---
|
||||
|
||||
## J. Import-refusal behaviour
|
||||
|
||||
Unchanged in behaviour, and already naming the limit:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Status | **413**, from the middleware, on `Content-Length`, before the body is parsed |
|
||||
| Message | "Request too large (limit 20 MB)." |
|
||||
| WP-D test | `test_that_same_bundle_is_refused_by_import_naming_the_limit` posts the *actual oversized export* and asserts 413 and that the detail contains `limits.import_limit_label()` |
|
||||
| Not relaxed | the same boundary, the same status, the same middleware. `test_a_bundle_under_the_limit_still_imports` keeps the ordinary path honest (201) |
|
||||
|
||||
No wording correction was needed.
|
||||
|
||||
---
|
||||
|
||||
## K. Frontend behaviour
|
||||
|
||||
`api.exportAdventure` now returns the bundle **and** what the server said about
|
||||
it (`exportBytes`, `importLimitBytes`, `importable`, `warning`), read from the
|
||||
headers.
|
||||
|
||||
**The no-header case.** `importable` is `resp.headers.get(…) !== 'false'`, so
|
||||
only the literal string `false` is read as a refusal: an older server that sends
|
||||
no headers — or a header that arrives malformed — yields `importable: true` and
|
||||
`warning: null`, and the page says nothing it was not told. The two size fields
|
||||
fall back to `null` unless they parse as a positive number.
|
||||
|
||||
This is asserted directly: three tests stub `fetch` with real response headers
|
||||
and check what `api.exportAdventure` makes of them — a warning with the sizes it
|
||||
named, an ordinary export marked importable, and an older server sending no
|
||||
headers at all. They exist because the page-level tests below mock
|
||||
`api.exportAdventure` itself and so cannot see a header name, which meant a typo
|
||||
on the frontend side would have left the whole suite green (§O.7).
|
||||
|
||||
Both reader-facing entry points keep delivering the file first and then report:
|
||||
|
||||
| Entry point | Under the limit | Over the limit |
|
||||
| --- | --- | --- |
|
||||
| Campaign library, **Export** | file written; "Campaign exported." | file written; the warning, as an error-styled toast |
|
||||
| Campaign settings, **Export campaign** | file written; "Campaign exported." | file written; the warning |
|
||||
|
||||
No new modal, no redesign: the existing toast carries it.
|
||||
|
||||
**Tests** (`pages/exportHonesty.test.jsx`, **7**): three read real response
|
||||
headers through a stubbed `fetch` (above), and four drive both entry points —
|
||||
the file is delivered in every case; the warning is shown when the server sends
|
||||
one, naming 20 MB and saying the export succeeded; the ordinary confirmation is
|
||||
shown when it does not.
|
||||
|
||||
---
|
||||
|
||||
## L. Offline regression
|
||||
|
||||
`tools/m11_offline.py`, against the production build, in the offline container:
|
||||
|
||||
**23 checks, 23 passed, 0 failed** —
|
||||
`$HOME/v11-evidence/wp-d/offline/offline-report.json`.
|
||||
|
||||
The two this package could have broken are in it and passed:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| a campaign exports offline | ok |
|
||||
| and imports offline, with its state | ok |
|
||||
| no secret is present in the export | ok |
|
||||
| a local file imports offline | ok |
|
||||
|
||||
The export route now sets headers and serialises compactly; the offline
|
||||
container still exports a campaign and imports it back with its state, so
|
||||
neither the round trip nor the secret-scrubbing changed.
|
||||
|
||||
## M. Full regression
|
||||
|
||||
| Suite | Result |
|
||||
| --- | --- |
|
||||
| **Full backend suite** (`pytest -q`, no `AIDND_TEST_*` set) | **1,712 passed, 17 skipped, 0 failed, 0 xfailed** (936.6 s) |
|
||||
| **Frontend suite** (`npm test`) | **175 passed**, 15 files, 0 failed |
|
||||
| **Lint** (`npm run lint`, oxlint) | **exit 0**, 0 errors, 15 warnings |
|
||||
| **Production build** (`npm run build`) | succeeded |
|
||||
| **Offline regression** | 23 passed, 0 failed (§L) |
|
||||
|
||||
**The backend count reconciles exactly.** WP-B's closing tree was 1,693; WP-C
|
||||
added 7 (`test_v11_c_browser_helpers.py`) and did not rerun the suite because no
|
||||
application code changed; WP-D adds the 12 in `test_v11_d_recovery.py`.
|
||||
1,693 + 7 + 12 = **1,712**.
|
||||
|
||||
**The 17 skips are named, not assumed.** Run with `-rs`, every one is an
|
||||
environment-gated real-model test, and none is new:
|
||||
|
||||
| File | Skipped | Gate |
|
||||
| --- | --- | --- |
|
||||
| `test_knowledge_real_model.py` | 7 | `AIDND_TEST_ENDPOINT` (and `AIDND_TEST_EMBED_MODEL`) |
|
||||
| `test_context_realistic.py` | 3 | `AIDND_TEST_ENDPOINT` and `AIDND_TEST_MODEL` |
|
||||
| `test_narrative_realistic.py` | 3 | `AIDND_TEST_ENDPOINT` and `AIDND_TEST_MODEL` |
|
||||
| `test_m11_real_window.py` | 3 | two the same; one `AIDND_TEST_WIDE_MODEL` |
|
||||
| `test_provider_wiring.py` | 1 | `AIDND_TEST_ENDPOINT` and `AIDND_TEST_MODEL` |
|
||||
|
||||
That is the same 17 B.1 and B.2 recorded. WP-D used no model and added no skip.
|
||||
|
||||
**The plan's named regression requirements**, run individually rather than
|
||||
assumed to be inside the total:
|
||||
|
||||
| Requirement | Result |
|
||||
| --- | --- |
|
||||
| I01-I07, L01-L04 | **25 passed**, 1,704 deselected |
|
||||
| The backup case in `test_m11_migration.py` | **1 passed** |
|
||||
| `backup.test.jsx` | **7 passed** |
|
||||
| The offline container's export and import | passed (§L) |
|
||||
|
||||
**Frontend arithmetic.** WP-C's baseline was 168. WP-D adds 4 page-level export
|
||||
tests and 3 header-parsing tests: 168 + 7 = **175**. The 15 lint warnings are
|
||||
the same pre-existing `only-export-components` and unused-import kind recorded
|
||||
at WP-C, and **none is in a file WP-D changed**.
|
||||
|
||||
## N. Compatibility
|
||||
|
||||
| Area | Effect |
|
||||
| --- | --- |
|
||||
| Database schema | unchanged; no migration |
|
||||
| Bundle format | `ai-dnd-adventure-v3`, unchanged |
|
||||
| Normal bundle contents | byte-identical (§I) |
|
||||
| Existing backups | still valid files with the same names; only the check that admits a new one is stricter |
|
||||
| v1.0.0 databases | open unchanged (no schema or data path changed) |
|
||||
| Import limit | unchanged at 20 MB |
|
||||
| Scheduled backups | none, as before |
|
||||
| History, branches, Save Points, state, memory, knowledge | untouched |
|
||||
| Endpoint policy, local-only operation | untouched |
|
||||
|
||||
## O. Residual risks
|
||||
|
||||
1. **Closed: the backup is now driven through the UI.** This risk previously
|
||||
read "the browser click was not driven for the backup", and the owner
|
||||
correctly refused criterion 3 on endpoint evidence. §E.2 drives the real
|
||||
**Back up now** control in Firefox on both databases — 34 checks, 0 failed —
|
||||
including the ≥100 MB case, which does mean the harness starts a server
|
||||
against a 117 MB database and takes 0.719 s to do it. What remains is
|
||||
ordinary coverage scope: this runs as its own tool rather than inside the
|
||||
release harness, so it is not part of the 101-check run in the WP-E report.
|
||||
2. **The 20 MB ceiling is unchanged.** A campaign past it still cannot be
|
||||
imported by this version. WP-D makes that audible at export; raising the
|
||||
limit or streaming import stays v1.2, as the plan assigns it.
|
||||
3. **The warning is about *this* version.** It says what this build's importer
|
||||
will accept. A future build with a higher ceiling could import a file this
|
||||
one warned about, and the wording ("this version") is chosen so that stays
|
||||
true rather than becoming a lie.
|
||||
4. **`integrity_check` is not a guarantee of recoverability.** It proves the
|
||||
copy's pages, records and indexes agree. A database that was already
|
||||
logically wrong when it was copied is copied faithfully and passes. The
|
||||
package makes the check honest, not omniscient.
|
||||
5. **No restore path.** There is still no restore button and no scheduled
|
||||
backup — both explicitly out of scope. A reader who needs a backup restores
|
||||
it by replacing the file themselves, as before.
|
||||
6. **The header channel depends on the browser reaching headers.** Both export
|
||||
call sites read them through `fetch`, so a proxy that stripped `X-` headers
|
||||
would silently return to v1 behaviour: the file still arrives, the warning
|
||||
does not. Local-only operation makes that unlikely, and the failure is the
|
||||
old behaviour rather than a wrong claim.
|
||||
7. **Header parsing was untested — now closed.** Writing this section exposed
|
||||
it: the page-level tests mock `api.exportAdventure` and the backend tests
|
||||
assert what the server sends, so nothing read an actual header name, and a
|
||||
frontend-side typo would have left every test green and the reader silently
|
||||
uninformed. Three tests that stub `fetch` with real headers now cover it
|
||||
(§K). What remains is the ordinary version of this risk: the two sides agree
|
||||
by matching string literals in two files, and only a browser-level test would
|
||||
catch a mismatch introduced in both at once.
|
||||
|
||||
## P. Final decision
|
||||
|
||||
**Against the plan's acceptance criteria** (§ WP-D, *Recovery honesty*):
|
||||
|
||||
| # | Criterion | Verdict |
|
||||
| --- | --- | --- |
|
||||
| 1 | A healthy database's backup reports `integrity_check` ok and is kept | **PASS** — `test_a_healthy_backup_passes_the_full_check_and_is_kept`, and both endpoint runs returned `integrity: ok` (§E.1) |
|
||||
| 2 | A copy `integrity_check` rejects and `quick_check` does not is rejected and not kept; existing backups still never overwritten | **PASS** — the fixture is proved to be exactly that difference (§D), and three tests cover rejection, the untouched earlier backup, and the unchanged file semantics |
|
||||
| 3 | The time is recorded on the evidence database and on a synthetic ≥100 MB, and the backup completes through the UI on both | **PASS** — pragma timings in §E on three databases, and the backup completes **through the UI** on both in §E.2: 0.108 s with `integrity_check` `ok` in 5.8 ms on the 2.3 MB campaign, 0.719 s with `ok` in 78.5 ms on the 117 MB application database. An earlier draft claimed this criterion on endpoint evidence (§E.1); that claim was wrong and is corrected |
|
||||
| 4 | An over-20 MB export succeeds, delivers the file, and warns naming the limit, in the API response and a component test; under the limit, no warning | **PASS** — §H, and 7 frontend tests (§K) |
|
||||
| 5 | Importing that bundle is refused with a message naming the limit | **PASS** — §J, asserted on the actual oversized export |
|
||||
| 6 | A normal export is byte-identical before and after, apart from timestamps | **PASS** — fully identical, no timestamp allowance needed (§I) |
|
||||
|
||||
**What I corrected rather than reported around.** Two claims of my own failed
|
||||
checking and were fixed, not softened: §E originally rested criterion 3 on
|
||||
`backup.create()` calls, and driving the real endpoint showed the index-heavy
|
||||
synthetic cannot serve that criterion at all (the app's startup migrations abort
|
||||
against a schema-less database) — hence the campaign-shaped database in §E.1.
|
||||
And §K asserted the no-header fallback from the code alone, which exposed that
|
||||
**no test read any header name**; three tests now do (§O.7).
|
||||
|
||||
**Not done, and not claimed:** the import ceiling is unchanged at 20 MB, by the
|
||||
plan's own assignment of the raise to v1.2. The UI backup runs as its own tool
|
||||
rather than inside the release harness (§O.1).
|
||||
|
||||
**No product code changed for the UI verification.** The run exposed one defect,
|
||||
and it was in my own assertion, not in the application (§E.2). Backend
|
||||
**1,723 passed / 17 skipped / 0 failed** and offline **23/23** therefore stand
|
||||
as prior evidence, unchanged and not re-run; the targeted backup and browser
|
||||
checks were re-run instead.
|
||||
|
||||
```text
|
||||
BACKUP INTEGRITY: PASS
|
||||
BACKUP THROUGH UI — REAL DB: PASS
|
||||
BACKUP THROUGH UI — >=100 MB DB: PASS
|
||||
EXPORT HONESTY: PASS
|
||||
|
||||
WP-D OVERALL:
|
||||
PASS
|
||||
```
|
||||
|
||||
All WP-D changes are **staged and uncommitted**. No commit, no push, no tag.
|
||||
WP-E has not begun.
|
||||
@@ -0,0 +1,374 @@
|
||||
# v1.1 WP-E — Control-Boundary Contrast
|
||||
|
||||
**Status:** COMPLETE — **PASS**, with owner approval of the screenshots
|
||||
outstanding. The decision, and what was deliberately not claimed, is in §O.
|
||||
|
||||
---
|
||||
|
||||
## A. Repository baseline
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Branch | `v1.1-development` |
|
||||
| HEAD | `59b5ebc` — *v1.1 WP-C: browser release coverage*, signed by the owner |
|
||||
| Working tree at start | WP-D staged (10 files), nothing committed |
|
||||
| Criterion | WCAG 2.1 **1.4.11 Non-text Contrast**, 3:1, for control boundaries; **1.4.3** 4.5:1 for body text, unchanged |
|
||||
|
||||
---
|
||||
|
||||
## B. What v1.0.0 actually did
|
||||
|
||||
`tools/contrast_audit.py` measured control boundaries, printed that two of them
|
||||
were below 3:1, and **exited 0**. Its own comment argued the position:
|
||||
|
||||
> in this design a control is identified by its *label*, which is measured above
|
||||
> and passes, not by its edge. So a boundary below 3:1 is reported with its
|
||||
> number and does not fail the run.
|
||||
|
||||
So the audit was a report, not a gate: no palette change could ever fail it on a
|
||||
boundary. The two numbers it printed were **1.33:1** (`--border` on
|
||||
`--bg-panel`) and **1.75:1** (`--border-bright`), against a floor of 3.0.
|
||||
|
||||
**WP-E overturns that argument.** 1.4.11 covers the visual information needed to
|
||||
identify a component *and its boundary*; a reader who cannot see where a text box
|
||||
ends cannot see that there is a text box to type into, label or no label. The
|
||||
tokens were raised rather than the criterion re-argued.
|
||||
|
||||
---
|
||||
|
||||
## C. Token inventory
|
||||
|
||||
| Token | v1.0.0 | v1.1 | Why |
|
||||
| --- | --- | --- | --- |
|
||||
| `--border` | `#2b2b3d` | **`#676792`** | every control's resting edge |
|
||||
| `--border-bright` | `#3d3d55` | **`#7a7aaa`** | hover edges, the composer's resting edge, the open panel tab |
|
||||
| `--bg-panel`, `--bg-input`, `--bg`, `--text`, `--text-dim`, `--accent*`, `--danger`, `--warning`, `--player`, `--chart-*` | — | **unchanged** | WP-E is a boundary package; no text or accent colour moved |
|
||||
|
||||
**The floor is taken against `--bg-input`, not `--bg-panel`.** Inputs and buttons
|
||||
are drawn on `--bg-input` (`styles/forms.css`), which is lighter than
|
||||
`--bg-panel` and therefore the harder case. The audit had been checking only
|
||||
`--bg-panel`, so a token could have passed the audit while the real control
|
||||
failed. Measured on the new values:
|
||||
|
||||
| | vs `--bg-input` | vs `--bg-panel` | vs `--bg` |
|
||||
| --- | --- | --- | --- |
|
||||
| `--border` | **3.21:1** | 3.44:1 | 3.70:1 |
|
||||
| `--border-bright` | **4.24:1** | 4.55:1 | 4.88:1 |
|
||||
|
||||
Two properties were preserved deliberately: the rest→hover step is the same size
|
||||
as before (1.318 → 1.320), so hover still reads as a change rather than a jump;
|
||||
and `--border-bright` stays *below* body text against the same panel (2.98:1
|
||||
between them), so no edge outshines the words inside it.
|
||||
|
||||
---
|
||||
|
||||
## D. Component inventory
|
||||
|
||||
Where these tokens are actually drawn, from the stylesheets:
|
||||
|
||||
| Control | Rule | Rest | Hover / active | Focus |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Story composer | `.input-bar` (story.css) | `--border-bright` on `--bg-panel` | — | `--accent-dim` + `--accent-glow` ring |
|
||||
| Story controls | `.story-controls button` | `--border` on `--bg-panel` | `--border-bright` | (M11 focus check) |
|
||||
| Fields and buttons | `forms.css` | `--border` on `--bg-input` | `--accent-dim` | `--accent-dim` + ring |
|
||||
| Panel tabs | `.panel-tabs button` | **`transparent`** | `--border` on hover, `--border-bright` when active | — |
|
||||
| Top navigation | `.topnav` | `--border` bottom edge on **`--bg-panel-glass`** | — | — |
|
||||
| Scrollbar thumb | `base.css` | `--border-bright` as a *fill* on `--bg` | `--accent-dim` | — |
|
||||
|
||||
Two of these cannot be answered by token arithmetic at all, and both are
|
||||
measured in the browser instead (§G): the nav sits on a translucent panel, and
|
||||
the panel tab's edge is `transparent` until the panel is open.
|
||||
|
||||
---
|
||||
|
||||
## E. The audit is now a gate
|
||||
|
||||
`tools/contrast_audit.py`:
|
||||
|
||||
1. **Boundary pairs fail.** `text` and `boundary` rows are both pass/fail; the
|
||||
advisory branch is gone. The run returns 1 if either kind falls short.
|
||||
2. **Eight boundary pairs replace two.** Each border is checked against every
|
||||
background it is drawn on — `--bg-input`, `--bg-panel` and `--bg` — plus the
|
||||
focused edge (`--accent-dim`) on both panel and field backgrounds.
|
||||
3. **The verdict is taken on the number that is printed** (rounded to two
|
||||
decimals), so a pair shown as `3.00:1` is not failed for arithmetic the
|
||||
reader cannot see.
|
||||
4. The comment block that argued the old position is replaced by one recording
|
||||
what changed and why, including why `--bg-panel-glass` is not in the list.
|
||||
|
||||
## F. Gate tests
|
||||
|
||||
`backend/tests/test_v11_e_contrast.py` — **11 passed**. The threshold is
|
||||
exercised from both sides, on real token files:
|
||||
|
||||
| Test | Result |
|
||||
| --- | --- |
|
||||
| A boundary at **2.99:1** against `--bg-input` fails the run (exit 1) | PASS |
|
||||
| A boundary at **3.00:1** passes (exit 0) | PASS |
|
||||
| The **v1.0.0 value** `#2b2b3d` fails, at 1.24:1 against `--bg-input` | PASS |
|
||||
| A dimmed `--text-dim` still fails as a *text* pair | PASS |
|
||||
| A renamed token is a failure, not a silent skip | PASS |
|
||||
| The shipped palette passes both criteria | PASS |
|
||||
| Every boundary pair is measured against the background it is drawn on | PASS |
|
||||
| The M11 text baselines are unchanged: 14.57 / 13.57 / 5.48 / 5.88 | PASS |
|
||||
| The hover edge stays brighter than the resting edge | PASS |
|
||||
| No boundary becomes as loud as body text | PASS |
|
||||
| The WCAG ratio formula is anchored on known values (21:1, 1:1, symmetry) | PASS |
|
||||
|
||||
The 2.99 and 3.00 values are worth noting: **both clear 3:1 against
|
||||
`--bg-panel`** (3.21 and 3.22). They decide the gate only because the floor is
|
||||
now taken against the background the control is really on — so these two tests
|
||||
also prove §C's change is doing work.
|
||||
|
||||
---
|
||||
|
||||
## G. Browser measurement
|
||||
|
||||
`tools/m11_browser.py` gains a third suite, **WP-E**, counted separately from
|
||||
M11's 38 and WP-C's 53. It measures the *rendered* edge — `borderColor` from
|
||||
`getComputedStyle` — against what is actually behind it, with every translucent
|
||||
layer composited bottom-up.
|
||||
|
||||
A boundary is measured against **both** adjacent colours (the control's own fill
|
||||
inside it, the background outside it) and passes on the better of the two: an
|
||||
edge that matches its fill but contrasts with the page is still a visible
|
||||
outline. What 1.4.11 asks is that the component's extent be perceivable.
|
||||
|
||||
Two harness capabilities were added for this (`tools/m11_webdriver.py`):
|
||||
|
||||
- **`hover()`** moves a real pointer through the WebDriver Actions API.
|
||||
Dispatching a `mouseover` event from JavaScript does *not* trigger CSS
|
||||
`:hover`, so a synthetic event would have re-measured the resting edge and
|
||||
reported it as the hover edge.
|
||||
- **`screenshot()`** writes the viewport as a PNG, for the before/after evidence.
|
||||
|
||||
**A defect this found in my own first measurement.** The first run reported the
|
||||
hover edge as `rgb(114, 114, 160)` and the focused edge as `rgb(144, 120, 81)` —
|
||||
neither of which is any token. Both controls carry `transition: border-color
|
||||
0.15s`, so the measurement was taken mid-animation, on a colour no state
|
||||
actually has. `_settled()` now polls until the computed edge colour is the same
|
||||
on two consecutive reads before measuring (polled, not slept, per this harness's
|
||||
own rule). After the fix the same edges read exactly `rgb(122, 122, 170)`
|
||||
(`--border-bright`) and `rgb(150, 119, 58)` (`--accent-dim`).
|
||||
|
||||
---
|
||||
|
||||
## H. Before and after, measured in the browser
|
||||
|
||||
Both passes were taken the same way — `--only boundaries --no-narrator`, the
|
||||
production build — with only `tokens.css` differing. Evidence under
|
||||
`$HOME/v11-evidence/wp-e/before/` and `.../after/`.
|
||||
|
||||
| Control (state) | Before | After | Floor |
|
||||
| --- | --- | --- | --- |
|
||||
| Story composer — resting edge | **1.88:1** FAIL | **4.88:1** pass | 3.0 |
|
||||
| Story control — resting edge | **1.43:1** FAIL | **3.70:1** pass | 3.0 |
|
||||
| Open panel tab — resting edge | **1.75:1** FAIL | **4.55:1** pass | 3.0 |
|
||||
| Story control — hover edge | **1.88:1** FAIL | **4.88:1** pass | 3.0 |
|
||||
| Top navigation — translucent edge | **1.43:1** FAIL | **3.70:1** pass | 3.0 |
|
||||
| Story composer — focused edge | 4.70:1 pass | 4.70:1 pass | 3.0 |
|
||||
|
||||
**Suite result: before 5 passed / 5 failed; after 10 passed / 0 failed / 0
|
||||
skipped.**
|
||||
|
||||
Two things this table says that a summary would blur:
|
||||
|
||||
- **Focus was never the defect.** The focused edge (`--accent-dim`) already
|
||||
cleared 3:1 in v1.0.0 at 4.70:1, and WP-E did not change it. What failed was
|
||||
rest and hover — the states a reader spends all their time in.
|
||||
- **The translucent edge is real evidence.** The nav's background composited to
|
||||
`rgb(17, 17, 29)` — `--bg-panel-glass` (rgba 19,19,32 @ 0.82) over
|
||||
`rgb(10, 10, 15)` — not a fallback. That is the case token arithmetic cannot
|
||||
reach, and it moved from 1.43:1 to 3.70:1.
|
||||
|
||||
## I. Screenshots
|
||||
|
||||
| File | |
|
||||
| --- | --- |
|
||||
| `before/control-boundaries.png` | 131,175 bytes, 1366×682 |
|
||||
| `before/control-boundaries-nav.png` | 63,376 bytes, 1366×682 |
|
||||
| `after/control-boundaries.png` | 132,430 bytes, 1366×682 |
|
||||
| `after/control-boundaries-nav.png` | 63,541 bytes, 1366×682 |
|
||||
|
||||
All four are PNG, 1366×682, taken on the production build through the same
|
||||
harness path, differing only in `tokens.css`. The play-page pair shows the
|
||||
composer, the story controls and the open panel tab; the nav pair shows the
|
||||
translucent top edge on the library route.
|
||||
|
||||
## J. A finding this package created and fixed
|
||||
|
||||
Raising `--border-bright` broke something that had nothing to do with control
|
||||
boundaries. `.slice-7` in the context inspector's token breakdown was painted
|
||||
with `var(--border-bright)`, so it followed the token to `#7a7aaa` — an OKLab ΔE
|
||||
of **0.035** from `.slice-6` (`#7c86b8`), making two neighbouring chart slices
|
||||
effectively the same colour. The other slices sit **0.100–0.119** from their
|
||||
nearest neighbour.
|
||||
|
||||
`.slice-7` is now pinned to `#3d3d55`, the literal value it already rendered, so
|
||||
its appearance is unchanged from v1.0.0 and its separation (ΔE **0.251**) is the
|
||||
widest in the set. A chart fill and a control edge have different jobs and should
|
||||
not share a token.
|
||||
|
||||
Reassigning it to a fresh hue was considered and rejected on evidence: inside the
|
||||
palette's own chroma (0.045–0.120) and lightness (0.586–0.804) bands, the only
|
||||
hues clearing the set's 0.100 separation floor are pinks near 14°, which is
|
||||
`--danger`'s territory. Painting an ordinary prompt section in the colour this
|
||||
application reserves for failure would trade an accessibility fix for a semantic
|
||||
lie.
|
||||
|
||||
*(A first attempt, `#5d7f9e`, was rejected by the same measurement at ΔE 0.061 —
|
||||
below every real slice. It is recorded here because it was written into the file
|
||||
before it was measured.)*
|
||||
|
||||
## K. Text contrast regression
|
||||
|
||||
Unchanged, and asserted so in §F: **14.57:1** body text on the page, **13.57:1**
|
||||
in a panel, **5.48:1** secondary text in a panel, **5.88:1** on the page. Every
|
||||
text pair still clears 1.4.3, and no text token was touched.
|
||||
|
||||
## L. Browser release regression
|
||||
|
||||
The full harness, all three suites, against a real narrator on the production
|
||||
build. Evidence: `$HOME/v11-evidence/wp-e/release/browser-report.json`.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Kind | **`release regression`** — not `partial`, not `development (only …)` |
|
||||
| Narrator | `qwen2.5:3b-instruct`, the reference model, over plain HTTP on the LAN GPU host |
|
||||
| Browser | Firefox 155.0.1, geckodriver 0.37.1 |
|
||||
| Served | FastAPI on loopback, the **built** SPA (`dist` 2026-09-15T21:08:52) |
|
||||
| Duration | 71 s |
|
||||
| Turns played | **8**, of which `turns_not_clean` **0** and `protocol_shapes_in_narration` **0** |
|
||||
|
||||
**Counted per suite, as the brief requires:**
|
||||
|
||||
| Suite | Passed | Failed | Skipped |
|
||||
| --- | --- | --- | --- |
|
||||
| **M11** (the v1 release regression) | **38** | **0** | **0** |
|
||||
| **WP-C** (browser release coverage) | **53** | **0** | **0** |
|
||||
| **WP-E** (control boundaries) | **10** | **0** | **0** |
|
||||
| **Total** | **101** | **0** | **0** |
|
||||
|
||||
M11's 38 and WP-C's 53 are unchanged in count and in name: WP-E added a suite
|
||||
beside them rather than altering either. The `kind` field is quoted above
|
||||
because a fast run invites the question — 71 s for 101 checks including 8
|
||||
narrated turns is the GPU host being quick with a 3B model, and the 8 recorded
|
||||
turns with no unclean accounting are what rule out narration having been
|
||||
skipped.
|
||||
|
||||
The WP-E rows in this run are the same ten as the standalone capture in §H,
|
||||
re-measured with a narrator present and a full story on the page.
|
||||
|
||||
## M. Full regression
|
||||
|
||||
| Suite | Result |
|
||||
| --- | --- |
|
||||
| **Full backend suite** (`pytest -q`, no `AIDND_TEST_*` set) | **1,723 passed, 17 skipped, 0 failed, 0 xfailed** (1,054.7 s) |
|
||||
| **Frontend suite** (`npm test`) | **175 passed**, 15 files, 0 failed |
|
||||
| **Lint** (`npm run lint`, oxlint) | **exit 0**, 0 errors, 15 warnings |
|
||||
| **Production build** (`npm run build`) | succeeded |
|
||||
| **Browser harness** (M11 + WP-C + WP-E) | **101 passed, 0 failed, 0 skipped** (§L) |
|
||||
| **Contrast audit** (`python tools/contrast_audit.py`) | **exit 0** — every text pair and every boundary pair passes |
|
||||
|
||||
**The backend count reconciles exactly.** WP-D's tree was 1,712; WP-E adds the
|
||||
11 in `test_v11_e_contrast.py`. 1,712 + 11 = **1,723**. The 17 skips are the same
|
||||
environment-gated real-model tests recorded since B.1 — WP-E used no model and
|
||||
added no skip.
|
||||
|
||||
**The plan's regression requirements for WP-E** were the frontend suite and
|
||||
lint, and the harness's accessibility checks. All three pass: 175 and exit 0
|
||||
above, and M11's `A11y` rows — accessible names, visible keyboard focus, no
|
||||
positive tabindex, nothing revealed only on hover, and the four rendered text
|
||||
contrasts — are inside the 38/38 in §L.
|
||||
|
||||
**Lint detail.** The 15 warnings are the same pre-existing
|
||||
`only-export-components` and unused-import kind recorded at WP-C and WP-D, and
|
||||
**none is in a file WP-E changed**. `tokens.css` and `context.css` are not
|
||||
flagged.
|
||||
|
||||
## N. Residual risks
|
||||
|
||||
1. **The screenshots are unapproved.** The measurements say every boundary now
|
||||
clears 3:1; whether the result *looks* right in this design is a judgment
|
||||
the numbers cannot make. Recorded as PENDING in §O, not assumed.
|
||||
2. **Five controls are measured in the browser; the rest inherit.** The audit
|
||||
checks token pairs, and the harness measures the composer, a story control,
|
||||
the open panel tab, the nav edge and the focused composer. Every other
|
||||
bordered surface — modals, cards, the knowledge and context panels — draws
|
||||
the same two tokens, so it moves with them, but none is individually
|
||||
measured. A component that overrides a border with a literal colour would
|
||||
not be caught by either check.
|
||||
3. **`--bg-panel-glass` is outside the token audit by nature.** It is rgba over
|
||||
a gradient, so no token pair can express it; its edge is covered only by the
|
||||
browser measurement, which runs in the harness rather than in CI.
|
||||
4. **Disabled controls are deliberately not measured.** `.story-controls
|
||||
button:disabled` carries `opacity: 0.35`, so a disabled control's rendered
|
||||
edge is dimmer than any value here. WCAG 1.4.11 exempts inactive components,
|
||||
and the harness selects `:not(:disabled)` on purpose — stated so that the
|
||||
exclusion is visible rather than looking like an oversight.
|
||||
5. **The chart set was re-checked only where WP-E disturbed it.** `.slice-7`'s
|
||||
separation and colour-blind distance were measured against the other seven
|
||||
(§J); the set as a whole was not re-audited, which is outside this package.
|
||||
6. **`a11y.test.jsx` was not extended**, though the plan listed it as likely
|
||||
affected. It asserts structure and names, not colours, and adding colour
|
||||
assertions in jsdom would test the stylesheet's text rather than a rendered
|
||||
result. Boundary contrast is asserted instead where it can be measured: the
|
||||
gate tests (§F) and the browser (§G).
|
||||
|
||||
**One risk that turned out not to exist.** §G's rule — a boundary passes on the
|
||||
better of its two adjacent colours — was written to avoid failing an edge that
|
||||
contrasts with the page but matches its own fill. In the event it never did any
|
||||
work: every measured boundary clears 3:1 against **both** neighbours (composer
|
||||
4.55/4.88, story control 3.44/3.70, panel tab 4.24/4.55, hover 4.55/4.88, focus
|
||||
4.38/4.70, nav 3.51/3.70). The stricter reading would have produced the same
|
||||
verdict on every row.
|
||||
|
||||
## O. Final decision
|
||||
|
||||
**Against the plan's acceptance criteria** (§ WP-E, *Control-boundary contrast*):
|
||||
|
||||
| # | Criterion | Verdict |
|
||||
| --- | --- | --- |
|
||||
| 1 | `tools.contrast_audit` exits non-zero on any boundary pair below 3:1, and 0 on the package tree | **PASS** — 2.99:1 exits 1 and 3.00:1 exits 0 (§F); the v1.0.0 value exits 1; the package tree exits 0 |
|
||||
| 2 | Every text pair still clears 4.5:1, and the rendered text contrasts do not fall below v1's 14.57 / 5.48 / 13.57 / 5.88 | **PASS** — asserted as exact baselines in §F and re-measured on the rendered page in §L's M11 rows. No text token changed |
|
||||
| 3 | The rendered boundary of the story input and of a primary control, at rest and on hover, is at least 3:1; the focus indicator is still visible | **PASS** — composer 4.88:1, story control 3.70:1 at rest and 4.88:1 on hover (§H), and M11's visible-focus check passes in §L |
|
||||
| 4 | The owner approves the before-and-after screenshots, and the report records the approval | **PENDING** — not a criterion I can satisfy. The four PNGs are in §I |
|
||||
|
||||
**Where I exceeded the criterion, said plainly.** The plan asks for 3:1 "against
|
||||
their panel" and names two controls. This package measures against
|
||||
**`--bg-input`** as well — the lighter background inputs and buttons are really
|
||||
drawn on, and the one that decides the gate — and adds the open panel tab, the
|
||||
translucent navigation edge and the focused state. The stricter floor is the
|
||||
reason the two threshold tests in §F are decided by `--bg-input` rather than
|
||||
`--bg-panel`, where both would have passed.
|
||||
|
||||
**What I got wrong and corrected.** Three of my own claims failed checking and
|
||||
were fixed rather than softened: the first hover and focus measurements were
|
||||
taken mid-transition and reported colours no state has (§G); two contrast
|
||||
figures were written into `context.css` before being measured, and were wrong
|
||||
(§J); and my first gate check reported the current tokens as failing because my
|
||||
harness crashed on a shallow path, not because of any contrast.
|
||||
|
||||
**Not done, and not claimed:** owner approval (criterion 4), individual
|
||||
measurement of every bordered component (§N.2), and any palette work beyond the
|
||||
two boundary tokens and the one chart fill that borrowed from them.
|
||||
|
||||
```text
|
||||
WP-E CONTRAST GATE: PASS
|
||||
WP-E BOUNDARY MEASUREMENT: PASS
|
||||
|
||||
WP-E OVERALL:
|
||||
PASS, pending owner approval of the screenshots (criterion 4)
|
||||
```
|
||||
|
||||
All WP-E changes are **staged and uncommitted**. No commit, no push, no tag.
|
||||
v1.1 release validation has not begun.
|
||||
|
||||
```text
|
||||
OWNER SCREENSHOT APPROVAL: PENDING
|
||||
```
|
||||
|
||||
The before/after screenshots in §I are the evidence for a change a reader judges
|
||||
by looking at it. The measurements say every boundary now clears 3:1; whether
|
||||
the result looks right in this design is the owner's call, and it is recorded as
|
||||
pending rather than assumed.
|
||||
Reference in New Issue
Block a user