"` 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.
+
+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
+ `` 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-"`, 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.
+- **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-` 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.