{% if marks %}
{% include "_marks.html" %}
{% else %}
This booth has no marks.
{% include "_marks.html" %}
{% endif %}
+
{% endblock %}
diff --git a/booth/templates/view.html b/booth/templates/view.html
index 5fe6aca..eb74b50 100644
--- a/booth/templates/view.html
+++ b/booth/templates/view.html
@@ -138,7 +138,12 @@
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
color:oklch(0.91 0.008 216)}
- .vprev{left:0}.vnext{right:360px}
+ /* The next arrow clears the 360px verdict rail only while the rail sits
+ beside the stage. Scoped to the wide layout: stated bare, this rule came
+ later in the page than base.html's narrow override and silently won it,
+ parking the arrow 360px in from the edge of a phone. */
+ .vprev{left:0}.vnext{right:0}
+ @media (min-width:901px){.vnext{right:360px}}
.vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
color:var(--text-body);white-space:pre-wrap}
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md
index 1cfdee2..2ee041c 100644
--- a/docs/contracts/r2_flow.contract.md
+++ b/docs/contracts/r2_flow.contract.md
@@ -102,7 +102,9 @@ no route derives it.
- 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`.
+- **`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
@@ -120,7 +122,12 @@ no route derives it.
items.
- NEVER RAISES, like `record_view`: failing to record a look costs the
marker, not the page.
-- `read_seen(booth) -> set[str]` is lenient, like `read_blurred`.
+- `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
@@ -142,7 +149,8 @@ exactly `application/json` and whose q-value is absent or greater than 0.
- 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.
+- 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
@@ -177,24 +185,42 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
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 region absent from the response is left alone and never deleted.
- Deleting it would shift every tile after it under the reader's eye. It
- is marked `is-stale` so it does not pass for current: un-flagging under
- `?filter=flagged` is the case. The next navigation drops it.
+ - 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;
- - unsaved drafts.
+ - every DIRTY control: a half-typed or edited note, a radio picked and
+ not yet sent.
- The form just sent is the exception: its field comes back empty, and its
+ 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. **The script never re-POSTs.** A retry after a lost response would re-apply
+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
@@ -218,20 +244,29 @@ rule — a second renderer in JavaScript would be the same bug in a new language
legacy-import stamp, sort wrong as text.
- An unparseable stamp sorts AFTER every parseable one, and name breaks the
tie.
-- **`flags`**: the number of items carrying a READABLE flag mark, shown on
- every Desk row that has any. `flagged_targets(marks)` is the ONE flag
+- **`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 filmstrip, the tape and the review button
- all read it, and an unreadable flag entry counts nowhere.
-- **`landed_at`**: the newest mtime among the booth's CONTENT — its REGULAR
- FILES with no dot-component in their path. **Deliberately not
- `_newest_mtime`** (INV-5). Three refinements, each load-bearing:
+ 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.**
- - **An unreadable booth reads as NOW.** It is shown as new rather than
- hidden as old.
+ - **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.
@@ -270,6 +305,9 @@ The side column holds:
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
@@ -312,6 +350,9 @@ unchanged) remain on every row.
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
@@ -327,11 +368,17 @@ unchanged) remain on every row.
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
Each tile is `data-region="item-