From 744fa5263e1d6769a0bddaebc50602663daea46a Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 22 Sep 2026 22:12:52 -0700 Subject: [PATCH 01/15] =?UTF-8?q?feat(theme):=20SVOS=20retheme=20=E2=80=94?= =?UTF-8?q?=20concept-round=20candidate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-skins every Booth surface in the SVOS design system (design-systems palettes/svos @ ed2f8d8). Visual and interaction layer only: no route, no copy, no ordering and no information-architecture change. - _svos_tokens.css: SVOS semantic tokens vendored by copy, with the four [data-theme] scopes re-scoped onto prefers-color-scheme and prefers-contrast (dark, light, dark-hc, light-hc). Included into base.html's diff --git a/booth/templates/doc.html b/booth/templates/doc.html index d2ded30..2f89bb9 100644 --- a/booth/templates/doc.html +++ b/booth/templates/doc.html @@ -23,14 +23,14 @@ {% endif %} From bf553649203df87e0d521d0129e589f3572bb1e9 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 06:56:35 -0700 Subject: [PATCH 03/15] fix(theme): at phone width the JS-off rail fallback is the measured worst case booth-dev suggested this. At or below 480px, .item's scroll-margin fallback is 205px, the 16-group rail measured at 390px. With JS on, --rail-h is exact and nothing changes. Measured on the same 76 jumps: - JS off: 0 under the rail, previously 19. At 390px, where a short rail gets the full fallback, tiles overshoot by at most 74px, and they stay visible. - JS on: unchanged, 0 under. 660 passed; visual order still matches document order on 32 renders. --- booth/templates/base.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/booth/templates/base.html b/booth/templates/base.html index 325d4cd..d334641 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -622,6 +622,14 @@ .bench-add input[type=url]{flex:2;min-width:0} .bench-add input[type=text]{flex:1;min-width:0} + /* JS-off fallback at phone width (booth-dev's suggestion, 2026-09-23): the + rail wraps tallest here, so the fallback is the MEASURED worst case — + 205px, the 16-group live maximum at 390px. Overshooting a short rail + leaves the tile visible; undershooting hides it. JS on, --rail-h is exact + and this changes nothing. */ + @media (max-width:480px){ + .item{scroll-margin-top:calc(var(--rail-h, 205px) + 12px)} + } @media (max-width:600px){ main{padding:20px 16px 40px} .topbar{padding:12px 16px} From 051599a30e7c211f958bc47543cbd349ab327e27 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 08:15:32 -0700 Subject: [PATCH 04/15] =?UTF-8?q?docs(contract):=20r2=20=E2=80=94=20the=20?= =?UTF-8?q?review=20flow:=20the=20Desk,=20the=20lightbox,=20the=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROPOSED. Ruled by the operator 2026-09-23 (flow: a_b, compare this_arc, voice plain, emblem no). Compare is not in this contract; it follows as r3. Seam-reviewed against the live module surfaces before the cross-frontier contract panel returned. Four findings are folded in: Mark.created is a string, the board is BOOTH_LINKS_BOARD, the bench-read error state, and an unreadable marks file counting as needing the operator. --- docs/contracts/r2_flow.contract.md | 305 +++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 docs/contracts/r2_flow.contract.md diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md new file mode 100644 index 0000000..add534d --- /dev/null +++ b/docs/contracts/r2_flow.contract.md @@ -0,0 +1,305 @@ +--- +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." +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 (FOUR assertions change: the kept-lane pair at L785-786 and L810-811. 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." + - "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 | `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 | + +## 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. + +- **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"`**: 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. + - 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`. +- **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(request) -> bool`** is True **only** when the `Accept` header, +split on commas with parameters stripped, contains the exact media type +`application/json`. + +- Absent, empty, `*/*` or `application/*` → False. +- Any header that fails to parse → False. +- **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. + +- `f` must name an item in the booth, else the landing falls back to the + booth page. +- 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 the regions marked + `data-region=""` with the same-id regions from the response. +3. On anything else, submit the form normally. + +**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: + +- **`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 count of flag marks. +- **`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. + +**The index renders three sections, always in this order:** + +1. **Needs you** — `marks_open > 0`, **or** `hold == "unreadable"`. + - 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. +3. **Everything else** — ordered by `(-mtime, name)`, where `mtime` is today's + `_newest_mtime`: last activity first. + +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. +- **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. + +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. + +### 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. +- **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 tile's thumbnail and 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 + 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 + `
` 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. + +### C6 — the review (view.html, booth_view_file) + +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. +- **The rail** (`data-region="rail"`) holds: + - `#NN of M`, and position within the group; + - 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: seen count, flag tray, and every open booth-level + pick answerable in place. +- **Keys** (additive; editable targets keep their keys, as today): + + | key | action | + |---|---| + | ← → and Space | move | + | F | flag | + | N | focus the note | + | Esc | back to the grid, at `#item-` so the grid scrolls to where you were | + +## Invariants + +- **INV-1 — one resolver.** `ordinal` is set in `booth_items`. No route computes + a position. +- **INV-2 — order, stated.** Each ordered surface has a one-line rule: + + | surface | rule | + |---|---| + | items | `sorted(rel)` | + | ordinals | position in that | + | review ring | that, filtered to media | + | filmstrip, tape | the review ring | + | flag tray | by ordinal | + | Desk sections | fixed: needs → new → everything | + | needs you | `(open_since, name)` | + | new since you looked | `(-landed_at, name)` | + | everything else | `(-mtime, name)` | + | bookmarks | `order_for_display` | + + 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. +- **INV-5 — two named clocks.** + - `mtime` / `_newest_mtime`: activity. It includes dotfiles and excludes + locks, and it feeds lifetime and 'everything else'. + - `landed_at`: content only (non-dot entries), and it feeds 'new since you + looked'. + - Never the one where the other is meant: a mark or a view is not new + content, and new content is not the only activity. +- **INV-6 — no second renderer.** The in-place script inserts server-rendered + HTML and builds none. +- **INV-7 — autoescape.** No `|safe` on any booth name, item name, caption, + why or mark text. The flag tray and filmstrip render names through the same + 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. + +## Assertions that change (declared before the code, per CLAUDE.md) + +| test | today | after R2 | why | +|---|---|---|---| +| 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 | + +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 +checked: + +- the `vnav vprev` / `vnav vnext` anchors (test_booth L569-591 and + test_navigation L337) keep their classes and hrefs; +- `Wipe now` stays in the booth header; +- `class="boothhead"` stays. + +## Out of scope + +- Compare (r3). +- Thumbnails. +- 1–9 answer keys. +- Lifetime policy for answered picks. +- The verbatim path. +- The link-board page (`/b/links/`) beyond CSS. From ea44c18d42db2bc7f41cfcc0dcf62a76e8de00ff Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 08:18:01 -0700 Subject: [PATCH 05/15] =?UTF-8?q?docs(contract):=20r2=20=E2=80=94=20fold?= =?UTF-8?q?=20booth-dev's=20items.py=20notes=20and=20the=20empty-section?= =?UTF-8?q?=20negative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordinal is appended, not inserted. The quote() guard stays, and skipped items take no ordinal. Empty Desk sections do not render; this carries forward the negative half of the kept-lane pair. The 1:1 toggle is bound only when the stage is an image. --- docs/contracts/r2_flow.contract.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index add534d..662f806 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -66,6 +66,15 @@ open_questions: `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. @@ -178,6 +187,12 @@ The side column holds: - 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. @@ -212,7 +227,12 @@ unchanged) remain on every row. 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. +- **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 ``. + - 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 caption; From 7a4d3fcbf8a4ae1d20bdc5db253fc24e7d3c4a92 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 08:27:13 -0700 Subject: [PATCH 06/15] =?UTF-8?q?docs(contract):=20r2=20=E2=80=94=20fold?= =?UTF-8?q?=20the=20heid=20contract=20panel=20(round=20"Lark",=204/4=20arm?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaged, not adopted wholesale. Folded: - Reviewing refreshes .viewed, as it already did. It is now stated, so the two clocks cannot read as disagreeing. - INV-4 is scoped to pre-R2 request shapes. back=view is the declared exception. - back=view lands on the review only for media items. Anything else falls back to the booth page. - In-place regions: every element whose content can depend on marks is a region, including the rail counts, the filmstrip and the tape. The stage never is. - The script never re-POSTs. A lost response must not duplicate a note or re-date an answer. - The dangling "invariant 5" now points at the Booth's CLAUDE.md invariant 5. - "M" is defined once. Needs-you is picks only. Every key is suppressed in editable fields. - The toggle and the narrow collapse are classified against INV-3. - Every Booth state file is a dotfile, stated. So are "no generated thumbnails" and the audio placeholder. - The requirement wording is tightened, and C7 records the voice and emblem rulings. --- docs/contracts/r2_flow.contract.md | 133 +++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 26 deletions(-) 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. From 277554a3f78bea6b583796334d2a6f6425633160 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 08:32:38 -0700 Subject: [PATCH 07/15] feat(r2): C1 ordinals and C2 the review ring and .seen - Item.ordinal: the 1-based position in booth_items over the items that render. It is appended, and set in the resolver. Tiles print it padded to the whole set's width, and a filter never renumbers. - review_chain: the item order filtered to media. It replaces image_chain as the zoom route's ring, so a set of pictures and sound steps through both. image_chain stays importable. - .seen: which media items were looked at full size, written by the review route under record_view's gate. It is rewritten whole: deduplicated, pruned to live items, sorted. The temp file is created with O_EXCL and swapped in with os.replace, so a planted symlink is replaced, never written through. It never raises. Nine new tests. The contiguity and symlink tests are mutation-checked. 669 passed. --- booth/app.py | 48 +++++++++++- booth/items.py | 37 +++++++++ booth/templates/booth.html | 9 +++ tests/test_flow.py | 153 +++++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 tests/test_flow.py diff --git a/booth/app.py b/booth/app.py index d054e9f..1e6d841 100644 --- a/booth/app.py +++ b/booth/app.py @@ -41,6 +41,7 @@ import os import re import secrets import shutil +import tempfile import time import zipfile from contextlib import asynccontextmanager @@ -87,6 +88,10 @@ from booth.items import ( # noqa: E402,F401 doc_kind, find_item, image_chain, + review_chain, + REVIEW_KINDS, + SEEN_FILE, + read_seen, read_blurred, render_doc, render_doc_body, @@ -319,6 +324,37 @@ def record_view(booth: Path) -> None: pass +def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None: + """Note that `rel` was looked at full size (R2 C2). + + Rewrites the whole marker — the previous set plus `rel`, pruned to rels that + are still items, sorted — so it is deduplicated and never outgrows the + booth. Atomic replace (CLAUDE.md invariant 5) through a temp file created + with O_EXCL: a planted `.seen.tmp` symlink cannot redirect the write, and + `os.replace` swaps a planted `.seen` symlink out rather than writing + through it. + + NEVER RAISES, for `record_view`'s reason: not recording a look is a cost + this service can absorb, not answering the request is not. + """ + try: + live = {it.rel for it in items} + seen = (read_seen(booth) | {rel}) & live + fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth) + try: + with os.fdopen(fd, "w") as fh: + fh.write("".join(f"{r}\n" for r in sorted(seen))) + os.replace(tmp, booth / SEEN_FILE) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + except OSError: + pass + + HOLD_UNREADABLE = "unreadable" HOLD_OPEN = "open" @@ -522,6 +558,8 @@ def build_gallery(child: Path) -> list[dict]: "section": it.section, # U7. Derived in the resolver (INV-1); this only carries it. "group": it.group, + # R2 C1. Same rule: the resolver numbers, this carries. + "ordinal": it.ordinal, "caption": it.caption, "rendered": rendered, "rendered_html": rendered_html, @@ -1485,6 +1523,10 @@ def create_app( # of a thing that is not an item is not a view of the booth. if item is not None: record_view(booth) + # R2 C2: WHICH item was looked at — media only, the ring the tape + # draws. Same gate as the view above, and it never raises either. + if item.kind in REVIEW_KINDS: + record_seen(booth, item.rel, items) marks = marks_for(booth) item_marks = marks_for_target(marks, f) common = { @@ -1505,8 +1547,10 @@ def create_app( } if item is not None and item.kind == "image": - # prev/next ring (wraps; only when there is more than one image) - names = image_chain(items) + # prev/next ring (wraps; only when there is more than one item in + # it). R2 C2: the ring is `review_chain` — the item order filtered to + # MEDIA — so a set that mixes pictures and sound steps through both. + names = review_chain(items) prev_url = next_url = None if f in names and len(names) > 1: i = names.index(f) diff --git a/booth/items.py b/booth/items.py index 4f33621..8b93a10 100644 --- a/booth/items.py +++ b/booth/items.py @@ -95,6 +95,27 @@ class Item: blurred: bool doc: str | None size: int + # R2 C1: the 1-based position in `booth_items` order over ALL items — the + # number the operator means by "the third one". Set in the resolver loop + # and nowhere else (INV-1). APPENDED, never inserted: a mid-dataclass field + # is a positional-construction break. + ordinal: int + + +# R2 C2: which items have been looked at full size. UI state, not judgment — +# never exposed to sessions, holds nothing. One viewer: this records WHAT was +# seen, never who saw it. +SEEN_FILE = ".seen" + + +def read_seen(booth: Path) -> set[str]: + """Rels seen at full size. Missing or unreadable file -> empty set; a + damaged marker costs the tape its memory, never the page.""" + try: + text = (booth / SEEN_FILE).read_text() + except (OSError, UnicodeDecodeError): + return set() + return {ln.strip() for ln in text.splitlines() if ln.strip()} def read_blurred(booth: Path) -> set[str]: @@ -273,6 +294,10 @@ def booth_items(booth: Path) -> list[Item]: blurred=rel in blurred, doc=doc_kind(p.name), size=size, + # Counted over items that RENDER: a caption sidecar or a name + # the quote() guard skipped takes no number, so the numbers + # stay contiguous over what the operator can see. + ordinal=len(items) + 1, ) ) return items @@ -287,6 +312,18 @@ def image_chain(items: Sequence[Item]) -> list[str]: return [it.rel for it in items if it.kind == "image"] +# R2 C2: what the review route steps through. ONE LINE: the item order +# filtered to media. It is a declared change to the zoom-ring rule, which was +# images only: a listening set is reviewed the same way a picture set is. +REVIEW_KINDS = ("image", "video", "audio") + + +def review_chain(items: Sequence[Item]) -> list[str]: + """The rels of the media items, in item order — the review's prev/next ring, + its filmstrip and its tape.""" + return [it.rel for it in items if it.kind in REVIEW_KINDS] + + def find_item(items: Sequence[Item], rel: str) -> Item | None: """The record for one rel, or None — the zoom/doc route's entry point.""" for it in items: diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 58a5e65..c4e57cd 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -53,6 +53,13 @@
{%- endmacro %} +{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so + a column of them lines up. Width reads `all_items`, never the filtered list: + a filter must not change how a number is written any more than which. #} +{% macro ordinal(it) -%} + #{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }} +{%- endmacro %} + {% block title %}{{ name }} · The Booth{% endblock %} {% block content %}
@@ -310,6 +317,7 @@
+ {{ ordinal(it) }} {{ it.name }} ⤢ @@ -333,6 +341,7 @@ {% else %}
+ {{ ordinal(it) }} {% if it.blurred %} {# Click-to-reveal is per-viewer and client-side: nothing is persisted, so a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 0000000..ce616eb --- /dev/null +++ b/tests/test_flow.py @@ -0,0 +1,153 @@ +"""R2 — the review flow: the Desk, the lightbox, the review. + +Contract: docs/contracts/r2_flow.contract.md. Tests are grouped by the +contract's components (C1-C7) and named for the behaviour they pin. +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from booth.app import create_app # noqa: E402 +from booth.items import booth_items # noqa: E402 +from booth.marks import set_flag # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\n" + + +def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path: + b = root / name + b.mkdir() + for rel, data in files.items(): + p = b / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(data) + return b + + +def _client(root: pathlib.Path) -> TestClient: + return TestClient(create_app(root, ttl_hours=24, start_sweeper=False)) + + +def _ordinals(body: str) -> dict[str, str]: + """rel -> the ordinal text its tile prints, in render order.""" + out = {} + for fig in re.findall(r'
]*>.*?
', body, re.S): + rel = re.search(r'data-item="([^"]+)"', fig).group(1) + m = re.search(r'class="ord"[^>]*>#(\d+)<', fig) + out[rel] = m.group(1) if m else None + return out + + +# ---- C1: Item.ordinal ------------------------------------------------------ + +def test_a_filtered_tile_keeps_its_number_in_the_whole_set(tmp_path): + """The tracer. b.png is the second item of three; under ?filter=flagged it + is the ONLY tile rendered and must still print #2, because the number is a + property of the item, not of the view.""" + b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG}) + set_flag(b, "b.png", True) + body = _client(tmp_path).get("/b/g/?filter=flagged").text + assert _ordinals(body) == {"b.png": "2"} + + +def test_ordinals_count_rendered_items_only(tmp_path): + """A caption sidecar is not an item and takes no number, so the numbers + stay contiguous over what the operator can see. And an undecodable name + that the quote() guard skips takes none either — it is not rendered.""" + b = _booth(tmp_path, "g", {"a.png": PNG, "a.png.txt": b"cap", "b.png": PNG, + "c.png": PNG}) + import os + os.close(os.open(bytes(b) + b"/m\xff.png", os.O_CREAT | os.O_WRONLY, 0o644)) + items = booth_items(b) + assert [(it.rel, it.ordinal) for it in items] == [("a.png", 1), ("b.png", 2), ("c.png", 3)] + + +def test_ordinals_pad_to_the_width_of_the_whole_set(tmp_path): + """Twelve items: numbers are written two wide, so a column of them lines + up — and a filter showing only the first does not narrow it to #1.""" + b = _booth(tmp_path, "g", {f"{n:02d}.png": PNG for n in range(1, 13)}) + set_flag(b, "01.png", True) + c = _client(tmp_path) + assert _ordinals(c.get("/b/g/").text)["01.png"] == "01" + assert _ordinals(c.get("/b/g/").text)["12.png"] == "12" + assert _ordinals(c.get("/b/g/?filter=flagged").text) == {"01.png": "01"} + + +# ---- C2: review_chain and .seen ------------------------------------------- + +def test_the_review_ring_is_the_item_order_filtered_to_media(tmp_path): + """Images, video and audio, in set order. A doc is not in the ring: it + keeps its reading page.""" + from booth.items import review_chain + b = _booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.md": b"# c", + "d.webm": b"\x1aE", "e.zip": b"PK"}) + assert review_chain(booth_items(b)) == ["a.png", "b.mp3", "d.webm"] + + +def test_the_review_route_rings_through_audio_in_set_order(tmp_path): + """From the only image, "next" is the audio track that follows it in the + set — today's image-only ring had nowhere to go.""" + _booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.md": b"# c"}) + body = _client(tmp_path).get("/b/g/view?f=a.png").text + assert 'class="vnav vnext" href="?f=b.mp3"' in body + assert 'class="vnav vprev" href="?f=b.mp3"' in body # a two-item ring wraps + + +def test_a_full_size_look_is_recorded_as_seen_and_a_non_item_is_not(tmp_path): + """`.seen` answers WHICH items were looked at full size. Gated on the item + record like `record_view`: pointing `f` at a dotfile the service itself + wrote is not a look at anything.""" + from booth.items import read_seen + b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG}) + (b / ".marks.lock").write_bytes(b"") + c = _client(tmp_path) + for f in ("a.png", "c.png", "a.png", ".marks.lock"): + c.get(f"/b/g/view?f={f}") + assert read_seen(b) == {"a.png", "c.png"} + assert (b / ".seen").read_text() == "a.png\nc.png\n" # sorted, deduplicated + + +def test_seen_is_pruned_to_live_items_at_the_next_write(tmp_path): + """A deleted file drops out of `.seen` the next time anything is seen, so + the marker never outgrows the booth and never counts a ghost.""" + from booth.items import read_seen + b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG}) + c = _client(tmp_path) + c.get("/b/g/view?f=a.png") + (b / "a.png").unlink() + c.get("/b/g/view?f=b.png") + assert read_seen(b) == {"b.png"} + + +def test_a_planted_seen_symlink_is_replaced_not_written_through(tmp_path): + """Any fleet session can write into a booth. A `.seen` symlink aimed at a + file outside must not turn a page view into a write at that path.""" + outside = tmp_path / "victim.txt" + outside.write_text("untouched") + b = _booth(tmp_path, "g", {"a.png": PNG}) + (b / ".seen").symlink_to(outside) + r = _client(tmp_path).get("/b/g/view?f=a.png") + assert r.status_code == 200 + assert outside.read_text() == "untouched" + assert not (b / ".seen").is_symlink() + + +def test_a_look_that_cannot_be_recorded_still_serves_the_page(tmp_path): + """NEVER RAISES: a booth the service cannot write to costs the marker, not + the page.""" + b = _booth(tmp_path, "g", {"a.png": PNG}) + b.chmod(0o555) + try: + r = _client(tmp_path).get("/b/g/view?f=a.png") + finally: + b.chmod(0o755) + assert r.status_code == 200 + assert not (b / ".seen").exists() From b9750d221ad2637101cae470526267e874b331d2 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 08:36:54 -0700 Subject: [PATCH 08/15] =?UTF-8?q?feat(r2):=20C3=20server=20side=20?= =?UTF-8?q?=E2=80=94=20204=20on=20an=20explicit=20JSON=20Accept,=20and=20b?= =?UTF-8?q?ack=3Dview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wants_json: true only for an exact `application/json` entry with q > 0. Absent, empty, wildcard, application/*, near misses, q=0 and malformed headers all fall through to the 303. - The four mark routes share one exit, _mark_done: 204 with no body for the in-place client, otherwise _mark_redirect unchanged. - back=view lands on /b//view?f=#rail, only for a media item of this booth. It is built from the resolved rel and never echoed. Anything else takes the no-`back` landing. - tests/golden/r2_mark_303.json: 108 responses recorded from the PRE-R2 code (6 route cases x back absent|marks x 9 non-JSON Accepts), replayed byte for byte (INV-4). Two mutations (q>=0, substring match) turn it red. - The contract now states the q=0 rule. --- booth/app.py | 61 +- docs/contracts/r2_flow.contract.md | 7 +- tests/golden/r2_mark_303.json | 2180 ++++++++++++++++++++++++++++ tests/test_flow.py | 73 + 4 files changed, 2315 insertions(+), 6 deletions(-) create mode 100644 tests/golden/r2_mark_303.json diff --git a/booth/app.py b/booth/app.py index 1e6d841..098a1f5 100644 --- a/booth/app.py +++ b/booth/app.py @@ -355,6 +355,35 @@ def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None: pass +def wants_json(accept: str | None) -> bool: + """Whether a mark POST asked for the in-place answer (R2 C3). + + True ONLY when the Accept header lists `application/json` exactly — + parameters stripped — with a q-value that is absent or above zero. Absent, + empty, wildcard, `application/*`, a near miss like `application/jsonx`, an + explicit `q=0`, a malformed q: all False. It FAILS TOWARD THE 303, because + the plain form's redirect is the no-JS guarantee and a mis-parse must land + there, never on a 204 a browser would render as nothing happening. + """ + if not accept: + return False + try: + for entry in accept.split(","): + mtype, *params = entry.split(";") + if mtype.strip().lower() != "application/json": + continue + q = 1.0 + for param in params: + key, _, value = param.partition("=") + if key.strip().lower() == "q": + q = float(value.strip()) + if q > 0: + return True + except ValueError: + return False + return False + + HOLD_UNREADABLE = "unreadable" HOLD_OPEN = "open" @@ -1183,8 +1212,32 @@ def create_app( base = f"/b/{quote(name, safe='')}/" if form.get("back") == "marks": base = f"/b/{quote(name, safe='')}/marks" + elif form.get("back") == "view": + # R2 C3: judgment made at full size lands back at full size — the + # JS-off fix for being thrown out to the grid. Only for a MEDIA item + # of this booth; anything else takes the no-`back` landing above. + # Built from the resolved rel, never echoed from the form. + f = form.get("f") + if isinstance(f, str) and f: + try: + ring = review_chain(booth_items(resolve_booth(name))) + except HTTPException: + ring = [] + if f in ring: + return RedirectResponse( + url=f"/b/{quote(name, safe='')}/view?f={quote(f, safe='/')}#rail", + status_code=303) return RedirectResponse(url=f"{base}#{anchor}", status_code=303) + def _mark_done(request: Request, name: str, form, anchor: str) -> Response: + """The one exit for every mark route (R2 C3). A request that asked for + the in-place answer gets 204 and no body — the page fetches its own + fresh regions. Everything else gets `_mark_redirect`, byte for byte what + it got before R2 (INV-4).""" + if wants_json(request.headers.get("accept")): + return Response(status_code=204) + return _mark_redirect(name, form, anchor) + @app.post("/b/{name}/answer") async def booth_answer(request: Request, name: str): """Record the operator's pick — one of N options a session declared in @@ -1235,7 +1288,7 @@ def create_app( _form_text(form, "choice"), notes, who=who) except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}") + return _mark_done(request, name, form, f"mark-{quote(mark_id, safe='')}") @app.post("/b/{name}/note") async def booth_note(request: Request, name: str): @@ -1256,7 +1309,7 @@ def create_app( who=request.client.host if request.client else "") except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}") + return _mark_done(request, name, form, f"mark-{quote(mark.id, safe='')}") @app.post("/b/{name}/flag") async def booth_flag(request: Request, name: str): @@ -1278,7 +1331,7 @@ def create_app( who=request.client.host if request.client else "") except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return _mark_redirect(name, form, f"item-{quote(target, safe='')}") + return _mark_done(request, name, form, f"item-{quote(target, safe='')}") @app.post("/b/{name}/unmark") async def booth_unmark(request: Request, name: str): @@ -1290,7 +1343,7 @@ def create_app( if not isinstance(mark_id, str) or not mark_id: raise HTTPException(status_code=400, detail="which mark?") await run_in_threadpool(delete_mark, booth, mark_id) - return _mark_redirect(name, form, "marks") + return _mark_done(request, name, form, "marks") @app.post("/b/{name}/import-asks") async def booth_import_asks(request: Request, name: str): diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index dafd6c4..0aef900 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -129,10 +129,13 @@ 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 with parameters stripped, contains the exact media type -`application/json`. +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. - Any header that fails to parse → False. - **It fails toward the 303.** diff --git a/tests/golden/r2_mark_303.json b/tests/golden/r2_mark_303.json new file mode 100644 index 0000000..aa9d3e6 --- /dev/null +++ b/tests/golden/r2_mark_303.json @@ -0,0 +1,2180 @@ +[ + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/answer", + "form": { + "ask": "q", + "choice": "x", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-q" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "target": "a.png", + "text": "hello", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/note", + "form": { + "text": "booth note", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#mark-note-2" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "b b.png", + "on": "1", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-b%20b.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/flag", + "form": { + "target": "a.png", + "on": "0", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#item-a.png" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": null, + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "*/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "text/html", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "application/*", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "application/jsonx", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": ";;;", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + }, + { + "path": "/b/g/unmark", + "form": { + "mark": "note-1", + "back": "marks" + }, + "accept": "application/json;q=0", + "status": 303, + "headers": [ + [ + "content-length", + "0" + ], + [ + "location", + "/b/g/marks#marks" + ] + ], + "body": "" + } +] \ No newline at end of file diff --git a/tests/test_flow.py b/tests/test_flow.py index ce616eb..a872fdf 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -151,3 +151,76 @@ def test_a_look_that_cannot_be_recorded_still_serves_the_page(tmp_path): b.chmod(0o755) assert r.status_code == 200 assert not (b / ".seen").exists() + + +# ---- C3: in-place judgment -------------------------------------------------- + +GOLDEN = pathlib.Path(__file__).parent / "golden" / "r2_mark_303.json" + + +def _seed(root: pathlib.Path) -> TestClient: + """The golden's fixture, byte for byte (see golden_gen in the R2 notes).""" + from booth.marks import declare_pick, write_note + root.mkdir(parents=True, exist_ok=True) + b = _booth(root, "g", {"a.png": PNG, "b b.png": PNG, "c.md": PNG}) + declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}, target="a.png") + set_flag(b, "a.png", True) + write_note(b, "a.png", "seed") + return TestClient(create_app(root, ttl_hours=24, start_sweeper=False), + follow_redirects=False) + + +def test_every_pre_r2_request_shape_gets_a_byte_identical_303(tmp_path): + """INV-4. The golden was recorded from the PRE-R2 code: every mark route, + with `back` absent and `back=marks`, under nine Accept headers that must + NOT count as asking for JSON. Status, every header, and the body must match + exactly — the no-JS guarantee lives in these bytes.""" + import json + cases = json.loads(GOLDEN.read_text()) + assert len(cases) == 108 + for i, case in enumerate(cases): + c = _seed(tmp_path / str(i)) + headers = {} if case["accept"] is None else {"accept": case["accept"]} + r = c.post(case["path"], data=case["form"], headers=headers) + got = {"status": r.status_code, + "headers": sorted([k.lower(), v] for k, v in r.headers.items()), + "body": r.content.decode("latin-1")} + want = {k: case[k] for k in ("status", "headers", "body")} + assert got == want, (case["path"], case["form"], case["accept"]) + + +@pytest.mark.parametrize("path,form", [ + ("/b/g/answer", {"ask": "q", "choice": "y"}), + ("/b/g/note", {"target": "a.png", "text": "in place"}), + ("/b/g/flag", {"target": "b b.png", "on": "1"}), + ("/b/g/unmark", {"mark": "note-1"}), +]) +@pytest.mark.parametrize("accept", ["application/json", "text/html, application/json;q=0.5"]) +def test_an_explicit_json_accept_gets_204_and_the_write_still_lands(tmp_path, path, form, accept): + """The in-place path: same write as the form, no redirect, no body.""" + from booth.marks import marks_for + c = _seed(tmp_path) + before = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")] + r = c.post(path, data=form, headers={"accept": accept}) + assert r.status_code == 204 and r.content == b"" + assert "location" not in r.headers + after = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")] + assert after != before, "the write must happen exactly as for the form" + + +@pytest.mark.parametrize("f,landing", [ + ("a.png", "/b/g/view?f=a.png#rail"), + ("b b.png", "/b/g/view?f=b%20b.png#rail"), + ("c.md", "/b/g/#item-b%20b.png"), # a doc is not in the review ring + ("gone.png", "/b/g/#item-b%20b.png"), # not an item + ("", "/b/g/#item-b%20b.png"), + ("../../etc/passwd", "/b/g/#item-b%20b.png"), +]) +def test_back_view_lands_on_the_review_only_for_a_media_item(tmp_path, f, landing): + """The JS-off fix for the bounce: a flag set at full size lands back at + full size. Anything that is not a media item in this booth falls back to + the booth page exactly as a form with no `back` does.""" + c = _seed(tmp_path) + r = c.post("/b/g/flag", data={"target": "b b.png", "on": "1", "back": "view", "f": f}) + assert r.status_code == 303 + assert r.headers["location"] == landing From ce27b06f32ae3738a223553dbd6418515c6e03aa Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 08:45:54 -0700 Subject: [PATCH 09/15] =?UTF-8?q?feat(r2):=20C4=20the=20Desk=20=E2=80=94?= =?UTF-8?q?=20the=20index=20triaged=20by=20what=20needs=20the=20operator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list_booths gains open_since (parsed, never compared as text), flags, landed_at (content only; a new, differently named clock, INV-5), viewed_at, and a four-image preview that keeps blur. - The index renders needs you / new since you looked / everything else, always in that order. Needs you includes unreadable marks, so a damaged judgment file cannot hide. Everything else keeps list_booths' order rather than stating a second rule. An empty section renders nothing. - The side column holds live benches (a damaged registry says so), bookmarks from BOOTH_LINKS_BOARD with booth URLs left out (capped at 8), and the pickup form. - test_booth's kept-lane test is rewritten as the contract declared: kept is a fact on each row, not a lane. Two of the new tests were VACUOUS on their first draft, and mutation- checking caught both. The clocks test used a future t0, so a hand-set marker outranked every real write. The look-then-judge test followed the flag's 303, and the resulting GET recorded a fresh look. Both are fixed and now go red under their mutation. --- booth/app.py | 118 +++++++++++- booth/templates/base.html | 81 ++++++++ booth/templates/index.html | 295 +++++++++++++++-------------- docs/contracts/r2_flow.contract.md | 11 +- tests/test_booth.py | 19 +- tests/test_flow.py | 183 ++++++++++++++++++ 6 files changed, 545 insertions(+), 162 deletions(-) diff --git a/booth/app.py b/booth/app.py index 098a1f5..b48db1f 100644 --- a/booth/app.py +++ b/booth/app.py @@ -41,11 +41,13 @@ import os import re import secrets import shutil +import stat import tempfile import time import zipfile from contextlib import asynccontextmanager from dataclasses import replace +from datetime import datetime, timezone from pathlib import Path from typing import Sequence from urllib.parse import quote, unquote @@ -254,6 +256,56 @@ def _newest_mtime(path: Path) -> float: return newest +def _content_mtime(path: Path) -> float: + """`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular + files with no dot-component in their path. Deliberately NOT `_newest_mtime` + (INV-5 of r2): a mark, a view, a blur or a keep is activity, never new + content, so none of them may make a booth read as newly landed. + + Files only, never directories: creating `.viewed` bumps the booth + directory's own mtime, and counting that would make the first look at a + booth look like a delivery. An empty booth landed at 0.0. Unknowable reads + as NOW, the posture `_newest_mtime` takes and for a milder reason here: a + booth we cannot read is shown as new rather than hidden as old. + """ + newest = 0.0 + try: + for p in path.rglob("*"): + rel = p.relative_to(path) + if any(part.startswith(".") for part in rel.parts): + continue + try: + st = p.stat() + except FileNotFoundError: + continue + if stat.S_ISREG(st.st_mode) and st.st_mtime > newest: + newest = st.st_mtime + except OSError: + return time.time() + return newest + + +def _viewed_at(path: Path) -> float | None: + """The mtime of the booth's `.viewed` marker (U4), or None if it has never + been looked at. `lstat`, like `is_kept`: a planted symlink is read as the + marker it claims to be, never followed.""" + try: + return os.lstat(path / VIEW_MARKER).st_mtime + except OSError: + return None + + +def _stamp(created: str) -> datetime | None: + """A mark's `created` as an aware datetime, or None when it will not parse. + Strings are never compared: two ISO stamps with different offsets sort + wrong as text. A naive stamp is read as UTC.""" + try: + dt = datetime.fromisoformat(created) + except (TypeError, ValueError): + return None + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + def booth_age_seconds(path: Path, now: float | None = None) -> float: now = time.time() if now is None else now return now - _newest_mtime(path) @@ -384,6 +436,9 @@ def wants_json(accept: str | None) -> bool: return False +# The Desk shows this many bookmarks and links to the board for the rest. +BOOKMARKS_SHOWN = 8 + HOLD_UNREADABLE = "unreadable" HOLD_OPEN = "open" @@ -529,6 +584,10 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> # rather than re-opening .blurred here. thumb_blurred = it.blurred mtime = _newest_mtime(child) + # R2 C4: the oldest question still owed an answer, PARSED. Unparseable + # stamps are left out, so a booth whose every open pick is unparseable + # has no `open_since` and sorts after every booth that has one. + stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st] booths.append( { "name": child.name, @@ -555,6 +614,17 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> "hold": hold, "expires_in": max(0.0, ttl_seconds - (now - mtime)), "mtime": mtime, + # ---- R2 C4, the Desk. All from the pass above; no second read. + "open_since": min(stamps) if stamps else None, + "flags": sum(1 for m in marks if m.shape == "flag"), + # Two clocks, named apart (INV-5): `mtime` is activity, + # `landed_at` is content. "New since you looked" reads only the + # second, so a flag or a view never makes a booth look new. + "landed_at": _content_mtime(child), + "viewed_at": _viewed_at(child), + # The first four images in item order, as the originals shown + # small. Blurred ones stay blurred, the cover's rule. + "preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4], } ) # Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime @@ -779,6 +849,7 @@ def create_app( sweep_interval_s: int = 900, max_upload_mb: float = 1024.0, max_files: int = 50, + links_board: str = "links", ) -> FastAPI: data_dir = Path(data_dir).expanduser().resolve() data_dir.mkdir(parents=True, exist_ok=True) @@ -889,19 +960,49 @@ def create_app( @app.get("/", response_class=HTMLResponse) def index(request: Request): - # Two lanes, split here rather than in the template: kept boards are a - # different KIND of thing from the ephemeral churn — durable, deliberate, - # operator-facing — and burying them in a feed that turns over daily is - # exactly how they would get lost, which is the problem they exist to - # solve. Kept renders first. + """THE DESK (R2 C4) — the index triaged by what needs the operator. + + Three sections, ALWAYS in this order, each booth in exactly one: + needs — an open pick, or marks that cannot be read (somebody has to + fix those, so they must not hide further down). Oldest open + question first; a booth with no parseable stamp after every + booth that has one; name breaks ties. + new — content landed since the booth was last looked at, or never + looked at. Newest content first; name breaks ties. + rest — everything else, in `list_booths`' own order (last activity + first, name as the tie-break). No second rule is stated. + The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so + the lanes sorted nothing. Kept status still shows on every row. + """ everything = list_booths(data_dir, ttl_seconds) + needs = [b for b in everything + if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE] + needs.sort(key=lambda b: ((0, b["open_since"].timestamp()) + if b["open_since"] else (1, 0.0), b["name"])) + in_needs = {b["name"] for b in needs} + new = [b for b in everything if b["name"] not in in_needs + and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])] + new.sort(key=lambda b: (-b["landed_at"], b["name"])) + in_new = {b["name"] for b in new} + rest = [b for b in everything + if b["name"] not in in_needs and b["name"] not in in_new] + benches, benches_error = read_benches(data_dir) + board = data_dir / links_board + bookmarks = [row for row in _board_rows(board) + if booth_target(row["url"]) is None] if board.is_dir() else [] return templates.TemplateResponse( request, "index.html", { **base_ctx, - "kept": [b for b in everything if b["kept"]], - "booths": [b for b in everything if not b["kept"]], + "needs": needs, + "new": new, + "rest": rest, + "benches": [b for b in benches if b.state != "retired"], + "benches_error": benches_error, + "bookmarks": bookmarks[:BOOKMARKS_SHOWN], + "bookmarks_total": len(bookmarks), + "board_url": f"/b/{quote(links_board, safe='')}/", }, ) @@ -1875,6 +1976,9 @@ def _from_env() -> FastAPI: sweep_interval_s=interval, max_upload_mb=max_mb, max_files=max_n, + # The board the CLI's `booth link` writes (scripts/booth reads the same + # variable), so the Desk's bookmarks come from where they are written. + links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"), ) diff --git a/booth/templates/base.html b/booth/templates/base.html index d334641..da1b13d 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -157,6 +157,87 @@ box-shadow:var(--shadow-sm)} .thumb .badge+.badge-mark{top:36px} + /* ---- THE DESK (R2 C4) ------------------------------------------------- + The index as triage. Sections stack in a fixed order; a row is a booth. + "Needs you" rows carry an amber inner edge — the one thing on the page + that asks to be looked at. */ + .desk{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:var(--space-6);align-items:start} + @media (max-width:1000px){.desk{grid-template-columns:1fr}} + .desk-sec{margin:0 0 var(--space-6)} + .desk-head{display:flex;align-items:center;gap:10px;margin:0 0 10px;font-family:var(--font-mono); + font-size:var(--size-micro);font-weight:500;letter-spacing:var(--tracking-caps);text-transform:uppercase; + color:var(--text-muted)} + .desk-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)} + .desk-head-needs{color:var(--warning-text)} + .desk-head-new{color:var(--intel-text)} + .desk-rule{font-weight:400;letter-spacing:0;text-transform:none;color:var(--text-muted)} + .desk-row{display:grid;grid-template-columns:210px minmax(0,1fr) auto;gap:var(--space-4);align-items:center; + padding:12px;margin-bottom:8px;border:1px solid var(--border-default);border-radius:var(--radius-xl); + background:var(--surface-card);box-shadow:var(--shadow-sm); + transition:border-color var(--dur-2) var(--ease-out)} + .desk-row:hover,.desk-row:focus-within{border-color:var(--border-strong)} + .desk-row.is-needs{box-shadow:inset 3px 0 0 var(--warning),var(--shadow-sm)} + .desk-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:3px;height:58px;border-radius:var(--radius-md); + overflow:hidden;background:var(--surface-sunken)} + .desk-strip img{width:100%;height:100%;object-fit:cover;display:block} + .desk-strip .ph{grid-column:1/-1;display:flex;align-items:center;justify-content:center;font-family:var(--font-mono); + font-size:var(--size-caption);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)} + .desk-strip:hover{text-decoration:none} + .desk-main{min-width:0} + .desk-title{display:block;font-weight:600;font-size:var(--size-h3);line-height:1.3;color:var(--text-heading); + overflow-wrap:anywhere} + .desk-title:hover{color:var(--text-link);text-decoration:none} + .desk-slug{font-family:var(--font-mono);font-size:var(--size-caption);font-weight:400;color:var(--text-muted);margin-left:6px} + .desk-main .prov{margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .desk-facts{margin-top:5px;font-family:var(--font-mono);font-size:var(--size-caption);color:var(--text-muted)} + .desk-flags{color:var(--success-text)} + .desk-side{display:flex;flex-direction:column;align-items:flex-end;gap:8px} + .badge-new{color:var(--intel-text);background:var(--intel-soft)} + .badge-broken{color:var(--danger-text);background:var(--danger-soft);border-color:color-mix(in oklab,var(--danger) 40%,transparent)} + /* The row's keep / release / × — quiet until the row is hovered or focused, + because a destructive control must not compete with the thing you came to + read. Static here: on the old cards they floated over a thumbnail. */ + .desk-acts{display:flex;gap:6px;opacity:0;transition:opacity var(--dur-1)} + .desk-row:hover .desk-acts,.desk-acts:focus-within{opacity:1} + .desk-acts form{position:static;opacity:1;margin:0} + .desk-acts button{height:28px;min-width:28px;padding:0 8px;font-size:var(--size-sm);border-radius:var(--radius-md); + border:1px solid var(--border-strong);background:var(--surface-raised);color:var(--text-body); + -webkit-backdrop-filter:none;backdrop-filter:none;cursor:pointer} + .desk-acts .release button{font-family:var(--font-mono);font-size:var(--size-caption)} + .desk-acts .wipe button:hover{background:var(--danger);border-color:var(--danger);color:var(--danger-contrast)} + .desk-acts .keepit button:hover,.desk-acts .release button:hover{background:var(--surface-overlay)} + @media (max-width:700px){ + .desk-row{grid-template-columns:1fr} + .desk-side{flex-direction:row;align-items:center;justify-content:space-between} + .desk-acts{opacity:1} + } + /* the side column */ + .desk-panel{border:1px solid var(--border-default);border-radius:var(--radius-xl);background:var(--surface-card); + overflow:hidden;margin-bottom:var(--space-4);box-shadow:var(--shadow-sm)} + .desk-panel-head{display:flex;align-items:baseline;gap:8px;margin:0;padding:10px 14px;background:var(--surface-raised); + border-bottom:1px solid var(--border-subtle);font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500; + letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-heading)} + .desk-panel-head .desk-rule{margin-left:auto} + .desk-bench,.desk-mark{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-subtle); + font-size:var(--size-sm);color:var(--text-heading)} + .desk-bench:last-child,.desk-mark:last-of-type{border-bottom:0} + .desk-bench:hover,.desk-mark:hover{background:var(--surface-sunken);text-decoration:none} + .desk-mark{display:block;color:var(--text-link)} + .desk-mark.is-pinned{background:var(--surface-overlay)} + .desk-bench-dot{flex:0 0 auto;width:6px;height:6px;border-radius:var(--radius-pill);background:var(--text-muted)} + /* Device 3: a live bench is a running thing — the one row that glows. */ + .desk-bench.is-live .desk-bench-dot{background:var(--accent);box-shadow:var(--glow-armed)} + .desk-bench.is-promoted .desk-bench-dot{background:var(--intel)} + .desk-bench-main{display:flex;flex-direction:column;min-width:0} + .desk-bench-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .desk-bench-sub{display:block;font-family:var(--font-mono);font-size:var(--size-micro);color:var(--text-muted)} + .desk-more{display:block;padding:9px 14px;border-top:1px solid var(--border-subtle);font-family:var(--font-mono); + font-size:var(--size-caption)} + .desk-panel .uploader{flex-direction:column;margin:0;padding:12px} + .desk-panel .drop{flex:1 1 auto;padding:14px 12px} + .desk-panel .up-go{padding:10px 14px;border-radius:var(--radius-lg)} + .desk-panel .drop.has+.up-go{padding:10px 14px;border-radius:var(--radius-lg)} + /* ---- index: lanes and cards ------------------------------------------ */ .lane-head{margin:28px 0 12px;font-family:var(--font-mono);font-size:var(--size-micro);font-weight:500; letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted); diff --git a/booth/templates/index.html b/booth/templates/index.html index b56647c..424f040 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -1,136 +1,155 @@ {% extends "base.html" %} {% from "_provenance.html" import provenance %} {% from "_lifetime.html" import lifetime %} -{% block content %} - - - - +{# THE DESK (R2 C4). The index triaged by what needs the operator: needs you, + then new since you looked, then everything else — always in that order, and + the ORDER WITHIN each is decided in app.index, never here. A section with no + booths renders nothing at all: no heading, no empty box (the negative half + of the kept-lane pair this replaces). #} -{% if kept %} - {# Kept boards render FIRST and look different on purpose: they are durable - operator-facing things (the agent link board, standing reports) and the - point of the lane is that they cannot be lost in a feed that turns over - every day. No countdown — they have no expiry to advertise. #} -

Kept · no expiry · {{ keep_marker }}

-
- {% for b in kept %} -
- - {% if b.thumb_url %} - {# A cover blurred inside the booth must be blurred here too, or the - front page undoes the censoring the booth page applied. #} - - {% elif b.has_index %} -
▦ page
- {% elif b.kinds.video %} -
▶ video
- {% elif b.kinds.audio %} -
♪ audio
- {% else %} -
◆ files
- {% endif %} - ★ kept -
-
- {{ b.name }} -
{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(true, b.hold, b.expires_in) }} · ⬇ zip
- {{ provenance(b.manifest) }} -
- {# There IS a × here now (operator, 2026-09-21). The old rule was - release-then-find-it-in-the-other-lane, on the theory that two - deliberate acts protect durable boards. In practice it protects - nothing and costs a hunt: the board you just released is loose in a - feed that turns over, and you have to go find it to finish the job - you had already decided on. - - The protection now lives in the CONFIRMATION, not in the number of - lanes you must traverse — this one names the booth and says the word - KEPT, where the ephemeral × just asks. A deliberate act, one click, - reachable. - - Release still exists and is still the reversible option. Note it - BUMPS the directory mtime, so the board's age resets and it survives - another full TTL — unkeep-and-wait is a 24h delay, not a delete, - which is exactly why a direct × was worth adding. #} - {# ⚠ BOTH OF THESE WERE position:absolute ON THE SAME CORNER, and `release` - is the later sibling, so it painted over the × completely: measured - 30x22 px of overlap on a 30px button, and elementFromPoint at the ×'s - centre returned the release form. The × was unclickable from the day - it shipped. - - One flex row, positioned once, instead of two independently guessed - offsets — so neither control can drift back on top of the other when - a label changes width. #} -
-
- -
-
- -
-
-
+{# The first four images, the originals shown small. A blurred one stays + blurred (`blurred-thumb`, the cover's rule). A booth with no images shows the + kind placeholder the cards used to. #} +{% macro preview(b) -%} +
- {% if b.thumb_url %} - - {% elif b.has_index %} -
▦ page
- {% elif b.kinds.video %} -
▶ video
- {% elif b.kinds.audio %} -
♪ audio
- {% else %} -
◆ files
- {% endif %} - {% if b.uploaded %}⬆ pickup{% endif %} - {% if b.marks_open %}? {{ b.marks_open }} open{% endif %} +
+{%- endmacro %} + +{% macro row(b, section) -%} +
+ {{ preview(b) }} +
+ {# The manifest title leads when there is one; the directory name stays + beside it because it is what the URL says. #} + + {%- if b.manifest and not b.manifest.error and b.manifest.title and b.manifest.title != b.name -%} + {{ b.manifest.title }} {{ b.name }} + {%- else -%}{{ b.name }}{%- endif -%} -
- {{ b.name }} -
{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(false, b.hold, b.expires_in) }} · ⬇ zip
- {{ provenance(b.manifest) }} + {{ provenance(b.manifest) }} +
+ {{ b.count }} item{{ '' if b.count == 1 else 's' }} + {% if b.flags %} · {{ b.flags }} flagged{% endif %} + · {{ lifetime(b.kept, b.hold, b.expires_in) }} + · ⬇ zip
- {# Promote to the kept lane. The /keep route and the `booth keep` CLI verb - both predate this button; until 2026-09-19 the UI could only RELEASE a - kept booth, never keep an ephemeral one, so the round trip was only - closed if you had a shell. Reversible, so no confirmation — the × next - to it is the destructive one and keeps its prompt. #} -
- -
-
- -
-
- {% endfor %} +
+
+ {% if b.marks_open %}? {{ b.marks_open }} open + {% elif b.hold == "unreadable" %}marks unreadable + {% elif section == 'new' %}new{% endif %} + {% if b.uploaded %}⬆ pickup{% endif %} +
+ {# Keep / release and the ×. The confirmation text is DATA-DRIVEN: the + booth name travels as a data attribute and never reaches a JS string + (see the script below). Release is reversible, so it has no prompt of + its own beyond the wording. #} + {% if b.kept %} +
+ +
+
+ +
+ {% else %} +
+ +
+
+ +
+ {% endif %} +
+
+ +{%- endmacro %} + +{% block content %} +
+
+ {% if needs %} +
+

Needs you oldest question first

+ {% for b in needs %}{{ row(b, 'needs') }}{% endfor %} +
+ {% endif %} + {% if new %} +
+

New since you looked newest first

+ {% for b in new %}{{ row(b, 'new') }}{% endfor %} +
+ {% endif %} + {% if rest %} +
+

Everything else last activity first

+ {% for b in rest %}{{ row(b, 'rest') }}{% endfor %} +
+ {% endif %} + {% if not needs and not new and not rest %} +
+ No booths yet. Drop a folder into {{ data_dir }}, or upload files for pickup. +
+ {% endif %}
-{% endif %} + + +
{% endif %} @@ -500,17 +535,30 @@ }); })(); - /* Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits + /* TILE CONTROLS, bound per node and RE-BOUND after an in-place swap (R2 C3): + the swap puts fresh server-rendered tiles in the page, and a handler bound + to the node it replaced goes with that node. `__bound` keeps a node from + being bound twice. + + Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits inside , so without this its click would just toggle the
open/closed — stopPropagation + preventDefault make ✕ mean "close", not "collapse". Collapse stays available via the rest of the summary bar. With - JS off the button is inert and collapse via
still works. */ - (function () { + JS off the button is inert and collapse via
still works. + + Blur reveal. WARNING: this handler previously sat after the content block's + closing tag, which in a child template Jinja DISCARDS — the button rendered + and did nothing, and two commits plus a README claimed click-to-reveal + worked. Anything that must reach the page belongs inside the content + block. Per-viewer and never persisted: a reload re-hides. */ + function bindTiles() { + function once(el) { if (el.__bound) return false; el.__bound = true; return true; } /* A form inside would otherwise collapse the doc on submit. */ - document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) { - f.addEventListener('click', function (ev) { ev.stopPropagation(); }); + document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) { + if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); }); }); document.querySelectorAll('.doc-close').forEach(function (btn) { + if (!once(btn)) return; btn.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); @@ -518,80 +566,20 @@ if (item) item.classList.add('is-closed'); }); }); - })(); - - /* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row - × / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only - adds select-all, a live count, and disabling 🗑 when nothing is ticked. The - per-row × confirm reads desc/url from data-* attributes rather than being - interpolated into an inline handler, so an arbitrary agent-posted description - (quotes, newlines) can never break out into the page's JS. */ - (function () { - var form = document.getElementById('boardform'); - if (!form) return; - var boxes = Array.prototype.slice.call(form.querySelectorAll('.board-check')); - var selall = document.getElementById('board-selall'); - var delBtn = document.getElementById('board-del-sel'); - var countEl = document.getElementById('board-selcount'); - - function selected() { return boxes.filter(function (b) { return b.checked; }); } - function refresh() { - var n = selected().length; - if (countEl) countEl.textContent = n; - if (delBtn) delBtn.disabled = n === 0; - if (selall) { - selall.checked = n > 0 && n === boxes.length; - selall.indeterminate = n > 0 && n < boxes.length; - } - } - if (selall) { - selall.addEventListener('change', function () { - boxes.forEach(function (b) { b.checked = selall.checked; }); - refresh(); - }); - } - boxes.forEach(function (b) { b.addEventListener('change', refresh); }); - - // Bulk delete: confirm with the count. Attached to the button (not the form's - // submit) so the per-row × / ★ submits — which share this form — are unaffected. - if (delBtn) { - delBtn.addEventListener('click', function (ev) { - var n = selected().length; - if (n === 0) { ev.preventDefault(); return; } - if (!confirm('Delete ' + n + ' selected link' + (n === 1 ? '' : 's') + '?\n\nThe rest of the board is untouched.')) { - ev.preventDefault(); - } - }); - } - - form.querySelectorAll('.board-rm-btn').forEach(function (btn) { - btn.addEventListener('click', function (ev) { - var d = btn.getAttribute('data-desc') || ''; - var u = btn.getAttribute('data-url') || ''; - if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) { - ev.preventDefault(); - } - }); - }); - - refresh(); - })(); - - /* Blur reveal. WARNING: this handler previously sat after the content - block's closing tag, which in a - child template Jinja DISCARDS — the button rendered and did nothing, and - two commits plus a README claimed click-to-reveal worked. Anything that - must reach the page belongs inside the content block. Verified now by - grepping the SERVED html for this function, not the template for the text. - Per-viewer and never persisted: a reload re-hides. */ - document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) { - btn.addEventListener('click', function (ev) { - ev.preventDefault(); - ev.stopPropagation(); + document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) { var fig = btn.closest('.item'); - var on = fig.classList.toggle('revealed'); - btn.textContent = on ? '🙈 hide' : '👁 reveal'; + /* a swap carries `revealed` across (base.html); the label follows it */ + btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal'; + if (!once(btn)) return; + btn.addEventListener('click', function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + var on = fig.classList.toggle('revealed'); + btn.textContent = on ? '🙈 hide' : '👁 reveal'; + }); }); - }); + } + bindTiles(); + document.addEventListener('booth:swapped', bindTiles); {% endblock %} diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index 7817463..4d9f9f5 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -33,6 +33,7 @@ touches: - "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." @@ -389,6 +390,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_embed_browser.py `test_the_keyboard_flag_actually_submits` | pressing `f` causes a NAVIGATION (`page.expect_navigation()`), and the reloaded page shows the flag | pressing `f` causes NO navigation; the flag comes back from the server into the swapped tile. A window marker set before the keypress must survive, proving no reload | requirement 3: the reload IS the defect. The test's real claim — the key reaches the server and the server's state comes back — is kept, and asserted more strictly | | test_booth.py L789 | the kept booth renders BEFORE the ephemeral one (`html.index("links") < html.index("scratch")`) | replaced by the Desk's stated order (needs → new → everything, each with its own key) | the kept-first order was the lane's; with no lane there is no kept-first rule, and a second hidden ordering would break INV-2 | | test_booth.py L810-811 | lane absent when nothing is kept | these two SURVIVE unchanged (they assert absence and stay true) | — | diff --git a/tests/test_embed_browser.py b/tests/test_embed_browser.py index 0c18364..a8affea 100644 --- a/tests/test_embed_browser.py +++ b/tests/test_embed_browser.py @@ -592,17 +592,26 @@ def test_the_keyboard_flag_actually_submits(browser, live): a hidden input does not submit its form. The shortcut never worked while still swallowing the keystroke. - Asserted end to end: press f, and the flag must come back from the server - on the reloaded page.""" + Asserted end to end: press f, and the flag must come back from the server. + + R2 C3 (docs/contracts/r2_flow.contract.md, "Assertions that change"): this + used to expect a NAVIGATION — the flag form POSTed and the page reloaded. + That reload is the defect R2 removes. The claim that matters is kept and + tightened: the flag must come back from the SERVER (the swapped tile is + server-rendered), and a marker set on the window before the keypress must + survive, which a reload would wipe.""" base, root = live _gallery(root) page = browser.new_page() page.goto(f"{base}/b/g/", wait_until="networkidle") + page.evaluate("window.__noReload = 1") page.keyboard.press("ArrowRight") # cursor onto the first tile - with page.expect_navigation(): # the flag form POSTs and redirects back - page.keyboard.press("f") + page.keyboard.press("f") + page.wait_for_selector("figure.item.is-flagged", timeout=10000) flagged = page.locator("figure.item.is-flagged").count() + survived = page.evaluate("window.__noReload === 1") page.close() assert flagged == 1, f"the f key flagged {flagged} items, expected 1" + assert survived, "the flag reloaded the page; in-place judgment must not" diff --git a/tests/test_flow.py b/tests/test_flow.py index fe66fb5..2c2606d 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -407,3 +407,72 @@ def test_a_booth_without_images_shows_its_kind_instead(tmp_path): _booth(tmp_path, "songs", {"a.mp3": b"ID3", "b.mp3": b"ID3"}) row = re.search(r'data-booth="songs".*?', _client(tmp_path).get("/").text, re.S).group(0) assert "♪ audio" in row and " str: + m = re.search(r'<(\w+)[^>]*data-region="%s"[^>]*>' % re.escape(rid), body) + assert m, f"no region {rid}" + tag = m.group(1) + # regions in these templates do not nest a same-named tag inside themselves + end = body.index(f"", m.end()) + return body[m.start():end] + + +def test_the_verdict_sits_beside_the_set_on_a_gallery_booth(tmp_path): + """The tracer for C5: the open question, the flags and the notes live in + one aside next to the grid — not in a panel above it that scrolls away.""" + from booth.marks import declare_pick + b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG}) + declare_pick(b, "q", {"prompt": "Which one?", "options": ["a", "b"]}) + body = _client(tmp_path).get("/b/g/").text + aside = _region(body, "verdict") + assert aside.startswith('
{% elif tray is not defined and flags %}
@@ -158,6 +165,8 @@
{% endif %} +{% set fold_notes = tray is defined and notes %} +{% if fold_notes %}
notes · {{ notes|length }}{% endif %} {% for a in notes %}
@@ -175,6 +184,7 @@
{{ a.text }}
{% endfor %} +{% if fold_notes %}
{% endif %} {# The operator volunteering a remark, which before marks had no mechanism at diff --git a/booth/templates/base.html b/booth/templates/base.html index d16007c..f83745c 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -396,6 +396,22 @@ .lightbox{grid-template-columns:1fr;grid-template-areas:"verdict" "set"} .verdict{position:static;max-height:none} } + /* The fold (C5): a CLOSED
in the markup. Wide, CSS alone shows its + content and hides its summary — `::details-content` is the part of a + details element that closing hides — so nothing is folded where there is + room. Narrow, it stays closed: the question above the set is not buried + under the tray and the notes, which are one tap away. No script. */ + .v-fold{display:flex;flex-direction:column;gap:var(--space-3)} + .v-fold-head{cursor:pointer;list-style:none;padding:8px 14px;border:1px solid var(--border-default); + border-radius:var(--radius-lg);background:var(--surface-card);font-family:var(--font-mono); + font-size:var(--size-micro);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-muted)} + .v-fold-head::-webkit-details-marker{display:none} + .v-fold-head::before{content:"▸ ";color:var(--text-muted)} + .v-fold[open] > .v-fold-head::before{content:"▾ "} + @media (min-width:1001px){ + .verdict .v-fold > .v-fold-head{display:none} + .verdict .v-fold::details-content{content-visibility:visible;display:contents} + } /* the flag tray: the flagged items in set order, the originals shown small */ .tray{display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:6px;padding:12px 14px} .tray-item{position:relative;display:block;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden; @@ -419,6 +435,10 @@ .grp-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)} .grp-key{color:var(--text-heading);font-weight:600} .grp-n{color:var(--text-muted)} + /* a region the fresh page no longer has (C3): kept in place, visibly not current */ + .is-stale{opacity:.45;filter:grayscale(.6)} + /* a region boundary that must not change layout */ + .region-wrap{display:contents} .status{margin:0 0 12px;padding:8px 12px;border-radius:var(--radius-md);background:var(--warning-soft); color:var(--warning-text);font-size:var(--size-sm)} @@ -836,6 +856,15 @@ ['revealed', 'is-closed'].forEach(function (c) { if (oldEl.classList.contains(c)) newEl.classList.add(c); }); + /* A disclosure the reader opened or closed stays that way: the server + renders its default, the reader's choice is client state. */ + var newDetails = newEl.querySelectorAll('details'); + oldEl.querySelectorAll('details').forEach(function (d, i) { + /* ...except the one holding the form just sent: an answered pick's + form comes back folded on purpose, showing the recorded answer. */ + if (sent && d.contains(sent)) return; + if (newDetails[i]) newDetails[i].open = d.open; + }); /* An unsaved DRAFT survives a swap it was not part of: a note half-typed on one tile must not vanish because a flag landed on another. The form that was just sent is the exception — its field is supposed to @@ -860,6 +889,13 @@ var node = document.importNode(next, true); carry(el, node, sent); el.replaceWith(node); + } else { + /* ABSENT from the fresh page — a tile a filter no longer matches, + say, after un-flagging under ?filter=flagged. Left in place, never + deleted (deleting would shift every tile after it under the + reader's eye), and marked stale so it does not pass for current. + The next navigation drops it. */ + el.classList.add('is-stale'); } }); document.dispatchEvent(new CustomEvent('booth:swapped')); @@ -880,8 +916,10 @@ if (!r.ok) throw new Error('status ' + r.status); return r.text(); }).then(function (html) { swap(html, form); }).catch(function () { + /* Said, then reloaded after a beat, so the words are readable rather + than a flash before the page goes. */ say('Could not save in place — reloading to show what was saved.'); - window.location.reload(); + setTimeout(function () { window.location.reload(); }, 900); }); }); })(); diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 0a4f251..8599281 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -73,7 +73,9 @@ {% else %}

