Code fixes: - The narrow-screen fold was specified and never built (4/4). The tray and notes are now closed <details> in the aside; above 1000px CSS alone (::details-content) shows them and hides the summary. There is no script. Browser-tested at 390 and 1400, JS on and off. - The lightbox gated on parsed board rows, not page identity (3/4). It now uses is_board, the lesson the bench panel already carried. - wants_json returned True at the first good entry, so a malformed later entry was never read (3/4). It now parses every entry first; any error is False. - One flag predicate, flagged_targets. It serves the Desk count, the tray, the filmstrip, the tape and the review button. An unreadable flag entry counts nowhere. - The header's open count and lifetime line, and the no-set marks panel, are now regions (they were stale after an in-place answer). - Inline group headers render only when every group is one contiguous run. Interleaved directories no longer reprint or misfile headers. - A booth held unreadable has no open_since, even with a readable pick beside the damage. - The swap marks an absent region is-stale instead of leaving it looking current. It carries disclosure state (except the sent form's). The failure message is readable for 0.9 s before the reload. Contract amended where the code was right and the text was not: the wants_json and record_seen signatures, landed_at's three refinements, the group position being ring-based, the end of the set offering every other open pick, the Space-key player exception, and the fold mechanism. New tests cover the parse order; a board with media; the header region; the no-set panel; interleaved groups; mixed damage; the flag predicate; the review recording .viewed; the fold at two widths with JS on and off; the status message before the reload; a lost response after a landed write (exactly one note); a stale absent region; stage node identity across a swap; and F with a radio focused. The lost-response and stale tests turn red under their mutations. 724 passed.
28 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.2-BUILT | BUILT 2026-09-23 on design-dev/svos-retheme (C1-C7, TDD), awaiting heid code-review and bug-hunt before the hand-over to booth-dev. 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, items)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.itemsis the route's ownbooth_itemsresult. It is passed in so that the prune ("minus rels no longer inbooth_items") costs no second walk.- 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(accept: str | None) -> bool takes the raw Accept header, so
it is a pure function a test can call directly. It is True only when the
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. - 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.
- 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: the verdict aside, each tile, the rail (its filter
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 review: the rail, the filmstrip and the tape.
- On the lightbox: the verdict aside, each tile, the rail (its filter
counts change when you flag), and the header's open count and lifetime
line (
-
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-staleso it does not pass for current: un-flagging under?filter=flaggedis the case. The next navigation drops it. -
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.
The form just sent is the exception: its field comes back empty, and its disclosure comes back folded.
-
- 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). After a beat (0.9 s, so the words can be read) it 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 number of items carrying a READABLE flag mark, shown on every Desk row that has any.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:- 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. - An empty booth landed at 0.0.
- An unreadable booth reads as NOW. It is shown as new rather than hidden as old.
- Files only, never directories. Creating any dotfile (
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. A booth heldunreadablehas noopen_since— even when a readable pick sits beside the damage, because the damage is the thing to fix — and sorts 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 — in
list_booths' own existing order:(mtime, name)descending, wheremtimeis today's_newest_mtime. That is last activity first, with name as the tie-break (test_the_index_order_has_a_tie_breakerpins it). The Desk reuses that rule rather than stating a second one.- 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.- The markup is a CLOSED
<details>. - Above 1000px, CSS alone shows its content (
::details-content) and hides its summary, so nothing is folded where there is room. - A browser without
::details-contentshows the fold at every width: one tap, never hidden.
- The markup is a CLOSED
- 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 AND every group is one contiguous run in the rendered order, the grid additionally renders an inline group header before each group's first tile.- Groups come from basenames and the order from full paths, so groups can
interleave (
d1/aa,d1/bb,d2/aa). - A header would then either repeat or file an item under the wrong group,
so interleaved groups get no inline headers. The rail's jump links are
unaffected. It is a
<div>spanning the grid, never afigure.item, so the keyboard and the order check are blind to it by construction.
- Groups come from basenames and the order from full paths, so groups can
interleave (
- 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 review RING spans two or more groups (the gallery rail's own rule: one group for everything says nothing);
- 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: the seen count, the flag tray, and EVERY OTHER
open pick, answerable in place.
- That includes picks targeting other items, not only booth-level ones: the end of the set is where the remaining questions get cleared.
- Before the last item, the other picks are a count and a link.
- 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. Shift+Space moves back. Space is left to a focused <video>/<audio>player, whose own play key it isF 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 list_boothsorder:(mtime, name)descendingbookmarks 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_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 |
with JS on, the flag now applies in place (requirement 3). The gallery reload was the no-JS design working, not a defect, and the plain-form path is still pinned by the INV-4 golden. The defect R2 fixes is the full-size EJECTION, view.html's flag form carrying no back. 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) | — |
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.