docs(contract): r2 — the review flow: the Desk, the lightbox, the review
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.
This commit is contained in:
@@ -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 <form method=post>. 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=<rel>`. It lands on
|
||||||
|
`/b/<name>/view?f=<quote(rel)>#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="<id>"` 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
|
||||||
|
`<div>` spanning the grid, never a `figure.item`, so the keyboard and the
|
||||||
|
order check are blind to it by construction.
|
||||||
|
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
|
||||||
|
Each tile is `data-region="item-<url>"`, 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-<url>` 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.
|
||||||
Reference in New Issue
Block a user