Operator, on the live Desk: "release and x take up space whether or not they're visible." They sat in a side column at opacity 0, which hides a control and still reserves its box, and hover-only never worked on touch. Each control now sits on the facts line beside the state it changes: release after "kept", keep after a countdown or hold, wipe last. They are always visible and quiet, and wipe turns danger only under the pointer or focus. The side column renders only when the row carries a badge. The row is flex, so an absent column costs no gap. Forms, POST targets and data-confirm wording are unchanged. The flex row exposed a latent sizing bug: the stacked Desk column was a bare 1fr, whose minimum is its content's, so a long nowrap provenance line scrolled the page sideways at phone width (1029px at 390). It is now minmax(0,1fr). Both behaviours have browser tests, mutation-proved (r2_flow.toml: 21/21). Contract C4 amended.
539 lines
33 KiB
Markdown
539 lines
33 KiB
Markdown
---
|
||
contract_version: "0.2-BUILT"
|
||
status: "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."
|
||
module: "booth.app + booth.items + templates (the review flow)"
|
||
purpose: "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."
|
||
depends_on:
|
||
- "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)."
|
||
language: "python + jinja + a little javascript"
|
||
complexity: "high"
|
||
estimated_loc: 900
|
||
confidence: 0.6
|
||
used_by:
|
||
- "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)"
|
||
touches:
|
||
- "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')"
|
||
assumptions:
|
||
- "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."
|
||
open_questions:
|
||
- "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.
|
||
|
||
### 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.
|