Files
booth/docs/contracts/r2_flow.contract.md
T
vh 704e8cd809 fix(desk): the heid bug-hunt panel on the row controls (round "Slate", 4/4)
- Touch: on a coarse pointer every row control is at least 28px square
  again (32px), and wipe stands clear of the zip link. The move onto the
  facts line had dropped the deliberate 28px floor to ~21px, 4-6px from
  zip; with scripts off no confirm fires, so a mis-tap on wipe is the
  delete. The zip link no longer breaks between its glyph and its word,
  and each separator is glued to the item after it.
- The wipe dialog shows the name as it should be read: control and bidi
  formatting characters in an agent-made name show as U+FFFD, so U+202E
  or a newline cannot rewrite what the operator approves. An unknown
  data-confirm word now prompts generically instead of submitting
  unguarded (fail closed).
- No page scrolls sideways: `code` wraps anywhere, so a long unbreakable
  install path in the footer or the empty Desk no longer widens every
  page. The overflow test now sweeps 390/720/850/1000/1400 with the
  heaviest row the Desk draws, and compares scrollWidth with the page's
  own clientWidth.

Its first fixture used a hyphenated path, which wrapped by itself; the
test passed with the bug present until the path became one unbreakable
run. r2_flow.toml: 27/27 proved. 749 passed.
2026-09-23 11:27:34 -07:00

33 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": a UTF-8 JSON array of rels. Not one rel per line, .blurred's shape: a file name may contain a newline, and a line format would split one such rel into two, neither of them real.
    • 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 and NEVER RAISES. It opens without following a symlink and without blocking, reads only a regular file of at most 1 MiB, and keeps only the array's string members. Anything else — a link, a FIFO, a directory, an oversized, malformed or too-deeply-nested file — reads as the empty set. .seen sits in an agent-writable directory, and a planted FIFO must not hang the review route.
  • 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. A q-value that is not a finite number (q=nan, q=inf) fails to parse.
  • 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 standalone marks page: the header's open count (booth-status) and the panel (marks-panel), one region around both its states so answering the last mark away swaps in the empty state.
      • 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 TILE (item-*) 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.

    • Any OTHER difference in structure — a non-tile region in the response that the page lacks, or one the page has that the response lacks — or a page with no region to swap at all, is not patched: the script reloads with a GET, so what you see is the server's truth.

    • 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;
      • every DIRTY control: a half-typed or edited note, a radio picked and not yet sent.

      All of it is matched by IDENTITY, never by position: a form by its action and its hidden ask/target/mark/f fields, a control by its form plus its name (plus its value for a radio or checkbox), a disclosure by the pick or form it holds. A flag that adds a tray row above a draft must not move the draft into the wrong box. The form just sent is the exception: its fields come back as the server rendered them, and its disclosure comes back folded.

  3. Saves are SERIALIZED. Each save runs its POST, its GET and its swap before the next begins, so an older snapshot never lands after a newer one (three quick flags show three flags). A form already queued or in flight ignores another submit: a double-click writes one note, not two.
  4. 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 CURRENT items carrying a READABLE flag mark, shown on every Desk row that has any — flagged_targets(marks) intersected with the booth's item rels. flagged_targets(marks) is the ONE flag predicate. The Desk, the tray, the orphan list, the rail's flagged filter, the tiles, the filmstrip, the tape and the review button all read it, and an unreadable flag entry counts nowhere. A flag whose file has since been deleted is an ORPHAN: it counts on no Desk row, and the tray lists it (C5) so it can be cleared.
  • landed_at: the newest mtime among the booth's CONTENT — its regular files and symlinks with no dot-component in their path, each read by lstat. Deliberately not _newest_mtime (INV-5). Five 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.
    • A symlink counts by its OWN mtime — when it was placed — never its target's. A link to a busy file outside the booth must not make the booth read as newly delivered.
    • An empty booth landed at 0.0.
    • One unreadable entry is skipped. Reading the whole booth as landed NOW for one bad entry would pin it in 'new' forever.
    • A booth whose walk cannot run at all 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.
  • Agent-written URLs become links only when they are http(s). A bench URL or a bookmark with any other scheme renders as plain text. Autoescape stops markup, not a javascript: href.
  • 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.

A row's controls sit on its facts line, each beside the state it changes: release after "kept", ★ keep after a countdown or a hold, × wipe last. They are always visible, with no hover-only reveal: that was a column that reserved its room while invisible (operator, 2026-09-23, on the live Desk: "release and x take up space whether or not they're visible"), and touch has no hover. The side column renders only when the row has a badge, so a row without one reserves nothing. The forms, POST targets and data-confirm wording are unchanged.

  • On a coarse pointer every row control is at least 28px square, the floor it had as a column, and wipe stands clear of the zip link. With scripts off no confirm fires, so a mis-tap on wipe is the delete. A fine pointer keeps the compact line.
  • The confirm dialog shows the name as it should be READ. Control and bidi formatting characters in an agent-made name show as U+FFFD, so a U+202E or a newline cannot rewrite what the operator approves. A data-confirm word the page does not know still asks, generically: the prompt fails closed.
  • No page scrolls sideways at any width, including an install path with no break opportunity in the footer or the empty Desk (code wraps anywhere). Tested at 390, 720, 850, 1000 and 1400px with the heaviest row the Desk draws.

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.
    • Orphan flags — flags whose target is no longer an item — follow the tray, by target, each with its unmark form. A flag the page cannot show must still be clearable, or it counts in the rail forever.
  • 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.
  • An audio or video tile carries a review link to its review page. On those tiles a click drives the player, so without the link the review is reachable only by key.

C6 — the review (view.html, booth_view_file)

This applies to image, video and audio items. Docs keep doc.html.

A requested rel the filesystem cannot represent (a NUL byte, an over-long path) is a 404, as any other unknown rel is — never a 500.

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

Accepted risks (named, not fixed)

  • .seen is read-modify-write without a lock. Two reviews of the same booth racing can drop one rel from .seen. The cost is cosmetic — a frame shown unseen on the tape — and the next look repairs it; a lock would buy a cosmetic count at the price of a lock file the lifetime clock must ignore.
  • A .viewed symlink planted by an agent freezes 'new'. viewed_at reads it by lstat, and record_view refuses to write through it (O_NOFOLLOW), so the marker never moves again: once content lands after it, the booth reads as 'new' however often it is opened. It fails in the visible direction — shown, never hidden — and needs write access to the booth, which already buys worse. The remedy is deleting the link.
  • Item gains ordinal with no default. booth_items is the single construction site, keyword-only; a default would let a second site forget it silently (INV-1).

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.