fix(r2): the heid code-review panel (round "Wren", 4/4) — triaged and folded

Code fixes:
- The narrow-screen fold was specified and never built (4/4). The tray and
  notes are now closed <details> in the aside; above 1000px CSS alone
  (::details-content) shows them and hides the summary. There is no
  script. Browser-tested at 390 and 1400, JS on and off.
- The lightbox gated on parsed board rows, not page identity (3/4). It now
  uses is_board, the lesson the bench panel already carried.
- wants_json returned True at the first good entry, so a malformed later
  entry was never read (3/4). It now parses every entry first; any error
  is False.
- One flag predicate, flagged_targets. It serves the Desk count, the tray,
  the filmstrip, the tape and the review button. An unreadable flag entry
  counts nowhere.
- The header's open count and lifetime line, and the no-set marks panel,
  are now regions (they were stale after an in-place answer).
- Inline group headers render only when every group is one contiguous run.
  Interleaved directories no longer reprint or misfile headers.
- A booth held unreadable has no open_since, even with a readable pick
  beside the damage.
- The swap marks an absent region is-stale instead of leaving it looking
  current. It carries disclosure state (except the sent form's). The
  failure message is readable for 0.9 s before the reload.

Contract amended where the code was right and the text was not: the
wants_json and record_seen signatures, landed_at's three refinements, the
group position being ring-based, the end of the set offering every other
open pick, the Space-key player exception, and the fold mechanism.

New tests cover the parse order; a board with media; the header region; the
no-set panel; interleaved groups; mixed damage; the flag predicate; the
review recording .viewed; the fold at two widths with JS on and off; the
status message before the reload; a lost response after a landed write
(exactly one note); a stale absent region; stage node identity across a
swap; and F with a radio focused. The lost-response and stale tests turn
red under their mutations. 724 passed.
This commit is contained in:
vh
2026-09-23 09:41:18 -07:00
parent 881c7f5df3
commit fa5d46443d
7 changed files with 404 additions and 41 deletions
+70 -19
View File
@@ -103,7 +103,7 @@ no route derives it.
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`.
- Written by `record_seen(booth, rel)` from the review route, below the 404s
- 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
rels no longer in `booth_items`, sorted. It is deduplicated and pruned, so
@@ -121,6 +121,8 @@ no route derives it.
- 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`.
- `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
holds nothing.
- It adds no lifetime RULE. Being a dotfile, its write does move
@@ -129,14 +131,17 @@ no route derives it.
### C3 — in-place judgment (app.py, base.html)
**`wants_json(request) -> bool`** is True **only** when the `Accept` header,
split on commas, contains an entry whose media type, parameters stripped, is
**`wants_json(accept: str | None) -> bool`** takes the raw `Accept` header, so
it is a pure function a test can call directly. It is True **only** when the
header, split on commas, contains an entry whose media type, parameters stripped, is
exactly `application/json` and whose q-value is absent or greater than 0.
- Absent, empty, `*/*` or `application/*` → False.
- `application/json;q=0` → False. A client that explicitly refuses JSON gets
the redirect.
- 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.
- **It fails toward the 303.**
@@ -167,18 +172,35 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
2. On 204, GET the current URL and replace **every** element carrying
`data-region="<id>"` with the same-id element from the response.
- The rule is "every region whose content can depend on marks is a
region". On the lightbox that means the verdict aside, each tile, and the
rail (its filter counts change when you flag). On the review it means the
rail, the filmstrip and the tape.
region".
- On the lightbox: the verdict aside, each tile, the rail (its filter
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 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.
- 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.
The form just sent is the exception: its field comes back empty, and its
disclosure comes back folded.
3. **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
(`data-region="status"`, via textContent), then reloads the page with a
GET, so what you see is the server's truth.
(`data-region="status"`, via textContent). After a beat (0.9 s, so the
words can be read) it reloads the page with a GET, so what you see is the
server's truth.
- The one case where a non-JS submit happens is a script that cannot run at
all. That is the plain form.
@@ -196,9 +218,20 @@ 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 count of flag marks, shown on every Desk row that has any.
- **`landed_at`**: the newest mtime among the booth's NON-DOT entries — its
content. **Deliberately not `_newest_mtime`** (INV-5).
- **`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
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:
- **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.
- **An empty booth landed at 0.0.**
- **An unreadable booth 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.
@@ -217,8 +250,10 @@ rule — a second renderer in JavaScript would be the same bug in a new language
(errored picks are not open). Somebody has to fix it, so it must not hide
in 'everything else'. It renders with the existing "marks unreadable"
lifetime line.
- Ordered by `(open_since, name)`, oldest question first. Unreadable booths
have no `open_since` and sort after every booth that has one.
- Ordered by `(open_since, name)`, oldest question first. A booth held
`unreadable` has no `open_since` — even when a readable pick sits beside
the damage, because the damage is the thing to fix — and sorts after every
booth that has one.
2. **New since you looked** — `not in_needs_you and (viewed_at is None or
landed_at > viewed_at)`. Ordered by `(-landed_at, name)`, newest first.
3. **Everything else** — in `list_booths`' own existing order: `(mtime, name)`
@@ -260,6 +295,11 @@ unchanged) remain on every row.
**verdict aside** on the right (`position:sticky`, `data-region="verdict"`).
Under 1000px the aside stacks above the set, with its flags and notes
collapsed as `<details>`, which needs no script.
- The markup is a CLOSED `<details>`.
- Above 1000px, CSS alone shows its content (`::details-content`) and hides
its summary, so nothing is folded where there is room.
- A browser without `::details-content` shows the fold at every width: one
tap, never hidden.
- **Board booths are unchanged.** Anything with `links.md` keeps today's
single column.
- **The aside holds, top to bottom:**
@@ -274,8 +314,14 @@ unchanged) remain on every row.
The order is total with no tie-break, because rels are unique.
- **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, the grid additionally
renders an inline group header before each group's first tile. It is a
group anchors. When `rail.groups` is non-empty AND every group is one
contiguous run in the rendered order, the grid additionally renders an
inline group header before each group's first tile.
- Groups come from basenames and the order from full paths, so groups can
interleave (`d1/aa`, `d1/bb`, `d2/aa`).
- A header would then either repeat or file an item under the wrong group,
so interleaved groups get no inline headers. The rail's jump links are
unaffected. It is a
`<div>` spanning the grid, never a `figure.item`, so the keyboard and the
order check are blind to it by construction.
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
@@ -301,7 +347,9 @@ This applies to image, video and audio items. Docs keep `doc.html`.
- `K of M`, where K is its position in `review_chain` and M is the length
of `review_chain`. The tape's "N of M seen" uses the SAME M, and N counts
`.seen` ∩ `review_chain`;
- its position within its group, when the booth has groups;
- its position within its group, when the review RING spans two or more
groups (the gallery rail's own rule: one group for everything says
nothing);
- the caption;
- the flag form (`back=view`);
- notes and the add-note form (`back=view`);
@@ -312,15 +360,18 @@ This applies to image, video and audio items. Docs keep `doc.html`.
- **The tape** (B's device) is one segment per `review_chain` item: seen /
flagged / current, plus "N of M seen".
- **The end of the set** is not a separate page. On the last ring item the
rail adds a summary block: seen count, flag tray, and every open booth-level
pick answerable in place.
rail adds a summary block: the seen count, the flag tray, and EVERY OTHER
open pick, answerable in place.
- That includes picks targeting other items, not only booth-level ones: the
end of the set is where the remaining questions get cleared.
- Before the last item, the other picks are a count and a link.
- **Keys** (additive). **Every** key here, new and old, is ignored while focus
is in an `input`, `textarea`, `select` or `contenteditable`, the same
`isEditable` guard view.html carries today, so F never fires mid-note:
| key | action |
|---|---|
| ← → and Space | move |
| ← → and Space | move. Shift+Space moves back. Space is left to a focused `<video>`/`<audio>` player, whose own play key it is |
| F | flag |
| N | focus the note |
| Esc | back to the grid, at `#item-<url>` so the grid scrolls to where you were |