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:
+335
-25
@@ -37,14 +37,19 @@ import asyncio
|
||||
import fcntl
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -87,6 +92,10 @@ from booth.items import ( # noqa: E402,F401
|
||||
doc_kind,
|
||||
find_item,
|
||||
image_chain,
|
||||
review_chain,
|
||||
REVIEW_KINDS,
|
||||
SEEN_FILE,
|
||||
read_seen,
|
||||
read_blurred,
|
||||
render_doc,
|
||||
render_doc_body,
|
||||
@@ -249,6 +258,62 @@ def _newest_mtime(path: Path) -> float:
|
||||
return newest
|
||||
|
||||
|
||||
def _content_mtime(path: Path) -> float:
|
||||
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
|
||||
files and symlinks (by lstat) with no dot-component in their path. Deliberately NOT `_newest_mtime`
|
||||
(INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
|
||||
content, so none of them may make a booth read as newly landed.
|
||||
|
||||
Files only, never directories: creating `.viewed` bumps the booth
|
||||
directory's own mtime, and counting that would make the first look at a
|
||||
booth look like a delivery. An empty booth landed at 0.0. One unreadable
|
||||
ENTRY is skipped; a booth whose walk cannot run at all reads as NOW, the
|
||||
posture `_newest_mtime` takes and for a milder reason here: a booth we
|
||||
cannot read is shown as new rather than hidden as old.
|
||||
"""
|
||||
newest = 0.0
|
||||
try:
|
||||
for p in path.rglob("*"):
|
||||
rel = p.relative_to(path)
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
try:
|
||||
# lstat: a posted SYMLINK counts by its own mtime — when it was
|
||||
# placed — never by its target's. A link to a busy file outside
|
||||
# the booth must not make the booth read as newly delivered.
|
||||
st = p.lstat()
|
||||
except OSError:
|
||||
# One unreadable entry costs that entry, not the booth: reading
|
||||
# the whole booth as landed NOW would pin it in "new" forever.
|
||||
continue
|
||||
if (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)) and st.st_mtime > newest:
|
||||
newest = st.st_mtime
|
||||
except OSError:
|
||||
return time.time()
|
||||
return newest
|
||||
|
||||
|
||||
def _viewed_at(path: Path) -> float | None:
|
||||
"""The mtime of the booth's `.viewed` marker (U4), or None if it has never
|
||||
been looked at. `lstat`, like `is_kept`: a planted symlink is read as the
|
||||
marker it claims to be, never followed."""
|
||||
try:
|
||||
return os.lstat(path / VIEW_MARKER).st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _stamp(created: str) -> datetime | None:
|
||||
"""A mark's `created` as an aware datetime, or None when it will not parse.
|
||||
Strings are never compared: two ISO stamps with different offsets sort
|
||||
wrong as text. A naive stamp is read as UTC."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(created)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def booth_age_seconds(path: Path, now: float | None = None) -> float:
|
||||
now = time.time() if now is None else now
|
||||
return now - _newest_mtime(path)
|
||||
@@ -319,6 +384,104 @@ def record_view(booth: Path) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None:
|
||||
"""Note that `rel` was looked at full size (R2 C2).
|
||||
|
||||
Rewrites the whole marker — the previous set plus `rel`, pruned to rels that
|
||||
are still items, sorted — so it is deduplicated and never outgrows the
|
||||
booth. Atomic replace (CLAUDE.md invariant 5) through a temp file created
|
||||
with O_EXCL: a planted `.seen.tmp` symlink cannot redirect the write, and
|
||||
`os.replace` swaps a planted `.seen` symlink out rather than writing
|
||||
through it.
|
||||
|
||||
NEVER RAISES, for `record_view`'s reason: not recording a look is a cost
|
||||
this service can absorb, not answering the request is not.
|
||||
"""
|
||||
try:
|
||||
live = {it.rel for it in items}
|
||||
seen = (read_seen(booth) | {rel}) & live
|
||||
# A JSON array, UTF-8 explicitly: a rel may hold a newline or a leading
|
||||
# space, and the host locale must not decide whether a name encodes.
|
||||
body = json.dumps(sorted(seen), ensure_ascii=False).encode("utf-8", "surrogateescape")
|
||||
fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as fh:
|
||||
fh.write(body)
|
||||
os.replace(tmp, booth / SEEN_FILE)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except (OSError, ValueError):
|
||||
# ValueError covers an encode failure — it is not an OSError, and a
|
||||
# look that cannot be recorded must never cost the page.
|
||||
pass
|
||||
|
||||
|
||||
def wants_json(accept: str | None) -> bool:
|
||||
"""Whether a mark POST asked for the in-place answer (R2 C3).
|
||||
|
||||
True ONLY when the Accept header lists `application/json` exactly —
|
||||
parameters stripped — with a q-value that is absent or above zero. Absent,
|
||||
empty, wildcard, `application/*`, a near miss like `application/jsonx`, an
|
||||
explicit `q=0`, a malformed q: all False. It FAILS TOWARD THE 303, because
|
||||
the plain form's redirect is the no-JS guarantee and a mis-parse must land
|
||||
there, never on a 204 a browser would render as nothing happening.
|
||||
"""
|
||||
if not accept:
|
||||
return False
|
||||
# EVERY entry is parsed before anything is decided: returning True at the
|
||||
# first good JSON entry meant a malformed one after it was never read, so
|
||||
# `application/json, application/json;q=broken` was a 204 and the same pair
|
||||
# reversed a 303 (heid code-review, 3/4). One unparseable entry anywhere
|
||||
# makes the whole header False.
|
||||
wanted = False
|
||||
try:
|
||||
for entry in accept.split(","):
|
||||
mtype, *params = entry.split(";")
|
||||
q = 1.0
|
||||
for param in params:
|
||||
key, _, value = param.partition("=")
|
||||
if key.strip().lower() == "q":
|
||||
q = float(value.strip())
|
||||
if not math.isfinite(q):
|
||||
# inf, 1e999, nan parse as floats but are not q-values
|
||||
raise ValueError("non-finite q")
|
||||
if mtype.strip().lower() == "application/json" and q > 0:
|
||||
wanted = True
|
||||
except ValueError:
|
||||
return False
|
||||
return wanted
|
||||
|
||||
|
||||
def flagged_targets(marks: Sequence[Mark]) -> set[str]:
|
||||
"""THE flag predicate (R2): the items carrying a READABLE flag mark. The
|
||||
Desk count, the tray, the filmstrip, the tape and the review button all
|
||||
read this, so they cannot disagree about one item. An unreadable flag
|
||||
entry is judgment nobody can see, and counts nowhere."""
|
||||
return {m.target for m in marks
|
||||
if m.shape == "flag" and m.error is None and m.target}
|
||||
|
||||
|
||||
def _contiguous(keys: Sequence[str | None]) -> bool:
|
||||
"""True when each non-None key occupies ONE unbroken run of the sequence."""
|
||||
seen: set[str] = set()
|
||||
prev = object()
|
||||
for k in keys:
|
||||
if k != prev:
|
||||
if k is not None and k in seen:
|
||||
return False
|
||||
if k is not None:
|
||||
seen.add(k)
|
||||
prev = k
|
||||
return True
|
||||
|
||||
|
||||
# The Desk shows this many bookmarks and links to the board for the rest.
|
||||
BOOKMARKS_SHOWN = 8
|
||||
|
||||
HOLD_UNREADABLE = "unreadable"
|
||||
HOLD_OPEN = "open"
|
||||
|
||||
@@ -464,6 +627,10 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
# rather than re-opening .blurred here.
|
||||
thumb_blurred = it.blurred
|
||||
mtime = _newest_mtime(child)
|
||||
# R2 C4: the oldest question still owed an answer, PARSED. Unparseable
|
||||
# stamps are left out, so a booth whose every open pick is unparseable
|
||||
# has no `open_since` and sorts after every booth that has one.
|
||||
stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st]
|
||||
booths.append(
|
||||
{
|
||||
"name": child.name,
|
||||
@@ -490,6 +657,23 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
||||
"hold": hold,
|
||||
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||
"mtime": mtime,
|
||||
# ---- R2 C4, the Desk. All from the pass above; no second read.
|
||||
# A booth held for UNREADABLE marks has no `open_since`, even if
|
||||
# a readable pick sits beside the damage: it sorts after every
|
||||
# dated question, because the damage is what needs fixing.
|
||||
"open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE
|
||||
else None),
|
||||
# items that EXIST: a flag on a file since deleted is shown on
|
||||
# the booth page for withdrawal, not counted as a pick here
|
||||
"flags": len(flagged_targets(marks) & {it.rel for it in items}),
|
||||
# Two clocks, named apart (INV-5): `mtime` is activity,
|
||||
# `landed_at` is content. "New since you looked" reads only the
|
||||
# second, so a flag or a view never makes a booth look new.
|
||||
"landed_at": _content_mtime(child),
|
||||
"viewed_at": _viewed_at(child),
|
||||
# The first four images in item order, as the originals shown
|
||||
# small. Blurred ones stay blurred, the cover's rule.
|
||||
"preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4],
|
||||
}
|
||||
)
|
||||
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
|
||||
@@ -522,6 +706,8 @@ def build_gallery(child: Path) -> list[dict]:
|
||||
"section": it.section,
|
||||
# U7. Derived in the resolver (INV-1); this only carries it.
|
||||
"group": it.group,
|
||||
# R2 C1. Same rule: the resolver numbers, this carries.
|
||||
"ordinal": it.ordinal,
|
||||
"caption": it.caption,
|
||||
"rendered": rendered,
|
||||
"rendered_html": rendered_html,
|
||||
@@ -559,9 +745,10 @@ def _zip_filename(name: str) -> str:
|
||||
# the same icon. Keep the two in sync if the Booth's icon ever changes.
|
||||
FAVICON_HREF = (
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
|
||||
"%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
|
||||
"cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
|
||||
"cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
|
||||
"%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5"
|
||||
"M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' "
|
||||
"stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' "
|
||||
"fill='%23b2cd12'/%3E%3C/svg%3E"
|
||||
)
|
||||
|
||||
# The seam a verbatim report declares to get the Booth's chrome. ONE line, and
|
||||
@@ -711,6 +898,7 @@ def create_app(
|
||||
sweep_interval_s: int = 900,
|
||||
max_upload_mb: float = 1024.0,
|
||||
max_files: int = 50,
|
||||
links_board: str = "links",
|
||||
) -> FastAPI:
|
||||
data_dir = Path(data_dir).expanduser().resolve()
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -821,19 +1009,49 @@ def create_app(
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
# Two lanes, split here rather than in the template: kept boards are a
|
||||
# different KIND of thing from the ephemeral churn — durable, deliberate,
|
||||
# operator-facing — and burying them in a feed that turns over daily is
|
||||
# exactly how they would get lost, which is the problem they exist to
|
||||
# solve. Kept renders first.
|
||||
"""THE DESK (R2 C4) — the index triaged by what needs the operator.
|
||||
|
||||
Three sections, ALWAYS in this order, each booth in exactly one:
|
||||
needs — an open pick, or marks that cannot be read (somebody has to
|
||||
fix those, so they must not hide further down). Oldest open
|
||||
question first; a booth with no parseable stamp after every
|
||||
booth that has one; name breaks ties.
|
||||
new — content landed since the booth was last looked at, or never
|
||||
looked at. Newest content first; name breaks ties.
|
||||
rest — everything else, in `list_booths`' own order (last activity
|
||||
first, name as the tie-break). No second rule is stated.
|
||||
The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so
|
||||
the lanes sorted nothing. Kept status still shows on every row.
|
||||
"""
|
||||
everything = list_booths(data_dir, ttl_seconds)
|
||||
needs = [b for b in everything
|
||||
if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE]
|
||||
needs.sort(key=lambda b: ((0, b["open_since"].timestamp())
|
||||
if b["open_since"] else (1, 0.0), b["name"]))
|
||||
in_needs = {b["name"] for b in needs}
|
||||
new = [b for b in everything if b["name"] not in in_needs
|
||||
and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])]
|
||||
new.sort(key=lambda b: (-b["landed_at"], b["name"]))
|
||||
in_new = {b["name"] for b in new}
|
||||
rest = [b for b in everything
|
||||
if b["name"] not in in_needs and b["name"] not in in_new]
|
||||
benches, benches_error = read_benches(data_dir)
|
||||
board = data_dir / links_board
|
||||
bookmarks = [row for row in _board_rows(board)
|
||||
if booth_target(row["url"]) is None] if board.is_dir() else []
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"index.html",
|
||||
{
|
||||
**base_ctx,
|
||||
"kept": [b for b in everything if b["kept"]],
|
||||
"booths": [b for b in everything if not b["kept"]],
|
||||
"needs": needs,
|
||||
"new": new,
|
||||
"rest": rest,
|
||||
"benches": [b for b in benches if b.state != "retired"],
|
||||
"benches_error": benches_error,
|
||||
"bookmarks": bookmarks[:BOOKMARKS_SHOWN],
|
||||
"bookmarks_total": len(bookmarks),
|
||||
"board_url": f"/b/{quote(links_board, safe='')}/",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -970,6 +1188,27 @@ def create_app(
|
||||
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
|
||||
},
|
||||
"booth_marks": marks_for_target(marks, None),
|
||||
# R2 C5: the flag tray — the flagged items in SET order, i.e.
|
||||
# by ordinal, over the full gallery (a filter hides tiles, not
|
||||
# judgments). Order is total with no tie-break: rels are unique.
|
||||
# Inline group headers only when every group is ONE contiguous
|
||||
# run in the rendered order. Groups come from basenames and the
|
||||
# order from full paths, so they can interleave (d1/aa, d1/bb,
|
||||
# d2/aa); a header then either repeats or files an item under
|
||||
# the wrong group. The rail's jump links do not depend on this.
|
||||
"inline_groups": bool(rail["groups"]) and _contiguous(
|
||||
[it["group"] for it in shown]),
|
||||
# THE flag predicate for this page — the tile class and the tile
|
||||
# toggle read it too, so no surface on the page can disagree.
|
||||
"flagged_set": flagged_targets(marks),
|
||||
"tray": [it for it in gallery if it["name"] in flagged_targets(marks)],
|
||||
# A flag whose file is gone from the booth: no tile to stamp and
|
||||
# no tray slot, so it is listed apart with its withdraw control
|
||||
# rather than vanishing from the page while staying in the file.
|
||||
"orphan_flags": [m for m in marks
|
||||
if m.shape == "flag" and m.error is None and m.target
|
||||
and m.target not in {it["name"] for it in gallery}],
|
||||
"ord_width": len(str(len(gallery))),
|
||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||
# The same provenance line the index card carries. Deliberate:
|
||||
# a booth URL handed to the operator lands HERE, never on the
|
||||
@@ -1144,8 +1383,32 @@ def create_app(
|
||||
base = f"/b/{quote(name, safe='')}/"
|
||||
if form.get("back") == "marks":
|
||||
base = f"/b/{quote(name, safe='')}/marks"
|
||||
elif form.get("back") == "view":
|
||||
# R2 C3: judgment made at full size lands back at full size — the
|
||||
# JS-off fix for being thrown out to the grid. Only for a MEDIA item
|
||||
# of this booth; anything else takes the no-`back` landing above.
|
||||
# Built from the resolved rel, never echoed from the form.
|
||||
f = form.get("f")
|
||||
if isinstance(f, str) and f:
|
||||
try:
|
||||
ring = review_chain(booth_items(resolve_booth(name)))
|
||||
except HTTPException:
|
||||
ring = []
|
||||
if f in ring:
|
||||
return RedirectResponse(
|
||||
url=f"/b/{quote(name, safe='')}/view?f={quote(f, safe='/')}#rail",
|
||||
status_code=303)
|
||||
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)
|
||||
|
||||
def _mark_done(request: Request, name: str, form, anchor: str) -> Response:
|
||||
"""The one exit for every mark route (R2 C3). A request that asked for
|
||||
the in-place answer gets 204 and no body — the page fetches its own
|
||||
fresh regions. Everything else gets `_mark_redirect`, byte for byte what
|
||||
it got before R2 (INV-4)."""
|
||||
if wants_json(request.headers.get("accept")):
|
||||
return Response(status_code=204)
|
||||
return _mark_redirect(name, form, anchor)
|
||||
|
||||
@app.post("/b/{name}/answer")
|
||||
async def booth_answer(request: Request, name: str):
|
||||
"""Record the operator's pick — one of N options a session declared in
|
||||
@@ -1196,7 +1459,7 @@ def create_app(
|
||||
_form_text(form, "choice"), notes, who=who)
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
|
||||
return _mark_done(request, name, form, f"mark-{quote(mark_id, safe='')}")
|
||||
|
||||
@app.post("/b/{name}/note")
|
||||
async def booth_note(request: Request, name: str):
|
||||
@@ -1217,7 +1480,7 @@ def create_app(
|
||||
who=request.client.host if request.client else "")
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}")
|
||||
return _mark_done(request, name, form, f"mark-{quote(mark.id, safe='')}")
|
||||
|
||||
@app.post("/b/{name}/flag")
|
||||
async def booth_flag(request: Request, name: str):
|
||||
@@ -1239,7 +1502,7 @@ def create_app(
|
||||
who=request.client.host if request.client else "")
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _mark_redirect(name, form, f"item-{quote(target, safe='')}")
|
||||
return _mark_done(request, name, form, f"item-{quote(target, safe='')}")
|
||||
|
||||
@app.post("/b/{name}/unmark")
|
||||
async def booth_unmark(request: Request, name: str):
|
||||
@@ -1251,7 +1514,7 @@ def create_app(
|
||||
if not isinstance(mark_id, str) or not mark_id:
|
||||
raise HTTPException(status_code=400, detail="which mark?")
|
||||
await run_in_threadpool(delete_mark, booth, mark_id)
|
||||
return _mark_redirect(name, form, "marks")
|
||||
return _mark_done(request, name, form, "marks")
|
||||
|
||||
@app.post("/b/{name}/import-asks")
|
||||
async def booth_import_asks(request: Request, name: str):
|
||||
@@ -1469,7 +1732,9 @@ def create_app(
|
||||
booth = resolve_booth(name)
|
||||
try:
|
||||
target = (booth / f).resolve()
|
||||
except OSError:
|
||||
except (OSError, ValueError):
|
||||
# ValueError: an embedded NUL. Hostile input, like every other
|
||||
# unresolvable `f` — a 404, never a 500.
|
||||
raise HTTPException(status_code=404, detail="no such file")
|
||||
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="no such file")
|
||||
@@ -1484,6 +1749,10 @@ def create_app(
|
||||
# of a thing that is not an item is not a view of the booth.
|
||||
if item is not None:
|
||||
record_view(booth)
|
||||
# R2 C2: WHICH item was looked at — media only, the ring the tape
|
||||
# draws. Same gate as the view above, and it never raises either.
|
||||
if item.kind in REVIEW_KINDS:
|
||||
record_seen(booth, item.rel, items)
|
||||
marks = marks_for(booth)
|
||||
item_marks = marks_for_target(marks, f)
|
||||
common = {
|
||||
@@ -1500,20 +1769,58 @@ def create_app(
|
||||
# notes and the flag state travel to full size, which is the size at
|
||||
# which the judgment is actually being made.
|
||||
"marks": item_marks,
|
||||
"flagged": any(m.shape == "flag" for m in item_marks),
|
||||
"flagged": f in flagged_targets(marks),
|
||||
}
|
||||
|
||||
if item is not None and item.kind == "image":
|
||||
# prev/next ring (wraps; only when there is more than one image)
|
||||
names = image_chain(items)
|
||||
if item is not None and item.kind in REVIEW_KINDS:
|
||||
# THE REVIEW (R2 C6): images, video and audio at full size with the
|
||||
# judgment on screen. The ring is `review_chain` — the item order
|
||||
# filtered to MEDIA — and the prev/next, the filmstrip and the tape
|
||||
# all read that ONE list, so they cannot disagree about "next".
|
||||
ring = review_chain(items)
|
||||
by_rel = {it.rel: it for it in items}
|
||||
pos = ring.index(f)
|
||||
prev_url = next_url = None
|
||||
if f in names and len(names) > 1:
|
||||
i = names.index(f)
|
||||
prev_url = quote(names[(i - 1) % len(names)], safe="/")
|
||||
next_url = quote(names[(i + 1) % len(names)], safe="/")
|
||||
if len(ring) > 1:
|
||||
prev_url = quote(ring[(pos - 1) % len(ring)], safe="/")
|
||||
next_url = quote(ring[(pos + 1) % len(ring)], safe="/")
|
||||
flagged_rels = flagged_targets(marks)
|
||||
# recorded above, before this read: the current item counts as seen
|
||||
seen = read_seen(booth) & set(ring)
|
||||
film = [{"name": r, "url": by_rel[r].url, "ordinal": by_rel[r].ordinal,
|
||||
"kind": by_rel[r].kind, "blurred": by_rel[r].blurred,
|
||||
"flagged": r in flagged_rels, "seen": r in seen,
|
||||
"current": r == f} for r in ring]
|
||||
# Position within the group, only when there IS grouping: two or
|
||||
# more groups among the ring. One group for everything says nothing.
|
||||
group = None
|
||||
ring_groups = {by_rel[r].group for r in ring if by_rel[r].group}
|
||||
if item.group and len(ring_groups) > 1:
|
||||
members = [r for r in ring if by_rel[r].group == item.group]
|
||||
group = {"key": item.group, "k": members.index(f) + 1, "n": len(members)}
|
||||
open_now = open_marks(marks)
|
||||
return templates.TemplateResponse(
|
||||
request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url}
|
||||
)
|
||||
request, "view.html", {
|
||||
**common,
|
||||
"kind": item.kind,
|
||||
"ordinal": item.ordinal,
|
||||
"ord_width": len(str(len(items))),
|
||||
"ring_k": pos + 1,
|
||||
"ring_m": len(ring),
|
||||
"prev_url": prev_url,
|
||||
"next_url": next_url,
|
||||
"film": film,
|
||||
"seen_n": len(seen),
|
||||
"group": group,
|
||||
# a question ABOUT this item is answerable here; the rest are
|
||||
# a count and a link — until the last item, where the end of
|
||||
# the set offers them all
|
||||
"item_picks": [m for m in open_now if m.target == f],
|
||||
"other_picks": [m for m in open_now if m.target != f],
|
||||
"is_last": pos == len(ring) - 1,
|
||||
"tray": [x for x in film if x["flagged"]],
|
||||
"back_url": f"/b/{quote(name, safe='')}/#item-{item.url}",
|
||||
})
|
||||
|
||||
# .md renders, .txt/.log show as text — viewable in-booth, no download
|
||||
if item is not None:
|
||||
@@ -1777,6 +2084,9 @@ def _from_env() -> FastAPI:
|
||||
sweep_interval_s=interval,
|
||||
max_upload_mb=max_mb,
|
||||
max_files=max_n,
|
||||
# The board the CLI's `booth link` writes (scripts/booth reads the same
|
||||
# variable), so the Desk's bookmarks come from where they are written.
|
||||
links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ See docs/contracts/u1_item_record.contract.md.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
@@ -95,6 +98,57 @@ class Item:
|
||||
blurred: bool
|
||||
doc: str | None
|
||||
size: int
|
||||
# R2 C1: the 1-based position in `booth_items` order over ALL items — the
|
||||
# number the operator means by "the third one". Set in the resolver loop
|
||||
# and nowhere else (INV-1). APPENDED, never inserted: a mid-dataclass field
|
||||
# is a positional-construction break.
|
||||
ordinal: int
|
||||
|
||||
|
||||
# R2 C2: which items have been looked at full size. UI state, not judgment —
|
||||
# never exposed to sessions, holds nothing. One viewer: this records WHAT was
|
||||
# seen, never who saw it.
|
||||
SEEN_FILE = ".seen"
|
||||
|
||||
|
||||
# A seen marker bigger than this is not one this service wrote: a JSON array of
|
||||
# every rel in a 270-item booth is a few KB.
|
||||
SEEN_MAX_BYTES = 1 << 20
|
||||
|
||||
|
||||
def read_seen(booth: Path) -> set[str]:
|
||||
"""Rels seen at full size (R2 C2). A JSON array of strings, because a rel
|
||||
may hold a leading space or a newline and must round-trip exactly.
|
||||
|
||||
NEVER RAISES and NEVER BLOCKS. Any fleet session can write into a booth,
|
||||
so the marker may be planted: it is opened without following a link and
|
||||
without blocking (a FIFO with no writer), refused unless it is a regular
|
||||
file of sane size, and anything unreadable or malformed reads as nothing
|
||||
seen — a damaged marker costs the tape its memory, never the page.
|
||||
"""
|
||||
try:
|
||||
fd = os.open(booth / SEEN_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
except OSError:
|
||||
return set()
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_size > SEEN_MAX_BYTES:
|
||||
return set()
|
||||
raw = os.read(fd, SEEN_MAX_BYTES + 1)
|
||||
except OSError:
|
||||
return set()
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError, RecursionError):
|
||||
# RecursionError: a deeply nested array (`[[[[...`) blows the parser's
|
||||
# stack, and it is neither a ValueError nor an OSError — the same hole
|
||||
# marks.py, manifest.py and benches.py already close.
|
||||
return set()
|
||||
if not isinstance(data, list):
|
||||
return set()
|
||||
return {r for r in data if isinstance(r, str)}
|
||||
|
||||
|
||||
def read_blurred(booth: Path) -> set[str]:
|
||||
@@ -273,6 +327,10 @@ def booth_items(booth: Path) -> list[Item]:
|
||||
blurred=rel in blurred,
|
||||
doc=doc_kind(p.name),
|
||||
size=size,
|
||||
# Counted over items that RENDER: a caption sidecar or a name
|
||||
# the quote() guard skipped takes no number, so the numbers
|
||||
# stay contiguous over what the operator can see.
|
||||
ordinal=len(items) + 1,
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -287,6 +345,18 @@ def image_chain(items: Sequence[Item]) -> list[str]:
|
||||
return [it.rel for it in items if it.kind == "image"]
|
||||
|
||||
|
||||
# R2 C2: what the review route steps through. ONE LINE: the item order
|
||||
# filtered to media. It is a declared change to the zoom-ring rule, which was
|
||||
# images only: a listening set is reviewed the same way a picture set is.
|
||||
REVIEW_KINDS = ("image", "video", "audio")
|
||||
|
||||
|
||||
def review_chain(items: Sequence[Item]) -> list[str]:
|
||||
"""The rels of the media items, in item order — the review's prev/next ring,
|
||||
its filmstrip and its tape."""
|
||||
return [it.rel for it in items if it.kind in REVIEW_KINDS]
|
||||
|
||||
|
||||
def find_item(items: Sequence[Item], rel: str) -> Item | None:
|
||||
"""The record for one rel, or None — the zoom/doc route's entry point."""
|
||||
for it in items:
|
||||
|
||||
+52
-36
@@ -28,53 +28,69 @@
|
||||
window.__boothEmbed = true;
|
||||
|
||||
var CSS = [
|
||||
/* SVOS values, written as literals: this sheet lands in a page we did not
|
||||
write, so it can lean on none of base.html's tokens. Hex equivalents of
|
||||
the SVOS semantic tokens (design-systems palettes/svos @ ed2f8d8). */
|
||||
/* ---- the way home, and the open-asks jump ---- */
|
||||
".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;",
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;",
|
||||
"text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}",
|
||||
"display:inline-block;margin:.6rem;padding:.38rem .75rem;border-radius:8px;",
|
||||
"text-decoration:none;letter-spacing:.01em;box-shadow:0 4px 14px rgba(0,0,0,.4)}",
|
||||
/* top-right: a top-left chip clips the page title on left-aligned report
|
||||
layouts, and this matches the zoom view's back affordance. */
|
||||
".booth-nav-home{right:0;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);",
|
||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .18s,border-color .18s}",
|
||||
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}",
|
||||
".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}",
|
||||
".booth-nav-asks:hover{filter:brightness(1.08)}",
|
||||
".booth-nav-home{right:0;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#dce3e5;background:rgba(12,16,20,.86);border:1px solid rgba(255,255,255,.16);",
|
||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .12s,border-color .12s}",
|
||||
".booth-nav-home:hover{background:rgba(31,35,40,.96);border-color:rgba(255,255,255,.34)}",
|
||||
/* amber = needs you: the one chip that asks to be clicked */
|
||||
".booth-nav-asks{right:7.4rem;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#15191d;background:#fbc10f;border:1px solid #fbc10f;transition:filter .12s}",
|
||||
".booth-nav-asks:hover{filter:brightness(1.06)}",
|
||||
"@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
|
||||
/* ---- ask fragments. Self-contained: the host page carries its own CSS and
|
||||
nothing here may inherit from it, so the palette adapts via
|
||||
prefers-color-scheme rather than borrowing. ---- */
|
||||
".bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);",
|
||||
"border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);",
|
||||
"font:15px/1.5 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
|
||||
".bk-ask.bk-done{border-top-color:#3fae6a}",
|
||||
".bk-ask.bk-skip{border-top-color:#6f7c8c}",
|
||||
".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}",
|
||||
".bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||
"letter-spacing:.12em;text-transform:uppercase;color:#c9a227}",
|
||||
".bk-ask.bk-done .bk-ask-tag{color:#3fae6a}",
|
||||
".bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}",
|
||||
nothing here may inherit from it. The palette is a set of custom
|
||||
properties SCOPED TO .bk-ask, flipped by prefers-color-scheme — so each
|
||||
rule below is written once and a host page cannot reach the values
|
||||
without targeting our own class. Neutrals stay translucent so the
|
||||
fragment sits on a light or a dark host alike. ---- */
|
||||
".bk-ask{--bk-accent:#b2cd12;--bk-accent-line:rgba(178,205,18,.55);--bk-accent-soft:rgba(178,205,18,.12);",
|
||||
"--bk-on-accent:#0c1014;--bk-open:#d29a02;--bk-open-text:#fbc10f;--bk-done:#71a166;--bk-done-text:#9bce90;",
|
||||
"--bk-skip:#868d91;--bk-err:#fea47d}",
|
||||
"@media (prefers-color-scheme: light){.bk-ask{--bk-accent:#586519;--bk-accent-line:rgba(88,101,25,.55);",
|
||||
"--bk-accent-soft:rgba(88,101,25,.11);--bk-on-accent:#fff;--bk-open:#7c5500;--bk-open-text:#7c5500;",
|
||||
"--bk-done:#486741;--bk-done-text:#486741;--bk-skip:#52595e;--bk-err:#a42e07}}",
|
||||
".bk-ask{margin:1.1rem 0;padding:.9rem 1rem;border:1px solid rgba(128,140,160,.34);",
|
||||
"border-top:2px solid var(--bk-open);border-radius:8px;background:rgba(128,140,160,.07);",
|
||||
"font:15px/1.55 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
|
||||
".bk-ask.bk-done{border-top-color:var(--bk-done)}",
|
||||
".bk-ask.bk-skip{border-top-color:var(--bk-skip)}",
|
||||
".bk-ask-tag{display:inline-block;margin-bottom:.6rem;padding:2px 7px;border:1.5px solid currentColor;border-radius:3px;",
|
||||
"font:600 10.5px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||
"letter-spacing:.12em;text-transform:uppercase;color:var(--bk-open-text)}",
|
||||
".bk-ask.bk-done .bk-ask-tag{color:var(--bk-done-text)}",
|
||||
".bk-ask.bk-skip .bk-ask-tag{color:var(--bk-skip)}",
|
||||
".bk-ask-title{margin:0 0 .15rem;font:500 11px/1.4 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||
"letter-spacing:.12em;text-transform:uppercase;opacity:.62}",
|
||||
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
|
||||
".bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}",
|
||||
".bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;",
|
||||
"border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}",
|
||||
".bk-ask-opts{display:flex;flex-direction:column;gap:.35rem}",
|
||||
".bk-ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.55rem .7rem;cursor:pointer;",
|
||||
"border:1px solid rgba(128,140,160,.3);border-radius:8px;background:rgba(128,140,160,.06)}",
|
||||
".bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}",
|
||||
".bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}",
|
||||
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}",
|
||||
".bk-ask-opt:has(input:checked){border-color:var(--bk-accent-line);background:var(--bk-accent-soft)}",
|
||||
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:var(--bk-accent)}",
|
||||
".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
|
||||
".bk-ask-det{font-size:.8rem;opacity:.68}",
|
||||
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;",
|
||||
".bk-ask-det{font-size:.82rem;opacity:.68}",
|
||||
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .65rem;",
|
||||
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
|
||||
"border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}",
|
||||
".bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;",
|
||||
"letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;",
|
||||
"background:#2fa8a0;color:#08131a}",
|
||||
".bk-ask-go:hover{filter:brightness(1.09)}",
|
||||
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}",
|
||||
"border:1px solid rgba(128,140,160,.34);border-radius:8px;resize:vertical}",
|
||||
".bk-ask-notes:focus{outline:2px solid var(--bk-accent);outline-offset:1px}",
|
||||
/* the primary — green, because submitting is what arms the answer */
|
||||
".bk-ask-go{margin-top:.75rem;cursor:pointer;font:600 13px/1 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);",
|
||||
"background:var(--bk-accent);color:var(--bk-on-accent)}",
|
||||
".bk-ask-go:hover{filter:brightness(1.06)}",
|
||||
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem;opacity:.8}",
|
||||
".bk-ask-was b{opacity:1}",
|
||||
".bk-ask-err{color:#d6452a;font-size:.86rem}",
|
||||
"@media (prefers-color-scheme: light){.bk-ask-tag{color:#8a6d10}.bk-ask-go{color:#fff}}",
|
||||
".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
|
||||
"@media print{.bk-ask{break-inside:avoid}}"
|
||||
].join("");
|
||||
|
||||
|
||||
+76
-19
@@ -26,13 +26,18 @@
|
||||
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
|
||||
<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 %}
|
||||
<article class="mark mark-note is-broken" id="mark-{{ a.id }}">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state">⚠ broken</span>
|
||||
<span class="mark-id"><code>{{ a.id }}</code></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 }}">
|
||||
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||
<button type="submit" class="mark-x" title="withdraw this mark">×</button>
|
||||
@@ -42,6 +47,7 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
{% 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 }}">
|
||||
<header class="mark-head">
|
||||
@@ -75,7 +81,7 @@
|
||||
{% endif %}
|
||||
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
|
||||
<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
|
||||
has already published POST that name, and breaking every landed
|
||||
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
|
||||
verbatim report that cannot show the recorded judgment. #}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
@@ -117,25 +124,52 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% for a in notes %}
|
||||
<article class="mark mark-note" id="mark-{{ a.id }}">
|
||||
{% if not picks_only %}
|
||||
{# FLAGS come right after the picks. On the lightbox (`tray` defined) they
|
||||
render as the TRAY: the flagged items in SET order — by tile number, the
|
||||
declared R2 change from the click order below — each the original shown
|
||||
small, blurred if the item is. The standalone marks page has no item
|
||||
records, so it keeps the list, in `(created, id)` order. #}
|
||||
{% if tray is defined %}{% if tray %}
|
||||
{# In the lightbox the tray and the notes FOLD on a narrow screen (R2 C5):
|
||||
a closed <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">
|
||||
<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">
|
||||
<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>
|
||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||
<span class="mark-id">{{ tray|length }} item{{ '' if tray|length == 1 else 's' }} · in set order</span>
|
||||
</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>
|
||||
{% endfor %}
|
||||
|
||||
{% if flags %}
|
||||
</details>
|
||||
{% endif %}
|
||||
{% 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">
|
||||
<header class="mark-head">
|
||||
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||
@@ -149,11 +183,34 @@
|
||||
</article>
|
||||
{% 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
|
||||
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 %}
|
||||
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
|
||||
<button type="submit">Add note</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+105
-32
@@ -19,8 +19,9 @@
|
||||
note field. Same macro discipline as blurtoggle above — three item branches,
|
||||
one definition. `marks` here is THIS item's marks, from item_marks. #}
|
||||
{% macro markcontrols(name_url, it, marks, cls='') -%}
|
||||
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
|
||||
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag">
|
||||
{# THE flag predicate (flagged_targets), shared with every other surface #}
|
||||
{% 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="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' }}"
|
||||
@@ -37,7 +38,7 @@
|
||||
{% for m in marks if m.shape == 'note' %}
|
||||
<div class="item-note" id="mark-{{ m.id }}">
|
||||
<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 }}">
|
||||
<button class="mark-x" title="withdraw this note">×</button>
|
||||
</form>
|
||||
@@ -45,7 +46,7 @@
|
||||
{% endfor %}
|
||||
<details class="item-addnote">
|
||||
<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 }}">
|
||||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||||
<button type="submit">Add</button>
|
||||
@@ -53,6 +54,13 @@
|
||||
</details>
|
||||
{%- 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 content %}
|
||||
<div class="boothhead">
|
||||
@@ -66,7 +74,9 @@
|
||||
{% else %}
|
||||
<h1>{{ name }}</h1>
|
||||
{% 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 %}
|
||||
{{ provenance(manifest) }}
|
||||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
||||
@@ -109,8 +119,17 @@
|
||||
a gallery, and the add-note control would be noise on it — but the
|
||||
suppression was unconditional, so a pick declared on a booth that happens to
|
||||
carry a links.md had no form to answer it and nothing said so. #}
|
||||
{% if marks or not board %}
|
||||
{# R2 C5: on a GALLERY booth the panel moves into the verdict aside beside the
|
||||
set (below). It renders up here only where there is no set to sit beside —
|
||||
a board, or a booth with marks and nothing to show. #}
|
||||
{# `is_board`, not `board`: PAGE IDENTITY, not page content — the lesson the
|
||||
bench panel already learned. `board` is the parsed rows, empty for a
|
||||
links.md with none, and a board with an image in it must still be a board. #}
|
||||
{% set lightbox = all_items and not is_board %}
|
||||
{% if (marks or not board) and not lightbox %}
|
||||
<div class="marks-panel" data-region="marks-panel">
|
||||
{% include "_marks.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# THE BENCH REGISTRY — BLOCK LEVEL, and that placement is load-bearing.
|
||||
@@ -250,6 +269,18 @@
|
||||
{% if not all_items and not board and not marks %}
|
||||
<div class="empty">This booth is empty.</div>
|
||||
{% 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
|
||||
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. #}
|
||||
@@ -266,7 +297,7 @@
|
||||
is therefore the whole guard; the two degenerate cases (one group for
|
||||
everything, one group per item) are decided in Python, where they can be
|
||||
measured, rather than by a count in a template. #}
|
||||
<div class="rail">
|
||||
<div class="rail" data-region="filters">
|
||||
<span class="rail-total">{{ rail.total }} item{{ '' if rail.total == 1 else 's' }}</span>
|
||||
{% for f in rail.counts %}
|
||||
<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.
|
||||
<a href="/b/{{ name_url }}/">show all {{ rail.total }}</a></div>
|
||||
{% 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">
|
||||
{% 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 %}
|
||||
{# Docs render INLINE, collapsible, and closable — not a link to a
|
||||
separate page. <details open> is native collapse (works with JS off);
|
||||
the ✕ hides the item for the session (JS, progressive enhancement).
|
||||
The item spans the full grid width so prose has room to read. #}
|
||||
<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 %}
|
||||
{# Inline docs need this MORE than images, not less: a rendered doc puts
|
||||
its text straight on the page, so "blur the picture" logic that skips
|
||||
@@ -310,6 +348,7 @@
|
||||
<details class="doc-inline" open>
|
||||
<summary class="doc-bar">
|
||||
<span class="doc-chevron" aria-hidden="true">▸</span>
|
||||
{{ ordinal(it) }}
|
||||
<span class="doc-name">{{ it.name }}</span>
|
||||
<span class="doc-spacer"></span>
|
||||
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
|
||||
@@ -332,7 +371,8 @@
|
||||
</details>
|
||||
</figure>
|
||||
{% 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 %}
|
||||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||||
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
|
||||
@@ -367,6 +407,10 @@
|
||||
<figcaption>
|
||||
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||||
<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) }}
|
||||
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||||
</figcaption>
|
||||
@@ -376,6 +420,10 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if lightbox %}
|
||||
</div>{# .lb-set #}
|
||||
</div>{# .lightbox #}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if items %}
|
||||
@@ -446,6 +494,11 @@
|
||||
Found by the heid bug-hunt panel (hulda), 2026-09-22. */
|
||||
case 'f': click('.flagtoggle button'); e.preventDefault(); break;
|
||||
case 'n': var el = current();
|
||||
/* The add-note field lives in a closed <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 (f) { f.focus(); e.preventDefault(); } }
|
||||
break;
|
||||
@@ -455,6 +508,13 @@
|
||||
at = -1; break;
|
||||
}
|
||||
});
|
||||
/* R2 C3: an in-place save swaps the tiles for fresh server-rendered ones,
|
||||
and the cursor is client state the server cannot render. Put it back on
|
||||
the same position — the order did not change, only the judgment. */
|
||||
document.addEventListener('booth:swapped', function () {
|
||||
if (at < 0) return;
|
||||
tiles().forEach(function (el, j) { el.classList.toggle('is-cursor', j === at); });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% 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>
|
||||
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
|
||||
"collapse". Collapse stays available via the rest of the summary bar. With
|
||||
JS off the button is inert and collapse via <details> still works. */
|
||||
(function () {
|
||||
JS off the button is inert and collapse via <details> still works.
|
||||
|
||||
Blur reveal. WARNING: this handler previously sat after the content block's
|
||||
closing tag, which in a child template Jinja DISCARDS — the button rendered
|
||||
and did nothing, and two commits plus a README claimed click-to-reveal
|
||||
worked. Anything that must reach the page belongs inside the content
|
||||
block. Per-viewer and never persisted: a reload re-hides. */
|
||||
function bindTiles() {
|
||||
function once(el) { if (el.__bound) return false; el.__bound = true; return true; }
|
||||
/* A form inside <summary> would otherwise collapse the doc on submit. */
|
||||
document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) {
|
||||
f.addEventListener('click', function (ev) { ev.stopPropagation(); });
|
||||
document.querySelectorAll('.doc-bar .blurtoggle, .doc-bar .flagtoggle').forEach(function (f) {
|
||||
if (once(f)) f.addEventListener('click', function (ev) { ev.stopPropagation(); });
|
||||
});
|
||||
document.querySelectorAll('.doc-close').forEach(function (btn) {
|
||||
if (!once(btn)) return;
|
||||
btn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -509,8 +582,25 @@
|
||||
if (item) item.classList.add('is-closed');
|
||||
});
|
||||
});
|
||||
})();
|
||||
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
|
||||
var fig = btn.closest('.item');
|
||||
/* a swap carries `revealed` across (base.html); the label follows it */
|
||||
btn.textContent = fig.classList.contains('revealed') ? '🙈 hide' : '👁 reveal';
|
||||
if (!once(btn)) return;
|
||||
btn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var on = fig.classList.toggle('revealed');
|
||||
btn.textContent = on ? '🙈 hide' : '👁 reveal';
|
||||
});
|
||||
});
|
||||
}
|
||||
bindTiles();
|
||||
document.addEventListener('booth:swapped', bindTiles);
|
||||
|
||||
/* RESTORED (heid bug-hunt, 2/4): R2's rewrite of the tile handlers above
|
||||
deleted this block with them. Its confirmations guard destructive
|
||||
actions, so it is back verbatim. */
|
||||
/* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row
|
||||
× / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only
|
||||
adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
|
||||
@@ -567,22 +657,5 @@
|
||||
|
||||
refresh();
|
||||
})();
|
||||
|
||||
/* Blur reveal. WARNING: this handler previously sat after the content
|
||||
block's closing tag, which in a
|
||||
child template Jinja DISCARDS — the button rendered and did nothing, and
|
||||
two commits plus a README claimed click-to-reveal worked. Anything that
|
||||
must reach the page belongs inside the content block. Verified now by
|
||||
grepping the SERVED html for this function, not the template for the text.
|
||||
Per-viewer and never persisted: a reload re-hides. */
|
||||
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var fig = btn.closest('.item');
|
||||
var on = fig.classList.toggle('revealed');
|
||||
btn.textContent = on ? '🙈 hide' : '👁 reveal';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<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. */
|
||||
.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}
|
||||
.doccap{margin:.9rem 0 1.2rem;padding:.6rem .85rem;font-size:.85rem;line-height:1.5;
|
||||
color:var(--fg-1);background:var(--rk-surface,rgba(255,255,255,.04));
|
||||
border-left:2px solid var(--aus-bright-cyan,#42dcd1);border-radius:0 6px 6px 0;
|
||||
white-space:pre-wrap}
|
||||
.doccap{margin:0 0 20px;padding:8px 14px;font-size:var(--size-body);line-height:var(--leading-body);
|
||||
color:var(--text-body);border-left:3px solid var(--border-strong);white-space:pre-wrap}
|
||||
.docmarks{display:flex;flex-direction:column;gap:8px;margin:0 0 20px}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
+157
-142
@@ -1,136 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_provenance.html" import provenance %}
|
||||
{% from "_lifetime.html" import lifetime %}
|
||||
{% block content %}
|
||||
<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 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>
|
||||
{# THE DESK (R2 C4). The index triaged by what needs the operator: needs you,
|
||||
then new since you looked, then everything else — always in that order, and
|
||||
the ORDER WITHIN each is decided in app.index, never here. A section with no
|
||||
booths renders nothing at all: no heading, no empty box (the negative half
|
||||
of the kept-lane pair this replaces). #}
|
||||
|
||||
{% if kept %}
|
||||
{# Kept boards render FIRST and look different on purpose: they are durable
|
||||
operator-facing things (the agent link board, standing reports) and the
|
||||
point of the lane is that they cannot be lost in a feed that turns over
|
||||
every day. No countdown — they have no expiry to advertise. #}
|
||||
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
|
||||
<div class="grid kept-grid">
|
||||
{% for b in kept %}
|
||||
<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>
|
||||
{# The first four images, the originals shown small. A blurred one stays
|
||||
blurred (`blurred-thumb`, the cover's rule). A booth with no images shows the
|
||||
kind placeholder the cards used to. #}
|
||||
{% macro preview(b) -%}
|
||||
<a class="desk-strip" href="/b/{{ b.name_url }}/" tabindex="-1" aria-hidden="true">
|
||||
{% if b.preview %}
|
||||
{% for url, blurred in b.preview %}
|
||||
<img class="{{ 'blurred-thumb' if blurred }}" loading="lazy" src="/b/{{ b.name_url }}/{{ url }}" alt="">
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
{% elif b.has_index %}<span class="ph">▦ page</span>
|
||||
{% elif b.kinds.video %}<span class="ph">▶ video</span>
|
||||
{% elif b.kinds.audio %}<span class="ph">♪ audio</span>
|
||||
{% else %}<span class="ph">◆ files</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="grid">
|
||||
{% for b in booths %}
|
||||
<article class="card">
|
||||
<a class="thumb" href="/b/{{ b.name_url }}/">
|
||||
{% if b.thumb_url %}
|
||||
<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 %}
|
||||
{% 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>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro row(b, section) -%}
|
||||
<article class="desk-row{% if section == 'needs' %} is-needs{% endif %}" data-booth="{{ b.name }}" data-kept="{{ '1' if b.kept else '0' }}">
|
||||
{{ preview(b) }}
|
||||
<div class="desk-main">
|
||||
{# The manifest title leads when there is one; the directory name stays
|
||||
beside it because it is what the URL says. #}
|
||||
<a class="desk-title" href="/b/{{ b.name_url }}/">
|
||||
{%- if b.manifest and not b.manifest.error and b.manifest.title and b.manifest.title != b.name -%}
|
||||
{{ b.manifest.title }} <span class="desk-slug">{{ b.name }}</span>
|
||||
{%- else -%}{{ b.name }}{%- endif -%}
|
||||
</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(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>
|
||||
{{ provenance(b.manifest) }}
|
||||
{{ provenance(b.manifest) }}
|
||||
<div class="desk-facts">
|
||||
{{ b.count }} item{{ '' if b.count == 1 else 's' }}
|
||||
{% 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>
|
||||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
||||
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
||||
kept booth, never keep an ephemeral one, so the round trip was only
|
||||
closed if you had a shell. Reversible, so no confirmation — the × next
|
||||
to it is the destructive one and keeps its prompt. #}
|
||||
<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>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="desk-side">
|
||||
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>
|
||||
{% elif b.hold == "unreadable" %}<span class="badge badge-broken">marks unreadable</span>
|
||||
{% elif section == 'new' %}<span class="badge badge-new">new</span>{% endif %}
|
||||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||
<div class="desk-acts">
|
||||
{# Keep / release and the ×. The confirmation text is DATA-DRIVEN: the
|
||||
booth name travels as a data attribute and never reaches a JS string
|
||||
(see the script below). Release is reversible, so it has no prompt of
|
||||
its own beyond the wording. #}
|
||||
{% if b.kept %}
|
||||
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
|
||||
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>
|
||||
{% 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>
|
||||
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
|
||||
@@ -162,31 +185,23 @@
|
||||
});
|
||||
})();
|
||||
|
||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN.
|
||||
These were an inline onsubmit calling confirm() with the booth NAME
|
||||
interpolated straight into the JS string literal. Jinja's autoescape is
|
||||
HTML-attribute escaping, not JS-string escaping: the browser decodes the
|
||||
entity back to a quote before the JS parser ever sees it, so a booth name
|
||||
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. */
|
||||
/* Destructive-action confirmation, delegated and DATA-DRIVEN. The booth name
|
||||
travels as a data attribute, where escaping is escaping, and never reaches
|
||||
a JS string literal: a booth name is agent-authored, and an inline handler
|
||||
carrying one was a live injection path. With JS off the form submits
|
||||
without a prompt. */
|
||||
(function () {
|
||||
var WORDS = {
|
||||
release: function (n) {
|
||||
return 'Release \u201c' + n + '\u201d?\n\nIt moves to the ephemeral lane so you '
|
||||
+ 'can wipe it from there. Nothing is deleted by this step.';
|
||||
return 'Release “' + n + '”?\n\nIt rejoins the sweep: it will be wiped '
|
||||
+ '{{ ttl_hours|int }}h after its last activity. Nothing is deleted by this step.';
|
||||
},
|
||||
'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 '
|
||||
+ '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) {
|
||||
var form = ev.target.closest ? ev.target.closest('form[data-confirm]') : null;
|
||||
|
||||
@@ -12,12 +12,16 @@
|
||||
{# `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
|
||||
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>
|
||||
{# 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 %}
|
||||
{% include "_marks.html" %}
|
||||
{% else %}
|
||||
<div class="empty">This booth has no marks.</div>
|
||||
{% include "_marks.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
+199
-82
@@ -1,118 +1,235 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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 %}
|
||||
<div class="viewer">
|
||||
<div class="viewer review">
|
||||
<div class="vbar">
|
||||
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
|
||||
<span class="vname">{{ file }}</span>
|
||||
<a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)">✕</a>
|
||||
<span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</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">
|
||||
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
|
||||
</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 %}
|
||||
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
|
||||
{# THE ANNOTATION, at full size. It was never rendered here before U1 — not
|
||||
because the template dropped it, but because the route never resolved it.
|
||||
A caption is most useful at the size where you are actually judging the
|
||||
thing, so it belongs here at least as much as in the grid. #}
|
||||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
||||
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
|
||||
the size at which the operator is actually deciding, so the flag toggle and
|
||||
the notes belong here at least as much as on the tile. #}
|
||||
<div class="vmarks">
|
||||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||
<button class="vbtn{% if flagged %} is-flagged{% endif %}"
|
||||
title="{{ 'un-flag this item' if flagged else 'flag this one' }}"
|
||||
>{{ '✔ flagged' if flagged else '○ flag' }}</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">
|
||||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||
<button class="mark-x" title="withdraw this note">×</button>
|
||||
|
||||
{# THE TAPE (B's device): one segment per item in the review ring — seen,
|
||||
flagged, current — so how far through the set you are is always in view. #}
|
||||
<div class="tape" data-region="tape" aria-label="{{ seen_n }} of {{ ring_m }} seen">
|
||||
<div class="tape-segs">
|
||||
{% for x in film %}
|
||||
<a class="tape-s{% if x.current %} is-current{% elif x.flagged %} is-flagged{% elif x.seen %} is-seen{% endif %}"
|
||||
href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}"></a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
|
||||
</div>
|
||||
|
||||
<div class="review-body">
|
||||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
|
||||
<div class="vstage fit{% if blurred %} is-blurred{% endif %}" id="vstage">
|
||||
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}">
|
||||
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
|
||||
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
|
||||
{% endif %}
|
||||
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}">👁 reveal — blur is cosmetic</button>{% endif %}
|
||||
</div>
|
||||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
|
||||
<input type="hidden" name="target" value="{{ file }}">
|
||||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||||
<button type="submit">Add note</button>
|
||||
</form>
|
||||
|
||||
{# A question ABOUT this item is answerable here. #}
|
||||
{% if item_picks %}
|
||||
<div class="vr-sec">
|
||||
{% with marks=item_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||||
</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>
|
||||
|
||||
{# 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>
|
||||
<style>
|
||||
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
|
||||
align-items:center;justify-content:center;width:2.6rem;height:3.4rem;font-size:2rem;
|
||||
line-height:1;text-decoration:none;color:var(--fg-1);background:rgba(20,23,32,.55);
|
||||
border:1px solid rgba(255,255,255,.10);border-radius:10px;margin:0 .5rem;user-select:none;
|
||||
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);transition:background .15s,border-color .15s}
|
||||
.vnav:hover{background:rgba(28,33,46,.92);border-color:var(--aus-bright-cyan,#42dcd1)}
|
||||
.vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex;
|
||||
align-items:center;justify-content:center;width:40px;height:56px;font-size:28px;
|
||||
line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .6);
|
||||
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 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}
|
||||
/* Bottom bar rather than the top chrome: a caption can run to CAPTION_MAX
|
||||
(800 chars), which would shove the filename and the Fit/1:1 toggle around. */
|
||||
.vcap{flex:0 0 auto;max-height:22vh;overflow-y:auto;padding:.6rem clamp(12px,3vw,20px);
|
||||
font-size:.85rem;line-height:1.5;color:var(--fg-1);background:var(--rk-surface,rgba(20,23,32,.92));
|
||||
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}}
|
||||
@media (min-width:901px){.vnext{right:360px}}
|
||||
.vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
|
||||
color:var(--text-body);white-space:pre-wrap}
|
||||
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var img = document.getElementById('vimg');
|
||||
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 BACK = {{ back_url|tojson }};
|
||||
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
||||
|
||||
function setMode(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);
|
||||
/* Fit / 1:1 — bound only when the stage is a picture (R2 C6). */
|
||||
var img = document.getElementById('vimg');
|
||||
if (img) {
|
||||
var stage = document.getElementById('vstage');
|
||||
var toggle = document.getElementById('vtoggle');
|
||||
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.
|
||||
The handler is on `document` and the note textarea shipped into this same
|
||||
page, so typing a note and reaching for ← threw the draft away; Escape
|
||||
did it in one keystroke. Anything editable keeps its own keys. */
|
||||
/* Keep the current frame in view on the filmstrip — on load, and after an
|
||||
in-place save swaps the strip for a fresh one. Additive: without it the
|
||||
strip is still a row of links. */
|
||||
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) {
|
||||
return !!(el && (el.isContentEditable ||
|
||||
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
||||
}
|
||||
document.addEventListener('keydown', function (e) {
|
||||
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;
|
||||
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
||||
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>
|
||||
|
||||
@@ -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
@@ -771,7 +771,13 @@ def test_sentinel_is_not_counted_as_an_item(tmp_path):
|
||||
assert booth["count"] == 1
|
||||
|
||||
|
||||
def test_index_separates_kept_from_ephemeral(client):
|
||||
def test_index_marks_kept_on_the_row_instead_of_a_lane(client):
|
||||
"""R2 C4 (docs/contracts/r2_flow.contract.md, "Assertions that change").
|
||||
This test used to require a kept LANE rendered before the ephemeral grid.
|
||||
The Desk removed the lanes — 23 of 24 live booths were kept, so they sorted
|
||||
nothing — and orders by what needs the operator instead (tested in
|
||||
tests/test_flow.py). What survives is the fact: a kept booth still says it
|
||||
is kept, on its own row."""
|
||||
c, data = client
|
||||
_touch(data / "scratch" / "a.png")
|
||||
_touch(data / "links" / "a.png")
|
||||
@@ -779,14 +785,9 @@ def test_index_separates_kept_from_ephemeral(client):
|
||||
|
||||
html = c.get("/").text
|
||||
|
||||
# Assert on the lane's markup, not on the word "Kept" — that string also
|
||||
# appears in the stylesheet comment that is served on every page, so a bare
|
||||
# substring check passes for the wrong reason.
|
||||
assert 'class="grid kept-grid"' in html, "kept booths need their own lane"
|
||||
assert 'class="card card-kept"' in html
|
||||
# The kept lane is rendered before the ephemeral grid, so the operator sees
|
||||
# durable boards first rather than hunting for them among the churn.
|
||||
assert html.index("links") < html.index("scratch")
|
||||
assert 'data-booth="links" data-kept="1"' in html
|
||||
assert 'data-booth="scratch" data-kept="0"' in html
|
||||
assert 'class="grid kept-grid"' not in html, "no lane: kept is a fact, not a grouping"
|
||||
|
||||
|
||||
def test_kept_booth_shows_kept_instead_of_a_countdown(client):
|
||||
|
||||
@@ -592,17 +592,28 @@ def test_the_keyboard_flag_actually_submits(browser, live):
|
||||
a hidden input does not submit its form. The shortcut never worked while
|
||||
still swallowing the keystroke.
|
||||
|
||||
Asserted end to end: press f, and the flag must come back from the server
|
||||
on the reloaded page."""
|
||||
Asserted end to end: press f, and the flag must come back from the server.
|
||||
|
||||
R2 C3 (docs/contracts/r2_flow.contract.md, "Assertions that change"): this
|
||||
used to expect a NAVIGATION — the flag form POSTed, 303'd and reloaded. That
|
||||
was the no-JS design working, not a defect, and it still is with scripts
|
||||
off (tests/golden/r2_mark_303.json replays those responses byte for byte).
|
||||
What changed is that WITH JS ON the flag now applies in place. The claim
|
||||
that matters is kept and tightened: the flag must come back from the SERVER
|
||||
(the swapped tile is server-rendered), and a marker set on the window before
|
||||
the keypress must survive, which a reload would wipe."""
|
||||
base, root = live
|
||||
_gallery(root)
|
||||
page = browser.new_page()
|
||||
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||
page.evaluate("window.__noReload = 1")
|
||||
|
||||
page.keyboard.press("ArrowRight") # cursor onto the first tile
|
||||
with page.expect_navigation(): # the flag form POSTs and redirects back
|
||||
page.keyboard.press("f")
|
||||
page.keyboard.press("f")
|
||||
page.wait_for_selector("figure.item.is-flagged", timeout=10000)
|
||||
flagged = page.locator("figure.item.is-flagged").count()
|
||||
survived = page.evaluate("window.__noReload === 1")
|
||||
page.close()
|
||||
|
||||
assert flagged == 1, f"the f key flagged {flagged} items, expected 1"
|
||||
assert survived, "the flag reloaded the page; in-place judgment must not"
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user