{{ name }}

{% endif %} - {% if uploaded %}⬆ pickup {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %} + {# The open count and the lifetime line depend on marks, so they are a region + (R2 C3): answering the last pick in place must not leave "1 open" behind. #} + {% if uploaded %}⬆ pickup {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %} {% if items %}
⬇ zip{% endif %} {{ provenance(manifest) }} {# A durable multi-writer board gets no one-click wipe — same rule as the @@ -119,9 +121,14 @@ {# R2 C5: on a GALLERY booth the panel moves into the verdict aside beside the set (below). It renders up here only where there is no set to sit beside — a board, or a booth with marks and nothing to show. #} -{% set lightbox = all_items and not board %} +{# `is_board`, not `board`: PAGE IDENTITY, not page content — the lesson the + bench panel already learned. `board` is the parsed rows, empty for a + links.md with none, and a board with an image in it must still be a board. #} +{% set lightbox = all_items and not is_board %} {% if (marks or not board) and not lightbox %} +
{% include "_marks.html" %} +
{% endif %} {# THE BENCH REGISTRY — BLOCK LEVEL, and that placement is load-bearing. @@ -266,11 +273,13 @@ (the question above the work), and on a wide one the grid areas in base.html put the aside on the right. Placement, not order — nothing in an ordered collection moves. #} + {% if lightbox %}
{% else %} -
+
{{ ordinal(it) }} {% if it.blurred %} {# Click-to-reveal is per-viewer and client-side: nothing is persisted, so @@ -406,6 +407,10 @@
⬇ {{ it.caption or it.name }} + {# R2: every MEDIA tile links into the review — a picture through its + image, sound and video through this. Enter on the grid cursor + follows the first `view` link on the tile. #} + {% if it.kind in ('video', 'audio') %}⤢ review{% endif %} {{ blurtoggle(name_url, it) }} {{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
@@ -592,5 +597,65 @@ } bindTiles(); document.addEventListener('booth:swapped', bindTiles); + + /* RESTORED (heid bug-hunt, 2/4): R2's rewrite of the tile handlers above + deleted this block with them. Its confirmations guard destructive + actions, so it is back verbatim. */ + /* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row + × / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only + adds select-all, a live count, and disabling 🗑 when nothing is ticked. The + per-row × confirm reads desc/url from data-* attributes rather than being + interpolated into an inline handler, so an arbitrary agent-posted description + (quotes, newlines) can never break out into the page's JS. */ + (function () { + var form = document.getElementById('boardform'); + if (!form) return; + var boxes = Array.prototype.slice.call(form.querySelectorAll('.board-check')); + var selall = document.getElementById('board-selall'); + var delBtn = document.getElementById('board-del-sel'); + var countEl = document.getElementById('board-selcount'); + + function selected() { return boxes.filter(function (b) { return b.checked; }); } + function refresh() { + var n = selected().length; + if (countEl) countEl.textContent = n; + if (delBtn) delBtn.disabled = n === 0; + if (selall) { + selall.checked = n > 0 && n === boxes.length; + selall.indeterminate = n > 0 && n < boxes.length; + } + } + if (selall) { + selall.addEventListener('change', function () { + boxes.forEach(function (b) { b.checked = selall.checked; }); + refresh(); + }); + } + boxes.forEach(function (b) { b.addEventListener('change', refresh); }); + + // Bulk delete: confirm with the count. Attached to the button (not the form's + // submit) so the per-row × / ★ submits — which share this form — are unaffected. + if (delBtn) { + delBtn.addEventListener('click', function (ev) { + var n = selected().length; + if (n === 0) { ev.preventDefault(); return; } + if (!confirm('Delete ' + n + ' selected link' + (n === 1 ? '' : 's') + '?\n\nThe rest of the board is untouched.')) { + ev.preventDefault(); + } + }); + } + + form.querySelectorAll('.board-rm-btn').forEach(function (btn) { + btn.addEventListener('click', function (ev) { + var d = btn.getAttribute('data-desc') || ''; + var u = btn.getAttribute('data-url') || ''; + if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) { + ev.preventDefault(); + } + }); + }); + + refresh(); + })(); {% endblock %} diff --git a/booth/templates/index.html b/booth/templates/index.html index 424f040..0e25f60 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -116,11 +116,14 @@

Benches running things

{% for b in benches %} - + {# Agent-written URLs: only http(s) becomes a link. Autoescape stops markup, + not a `javascript:` scheme, so anything else renders as plain text. #} + {% set web = b.url.lower().startswith(('http://', 'https://')) %} + <{{ 'a' if web else 'div' }} class="desk-bench is-{{ b.state }}"{% if web %} href="{{ b.url }}" target="_blank" rel="noopener"{% endif %}> {{ b.name or b.url }} {% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }} - + {% endfor %}
{% endif %} @@ -129,8 +132,9 @@

Bookmarks pinned first

{% for e in bookmarks %} - - {{ e.desc }}{% if e.who %}{{ e.who }}{% endif %} + {% set web = e.url.lower().startswith(('http://', 'https://')) %} + <{{ 'a' if web else 'div' }} class="desk-mark{% if e.pinned %} is-pinned{% endif %}"{% if web %} href="{{ e.url }}" target="_blank" rel="noopener"{% endif %}> + {{ e.desc }}{% if e.who %}{{ e.who }}{% endif %} {% endfor %} all {{ bookmarks_total }} on the board →
diff --git a/booth/templates/marks.html b/booth/templates/marks.html index 1ef1668..4ecd97c 100644 --- a/booth/templates/marks.html +++ b/booth/templates/marks.html @@ -12,12 +12,16 @@ {# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2). This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which read a half-answered pick as closed. #} - {% if marks_open %}{{ marks_open }} open · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }} + {% if marks_open %}{{ marks_open }} open · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}
+{# One region around both branches, so answering the last mark away swaps in + the empty state instead of reading as a structural change. #} +
{% if marks %} {% include "_marks.html" %} {% else %}
This booth has no marks.
{% include "_marks.html" %} {% endif %} +
{% endblock %} diff --git a/booth/templates/view.html b/booth/templates/view.html index 5fe6aca..eb74b50 100644 --- a/booth/templates/view.html +++ b/booth/templates/view.html @@ -138,7 +138,12 @@ transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)} .vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none; color:oklch(0.91 0.008 216)} - .vprev{left:0}.vnext{right:360px} + /* The next arrow clears the 360px verdict rail only while the rail sits + beside the stage. Scoped to the wide layout: stated bare, this rule came + later in the page than base.html's narrow override and silently won it, + parking the arrow 360px in from the edge of a phone. */ + .vprev{left:0}.vnext{right:0} + @media (min-width:901px){.vnext{right:360px}} .vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body); color:var(--text-body);white-space:pre-wrap} @media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}} diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index 1cfdee2..2ee041c 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -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-"`, 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 ``. @@ -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). diff --git a/tests/test_flow.py b/tests/test_flow.py index 7d7811c..df14bf0 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -113,7 +113,7 @@ def test_a_full_size_look_is_recorded_as_seen_and_a_non_item_is_not(tmp_path): for f in ("a.png", "c.png", "a.png", ".marks.lock"): c.get(f"/b/g/view?f={f}") assert read_seen(b) == {"a.png", "c.png"} - assert (b / ".seen").read_text() == "a.png\nc.png\n" # sorted, deduplicated + assert (b / ".seen").read_text() == '["a.png", "c.png"]' # sorted, deduplicated, JSON def test_seen_is_pruned_to_live_items_at_the_next_write(tmp_path): @@ -673,3 +673,127 @@ def test_a_full_size_look_also_counts_as_looking_at_the_booth(tmp_path): assert not (b / ".viewed").exists() _client(tmp_path).get("/b/g/view?f=a.png") assert (b / ".viewed").exists() and (b / ".seen").exists() + + +def test_a_flag_on_a_file_that_is_gone_stays_visible_and_withdrawable(tmp_path): + """Nyx N3 (2/4): the tray only shows live items, and `tray` being always + defined killed the old list fallback — so a flag whose file was deleted + rendered NOWHERE on the booth page while the Desk still counted it. It is + now listed apart, with its withdraw control; the Desk counts live items.""" + b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG}) + set_flag(b, "a.png", True) + set_flag(b, "b.png", True) + (b / "b.png").unlink() + c = _client(tmp_path) + aside = _region(c.get("/b/g/").text, "verdict") + orphans = re.search(r'class="orphan-flags".*?', aside, re.S) + assert orphans and "b.png" in orphans.group(0) + assert 'action="/b/g/unmark"' in orphans.group(0) + row = re.search(r'data-booth="g".*?', c.get("/").text, re.S).group(0) + assert "1 flagged" in row + + +def test_a_planted_fifo_or_device_seen_marker_cannot_hang_the_review(tmp_path): + """Nyx N4 (3/4): `.seen` was read with an unbounded, symlink-following + read_text(). A FIFO with no writer blocked the worker forever; a symlink to + /dev/zero read until memory ran out. The read now refuses anything that is + not a small regular file, without following a link.""" + import os + import threading + b = _booth(tmp_path, "g", {"a.png": PNG}) + os.mkfifo(b / ".seen") + out = {} + t = threading.Thread(target=lambda: out.setdefault( + "r", _client(tmp_path).get("/b/g/view?f=a.png")), daemon=True) + t.start() + t.join(timeout=5) + assert "r" in out, "the review hung on a FIFO .seen" + assert out["r"].status_code == 200 + h = _booth(tmp_path, "h", {"a.png": PNG}) + (h / ".seen").symlink_to("/dev/zero") + assert _client(tmp_path).get("/b/h/view?f=a.png").status_code == 200 + + +def test_seen_round_trips_names_with_spaces_and_newlines(tmp_path): + """Nyx (groa, hulda): one stripped line per rel lost ` a.png` and split a + name holding a newline into two identities. The marker is a JSON array.""" + from booth.items import read_seen + b = _booth(tmp_path, "g", {"a.png": PNG, " a.png": PNG, "x\ny.png": PNG}) + c = _client(tmp_path) + c.get("/b/g/view", params={"f": " a.png"}) + c.get("/b/g/view", params={"f": "x\ny.png"}) + assert read_seen(b) == {" a.png", "x\ny.png"} + + + +def test_a_deeply_nested_seen_marker_reads_as_nothing_seen(tmp_path): + """A JSON array nested past the parser's recursion limit raises + RecursionError, which is not a ValueError: a 100 KB file of `[` planted as + `.seen` escaped the never-raises read and 500'd every review of the booth. + It reads as nothing seen, and the next look rewrites it.""" + from booth.items import read_seen + b = _booth(tmp_path, "g", {"a.png": PNG}) + (b / ".seen").write_text("[" * 100_000) + assert read_seen(b) == set() + assert _client(tmp_path).get("/b/g/view?f=a.png").status_code == 200 + assert read_seen(b) == {"a.png"} + +def test_the_content_clock_reads_the_booth_not_what_its_links_point_at(tmp_path): + """Nyx (groa, regin): stat() followed a symlink, so a link to a busy file + outside the booth made the booth read as newly delivered on every load; and + one unreadable entry (a symlink loop) made the whole booth read as landed + NOW, forever. The link's own mtime counts; an unreadable entry is skipped.""" + import os + t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug + outside = tmp_path / "busy.log" + outside.write_text("x") + b = _booth(tmp_path, "g", {"a.png": PNG}) + (b / "linked.png").symlink_to(outside) + (b / "loop.png").symlink_to(b / "loop.png") + for p in (b / "a.png", b / "linked.png", b / "loop.png"): + os.utime(p, (t0, t0), follow_symlinks=False) + _at(b, t0) + c = _client(tmp_path) + c.get("/b/g/") # look at it + os.utime(outside, None) # the outside file keeps moving + assert _desk(c.get("/").text).get("rest") == ["g"] + + +def test_a_nul_in_the_review_path_is_a_404_not_a_500(tmp_path): + """Nyx (groa, seat-probed): Path raises ValueError on an embedded NUL, and + the route caught only OSError. Every other hostile `f` is a 404.""" + _booth(tmp_path, "g", {"a.png": PNG}) + assert _client(tmp_path).get("/b/g/view?f=a%00.png").status_code == 404 + + +@pytest.mark.parametrize("q", ["inf", "1e999", "nan", "-inf"]) +def test_a_non_finite_q_is_malformed(q): + """Nyx (regin): float() parses inf and 1e999, and inf > 0 — a malformed + header slipped through to the 204. Non-finite q is malformed: False.""" + from booth.app import wants_json + assert wants_json(f"application/json;q={q}") is False + + +def test_a_sound_only_booth_can_open_the_review(tmp_path): + """Nyx (groa): only the image tile linked to view?f=, so a booth of tracks + had no way into the review, the tape or `.seen`. Every media tile links in + (and Enter on the grid cursor follows that link).""" + _booth(tmp_path, "g", {"a.mp3": b"ID3", "b.webm": b"\x1aE"}) + body = _client(tmp_path).get("/b/g/").text + for rel in ("a.mp3", "b.webm"): + fig = re.search(r']*data-item="%s".*?' % re.escape(rel), body, re.S).group(0) + assert f'href="view?f={rel}"' in fig, rel + + +def test_the_desk_never_makes_a_non_web_url_clickable(tmp_path): + """Nyx (kimi): bookmark and bench URLs are agent-written and land in href. + Autoescape does nothing about a `javascript:` scheme. The Desk links only + http(s) and shows anything else as plain text.""" + rows = (_link("evil", "javascript:alert`1`") + + _link("fine", "https://example.test/")) + board = _booth(tmp_path, "links", {"links.md": rows.encode()}) + (board / ".forever").write_bytes(b"") + body = _client(tmp_path).get("/").text + panel = re.search(r'data-panel="bookmarks".*?', body, re.S).group(0) + assert 'href="javascript:' not in panel + assert 'href="https://example.test/"' in panel and "evil" in panel diff --git a/tests/test_flow_browser.py b/tests/test_flow_browser.py index 15a34ea..78daedd 100644 --- a/tests/test_flow_browser.py +++ b/tests/test_flow_browser.py @@ -250,3 +250,127 @@ def test_on_a_narrow_screen_flags_and_notes_fold_and_on_a_wide_one_they_show(bro page.locator(".verdict summary.v-fold-head").first.click() assert tray.is_visible() ctx.close() + + +# ---- fixups from the heid bug-hunt panel (round "Nyx") ------------------------ + +def test_the_link_board_still_confirms_before_removing_a_row(browser, live): + """Nyx N1 (2/4): the R2 rewrite of booth.html's scripts deleted the board's + multi-select + confirmation script along with the handlers it replaced. + Removing a row is destructive; the confirm naming it must still stand in + front of the POST, and select-all must still select.""" + base, root = live + board = root / "links" + board.mkdir() + (board / "links.md").write_text( + "- [one](http://x/1) · a · 2026-09-01 10:00\n" + "- [two](http://x/2) · a · 2026-09-01 10:01\n") + page = browser.new_page() + page.goto(f"{base}/b/links/", wait_until="networkidle") + dialogs = [] + page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss())) + page.locator(".board-rm-btn").first.click() + page.wait_for_timeout(300) + page.locator("#board-selall").check() + ticked = page.eval_on_selector_all(".board-check", "els => els.filter(e => e.checked).length") + page.close() + assert dialogs and "Remove this link?" in dialogs[0] + assert ticked == 2 + assert (board / "links.md").read_text().count("- [") == 2, "a dismissed confirm removed nothing" + + +def test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once(browser, live): + """Nyx N5 (hulda, regin, groa): a picked-but-unsent radio was reset by any + other in-place save, drafts were matched by POSITION, and a double-click on + Add note wrote two notes. Now: dirty controls carry by identity, and a form + already in flight ignores a second submit.""" + from booth.marks import declare_pick, marks_for + base, root = live + b = _set(root, 3) + declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}) + page = browser.new_page(viewport={"width": 1400, "height": 900}) + page.goto(f"{base}/b/g/", wait_until="networkidle") + page.locator('.verdict input[type=radio][value="y"]').check() + page.locator('figure.item[data-item="02.png"] .flagtoggle button').click() + page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000) + assert page.locator('.verdict input[type=radio][value="y"]').is_checked() + + page.locator(".verdict .mark-add textarea").fill("once") + page.locator(".verdict .mark-add button").dblclick() + page.wait_for_timeout(1500) + page.close() + assert [m.text for m in marks_for(b) if m.shape == "note"] == ["once"] + + +# The first page refresh after a save is held back 1s in the CLIENT: the server +# renders it at once (so it carries only the first flag) and the browser sees +# it late. Localhost alone never loses that race, so without the hold the test +# passed with sequencing deleted — it has to be forced to be a control. +_HOLD_FIRST_REFRESH = """ +(function () { + var real = window.fetch, n = 0; + window.fetch = function (u, o) { + var p = real.apply(this, arguments); + if ((!o || !o.method || o.method === 'GET') && n++ === 0) { + return p.then(function (r) { + return new Promise(function (res) { setTimeout(function () { res(r); }, 1000); }); + }); + } + return p; + }; +})(); +""" + + +def test_quick_successive_flags_all_show(browser, live): + """Nyx (hulda): with no sequencing, an older refresh landing after a newer + one showed the newer flag as gone. Saves are serialized.""" + base, root = live + _set(root, 4) + page = browser.new_page(viewport={"width": 1400, "height": 900}) + page.add_init_script(_HOLD_FIRST_REFRESH) + page.goto(f"{base}/b/g/", wait_until="networkidle") + for rel in ("01.png", "02.png", "03.png"): + page.locator(f'figure.item[data-item="{rel}"] .flagtoggle button').click() + page.wait_for_timeout(150) + page.wait_for_function( + "document.querySelectorAll('figure.item.is-flagged').length === 3", timeout=10000) + page.wait_for_timeout(1500) + n = page.locator("figure.item.is-flagged").count() + page.close() + assert n == 3 + + +def test_the_standalone_marks_page_updates_in_place(browser, live): + """Nyx (kimi): the marks page's forms are in-place, but the page had no + region, so an answer saved and the page never showed it.""" + from booth.marks import declare_pick + base, root = live + b = _set(root, 1) + declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}) + page = browser.new_page() + page.goto(f"{base}/b/g/marks", wait_until="networkidle") + page.evaluate("window.__same_page = 1") + page.locator('input[type=radio][value="x"]').check() + page.locator(".mark-submit").click() + page.wait_for_selector(".mark.is-answered", timeout=10000) + # In place, not the reload fallback: the page's own window survived. + same = page.evaluate("window.__same_page === 1") + page.close() + assert same + + +def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live): + """Nyx: view.html's bare `.vnext{right:360px}` came later in the page than + base.html's narrow override and won it, parking the arrow 360px in from + the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge.""" + base, root = live + _set(root, 3) + rights = {} + for w in (1400, 390): + page = browser.new_page(viewport={"width": w, "height": 900}) + page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle") + rights[w] = page.evaluate( + "getComputedStyle(document.querySelector('.vnav.vnext')).right") + page.close() + assert rights == {1400: "360px", 390: "0px"}