merge(r2): the review flow — the Desk, the lightbox, the reel

design-dev's R2, built against the operator's 2026-09-23 rulings (a_b /
this_arc / plain / no emblem) and handed over clean. Merged, not rebased: the
branch is another agent's work and its seven TDD commits are the record of how
it was built.

Full house discipline on his side, all complete: contract, heid contract panel
(Lark) folded, seam review against the real modules, TDD slices C1-C7, heid
code-review (Wren) 4/4 folded, heid bug-hunt (Nyx) 4/4 folded. Every new browser
test mutation-checked against its own fix.

Reviewed here before taking it, on the three things only this side knows:
  - the quote() guard in the collection loop is intact (it looks like a stray
    try around a discarded call, which is how it would get tidied away; it is
    what stands between one 0xff filename and a 500 on every booth's card)
  - Item.ordinal is APPENDED, not inserted — the mistake we made with
    Item.group and two bug-hunt arms flagged
  - image_chain stays importable and unchanged; review_chain supersedes it only
    for the review route

.seen came back better than specified: O_NOFOLLOW | O_NONBLOCK plus an S_ISREG
check, which defeats a planted symlink AND the FIFO-with-no-writer hang that
cost this service an outage once already, and a JSON array so a rel carrying a
leading space or newline round-trips exactly.

The zoom ring is now review_chain (image, video and audio) rather than
image_chain. That is a declared ordering-rule change and ROADMAP's table moves
with it.
This commit is contained in:
vh
2026-09-23 10:25:20 -07:00
17 changed files with 6153 additions and 860 deletions
+335 -25
View File
@@ -37,14 +37,19 @@ import asyncio
import fcntl import fcntl
import hashlib import hashlib
import io import io
import json
import math
import os import os
import re import re
import secrets import secrets
import shutil import shutil
import stat
import tempfile
import time import time
import zipfile import zipfile
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import replace from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Sequence from typing import Sequence
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
@@ -87,6 +92,10 @@ from booth.items import ( # noqa: E402,F401
doc_kind, doc_kind,
find_item, find_item,
image_chain, image_chain,
review_chain,
REVIEW_KINDS,
SEEN_FILE,
read_seen,
read_blurred, read_blurred,
render_doc, render_doc,
render_doc_body, render_doc_body,
@@ -249,6 +258,62 @@ def _newest_mtime(path: Path) -> float:
return newest 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: def booth_age_seconds(path: Path, now: float | None = None) -> float:
now = time.time() if now is None else now now = time.time() if now is None else now
return now - _newest_mtime(path) return now - _newest_mtime(path)
@@ -319,6 +384,104 @@ def record_view(booth: Path) -> None:
pass 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_UNREADABLE = "unreadable"
HOLD_OPEN = "open" 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. # rather than re-opening .blurred here.
thumb_blurred = it.blurred thumb_blurred = it.blurred
mtime = _newest_mtime(child) 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( booths.append(
{ {
"name": child.name, "name": child.name,
@@ -490,6 +657,23 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"hold": hold, "hold": hold,
"expires_in": max(0.0, ttl_seconds - (now - mtime)), "expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": 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 # 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, "section": it.section,
# U7. Derived in the resolver (INV-1); this only carries it. # U7. Derived in the resolver (INV-1); this only carries it.
"group": it.group, "group": it.group,
# R2 C1. Same rule: the resolver numbers, this carries.
"ordinal": it.ordinal,
"caption": it.caption, "caption": it.caption,
"rendered": rendered, "rendered": rendered,
"rendered_html": rendered_html, "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. # the same icon. Keep the two in sync if the Booth's icon ever changes.
FAVICON_HREF = ( FAVICON_HREF = (
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'" "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' " "%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5"
"cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle " "M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' "
"cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E" "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 # 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, sweep_interval_s: int = 900,
max_upload_mb: float = 1024.0, max_upload_mb: float = 1024.0,
max_files: int = 50, max_files: int = 50,
links_board: str = "links",
) -> FastAPI: ) -> FastAPI:
data_dir = Path(data_dir).expanduser().resolve() data_dir = Path(data_dir).expanduser().resolve()
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
@@ -821,19 +1009,49 @@ def create_app(
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(request: Request): def index(request: Request):
# Two lanes, split here rather than in the template: kept boards are a """THE DESK (R2 C4) — the index triaged by what needs the operator.
# different KIND of thing from the ephemeral churn — durable, deliberate,
# operator-facing — and burying them in a feed that turns over daily is Three sections, ALWAYS in this order, each booth in exactly one:
# exactly how they would get lost, which is the problem they exist to needs — an open pick, or marks that cannot be read (somebody has to
# solve. Kept renders first. 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) 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( return templates.TemplateResponse(
request, request,
"index.html", "index.html",
{ {
**base_ctx, **base_ctx,
"kept": [b for b in everything if b["kept"]], "needs": needs,
"booths": [b for b in everything if not b["kept"]], "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 it["name"]: marks_for_target(marks, it["name"]) for it in gallery
}, },
"booth_marks": marks_for_target(marks, None), "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(), "uploaded": (booth / UPLOAD_MARKER).exists(),
# The same provenance line the index card carries. Deliberate: # The same provenance line the index card carries. Deliberate:
# a booth URL handed to the operator lands HERE, never on the # 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='')}/" base = f"/b/{quote(name, safe='')}/"
if form.get("back") == "marks": if form.get("back") == "marks":
base = f"/b/{quote(name, safe='')}/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) 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") @app.post("/b/{name}/answer")
async def booth_answer(request: Request, name: str): async def booth_answer(request: Request, name: str):
"""Record the operator's pick — one of N options a session declared in """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) _form_text(form, "choice"), notes, who=who)
except AskError as exc: except AskError as exc:
raise HTTPException(status_code=400, detail=str(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") @app.post("/b/{name}/note")
async def booth_note(request: Request, name: str): async def booth_note(request: Request, name: str):
@@ -1217,7 +1480,7 @@ def create_app(
who=request.client.host if request.client else "") who=request.client.host if request.client else "")
except AskError as exc: except AskError as exc:
raise HTTPException(status_code=400, detail=str(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") @app.post("/b/{name}/flag")
async def booth_flag(request: Request, name: str): async def booth_flag(request: Request, name: str):
@@ -1239,7 +1502,7 @@ def create_app(
who=request.client.host if request.client else "") who=request.client.host if request.client else "")
except AskError as exc: except AskError as exc:
raise HTTPException(status_code=400, detail=str(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") @app.post("/b/{name}/unmark")
async def booth_unmark(request: Request, name: str): 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: if not isinstance(mark_id, str) or not mark_id:
raise HTTPException(status_code=400, detail="which mark?") raise HTTPException(status_code=400, detail="which mark?")
await run_in_threadpool(delete_mark, booth, mark_id) 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") @app.post("/b/{name}/import-asks")
async def booth_import_asks(request: Request, name: str): async def booth_import_asks(request: Request, name: str):
@@ -1469,7 +1732,9 @@ def create_app(
booth = resolve_booth(name) booth = resolve_booth(name)
try: try:
target = (booth / f).resolve() 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") raise HTTPException(status_code=404, detail="no such file")
if not str(target).startswith(str(booth) + os.sep) or not target.is_file(): if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such 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. # of a thing that is not an item is not a view of the booth.
if item is not None: if item is not None:
record_view(booth) 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) marks = marks_for(booth)
item_marks = marks_for_target(marks, f) item_marks = marks_for_target(marks, f)
common = { common = {
@@ -1500,20 +1769,58 @@ def create_app(
# notes and the flag state travel to full size, which is the size at # notes and the flag state travel to full size, which is the size at
# which the judgment is actually being made. # which the judgment is actually being made.
"marks": item_marks, "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": if item is not None and item.kind in REVIEW_KINDS:
# prev/next ring (wraps; only when there is more than one image) # THE REVIEW (R2 C6): images, video and audio at full size with the
names = image_chain(items) # 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 prev_url = next_url = None
if f in names and len(names) > 1: if len(ring) > 1:
i = names.index(f) prev_url = quote(ring[(pos - 1) % len(ring)], safe="/")
prev_url = quote(names[(i - 1) % len(names)], safe="/") next_url = quote(ring[(pos + 1) % len(ring)], safe="/")
next_url = quote(names[(i + 1) % len(names)], 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( 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 # .md renders, .txt/.log show as text — viewable in-booth, no download
if item is not None: if item is not None:
@@ -1777,6 +2084,9 @@ def _from_env() -> FastAPI:
sweep_interval_s=interval, sweep_interval_s=interval,
max_upload_mb=max_mb, max_upload_mb=max_mb,
max_files=max_n, 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"),
) )
+70
View File
@@ -15,7 +15,10 @@ See docs/contracts/u1_item_record.contract.md.
from __future__ import annotations from __future__ import annotations
import json
import os
import re import re
import stat
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Sequence from typing import Sequence
@@ -95,6 +98,57 @@ class Item:
blurred: bool blurred: bool
doc: str | None doc: str | None
size: int 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]: def read_blurred(booth: Path) -> set[str]:
@@ -273,6 +327,10 @@ def booth_items(booth: Path) -> list[Item]:
blurred=rel in blurred, blurred=rel in blurred,
doc=doc_kind(p.name), doc=doc_kind(p.name),
size=size, 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 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"] 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: def find_item(items: Sequence[Item], rel: str) -> Item | None:
"""The record for one rel, or None — the zoom/doc route's entry point.""" """The record for one rel, or None — the zoom/doc route's entry point."""
for it in items: for it in items:
+52 -36
View File
@@ -28,53 +28,69 @@
window.__boothEmbed = true; window.__boothEmbed = true;
var CSS = [ 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 ---- */ /* ---- the way home, and the open-asks jump ---- */
".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;", ".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;",
"display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;", "display:inline-block;margin:.6rem;padding:.38rem .75rem;border-radius:8px;",
"text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}", "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 /* top-right: a top-left chip clips the page title on left-aligned report
layouts, and this matches the zoom view's back affordance. */ 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;", ".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:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);", "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 .18s,border-color .18s}", "-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .12s,border-color .12s}",
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}", ".booth-nav-home:hover{background:rgba(31,35,40,.96);border-color:rgba(255,255,255,.34)}",
".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;", /* amber = needs you: the one chip that asks to be clicked */
"color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}", ".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;",
".booth-nav-asks:hover{filter:brightness(1.08)}", "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}}", "@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
/* ---- ask fragments. Self-contained: the host page carries its own CSS and /* ---- ask fragments. Self-contained: the host page carries its own CSS and
nothing here may inherit from it, so the palette adapts via nothing here may inherit from it. The palette is a set of custom
prefers-color-scheme rather than borrowing. ---- */ properties SCOPED TO .bk-ask, flipped by prefers-color-scheme — so each
".bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);", rule below is written once and a host page cannot reach the values
"border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);", without targeting our own class. Neutrals stay translucent so the
"font:15px/1.5 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}", fragment sits on a light or a dark host alike. ---- */
".bk-ask.bk-done{border-top-color:#3fae6a}", ".bk-ask{--bk-accent:#b2cd12;--bk-accent-line:rgba(178,205,18,.55);--bk-accent-soft:rgba(178,205,18,.12);",
".bk-ask.bk-skip{border-top-color:#6f7c8c}", "--bk-on-accent:#0c1014;--bk-open:#d29a02;--bk-open-text:#fbc10f;--bk-done:#71a166;--bk-done-text:#9bce90;",
".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}", "--bk-skip:#868d91;--bk-err:#fea47d}",
".bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;", "@media (prefers-color-scheme: light){.bk-ask{--bk-accent:#586519;--bk-accent-line:rgba(88,101,25,.55);",
"letter-spacing:.12em;text-transform:uppercase;color:#c9a227}", "--bk-accent-soft:rgba(88,101,25,.11);--bk-on-accent:#fff;--bk-open:#7c5500;--bk-open-text:#7c5500;",
".bk-ask.bk-done .bk-ask-tag{color:#3fae6a}", "--bk-done:#486741;--bk-done-text:#486741;--bk-skip:#52595e;--bk-err:#a42e07}}",
".bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}", ".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-prompt{margin:0 0 .6rem;font-weight:600}",
".bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}", ".bk-ask-opts{display:flex;flex-direction:column;gap:.35rem}",
".bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;", ".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:6px;background:rgba(128,140,160,.06)}", "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: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: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:#2fa8a0}", ".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-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
".bk-ask-det{font-size:.8rem;opacity:.68}", ".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 .6rem;", ".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);", "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}", "border:1px solid rgba(128,140,160,.34);border-radius:8px;resize:vertical}",
".bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;", ".bk-ask-notes:focus{outline:2px solid var(--bk-accent);outline-offset:1px}",
"letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;", /* the primary — green, because submitting is what arms the answer */
"background:#2fa8a0;color:#08131a}", ".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;",
".bk-ask-go:hover{filter:brightness(1.09)}", "padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);",
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}", "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-was b{opacity:1}",
".bk-ask-err{color:#d6452a;font-size:.86rem}", ".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
"@media (prefers-color-scheme: light){.bk-ask-tag{color:#8a6d10}.bk-ask-go{color:#fff}}",
"@media print{.bk-ask{break-inside:avoid}}" "@media print{.bk-ask{break-inside:avoid}}"
].join(""); ].join("");
+76 -19
View File
@@ -26,13 +26,18 @@
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %} {% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
<section class="marks"> <section class="marks">
{# `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 %} {% for a in broken %}
<article class="mark mark-note is-broken" id="mark-{{ a.id }}"> <article class="mark mark-note is-broken" id="mark-{{ a.id }}">
<header class="mark-head"> <header class="mark-head">
<span class="mark-state">⚠ broken</span> <span class="mark-state">⚠ broken</span>
<span class="mark-id"><code>{{ a.id }}</code></span> <span class="mark-id"><code>{{ a.id }}</code></span>
<span class="board-spacer"></span> <span class="board-spacer"></span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark"> <form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ a.id }}"> <input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %} {% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this mark">×</button> <button type="submit" class="mark-x" title="withdraw this mark">×</button>
@@ -42,6 +47,7 @@
</article> </article>
{% endfor %} {% endfor %}
{% endif %}
{% for a in picks %} {% for a in picks %}
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}"> <article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
<header class="mark-head"> <header class="mark-head">
@@ -75,7 +81,7 @@
{% endif %} {% endif %}
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}> <details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
<summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary> <summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer"> <form class="mark-form" method="post" action="/b/{{ name_url }}/answer" data-inplace>
{# The field is still `ask`: inline fragments in reports the operator {# The field is still `ask`: inline fragments in reports the operator
has already published POST that name, and breaking every landed has already published POST that name, and breaking every landed
verbatim report to tidy a form field is not a trade worth making. #} verbatim report to tidy a form field is not a trade worth making. #}
@@ -83,6 +89,7 @@
{# On the standalone page, come back HERE — the booth's own page is a {# On the standalone page, come back HERE — the booth's own page is a
verbatim report that cannot show the recorded judgment. #} verbatim report that cannot show the recorded judgment. #}
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %} {% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
{% if back_view %}<input type="hidden" name="back" value="view"><input type="hidden" name="f" value="{{ back_view }}">{% endif %}
{% for q in a.questions %} {% for q in a.questions %}
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %} {% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %} {% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
@@ -117,25 +124,52 @@
</article> </article>
{% endfor %} {% endfor %}
{% for a in notes %} {% if not picks_only %}
<article class="mark mark-note" id="mark-{{ a.id }}"> {# 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 <details>, 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. #}
<details class="v-fold">
<summary class="v-fold-head">✔ flagged · {{ tray|length }}</summary>
<article class="mark mark-flags" id="mark-flags">
<header class="mark-head"> <header class="mark-head">
<span class="mark-state mark-state-note">note</span> <span class="mark-state mark-state-flag">✔ flagged</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span> <span class="mark-id">{{ tray|length }} item{{ '' if tray|length == 1 else 's' }} · in set order</span>
{% else %}<span class="mark-target">on this booth</span>{% endif %}
<span class="board-spacer"></span>
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this note">×</button>
</form>
</header> </header>
<pre class="mark-text">{{ a.text }}</pre> <div class="tray">
{% for it in tray %}
<a class="tray-item{% if it.blurred %} is-blurred{% endif %}" href="view?f={{ it.url }}" title="{{ it.name }}">
{%- if it.kind == 'image' %}<img loading="lazy" src="{{ it.url }}" alt="">{% else %}<span class="tray-kind">{{ it.kind }}</span>{% endif -%}
<span class="tray-ord">#{{ "%0*d"|format(ord_width, it.ordinal) }}</span></a>
{% endfor %}
</div>
</article> </article>
{% endfor %} </details>
{% endif %}
{% if flags %} {% if orphan_flags %}
<article class="mark mark-flags">
<header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span>
<span class="mark-id">{{ orphan_flags|length }} on files no longer in this booth</span>
</header>
<ul class="orphan-flags">
{% for m in orphan_flags %}
<li><span class="mono">{{ m.target }}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}">
<button type="submit" class="mark-x" title="withdraw this flag">×</button>
</form></li>
{% endfor %}
</ul>
</article>
{% endif %}
{% elif flags %}
<article class="mark mark-flags" id="mark-flags"> <article class="mark mark-flags" id="mark-flags">
<header class="mark-head"> <header class="mark-head">
<span class="mark-state mark-state-flag">✔ flagged</span> <span class="mark-state mark-state-flag">✔ flagged</span>
@@ -149,11 +183,34 @@
</article> </article>
{% endif %} {% endif %}
{% set fold_notes = tray is defined and notes %}
{% if fold_notes %}<details class="v-fold"><summary class="v-fold-head">notes · {{ notes|length }}</summary>{% endif %}
{% for a in notes %}
<article class="mark mark-note" id="mark-{{ a.id }}">
<header class="mark-head">
<span class="mark-state mark-state-note">note</span>
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
{% else %}<span class="mark-target">on this booth</span>{% endif %}
<span class="board-spacer"></span>
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ a.id }}">
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<button type="submit" class="mark-x" title="withdraw this note">×</button>
</form>
</header>
<pre class="mark-text">{{ a.text }}</pre>
</article>
{% endfor %}
{% if fold_notes %}</details>{% endif %}
{# The operator volunteering a remark, which before marks had no mechanism at {# The operator volunteering a remark, which before marks had no mechanism at
all — this is the direction that was running through chat. #} all — this is the direction that was running through chat. #}
<form class="mark-add" method="post" action="/b/{{ name_url }}/note"> <form class="mark-add" method="post" action="/b/{{ name_url }}/note" data-inplace>
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %} {% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea> <textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
<button type="submit">Add note</button> <button type="submit">Add note</button>
</form> </form>
{% endif %}
</section> </section>
+273
View File
@@ -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;
}
}
+967 -504
View File
File diff suppressed because it is too large Load Diff
+105 -32
View File
@@ -19,8 +19,9 @@
note field. Same macro discipline as blurtoggle above — three item branches, note field. Same macro discipline as blurtoggle above — three item branches,
one definition. `marks` here is THIS item's marks, from item_marks. #} one definition. `marks` here is THIS item's marks, from item_marks. #}
{% macro markcontrols(name_url, it, marks, cls='') -%} {% 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 #}
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag"> {% set flagged = it.name in flagged_set %}
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<input type="hidden" name="target" value="{{ it.name }}"> <input type="hidden" name="target" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}"> <input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<button title="{{ 'un-flag this item' if flagged else 'flag this one — the session that posted it can read the selection' }}" <button title="{{ 'un-flag this item' if flagged else 'flag this one — the session that posted it can read the selection' }}"
@@ -37,7 +38,7 @@
{% for m in marks if m.shape == 'note' %} {% for m in marks if m.shape == 'note' %}
<div class="item-note" id="mark-{{ m.id }}"> <div class="item-note" id="mark-{{ m.id }}">
<pre>{{ m.text }}</pre> <pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark"> <form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}"> <input type="hidden" name="mark" value="{{ m.id }}">
<button class="mark-x" title="withdraw this note">×</button> <button class="mark-x" title="withdraw this note">×</button>
</form> </form>
@@ -45,7 +46,7 @@
{% endfor %} {% endfor %}
<details class="item-addnote"> <details class="item-addnote">
<summary>+ note</summary> <summary>+ note</summary>
<form method="post" action="/b/{{ name_url }}/note"> <form method="post" action="/b/{{ name_url }}/note" data-inplace>
<input type="hidden" name="target" value="{{ it.name }}"> <input type="hidden" name="target" value="{{ it.name }}">
<textarea name="text" rows="2" placeholder="a note on this item"></textarea> <textarea name="text" rows="2" placeholder="a note on this item"></textarea>
<button type="submit">Add</button> <button type="submit">Add</button>
@@ -53,6 +54,13 @@
</details> </details>
{%- endmacro %} {%- 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) -%}
<span class="ord" data-ordinal="{{ it.ordinal }}">#{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }}</span>
{%- endmacro %}
{% block title %}{{ name }} · The Booth{% endblock %} {% block title %}{{ name }} · The Booth{% endblock %}
{% block content %} {% block content %}
<div class="boothhead"> <div class="boothhead">
@@ -66,7 +74,9 @@
{% else %} {% else %}
<h1>{{ name }}</h1> <h1>{{ name }}</h1>
{% endif %} {% endif %}
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% 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 %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span> {# 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. #}
<span class="region-wrap" data-region="booth-status"><span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% 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 %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}</span></span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %} {% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{{ provenance(manifest) }} {{ provenance(manifest) }}
{# A durable multi-writer board gets no one-click wipe — same rule as the {# 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 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 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. #} 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 %}
<div class="marks-panel" data-region="marks-panel">
{% include "_marks.html" %} {% include "_marks.html" %}
</div>
{% endif %} {% endif %}
{# THE BENCH REGISTRY — BLOCK LEVEL, and that placement is load-bearing. {# 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 %} {% if not all_items and not board and not marks %}
<div class="empty">This booth is empty.</div> <div class="empty">This booth is empty.</div>
{% elif all_items %} {% 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 %}
<div class="lightbox">
<aside class="verdict" data-region="verdict" aria-label="your verdict">
{% include "_marks.html" %}
</aside>
<div class="lb-set">
{% endif %}
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its {# `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 links.md is rendered as the board above and filtered out), so a plain else
would emit an empty <div class="gallery"> under the board. #} would emit an empty <div class="gallery"> under the board. #}
@@ -266,7 +297,7 @@
is therefore the whole guard; the two degenerate cases (one group for 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 everything, one group per item) are decided in Python, where they can be
measured, rather than by a count in a template. #} measured, rather than by a count in a template. #}
<div class="rail"> <div class="rail" data-region="filters">
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span> <span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
{% for f in rail.counts %} {% for f in rail.counts %}
<a class="rail-f{% if f.key == filter %} on{% endif %}" <a class="rail-f{% if f.key == filter %} on{% endif %}"
@@ -292,14 +323,21 @@
<div class="empty">No items match the <b>{{ filter }}</b> filter. <div class="empty">No items match the <b>{{ filter }}</b> filter.
<a href="/b/{{ name_url }}/">show all {{ rail.total }}</a></div> <a href="/b/{{ name_url }}/">show all {{ rail.total }}</a></div>
{% endif %} {% endif %}
{% set group_n = {} %}{% for g in rail.groups %}{% set _ = group_n.update({g.key: g.n}) %}{% endfor %}
<div class="gallery" id="grid" tabindex="-1"> <div class="gallery" id="grid" tabindex="-1">
{% for it in items %} {% for it in items %}
{# R2 C5: an inline header before each group's FIRST tile, only when the
rail thinks grouping is informative. A <div> 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) %}
<div class="grp-head" aria-hidden="true"><span class="grp-key">{{ it.group }}</span> <span class="grp-n">{{ group_n.get(it.group, '') }}</span></div>
{% endif %}
{% if it.doc and it.rendered is not none %} {% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a {# Docs render INLINE, collapsible, and closable — not a link to a
separate page. <details open> is native collapse (works with JS off); separate page. <details open> is native collapse (works with JS off);
the ✕ hides the item for the session (JS, progressive enhancement). the ✕ hides the item for the session (JS, progressive enhancement).
The item spans the full grid width so prose has room to read. #} The item spans the full grid width so prose has room to read. #}
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}"> <figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
{% if it.blurred %} {% if it.blurred %}
{# Inline docs need this MORE than images, not less: a rendered doc puts {# 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 its text straight on the page, so "blur the picture" logic that skips
@@ -310,6 +348,7 @@
<details class="doc-inline" open> <details class="doc-inline" open>
<summary class="doc-bar"> <summary class="doc-bar">
<span class="doc-chevron" aria-hidden="true">▸</span> <span class="doc-chevron" aria-hidden="true">▸</span>
{{ ordinal(it) }}
<span class="doc-name">{{ it.name }}</span> <span class="doc-name">{{ it.name }}</span>
<span class="doc-spacer"></span> <span class="doc-spacer"></span>
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a> <a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
@@ -332,7 +371,8 @@
</details> </details>
</figure> </figure>
{% else %} {% else %}
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}"> <figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if it.name in flagged_set %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}" data-region="item-{{ it.url }}">
{{ ordinal(it) }}
{% if it.blurred %} {% if it.blurred %}
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so {# 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 a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
@@ -367,6 +407,10 @@
<figcaption> <figcaption>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a> <a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<span class="cap-text">{{ it.caption or it.name }}</span> <span class="cap-text">{{ it.caption or it.name }}</span>
{# 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') %}<a class="rv-link" href="view?f={{ it.url }}" title="review at full size">⤢ review</a>{% endif %}
{{ blurtoggle(name_url, it) }} {{ blurtoggle(name_url, it) }}
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }} {{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
</figcaption> </figcaption>
@@ -376,6 +420,10 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% if lightbox %}
</div>{# .lb-set #}
</div>{# .lightbox #}
{% endif %}
{% endif %} {% endif %}
{% if items %} {% if items %}
@@ -446,6 +494,11 @@
Found by the heid bug-hunt panel (hulda), 2026-09-22. */ Found by the heid bug-hunt panel (hulda), 2026-09-22. */
case 'f': click('.flagtoggle button'); e.preventDefault(); break; case 'f': click('.flagtoggle button'); e.preventDefault(); break;
case 'n': var el = current(); case 'n': var el = current();
/* The add-note field lives in a closed <details> (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 (el) { var f = el.querySelector('input[type=text], textarea');
if (f) { f.focus(); e.preventDefault(); } } if (f) { f.focus(); e.preventDefault(); } }
break; break;
@@ -455,6 +508,13 @@
at = -1; break; 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); });
});
})(); })();
</script> </script>
{% endif %} {% 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 <summary>, so without this its click would just toggle the <details> inside <summary>, so without this its click would just toggle the <details>
open/closed — stopPropagation + preventDefault make ✕ mean "close", not open/closed — stopPropagation + preventDefault make ✕ mean "close", not
"collapse". Collapse stays available via the rest of the summary bar. With "collapse". Collapse stays available via the rest of the summary bar. With
JS off the button is inert and collapse via <details> still works. */ JS off the button is inert and collapse via <details> still works.
(function () {
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 <summary> would otherwise collapse the doc on submit. */ /* A form inside <summary> would otherwise collapse the doc on submit. */
document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) { document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) {
f.addEventListener('click', function (ev) { ev.stopPropagation(); }); if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); });
}); });
document.querySelectorAll('.doc-close').forEach(function (btn) { document.querySelectorAll('.doc-close').forEach(function (btn) {
if (!once(btn)) return;
btn.addEventListener('click', function (ev) { btn.addEventListener('click', function (ev) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@@ -509,8 +582,25 @@
if (item) item.classList.add('is-closed'); 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 /* 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 × / ★, 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 adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
@@ -567,22 +657,5 @@
refresh(); 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';
});
});
</script> </script>
{% endblock %} {% endblock %}
+6 -6
View File
@@ -23,14 +23,14 @@
{% endif %} {% endif %}
</div> </div>
<style> <style>
/* .markdown-body and .textview now live in base.html (shared with the inline /* .markdown-body and .textview live in base.html (shared with the inline
gallery view). Only the full-page layout wrapper is page-specific. */ gallery view). Only the full-page layout wrapper is page-specific. */
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem} .docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 64px}
.docview .vbar{margin:0 calc(-1 * clamp(12px,3vw,20px)) 20px;border-radius:0}
.docview .textview{overflow-x:auto} .docview .textview{overflow-x:auto}
.doccap{margin:.9rem 0 1.2rem;padding:.6rem .85rem;font-size:.85rem;line-height:1.5; .doccap{margin:0 0 20px;padding:8px 14px;font-size:var(--size-body);line-height:var(--leading-body);
color:var(--fg-1);background:var(--rk-surface,rgba(255,255,255,.04)); color:var(--text-body);border-left:3px solid var(--border-strong);white-space:pre-wrap}
border-left:2px solid var(--aus-bright-cyan,#42dcd1);border-radius:0 6px 6px 0; .docmarks{display:flex;flex-direction:column;gap:8px;margin:0 0 20px}
white-space:pre-wrap}
</style> </style>
<script> <script>
(function () { (function () {
+157 -142
View File
@@ -1,136 +1,159 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_provenance.html" import provenance %} {% from "_provenance.html" import provenance %}
{% from "_lifetime.html" import lifetime %} {% from "_lifetime.html" import lifetime %}
{% block content %} {# THE DESK (R2 C4). The index triaged by what needs the operator: needs you,
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data"> then new since you looked, then everything else — always in that order, and
<label class="drop" for="booth-files"> the ORDER WITHIN each is decided in app.index, never here. A section with no
<span class="drop-icon">⬆</span> booths renders nothing at all: no heading, no empty box (the negative half
<span class="drop-main">Upload files for pickup</span> of the kept-lane pair this replaces). #}
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
<input id="booth-files" name="files" type="file" multiple>
</label>
<button class="up-go" type="submit">Get pickup id →</button>
</form>
{% if kept %} {# The first four images, the originals shown small. A blurred one stays
{# Kept boards render FIRST and look different on purpose: they are durable blurred (`blurred-thumb`, the cover's rule). A booth with no images shows the
operator-facing things (the agent link board, standing reports) and the kind placeholder the cards used to. #}
point of the lane is that they cannot be lost in a feed that turns over {% macro preview(b) -%}
every day. No countdown — they have no expiry to advertise. #} <a class="desk-strip" href="/b/{{ b.name_url }}/" tabindex="-1" aria-hidden="true">
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2> {% if b.preview %}
<div class="grid kept-grid"> {% for url, blurred in b.preview %}
{% for b in kept %} <img class="{{ 'blurred-thumb' if blurred }}" loading="lazy" src="/b/{{ b.name_url }}/{{ url }}" alt="">
<article class="card card-kept">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
{# A cover blurred inside the booth must be blurred here too, or the
front page undoes the censoring the booth page applied. #}
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
<div class="ph">▶ video</div>
{% elif b.kinds.audio %}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
<span class="badge badge-kept">★ kept</span>
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(true, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
{{ provenance(b.manifest) }}
</div>
{# There IS a × here now (operator, 2026-09-21). The old rule was
release-then-find-it-in-the-other-lane, on the theory that two
deliberate acts protect durable boards. In practice it protects
nothing and costs a hunt: the board you just released is loose in a
feed that turns over, and you have to go find it to finish the job
you had already decided on.
The protection now lives in the CONFIRMATION, not in the number of
lanes you must traverse — this one names the booth and says the word
KEPT, where the ephemeral × just asks. A deliberate act, one click,
reachable.
Release still exists and is still the reversible option. Note it
BUMPS the directory mtime, so the board's age resets and it survives
another full TTL — unkeep-and-wait is a 24h delay, not a delete,
which is exactly why a direct × was worth adding. #}
{# ⚠ BOTH OF THESE WERE position:absolute ON THE SAME CORNER, and `release`
is the later sibling, so it painted over the × completely: measured
30x22 px of overlap on a 30px button, and elementFromPoint at the ×'s
centre returned the release form. The × was unclickable from the day
it shipped.
One flex row, positioned once, instead of two independently guessed
offsets — so neither control can drift back on top of the other when
a label changes width. #}
<div class="kept-actions">
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
data-booth="{{ b.name }}" data-confirm="release">
<button title="release this board so it can be wiped">release</button>
</form>
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe-kept">
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
</form>
</div>
</article>
{% endfor %} {% endfor %}
</div> {% elif b.has_index %}<span class="ph">▦ page</span>
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %} {% elif b.kinds.video %}<span class="ph">▶ video</span>
{% endif %} {% elif b.kinds.audio %}<span class="ph">♪ audio</span>
{% else %}<span class="ph">◆ files</span>
{% if not booths %}
{% if not kept %}
<div class="empty">
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
</div>
{% endif %} {% endif %}
{% else %} </a>
<div class="grid"> {%- endmacro %}
{% for b in booths %}
<article class="card"> {% macro row(b, section) -%}
<a class="thumb" href="/b/{{ b.name_url }}/"> <article class="desk-row{% if section == 'needs' %} is-needs{% endif %}" data-booth="{{ b.name }}" data-kept="{{ '1' if b.kept else '0' }}">
{% if b.thumb_url %} {{ preview(b) }}
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy" <div class="desk-main">
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt=""> {# The manifest title leads when there is one; the directory name stays
{% elif b.has_index %} beside it because it is what the URL says. #}
<div class="ph">▦ page</div> <a class="desk-title" href="/b/{{ b.name_url }}/">
{% elif b.kinds.video %} {%- if b.manifest and not b.manifest.error and b.manifest.title and b.manifest.title != b.name -%}
<div class="ph">▶ video</div> {{ b.manifest.title }} <span class="desk-slug">{{ b.name }}</span>
{% elif b.kinds.audio %} {%- else -%}{{ b.name }}{%- endif -%}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>{% endif %}
</a> </a>
<div class="meta"> {{ provenance(b.manifest) }}
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a> <div class="desk-facts">
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · {{ lifetime(false, b.hold, b.expires_in) }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div> {{ b.count }} item{{ '' if b.count == 1 else 's' }}
{{ provenance(b.manifest) }} {% if b.flags %} · <span class="desk-flags">{{ b.flags }} flagged</span>{% endif %}
· {{ lifetime(b.kept, b.hold, b.expires_in) }}
· <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>
</div> </div>
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb </div>
both predate this button; until 2026-09-19 the UI could only RELEASE a <div class="desk-side">
kept booth, never keep an ephemeral one, so the round trip was only {% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
closed if you had a shell. Reversible, so no confirmation — the × next {% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
to it is the destructive one and keeps its prompt. #} {% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep"> {% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button> <div class="desk-acts">
</form> {# Keep / release and the ×. The confirmation text is DATA-DRIVEN: the
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete" booth name travels as a data attribute and never reaches a JS string
data-booth="{{ b.name }}" data-confirm="wipe"> (see the script below). Release is reversible, so it has no prompt of
<button title="wipe now" aria-label="wipe booth">×</button> its own beyond the wording. #}
</form> {% if b.kept %}
</article> <form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
{% endfor %} data-booth="{{ b.name }}" data-confirm="release">
<button title="release this booth so it can be wiped">release</button>
</form>
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe-kept">
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
</form>
{% else %}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
</form>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
data-booth="{{ b.name }}" data-confirm="wipe">
<button title="wipe now" aria-label="wipe booth">×</button>
</form>
{% endif %}
</div>
</div>
</article>
{%- endmacro %}
{% block content %}
<div class="desk">
<div class="desk-list">
{% if needs %}
<section class="desk-sec" data-section="needs">
<h2 class="desk-head desk-head-needs">Needs you <span class="desk-rule">oldest question first</span></h2>
{% for b in needs %}{{ row(b, 'needs') }}{% endfor %}
</section>
{% endif %}
{% if new %}
<section class="desk-sec" data-section="new">
<h2 class="desk-head desk-head-new">New since you looked <span class="desk-rule">newest first</span></h2>
{% for b in new %}{{ row(b, 'new') }}{% endfor %}
</section>
{% endif %}
{% if rest %}
<section class="desk-sec" data-section="rest">
<h2 class="desk-head">Everything else <span class="desk-rule">last activity first</span></h2>
{% for b in rest %}{{ row(b, 'rest') }}{% endfor %}
</section>
{% endif %}
{% if not needs and not new and not rest %}
<div class="empty">
No booths yet. Drop a folder into <code>{{ data_dir }}</code>, or upload files for pickup.
</div>
{% endif %}
</div> </div>
{% endif %}
<aside class="desk-aside">
{# Benches: running things. DAMAGED AND ABSENT MUST NOT RENDER THE SAME —
an unreadable registry says so; an empty one renders no panel. #}
{% if benches_error %}
<section class="desk-panel" data-panel="benches">
<h2 class="desk-panel-head">Benches</h2>
<div class="bench-err">the bench registry could not be read: {{ benches_error }}</div>
</section>
{% elif benches %}
<section class="desk-panel" data-panel="benches">
<h2 class="desk-panel-head">Benches <span class="desk-rule">running things</span></h2>
{% for b in benches %}
{# Agent-written URLs: only http(s) becomes a link. Autoescape stops markup,
not a `javascript:` scheme, so anything else renders as plain text. #}
{% set web = b.url.lower().startswith(('http://', 'https://')) %}
<{{ 'a' if web else 'div' }} class="desk-bench is-{{ b.state }}"{% if web %} href="{{ b.url }}" target="_blank" rel="noopener"{% endif %}>
<span class="desk-bench-dot" aria-hidden="true"></span>
<span class="desk-bench-main"><span class="desk-bench-name">{{ b.name or b.url }}</span>
<span class="desk-bench-sub">{% if b.owner %}{{ b.owner }} · {% endif %}{{ b.state }}</span></span>
</{{ 'a' if web else 'div' }}>
{% endfor %}
</section>
{% endif %}
{% if bookmarks %}
<section class="desk-panel" data-panel="bookmarks">
<h2 class="desk-panel-head">Bookmarks <span class="desk-rule">pinned first</span></h2>
{% for e in bookmarks %}
{% set web = e.url.lower().startswith(('http://', 'https://')) %}
<{{ 'a' if web else 'div' }} class="desk-mark{% if e.pinned %} is-pinned{% endif %}"{% if web %} href="{{ e.url }}" target="_blank" rel="noopener"{% endif %}>
{{ e.desc }}{% if e.who %}<span class="desk-bench-sub">{{ e.who }}</span>{% endif %}</{{ 'a' if web else 'div' }}>
{% endfor %}
<a class="desk-more" href="{{ board_url }}">all {{ bookmarks_total }} on the board →</a>
</section>
{% endif %}
<section class="desk-panel" data-panel="pickup">
<h2 class="desk-panel-head">Pickup</h2>
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
<label class="drop" for="booth-files">
<span class="drop-icon">⬆</span>
<span class="drop-main">Upload files for pickup</span>
<span class="drop-sub" id="drop-sub">drop here, or click · wiped in {{ ttl_hours }}h</span>
<input id="booth-files" name="files" type="file" multiple>
</label>
<button class="up-go" type="submit">Get pickup id →</button>
</form>
</section>
</aside>
</div>
<script> <script>
/* progressive enhancement: reflect chosen files + drag-drop onto the panel. /* progressive enhancement: reflect chosen files + drag-drop onto the panel.
@@ -162,31 +185,23 @@
}); });
})(); })();
/* Destructive-action confirmation, delegated and DATA-DRIVEN. /* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
These were an inline onsubmit calling confirm() with the booth NAME travels as a data attribute, where escaping is escaping, and never reaches
interpolated straight into the JS string literal. Jinja's autoescape is a JS string literal: a booth name is agent-authored, and an inline handler
HTML-attribute escaping, not JS-string escaping: the browser decodes the carrying one was a live injection path. With JS off the form submits
entity back to a quote before the JS parser ever sees it, so a booth name without a prompt. */
crafted to close that string executed on submit. Booth names are
agent-authored — making a folder under the data dir is the whole API — so
that is a live path, not a theoretical one.
The name now travels as a DATA ATTRIBUTE, where escaping is escaping, and
never reaches a JS string literal. Same pattern the board controls already
use. With JS off the form submits without a prompt, which is what every
no-JS browser here already did. */
(function () { (function () {
var WORDS = { var WORDS = {
release: function (n) { release: function (n) {
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you ' return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
+ 'can wipe it from there. Nothing is deleted by this step.'; + '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
}, },
'wipe-kept': function (n) { 'wipe-kept': function (n) {
return 'WIPE the KEPT booth \u201c' + n + '\u201d?\n\nThis deletes it and its files ' return 'WIPE the KEPT booth “' + n + '”?\n\nThis deletes it and its files '
+ 'immediately. Kept booths are the ones nothing else will clean up, so nobody ' + 'immediately. Kept booths are the ones nothing else will clean up, so nobody '
+ 'else is going to do this for you \u2014 and nothing brings it back.'; + 'else is going to do this for you — and nothing brings it back.';
}, },
wipe: function (n) { return 'Wipe booth \u201c' + n + '\u201d?'; } wipe: function (n) { return 'Wipe booth “' + n + '”?'; }
}; };
document.addEventListener('submit', function (ev) { document.addEventListener('submit', function (ev) {
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null; var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
+5 -1
View File
@@ -12,12 +12,16 @@
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2). {# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
read a half-answered pick as closed. #} read a half-answered pick as closed. #}
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span> <span class="region-wrap" data-region="booth-status"><span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}</span></span>
</div> </div>
{# One region around both branches, so answering the last mark away swaps in
the empty state instead of reading as a structural change. #}
<div class="marks-panel" data-region="marks-panel">
{% if marks %} {% if marks %}
{% include "_marks.html" %} {% include "_marks.html" %}
{% else %} {% else %}
<div class="empty">This booth has no marks.</div> <div class="empty">This booth has no marks.</div>
{% include "_marks.html" %} {% include "_marks.html" %}
{% endif %} {% endif %}
</div>
{% endblock %} {% endblock %}
+199 -82
View File
@@ -1,118 +1,235 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %} {% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{# THE REVIEW (R2 C6). One media item at full size — image, video or audio —
with the judgment on screen beside it, the whole set as a filmstrip below and
the tape above. Docs keep doc.html. Everything a mark can change is a
`data-region` the in-place script swaps (the rail, the filmstrip, the tape);
THE STAGE NEVER IS — swapping it would restart a playing track. #}
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
{% block content %} {% block content %}
<div class="viewer"> <div class="viewer review">
<div class="vbar"> <div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a> <a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)">✕</a>
<span class="vname">{{ file }}</span> <span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
<span class="vspacer"></span> <span class="vspacer"></span>
{% if kind == 'image' %}
{# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
and only ever rendered for a picture. With scripts off the image shows at
fit size and no judgment depends on this. #}
<span class="vtoggle" id="vtoggle" style="display:none"> <span class="vtoggle" id="vtoggle" style="display:none">
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button> <button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
</span> </span>
{% endif %}
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a> <a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
</div> </div>
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous image">‹</a>{% endif %}
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next image">›</a>{% endif %} {# THE TAPE (B's device): one segment per item in the review ring — seen,
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div> flagged, current — so how far through the set you are is always in view. #}
{# THE ANNOTATION, at full size. It was never rendered here before U1 — not <div class="tape" data-region="tape" aria-label="{{ seen_n }} of {{ ring_m }} seen">
because the template dropped it, but because the route never resolved it. <div class="tape-segs">
A caption is most useful at the size where you are actually judging the {% for x in film %}
thing, so it belongs here at least as much as in the grid. #} <a class="tape-s{% if x.current %} is-current{% elif x.flagged %} is-flagged{% elif x.seen %} is-seen{% endif %}"
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %} href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}"></a>
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is {% endfor %}
the size at which the operator is actually deciding, so the flag toggle and </div>
the notes belong here at least as much as on the tile. #} <span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
<div class="vmarks"> </div>
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
<input type="hidden" name="target" value="{{ file }}"> <div class="review-body">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}"> {% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
<button class="vbtn{% if flagged %} is-flagged{% endif %}" <div class="vstage fit{% if blurred %} is-blurred{% endif %}" id="vstage">
title="{{ 'un-flag this item' if flagged else 'flag this one' }}" {% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}">
>{{ '✔ flagged' if flagged else '○ flag' }}</button> {% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
</form> {% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
{% for m in marks if m.shape == 'note' %} {% endif %}
<div class="vnote"><pre>{{ m.text }}</pre> {% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}">👁 reveal — blur is cosmetic</button>{% endif %}
<form method="post" action="/b/{{ name_url }}/unmark"> </div>
<input type="hidden" name="mark" value="{{ m.id }}"> {% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
<button class="mark-x" title="withdraw this note">×</button>
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
<div class="vr-sec">
<div class="vr-where"><span class="ord">{{ num(ordinal) }}</span> · {{ ring_k }} of {{ ring_m }}
{%- if group %} · {{ group.k }} of {{ group.n }} in {{ group.key }}{% endif %}</div>
{# THE ANNOTATION, at full size — the size where it is most readable. #}
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
</div>
<div class="vr-sec vr-judge">
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
<input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
<input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}">
<button class="vbtn vflag-btn{% if flagged %} is-flagged{% endif %}" id="vflag-btn"
title="{{ 'un-flag this item' if flagged else 'flag this one' }} (F)"
>{{ '✔ flagged' if flagged else '○ flag' }} <kbd>F</kbd></button>
</form>
{% for m in marks if m.shape == 'note' %}
<div class="vnote"><pre>{{ m.text }}</pre>
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
<input type="hidden" name="mark" value="{{ m.id }}">
<input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}">
<button class="mark-x" title="withdraw this note">×</button>
</form>
</div>
{% endfor %}
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note" data-inplace>
<input type="hidden" name="target" value="{{ file }}">
<input type="hidden" name="back" value="view">
<input type="hidden" name="f" value="{{ file }}">
<textarea name="text" id="vnote-text" rows="2" placeholder="a note on this item (N)"></textarea>
<button type="submit">Add note</button>
</form> </form>
</div> </div>
{% endfor %}
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note"> {# A question ABOUT this item is answerable here. #}
<input type="hidden" name="target" value="{{ file }}"> {% if item_picks %}
<textarea name="text" rows="2" placeholder="a note on this item"></textarea> <div class="vr-sec">
<button type="submit">Add note</button> {% with marks=item_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
</form> </div>
{% endif %}
{% if is_last %}
{# THE END OF THE SET — not a separate page: on the last item the rail
adds the summary and every question still open on the booth. #}
<div class="vr-sec vr-end">
<p class="vr-end-head">End of the set · {{ seen_n }} of {{ ring_m }} seen · {{ tray|length }} flagged</p>
{% if tray %}
<div class="tray">
{% for x in tray %}
<a class="tray-item{% if x.blurred %} is-blurred{% endif %}" href="?f={{ x.url }}" title="{{ x.name }}">
{%- if x.kind == 'image' %}<img loading="lazy" src="{{ x.url }}" alt="">{% else %}<span class="tray-kind">{{ x.kind }}</span>{% endif -%}
<span class="tray-ord">{{ num(x.ordinal) }}</span></a>
{% endfor %}
</div>
{% endif %}
{% if other_picks %}
{% with marks=other_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
{% endif %}
</div>
{% elif other_picks %}
<div class="vr-sec vr-more">
<a href="/b/{{ name_url }}/">{{ other_picks|length }} more open question{{ '' if other_picks|length == 1 else 's' }} on this booth →</a>
</div>
{% endif %}
<div class="vr-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> move · <kbd>F</kbd> flag · <kbd>N</kbd> note · <kbd>Esc</kbd> grid</div>
</aside>
</div> </div>
{# THE FILMSTRIP: the review ring in set order, numbered like the tiles,
flagged frames underlined, the current one in the reticle. #}
<nav class="film" data-region="film" aria-label="the set">
{% for x in film %}
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.current %} is-current{% endif %}{% if x.blurred %} is-blurred{% endif %}"
href="?f={{ x.url }}" title="{{ x.name }}"{% if x.current %} aria-current="true"{% endif %}>
{%- if x.kind == 'image' %}<img loading="lazy" src="{{ x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
<span class="film-ord">{{ num(x.ordinal) }}</span></a>
{% endfor %}
</nav>
</div> </div>
<style> <style>
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex; .vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex;
align-items:center;justify-content:center;width:2.6rem;height:3.4rem;font-size:2rem; align-items:center;justify-content:center;width:40px;height:56px;font-size:28px;
line-height:1;text-decoration:none;color:var(--fg-1);background:rgba(20,23,32,.55); line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .6);
border:1px solid rgba(255,255,255,.10);border-radius:10px;margin:0 .5rem;user-select:none; border:1px solid rgb(255 255 255 / .12);border-radius:var(--radius-lg);margin:0 8px;user-select:none;
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);transition:background .15s,border-color .15s} -webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
.vnav:hover{background:rgba(28,33,46,.92);border-color:var(--aus-bright-cyan,#42dcd1)} transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
color:oklch(0.91 0.008 216)}
/* The next arrow clears the 360px verdict rail only while the rail sits
beside the stage. Scoped to the wide layout: stated bare, this rule came
later in the page than base.html's narrow override and silently won it,
parking the arrow 360px in from the edge of a phone. */
.vprev{left:0}.vnext{right:0} .vprev{left:0}.vnext{right:0}
/* Bottom bar rather than the top chrome: a caption can run to CAPTION_MAX @media (min-width:901px){.vnext{right:360px}}
(800 chars), which would shove the filename and the Fit/1:1 toggle around. */ .vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
.vcap{flex:0 0 auto;max-height:22vh;overflow-y:auto;padding:.6rem clamp(12px,3vw,20px); color:var(--text-body);white-space:pre-wrap}
font-size:.85rem;line-height:1.5;color:var(--fg-1);background:var(--rk-surface,rgba(20,23,32,.92)); @media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
border-top:1px solid rgba(255,255,255,.10);white-space:pre-wrap}
@media print{.vcap{max-height:none;overflow:visible}}
@media print{.vnav{display:none}}
</style> </style>
<script> <script>
(function () { (function () {
var img = document.getElementById('vimg'); var BACK = {{ back_url|tojson }};
var stage = document.getElementById('vstage');
var toggle = document.getElementById('vtoggle');
var bFit = document.getElementById('btn-fit');
var bOne = document.getElementById('btn-one');
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }};
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }}; var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }}; var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
function setMode(mode) { /* Fit / 1:1 — bound only when the stage is a picture (R2 C6). */
var fit = mode === 'fit'; var img = document.getElementById('vimg');
stage.classList.toggle('fit', fit); if (img) {
stage.classList.toggle('one', !fit); var stage = document.getElementById('vstage');
bFit.classList.toggle('on', fit); var toggle = document.getElementById('vtoggle');
bOne.classList.toggle('on', !fit); var bFit = document.getElementById('btn-fit');
var bOne = document.getElementById('btn-one');
var setMode = function (mode) {
var fit = mode === 'fit';
stage.classList.toggle('fit', fit);
stage.classList.toggle('one', !fit);
bFit.classList.toggle('on', fit);
bOne.classList.toggle('on', !fit);
};
/* "fits" == the image at natural size already sits inside the stage, so
Fit and 1:1 would render identically — then the toggle is hidden. */
var evaluate = function () {
if (!img.naturalWidth) return;
if (img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight) {
toggle.style.display = 'none';
setMode('fit');
} else {
toggle.style.display = 'inline-flex';
if (!stage.classList.contains('one')) setMode('fit');
}
};
bFit.addEventListener('click', function () { setMode('fit'); });
bOne.addEventListener('click', function () { setMode('one'); });
img.addEventListener('load', evaluate);
window.addEventListener('resize', evaluate);
if (img.complete) evaluate();
} }
// "fits" == the image at natural size already sits inside the stage, so Fit
// and 1:1 would render identically — in that case we hide the toggle entirely.
function fits() {
return img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight;
}
function evaluate() {
if (!img.naturalWidth) return;
if (fits()) {
toggle.style.display = 'none';
setMode('fit');
} else {
toggle.style.display = 'inline-flex';
if (!stage.classList.contains('one')) setMode('fit');
}
}
bFit.addEventListener('click', function () { setMode('fit'); });
bOne.addEventListener('click', function () { setMode('one'); });
img.addEventListener('load', evaluate);
window.addEventListener('resize', evaluate);
if (img.complete) evaluate();
/* An arrow key inside the note field is a CARET move, not a navigation. /* Keep the current frame in view on the filmstrip — on load, and after an
The handler is on `document` and the note textarea shipped into this same in-place save swaps the strip for a fresh one. Additive: without it the
page, so typing a note and reaching for ← threw the draft away; Escape strip is still a row of links. */
did it in one keystroke. Anything editable keeps its own keys. */ function centreFilm() {
var cur = document.querySelector('.film-f.is-current');
var film = document.querySelector('.film');
if (cur && film) film.scrollLeft = cur.offsetLeft - (film.clientWidth - cur.offsetWidth) / 2;
}
centreFilm();
document.addEventListener('booth:swapped', centreFilm);
/* Blur reveal on the stage — per-viewer, never persisted. Cosmetic, and
the button says so. */
var rv = document.getElementById('vreveal');
if (rv) rv.addEventListener('click', function () {
var on = document.getElementById('vstage').classList.toggle('revealed');
rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
});
/* EVERY key here, new and old, is ignored while focus is in something
editable: an arrow key in the note field is a caret move, and F typed
into a note is a letter, not a flag. */
function isEditable(el) { function isEditable(el) {
return !!(el && (el.isContentEditable || return !!(el && (el.isContentEditable ||
/^(input|textarea|select)$/i.test(el.tagName || ''))); /^(input|textarea|select)$/i.test(el.tagName || '')));
} }
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (isEditable(e.target)) return; if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
/* Space moves only when the stage is not a player that wants it. */
var player = document.getElementById('vmedia');
if (e.key === 'Escape') window.location.href = BACK; if (e.key === 'Escape') window.location.href = BACK;
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV; else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT; else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
else if (e.key === ' ' && NEXT && e.target !== player) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; }
else if (e.key === 'f' || e.key === 'F') {
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
if (b) { e.preventDefault(); b.click(); }
}
else if (e.key === 'n' || e.key === 'N') {
var t = document.getElementById('vnote-text');
if (t) { e.preventDefault(); t.focus(); }
}
}); });
})(); })();
</script> </script>
+528
View File
@@ -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 <form method=post>. The in-place behaviour is additive and falls back to today's 303."
- "THE VERBATIM PATH IS OUT OF SCOPE. A booth with its own index.html is served as the author wrote it (requirement 6). The Desk links to it; the lightbox never renders for it."
- "NO THUMBNAILS. Tiles, the filmstrip and the Desk's preview strip use the original files with loading=lazy. Progressive loading stays parked until page weight is measured."
open_questions:
- "ANSWERED BOOTHS LOSE THEIR HOLD (raised by booth-dev in b46ac02). A booth is held while its question is open, so it becomes sweepable the moment it becomes a decision record. The flow question: should an answered pick hold its booth for a grace period, or should the record live elsewhere? NOT SOLVED HERE, because it is a lifetime-policy change and this contract changes no lifetime rule. Raised separately."
- "KEY 1–9 TO ANSWER A PICK from the review rail. It appeared in the concept mock. Dropped from this contract: multi-question picks make the mapping ambiguous, and the operator ruled the flow, not the keymap. Parked."
---
# R2 — the review flow: the Desk, the lightbox, the review
## The requirements this answers (from the round-2 README, uncorrected by the operator)
| # | requirement | answered by |
|---|---|---|
| 1 | show me what needs me | the Desk's *needs you* section |
| 2 | picking winners is the main judgment | the lightbox's flag tray; F in the review |
| 3 | flag without losing my place | in-place judgment + `back=view` |
| 4 | position is identity (within the set as it is now — not a durable id) | `Item.ordinal`, printed on every tile |
| 5 | the question stays beside the work | the verdict aside (sticky) |
| 6 | reports keep their author's layout | verbatim path untouched |
| 7 | listening sets are real | `review_chain` includes audio and video |
| 8 | lifetime is not an organising principle (it is still SHOWN as a fact on each row; it no longer GROUPS or SORTS) | the Desk drops the kept/ephemeral lanes |
## Terms used below
- **Reticle**: the SVOS selection mark in base.html — four corner brackets drawn
inside a box. It marks the one current or selected thing and nothing else.
- **Tape**: a row of small segments, one per item in the review ring, each
showing *seen*, *flagged* or *current*.
- **Stage**: the area of the review page where the artifact itself renders.
- **All Booth state files are dotfiles.** That covers `.marks.json`
(MARKS_FILE), `.viewed`, `.blurred`, `.seen`, `.forever`, `.pins`,
`.booth.json` and every `*.lock`. "Non-dot entries" means the posted content
and nothing the Booth or the operator wrote.
## Components
### C1 — `Item.ordinal` (items.py)
`ordinal: int` is the item's 1-based position in `booth_items(booth)`, i.e. in
`sorted(rel)` order over **all** items. It is assigned in the resolver loop, so
no route derives it.
- `ordinal` is **appended** as the dataclass's last field, never inserted.
Mid-dataclass insertion is a positional-construction break, and `group` has
already had that conversation.
- The resolver's `quote()` guard on non-UTF-8 names stays exactly as it is. It
looks like a stray `try` around a discarded result, but it is what keeps one
0xff filename from taking down the index for every booth.
- An item skipped by that guard takes no ordinal, so ordinals stay contiguous
over the items that render.
- **A filter never renumbers.** Under `?filter=flagged` a tile still shows the
number it has in the whole set. That is the point: "#07" is a property of the
item, not of the view.
- **A new file renumbers everything after it.** That is honest, and it matches
the order: the operator's positional references are to the set as it is now.
### C2 — `review_chain` and `.seen` (items.py, app.py)
- **`review_chain(items)`**: the rels of items whose kind is image, video or
audio, in item order. ONE LINE: *the item order filtered to media.* It
replaces `image_chain` as the review route's prev/next ring.
- It is a **declared change** to the zoom-ring rule. Today's ring is images
only. A booth mixing images and audio now rings through both, in set order.
- `image_chain` stays for its callers and tests.
- **`SEEN_FILE = ".seen"`**: a UTF-8 JSON array of rels. Not one rel per
line, `.blurred`'s shape: a file name may contain a newline, and a line format
would split one such rel into two, neither of them real.
- Written by `record_seen(booth, rel, items)` from the review route, below the 404s
and gated on the item record — the same gate `record_view` has.
- Each write rewrites the whole file: the previous set plus `rel`, minus
rels no longer in `booth_items`, sorted. It is deduplicated and pruned, so
it never grows past the booth's item count.
- Atomic replace, per the Booth's CLAUDE.md invariant 5 ("sidecar writes are
atomic"), not this contract's INV-5.
- Seen is keyed by rel. A file replaced at the same path stays seen; a
deleted file drops out at the next write, and every count below
intersects with the current `review_chain`.
- **The review route ALSO calls `record_view` (existing U4 behaviour,
unchanged).** So reviewing a booth at full size refreshes "you looked" for
the Desk exactly as opening its grid does. `.viewed` and `.seen` never
disagree about whether you looked at the booth; `.seen` only adds WHICH
items.
- NEVER RAISES, like `record_view`: failing to record a look costs the
marker, not the page.
- `read_seen(booth) -> set[str]` is lenient and NEVER RAISES. It opens without
following a symlink and without blocking, reads only a regular file of at
most 1 MiB, and keeps only the array's string members. Anything else — a
link, a FIFO, a directory, an oversized, malformed or too-deeply-nested
file — reads as the empty set. `.seen` sits in an agent-writable directory, and a planted FIFO
must not hang the review route.
- `items` is the route's own `booth_items` result. It is passed in so that the
prune ("minus rels no longer in `booth_items`") costs no second walk.
- **Seen is UI state, not judgment.** It is not exposed in `marks.json` and it
holds nothing.
- It adds no lifetime RULE. Being a dotfile, its write does move
`_newest_mtime`. So does the `.viewed` write on the same request, so a
review page ages a booth exactly as it does today.
### C3 — in-place judgment (app.py, base.html)
**`wants_json(accept: str | None) -> bool`** takes the raw `Accept` header, so
it is a pure function a test can call directly. It is True **only** when the
header, split on commas, contains an entry whose media type, parameters stripped, is
exactly `application/json` and whose q-value is absent or greater than 0.
- Absent, empty, `*/*` or `application/*` → False.
- `application/json;q=0` → False. A client that explicitly refuses JSON gets
the redirect.
- A near miss such as `application/jsonx` → False.
- **Every entry is parsed before anything is decided.** One unparseable
entry anywhere, before or after a good one, makes the whole header False.
- Any header that fails to parse → False. A q-value that is not a finite
number (`q=nan`, `q=inf`) fails to parse.
- **It fails toward the 303.**
The four mark routes (`/answer`, `/note`, `/flag`, `/unmark`) perform the same
write as today, then:
- `wants_json` → **204 No Content**.
- otherwise → today's `_mark_redirect(...)`, **byte-identical**: same status,
same `Location`, same body.
**`back=view`** is a new landing for `_mark_redirect`, carried by the review
route's forms together with `f=<rel>`. It lands on
`/b/<name>/view?f=<quote(rel)>#rail`. This fixes the JS-off bounce too:
today's zoom flag form carries no `back`, so it lands on the gallery.
- `back=view` lands on the review only when `f` names an item in
`review_chain`, i.e. a media item. For anything else (a doc, a missing rel,
an empty `f`) the landing falls back to the booth page, exactly as a form
with no `back` does today. `doc.html` carries no forms, so no shipped page
sends `back=view` with a doc.
- The URL is built server-side from `name` + `quote(f)`, never echoed, so this
is not an open redirect.
**The client**: one small script in base.html, bound to forms marked
`data-inplace`.
1. POST the form with `Accept: application/json`.
2. On 204, GET the current URL and replace **every** element carrying
`data-region="<id>"` with the same-id element from the response.
- The rule is "every region whose content can depend on marks is a
region".
- On the lightbox: the verdict aside, each tile, the rail (its filter
counts change when you flag), and the header's open count and lifetime
line (`booth-status`).
- On a booth with marks but no set: the panel (`marks-panel`).
- On the standalone marks page: the header's open count (`booth-status`)
and the panel (`marks-panel`), one region around both its states so
answering the last mark away swaps in the empty state.
- On the review: the rail, the filmstrip and the tape.
- The stage is never a region: replacing it would restart a playing video
or audio track.
- A TILE (`item-*`) absent from the response is left alone and never
deleted. Deleting it would shift every tile after it under the reader's
eye. It is marked `is-stale` so it does not pass for current:
un-flagging under `?filter=flagged` is the case. The next navigation
drops it.
- Any OTHER difference in structure — a non-tile region in the response
that the page lacks, or one the page has that the response lacks — or a
page with no region to swap at all, is not patched: the script reloads
with a GET, so what you see is the server's truth.
- The swap also carries the per-viewer state a reload would have reset
but an in-place save must not:
- live media whose src is unchanged;
- a revealed blur;
- a closed doc;
- disclosures the reader opened or closed;
- every DIRTY control: a half-typed or edited note, a radio picked and
not yet sent.
All of it is matched by IDENTITY, never by position: a form by its
action and its hidden `ask`/`target`/`mark`/`f` fields, a control by its
form plus its name (plus its value for a radio or checkbox), a disclosure
by the pick or form it holds. A flag that adds a tray row above a draft
must not move the draft into the wrong box. The form just sent is the
exception: its fields come back as the server rendered them, and its
disclosure comes back folded.
3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap
before the next begins, so an older snapshot never lands after a newer one
(three quick flags show three flags). A form already queued or in flight
ignores another submit: a double-click writes one note, not two.
4. **The script never re-POSTs.** A retry after a lost response would re-apply
the judgment: a duplicate note, or a re-dated answer.
- On a non-204 HTTP response, or a network failure, it writes a fixed
message into the page's server-rendered status element
(`data-region="status"`, via textContent). After a beat (0.9 s, so the
words can be read) it reloads the page with a GET, so what you see is the
server's truth.
- The one case where a non-JS submit happens is a script that cannot run at
all. That is the plain form.
**The server renders every state; the script only places it.** This is U3's
rule — a second renderer in JavaScript would be the same bug in a new language.
### C4 — the Desk (index.html, app.index, list_booths)
`list_booths` gains five fields, all read in the one pass it already makes:
- **`open_since`**: the `created` of the OLDEST open pick in the booth, or
None. Computed via `open_marks`, INV-2.
- `Mark.created` is a STRING. It is parsed with `datetime.fromisoformat`,
never compared lexically: two ISO stamps with different offsets, or a
legacy-import stamp, sort wrong as text.
- An unparseable stamp sorts AFTER every parseable one, and name breaks the
tie.
- **`flags`**: the number of CURRENT items carrying a READABLE flag mark,
shown on every Desk row that has any — `flagged_targets(marks)` intersected
with the booth's item rels. `flagged_targets(marks)` is the ONE flag
predicate. The Desk, the tray, the 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 `<details>`, which needs no script.
- The markup is a CLOSED `<details>`.
- Above 1000px, CSS alone shows its content (`::details-content`) and hides
its summary, so nothing is folded where there is room.
- A browser without `::details-content` shows the fold at every width: one
tap, never hidden.
- **Board booths are unchanged.** Anything with `links.md` keeps today's
single column.
- **The aside holds, top to bottom:**
1. open picks (the existing `_marks.html` pick rendering);
2. the flag tray;
3. notes;
4. the booth-note form.
- **The flag tray is ordered by ORDINAL** — a declared change from the marks
panel's `(created, id)`. It shows each flagged item's original file
displayed small (no generated thumbnail), blurred if the item is blurred,
with its #.
The order is total with no tie-break, because rels are unique.
- **Orphan flags** — flags whose target is no longer an item — follow the
tray, by target, each with its unmark form. A flag the page cannot show
must still be clearable, or it counts in the rail forever.
- **The rail stays.** Same element, same `.rail` class (booth.html's cursor
and base.html's `--rail-h` script both read it), same filter hrefs, same
group anchors. When `rail.groups` is non-empty AND every group is one
contiguous run in the rendered order, the grid additionally renders an
inline group header before each group's first tile.
- Groups come from basenames and the order from full paths, so groups can
interleave (`d1/aa`, `d1/bb`, `d2/aa`).
- A header would then either repeat or file an item under the wrong group,
so interleaved groups get no inline headers. The rail's jump links are
unaffected. It is a
`<div>` spanning the grid, never a `figure.item`, so the keyboard and the
order check are blind to it by construction.
- **Every tile shows `#NN`** (its ordinal, zero-padded to the set's width).
Each tile is `data-region="item-<url>"`, so the in-place script can replace
exactly the tile it flagged.
- **An audio or video tile carries a `review` link** to its review page. On
those tiles a click drives the player, so without the link the review is
reachable only by key.
### C6 — the review (view.html, booth_view_file)
This applies to image, video and audio items. Docs keep `doc.html`.
A requested rel the filesystem cannot represent (a NUL byte, an over-long
path) is a 404, as any other unknown rel is — never a 500.
- **The stage**: the artifact at fit size, with a 1:1 toggle for images ONLY.
- The toggle and its script are rendered and bound only when the stage is
an `<img>`.
- The toggle is a JS-only VIEWING convenience, as it is today: the button
starts hidden and the script shows it. With scripts off the image shows at
fit size, and no judgment depends on the toggle (INV-3).
- Video and audio get their native controls and no toggle. A toggle that
renders on audio and silently no-ops (today's script binds
`getElementById('vimg')`) is the failure this names.
- **The rail** (`data-region="rail"`) holds:
- the item's ordinal `#NN` (its number in the whole set, the same number
its tile shows);
- `K of M`, where K is its position in `review_chain` and M is the length
of `review_chain`. The tape's "N of M seen" uses the SAME M, and N counts
`.seen` ∩ `review_chain`;
- its position within its group, when the review RING spans two or more
groups (the gallery rail's own rule: one group for everything says
nothing);
- the caption;
- the flag form (`back=view`);
- notes and the add-note form (`back=view`);
- any open pick TARGETING this item, answerable here (`back=view`);
- the booth's other open picks as a count and a link.
- **The filmstrip** is `review_chain` in order, with ordinals, flagged frames
underlined and the current frame in the reticle.
- **The tape** (B's device) is one segment per `review_chain` item: seen /
flagged / current, plus "N of M seen".
- **The end of the set** is not a separate page. On the last ring item the
rail adds a summary block: the seen count, the flag tray, and EVERY OTHER
open pick, answerable in place.
- That includes picks targeting other items, not only booth-level ones: the
end of the set is where the remaining questions get cleared.
- Before the last item, the other picks are a count and a link.
- **Keys** (additive). **Every** key here, new and old, is ignored while focus
is in an `input`, `textarea`, `select` or `contenteditable`, the same
`isEditable` guard view.html carries today, so F never fires mid-note:
| key | action |
|---|---|
| ← → and Space | move. Shift+Space moves back. Space is left to a focused `<video>`/`<audio>` player, whose own play key it is |
| F | flag |
| N | focus the note |
| Esc | back to the grid, at `#item-<url>` so the grid scrolls to where you were |
### C7 — copy and brand (the two rulings that are not layout)
- **Voice: plain and direct** (ruling `voice=plain`). Every NEW string R2
introduces says what it means, with no villainy and no jokes. Existing
strings are unchanged unless their surface is rewritten.
- **No SVS emblem** anywhere in the Booth's chrome (ruling `emblem=no`). The
brand dot and the reticle favicon from the SVOS retheme stay.
## Invariants
- **INV-1 — one resolver.** `ordinal` is set in `booth_items`. No route computes
a position.
- **INV-2 — order, stated.** Each ordered surface has a one-line rule:
| surface | rule |
|---|---|
| items | `sorted(rel)` |
| ordinals | position in that |
| review ring | that, filtered to media |
| filmstrip, tape | the review ring |
| flag tray | by ordinal |
| Desk sections | fixed: needs → new → everything |
| needs you | `(open_since, name)` |
| new since you looked | `(-landed_at, name)` |
| everything else | `list_booths` order: `(mtime, name)` descending |
| bookmarks | `order_for_display` |
The notes list keeps `(created, id)`.
- **INV-3 — JS-off parity.** Every judgment, filter and jump works with
scripts disabled. The only JS-only affordances are:
- the keys;
- the in-place swap;
- the image 1:1 toggle (a viewing convenience, unchanged from today);
- the existing copy buttons and blur reveal.
The narrow-screen collapse is `<details>` and needs no script.
- **INV-4 — 303 byte-identity, for every request shape that existed before
R2.**
- For a request where `wants_json` is False and `back` is absent or
`marks`, each mark route's response (status, headers, body) is
byte-identical to its pre-R2 response.
- `back=view` is a NEW request shape with no pre-R2 counterpart. Its landing
is specified in C3 and is the one declared exception.
- **INV-5 — two named clocks.**
- `mtime` / `_newest_mtime`: activity. It includes dotfiles and excludes
locks, and it feeds lifetime and 'everything else'.
- `landed_at`: content only (non-dot entries), and it feeds 'new since you
looked'.
- Never the one where the other is meant: a mark or a view is not new
content, and new content is not the only activity.
- **INV-6 — no second renderer.** The in-place script inserts server-rendered
HTML and builds none.
- **INV-7 — autoescape.** No `|safe` on any booth name, item name, caption,
why or mark text. The flag tray and filmstrip render names through the same
escaping path as the grid.
- **INV-8 — blur honesty.** A blurred item stays blurred on every new surface:
the Desk preview strip, the flag tray, the filmstrip and the review stage.
Reveal stays per-BROWSER and client-side (nothing persisted). Copy keeps admitting it is cosmetic.
## Assertions that change (declared before the code, per CLAUDE.md)
| test | today | after R2 | why |
|---|---|---|---|
| test_booth.py L785 | `class="grid kept-grid"` present when a booth is kept | absent; the kept booth appears in its Desk section with the `kept` lifetime line | requirement 8: the lanes sort nothing |
| test_booth.py L786 | `class="card card-kept"` present | replaced by the row carrying `data-kept="1"` | same |
| test_embed_browser.py `test_the_keyboard_flag_actually_submits` | pressing `f` causes a NAVIGATION (`page.expect_navigation()`), and the reloaded page shows the flag | pressing `f` causes NO navigation; the flag comes back from the server into the swapped tile. A window marker set before the keypress must survive, proving no reload | with JS on, the flag now applies in place (requirement 3). The gallery reload was the no-JS design working, not a defect, and the plain-form path is still pinned by the INV-4 golden. The defect R2 fixes is the full-size EJECTION, `view.html`'s flag form carrying no `back`. The test's real claim — the key reaches the server and the server's state comes back — is kept, and asserted more strictly |
| test_booth.py L789 | the kept booth renders BEFORE the ephemeral one (`html.index("links") < html.index("scratch")`) | replaced by the Desk's stated order (needs → new → everything, each with its own key) | the kept-first order was the lane's; with no lane there is no kept-first rule, and a second hidden ordering would break INV-2 |
| test_booth.py L810-811 | lane absent when nothing is kept | these two SURVIVE unchanged (they assert absence and stay true) | — |
Every other existing assertion is expected to survive, and one of the TDD
slices is "the whole suite green before any new test". Named because they were
checked:
- the `vnav vprev` / `vnav vnext` anchors (test_booth L569-591 and
test_navigation L337) keep their classes and hrefs;
- `Wipe now` stays in the booth header;
- `class="boothhead"` stays.
## Accepted risks (named, not fixed)
- **`.seen` is read-modify-write without a lock.** Two reviews of the same
booth racing can drop one rel from `.seen`. The cost is cosmetic — a frame
shown unseen on the tape — and the next look repairs it; a lock would buy a
cosmetic count at the price of a lock file the lifetime clock must ignore.
- **A `.viewed` symlink planted by an agent freezes 'new'.** `viewed_at`
reads it by `lstat`, and `record_view` refuses to write through it
(`O_NOFOLLOW`), so the marker never moves again: once content lands after
it, the booth reads as 'new' however often it is opened. It fails in the
visible direction — shown, never hidden — and needs write access to the
booth, which already buys worse. The remedy is deleting the link.
- **`Item` gains `ordinal` with no default.** `booth_items` is the single
construction site, keyword-only; a default would let a second site forget
it silently (INV-1).
## Out of scope
- Compare (r3).
- Thumbnails.
- 1–9 answer keys.
- Lifetime policy for answered picks.
- The verbatim path. A verbatim booth's media items remain reachable at
`view?f=` by URL, as today, and nothing in the verbatim page links there.
- The link-board page (`/b/links/`) beyond CSS.
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -771,7 +771,13 @@ def test_sentinel_is_not_counted_as_an_item(tmp_path):
assert booth["count"] == 1 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 c, data = client
_touch(data / "scratch" / "a.png") _touch(data / "scratch" / "a.png")
_touch(data / "links" / "a.png") _touch(data / "links" / "a.png")
@@ -779,14 +785,9 @@ def test_index_separates_kept_from_ephemeral(client):
html = c.get("/").text html = c.get("/").text
# Assert on the lane's markup, not on the word "Kept" — that string also assert 'data-booth="links" data-kept="1"' in html
# appears in the stylesheet comment that is served on every page, so a bare assert 'data-booth="scratch" data-kept="0"' in html
# substring check passes for the wrong reason. assert 'class="grid kept-grid"' not in html, "no lane: kept is a fact, not a grouping"
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")
def test_kept_booth_shows_kept_instead_of_a_countdown(client): def test_kept_booth_shows_kept_instead_of_a_countdown(client):
+15 -4
View File
@@ -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 a hidden input does not submit its form. The shortcut never worked while
still swallowing the keystroke. still swallowing the keystroke.
Asserted end to end: press f, and the flag must come back from the server Asserted end to end: press f, and the flag must come back from the server.
on the reloaded page."""
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 base, root = live
_gallery(root) _gallery(root)
page = browser.new_page() page = browser.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle") page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
page.keyboard.press("ArrowRight") # cursor onto the first tile 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() flagged = page.locator("figure.item.is-flagged").count()
survived = page.evaluate("window.__noReload === 1")
page.close() page.close()
assert flagged == 1, f"the f key flagged {flagged} items, expected 1" assert flagged == 1, f"the f key flagged {flagged} items, expected 1"
assert survived, "the flag reloaded the page; in-place judgment must not"
+799
View File
@@ -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'<figure class="item[^"]*"[^>]*>.*?</figure>', 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'<section class="desk-sec"[^>]*data-section="(\w+)"[^>]*>(.*?)</section>',
body, re.S):
out[sec] = re.findall(r'<article class="desk-row[^"]*" data-booth="([^"]+)"', inner)
return out
def test_the_desk_triages_needs_you_then_new_then_everything_else(tmp_path):
"""The tracer for C4: three sections, always in this order, each booth in
exactly one of them."""
from booth.marks import declare_pick
t0 = time.time() - 10_000 # in the PAST: a future stamp outranks every real write and hides the bug
asks = _booth(tmp_path, "asks", {"a.png": PNG})
declare_pick(asks, "q", {"prompt": "Which?", "options": ["x", "y"]})
_booth(tmp_path, "fresh", {"a.png": PNG}) # never looked at
seen = _booth(tmp_path, "seen", {"a.png": PNG})
_at(seen / "a.png", t0)
(seen / ".viewed").write_bytes(b"")
_at(seen / ".viewed", t0 + 60) # looked AFTER it landed
body = _client(tmp_path).get("/").text
assert _desk(body) == {"needs": ["asks"], "new": ["fresh"], "rest": ["seen"]}
assert body.index('data-section="needs"') < body.index('data-section="new"') \
< body.index('data-section="rest"')
def _set_created(booth: pathlib.Path, mark_id: str, created: str) -> 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}) <sub>· {who} · 2026-09-01 10:00</sub>\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".*?</section>', body, re.S).group(0)
assert "live one" in benches and "old one" not in benches
marks = re.search(r'data-panel="bookmarks".*?</section>', 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".*?</section>', 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'<article class="desk-row[^"]*" data-booth="g".*?</article>', body, re.S).group(0)
imgs = re.findall(r'<img class="([^"]*)" loading="lazy" src="/b/g/([^"]+)"', row)
assert imgs == [("", "a.png"), ("blurred-thumb", "b.png"), ("", "c.png"), ("", "d.png")]
assert "2 flagged" in row
def test_a_booth_without_images_shows_its_kind_instead(tmp_path):
_booth(tmp_path, "songs", {"a.mp3": b"ID3", "b.mp3": b"ID3"})
row = re.search(r'data-booth="songs".*?</article>', _client(tmp_path).get("/").text, re.S).group(0)
assert "♪ audio" in row and "<img" not in row
# ---- C5: the lightbox ---------------------------------------------------------
def _region(body: str, rid: str) -> 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('<aside class="verdict"')
assert 'action="/b/g/answer"' in aside and "Which one?" in aside
assert body.count('action="/b/g/answer"') == 1, "the pick renders once, in the aside"
assert 'class="lightbox"' in body and 'id="grid"' in body
def test_the_flag_tray_lists_flags_by_tile_number_not_by_click_order(tmp_path):
"""Flag #3 first, then #1: the tray reads #1, #3. Today's panel list is in
click order `(created, id)`; the tray is the SET's order, the declared
change. Each entry is the original shown small, blurred if the item is."""
from booth.app import set_blurred
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG})
set_flag(b, "c.png", True)
time.sleep(0.01)
set_flag(b, "a.png", True)
set_blurred(b, "a.png", True)
aside = _region(_client(tmp_path).get("/b/g/").text, "verdict")
tray = re.findall(r'<a class="tray-item( is-blurred)?" href="view\?f=([^"]+)"[^>]*>.*?#(\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'<div class="grp-head"[^>]*><span class="grp-key">(\w+)</span> <span class="grp-n">(\d+)</span>', grid)
assert heads == [("aa", "2"), ("bb", "2"), ("cc", "2")]
assert len(re.findall(r'<figure class="item', grid)) == 6
# and no header at all when grouping is not informative
_booth(tmp_path, "flat", {f"DSC{n}.jpg": PNG for n in range(4)})
assert 'class="grp-head"' not in _client(tmp_path).get("/b/flat/").text
def test_every_mark_dependent_element_is_a_swappable_region(tmp_path):
"""The in-place script replaces regions by id; a stale flag count or tile
after an in-place flag is the failure this pins."""
b = _booth(tmp_path, "g", {"a b.png": PNG, "c.png": PNG})
body = _client(tmp_path).get("/b/g/").text
for rid in ("verdict", "filters", "item-a%20b.png", "item-c.png", "status"):
assert f'data-region="{rid}"' in body, rid
# the standing board has no lightbox and therefore no verdict aside
_booth(tmp_path, "links", {"links.md": b"- [x](http://x/) <sub>\xc2\xb7 a \xc2\xb7 2026-09-01 10:00</sub>\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"<audio\b[^>]*\bcontrols\b", stage.split("</div>")[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'<a class="film-f([^"]*)"\s+href="\?f=([^"]+)"', _region(body, "film"))
assert [(rel, cls.split()) for cls, rel in film] == [
("a.png", []), ("c.png", ["is-current"]), ("d.mp3", ["is-flagged"])]
assert re.findall(r'class="film-ord">#(\d)<', body) == ["1", "3", "4"] # whole-set numbers
tape = _region(body, "tape")
assert re.findall(r'<a class="tape-s([^"]*)"', tape) == [" is-seen", " is-current", " is-flagged"]
assert "2 of 3 seen" in tape
def test_a_question_about_this_item_is_answerable_here_and_the_rest_wait_for_the_end(tmp_path):
"""The rail offers a pick TARGETING the item; booth-level picks are a count
and a link — until the last item, where the end of the set offers them
all, each landing back on the review."""
from booth.marks import declare_pick
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
declare_pick(b, "about-a", {"prompt": "Is a sharp?", "options": ["yes", "no"]}, target="a.png")
declare_pick(b, "overall", {"prompt": "Ship the set?", "options": ["yes", "no"]})
c = _client(tmp_path)
first = _region(c.get("/b/g/view?f=a.png").text, "rail")
assert "Is a sharp?" in first and "Ship the set?" not in first
assert "1 more open question on this booth" in first
assert first.count('name="back" value="view"') >= 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'<form class="mark-form"[^>]*>.*?Ship the set\?|Ship the set\?.*?</form>', 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"<video\b[^>]*\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'<header class="topbar">.*?</header>', body, re.S)
if m:
assert "<img" not in m.group(0) and "<svg" not in m.group(0), path
# ---- fixups from the heid code-review panel (round "Wren") --------------------
@pytest.mark.parametrize("accept,want", [
("application/json", True),
("application/json;q=0.5", True),
("application/json;q=abc", False), # malformed q alone
("application/json, application/json;q=broken", False), # malformed AFTER a good one
("application/json;q=broken, application/json", False),
("application/json, text/plain;q=nope", False), # any unparseable entry
])
def test_wants_json_fails_closed_on_any_unparseable_entry(accept, want):
"""C3: any header that fails to parse is False, whatever order its entries
come in. The first cut returned True as soon as it met a good JSON entry,
so a malformed one after it was never read (Wren W3, 3/4)."""
from booth.app import wants_json
assert wants_json(accept) is want
def test_a_board_booth_keeps_its_single_column_even_with_media_in_it(tmp_path):
"""C5: ANYTHING with links.md is a board — page identity, not page content
(the lesson `is_board` already carries). A board with an image and a
links.md that parses to no rows must not get the lightbox (Wren W2, 3/4)."""
_booth(tmp_path, "links", {"links.md": b"just prose, no rows\n", "a.png": PNG})
body = _client(tmp_path).get("/b/links/").text
assert 'class="lightbox"' not in body and 'data-region="verdict"' not in body
def test_the_header_count_and_lifetime_line_are_a_region_too(tmp_path):
"""Answering the last open pick in place must not leave "1 open" and
"held until answered" stale in the header (Wren, hulda) — they depend on
marks, so by C3's rule they are a region."""
from booth.marks import declare_pick
b = _booth(tmp_path, "g", {"a.png": PNG})
declare_pick(b, "q", {"prompt": "?", "options": ["x", "y"]})
status = _region(_client(tmp_path).get("/b/g/").text, "booth-status")
assert "1 open" in status and "held until answered" in status
def test_a_booth_with_marks_and_no_items_keeps_its_panel_in_a_region(tmp_path):
"""No set, so no lightbox — but the panel's forms are in-place, so the
panel must be a region or a note written there shows nowhere (Wren, hulda)."""
from booth.marks import write_note
b = _booth(tmp_path, "g", {})
write_note(b, None, "only a note")
body = _client(tmp_path).get("/b/g/").text
assert "only a note" in _region(body, "marks-panel")
def test_interleaved_groups_get_no_inline_headers(tmp_path):
"""Groups come from basenames, the order from full paths, so groups can
interleave: d1/aa, d1/bb, d2/aa, d2/bb. Re-printing 'aa' and 'bb' would
claim runs that are not there, and one header per group would file d2/aa
under 'bb'. Headers render only when every group is one contiguous run
(Wren, hulda). The rail's jump links are unaffected."""
_booth(tmp_path, "g", {"d1/aa-1.png": PNG, "d1/bb-1.png": PNG,
"d2/aa-2.png": PNG, "d2/bb-2.png": PNG})
body = _client(tmp_path).get("/b/g/").text
assert 'class="grp-head"' not in body
assert 'class="rail-g"' in body
def test_a_booth_with_damaged_marks_sorts_after_every_dated_question(tmp_path):
"""Mixed damage: one readable open pick (the OLDEST stamp here) plus one
entry that cannot be read. The booth is held 'unreadable', and the
contract puts unreadable booths after every booth with a dated question —
the damage is the thing to fix, not the age of the pick beside it (Wren,
groa)."""
import json
from booth.marks import declare_pick
mixed = _booth(tmp_path, "mixed", {"a.png": PNG})
clean = _booth(tmp_path, "clean", {"a.png": PNG})
declare_pick(mixed, "q", {"prompt": "?", "options": ["x", "y"]})
declare_pick(clean, "q", {"prompt": "?", "options": ["x", "y"]})
_set_created(mixed, "q", "2026-01-01T00:00:00+00:00")
_set_created(clean, "q", "2026-09-01T00:00:00+00:00")
doc = json.loads((mixed / ".marks.json").read_text())
doc["marks"].append({"id": "bad", "shape": "note", "created": 7, "text": "x"})
(mixed / ".marks.json").write_text(json.dumps(doc))
body = _client(tmp_path).get("/").text
assert _desk(body)["needs"] == ["clean", "mixed"]
def test_one_flag_predicate_everywhere_and_a_damaged_flag_counts_nowhere(tmp_path):
"""The Desk count, the tray, the filmstrip and the review button all read
ONE predicate: a readable flag mark on the item. An unreadable flag entry
is judgment we cannot see, and must not inflate a count it cannot be shown
in (Wren W4, groa/kimi)."""
import json
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
set_flag(b, "a.png", True)
doc = json.loads((b / ".marks.json").read_text())
doc["marks"].append({"id": "flag:b.png", "shape": "flag", "target": "b.png", "created": 9})
(b / ".marks.json").write_text(json.dumps(doc))
c = _client(tmp_path)
row = re.search(r'data-booth="g".*?</article>', 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".*?</ul>', 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".*?</article>', 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'<figure[^>]*data-item="%s".*?</figure>' % 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".*?</section>', body, re.S).group(0)
assert 'href="javascript:' not in panel
assert 'href="https://example.test/"' in panel and "evil" in panel
+376
View File
@@ -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 <input> — 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 <details> 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) <sub>· a · 2026-09-01 10:00</sub>\n"
"- [two](http://x/2) <sub>· a · 2026-09-01 10:01</sub>\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"}