Files
booth/docs/contracts/r2_flow.contract.md
T
vh ea44c18d42 docs(contract): r2 — fold booth-dev's items.py notes and the empty-section negative
Ordinal is appended, not inserted. The quote() guard stays, and skipped
items take no ordinal. Empty Desk sections do not render; this carries
forward the negative half of the kept-lane pair. The 1:1 toggle is bound
only when the stage is an image.
2026-09-23 08:18:01 -07:00

18 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.1-PROPOSED PROPOSED 2026-09-23 by design-dev. Ruled by the operator the same day in the `flow` mark on booth-flow-concepts (direction a_b, compare this_arc, voice plain, emblem no), relayed via Miranda → booth-dev, verbatim at docs/rulings/. Compare mode is NOT in this contract: it lands after this one as r3, as a view toggle over the same item record. booth.app + booth.items + templates (the review flow) Make the Booth a place where judgment happens rather than a place where files are shown. The operator's bar is 'did anything change when I opened it'. A reskin cannot clear that bar; this contract changes the flow. There are three surfaces and one plumbing change. THE DESK: the index triaged by what needs the operator. THE LIGHTBOX: a booth page with the set on the left and the verdict beside it. THE REVIEW: full size with the judgment on screen, a filmstrip, and seen-tracking. The plumbing is IN-PLACE JUDGMENT: a mark POST that does not reload the page or eject you from full size.
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 (FOUR assertions change: the kept-lane pair at L785-786 and L810-811. See 'Assertions that change')
tests/test_flow.py (NEW)
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 Item.ordinal, printed on every tile
5 the question stays beside the work the verdict aside (sticky)
6 reports keep their author's layout verbatim path untouched
7 listening sets are real review_chain includes audio and video
8 lifetime is not an organising principle the Desk drops the kept/ephemeral lanes

Components

C1 — Item.ordinal (items.py)

ordinal: int is the item's 1-based position in booth_items(booth), i.e. in sorted(rel) order over all items. It is assigned in the resolver loop, so no route derives it.

  • 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) from the review route, below the 404s and gated on the item record — the same gate record_view has.
    • Atomic replace, per invariant 5.
    • 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.
  • 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(request) -> bool is True only when the Accept header, split on commas with parameters stripped, contains the exact media type application/json.

  • Absent, empty, */* or application/* → 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.

  • f must name an item in the booth, else the landing falls back to the booth page.
  • The URL is built server-side from name + quote(f), never echoed, so this is not an open redirect.

The client: one small script in base.html, bound to forms marked data-inplace.

  1. POST the form with Accept: application/json.
  2. On 204, GET the current URL and replace the regions marked data-region="<id>" with the same-id regions from the response.
  3. On anything else, submit the form normally.

The server renders every state; the script only places it. This is U3's rule — a second renderer in JavaScript would be the same bug in a new language.

C4 — the Desk (index.html, app.index, list_booths)

list_booths gains four fields, all read in the one pass it already makes:

  • open_since: 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 count of flag marks.
  • landed_at: the newest mtime among the booth's NON-DOT entries — its content. Deliberately not _newest_mtime (INV-5).
  • viewed_at: the mtime of .viewed, or None.
  • preview: up to 4 image items as (url, blurred), first four in item order. A blurred one renders blurred, the same rule as the cover.

The index renders three sections, always in this order:

  1. Needs you — marks_open > 0, or hold == "unreadable".
    • 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. Unreadable booths have no open_since and sort after every booth that has one.
  2. New since you looked — not in (1), and viewed_at is None or landed_at > viewed_at. Ordered by (-landed_at, name), newest first.
  3. Everything else — ordered by (-mtime, name), where mtime is today's _newest_mtime: last activity first.

The side column holds:

  • Benches: read_benches(data_dir), non-retired, in the registry's existing order. Its error return renders as an error line, never as an empty list. This is the booth page's rule: damaged and absent must not render the same.
  • Bookmarks come from the board the CLI writes: the booth named by BOOTH_LINKS_BOARD, 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.
  • 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 tile's thumbnail and 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, the grid additionally renders an inline group header before each group's first tile. 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>.
    • 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:
    • #NN of M, and position within the group;
    • the caption;
    • the flag form (back=view);
    • notes and the add-note form (back=view);
    • any open pick TARGETING this item, answerable here (back=view);
    • the booth's other open picks as a count and a link.
  • The filmstrip is review_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: seen count, flag tray, and every open booth-level pick answerable in place.
  • Keys (additive; editable targets keep their keys, as today):
    key action
    ← → and Space move
    F flag
    N focus the note
    Esc back to the grid, at #item-<url> so the grid scrolls to where you were

Invariants

  • INV-1 — one resolver. 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 (-mtime, name)
    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 and the in-place swap.

  • INV-4 — 303 byte-identity. For a request where wants_json is False, each mark route's response (status, headers, body) is byte-identical to its pre-R2 response. This includes the two existing back landings.

  • 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-viewer and client-side. Copy keeps admitting it is cosmetic.

Assertions that change (declared before the code, per CLAUDE.md)

test today after R2 why
test_booth.py L785 class="grid kept-grid" present when a booth is kept absent; the kept booth appears in its Desk section with the kept lifetime line requirement 8: the lanes sort nothing
test_booth.py L786 class="card card-kept" present replaced by the row carrying data-kept="1" same
test_booth.py L810-811 lane absent when nothing is kept unchanged in spirit (no lane), and trivially true same

Every other existing assertion is expected to survive, and one of the TDD slices is "the whole suite green before any new test". Named because they were checked:

  • the vnav vprev / vnav 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.
  • The link-board page (/b/links/) beyond CSS.