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.
18 KiB
contract_version, status, module, purpose, depends_on, language, complexity, estimated_loc, confidence, used_by, touches, assumptions, open_questions
| contract_version | status | module | purpose | depends_on | language | complexity | estimated_loc | confidence | used_by | touches | assumptions | open_questions | |||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0.1-PROPOSED | 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. | booth.app + booth.items + templates (the review flow) | 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. |
|
python + jinja + a little javascript | high | 900 | 0.6 |
|
|
|
|
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.
-
ordinalis appended as the dataclass's last field, never inserted. Mid-dataclass insertion is a positional-construction break, andgrouphas already had that conversation. -
The resolver's
quote()guard on non-UTF-8 names stays exactly as it is. It looks like a straytryaround 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=flaggeda 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 replacesimage_chainas 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_chainstays 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 gaterecord_viewhas. - Atomic replace, per invariant 5.
- NEVER RAISES, like
record_view: failing to record a look costs the marker, not the page.
- Written by
read_seen(booth) -> set[str]is lenient, likeread_blurred.- Seen is UI state, not judgment. It is not exposed in
marks.jsonand it holds nothing.- It adds no lifetime RULE. Being a dotfile, its write does move
_newest_mtime. So does the.viewedwrite on the same request, so a review page ages a booth exactly as it does today.
- It adds no lifetime RULE. Being a dotfile, its write does move
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,
*/*orapplication/*→ 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, sameLocation, 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.
fmust 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.
- POST the form with
Accept: application/json. - On 204, GET the current URL and replace the regions marked
data-region="<id>"with the same-id regions from the response. - 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: thecreatedof the OLDEST open pick in the booth, or None. Computed viaopen_marks, INV-2.Mark.createdis a STRING. It is parsed withdatetime.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:
- Needs you —
marks_open > 0, orhold == "unreadable".- A damaged
.marks.jsonholds its booth but is not open byopen_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 noopen_sinceand sort after every booth that has one.
- A damaged
- New since you looked — not in (1), and
viewed_at is Noneorlanded_at > viewed_at. Ordered by(-landed_at, name), newest first. - Everything else — ordered by
(-mtime, name), wheremtimeis 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, defaultlinks. 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.
- Shown: rows that are not booth URLs (
- 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.
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.mdkeeps today's single column. - The aside holds, top to bottom:
- open picks (the existing
_marks.htmlpick rendering); - the flag tray;
- notes;
- the booth-note form.
- open picks (the existing
- 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
.railclass (booth.html's cursor and base.html's--rail-hscript both read it), same filter hrefs, same group anchors. Whenrail.groupsis non-empty, the grid additionally renders an inline group header before each group's first tile. It is a<div>spanning the grid, never afigure.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 isdata-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 ONLY.
- The toggle and its script are rendered and bound only when the stage is
an
<img>. - 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 toggle and its script are rendered and bound only when the stage is
an
- 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_chainin order, with ordinals, flagged frames underlined and the current frame in the reticle. - The tape (B's device) is one segment per
review_chainitem: 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.
ordinalis set inbooth_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_displayThe 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_jsonis False, each mark route's response (status, headers, body) is byte-identical to its pre-R2 response. This includes the two existingbacklandings. -
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
|safeon 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 vnextanchors (test_booth L569-591 and test_navigation L337) keep their classes and hrefs; Wipe nowstays 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.