diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index 662f806..dafd6c4 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -1,6 +1,6 @@ --- contract_version: "0.1-PROPOSED" -status: "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 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." +status: "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: @@ -31,7 +31,7 @@ touches: - "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 (FOUR assertions change: the kept-lane pair at L785-786 and L810-811. See 'Assertions that change')" + - "tests/test_booth.py (TWO assertions change: L785-786, the kept-lane presence pair. L810-811, the absence pair, survive unchanged. See 'Assertions that change')" - "tests/test_flow.py (NEW)" 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." @@ -52,11 +52,23 @@ open_questions: | 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 | `Item.ordinal`, printed on every tile | +| 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 | the Desk drops the kept/ephemeral lanes | +| 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 @@ -92,7 +104,19 @@ no route derives it. - **`SEEN_FILE = ".seen"`**: one rel per line, same shape as `.blurred`. - Written by `record_seen(booth, rel)` from the review route, below the 404s and gated on the item record — the same gate `record_view` has. - - Atomic replace, per invariant 5. + - 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, like `read_blurred`. @@ -124,8 +148,11 @@ 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. -- `f` must name an item in the booth, else the landing falls back to the - booth page. +- `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. @@ -133,16 +160,30 @@ today's zoom flag form carries no `back`, so it lands on the gallery. `data-inplace`. 1. POST the form with `Accept: application/json`. -2. On 204, GET the current URL and replace the regions marked - `data-region=""` with the same-id regions from the response. -3. On anything else, submit the form normally. +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 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. + - 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. +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. + - 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 four fields, all read in the one pass it already makes: +`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. @@ -151,26 +192,36 @@ 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. +- **`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). - **`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. Unreadable booths have no `open_since` and sort after every booth that has one. -2. **New since you looked** — not in (1), and `viewed_at is None` or - `landed_at > viewed_at`. Ordered by `(-landed_at, name)`, newest first. +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** — ordered by `(-mtime, name)`, where `mtime` is today's `_newest_mtime`: last activity first. + - 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: @@ -202,7 +253,7 @@ unchanged) remain on every row. - **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. + collapsed as `
`, which needs no script. - **Board booths are unchanged.** Anything with `links.md` keeps today's single column. - **The aside holds, top to bottom:** @@ -211,7 +262,9 @@ unchanged) remain on every row. 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 tile's thumbnail and its #. + 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. - **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 @@ -230,11 +283,19 @@ This applies to image, video and audio items. Docs keep `doc.html`. - **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: - - `#NN of M`, and position within the group; + - 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 booth has groups; - the caption; - the flag form (`back=view`); - notes and the add-note form (`back=view`); @@ -247,7 +308,9 @@ This applies to image, video and audio items. Docs keep `doc.html`. - **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. -- **Keys** (additive; editable targets keep their keys, as today): +- **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 | |---|---| @@ -256,6 +319,14 @@ This applies to image, video and audio items. Docs keep `doc.html`. | N | focus the note | | Esc | back to the grid, at `#item-` so the grid scrolls to where you were | +### C7 — copy and brand (the two rulings that are not layout) + +- **Voice: plain and direct** (ruling `voice=plain`). Every NEW string R2 + introduces says what it means, with no villainy and no jokes. Existing + strings are unchanged unless their surface is rewritten. +- **No SVS emblem** anywhere in the Booth's chrome (ruling `emblem=no`). The + brand dot and the reticle favicon from the SVOS retheme stay. + ## Invariants - **INV-1 — one resolver.** `ordinal` is set in `booth_items`. No route computes @@ -277,11 +348,20 @@ This applies to image, video and audio items. Docs keep `doc.html`. The notes list keeps `(created, id)`. - **INV-3 — JS-off parity.** Every judgment, filter and jump works with - scripts disabled. The only JS-only affordances are the keys and the in-place - swap. -- **INV-4 — 303 byte-identity.** For a request where `wants_json` is False, - each mark route's response (status, headers, body) is byte-identical to its - pre-R2 response. This includes the two existing `back` landings. + scripts disabled. The only JS-only affordances are: + - the keys; + - the in-place swap; + - the image 1:1 toggle (a viewing convenience, unchanged from today); + - the existing copy buttons and blur reveal. + + The narrow-screen collapse is `
` and needs no script. +- **INV-4 — 303 byte-identity, for every request shape that existed before + R2.** + - For a request where `wants_json` is False and `back` is absent or + `marks`, each mark route's response (status, headers, body) is + byte-identical to its pre-R2 response. + - `back=view` is a NEW request shape with no pre-R2 counterpart. Its landing + is specified in C3 and is the one declared exception. - **INV-5 — two named clocks.** - `mtime` / `_newest_mtime`: activity. It includes dotfiles and excludes locks, and it feeds lifetime and 'everything else'. @@ -296,7 +376,7 @@ This applies to image, video and audio items. Docs keep `doc.html`. escaping path as the grid. - **INV-8 — blur honesty.** A blurred item stays blurred on every new surface: the Desk preview strip, the flag tray, the filmstrip and the review stage. - Reveal stays per-viewer and client-side. Copy keeps admitting it is cosmetic. + Reveal stays per-BROWSER and client-side (nothing persisted). Copy keeps admitting it is cosmetic. ## Assertions that change (declared before the code, per CLAUDE.md) @@ -304,7 +384,7 @@ This applies to image, video and audio items. Docs keep `doc.html`. |---|---|---|---| | test_booth.py L785 | `class="grid kept-grid"` present when a booth is kept | absent; the kept booth appears in its Desk section with the `kept` lifetime line | requirement 8: the lanes sort nothing | | test_booth.py L786 | `class="card card-kept"` present | replaced by the row carrying `data-kept="1"` | same | -| test_booth.py L810-811 | lane absent when nothing is kept | unchanged in spirit (no lane), and trivially true | same | +| test_booth.py L810-811 | lane absent when nothing is kept | these two SURVIVE unchanged (they assert absence and stay true) | — | Every other existing assertion is expected to survive, and one of the TDD slices is "the whole suite green before any new test". Named because they were @@ -321,5 +401,6 @@ checked: - Thumbnails. - 1–9 answer keys. - Lifetime policy for answered picks. -- The verbatim path. +- The verbatim path. A verbatim booth's media items remain reachable at + `view?f=` by URL, as today, and nothing in the verbatim page links there. - The link-board page (`/b/links/`) beyond CSS.