fix(r2): the heid bug-hunt panel (round "Nyx", 4/4) — triaged and folded

In-place client (base.html):
- Saves are serialized: POST, re-fetch and swap complete before the next
  save starts, so an older snapshot can no longer land after a newer one.
- A form already queued or in flight ignores another submit; a
  double-click writes one note.
- Dirty controls (drafts, unsent radio choices) and disclosures carry by
  identity (form action + hidden ask/target/mark/f + name), not position.
- Any non-tile structural difference, or a page with no region to swap,
  reloads instead of patching.

Server and templates:
- .seen is a JSON array read without following links or blocking,
  regular files of at most 1 MiB only; malformed, nested-too-deep or
  planted markers read as nothing seen.
- landed_at reads symlinks by lstat and skips one unreadable entry
  instead of pinning the booth in "new".
- The Desk counts flags on current items only; orphan flags are listed
  under the tray with an unmark form.
- Agent-written bench and bookmark URLs link only when http(s).
- Audio and video tiles carry a review link.
- A rel the filesystem cannot represent is a 404, not a 500.
- A non-finite Accept q-value fails to parse.
- The standalone marks page has regions and updates in place.
- The review's next arrow sits at the edge at phone width.

Contract amended for each, plus an accepted-risks section (unlocked
.seen read-modify-write, a planted .viewed symlink, Item.ordinal with
no default).

