diff --git a/booth/app.py b/booth/app.py
index 2fe3b3d..f130d57 100644
--- a/booth/app.py
+++ b/booth/app.py
@@ -37,14 +37,19 @@ import asyncio
import fcntl
import hashlib
import io
+import json
+import math
import os
import re
import secrets
import shutil
+import stat
+import tempfile
import time
import zipfile
from contextlib import asynccontextmanager
from dataclasses import replace
+from datetime import datetime, timezone
from pathlib import Path
from typing import Sequence
from urllib.parse import quote, unquote
@@ -87,6 +92,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,
@@ -249,6 +258,62 @@ def _newest_mtime(path: Path) -> float:
return newest
+def _content_mtime(path: Path) -> float:
+ """`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
+ files and symlinks (by lstat) with no dot-component in their path. Deliberately NOT `_newest_mtime`
+ (INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
+ content, so none of them may make a booth read as newly landed.
+
+ Files only, never directories: creating `.viewed` bumps the booth
+ directory's own mtime, and counting that would make the first look at a
+ booth look like a delivery. An empty booth landed at 0.0. One unreadable
+ ENTRY is skipped; a booth whose walk cannot run at all reads as NOW, the
+ posture `_newest_mtime` takes and for a milder reason here: a booth we
+ cannot read is shown as new rather than hidden as old.
+ """
+ newest = 0.0
+ try:
+ for p in path.rglob("*"):
+ rel = p.relative_to(path)
+ if any(part.startswith(".") for part in rel.parts):
+ continue
+ try:
+ # lstat: a posted SYMLINK counts by its own mtime — when it was
+ # placed — never by its target's. A link to a busy file outside
+ # the booth must not make the booth read as newly delivered.
+ st = p.lstat()
+ except OSError:
+ # One unreadable entry costs that entry, not the booth: reading
+ # the whole booth as landed NOW would pin it in "new" forever.
+ continue
+ if (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)) and st.st_mtime > newest:
+ newest = st.st_mtime
+ except OSError:
+ return time.time()
+ return newest
+
+
+def _viewed_at(path: Path) -> float | None:
+ """The mtime of the booth's `.viewed` marker (U4), or None if it has never
+ been looked at. `lstat`, like `is_kept`: a planted symlink is read as the
+ marker it claims to be, never followed."""
+ try:
+ return os.lstat(path / VIEW_MARKER).st_mtime
+ except OSError:
+ return None
+
+
+def _stamp(created: str) -> datetime | None:
+ """A mark's `created` as an aware datetime, or None when it will not parse.
+ Strings are never compared: two ISO stamps with different offsets sort
+ wrong as text. A naive stamp is read as UTC."""
+ try:
+ dt = datetime.fromisoformat(created)
+ except (TypeError, ValueError):
+ return None
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
+
+
def booth_age_seconds(path: Path, now: float | None = None) -> float:
now = time.time() if now is None else now
return now - _newest_mtime(path)
@@ -319,6 +384,104 @@ 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
+ # A JSON array, UTF-8 explicitly: a rel may hold a newline or a leading
+ # space, and the host locale must not decide whether a name encodes.
+ body = json.dumps(sorted(seen), ensure_ascii=False).encode("utf-8", "surrogateescape")
+ fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth)
+ try:
+ with os.fdopen(fd, "wb") as fh:
+ fh.write(body)
+ os.replace(tmp, booth / SEEN_FILE)
+ except BaseException:
+ try:
+ os.unlink(tmp)
+ except OSError:
+ pass
+ raise
+ except (OSError, ValueError):
+ # ValueError covers an encode failure — it is not an OSError, and a
+ # look that cannot be recorded must never cost the page.
+ pass
+
+
+def wants_json(accept: str | None) -> bool:
+ """Whether a mark POST asked for the in-place answer (R2 C3).
+
+ True ONLY when the Accept header lists `application/json` exactly —
+ parameters stripped — with a q-value that is absent or above zero. Absent,
+ empty, wildcard, `application/*`, a near miss like `application/jsonx`, an
+ explicit `q=0`, a malformed q: all False. It FAILS TOWARD THE 303, because
+ the plain form's redirect is the no-JS guarantee and a mis-parse must land
+ there, never on a 204 a browser would render as nothing happening.
+ """
+ if not accept:
+ return False
+ # EVERY entry is parsed before anything is decided: returning True at the
+ # first good JSON entry meant a malformed one after it was never read, so
+ # `application/json, application/json;q=broken` was a 204 and the same pair
+ # reversed a 303 (heid code-review, 3/4). One unparseable entry anywhere
+ # makes the whole header False.
+ wanted = False
+ try:
+ for entry in accept.split(","):
+ mtype, *params = entry.split(";")
+ q = 1.0
+ for param in params:
+ key, _, value = param.partition("=")
+ if key.strip().lower() == "q":
+ q = float(value.strip())
+ if not math.isfinite(q):
+ # inf, 1e999, nan parse as floats but are not q-values
+ raise ValueError("non-finite q")
+ if mtype.strip().lower() == "application/json" and q > 0:
+ wanted = True
+ except ValueError:
+ return False
+ return wanted
+
+
+def flagged_targets(marks: Sequence[Mark]) -> set[str]:
+ """THE flag predicate (R2): the items carrying a READABLE flag mark. The
+ Desk count, the tray, the filmstrip, the tape and the review button all
+ read this, so they cannot disagree about one item. An unreadable flag
+ entry is judgment nobody can see, and counts nowhere."""
+ return {m.target for m in marks
+ if m.shape == "flag" and m.error is None and m.target}
+
+
+def _contiguous(keys: Sequence[str | None]) -> bool:
+ """True when each non-None key occupies ONE unbroken run of the sequence."""
+ seen: set[str] = set()
+ prev = object()
+ for k in keys:
+ if k != prev:
+ if k is not None and k in seen:
+ return False
+ if k is not None:
+ seen.add(k)
+ prev = k
+ return True
+
+
+# The Desk shows this many bookmarks and links to the board for the rest.
+BOOKMARKS_SHOWN = 8
+
HOLD_UNREADABLE = "unreadable"
HOLD_OPEN = "open"
@@ -464,6 +627,10 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
# rather than re-opening .blurred here.
thumb_blurred = it.blurred
mtime = _newest_mtime(child)
+ # R2 C4: the oldest question still owed an answer, PARSED. Unparseable
+ # stamps are left out, so a booth whose every open pick is unparseable
+ # has no `open_since` and sorts after every booth that has one.
+ stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st]
booths.append(
{
"name": child.name,
@@ -490,6 +657,23 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"hold": hold,
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": mtime,
+ # ---- R2 C4, the Desk. All from the pass above; no second read.
+ # A booth held for UNREADABLE marks has no `open_since`, even if
+ # a readable pick sits beside the damage: it sorts after every
+ # dated question, because the damage is what needs fixing.
+ "open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE
+ else None),
+ # items that EXIST: a flag on a file since deleted is shown on
+ # the booth page for withdrawal, not counted as a pick here
+ "flags": len(flagged_targets(marks) & {it.rel for it in items}),
+ # Two clocks, named apart (INV-5): `mtime` is activity,
+ # `landed_at` is content. "New since you looked" reads only the
+ # second, so a flag or a view never makes a booth look new.
+ "landed_at": _content_mtime(child),
+ "viewed_at": _viewed_at(child),
+ # The first four images in item order, as the originals shown
+ # small. Blurred ones stay blurred, the cover's rule.
+ "preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4],
}
)
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
@@ -522,6 +706,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,
@@ -559,9 +745,10 @@ def _zip_filename(name: str) -> str:
# the same icon. Keep the two in sync if the Booth's icon ever changes.
FAVICON_HREF = (
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
- "%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
- "cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
- "cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
+ "%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5"
+ "M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' "
+ "stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' "
+ "fill='%23b2cd12'/%3E%3C/svg%3E"
)
# The seam a verbatim report declares to get the Booth's chrome. ONE line, and
@@ -711,6 +898,7 @@ def create_app(
sweep_interval_s: int = 900,
max_upload_mb: float = 1024.0,
max_files: int = 50,
+ links_board: str = "links",
) -> FastAPI:
data_dir = Path(data_dir).expanduser().resolve()
data_dir.mkdir(parents=True, exist_ok=True)
@@ -821,19 +1009,49 @@ def create_app(
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
- # Two lanes, split here rather than in the template: kept boards are a
- # different KIND of thing from the ephemeral churn — durable, deliberate,
- # operator-facing — and burying them in a feed that turns over daily is
- # exactly how they would get lost, which is the problem they exist to
- # solve. Kept renders first.
+ """THE DESK (R2 C4) — the index triaged by what needs the operator.
+
+ Three sections, ALWAYS in this order, each booth in exactly one:
+ needs — an open pick, or marks that cannot be read (somebody has to
+ fix those, so they must not hide further down). Oldest open
+ question first; a booth with no parseable stamp after every
+ booth that has one; name breaks ties.
+ new — content landed since the booth was last looked at, or never
+ looked at. Newest content first; name breaks ties.
+ rest — everything else, in `list_booths`' own order (last activity
+ first, name as the tie-break). No second rule is stated.
+ The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so
+ the lanes sorted nothing. Kept status still shows on every row.
+ """
everything = list_booths(data_dir, ttl_seconds)
+ needs = [b for b in everything
+ if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE]
+ needs.sort(key=lambda b: ((0, b["open_since"].timestamp())
+ if b["open_since"] else (1, 0.0), b["name"]))
+ in_needs = {b["name"] for b in needs}
+ new = [b for b in everything if b["name"] not in in_needs
+ and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])]
+ new.sort(key=lambda b: (-b["landed_at"], b["name"]))
+ in_new = {b["name"] for b in new}
+ rest = [b for b in everything
+ if b["name"] not in in_needs and b["name"] not in in_new]
+ benches, benches_error = read_benches(data_dir)
+ board = data_dir / links_board
+ bookmarks = [row for row in _board_rows(board)
+ if booth_target(row["url"]) is None] if board.is_dir() else []
return templates.TemplateResponse(
request,
"index.html",
{
**base_ctx,
- "kept": [b for b in everything if b["kept"]],
- "booths": [b for b in everything if not b["kept"]],
+ "needs": needs,
+ "new": new,
+ "rest": rest,
+ "benches": [b for b in benches if b.state != "retired"],
+ "benches_error": benches_error,
+ "bookmarks": bookmarks[:BOOKMARKS_SHOWN],
+ "bookmarks_total": len(bookmarks),
+ "board_url": f"/b/{quote(links_board, safe='')}/",
},
)
@@ -970,6 +1188,27 @@ def create_app(
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
},
"booth_marks": marks_for_target(marks, None),
+ # R2 C5: the flag tray — the flagged items in SET order, i.e.
+ # by ordinal, over the full gallery (a filter hides tiles, not
+ # judgments). Order is total with no tie-break: rels are unique.
+ # Inline group headers only when every group is ONE contiguous
+ # run in the rendered order. Groups come from basenames and the
+ # order from full paths, so they can interleave (d1/aa, d1/bb,
+ # d2/aa); a header then either repeats or files an item under
+ # the wrong group. The rail's jump links do not depend on this.
+ "inline_groups": bool(rail["groups"]) and _contiguous(
+ [it["group"] for it in shown]),
+ # THE flag predicate for this page — the tile class and the tile
+ # toggle read it too, so no surface on the page can disagree.
+ "flagged_set": flagged_targets(marks),
+ "tray": [it for it in gallery if it["name"] in flagged_targets(marks)],
+ # A flag whose file is gone from the booth: no tile to stamp and
+ # no tray slot, so it is listed apart with its withdraw control
+ # rather than vanishing from the page while staying in the file.
+ "orphan_flags": [m for m in marks
+ if m.shape == "flag" and m.error is None and m.target
+ and m.target not in {it["name"] for it in gallery}],
+ "ord_width": len(str(len(gallery))),
"uploaded": (booth / UPLOAD_MARKER).exists(),
# The same provenance line the index card carries. Deliberate:
# a booth URL handed to the operator lands HERE, never on the
@@ -1144,8 +1383,32 @@ def create_app(
base = f"/b/{quote(name, safe='')}/"
if form.get("back") == "marks":
base = f"/b/{quote(name, safe='')}/marks"
+ elif form.get("back") == "view":
+ # R2 C3: judgment made at full size lands back at full size — the
+ # JS-off fix for being thrown out to the grid. Only for a MEDIA item
+ # of this booth; anything else takes the no-`back` landing above.
+ # Built from the resolved rel, never echoed from the form.
+ f = form.get("f")
+ if isinstance(f, str) and f:
+ try:
+ ring = review_chain(booth_items(resolve_booth(name)))
+ except HTTPException:
+ ring = []
+ if f in ring:
+ return RedirectResponse(
+ url=f"/b/{quote(name, safe='')}/view?f={quote(f, safe='/')}#rail",
+ status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)
+ def _mark_done(request: Request, name: str, form, anchor: str) -> Response:
+ """The one exit for every mark route (R2 C3). A request that asked for
+ the in-place answer gets 204 and no body — the page fetches its own
+ fresh regions. Everything else gets `_mark_redirect`, byte for byte what
+ it got before R2 (INV-4)."""
+ if wants_json(request.headers.get("accept")):
+ return Response(status_code=204)
+ return _mark_redirect(name, form, anchor)
+
@app.post("/b/{name}/answer")
async def booth_answer(request: Request, name: str):
"""Record the operator's pick — one of N options a session declared in
@@ -1196,7 +1459,7 @@ def create_app(
_form_text(form, "choice"), notes, who=who)
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
- return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
+ return _mark_done(request, name, form, f"mark-{quote(mark_id, safe='')}")
@app.post("/b/{name}/note")
async def booth_note(request: Request, name: str):
@@ -1217,7 +1480,7 @@ def create_app(
who=request.client.host if request.client else "")
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
- return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}")
+ return _mark_done(request, name, form, f"mark-{quote(mark.id, safe='')}")
@app.post("/b/{name}/flag")
async def booth_flag(request: Request, name: str):
@@ -1239,7 +1502,7 @@ def create_app(
who=request.client.host if request.client else "")
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
- return _mark_redirect(name, form, f"item-{quote(target, safe='')}")
+ return _mark_done(request, name, form, f"item-{quote(target, safe='')}")
@app.post("/b/{name}/unmark")
async def booth_unmark(request: Request, name: str):
@@ -1251,7 +1514,7 @@ def create_app(
if not isinstance(mark_id, str) or not mark_id:
raise HTTPException(status_code=400, detail="which mark?")
await run_in_threadpool(delete_mark, booth, mark_id)
- return _mark_redirect(name, form, "marks")
+ return _mark_done(request, name, form, "marks")
@app.post("/b/{name}/import-asks")
async def booth_import_asks(request: Request, name: str):
@@ -1469,7 +1732,9 @@ def create_app(
booth = resolve_booth(name)
try:
target = (booth / f).resolve()
- except OSError:
+ except (OSError, ValueError):
+ # ValueError: an embedded NUL. Hostile input, like every other
+ # unresolvable `f` — a 404, never a 500.
raise HTTPException(status_code=404, detail="no such file")
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such file")
@@ -1484,6 +1749,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 = {
@@ -1500,20 +1769,58 @@ def create_app(
# notes and the flag state travel to full size, which is the size at
# which the judgment is actually being made.
"marks": item_marks,
- "flagged": any(m.shape == "flag" for m in item_marks),
+ "flagged": f in flagged_targets(marks),
}
- 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)
+ if item is not None and item.kind in REVIEW_KINDS:
+ # THE REVIEW (R2 C6): images, video and audio at full size with the
+ # judgment on screen. The ring is `review_chain` — the item order
+ # filtered to MEDIA — and the prev/next, the filmstrip and the tape
+ # all read that ONE list, so they cannot disagree about "next".
+ ring = review_chain(items)
+ by_rel = {it.rel: it for it in items}
+ pos = ring.index(f)
prev_url = next_url = None
- if f in names and len(names) > 1:
- i = names.index(f)
- prev_url = quote(names[(i - 1) % len(names)], safe="/")
- next_url = quote(names[(i + 1) % len(names)], safe="/")
+ if len(ring) > 1:
+ prev_url = quote(ring[(pos - 1) % len(ring)], safe="/")
+ next_url = quote(ring[(pos + 1) % len(ring)], safe="/")
+ flagged_rels = flagged_targets(marks)
+ # recorded above, before this read: the current item counts as seen
+ seen = read_seen(booth) & set(ring)
+ film = [{"name": r, "url": by_rel[r].url, "ordinal": by_rel[r].ordinal,
+ "kind": by_rel[r].kind, "blurred": by_rel[r].blurred,
+ "flagged": r in flagged_rels, "seen": r in seen,
+ "current": r == f} for r in ring]
+ # Position within the group, only when there IS grouping: two or
+ # more groups among the ring. One group for everything says nothing.
+ group = None
+ ring_groups = {by_rel[r].group for r in ring if by_rel[r].group}
+ if item.group and len(ring_groups) > 1:
+ members = [r for r in ring if by_rel[r].group == item.group]
+ group = {"key": item.group, "k": members.index(f) + 1, "n": len(members)}
+ open_now = open_marks(marks)
return templates.TemplateResponse(
- request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url}
- )
+ request, "view.html", {
+ **common,
+ "kind": item.kind,
+ "ordinal": item.ordinal,
+ "ord_width": len(str(len(items))),
+ "ring_k": pos + 1,
+ "ring_m": len(ring),
+ "prev_url": prev_url,
+ "next_url": next_url,
+ "film": film,
+ "seen_n": len(seen),
+ "group": group,
+ # a question ABOUT this item is answerable here; the rest are
+ # a count and a link — until the last item, where the end of
+ # the set offers them all
+ "item_picks": [m for m in open_now if m.target == f],
+ "other_picks": [m for m in open_now if m.target != f],
+ "is_last": pos == len(ring) - 1,
+ "tray": [x for x in film if x["flagged"]],
+ "back_url": f"/b/{quote(name, safe='')}/#item-{item.url}",
+ })
# .md renders, .txt/.log show as text — viewable in-booth, no download
if item is not None:
@@ -1777,6 +2084,9 @@ def _from_env() -> FastAPI:
sweep_interval_s=interval,
max_upload_mb=max_mb,
max_files=max_n,
+ # The board the CLI's `booth link` writes (scripts/booth reads the same
+ # variable), so the Desk's bookmarks come from where they are written.
+ links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"),
)
diff --git a/booth/items.py b/booth/items.py
index 4f33621..5ad0a38 100644
--- a/booth/items.py
+++ b/booth/items.py
@@ -15,7 +15,10 @@ See docs/contracts/u1_item_record.contract.md.
from __future__ import annotations
+import json
+import os
import re
+import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
@@ -95,6 +98,57 @@ 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"
+
+
+# A seen marker bigger than this is not one this service wrote: a JSON array of
+# every rel in a 270-item booth is a few KB.
+SEEN_MAX_BYTES = 1 << 20
+
+
+def read_seen(booth: Path) -> set[str]:
+ """Rels seen at full size (R2 C2). A JSON array of strings, because a rel
+ may hold a leading space or a newline and must round-trip exactly.
+
+ NEVER RAISES and NEVER BLOCKS. Any fleet session can write into a booth,
+ so the marker may be planted: it is opened without following a link and
+ without blocking (a FIFO with no writer), refused unless it is a regular
+ file of sane size, and anything unreadable or malformed reads as nothing
+ seen — a damaged marker costs the tape its memory, never the page.
+ """
+ try:
+ fd = os.open(booth / SEEN_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
+ except OSError:
+ return set()
+ try:
+ st = os.fstat(fd)
+ if not stat.S_ISREG(st.st_mode) or st.st_size > SEEN_MAX_BYTES:
+ return set()
+ raw = os.read(fd, SEEN_MAX_BYTES + 1)
+ except OSError:
+ return set()
+ finally:
+ os.close(fd)
+ try:
+ data = json.loads(raw.decode("utf-8"))
+ except (UnicodeDecodeError, ValueError, RecursionError):
+ # RecursionError: a deeply nested array (`[[[[...`) blows the parser's
+ # stack, and it is neither a ValueError nor an OSError — the same hole
+ # marks.py, manifest.py and benches.py already close.
+ return set()
+ if not isinstance(data, list):
+ return set()
+ return {r for r in data if isinstance(r, str)}
def read_blurred(booth: Path) -> set[str]:
@@ -273,6 +327,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 +345,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/static/embed.js b/booth/static/embed.js
index b4a3ec4..b552b10 100644
--- a/booth/static/embed.js
+++ b/booth/static/embed.js
@@ -28,53 +28,69 @@
window.__boothEmbed = true;
var CSS = [
+ /* SVOS values, written as literals: this sheet lands in a page we did not
+ write, so it can lean on none of base.html's tokens. Hex equivalents of
+ the SVOS semantic tokens (design-systems palettes/svos @ ed2f8d8). */
/* ---- the way home, and the open-asks jump ---- */
".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;",
- "display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;",
- "text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}",
+ "display:inline-block;margin:.6rem;padding:.38rem .75rem;border-radius:8px;",
+ "text-decoration:none;letter-spacing:.01em;box-shadow:0 4px 14px rgba(0,0,0,.4)}",
/* top-right: a top-left chip clips the page title on left-aligned report
layouts, and this matches the zoom view's back affordance. */
- ".booth-nav-home{right:0;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
- "color:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);",
- "-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .18s,border-color .18s}",
- ".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}",
- ".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
- "color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}",
- ".booth-nav-asks:hover{filter:brightness(1.08)}",
+ ".booth-nav-home{right:0;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
+ "color:#dce3e5;background:rgba(12,16,20,.86);border:1px solid rgba(255,255,255,.16);",
+ "-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .12s,border-color .12s}",
+ ".booth-nav-home:hover{background:rgba(31,35,40,.96);border-color:rgba(255,255,255,.34)}",
+ /* amber = needs you: the one chip that asks to be clicked */
+ ".booth-nav-asks{right:7.4rem;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
+ "color:#15191d;background:#fbc10f;border:1px solid #fbc10f;transition:filter .12s}",
+ ".booth-nav-asks:hover{filter:brightness(1.06)}",
"@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
/* ---- ask fragments. Self-contained: the host page carries its own CSS and
- nothing here may inherit from it, so the palette adapts via
- prefers-color-scheme rather than borrowing. ---- */
- ".bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);",
- "border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);",
- "font:15px/1.5 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
- ".bk-ask.bk-done{border-top-color:#3fae6a}",
- ".bk-ask.bk-skip{border-top-color:#6f7c8c}",
- ".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}",
- ".bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
- "letter-spacing:.12em;text-transform:uppercase;color:#c9a227}",
- ".bk-ask.bk-done .bk-ask-tag{color:#3fae6a}",
- ".bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}",
+ nothing here may inherit from it. The palette is a set of custom
+ properties SCOPED TO .bk-ask, flipped by prefers-color-scheme — so each
+ rule below is written once and a host page cannot reach the values
+ without targeting our own class. Neutrals stay translucent so the
+ fragment sits on a light or a dark host alike. ---- */
+ ".bk-ask{--bk-accent:#b2cd12;--bk-accent-line:rgba(178,205,18,.55);--bk-accent-soft:rgba(178,205,18,.12);",
+ "--bk-on-accent:#0c1014;--bk-open:#d29a02;--bk-open-text:#fbc10f;--bk-done:#71a166;--bk-done-text:#9bce90;",
+ "--bk-skip:#868d91;--bk-err:#fea47d}",
+ "@media (prefers-color-scheme: light){.bk-ask{--bk-accent:#586519;--bk-accent-line:rgba(88,101,25,.55);",
+ "--bk-accent-soft:rgba(88,101,25,.11);--bk-on-accent:#fff;--bk-open:#7c5500;--bk-open-text:#7c5500;",
+ "--bk-done:#486741;--bk-done-text:#486741;--bk-skip:#52595e;--bk-err:#a42e07}}",
+ ".bk-ask{margin:1.1rem 0;padding:.9rem 1rem;border:1px solid rgba(128,140,160,.34);",
+ "border-top:2px solid var(--bk-open);border-radius:8px;background:rgba(128,140,160,.07);",
+ "font:15px/1.55 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
+ ".bk-ask.bk-done{border-top-color:var(--bk-done)}",
+ ".bk-ask.bk-skip{border-top-color:var(--bk-skip)}",
+ ".bk-ask-tag{display:inline-block;margin-bottom:.6rem;padding:2px 7px;border:1.5px solid currentColor;border-radius:3px;",
+ "font:600 10.5px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
+ "letter-spacing:.12em;text-transform:uppercase;color:var(--bk-open-text)}",
+ ".bk-ask.bk-done .bk-ask-tag{color:var(--bk-done-text)}",
+ ".bk-ask.bk-skip .bk-ask-tag{color:var(--bk-skip)}",
+ ".bk-ask-title{margin:0 0 .15rem;font:500 11px/1.4 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
+ "letter-spacing:.12em;text-transform:uppercase;opacity:.62}",
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
- ".bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}",
- ".bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;",
- "border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}",
+ ".bk-ask-opts{display:flex;flex-direction:column;gap:.35rem}",
+ ".bk-ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.55rem .7rem;cursor:pointer;",
+ "border:1px solid rgba(128,140,160,.3);border-radius:8px;background:rgba(128,140,160,.06)}",
".bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}",
- ".bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}",
- ".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}",
+ ".bk-ask-opt:has(input:checked){border-color:var(--bk-accent-line);background:var(--bk-accent-soft)}",
+ ".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:var(--bk-accent)}",
".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
- ".bk-ask-det{font-size:.8rem;opacity:.68}",
- ".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;",
+ ".bk-ask-det{font-size:.82rem;opacity:.68}",
+ ".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .65rem;",
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
- "border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}",
- ".bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
- "letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;",
- "background:#2fa8a0;color:#08131a}",
- ".bk-ask-go:hover{filter:brightness(1.09)}",
- ".bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}",
+ "border:1px solid rgba(128,140,160,.34);border-radius:8px;resize:vertical}",
+ ".bk-ask-notes:focus{outline:2px solid var(--bk-accent);outline-offset:1px}",
+ /* the primary — green, because submitting is what arms the answer */
+ ".bk-ask-go{margin-top:.75rem;cursor:pointer;font:600 13px/1 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
+ "padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);",
+ "background:var(--bk-accent);color:var(--bk-on-accent)}",
+ ".bk-ask-go:hover{filter:brightness(1.06)}",
+ ".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem;opacity:.8}",
".bk-ask-was b{opacity:1}",
- ".bk-ask-err{color:#d6452a;font-size:.86rem}",
- "@media (prefers-color-scheme: light){.bk-ask-tag{color:#8a6d10}.bk-ask-go{color:#fff}}",
+ ".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
"@media print{.bk-ask{break-inside:avoid}}"
].join("");
diff --git a/booth/templates/_marks.html b/booth/templates/_marks.html
index 34beeb3..6a10c2a 100644
--- a/booth/templates/_marks.html
+++ b/booth/templates/_marks.html
@@ -26,13 +26,18 @@
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
+{# `picks_only` + `back_view`: the review rail (view.html) includes this panel
+ with `marks` narrowed to the open picks it should offer, and wants only the
+ pick forms, each landing back on the review (`back=view`, R2 C3). ONE
+ renderer of a pick form, whichever page it sits on. #}
+{% if not picks_only %}
{% for a in broken %}
{% endfor %}
+{% endif %}
{% for a in picks %}
@@ -75,7 +81,7 @@
{% endif %}
{% if a.answer %}change answer{% else %}answer{% endif %}
-
{% endfor %}
-{% for a in notes %}
-
+{% if not picks_only %}
+{# FLAGS come right after the picks. On the lightbox (`tray` defined) they
+ render as the TRAY: the flagged items in SET order — by tile number, the
+ declared R2 change from the click order below — each the original shown
+ small, blurred if the item is. The standalone marks page has no item
+ records, so it keeps the list, in `(created, id)` order. #}
+{% if tray is defined %}{% if tray %}
+ {# In the lightbox the tray and the notes FOLD on a narrow screen (R2 C5):
+ a closed , which base.html shows open-and-summary-less above
+ 1000px with no script. Below it, the question sits above the set and the
+ tray and notes are one tap away instead of burying it. #}
+
+ ✔ flagged · {{ tray|length }}
+
- note
- {% if a.target %}on {{ a.target }}
- {% else %}on this booth {% endif %}
-
- {{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}
-
+ ✔ flagged
+ {{ tray|length }} item{{ '' if tray|length == 1 else 's' }} · in set order
- {{ a.text }}
+
-{% endfor %}
-
-{% if flags %}
+
+ {% endif %}
+ {% if orphan_flags %}
+
+
+ ✔ flagged
+ {{ orphan_flags|length }} on files no longer in this booth
+
+
+ {% for m in orphan_flags %}
+ {{ m.target }}
+
+ {% endfor %}
+
+
+ {% endif %}
+{% elif flags %}
✔ flagged
@@ -149,11 +183,34 @@
{% endif %}
+{% set fold_notes = tray is defined and notes %}
+{% if fold_notes %}notes · {{ notes|length }} {% endif %}
+{% for a in notes %}
+
+
+ note
+ {% if a.target %}on {{ a.target }}
+ {% else %}on this booth {% endif %}
+
+ {{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}
+
+
+ {{ a.text }}
+
+{% endfor %}
+{% if fold_notes %} {% endif %}
+
+
{# The operator volunteering a remark, which before marks had no mechanism at
all — this is the direction that was running through chat. #}
-
+{% endif %}
diff --git a/booth/templates/_svos_tokens.css b/booth/templates/_svos_tokens.css
new file mode 100644
index 0000000..f5486c9
--- /dev/null
+++ b/booth/templates/_svos_tokens.css
@@ -0,0 +1,273 @@
+/* SVOS tokens — VENDORED BY COPY from design-systems
+ palettes/svos/colors.css + svos-theme.css @ ed2f8d8. Do not hand-edit values;
+ re-vendor from the source. The only transform: SVOS scopes its four themes
+ by [data-theme]; the Booth has no theme switch and follows the OS, so the
+ blocks are re-scoped onto prefers-color-scheme / prefers-contrast queries.
+ Semantic tokens only in the Booth layer below — never a primitive, never
+ a raw hex. */
+:root {
+ --graphite-10: oklch(0.17 0.01 250);
+ --graphite-15: oklch(0.21 0.01 248);
+ --graphite-20: oklch(0.255 0.011 246);
+ --graphite-25: oklch(0.31 0.012 244);
+ --graphite-30: oklch(0.37 0.012 242);
+ --graphite-40: oklch(0.46 0.012 238);
+ --graphite-50: oklch(0.55 0.011 234);
+ --graphite-60: oklch(0.64 0.01 230);
+ --graphite-65: oklch(0.69 0.009 228);
+ --graphite-70: oklch(0.73 0.009 226);
+ --graphite-75: oklch(0.79 0.009 224);
+ --graphite-80: oklch(0.84 0.009 222);
+ --graphite-90: oklch(0.91 0.008 216);
+ --graphite-94: oklch(0.945 0.006 214);
+ --graphite-96: oklch(0.965 0.005 212);
+ --graphite-98: oklch(0.985 0.003 210);
+ --green-deep: oklch(0.48 0.1 119);
+ --green-base: oklch(0.66 0.15 119);
+ --green-bright: oklch(0.8 0.185 119);
+ --sage-deep: oklch(0.48 0.07 140);
+ --sage-base: oklch(0.66 0.1 140);
+ --sage-bright: oklch(0.8 0.1 140);
+ --amber-deep: oklch(0.48 0.1 78);
+ --amber-base: oklch(0.72 0.148 82);
+ --amber-bright: oklch(0.84 0.17 86);
+ --orange-deep: oklch(0.48 0.16 36);
+ --orange-base: oklch(0.66 0.213 38.5);
+ --orange-bright: oklch(0.8 0.12 44);
+ --intel-deep: oklch(0.48 0.09 235);
+ --intel-base: oklch(0.66 0.1 235);
+ --intel-bright: oklch(0.8 0.08 235);
+ --armed-green: var(--green-bright);
+ --hazard-orange: var(--orange-base);
+ --graphite-ink: var(--graphite-15);
+}
+
+/* dark — the lair default */
+:root {
+ color-scheme: dark;
+ --surface-sunken: var(--graphite-10);
+ --surface-base: var(--graphite-15);
+ --surface-raised: var(--graphite-20);
+ --surface-overlay: oklch(0.31 0.012 244);
+ --surface-card: var(--graphite-20);
+ --surface-input: var(--graphite-10);
+ --surface-scrim: oklch(0.14 0.01 250 / 0.72);
+ --text-heading: var(--graphite-90);
+ --text-body: var(--graphite-75);
+ --text-muted: var(--graphite-70);
+ --text-faint: var(--graphite-60);
+ --text-inverse: var(--graphite-15);
+ --text-link: var(--intel-bright);
+ --text-link-hover: var(--graphite-90);
+ --border-subtle: var(--graphite-25);
+ --border-default: var(--graphite-25);
+ --border-strong: var(--graphite-40);
+ --border-focus: var(--green-bright);
+ --accent: var(--green-bright);
+ --accent-hover: oklch(0.845 0.185 119);
+ --accent-active: oklch(0.73 0.17 119);
+ --accent-text: var(--green-bright);
+ --accent-contrast: var(--graphite-10);
+ --accent-soft: color-mix(in oklab, var(--green-bright) 12%, transparent);
+ --accent-soft-hover: color-mix(in oklab, var(--green-bright) 20%, transparent);
+ --success: var(--sage-base);
+ --success-text: var(--sage-bright);
+ --success-soft: color-mix(in oklab, var(--sage-base) 14%, transparent);
+ --warning: var(--amber-base);
+ --warning-text: var(--amber-bright);
+ --warning-soft: color-mix(in oklab, var(--amber-base) 13%, transparent);
+ --danger: var(--orange-base);
+ --danger-hover: oklch(0.71 0.18 38.5);
+ --danger-text: var(--orange-bright);
+ --danger-contrast: var(--graphite-10);
+ --danger-soft: color-mix(in oklab, var(--orange-base) 14%, transparent);
+ --intel: var(--intel-base);
+ --intel-text: var(--intel-bright);
+ --intel-soft: color-mix(in oklab, var(--intel-base) 14%, transparent);
+ --selection-bg: var(--green-bright);
+ --selection-fg: var(--graphite-10);
+}
+
+/* art layer: voices, type, space, radii, motion, elevation, the devices */
+:root {
+ /* voices — Plex speaks, mono records, Exan appears on the letterhead */
+ --font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ --font-brand: "Exan", "IBM Plex Sans", sans-serif;
+
+ /* type — 14px base, calm ladder */
+ --size-display: 38px; --size-h1: 25px; --size-h2: 18px; --size-h3: 15px;
+ --size-body: 14px; --size-sm: 13px; --size-caption: 12px;
+ --size-micro: 11px; --size-mono: 12.5px;
+ --tracking-display: -0.02em; --tracking-h: -0.01em; --tracking-caps: 0.12em;
+ --leading-body: 1.55; --leading-tight: 1.12;
+
+ /* spacing — 4px base, roomier than an ops console has any right to be */
+ --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
+ --space-5: 20px; --space-6: 24px; --space-8: 32px; --space-12: 48px;
+ --space-16: 64px; --space-24: 96px;
+
+ /* radii — calm 8/12; the Bureau stamps square off at 3 */
+ --radius-sm: 3px; --radius-md: 6px; --radius-lg: 8px;
+ --radius-xl: 12px; --radius-pill: 999px;
+
+ /* motion — unhurried decel; nothing loops, nothing bounces */
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+ --dur-1: 120ms; --dur-2: 200ms; --dur-3: 300ms;
+
+ /* elevation (dark default: heavier, cool) */
+ --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.35);
+ --shadow-md: 0 4px 14px rgb(0 0 0 / 0.4);
+ --shadow-lg: 0 14px 36px rgb(0 0 0 / 0.5);
+
+ /* device 2 — the hazard stripe (irreversible actions ONLY) */
+ --hazard-alt: color-mix(in oklab, var(--danger) 25%, var(--surface-sunken));
+ --hazard-stripe: repeating-linear-gradient(-45deg,
+ var(--danger) 0 8px, var(--hazard-alt) 8px 16px);
+
+ /* device 3 — the armed glow (live power ONLY) */
+ --glow-armed: 0 0 12px color-mix(in oklab, var(--accent) 40%, transparent);
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color-scheme: light;
+ --surface-sunken: var(--graphite-94);
+ --surface-base: var(--graphite-96);
+ --surface-raised: var(--graphite-98);
+ --surface-overlay: #ffffff;
+ --surface-card: var(--graphite-98);
+ --surface-input: #ffffff;
+ --surface-scrim: oklch(0.21 0.01 248 / 0.4);
+ --text-heading: var(--graphite-15);
+ --text-body: var(--graphite-30);
+ --text-muted: var(--graphite-40);
+ --text-faint: var(--graphite-50);
+ --text-inverse: var(--graphite-90);
+ --text-link: oklch(0.455 0.097 235);
+ --text-link-hover: var(--graphite-15);
+ --border-subtle: oklch(0.89 0.006 218);
+ --border-default: oklch(0.85 0.008 220);
+ --border-strong: var(--graphite-75);
+ --border-focus: var(--green-deep);
+ --accent: var(--green-deep);
+ --accent-hover: oklch(0.44 0.1 119);
+ --accent-active: oklch(0.4 0.09 119);
+ --accent-text: oklch(0.44 0.1 119);
+ --accent-contrast: #ffffff;
+ --accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent);
+ --accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent);
+ --success: var(--sage-deep);
+ --success-text: var(--sage-deep);
+ --success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent);
+ --warning: var(--amber-deep);
+ --warning-text: var(--amber-deep);
+ --warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent);
+ --danger: var(--orange-deep);
+ --danger-hover: oklch(0.44 0.15 36);
+ --danger-text: var(--orange-deep);
+ --danger-contrast: #ffffff;
+ --danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent);
+ --intel: var(--intel-deep);
+ --intel-text: var(--intel-deep);
+ --intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent);
+ --selection-bg: var(--green-deep);
+ --selection-fg: #ffffff;
+ --shadow-sm: 0 1px 2px rgb(23 31 38 / 0.07);
+ --shadow-md: 0 4px 14px rgb(23 31 38 / 0.1);
+ --shadow-lg: 0 14px 36px rgb(23 31 38 / 0.14);
+ --glow-armed: 0 0 10px color-mix(in oklab, var(--accent) 30%, transparent);
+ }
+}
+
+@media (prefers-contrast: more) {
+ :root {
+ --surface-sunken: var(--graphite-10);
+ --surface-base: var(--graphite-15);
+ --surface-raised: var(--graphite-20);
+ --surface-overlay: oklch(0.31 0.012 244);
+ --surface-card: var(--graphite-20);
+ --surface-input: var(--graphite-10);
+ --surface-scrim: oklch(0.14 0.01 250 / 0.72);
+ --text-heading: var(--graphite-90);
+ --text-body: var(--graphite-75);
+ --text-muted: var(--graphite-70);
+ --text-faint: oklch(0.7142 0.0085 230);
+ --text-inverse: var(--graphite-15);
+ --text-link: var(--intel-bright);
+ --text-link-hover: var(--graphite-90);
+ --border-subtle: var(--graphite-25);
+ --border-default: var(--graphite-40);
+ --border-strong: var(--graphite-50);
+ --border-focus: var(--green-bright);
+ --accent: var(--green-bright);
+ --accent-hover: oklch(0.845 0.185 119);
+ --accent-active: oklch(0.73 0.17 119);
+ --accent-text: var(--green-bright);
+ --accent-contrast: var(--graphite-10);
+ --accent-soft: color-mix(in oklab, var(--green-bright) 12%, transparent);
+ --accent-soft-hover: color-mix(in oklab, var(--green-bright) 20%, transparent);
+ --success: var(--sage-base);
+ --success-text: var(--sage-bright);
+ --success-soft: color-mix(in oklab, var(--sage-base) 14%, transparent);
+ --warning: var(--amber-base);
+ --warning-text: var(--amber-bright);
+ --warning-soft: color-mix(in oklab, var(--amber-base) 13%, transparent);
+ --danger: var(--orange-base);
+ --danger-hover: oklch(0.71 0.18 38.5);
+ --danger-text: var(--orange-bright);
+ --danger-contrast: var(--graphite-10);
+ --danger-soft: color-mix(in oklab, var(--orange-base) 14%, transparent);
+ --intel: var(--intel-base);
+ --intel-text: var(--intel-bright);
+ --intel-soft: color-mix(in oklab, var(--intel-base) 14%, transparent);
+ --selection-bg: var(--green-bright);
+ --selection-fg: var(--graphite-10);
+ }
+}
+
+@media (prefers-color-scheme: light) and (prefers-contrast: more) {
+ :root {
+ --surface-sunken: var(--graphite-94);
+ --surface-base: var(--graphite-96);
+ --surface-raised: var(--graphite-98);
+ --surface-overlay: #ffffff;
+ --surface-card: var(--graphite-98);
+ --surface-input: #ffffff;
+ --surface-scrim: oklch(0.21 0.01 248 / 0.4);
+ --text-heading: var(--graphite-15);
+ --text-body: var(--graphite-30);
+ --text-muted: oklch(0.4403 0.0102 238);
+ --text-faint: oklch(0.4403 0.00935 234);
+ --text-inverse: var(--graphite-90);
+ --text-link: oklch(0.4371 0.08245 235);
+ --text-link-hover: var(--graphite-15);
+ --border-subtle: oklch(0.89 0.006 218);
+ --border-default: var(--graphite-60);
+ --border-strong: var(--graphite-40);
+ --border-focus: var(--green-deep);
+ --accent: var(--green-deep);
+ --accent-hover: oklch(0.44 0.1 119);
+ --accent-active: oklch(0.4 0.09 119);
+ --accent-text: oklch(0.436 0.085 119);
+ --accent-contrast: #ffffff;
+ --accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent);
+ --accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent);
+ --success: var(--sage-deep);
+ --success-text: oklch(0.4033 0.0595 140);
+ --success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent);
+ --warning: var(--amber-deep);
+ --warning-text: oklch(0.4103 0.085 78);
+ --warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent);
+ --danger: var(--orange-deep);
+ --danger-hover: oklch(0.44 0.15 36);
+ --danger-text: oklch(0.4225 0.136 36);
+ --danger-contrast: #ffffff;
+ --danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent);
+ --intel: var(--intel-deep);
+ --intel-text: oklch(0.4373 0.0765 235);
+ --intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent);
+ --selection-bg: var(--green-deep);
+ --selection-fg: #ffffff;
+ }
+}
diff --git a/booth/templates/base.html b/booth/templates/base.html
index 710e4f4..9d2514c 100644
--- a/booth/templates/base.html
+++ b/booth/templates/base.html
@@ -4,541 +4,812 @@
{% block title %}The Booth{% endblock %}
-
+
+{# The two SVOS voices. display=swap and the system stacks in --font-sans /
+ --font-mono are the failure mode booth-dev named: a dead or slow link must
+ degrade to instantly-readable text, never to invisible text. The request
+ carries the origin only (strict-origin-when-cross-origin), so booth names do
+ not reach Google. #}
+
+
+
@@ -546,7 +817,199 @@
The Booth
ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't
-{% block content %}{% endblock %}
+
+{# R2 C3: where the in-place script says it could not save in place. Server
+ rendered and empty, so the script only ever sets its text — it builds no
+ markup (INV-6). #}
+
+{% block content %}{% endblock %}
+
+
diff --git a/booth/templates/booth.html b/booth/templates/booth.html
index 58a5e65..131f20b 100644
--- a/booth/templates/booth.html
+++ b/booth/templates/booth.html
@@ -19,8 +19,9 @@
note field. Same macro discipline as blurtoggle above — three item branches,
one definition. `marks` here is THIS item's marks, from item_marks. #}
{% macro markcontrols(name_url, it, marks, cls='') -%}
- {% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
-
+ {# THE flag predicate (flagged_targets), shared with every other surface #}
+ {% set flagged = it.name in flagged_set %}
+
{{ m.text }}
-
+
×
@@ -45,7 +46,7 @@
{% endfor %}
+ note
-
+
Add
@@ -53,6 +54,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 %}
@@ -66,7 +74,9 @@
{% else %}
{{ name }}
{% endif %}
-
{% if uploaded %}⬆ pickup {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}
+ {# The open count and the lifetime line depend on marks, so they are a region
+ (R2 C3): answering the last pick in place must not leave "1 open" behind. #}
+
{% if uploaded %}⬆ pickup {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}
{% if items %}
⬇ zip {% endif %}
{{ provenance(manifest) }}
{# A durable multi-writer board gets no one-click wipe — same rule as the
@@ -109,8 +119,17 @@
a gallery, and the add-note control would be noise on it — but the
suppression was unconditional, so a pick declared on a booth that happens to
carry a links.md had no form to answer it and nothing said so. #}
-{% if marks or not board %}
+{# R2 C5: on a GALLERY booth the panel moves into the verdict aside beside the
+ set (below). It renders up here only where there is no set to sit beside —
+ a board, or a booth with marks and nothing to show. #}
+{# `is_board`, not `board`: PAGE IDENTITY, not page content — the lesson the
+ bench panel already learned. `board` is the parsed rows, empty for a
+ links.md with none, and a board with an image in it must still be a board. #}
+{% set lightbox = all_items and not is_board %}
+{% if (marks or not board) and not lightbox %}
+
{% include "_marks.html" %}
+
{% endif %}
{# THE BENCH REGISTRY — BLOCK LEVEL, and that placement is load-bearing.
@@ -250,6 +269,18 @@
{% if not all_items and not board and not marks %}
This booth is empty.
{% elif all_items %}
+ {# THE LIGHTBOX (R2 C5). The verdict aside comes FIRST in the document and the
+ set second: on a narrow screen that is the stacking the contract wants
+ (the question above the work), and on a wide one the grid areas in
+ base.html put the aside on the right. Placement, not order — nothing in an
+ ordered collection moves. #}
+ {% if lightbox %}
+
+
+ {% include "_marks.html" %}
+
+
+ {% endif %}
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
links.md is rendered as the board above and filtered out), so a plain else
would emit an empty
under the board. #}
@@ -266,7 +297,7 @@
is therefore the whole guard; the two degenerate cases (one group for
everything, one group per item) are decided in Python, where they can be
measured, rather than by a count in a template. #}
-
+
{% endif %}
+ {% set group_n = {} %}{% for g in rail.groups %}{% set _ = group_n.update({g.key: g.n}) %}{% endfor %}
{% for it in items %}
+ {# R2 C5: an inline header before each group's FIRST tile, only when the
+ rail thinks grouping is informative. A
spanning the grid, never a
+ figure.item, so the keyboard and the order check are blind to it. #}
+ {% if inline_groups and it.group and (loop.first or loop.previtem.group != it.group) %}
+
{{ it.group }} {{ group_n.get(it.group, '') }}
+ {% endif %}
{% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a
separate page.
is native collapse (works with JS off);
the ✕ hides the item for the session (JS, progressive enhancement).
The item spans the full grid width so prose has room to read. #}
-
+
{% if it.blurred %}
{# Inline docs need this MORE than images, not less: a rendered doc puts
its text straight on the page, so "blur the picture" logic that skips
@@ -310,6 +348,7 @@
▸
+ {{ ordinal(it) }}
{{ it.name }}
⤢
@@ -332,7 +371,8 @@
{% 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
@@ -367,6 +407,10 @@
⬇
{{ it.caption or it.name }}
+ {# R2: every MEDIA tile links into the review — a picture through its
+ image, sound and video through this. Enter on the grid cursor
+ follows the first `view` link on the tile. #}
+ {% if it.kind in ('video', 'audio') %}⤢ review {% endif %}
{{ blurtoggle(name_url, it) }}
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
@@ -376,6 +420,10 @@
{% endif %}
{% endfor %}
+ {% if lightbox %}
+
{# .lb-set #}
+
{# .lightbox #}
+ {% endif %}
{% endif %}
{% if items %}
@@ -446,6 +494,11 @@
Found by the heid bug-hunt panel (hulda), 2026-09-22. */
case 'f': click('.flagtoggle button'); e.preventDefault(); break;
case 'n': var el = current();
+ /* The add-note field lives in a closed
(270 tiles
+ must not each carry an open textarea); a closed one cannot
+ take focus, so open it first. */
+ var d = el && el.querySelector('details.item-addnote');
+ if (d) d.open = true;
if (el) { var f = el.querySelector('input[type=text], textarea');
if (f) { f.focus(); e.preventDefault(); } }
break;
@@ -455,6 +508,13 @@
at = -1; break;
}
});
+ /* R2 C3: an in-place save swaps the tiles for fresh server-rendered ones,
+ and the cursor is client state the server cannot render. Put it back on
+ the same position — the order did not change, only the judgment. */
+ document.addEventListener('booth:swapped', function () {
+ if (at < 0) return;
+ tiles().forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
+ });
})();
{% endif %}
@@ -491,17 +551,30 @@
});
})();
- /* Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
+ /* TILE CONTROLS, bound per node and RE-BOUND after an in-place swap (R2 C3):
+ the swap puts fresh server-rendered tiles in the page, and a handler bound
+ to the node it replaced goes with that node. `__bound` keeps a node from
+ being bound twice.
+
+ Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
inside , so without this its click would just toggle the
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
"collapse". Collapse stays available via the rest of the summary bar. With
- JS off the button is inert and collapse via still works. */
- (function () {
+ JS off the button is inert and collapse via still works.
+
+ Blur reveal. WARNING: this handler previously sat after the content block's
+ closing tag, which in a child template Jinja DISCARDS — the button rendered
+ and did nothing, and two commits plus a README claimed click-to-reveal
+ worked. Anything that must reach the page belongs inside the content
+ block. Per-viewer and never persisted: a reload re-hides. */
+ function bindTiles() {
+ function once(el) { if (el.__bound) return false; el.__bound = true; return true; }
/* A form inside would otherwise collapse the doc on submit. */
- document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) {
- f.addEventListener('click', function (ev) { ev.stopPropagation(); });
+ document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) {
+ if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); });
});
document.querySelectorAll('.doc-close').forEach(function (btn) {
+ if (!once(btn)) return;
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
@@ -509,8 +582,25 @@
if (item) item.classList.add('is-closed');
});
});
- })();
+ document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
+ var fig = btn.closest('.item');
+ /* a swap carries `revealed` across (base.html); the label follows it */
+ btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal';
+ if (!once(btn)) return;
+ btn.addEventListener('click', function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var on = fig.classList.toggle('revealed');
+ btn.textContent = on ? '🙈 hide' : '👁 reveal';
+ });
+ });
+ }
+ bindTiles();
+ document.addEventListener('booth:swapped', bindTiles);
+ /* RESTORED (heid bug-hunt, 2/4): R2's rewrite of the tile handlers above
+ deleted this block with them. Its confirmations guard destructive
+ actions, so it is back verbatim. */
/* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row
× / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only
adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
@@ -567,22 +657,5 @@
refresh();
})();
-
- /* Blur reveal. WARNING: this handler previously sat after the content
- block's closing tag, which in a
- child template Jinja DISCARDS — the button rendered and did nothing, and
- two commits plus a README claimed click-to-reveal worked. Anything that
- must reach the page belongs inside the content block. Verified now by
- grepping the SERVED html for this function, not the template for the text.
- Per-viewer and never persisted: a reload re-hides. */
- document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
- btn.addEventListener('click', function (ev) {
- ev.preventDefault();
- ev.stopPropagation();
- var fig = btn.closest('.item');
- var on = fig.classList.toggle('revealed');
- btn.textContent = on ? '🙈 hide' : '👁 reveal';
- });
- });
{% endblock %}
diff --git a/booth/templates/doc.html b/booth/templates/doc.html
index d2ded30..2f89bb9 100644
--- a/booth/templates/doc.html
+++ b/booth/templates/doc.html
@@ -23,14 +23,14 @@
{% endif %}
diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md
new file mode 100644
index 0000000..2ee041c
--- /dev/null
+++ b/docs/contracts/r2_flow.contract.md
@@ -0,0 +1,528 @@
+---
+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
. 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=`. It lands on
+`/b//view?f=#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=""` 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 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.
+
+### 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 ``, which needs no script.
+ - The markup is a CLOSED ``.
+ - 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
+ `` 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.
+- **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 ` `.
+ - 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 ``/`` player, whose own play key it is |
+ | F | flag |
+ | N | focus the note |
+ | Esc | back to the grid, at `#item-` 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 `` 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.
diff --git a/tests/golden/r2_mark_303.json b/tests/golden/r2_mark_303.json
new file mode 100644
index 0000000..aa9d3e6
--- /dev/null
+++ b/tests/golden/r2_mark_303.json
@@ -0,0 +1,2180 @@
+[
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x",
+ "back": "marks"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x",
+ "back": "marks"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x",
+ "back": "marks"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x",
+ "back": "marks"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x",
+ "back": "marks"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x",
+ "back": "marks"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x",
+ "back": "marks"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x",
+ "back": "marks"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/answer",
+ "form": {
+ "ask": "q",
+ "choice": "x",
+ "back": "marks"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-q"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello",
+ "back": "marks"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello",
+ "back": "marks"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello",
+ "back": "marks"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello",
+ "back": "marks"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello",
+ "back": "marks"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello",
+ "back": "marks"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello",
+ "back": "marks"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello",
+ "back": "marks"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "target": "a.png",
+ "text": "hello",
+ "back": "marks"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note",
+ "back": "marks"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note",
+ "back": "marks"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note",
+ "back": "marks"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note",
+ "back": "marks"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note",
+ "back": "marks"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note",
+ "back": "marks"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note",
+ "back": "marks"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note",
+ "back": "marks"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/note",
+ "form": {
+ "text": "booth note",
+ "back": "marks"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#mark-note-2"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1",
+ "back": "marks"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1",
+ "back": "marks"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1",
+ "back": "marks"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1",
+ "back": "marks"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1",
+ "back": "marks"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1",
+ "back": "marks"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1",
+ "back": "marks"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1",
+ "back": "marks"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "b b.png",
+ "on": "1",
+ "back": "marks"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-b%20b.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0",
+ "back": "marks"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0",
+ "back": "marks"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0",
+ "back": "marks"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0",
+ "back": "marks"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0",
+ "back": "marks"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0",
+ "back": "marks"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0",
+ "back": "marks"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0",
+ "back": "marks"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/flag",
+ "form": {
+ "target": "a.png",
+ "on": "0",
+ "back": "marks"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#item-a.png"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1",
+ "back": "marks"
+ },
+ "accept": null,
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1",
+ "back": "marks"
+ },
+ "accept": "",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1",
+ "back": "marks"
+ },
+ "accept": "*/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1",
+ "back": "marks"
+ },
+ "accept": "text/html",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1",
+ "back": "marks"
+ },
+ "accept": "application/*",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1",
+ "back": "marks"
+ },
+ "accept": "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1",
+ "back": "marks"
+ },
+ "accept": "application/jsonx",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1",
+ "back": "marks"
+ },
+ "accept": ";;;",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#marks"
+ ]
+ ],
+ "body": ""
+ },
+ {
+ "path": "/b/g/unmark",
+ "form": {
+ "mark": "note-1",
+ "back": "marks"
+ },
+ "accept": "application/json;q=0",
+ "status": 303,
+ "headers": [
+ [
+ "content-length",
+ "0"
+ ],
+ [
+ "location",
+ "/b/g/marks#marks"
+ ]
+ ],
+ "body": ""
+ }
+]
\ No newline at end of file
diff --git a/tests/test_booth.py b/tests/test_booth.py
index 232b12a..f4afa1b 100644
--- a/tests/test_booth.py
+++ b/tests/test_booth.py
@@ -771,7 +771,13 @@ def test_sentinel_is_not_counted_as_an_item(tmp_path):
assert booth["count"] == 1
-def test_index_separates_kept_from_ephemeral(client):
+def test_index_marks_kept_on_the_row_instead_of_a_lane(client):
+ """R2 C4 (docs/contracts/r2_flow.contract.md, "Assertions that change").
+ This test used to require a kept LANE rendered before the ephemeral grid.
+ The Desk removed the lanes — 23 of 24 live booths were kept, so they sorted
+ nothing — and orders by what needs the operator instead (tested in
+ tests/test_flow.py). What survives is the fact: a kept booth still says it
+ is kept, on its own row."""
c, data = client
_touch(data / "scratch" / "a.png")
_touch(data / "links" / "a.png")
@@ -779,14 +785,9 @@ def test_index_separates_kept_from_ephemeral(client):
html = c.get("/").text
- # Assert on the lane's markup, not on the word "Kept" — that string also
- # appears in the stylesheet comment that is served on every page, so a bare
- # substring check passes for the wrong reason.
- assert 'class="grid kept-grid"' in html, "kept booths need their own lane"
- assert 'class="card card-kept"' in html
- # The kept lane is rendered before the ephemeral grid, so the operator sees
- # durable boards first rather than hunting for them among the churn.
- assert html.index("links") < html.index("scratch")
+ assert 'data-booth="links" data-kept="1"' in html
+ assert 'data-booth="scratch" data-kept="0"' in html
+ assert 'class="grid kept-grid"' not in html, "no lane: kept is a fact, not a grouping"
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
diff --git a/tests/test_embed_browser.py b/tests/test_embed_browser.py
index 0c18364..bc2a8aa 100644
--- a/tests/test_embed_browser.py
+++ b/tests/test_embed_browser.py
@@ -592,17 +592,28 @@ def test_the_keyboard_flag_actually_submits(browser, live):
a hidden input does not submit its form. The shortcut never worked while
still swallowing the keystroke.
- Asserted end to end: press f, and the flag must come back from the server
- on the reloaded page."""
+ Asserted end to end: press f, and the flag must come back from the server.
+
+ R2 C3 (docs/contracts/r2_flow.contract.md, "Assertions that change"): this
+ used to expect a NAVIGATION — the flag form POSTed, 303'd and reloaded. That
+ was the no-JS design working, not a defect, and it still is with scripts
+ off (tests/golden/r2_mark_303.json replays those responses byte for byte).
+ What changed is that WITH JS ON the flag now applies in place. The claim
+ that matters is kept and tightened: the flag must come back from the SERVER
+ (the swapped tile is server-rendered), and a marker set on the window before
+ the keypress must survive, which a reload would wipe."""
base, root = live
_gallery(root)
page = browser.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
+ page.evaluate("window.__noReload = 1")
page.keyboard.press("ArrowRight") # cursor onto the first tile
- with page.expect_navigation(): # the flag form POSTs and redirects back
- page.keyboard.press("f")
+ page.keyboard.press("f")
+ page.wait_for_selector("figure.item.is-flagged", timeout=10000)
flagged = page.locator("figure.item.is-flagged").count()
+ survived = page.evaluate("window.__noReload === 1")
page.close()
assert flagged == 1, f"the f key flagged {flagged} items, expected 1"
+ assert survived, "the flag reloaded the page; in-place judgment must not"
diff --git a/tests/test_flow.py b/tests/test_flow.py
new file mode 100644
index 0000000..df14bf0
--- /dev/null
+++ b/tests/test_flow.py
@@ -0,0 +1,799 @@
+"""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 time
+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", "c.png"]' # sorted, deduplicated, JSON
+
+
+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()
+
+
+# ---- C3: in-place judgment --------------------------------------------------
+
+GOLDEN = pathlib.Path(__file__).parent / "golden" / "r2_mark_303.json"
+
+
+def _seed(root: pathlib.Path) -> TestClient:
+ """The golden's fixture, byte for byte (see golden_gen in the R2 notes)."""
+ from booth.marks import declare_pick, write_note
+ root.mkdir(parents=True, exist_ok=True)
+ b = _booth(root, "g", {"a.png": PNG, "b b.png": PNG, "c.md": PNG})
+ declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]}, target="a.png")
+ set_flag(b, "a.png", True)
+ write_note(b, "a.png", "seed")
+ return TestClient(create_app(root, ttl_hours=24, start_sweeper=False),
+ follow_redirects=False)
+
+
+def test_every_pre_r2_request_shape_gets_a_byte_identical_303(tmp_path):
+ """INV-4. The golden was recorded from the PRE-R2 code: every mark route,
+ with `back` absent and `back=marks`, under nine Accept headers that must
+ NOT count as asking for JSON. Status, every header, and the body must match
+ exactly — the no-JS guarantee lives in these bytes."""
+ import json
+ cases = json.loads(GOLDEN.read_text())
+ assert len(cases) == 108
+ for i, case in enumerate(cases):
+ c = _seed(tmp_path / str(i))
+ headers = {} if case["accept"] is None else {"accept": case["accept"]}
+ r = c.post(case["path"], data=case["form"], headers=headers)
+ got = {"status": r.status_code,
+ "headers": sorted([k.lower(), v] for k, v in r.headers.items()),
+ "body": r.content.decode("latin-1")}
+ want = {k: case[k] for k in ("status", "headers", "body")}
+ assert got == want, (case["path"], case["form"], case["accept"])
+
+
+@pytest.mark.parametrize("path,form", [
+ ("/b/g/answer", {"ask": "q", "choice": "y"}),
+ ("/b/g/note", {"target": "a.png", "text": "in place"}),
+ ("/b/g/flag", {"target": "b b.png", "on": "1"}),
+ ("/b/g/unmark", {"mark": "note-1"}),
+])
+@pytest.mark.parametrize("accept", ["application/json", "text/html, application/json;q=0.5"])
+def test_an_explicit_json_accept_gets_204_and_the_write_still_lands(tmp_path, path, form, accept):
+ """The in-place path: same write as the form, no redirect, no body."""
+ from booth.marks import marks_for
+ c = _seed(tmp_path)
+ before = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")]
+ r = c.post(path, data=form, headers={"accept": accept})
+ assert r.status_code == 204 and r.content == b""
+ assert "location" not in r.headers
+ after = [(m.id, m.shape, m.answer, m.text) for m in marks_for(tmp_path / "g")]
+ assert after != before, "the write must happen exactly as for the form"
+
+
+@pytest.mark.parametrize("f,landing", [
+ ("a.png", "/b/g/view?f=a.png#rail"),
+ ("b b.png", "/b/g/view?f=b%20b.png#rail"),
+ ("c.md", "/b/g/#item-b%20b.png"), # a doc is not in the review ring
+ ("gone.png", "/b/g/#item-b%20b.png"), # not an item
+ ("", "/b/g/#item-b%20b.png"),
+ ("../../etc/passwd", "/b/g/#item-b%20b.png"),
+])
+def test_back_view_lands_on_the_review_only_for_a_media_item(tmp_path, f, landing):
+ """The JS-off fix for the bounce: a flag set at full size lands back at
+ full size. Anything that is not a media item in this booth falls back to
+ the booth page exactly as a form with no `back` does."""
+ c = _seed(tmp_path)
+ r = c.post("/b/g/flag", data={"target": "b b.png", "on": "1", "back": "view", "f": f})
+ assert r.status_code == 303
+ assert r.headers["location"] == landing
+
+
+# ---- C4: the Desk -------------------------------------------------------------
+
+def _at(path: pathlib.Path, t: float) -> None:
+ import os
+ os.utime(path, (t, t))
+
+
+def _desk(body: str) -> dict[str, list[str]]:
+ """section -> the booths it renders, in render order."""
+ out = {}
+ for sec, inner in re.findall(r']*data-section="(\w+)"[^>]*>(.*?) ',
+ body, re.S):
+ out[sec] = re.findall(r' None:
+ import json
+ doc = json.loads((booth / ".marks.json").read_text())
+ for m in doc["marks"]:
+ if m["id"] == mark_id:
+ m["created"] = created
+ (booth / ".marks.json").write_text(json.dumps(doc))
+
+
+def test_needs_you_orders_by_the_parsed_stamp_not_the_string(tmp_path):
+ """`created` is a string. As text, 11:00-07:00 sorts before 12:30-05:00;
+ as time it is 18:00Z against 17:30Z, so the second question is OLDER and
+ leads. An unparseable stamp, and a booth whose marks cannot be read, sort
+ after every parseable one; name breaks the tie."""
+ from booth.marks import declare_pick
+ for n in ("alpha", "bravo", "charlie", "delta"):
+ b = _booth(tmp_path, n, {"a.png": PNG})
+ if n != "delta":
+ declare_pick(b, "q", {"prompt": "?", "options": ["x", "y"]})
+ _set_created(tmp_path / "alpha", "q", "2026-09-22T11:00:00-07:00")
+ _set_created(tmp_path / "bravo", "q", "2026-09-22T12:30:00-05:00")
+ _set_created(tmp_path / "charlie", "q", "last tuesday")
+ (tmp_path / "delta" / ".marks.json").write_text("{not json")
+ body = _client(tmp_path).get("/").text
+ assert _desk(body)["needs"] == ["bravo", "alpha", "charlie", "delta"]
+ assert "marks unreadable" in body
+
+
+def test_flags_and_notes_alone_do_not_make_a_booth_need_you(tmp_path):
+ """Needs-you means a question TO the operator. Flags and notes are the
+ operator's own judgment."""
+ from booth.marks import write_note
+ b = _booth(tmp_path, "judged", {"a.png": PNG})
+ set_flag(b, "a.png", True)
+ write_note(b, None, "done here")
+ assert "needs" not in _desk(_client(tmp_path).get("/").text)
+
+
+def test_new_since_you_looked_reads_content_not_activity(tmp_path):
+ """INV-5, the two clocks. A flag made after the last look is ACTIVITY and
+ must not make a booth look new; a file landed after the last look is
+ CONTENT and must. Newest content first."""
+ t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
+ judged = _booth(tmp_path, "judged", {"a.png": PNG})
+ delivered = _booth(tmp_path, "delivered", {"a.png": PNG})
+ later = _booth(tmp_path, "later", {"a.png": PNG})
+ for b in (judged, delivered, later):
+ _at(b / "a.png", t0)
+ (b / ".viewed").write_bytes(b"")
+ _at(b / ".viewed", t0 + 10)
+ set_flag(judged, "a.png", True) # activity after the look
+ (delivered / "b.png").write_bytes(PNG)
+ _at(delivered / "b.png", t0 + 20) # content after the look
+ (later / "b.png").write_bytes(PNG)
+ _at(later / "b.png", t0 + 30) # ...and later still
+ desk = _desk(_client(tmp_path).get("/").text)
+ assert desk["new"] == ["later", "delivered"]
+ assert desk["rest"] == ["judged"]
+
+
+def test_an_empty_section_renders_nothing_and_a_full_one_renders(tmp_path):
+ """The negative half of the kept-lane pair, carried forward: a section with
+ no booths has no heading and no box. Checked against the element, never a
+ bare word the stylesheet also contains."""
+ c = _client(tmp_path)
+ body = c.get("/").text
+ for sec in ("needs", "new", "rest"):
+ assert f'data-section="{sec}"' not in body
+ assert 'data-panel="benches"' not in body and 'data-panel="bookmarks"' not in body
+ _booth(tmp_path, "fresh", {"a.png": PNG})
+ body = c.get("/").text
+ assert 'data-section="new"' in body
+ assert 'data-section="needs"' not in body and 'data-section="rest"' not in body
+
+
+def test_a_look_then_a_judgment_leaves_the_booth_out_of_new(tmp_path):
+ """Through the routes, not hand-set markers. Every Booth write that CREATES
+ a dotfile — `.viewed`, the marks file's temp-and-replace — bumps the booth
+ DIRECTORY's mtime. A `landed_at` that read the directory would make the
+ flag you set after looking read as a fresh delivery."""
+ t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
+ b = _booth(tmp_path, "g", {"a.png": PNG})
+ _at(b / "a.png", t0)
+ _at(b, t0)
+ c = _client(tmp_path)
+ assert _desk(c.get("/").text) == {"new": ["g"]}
+ c.get("/b/g/")
+ time.sleep(0.02)
+ # Not following the 303: following it GETs the booth page, which records a
+ # fresh look and would hide the defect this pins. A session writing a mark
+ # from the CLI never looks at the page at all.
+ c.post("/b/g/flag", data={"target": "a.png", "on": "1"}, follow_redirects=False)
+ assert _desk(c.get("/").text) == {"rest": ["g"]}
+
+
+def _link(desc: str, url: str, who: str = "x-dev") -> str:
+ return f"- [{desc}]({url}) · {who} · 2026-09-01 10:00 \n"
+
+
+def test_the_side_column_shows_live_benches_and_non_booth_bookmarks(tmp_path):
+ """Benches: non-retired, registry order. Bookmarks: the board the CLI
+ writes, booth URLs left out (a booth announces itself on the Desk), pinned
+ first then newest, capped at eight with the way to the rest."""
+ from booth.benches import set_bench_state, upsert_bench
+ upsert_bench(tmp_path, "http://h:1/", "live one", "a-dev")
+ retired, _ = upsert_bench(tmp_path, "http://h:2/", "old one", "a-dev")
+ set_bench_state(tmp_path, retired.id, "retired")
+ rows = "".join(_link(f"ref {n}", f"http://ref/{n}") for n in range(10))
+ rows += _link("a booth", "http://10.0.0.1:8090/b/somebooth/")
+ board = _booth(tmp_path, "links", {"links.md": rows.encode()})
+ (board / ".forever").write_bytes(b"")
+ body = _client(tmp_path).get("/").text
+ benches = re.search(r'data-panel="benches".*?', body, re.S).group(0)
+ assert "live one" in benches and "old one" not in benches
+ marks = re.search(r'data-panel="bookmarks".*?', body, re.S).group(0)
+ shown = re.findall(r'class="desk-mark[^"]*" href="([^"]+)"', marks)
+ assert shown == [f"http://ref/{n}" for n in (9, 8, 7, 6, 5, 4, 3, 2)] # newest first, 8
+ assert "all 10 on the board" in marks
+
+
+def test_a_damaged_bench_registry_says_so_rather_than_rendering_empty(tmp_path):
+ (tmp_path / ".benches.json").write_text("{broken")
+ body = _client(tmp_path).get("/").text
+ panel = re.search(r'data-panel="benches".*?', body, re.S)
+ assert panel and "could not be read" in panel.group(0)
+
+
+def test_a_row_previews_four_images_keeps_blur_and_counts_flags(tmp_path):
+ """The originals shown small (no generated thumbnail), the first four in
+ item order, a blurred one still blurred. The flag count is on the row."""
+ from booth.app import set_blurred
+ b = _booth(tmp_path, "g", {f"{n}.png": PNG for n in "abcde"})
+ set_blurred(b, "b.png", True)
+ set_flag(b, "c.png", True)
+ set_flag(b, "e.png", True)
+ body = _client(tmp_path).get("/").text
+ row = re.search(r'', body, re.S).group(0)
+ imgs = re.findall(r' ', _client(tmp_path).get("/").text, re.S).group(0)
+ assert "♪ audio" in row and " str:
+ """The element carrying data-region=rid, through its matching close tag
+ (same-name nesting counted, so a region holding spans or divs is whole)."""
+ m = re.search(r'<(\w+)[^>]*data-region="%s"[^>]*>' % re.escape(rid), body)
+ assert m, f"no region {rid}"
+ tag, depth, pos = m.group(1), 1, m.end()
+ for t in re.finditer(r"<(/?)%s\b[^>]*>" % tag, body[pos:]):
+ depth += -1 if t.group(1) else 1
+ if depth == 0:
+ return body[m.start():pos + t.end()]
+ raise AssertionError(f"region {rid} never closes")
+
+
+def test_the_verdict_sits_beside_the_set_on_a_gallery_booth(tmp_path):
+ """The tracer for C5: the open question, the flags and the notes live in
+ one aside next to the grid — not in a panel above it that scrolls away."""
+ from booth.marks import declare_pick
+ b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
+ declare_pick(b, "q", {"prompt": "Which one?", "options": ["a", "b"]})
+ body = _client(tmp_path).get("/b/g/").text
+ aside = _region(body, "verdict")
+ assert aside.startswith(']*>.*?#(\d+)<', aside, re.S)
+ assert tray == [(" is-blurred", "a.png", "1"), ("", "c.png", "3")]
+
+
+def test_groups_get_an_inline_header_that_is_not_a_tile(tmp_path):
+ """When the rail finds grouping informative, each group's first tile is
+ preceded by a header — a div, never a figure.item, so the tile sequence
+ the keyboard and the order check walk is exactly the items."""
+ files = {f"{g}-{n}.png": PNG for g in ("aa", "bb", "cc") for n in (1, 2)}
+ _booth(tmp_path, "g", files)
+ body = _client(tmp_path).get("/b/g/").text
+ grid = body[body.index('id="grid"'):]
+ heads = re.findall(r']*>
(\w+) (\d+) ', grid)
+ assert heads == [("aa", "2"), ("bb", "2"), ("cc", "2")]
+ assert len(re.findall(r'
\xc2\xb7 a \xc2\xb7 2026-09-01 10:00\n"})
+ board = _client(tmp_path).get("/b/links/").text
+ assert 'data-region="verdict"' not in board and 'class="lightbox"' not in board
+
+
+# ---- C6: the review -----------------------------------------------------------
+
+def test_an_audio_item_is_reviewed_like_a_picture(tmp_path):
+ """The tracer for C6. A track gets the review page — its native player on
+ the stage, no Fit/1:1 toggle (that is for images only), and the judgment
+ rail with a flag that lands back here."""
+ _booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.mp3": b"ID3"})
+ r = _client(tmp_path).get("/b/g/view?f=b.mp3", follow_redirects=False)
+ assert r.status_code == 200
+ body = r.text
+ stage = body[body.index('id="vstage"'):]
+ assert len(re.findall(r"]*\bcontrols\b", stage.split(" ")[0])) == 1
+ assert 'id="vtoggle"' not in body and ">1:1<" not in body
+ rail = _region(body, "rail")
+ assert 'name="back" value="view"' in rail and 'name="f" value="b.mp3"' in rail
+ assert "2 of 3" in rail and "#2" in rail
+
+
+def test_filmstrip_and_tape_are_the_review_ring_with_seen_and_flags(tmp_path):
+ """One list, three surfaces: the filmstrip and the tape are the ring in
+ set order. The tape counts seen ∩ ring — including the item being looked
+ at, which is recorded before the page renders; a doc is never in it."""
+ b = _booth(tmp_path, "g", {"a.png": PNG, "b.md": b"# b", "c.png": PNG, "d.mp3": b"ID3"})
+ set_flag(b, "d.mp3", True)
+ c = _client(tmp_path)
+ c.get("/b/g/view?f=a.png")
+ body = c.get("/b/g/view?f=c.png").text
+ film = re.findall(r'#(\d)<', body) == ["1", "3", "4"] # whole-set numbers
+ tape = _region(body, "tape")
+ assert re.findall(r' = 2 # flag + the pick
+ last = _region(c.get("/b/g/view?f=b.png").text, "rail")
+ assert "End of the set" in last
+ assert "Ship the set?" in last and "Is a sharp?" in last
+ form = re.search(r']*>.*?Ship the set\?|Ship the set\?.*? ', last, re.S)
+ assert form and 'name="f" value="b.png"' in last
+
+
+def test_only_a_picture_gets_the_fit_toggle_and_blur_stays_honest(tmp_path):
+ from booth.app import set_blurred
+ b = _booth(tmp_path, "g", {"a.png": PNG, "v.webm": b"\x1aE"})
+ set_blurred(b, "a.png", True)
+ c = _client(tmp_path)
+ pic = c.get("/b/g/view?f=a.png").text
+ assert 'id="vtoggle"' in pic and ">Fit<" in pic and ">1:1<" in pic
+ assert 'class="vstage fit is-blurred"' in pic and "blur is cosmetic" in pic
+ vid = c.get("/b/g/view?f=v.webm").text
+ assert 'id="vtoggle"' not in vid and re.search(r"]*\bcontrols\b", vid)
+
+
+# ---- C7: copy and brand -------------------------------------------------------
+
+def test_no_emblem_in_the_chrome(tmp_path):
+ """Ruling `emblem=no`: the top bar carries the brand dot and the name, and
+ no image at all."""
+ _booth(tmp_path, "g", {"a.png": PNG})
+ c = _client(tmp_path)
+ for path in ("/", "/b/g/", "/b/g/view?f=a.png"):
+ body = c.get(path).text
+ m = re.search(r'', body, re.S)
+ if m:
+ assert " ', c.get("/").text, re.S).group(0)
+ assert "1 flagged" in row
+ review = c.get("/b/g/view?f=b.png").text
+ assert "○ flag" in _region(review, "rail")
+ assert re.findall(r'class="film-f([^"]*)"\s+href="\?f=([^"]+)"', review)[1][0].split() == ["is-current"]
+
+
+def test_a_full_size_look_also_counts_as_looking_at_the_booth(tmp_path):
+ """C2: the review route calls record_view as well as record_seen, so the
+ Desk's "new since you looked" clears when the booth is reviewed at full
+ size, not only when its grid is opened (Wren, groa: untested)."""
+ b = _booth(tmp_path, "g", {"a.png": PNG})
+ assert not (b / ".viewed").exists()
+ _client(tmp_path).get("/b/g/view?f=a.png")
+ assert (b / ".viewed").exists() and (b / ".seen").exists()
+
+
+def test_a_flag_on_a_file_that_is_gone_stays_visible_and_withdrawable(tmp_path):
+ """Nyx N3 (2/4): the tray only shows live items, and `tray` being always
+ defined killed the old list fallback — so a flag whose file was deleted
+ rendered NOWHERE on the booth page while the Desk still counted it. It is
+ now listed apart, with its withdraw control; the Desk counts live items."""
+ b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
+ set_flag(b, "a.png", True)
+ set_flag(b, "b.png", True)
+ (b / "b.png").unlink()
+ c = _client(tmp_path)
+ aside = _region(c.get("/b/g/").text, "verdict")
+ orphans = re.search(r'class="orphan-flags".*?', aside, re.S)
+ assert orphans and "b.png" in orphans.group(0)
+ assert 'action="/b/g/unmark"' in orphans.group(0)
+ row = re.search(r'data-booth="g".*? ', c.get("/").text, re.S).group(0)
+ assert "1 flagged" in row
+
+
+def test_a_planted_fifo_or_device_seen_marker_cannot_hang_the_review(tmp_path):
+ """Nyx N4 (3/4): `.seen` was read with an unbounded, symlink-following
+ read_text(). A FIFO with no writer blocked the worker forever; a symlink to
+ /dev/zero read until memory ran out. The read now refuses anything that is
+ not a small regular file, without following a link."""
+ import os
+ import threading
+ b = _booth(tmp_path, "g", {"a.png": PNG})
+ os.mkfifo(b / ".seen")
+ out = {}
+ t = threading.Thread(target=lambda: out.setdefault(
+ "r", _client(tmp_path).get("/b/g/view?f=a.png")), daemon=True)
+ t.start()
+ t.join(timeout=5)
+ assert "r" in out, "the review hung on a FIFO .seen"
+ assert out["r"].status_code == 200
+ h = _booth(tmp_path, "h", {"a.png": PNG})
+ (h / ".seen").symlink_to("/dev/zero")
+ assert _client(tmp_path).get("/b/h/view?f=a.png").status_code == 200
+
+
+def test_seen_round_trips_names_with_spaces_and_newlines(tmp_path):
+ """Nyx (groa, hulda): one stripped line per rel lost ` a.png` and split a
+ name holding a newline into two identities. The marker is a JSON array."""
+ from booth.items import read_seen
+ b = _booth(tmp_path, "g", {"a.png": PNG, " a.png": PNG, "x\ny.png": PNG})
+ c = _client(tmp_path)
+ c.get("/b/g/view", params={"f": " a.png"})
+ c.get("/b/g/view", params={"f": "x\ny.png"})
+ assert read_seen(b) == {" a.png", "x\ny.png"}
+
+
+
+def test_a_deeply_nested_seen_marker_reads_as_nothing_seen(tmp_path):
+ """A JSON array nested past the parser's recursion limit raises
+ RecursionError, which is not a ValueError: a 100 KB file of `[` planted as
+ `.seen` escaped the never-raises read and 500'd every review of the booth.
+ It reads as nothing seen, and the next look rewrites it."""
+ from booth.items import read_seen
+ b = _booth(tmp_path, "g", {"a.png": PNG})
+ (b / ".seen").write_text("[" * 100_000)
+ assert read_seen(b) == set()
+ assert _client(tmp_path).get("/b/g/view?f=a.png").status_code == 200
+ assert read_seen(b) == {"a.png"}
+
+def test_the_content_clock_reads_the_booth_not_what_its_links_point_at(tmp_path):
+ """Nyx (groa, regin): stat() followed a symlink, so a link to a busy file
+ outside the booth made the booth read as newly delivered on every load; and
+ one unreadable entry (a symlink loop) made the whole booth read as landed
+ NOW, forever. The link's own mtime counts; an unreadable entry is skipped."""
+ import os
+ t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
+ outside = tmp_path / "busy.log"
+ outside.write_text("x")
+ b = _booth(tmp_path, "g", {"a.png": PNG})
+ (b / "linked.png").symlink_to(outside)
+ (b / "loop.png").symlink_to(b / "loop.png")
+ for p in (b / "a.png", b / "linked.png", b / "loop.png"):
+ os.utime(p, (t0, t0), follow_symlinks=False)
+ _at(b, t0)
+ c = _client(tmp_path)
+ c.get("/b/g/") # look at it
+ os.utime(outside, None) # the outside file keeps moving
+ assert _desk(c.get("/").text).get("rest") == ["g"]
+
+
+def test_a_nul_in_the_review_path_is_a_404_not_a_500(tmp_path):
+ """Nyx (groa, seat-probed): Path raises ValueError on an embedded NUL, and
+ the route caught only OSError. Every other hostile `f` is a 404."""
+ _booth(tmp_path, "g", {"a.png": PNG})
+ assert _client(tmp_path).get("/b/g/view?f=a%00.png").status_code == 404
+
+
+@pytest.mark.parametrize("q", ["inf", "1e999", "nan", "-inf"])
+def test_a_non_finite_q_is_malformed(q):
+ """Nyx (regin): float() parses inf and 1e999, and inf > 0 — a malformed
+ header slipped through to the 204. Non-finite q is malformed: False."""
+ from booth.app import wants_json
+ assert wants_json(f"application/json;q={q}") is False
+
+
+def test_a_sound_only_booth_can_open_the_review(tmp_path):
+ """Nyx (groa): only the image tile linked to view?f=, so a booth of tracks
+ had no way into the review, the tape or `.seen`. Every media tile links in
+ (and Enter on the grid cursor follows that link)."""
+ _booth(tmp_path, "g", {"a.mp3": b"ID3", "b.webm": b"\x1aE"})
+ body = _client(tmp_path).get("/b/g/").text
+ for rel in ("a.mp3", "b.webm"):
+ fig = re.search(r']*data-item="%s".*? ' % re.escape(rel), body, re.S).group(0)
+ assert f'href="view?f={rel}"' in fig, rel
+
+
+def test_the_desk_never_makes_a_non_web_url_clickable(tmp_path):
+ """Nyx (kimi): bookmark and bench URLs are agent-written and land in href.
+ Autoescape does nothing about a `javascript:` scheme. The Desk links only
+ http(s) and shows anything else as plain text."""
+ rows = (_link("evil", "javascript:alert`1`")
+ + _link("fine", "https://example.test/"))
+ board = _booth(tmp_path, "links", {"links.md": rows.encode()})
+ (board / ".forever").write_bytes(b"")
+ body = _client(tmp_path).get("/").text
+ panel = re.search(r'data-panel="bookmarks".*?', body, re.S).group(0)
+ assert 'href="javascript:' not in panel
+ assert 'href="https://example.test/"' in panel and "evil" in panel
diff --git a/tests/test_flow_browser.py b/tests/test_flow_browser.py
new file mode 100644
index 0000000..78daedd
--- /dev/null
+++ b/tests/test_flow_browser.py
@@ -0,0 +1,376 @@
+"""R2 C3/C5/C6 in a real DOM: in-place judgment.
+
+A TestClient can prove what the server answers. It cannot prove that a flag
+made in the page lands without a reload, that the regions come back fresh, or
+that a failure never re-POSTs. So: a real uvicorn, a real Chromium — the same
+harness as test_embed_browser.py, and like it this SKIPS, never fails, when no
+browser is available.
+"""
+import pathlib
+import socket
+import sys
+import threading
+import time
+
+import pytest
+
+sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
+
+from booth.app import create_app # noqa: E402
+
+playwright_api = pytest.importorskip("playwright.sync_api", reason="playwright is not installed")
+
+PNG = b"\x89PNG\r\n\x1a\n"
+
+
+@pytest.fixture(scope="module")
+def browser():
+ with playwright_api.sync_playwright() as pw:
+ try:
+ b = pw.chromium.launch()
+ except Exception as exc: # noqa: BLE001 - any launch failure is a skip
+ pytest.skip(f"no usable chromium: {exc}")
+ yield b
+ b.close()
+
+
+@pytest.fixture
+def live(tmp_path):
+ import uvicorn
+
+ sock = socket.socket()
+ sock.bind(("127.0.0.1", 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
+ server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
+ thread = threading.Thread(target=server.run, daemon=True)
+ thread.start()
+ deadline = time.time() + 10
+ while not server.started and time.time() < deadline:
+ time.sleep(0.02)
+ if not server.started:
+ pytest.skip("uvicorn did not come up")
+ try:
+ yield f"http://127.0.0.1:{port}", tmp_path
+ finally:
+ server.should_exit = True
+ thread.join(timeout=10)
+
+
+def _set(root: pathlib.Path, n: int = 30) -> pathlib.Path:
+ b = root / "g"
+ b.mkdir()
+ for i in range(1, n + 1):
+ (b / f"{i:02d}.png").write_bytes(PNG)
+ return b
+
+
+def test_a_flag_lands_in_place_and_every_region_catches_up(browser, live):
+ """Click a tile's flag far down the page: no reload, the scroll position
+ holds, and the tile, the tray and the rail's count all show the server's
+ new state — the three places one flag appears."""
+ base, root = live
+ _set(root)
+ posts = []
+ page = browser.new_page(viewport={"width": 1400, "height": 800})
+ page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
+ page.goto(f"{base}/b/g/", wait_until="networkidle")
+ page.evaluate("window.__noReload = 1")
+ tile = page.locator('figure.item[data-item="24.png"]')
+ tile.scroll_into_view_if_needed()
+ y = page.evaluate("window.scrollY")
+ tile.locator(".flagtoggle button").click()
+ page.wait_for_selector('figure.item.is-flagged[data-item="24.png"]', timeout=10000)
+ state = page.evaluate("""() => ({
+ reload: window.__noReload !== 1,
+ y: window.scrollY,
+ tray: [...document.querySelectorAll('.tray .tray-ord')].map(e => e.textContent),
+ count: document.querySelector('.rail-f[data-filter="flagged"] b').textContent,
+ })""")
+ page.close()
+ assert not state["reload"]
+ assert abs(state["y"] - y) < 4, "the page must not jump"
+ assert state["tray"] == ["#24"]
+ assert state["count"] == "1"
+ assert len(posts) == 1
+
+
+def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
+ """The server refuses the write (the marks file went bad under the page).
+ The script must not retry — a retry after a lost response would duplicate
+ the judgment — it says so and reloads to show the server's truth."""
+ base, root = live
+ b = _set(root, 3)
+ posts = []
+ page = browser.new_page()
+ page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
+ page.goto(f"{base}/b/g/", wait_until="networkidle")
+ (b / ".marks.json").write_text("{damaged")
+ page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
+ # the reader is TOLD before the page goes (Wren, hulda: nothing asserted it)
+ page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
+ said = page.locator('[data-region="status"]').inner_text()
+ page.wait_for_load_state("networkidle")
+ page.wait_for_timeout(1500) # past the reload
+ page.close()
+ assert "Could not save in place" in said
+ assert len(posts) == 1, f"re-POSTed: {posts}"
+
+
+def test_a_lost_response_after_a_landed_write_is_never_retried(browser, live):
+ """The case the never-re-POST rule exists for: the server WROTE the note,
+ then the response was lost. A retry would write it twice. The route lets
+ the request reach the server and then drops the reply."""
+ from booth.marks import marks_for
+ base, root = live
+ b = _set(root, 2)
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ page.goto(f"{base}/b/g/", wait_until="networkidle")
+
+ def drop_after_write(route):
+ route.fetch() # the write lands
+ route.abort() # ...and the browser never hears back
+ page.route("**/b/g/note", drop_after_write)
+ page.locator(".verdict .mark-add textarea").fill("exactly once")
+ page.locator(".verdict .mark-add button").click()
+ page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
+ page.wait_for_timeout(1500)
+ page.close()
+ notes = [m for m in marks_for(b) if m.shape == "note" and m.text == "exactly once"]
+ assert len(notes) == 1, f"written {len(notes)} times"
+
+
+def test_a_tile_the_fresh_page_no_longer_has_stays_put_marked_stale(browser, live):
+ """Un-flag under ?filter=flagged: the fresh page has no such tile. It is
+ left where it is (nothing shifts under the reader) and marked stale."""
+ from booth.marks import set_flag
+ base, root = live
+ b = _set(root, 3)
+ set_flag(b, "01.png", True)
+ set_flag(b, "02.png", True)
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ page.goto(f"{base}/b/g/?filter=flagged", wait_until="networkidle")
+ page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
+ page.wait_for_selector('figure.item.is-stale[data-item="01.png"]', timeout=10000)
+ tiles = page.eval_on_selector_all("figure.item", "els => els.map(e => e.dataset.item)")
+ page.close()
+ assert tiles == ["01.png", "02.png"]
+
+
+def test_the_review_keys_judge_in_place_and_stay_out_of_the_note(browser, live):
+ """At full size: F typed into the note is a letter; F outside it flags IN
+ PLACE (the filmstrip underline and the tape catch up, no reload); Space
+ moves on; Esc goes back to the grid at the tile you were on."""
+ base, root = live
+ _set(root, 4)
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ page.goto(f"{base}/b/g/view?f=02.png", wait_until="networkidle")
+ page.evaluate("window.__noReload = 1")
+
+ page.locator("#vnote-text").click()
+ page.keyboard.type("fff")
+ assert page.locator(".vflag-btn.is-flagged").count() == 0
+ assert page.locator("#vnote-text").input_value() == "fff"
+
+ page.locator(".vr-where").click() # focus back on the page, not a field
+ page.keyboard.press("f")
+ page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000)
+ state = page.evaluate("""() => ({
+ reload: window.__noReload !== 1,
+ film: [...document.querySelectorAll('.film-f.is-flagged .film-ord')].map(e => e.textContent),
+ draft: document.getElementById('vnote-text').value,
+ })""")
+ assert not state["reload"]
+ assert state["film"] == ["#2"]
+ assert state["draft"] == "fff", "an unsaved note must survive a swap it was not part of"
+
+ page.keyboard.press("n")
+ focused = page.evaluate("document.activeElement.id")
+ assert focused == "vnote-text", "N focuses the note"
+ page.locator(".vr-where").click()
+
+ with page.expect_navigation():
+ page.keyboard.press(" ")
+ assert page.url.endswith("/b/g/view?f=03.png")
+ with page.expect_navigation():
+ page.keyboard.press("ArrowRight")
+ assert page.url.endswith("/b/g/view?f=04.png")
+ with page.expect_navigation():
+ page.keyboard.press("ArrowLeft")
+ assert page.url.endswith("/b/g/view?f=03.png")
+ with page.expect_navigation():
+ page.keyboard.press("Escape")
+ assert page.url.endswith("/b/g/#item-03.png")
+ page.close()
+
+
+def test_the_stage_survives_an_in_place_save_and_a_focused_radio_keeps_f(browser, live):
+ """The stage is never a region: the same node must still be on the page
+ after a flag lands (a playing track would restart otherwise). And F while
+ a radio — an — has focus is not a flag."""
+ from booth.marks import declare_pick
+ base, root = live
+ b = _set(root, 2)
+ declare_pick(b, "q", {"prompt": "Sharp?", "options": ["yes", "no"]}, target="01.png")
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
+ page.evaluate("document.getElementById('vstage').__mark = 1")
+ page.locator('.vrail input[type=radio]').first.focus()
+ page.keyboard.press("f")
+ page.wait_for_timeout(600)
+ assert page.locator(".vflag-btn.is-flagged").count() == 0, "F in a radio is not a flag"
+ page.locator(".vr-where").click()
+ page.keyboard.press("f")
+ page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000)
+ same = page.evaluate("document.getElementById('vstage').__mark === 1")
+ page.close()
+ assert same, "the stage was replaced by the swap"
+
+
+@pytest.mark.parametrize("js", [True, False])
+def test_on_a_narrow_screen_flags_and_notes_fold_and_on_a_wide_one_they_show(browser, live, js):
+ """C5 (Wren W1, 4/4): under 1000px the verdict stacks ABOVE the set, and
+ its flags and notes fold into so the question is not buried —
+ with no script. Wide, they are simply shown. Checked with JS on and off."""
+ from booth.marks import set_flag, write_note
+ base, root = live
+ b = _set(root, 3)
+ set_flag(b, "02.png", True)
+ write_note(b, None, "a booth note")
+ for width, shown in ((390, False), (1400, True)):
+ ctx = browser.new_context(viewport={"width": width, "height": 900}, java_script_enabled=js)
+ page = ctx.new_page()
+ page.goto(f"{base}/b/g/", wait_until="networkidle")
+ tray = page.locator(".verdict .tray-item").first
+ note = page.locator(".verdict .mark-text", has_text="a booth note")
+ assert tray.is_visible() is shown, (width, js, "tray")
+ assert note.is_visible() is shown, (width, js, "note")
+ if not shown: # folded, but one tap away
+ page.locator(".verdict summary.v-fold-head").first.click()
+ assert tray.is_visible()
+ ctx.close()
+
+
+# ---- fixups from the heid bug-hunt panel (round "Nyx") ------------------------
+
+def test_the_link_board_still_confirms_before_removing_a_row(browser, live):
+ """Nyx N1 (2/4): the R2 rewrite of booth.html's scripts deleted the board's
+ multi-select + confirmation script along with the handlers it replaced.
+ Removing a row is destructive; the confirm naming it must still stand in
+ front of the POST, and select-all must still select."""
+ base, root = live
+ board = root / "links"
+ board.mkdir()
+ (board / "links.md").write_text(
+ "- [one](http://x/1) · a · 2026-09-01 10:00 \n"
+ "- [two](http://x/2) · a · 2026-09-01 10:01 \n")
+ page = browser.new_page()
+ page.goto(f"{base}/b/links/", wait_until="networkidle")
+ dialogs = []
+ page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss()))
+ page.locator(".board-rm-btn").first.click()
+ page.wait_for_timeout(300)
+ page.locator("#board-selall").check()
+ ticked = page.eval_on_selector_all(".board-check", "els => els.filter(e => e.checked).length")
+ page.close()
+ assert dialogs and "Remove this link?" in dialogs[0]
+ assert ticked == 2
+ assert (board / "links.md").read_text().count("- [") == 2, "a dismissed confirm removed nothing"
+
+
+def test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once(browser, live):
+ """Nyx N5 (hulda, regin, groa): a picked-but-unsent radio was reset by any
+ other in-place save, drafts were matched by POSITION, and a double-click on
+ Add note wrote two notes. Now: dirty controls carry by identity, and a form
+ already in flight ignores a second submit."""
+ from booth.marks import declare_pick, marks_for
+ base, root = live
+ b = _set(root, 3)
+ declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ page.goto(f"{base}/b/g/", wait_until="networkidle")
+ page.locator('.verdict input[type=radio][value="y"]').check()
+ page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
+ page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
+ assert page.locator('.verdict input[type=radio][value="y"]').is_checked()
+
+ page.locator(".verdict .mark-add textarea").fill("once")
+ page.locator(".verdict .mark-add button").dblclick()
+ page.wait_for_timeout(1500)
+ page.close()
+ assert [m.text for m in marks_for(b) if m.shape == "note"] == ["once"]
+
+
+# The first page refresh after a save is held back 1s in the CLIENT: the server
+# renders it at once (so it carries only the first flag) and the browser sees
+# it late. Localhost alone never loses that race, so without the hold the test
+# passed with sequencing deleted — it has to be forced to be a control.
+_HOLD_FIRST_REFRESH = """
+(function () {
+ var real = window.fetch, n = 0;
+ window.fetch = function (u, o) {
+ var p = real.apply(this, arguments);
+ if ((!o || !o.method || o.method === 'GET') && n++ === 0) {
+ return p.then(function (r) {
+ return new Promise(function (res) { setTimeout(function () { res(r); }, 1000); });
+ });
+ }
+ return p;
+ };
+})();
+"""
+
+
+def test_quick_successive_flags_all_show(browser, live):
+ """Nyx (hulda): with no sequencing, an older refresh landing after a newer
+ one showed the newer flag as gone. Saves are serialized."""
+ base, root = live
+ _set(root, 4)
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ page.add_init_script(_HOLD_FIRST_REFRESH)
+ page.goto(f"{base}/b/g/", wait_until="networkidle")
+ for rel in ("01.png", "02.png", "03.png"):
+ page.locator(f'figure.item[data-item="{rel}"] .flagtoggle button').click()
+ page.wait_for_timeout(150)
+ page.wait_for_function(
+ "document.querySelectorAll('figure.item.is-flagged').length === 3", timeout=10000)
+ page.wait_for_timeout(1500)
+ n = page.locator("figure.item.is-flagged").count()
+ page.close()
+ assert n == 3
+
+
+def test_the_standalone_marks_page_updates_in_place(browser, live):
+ """Nyx (kimi): the marks page's forms are in-place, but the page had no
+ region, so an answer saved and the page never showed it."""
+ from booth.marks import declare_pick
+ base, root = live
+ b = _set(root, 1)
+ declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
+ page = browser.new_page()
+ page.goto(f"{base}/b/g/marks", wait_until="networkidle")
+ page.evaluate("window.__same_page = 1")
+ page.locator('input[type=radio][value="x"]').check()
+ page.locator(".mark-submit").click()
+ page.wait_for_selector(".mark.is-answered", timeout=10000)
+ # In place, not the reload fallback: the page's own window survived.
+ same = page.evaluate("window.__same_page === 1")
+ page.close()
+ assert same
+
+
+def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
+ """Nyx: view.html's bare `.vnext{right:360px}` came later in the page than
+ base.html's narrow override and won it, parking the arrow 360px in from
+ the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge."""
+ base, root = live
+ _set(root, 3)
+ rights = {}
+ for w in (1400, 390):
+ page = browser.new_page(viewport={"width": w, "height": 900})
+ page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
+ rights[w] = page.evaluate(
+ "getComputedStyle(document.querySelector('.vnav.vnext')).right")
+ page.close()
+ assert rights == {1400: "360px", 390: "0px"}