--- contract_version: "0.2-BUILT" status: "BUILT 2026-09-23 on design-dev/svos-retheme (C1-C7, TDD), awaiting heid code-review and bug-hunt before the hand-over to booth-dev. PROPOSED 2026-09-23 by design-dev. Ruled by the operator the same day in the `flow` mark on booth-flow-concepts (direction a_b; compare MODE to be built in this arc; voice plain; emblem no), relayed via Miranda → booth-dev, verbatim at docs/rulings/. Compare mode is NOT in this contract: it lands after this one as r3, as a view toggle over the same item record." module: "booth.app + booth.items + templates (the review flow)" purpose: "Make the Booth a place where judgment happens rather than a place where files are shown. The operator's bar is 'did anything change when I opened it'. A reskin cannot clear that bar; this contract changes the flow. There are three surfaces and one plumbing change. THE DESK: the index triaged by what needs the operator. THE LIGHTBOX: a booth page with the set on the left and the verdict beside it. THE REVIEW: full size with the judgment on screen, a filmstrip, and seen-tracking. The plumbing is IN-PLACE JUDGMENT: a mark POST that does not reload the page or eject you from full size." depends_on: - "booth.items.booth_items + Item (INV-1: the one resolver). Item gains `ordinal`, derived there and nowhere else." - "booth.items.image_chain (the zoom ring). SUPERSEDED for the review route by `review_chain`; image_chain stays importable and unchanged for its existing callers and tests." - "booth.app._newest_mtime (THE definition of activity — booth-dev, 2026-09-23). The Desk's 'last activity' reuses it verbatim. The Desk's 'landed since you looked' is a DIFFERENT question and gets a DIFFERENTLY NAMED helper; see INV-5." - "booth.app.record_view / VIEW_MARKER (`.viewed`, U4). The Desk reads its mtime to answer 'new since you looked'." - "booth.app.hold_read / hold_reason / open_marks (INV-2 of U2: the one openness predicate). 'Needs you' is `open_marks(...)` non-empty, or `hold_reason(...) == \"unreadable\"` (C4); nothing else." - "booth.marks.as_dict, set_flag, write_note, answer_pick, delete_mark (the write API, UNCHANGED)." - "booth.app._mark_redirect (the 303 landing). Extended with one new `back` value; the existing two landings stay byte-identical." - "booth.benches.read_benches, booth.links.parse_link_entries / order_for_display / booth_target (the Desk's side column)." language: "python + jinja + a little javascript" complexity: "high" estimated_loc: 900 confidence: 0.6 used_by: - "booth.app.index (the Desk)" - "booth.app.booth_view (the lightbox)" - "booth.app.booth_view_file (the review)" - "booth.app.booth_answer / booth_note / booth_flag / booth_unmark (in-place judgment)" touches: - "booth/items.py (Item.ordinal; review_chain; read_seen/SEEN_FILE)" - "booth/app.py (list_booths fields; index sections; booth_view verdict data; booth_view_file review context + record_seen; wants_json + 204; _mark_redirect `back=view`)" - "booth/templates/index.html (REWRITTEN as the Desk)" - "booth/templates/booth.html (restructured: two panes; the marks panel moves into the verdict aside; tiles carry ordinals; inline group headers)" - "booth/templates/view.html (REWRITTEN as the review: stage, rail, filmstrip, tape)" - "booth/templates/_marks.html (renders inside the aside; flag list ordered by ordinal)" - "booth/templates/base.html (layout CSS; the in-place script)" - "booth/templates/doc.html (NOT restructured — a doc keeps its reading page; named because it was checked)" - "booth/static/embed.js (NOT TOUCHED — the verbatim path keeps its author's layout; requirement 6)" - "tests/test_booth.py (THREE assertions change, all in test_index_separates_kept_from_ephemeral: L785-786, the kept-lane presence pair, and L789, kept-before-ephemeral. L810-811, the absence pair, survive unchanged. See 'Assertions that change')" - "tests/test_flow.py (NEW)" - "tests/test_embed_browser.py (ONE test changes: test_the_keyboard_flag_actually_submits expected a navigation, which is the defect R2 removes. See 'Assertions that change')" assumptions: - "ONE VIEWER. `.seen` records what has been seen at full size, not WHO saw it. ROADMAP parks 'per-viewer state (who has seen what)' on the one-viewer premise; this contract keeps that premise and does not reopen the parked item." - "EVERY JUDGMENT WORKS WITH JAVASCRIPT OFF. Each control stays a plain
. The in-place behaviour is additive and falls back to today's 303." - "THE VERBATIM PATH IS OUT OF SCOPE. A booth with its own index.html is served as the author wrote it (requirement 6). The Desk links to it; the lightbox never renders for it." - "NO THUMBNAILS. Tiles, the filmstrip and the Desk's preview strip use the original files with loading=lazy. Progressive loading stays parked until page weight is measured." open_questions: - "ANSWERED BOOTHS LOSE THEIR HOLD (raised by booth-dev in b46ac02). A booth is held while its question is open, so it becomes sweepable the moment it becomes a decision record. The flow question: should an answered pick hold its booth for a grace period, or should the record live elsewhere? NOT SOLVED HERE, because it is a lifetime-policy change and this contract changes no lifetime rule. Raised separately." - "KEY 1–9 TO ANSWER A PICK from the review rail. It appeared in the concept mock. Dropped from this contract: multi-question picks make the mapping ambiguous, and the operator ruled the flow, not the keymap. Parked." --- # R2 — the review flow: the Desk, the lightbox, the review ## The requirements this answers (from the round-2 README, uncorrected by the operator) | # | requirement | answered by | |---|---|---| | 1 | show me what needs me | the Desk's *needs you* section | | 2 | picking winners is the main judgment | the lightbox's flag tray; F in the review | | 3 | flag without losing my place | in-place judgment + `back=view` | | 4 | position is identity (within the set as it is now — not a durable id) | `Item.ordinal`, printed on every tile | | 5 | the question stays beside the work | the verdict aside (sticky) | | 6 | reports keep their author's layout | verbatim path untouched | | 7 | listening sets are real | `review_chain` includes audio and video | | 8 | lifetime is not an organising principle (it is still SHOWN as a fact on each row; it no longer GROUPS or SORTS) | the Desk drops the kept/ephemeral lanes | ## Terms used below - **Reticle**: the SVOS selection mark in base.html — four corner brackets drawn inside a box. It marks the one current or selected thing and nothing else. - **Tape**: a row of small segments, one per item in the review ring, each showing *seen*, *flagged* or *current*. - **Stage**: the area of the review page where the artifact itself renders. - **All Booth state files are dotfiles.** That covers `.marks.json` (MARKS_FILE), `.viewed`, `.blurred`, `.seen`, `.forever`, `.pins`, `.booth.json` and every `*.lock`. "Non-dot entries" means the posted content and nothing the Booth or the operator wrote. ## Components ### C1 — `Item.ordinal` (items.py) `ordinal: int` is the item's 1-based position in `booth_items(booth)`, i.e. in `sorted(rel)` order over **all** items. It is assigned in the resolver loop, so no route derives it. - `ordinal` is **appended** as the dataclass's last field, never inserted. Mid-dataclass insertion is a positional-construction break, and `group` has already had that conversation. - The resolver's `quote()` guard on non-UTF-8 names stays exactly as it is. It looks like a stray `try` around a discarded result, but it is what keeps one 0xff filename from taking down the index for every booth. - An item skipped by that guard takes no ordinal, so ordinals stay contiguous over the items that render. - **A filter never renumbers.** Under `?filter=flagged` a tile still shows the number it has in the whole set. That is the point: "#07" is a property of the item, not of the view. - **A new file renumbers everything after it.** That is honest, and it matches the order: the operator's positional references are to the set as it is now. ### C2 — `review_chain` and `.seen` (items.py, app.py) - **`review_chain(items)`**: the rels of items whose kind is image, video or audio, in item order. ONE LINE: *the item order filtered to media.* It replaces `image_chain` as the review route's prev/next ring. - 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"`**: 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 rels no longer in `booth_items`, sorted. It is deduplicated and pruned, so it never grows past the booth's item count. - Atomic replace, per the Booth's CLAUDE.md invariant 5 ("sidecar writes are atomic"), not this contract's INV-5. - Seen is keyed by rel. A file replaced at the same path stays seen; a deleted file drops out at the next write, and every count below intersects with the current `review_chain`. - **The review route ALSO calls `record_view` (existing U4 behaviour, unchanged).** So reviewing a booth at full size refreshes "you looked" for the Desk exactly as opening its grid does. `.viewed` and `.seen` never disagree about whether you looked at the booth; `.seen` only adds WHICH items. - NEVER RAISES, like `record_view`: failing to record a look costs the marker, not the page. - `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 holds nothing. - It adds no lifetime RULE. Being a dotfile, its write does move `_newest_mtime`. So does the `.viewed` write on the same request, so a review page ages a booth exactly as it does today. ### C3 — in-place judgment (app.py, base.html) **`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. 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 write as today, then: - `wants_json` → **204 No Content**. - otherwise → today's `_mark_redirect(...)`, **byte-identical**: same status, same `Location`, same body. **`back=view`** is a new landing for `_mark_redirect`, carried by the review route's forms together with `f=`. It lands on `/b//view?f=#rail`. This fixes the JS-off bounce too: today's zoom flag form carries no `back`, so it lands on the gallery. - `back=view` lands on the review only when `f` names an item in `review_chain`, i.e. a media item. For anything else (a doc, a missing rel, an empty `f`) the landing falls back to the booth page, exactly as a form with no `back` does today. `doc.html` carries no forms, so no shipped page sends `back=view` with a doc. - The URL is built server-side from `name` + `quote(f)`, never echoed, so this is not an open redirect. **The client**: one small script in base.html, bound to forms marked `data-inplace`. 1. POST the form with `Accept: application/json`. 2. On 204, GET the current URL and replace **every** element carrying `data-region=""` 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: 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 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 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; - every DIRTY control: a half-typed or edited note, a radio picked and not yet sent. 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. **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 (`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. **The server renders every state; the script only places it.** This is U3's rule — a second renderer in JavaScript would be the same bug in a new language. ### C4 — the Desk (index.html, app.index, list_booths) `list_booths` gains five fields, all read in the one pass it already makes: - **`open_since`**: the `created` of the OLDEST open pick in the booth, or None. Computed via `open_marks`, INV-2. - `Mark.created` is a STRING. It is parsed with `datetime.fromisoformat`, never compared lexically: two ISO stamps with different offsets, or a legacy-import stamp, sort wrong as text. - An unparseable stamp sorts AFTER every parseable one, and name breaks the tie. - **`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 orphan list, the rail's `flagged` filter, the tiles, the filmstrip, the tape and the review button 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.** - **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. - A booth with no images (an audio set, a report) shows today's kind placeholder instead (`♪ audio`, `▦ page`, `▶ video`, `◆ files`). - These are the original files displayed small with `loading=lazy`. No thumbnail is GENERATED anywhere in R2; see Out of scope. **The index renders three sections, always in this order:** 1. **Needs you** — `marks_open > 0`, **or** `hold == "unreadable"`. - `marks_open` counts `open_marks(...)`, which only ever returns PICKS. A booth whose marks are only flags or notes is the operator's own judgment, not a question to them, so it is NOT here. - A damaged `.marks.json` holds its booth but is not open by `open_marks` (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. 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)` descending, where `mtime` is today's `_newest_mtime`. That is last activity first, with name as the tie-break (`test_the_index_order_has_a_tie_breaker` pins it). The Desk reuses that rule rather than stating a second one. - Flagging or viewing a booth moves it up this section. That is intended: it is activity. It never moves the booth into (2), because (2) reads `landed_at` (INV-5). The side column holds: - **Benches**: `read_benches(data_dir)`, non-retired, in the registry's 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 share it. - Shown: rows that are not booth URLs (`booth_target(url) is None`). - Order: pinned first, then newest (`order_for_display`). - Capped at 8, with a link to the full board. - **Pickup**: the existing upload form, unchanged, moved from the page head. **An empty section does not render** — no heading, no box. This is the load-bearing negative half of the kept-lane pair it replaces (`'class="grid kept-grid"' not in html`), carried forward into test_flow.py as a pair: present when it has rows, absent when it has none. It applies to each of the three sections and to the Benches and Bookmarks panels. The kept/ephemeral lanes are **removed**: 23 of 24 live booths are kept, so the lanes sort nothing. Kept status and the lifetime line (`_lifetime.html`, unchanged) remain on every row. **A row's controls sit on its facts line**, each beside the state it changes: `release` after "kept", `★ keep` after a countdown or a hold, `× wipe` last. They are always visible, with no hover-only reveal: that was a column that reserved its room while invisible (operator, 2026-09-23, on the live Desk: "release and x take up space whether or not they're visible"), and touch has no hover. The side column renders only when the row has a badge, so a row without one reserves nothing. The forms, POST targets and `data-confirm` wording are unchanged. - **On a coarse pointer every row control is at least 28px square**, the floor it had as a column, and wipe stands clear of the zip link. With scripts off no confirm fires, so a mis-tap on wipe is the delete. A fine pointer keeps the compact line. - **The confirm dialog shows the name as it should be READ.** Control and bidi formatting characters in an agent-made name show as U+FFFD, so a U+202E or a newline cannot rewrite what the operator approves. A `data-confirm` word the page does not know still asks, generically: the prompt fails closed. - **No page scrolls sideways at any width**, including an install path with no break opportunity in the footer or the empty Desk (`code` wraps anywhere). Tested at 390, 720, 850, 1000 and 1400px with the heaviest row the Desk draws. ### C5 — the lightbox (booth.html, booth_view) - **Layout.** Two panes on a gallery booth: the set on the left, the **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 `
`, which needs no script. - The markup is a CLOSED `
`. - 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:** 1. open picks (the existing `_marks.html` pick rendering); 2. the flag tray; 3. notes; 4. the booth-note form. - **The flag tray is ordered by ORDINAL** — a declared change from the marks panel's `(created, id)`. It shows each flagged item's original file 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 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 `
` 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). Each tile is `data-region="item-"`, 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 ``. - The toggle is a JS-only VIEWING convenience, as it is today: the button starts hidden and the script shows it. With scripts off the image shows at fit size, and no judgment depends on the toggle (INV-3). - Video and audio get their native controls and no toggle. A toggle that renders on audio and silently no-ops (today's script binds `getElementById('vimg')`) is the failure this names. - **The rail** (`data-region="rail"`) holds: - the item's ordinal `#NN` (its number in the whole set, the same number its tile shows); - `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 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`); - any open pick TARGETING this item, answerable here (`back=view`); - the booth's other open picks as a count and a link. - **The filmstrip** is `review_chain` in order, with ordinals, flagged frames underlined and the current frame in the reticle. - **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: 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. Shift+Space moves back. Space is left to a focused `