Files
booth/docs/contracts/r2_flow.contract.md
T
vh fa5d46443d fix(r2): the heid code-review panel (round "Wren", 4/4) — triaged and folded
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.
2026-09-23 09:41:18 -07:00

28 KiB
Raw Blame History

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.
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).
python + jinja + a little javascript high 900 0.6
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)
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 (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')
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.
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 (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.json and 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.

  • 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.

  • 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, 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 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.
  • 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 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(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, */* 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.
  • 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, 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.

  • 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.

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 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.
    • 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.

    • 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.

  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). 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.

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: 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 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.
  • 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. A booth held unreadable has no open_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.
  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 — in list_booths' own existing order: (mtime, name) descending, where mtime is today's _newest_mtime. That is last activity first, with name as the tie-break (test_the_index_order_has_a_tie_breaker pins 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).

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.

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-content shows the fold at every width: one tap, never hidden.
  • 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 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 group anchors. When rail.groups is 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 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 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 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 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 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 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: 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, select or contenteditable, the same isEditable guard 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 is
    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. 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 list_booths order: (mtime, name) descending
    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;
    • 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_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'.
    • 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-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 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. 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.