- 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/<name>/view?f=<rel>#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.
23 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 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. | 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 (within the set as it is now — not a durable id) | Item.ordinal, printed on every tile |
| 5 | the question stays beside the work | the verdict aside (sticky) |
| 6 | reports keep their author's layout | verbatim path untouched |
| 7 | listening sets are real | review_chain includes audio and video |
| 8 | lifetime is not an organising principle (it is still SHOWN as a fact on each row; it no longer GROUPS or SORTS) | the Desk drops the kept/ephemeral lanes |
Terms used below
- Reticle: the SVOS selection mark in base.html — four corner brackets drawn inside a box. It marks the one current or selected thing and nothing else.
- Tape: a row of small segments, one per item in the review ring, each showing seen, flagged or current.
- Stage: the area of the review page where the artifact itself renders.
- All Booth state files are dotfiles. That covers
.marks.json(MARKS_FILE),.viewed,.blurred,.seen,.forever,.pins,.booth.jsonand every*.lock. "Non-dot entries" means the posted content and nothing the Booth or the operator wrote.
Components
C1 — Item.ordinal (items.py)
ordinal: int is the item's 1-based position in booth_items(booth), i.e. in
sorted(rel) order over all items. It is assigned in the resolver loop, so
no route derives it.
-
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. - Each write rewrites the whole file: the previous set plus
rel, minus rels no longer inbooth_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..viewedand.seennever disagree about whether you looked at the booth;.seenonly adds WHICH items. - 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, contains an entry whose media type, parameters stripped, is
exactly application/json and whose q-value is absent or greater than 0.
- Absent, empty,
*/*orapplication/*→ 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.
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.
back=viewlands on the review only whenfnames an item inreview_chain, i.e. a media item. For anything else (a doc, a missing rel, an emptyf) the landing falls back to the booth page, exactly as a form with nobackdoes today.doc.htmlcarries no forms, so no shipped page sendsback=viewwith a doc.- The URL is built server-side from
name+quote(f), never echoed, so this is not an open redirect.
The client: one small script in base.html, bound to forms marked
data-inplace.
- POST the form with
Accept: application/json. - On 204, GET the current URL and replace every element carrying
data-region="<id>"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.
- 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.
- On a non-204 HTTP response, or a network failure, it writes a fixed
message into the page's server-rendered status element
(
The server renders every state; the script only places it. This is U3's rule — a second renderer in JavaScript would be the same bug in a new language.
C4 — the Desk (index.html, app.index, list_booths)
list_booths gains five fields, all read in the one pass it already makes:
open_since: 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, 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.
- A booth with no images (an audio set, a report) shows today's kind
placeholder instead (
The index renders three sections, always in this order:
- Needs you —
marks_open > 0, orhold == "unreadable".marks_opencountsopen_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.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.
- 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. - Everything else — ordered by
(-mtime, name), wheremtimeis 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).
- 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
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 as<details>, which needs no script. - 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 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
.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>. - 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 toggle and its script are rendered and bound only when the stage is
an
- The rail (
data-region="rail") holds:- the item's ordinal
#NN(its number in the whole set, the same number its tile shows); K of M, where K is its position inreview_chainand M is the length ofreview_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); - any open pick TARGETING this item, answerable here (
back=view); - the booth's other open picks as a count and a link.
- the item's ordinal
- 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). Every key here, new and old, is ignored while focus
is in an
input,textarea,selectorcontenteditable, the sameisEditableguard view.html carries today, so F never fires mid-note: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
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.
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;
- 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
<details>and needs no script. -
INV-4 — 303 byte-identity, for every request shape that existed before R2.
- For a request where
wants_jsonis False andbackis absent ormarks, each mark route's response (status, headers, body) is byte-identical to its pre-R2 response. back=viewis a NEW request shape with no pre-R2 counterpart. Its landing is specified in C3 and is the one declared exception.
- For a request where
-
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-BROWSER and client-side (nothing persisted). 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 | 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 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. 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.