741 passed. Each new browser test was mutation-checked against its fix;
the serialization test forces the race with a held first refresh, since
localhost alone never lost it.
This commit is contained in:
vh
2026-09-23 10:22:51 -07:00
parent fa5d46443d
commit 77833dc6d4
11 changed files with 622 additions and 82 deletions
+81 -18
View File
@@ -102,7 +102,9 @@ no route derives it.
- It is a **declared change** to the zoom-ring rule. Today's ring is images
only. A booth mixing images and audio now rings through both, in set order.
- `image_chain` stays for its callers and tests.
- **`SEEN_FILE = ".seen"`**: one rel per line, same shape as `.blurred`.
- **`SEEN_FILE = ".seen"`**: a UTF-8 JSON array of rels. Not one rel per
line, `.blurred`'s shape: a file name may contain a newline, and a line format
would split one such rel into two, neither of them real.
- Written by `record_seen(booth, rel, items)` from the review route, below the 404s
and gated on the item record — the same gate `record_view` has.
- Each write rewrites the whole file: the previous set plus `rel`, minus
@@ -120,7 +122,12 @@ no route derives it.
items.
- NEVER RAISES, like `record_view`: failing to record a look costs the
marker, not the page.
- `read_seen(booth) -> set[str]` is lenient, like `read_blurred`.
- `read_seen(booth) -> set[str]` is lenient and NEVER RAISES. It opens without
following a symlink and without blocking, reads only a regular file of at
most 1 MiB, and keeps only the array's string members. Anything else — a
link, a FIFO, a directory, an oversized, malformed or too-deeply-nested
file — reads as the empty set. `.seen` sits in an agent-writable directory, and a planted FIFO
must not hang the review route.
- `items` is the route's own `booth_items` result. It is passed in so that the
prune ("minus rels no longer in `booth_items`") costs no second walk.
- **Seen is UI state, not judgment.** It is not exposed in `marks.json` and it
@@ -142,7 +149,8 @@ exactly `application/json` and whose q-value is absent or greater than 0.
- A near miss such as `application/jsonx` → False.
- **Every entry is parsed before anything is decided.** One unparseable
entry anywhere, before or after a good one, makes the whole header False.
- Any header that fails to parse → False.
- Any header that fails to parse → False. A q-value that is not a finite
number (`q=nan`, `q=inf`) fails to parse.
- **It fails toward the 303.**
The four mark routes (`/answer`, `/note`, `/flag`, `/unmark`) perform the same
@@ -177,24 +185,42 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
counts change when you flag), and the header's open count and lifetime
line (`booth-status`).
- On a booth with marks but no set: the panel (`marks-panel`).
- On the standalone marks page: the header's open count (`booth-status`)
and the panel (`marks-panel`), one region around both its states so
answering the last mark away swaps in the empty state.
- On the review: the rail, the filmstrip and the tape.
- The stage is never a region: replacing it would restart a playing video
or audio track.
- A region absent from the response is left alone and never deleted.
Deleting it would shift every tile after it under the reader's eye. It
is marked `is-stale` so it does not pass for current: un-flagging under
`?filter=flagged` is the case. The next navigation drops it.
- A TILE (`item-*`) absent from the response is left alone and never
deleted. Deleting it would shift every tile after it under the reader's
eye. It is marked `is-stale` so it does not pass for current:
un-flagging under `?filter=flagged` is the case. The next navigation
drops it.
- Any OTHER difference in structure — a non-tile region in the response
that the page lacks, or one the page has that the response lacks — or a
page with no region to swap at all, is not patched: the script reloads
with a GET, so what you see is the server's truth.
- The swap also carries the per-viewer state a reload would have reset
but an in-place save must not:
- live media whose src is unchanged;
- a revealed blur;
- a closed doc;
- disclosures the reader opened or closed;
- unsaved drafts.
- every DIRTY control: a half-typed or edited note, a radio picked and
not yet sent.
The form just sent is the exception: its field comes back empty, and its
All of it is matched by IDENTITY, never by position: a form by its
action and its hidden `ask`/`target`/`mark`/`f` fields, a control by its
form plus its name (plus its value for a radio or checkbox), a disclosure
by the pick or form it holds. A flag that adds a tray row above a draft
must not move the draft into the wrong box. The form just sent is the
exception: its fields come back as the server rendered them, and its
disclosure comes back folded.
3. **The script never re-POSTs.** A retry after a lost response would re-apply
3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap
before the next begins, so an older snapshot never lands after a newer one
(three quick flags show three flags). A form already queued or in flight
ignores another submit: a double-click writes one note, not two.
4. **The script never re-POSTs.** A retry after a lost response would re-apply
the judgment: a duplicate note, or a re-dated answer.
- On a non-204 HTTP response, or a network failure, it writes a fixed
message into the page's server-rendered status element
@@ -218,20 +244,29 @@ rule — a second renderer in JavaScript would be the same bug in a new language
legacy-import stamp, sort wrong as text.
- An unparseable stamp sorts AFTER every parseable one, and name breaks the
tie.
- **`flags`**: the number of items carrying a READABLE flag mark, shown on
every Desk row that has any. `flagged_targets(marks)` is the ONE flag
- **`flags`**: the number of CURRENT items carrying a READABLE flag mark,
shown on every Desk row that has any — `flagged_targets(marks)` intersected
with the booth's item rels. `flagged_targets(marks)` is the ONE flag
predicate. The Desk, the tray, the filmstrip, the tape and the review button
all read it, and an unreadable flag entry counts nowhere.
- **`landed_at`**: the newest mtime among the booth's CONTENT — its REGULAR
FILES with no dot-component in their path. **Deliberately not
`_newest_mtime`** (INV-5). Three refinements, each load-bearing:
all read it, and an unreadable flag entry counts nowhere. A flag whose file
has since been deleted is an ORPHAN: it counts on no Desk row, and the tray
lists it (C5) so it can be cleared.
- **`landed_at`**: the newest mtime among the booth's CONTENT — its regular
files and symlinks with no dot-component in their path, each read by
`lstat`. **Deliberately not `_newest_mtime`** (INV-5). Five refinements,
each load-bearing:
- **Files only, never directories.** Creating any dotfile (`.viewed`, the
marks file's temp-and-replace) bumps the booth directory's own mtime, so
counting directories would make the flag you set after looking read as a
delivery.
- **A symlink counts by its OWN mtime** — when it was placed — never its
target's. A link to a busy file outside the booth must not make the booth
read as newly delivered.
- **An empty booth landed at 0.0.**
- **An unreadable booth reads as NOW.** It is shown as new rather than
hidden as old.
- **One unreadable entry is skipped.** Reading the whole booth as landed NOW
for one bad entry would pin it in 'new' forever.
- **A booth whose walk cannot run at all reads as NOW.** It is shown as new
rather than hidden as old.
- **`viewed_at`**: the mtime of `.viewed`, or None.
- **`preview`**: up to 4 image items as `(url, blurred)`, first four in item
order. A blurred one renders blurred, the same rule as the cover.
@@ -270,6 +305,9 @@ The side column holds:
existing order. Its error return renders as an error line, never as an empty
list. This is the booth page's rule: damaged and absent must not render the
same.
- **Agent-written URLs become links only when they are `http(s)`.** A bench
URL or a bookmark with any other scheme renders as plain text. Autoescape
stops markup, not a `javascript:` href.
- **Bookmarks** come from the board the CLI writes: the booth named by
`BOOTH_LINKS_BOARD`, default `links`. They are read through the same
never-raising path as `_board_rows`, which gets factored so both callers
@@ -312,6 +350,9 @@ unchanged) remain on every row.
displayed small (no generated thumbnail), blurred if the item is blurred,
with its #.
The order is total with no tie-break, because rels are unique.
- **Orphan flags** — flags whose target is no longer an item — follow the
tray, by target, each with its unmark form. A flag the page cannot show
must still be clearable, or it counts in the rail forever.
- **The rail stays.** Same element, same `.rail` class (booth.html's cursor
and base.html's `--rail-h` script both read it), same filter hrefs, same
group anchors. When `rail.groups` is non-empty AND every group is one
@@ -327,11 +368,17 @@ unchanged) remain on every row.
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
Each tile is `data-region="item-<url>"`, so the in-place script can replace
exactly the tile it flagged.
- **An audio or video tile carries a `review` link** to its review page. On
those tiles a click drives the player, so without the link the review is
reachable only by key.
### C6 — the review (view.html, booth_view_file)
This applies to image, video and audio items. Docs keep `doc.html`.
A requested rel the filesystem cannot represent (a NUL byte, an over-long
path) is a 404, as any other unknown rel is — never a 500.
- **The stage**: the artifact at fit size, with a 1:1 toggle for images ONLY.
- The toggle and its script are rendered and bound only when the stage is
an `<img>`.
@@ -454,6 +501,22 @@ checked:
- `Wipe now` stays in the booth header;
- `class="boothhead"` stays.
## Accepted risks (named, not fixed)
- **`.seen` is read-modify-write without a lock.** Two reviews of the same
booth racing can drop one rel from `.seen`. The cost is cosmetic — a frame
shown unseen on the tape — and the next look repairs it; a lock would buy a
cosmetic count at the price of a lock file the lifetime clock must ignore.
- **A `.viewed` symlink planted by an agent freezes 'new'.** `viewed_at`
reads it by `lstat`, and `record_view` refuses to write through it
(`O_NOFOLLOW`), so the marker never moves again: once content lands after
it, the booth reads as 'new' however often it is opened. It fails in the
visible direction — shown, never hidden — and needs write access to the
booth, which already buys worse. The remedy is deleting the link.
- **`Item` gains `ordinal` with no default.** `booth_items` is the single
construction site, keyword-only; a default would let a second site forget
it silently (INV-1).
## Out of scope
- Compare (r3).