diff --git a/booth/app.py b/booth/app.py
index d054e9f..1e6d841 100644
--- a/booth/app.py
+++ b/booth/app.py
@@ -41,6 +41,7 @@ import os
import re
import secrets
import shutil
+import tempfile
import time
import zipfile
from contextlib import asynccontextmanager
@@ -87,6 +88,10 @@ from booth.items import ( # noqa: E402,F401
doc_kind,
find_item,
image_chain,
+ review_chain,
+ REVIEW_KINDS,
+ SEEN_FILE,
+ read_seen,
read_blurred,
render_doc,
render_doc_body,
@@ -319,6 +324,37 @@ def record_view(booth: Path) -> None:
pass
+def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
+ """Note that `rel` was looked at full size (R2 C2).
+
+ Rewrites the whole marker — the previous set plus `rel`, pruned to rels that
+ are still items, sorted — so it is deduplicated and never outgrows the
+ booth. Atomic replace (CLAUDE.md invariant 5) through a temp file created
+ with O_EXCL: a planted `.seen.tmp` symlink cannot redirect the write, and
+ `os.replace` swaps a planted `.seen` symlink out rather than writing
+ through it.
+
+ NEVER RAISES, for `record_view`'s reason: not recording a look is a cost
+ this service can absorb, not answering the request is not.
+ """
+ try:
+ live = {it.rel for it in items}
+ seen = (read_seen(booth) | {rel}) & live
+ fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth)
+ try:
+ with os.fdopen(fd, "w") as fh:
+ fh.write("".join(f"{r}\n" for r in sorted(seen)))
+ os.replace(tmp, booth / SEEN_FILE)
+ except BaseException:
+ try:
+ os.unlink(tmp)
+ except OSError:
+ pass
+ raise
+ except OSError:
+ pass
+
+
HOLD_UNREADABLE = "unreadable"
HOLD_OPEN = "open"
@@ -522,6 +558,8 @@ def build_gallery(child: Path) -> list[dict]:
"section": it.section,
# U7. Derived in the resolver (INV-1); this only carries it.
"group": it.group,
+ # R2 C1. Same rule: the resolver numbers, this carries.
+ "ordinal": it.ordinal,
"caption": it.caption,
"rendered": rendered,
"rendered_html": rendered_html,
@@ -1485,6 +1523,10 @@ def create_app(
# of a thing that is not an item is not a view of the booth.
if item is not None:
record_view(booth)
+ # R2 C2: WHICH item was looked at — media only, the ring the tape
+ # draws. Same gate as the view above, and it never raises either.
+ if item.kind in REVIEW_KINDS:
+ record_seen(booth, item.rel, items)
marks = marks_for(booth)
item_marks = marks_for_target(marks, f)
common = {
@@ -1505,8 +1547,10 @@ def create_app(
}
if item is not None and item.kind == "image":
- # prev/next ring (wraps; only when there is more than one image)
- names = image_chain(items)
+ # prev/next ring (wraps; only when there is more than one item in
+ # it). R2 C2: the ring is `review_chain` — the item order filtered to
+ # MEDIA — so a set that mixes pictures and sound steps through both.
+ names = review_chain(items)
prev_url = next_url = None
if f in names and len(names) > 1:
i = names.index(f)
diff --git a/booth/items.py b/booth/items.py
index 4f33621..8b93a10 100644
--- a/booth/items.py
+++ b/booth/items.py
@@ -95,6 +95,27 @@ class Item:
blurred: bool
doc: str | None
size: int
+ # R2 C1: the 1-based position in `booth_items` order over ALL items — the
+ # number the operator means by "the third one". Set in the resolver loop
+ # and nowhere else (INV-1). APPENDED, never inserted: a mid-dataclass field
+ # is a positional-construction break.
+ ordinal: int
+
+
+# R2 C2: which items have been looked at full size. UI state, not judgment —
+# never exposed to sessions, holds nothing. One viewer: this records WHAT was
+# seen, never who saw it.
+SEEN_FILE = ".seen"
+
+
+def read_seen(booth: Path) -> set[str]:
+ """Rels seen at full size. Missing or unreadable file -> empty set; a
+ damaged marker costs the tape its memory, never the page."""
+ try:
+ text = (booth / SEEN_FILE).read_text()
+ except (OSError, UnicodeDecodeError):
+ return set()
+ return {ln.strip() for ln in text.splitlines() if ln.strip()}
def read_blurred(booth: Path) -> set[str]:
@@ -273,6 +294,10 @@ def booth_items(booth: Path) -> list[Item]:
blurred=rel in blurred,
doc=doc_kind(p.name),
size=size,
+ # Counted over items that RENDER: a caption sidecar or a name
+ # the quote() guard skipped takes no number, so the numbers
+ # stay contiguous over what the operator can see.
+ ordinal=len(items) + 1,
)
)
return items
@@ -287,6 +312,18 @@ def image_chain(items: Sequence[Item]) -> list[str]:
return [it.rel for it in items if it.kind == "image"]
+# R2 C2: what the review route steps through. ONE LINE: the item order
+# filtered to media. It is a declared change to the zoom-ring rule, which was
+# images only: a listening set is reviewed the same way a picture set is.
+REVIEW_KINDS = ("image", "video", "audio")
+
+
+def review_chain(items: Sequence[Item]) -> list[str]:
+ """The rels of the media items, in item order — the review's prev/next ring,
+ its filmstrip and its tape."""
+ return [it.rel for it in items if it.kind in REVIEW_KINDS]
+
+
def find_item(items: Sequence[Item], rel: str) -> Item | None:
"""The record for one rel, or None — the zoom/doc route's entry point."""
for it in items:
diff --git a/booth/templates/booth.html b/booth/templates/booth.html
index 58a5e65..c4e57cd 100644
--- a/booth/templates/booth.html
+++ b/booth/templates/booth.html
@@ -53,6 +53,13 @@
{%- endmacro %}
+{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so
+ a column of them lines up. Width reads `all_items`, never the filtered list:
+ a filter must not change how a number is written any more than which. #}
+{% macro ordinal(it) -%}
+ #{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }}
+{%- endmacro %}
+
{% block title %}{{ name }} · The Booth{% endblock %}
{% block content %}
@@ -310,6 +317,7 @@
▸
+ {{ ordinal(it) }}
{{ it.name }}⤢
@@ -333,6 +341,7 @@
{% else %}
+ {{ ordinal(it) }}
{% if it.blurred %}
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
diff --git a/tests/test_flow.py b/tests/test_flow.py
new file mode 100644
index 0000000..ce616eb
--- /dev/null
+++ b/tests/test_flow.py
@@ -0,0 +1,153 @@
+"""R2 — the review flow: the Desk, the lightbox, the review.
+
+Contract: docs/contracts/r2_flow.contract.md. Tests are grouped by the
+contract's components (C1-C7) and named for the behaviour they pin.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import re
+import sys
+
+import pytest
+from fastapi.testclient import TestClient
+
+sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
+
+from booth.app import create_app # noqa: E402
+from booth.items import booth_items # noqa: E402
+from booth.marks import set_flag # noqa: E402
+
+PNG = b"\x89PNG\r\n\x1a\n"
+
+
+def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path:
+ b = root / name
+ b.mkdir()
+ for rel, data in files.items():
+ p = b / rel
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_bytes(data)
+ return b
+
+
+def _client(root: pathlib.Path) -> TestClient:
+ return TestClient(create_app(root, ttl_hours=24, start_sweeper=False))
+
+
+def _ordinals(body: str) -> dict[str, str]:
+ """rel -> the ordinal text its tile prints, in render order."""
+ out = {}
+ for fig in re.findall(r']*>.*?', body, re.S):
+ rel = re.search(r'data-item="([^"]+)"', fig).group(1)
+ m = re.search(r'class="ord"[^>]*>#(\d+)<', fig)
+ out[rel] = m.group(1) if m else None
+ return out
+
+
+# ---- C1: Item.ordinal ------------------------------------------------------
+
+def test_a_filtered_tile_keeps_its_number_in_the_whole_set(tmp_path):
+ """The tracer. b.png is the second item of three; under ?filter=flagged it
+ is the ONLY tile rendered and must still print #2, because the number is a
+ property of the item, not of the view."""
+ b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG})
+ set_flag(b, "b.png", True)
+ body = _client(tmp_path).get("/b/g/?filter=flagged").text
+ assert _ordinals(body) == {"b.png": "2"}
+
+
+def test_ordinals_count_rendered_items_only(tmp_path):
+ """A caption sidecar is not an item and takes no number, so the numbers
+ stay contiguous over what the operator can see. And an undecodable name
+ that the quote() guard skips takes none either — it is not rendered."""
+ b = _booth(tmp_path, "g", {"a.png": PNG, "a.png.txt": b"cap", "b.png": PNG,
+ "c.png": PNG})
+ import os
+ os.close(os.open(bytes(b) + b"/m\xff.png", os.O_CREAT | os.O_WRONLY, 0o644))
+ items = booth_items(b)
+ assert [(it.rel, it.ordinal) for it in items] == [("a.png", 1), ("b.png", 2), ("c.png", 3)]
+
+
+def test_ordinals_pad_to_the_width_of_the_whole_set(tmp_path):
+ """Twelve items: numbers are written two wide, so a column of them lines
+ up — and a filter showing only the first does not narrow it to #1."""
+ b = _booth(tmp_path, "g", {f"{n:02d}.png": PNG for n in range(1, 13)})
+ set_flag(b, "01.png", True)
+ c = _client(tmp_path)
+ assert _ordinals(c.get("/b/g/").text)["01.png"] == "01"
+ assert _ordinals(c.get("/b/g/").text)["12.png"] == "12"
+ assert _ordinals(c.get("/b/g/?filter=flagged").text) == {"01.png": "01"}
+
+
+# ---- C2: review_chain and .seen -------------------------------------------
+
+def test_the_review_ring_is_the_item_order_filtered_to_media(tmp_path):
+ """Images, video and audio, in set order. A doc is not in the ring: it
+ keeps its reading page."""
+ from booth.items import review_chain
+ b = _booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.md": b"# c",
+ "d.webm": b"\x1aE", "e.zip": b"PK"})
+ assert review_chain(booth_items(b)) == ["a.png", "b.mp3", "d.webm"]
+
+
+def test_the_review_route_rings_through_audio_in_set_order(tmp_path):
+ """From the only image, "next" is the audio track that follows it in the
+ set — today's image-only ring had nowhere to go."""
+ _booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.md": b"# c"})
+ body = _client(tmp_path).get("/b/g/view?f=a.png").text
+ assert 'class="vnav vnext" href="?f=b.mp3"' in body
+ assert 'class="vnav vprev" href="?f=b.mp3"' in body # a two-item ring wraps
+
+
+def test_a_full_size_look_is_recorded_as_seen_and_a_non_item_is_not(tmp_path):
+ """`.seen` answers WHICH items were looked at full size. Gated on the item
+ record like `record_view`: pointing `f` at a dotfile the service itself
+ wrote is not a look at anything."""
+ from booth.items import read_seen
+ b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG})
+ (b / ".marks.lock").write_bytes(b"")
+ c = _client(tmp_path)
+ for f in ("a.png", "c.png", "a.png", ".marks.lock"):
+ c.get(f"/b/g/view?f={f}")
+ assert read_seen(b) == {"a.png", "c.png"}
+ assert (b / ".seen").read_text() == "a.png\nc.png\n" # sorted, deduplicated
+
+
+def test_seen_is_pruned_to_live_items_at_the_next_write(tmp_path):
+ """A deleted file drops out of `.seen` the next time anything is seen, so
+ the marker never outgrows the booth and never counts a ghost."""
+ from booth.items import read_seen
+ b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
+ c = _client(tmp_path)
+ c.get("/b/g/view?f=a.png")
+ (b / "a.png").unlink()
+ c.get("/b/g/view?f=b.png")
+ assert read_seen(b) == {"b.png"}
+
+
+def test_a_planted_seen_symlink_is_replaced_not_written_through(tmp_path):
+ """Any fleet session can write into a booth. A `.seen` symlink aimed at a
+ file outside must not turn a page view into a write at that path."""
+ outside = tmp_path / "victim.txt"
+ outside.write_text("untouched")
+ b = _booth(tmp_path, "g", {"a.png": PNG})
+ (b / ".seen").symlink_to(outside)
+ r = _client(tmp_path).get("/b/g/view?f=a.png")
+ assert r.status_code == 200
+ assert outside.read_text() == "untouched"
+ assert not (b / ".seen").is_symlink()
+
+
+def test_a_look_that_cannot_be_recorded_still_serves_the_page(tmp_path):
+ """NEVER RAISES: a booth the service cannot write to costs the marker, not
+ the page."""
+ b = _booth(tmp_path, "g", {"a.png": PNG})
+ b.chmod(0o555)
+ try:
+ r = _client(tmp_path).get("/b/g/view?f=a.png")
+ finally:
+ b.chmod(0o755)
+ assert r.status_code == 200
+ assert not (b / ".seen").exists()