diff --git a/booth/app.py b/booth/app.py index 2fe3b3d..f130d57 100644 --- a/booth/app.py +++ b/booth/app.py @@ -37,14 +37,19 @@ import asyncio import fcntl import hashlib import io +import json +import math import os import re import secrets import shutil +import stat +import tempfile import time import zipfile from contextlib import asynccontextmanager from dataclasses import replace +from datetime import datetime, timezone from pathlib import Path from typing import Sequence from urllib.parse import quote, unquote @@ -87,6 +92,10 @@ from booth.items import ( # noqa: E402,F401 doc_kind, find_item, image_chain, + review_chain, + REVIEW_KINDS, + SEEN_FILE, + read_seen, read_blurred, render_doc, render_doc_body, @@ -249,6 +258,62 @@ def _newest_mtime(path: Path) -> float: return newest +def _content_mtime(path: Path) -> float: + """`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular + files and symlinks (by lstat) with no dot-component in their path. Deliberately NOT `_newest_mtime` + (INV-5 of r2): a mark, a view, a blur or a keep is activity, never new + content, so none of them may make a booth read as newly landed. + + Files only, never directories: creating `.viewed` bumps the booth + directory's own mtime, and counting that would make the first look at a + booth look like a delivery. An empty booth landed at 0.0. One unreadable + ENTRY is skipped; a booth whose walk cannot run at all reads as NOW, the + posture `_newest_mtime` takes and for a milder reason here: a booth we + cannot read is shown as new rather than hidden as old. + """ + newest = 0.0 + try: + for p in path.rglob("*"): + rel = p.relative_to(path) + if any(part.startswith(".") for part in rel.parts): + continue + try: + # lstat: a posted SYMLINK counts by its own mtime — when it was + # placed — never by its target's. A link to a busy file outside + # the booth must not make the booth read as newly delivered. + st = p.lstat() + except OSError: + # One unreadable entry costs that entry, not the booth: reading + # the whole booth as landed NOW would pin it in "new" forever. + continue + if (stat.S_ISREG(st.st_mode) or stat.S_ISLNK(st.st_mode)) and st.st_mtime > newest: + newest = st.st_mtime + except OSError: + return time.time() + return newest + + +def _viewed_at(path: Path) -> float | None: + """The mtime of the booth's `.viewed` marker (U4), or None if it has never + been looked at. `lstat`, like `is_kept`: a planted symlink is read as the + marker it claims to be, never followed.""" + try: + return os.lstat(path / VIEW_MARKER).st_mtime + except OSError: + return None + + +def _stamp(created: str) -> datetime | None: + """A mark's `created` as an aware datetime, or None when it will not parse. + Strings are never compared: two ISO stamps with different offsets sort + wrong as text. A naive stamp is read as UTC.""" + try: + dt = datetime.fromisoformat(created) + except (TypeError, ValueError): + return None + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + def booth_age_seconds(path: Path, now: float | None = None) -> float: now = time.time() if now is None else now return now - _newest_mtime(path) @@ -319,6 +384,104 @@ def record_view(booth: Path) -> None: pass +def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None: + """Note that `rel` was looked at full size (R2 C2). + + Rewrites the whole marker — the previous set plus `rel`, pruned to rels that + are still items, sorted — so it is deduplicated and never outgrows the + booth. Atomic replace (CLAUDE.md invariant 5) through a temp file created + with O_EXCL: a planted `.seen.tmp` symlink cannot redirect the write, and + `os.replace` swaps a planted `.seen` symlink out rather than writing + through it. + + NEVER RAISES, for `record_view`'s reason: not recording a look is a cost + this service can absorb, not answering the request is not. + """ + try: + live = {it.rel for it in items} + seen = (read_seen(booth) | {rel}) & live + # A JSON array, UTF-8 explicitly: a rel may hold a newline or a leading + # space, and the host locale must not decide whether a name encodes. + body = json.dumps(sorted(seen), ensure_ascii=False).encode("utf-8", "surrogateescape") + fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth) + try: + with os.fdopen(fd, "wb") as fh: + fh.write(body) + os.replace(tmp, booth / SEEN_FILE) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + except (OSError, ValueError): + # ValueError covers an encode failure — it is not an OSError, and a + # look that cannot be recorded must never cost the page. + pass + + +def wants_json(accept: str | None) -> bool: + """Whether a mark POST asked for the in-place answer (R2 C3). + + True ONLY when the Accept header lists `application/json` exactly — + parameters stripped — with a q-value that is absent or above zero. Absent, + empty, wildcard, `application/*`, a near miss like `application/jsonx`, an + explicit `q=0`, a malformed q: all False. It FAILS TOWARD THE 303, because + the plain form's redirect is the no-JS guarantee and a mis-parse must land + there, never on a 204 a browser would render as nothing happening. + """ + if not accept: + return False + # EVERY entry is parsed before anything is decided: returning True at the + # first good JSON entry meant a malformed one after it was never read, so + # `application/json, application/json;q=broken` was a 204 and the same pair + # reversed a 303 (heid code-review, 3/4). One unparseable entry anywhere + # makes the whole header False. + wanted = False + try: + for entry in accept.split(","): + mtype, *params = entry.split(";") + q = 1.0 + for param in params: + key, _, value = param.partition("=") + if key.strip().lower() == "q": + q = float(value.strip()) + if not math.isfinite(q): + # inf, 1e999, nan parse as floats but are not q-values + raise ValueError("non-finite q") + if mtype.strip().lower() == "application/json" and q > 0: + wanted = True + except ValueError: + return False + return wanted + + +def flagged_targets(marks: Sequence[Mark]) -> set[str]: + """THE flag predicate (R2): the items carrying a READABLE flag mark. The + Desk count, the tray, the filmstrip, the tape and the review button all + read this, so they cannot disagree about one item. An unreadable flag + entry is judgment nobody can see, and counts nowhere.""" + return {m.target for m in marks + if m.shape == "flag" and m.error is None and m.target} + + +def _contiguous(keys: Sequence[str | None]) -> bool: + """True when each non-None key occupies ONE unbroken run of the sequence.""" + seen: set[str] = set() + prev = object() + for k in keys: + if k != prev: + if k is not None and k in seen: + return False + if k is not None: + seen.add(k) + prev = k + return True + + +# The Desk shows this many bookmarks and links to the board for the rest. +BOOKMARKS_SHOWN = 8 + HOLD_UNREADABLE = "unreadable" HOLD_OPEN = "open" @@ -464,6 +627,10 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> # rather than re-opening .blurred here. thumb_blurred = it.blurred mtime = _newest_mtime(child) + # R2 C4: the oldest question still owed an answer, PARSED. Unparseable + # stamps are left out, so a booth whose every open pick is unparseable + # has no `open_since` and sorts after every booth that has one. + stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st] booths.append( { "name": child.name, @@ -490,6 +657,23 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> "hold": hold, "expires_in": max(0.0, ttl_seconds - (now - mtime)), "mtime": mtime, + # ---- R2 C4, the Desk. All from the pass above; no second read. + # A booth held for UNREADABLE marks has no `open_since`, even if + # a readable pick sits beside the damage: it sorts after every + # dated question, because the damage is what needs fixing. + "open_since": (min(stamps) if stamps and hold != HOLD_UNREADABLE + else None), + # items that EXIST: a flag on a file since deleted is shown on + # the booth page for withdrawal, not counted as a pick here + "flags": len(flagged_targets(marks) & {it.rel for it in items}), + # Two clocks, named apart (INV-5): `mtime` is activity, + # `landed_at` is content. "New since you looked" reads only the + # second, so a flag or a view never makes a booth look new. + "landed_at": _content_mtime(child), + "viewed_at": _viewed_at(child), + # The first four images in item order, as the originals shown + # small. Blurred ones stay blurred, the cover's rule. + "preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4], } ) # Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime @@ -522,6 +706,8 @@ def build_gallery(child: Path) -> list[dict]: "section": it.section, # U7. Derived in the resolver (INV-1); this only carries it. "group": it.group, + # R2 C1. Same rule: the resolver numbers, this carries. + "ordinal": it.ordinal, "caption": it.caption, "rendered": rendered, "rendered_html": rendered_html, @@ -559,9 +745,10 @@ def _zip_filename(name: str) -> str: # the same icon. Keep the two in sync if the Booth's icon ever changes. FAVICON_HREF = ( "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'" - "%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' " - "cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle " - "cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E" + "%3E%3Crect width='32' height='32' rx='7' fill='%2315191d'/%3E%3Cpath d='M7 12V7h5" + "M20 7h5v5M7 20v5h5M25 20v5h-5' fill='none' stroke='%23b2cd12' stroke-width='2.5' " + "stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='3' " + "fill='%23b2cd12'/%3E%3C/svg%3E" ) # The seam a verbatim report declares to get the Booth's chrome. ONE line, and @@ -711,6 +898,7 @@ def create_app( sweep_interval_s: int = 900, max_upload_mb: float = 1024.0, max_files: int = 50, + links_board: str = "links", ) -> FastAPI: data_dir = Path(data_dir).expanduser().resolve() data_dir.mkdir(parents=True, exist_ok=True) @@ -821,19 +1009,49 @@ def create_app( @app.get("/", response_class=HTMLResponse) def index(request: Request): - # Two lanes, split here rather than in the template: kept boards are a - # different KIND of thing from the ephemeral churn — durable, deliberate, - # operator-facing — and burying them in a feed that turns over daily is - # exactly how they would get lost, which is the problem they exist to - # solve. Kept renders first. + """THE DESK (R2 C4) — the index triaged by what needs the operator. + + Three sections, ALWAYS in this order, each booth in exactly one: + needs — an open pick, or marks that cannot be read (somebody has to + fix those, so they must not hide further down). Oldest open + question first; a booth with no parseable stamp after every + booth that has one; name breaks ties. + new — content landed since the booth was last looked at, or never + looked at. Newest content first; name breaks ties. + rest — everything else, in `list_booths`' own order (last activity + first, name as the tie-break). No second rule is stated. + The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so + the lanes sorted nothing. Kept status still shows on every row. + """ everything = list_booths(data_dir, ttl_seconds) + needs = [b for b in everything + if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE] + needs.sort(key=lambda b: ((0, b["open_since"].timestamp()) + if b["open_since"] else (1, 0.0), b["name"])) + in_needs = {b["name"] for b in needs} + new = [b for b in everything if b["name"] not in in_needs + and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])] + new.sort(key=lambda b: (-b["landed_at"], b["name"])) + in_new = {b["name"] for b in new} + rest = [b for b in everything + if b["name"] not in in_needs and b["name"] not in in_new] + benches, benches_error = read_benches(data_dir) + board = data_dir / links_board + bookmarks = [row for row in _board_rows(board) + if booth_target(row["url"]) is None] if board.is_dir() else [] return templates.TemplateResponse( request, "index.html", { **base_ctx, - "kept": [b for b in everything if b["kept"]], - "booths": [b for b in everything if not b["kept"]], + "needs": needs, + "new": new, + "rest": rest, + "benches": [b for b in benches if b.state != "retired"], + "benches_error": benches_error, + "bookmarks": bookmarks[:BOOKMARKS_SHOWN], + "bookmarks_total": len(bookmarks), + "board_url": f"/b/{quote(links_board, safe='')}/", }, ) @@ -970,6 +1188,27 @@ def create_app( it["name"]: marks_for_target(marks, it["name"]) for it in gallery }, "booth_marks": marks_for_target(marks, None), + # R2 C5: the flag tray — the flagged items in SET order, i.e. + # by ordinal, over the full gallery (a filter hides tiles, not + # judgments). Order is total with no tie-break: rels are unique. + # Inline group headers only when every group is ONE contiguous + # run in the rendered order. Groups come from basenames and the + # order from full paths, so they can interleave (d1/aa, d1/bb, + # d2/aa); a header then either repeats or files an item under + # the wrong group. The rail's jump links do not depend on this. + "inline_groups": bool(rail["groups"]) and _contiguous( + [it["group"] for it in shown]), + # THE flag predicate for this page — the tile class and the tile + # toggle read it too, so no surface on the page can disagree. + "flagged_set": flagged_targets(marks), + "tray": [it for it in gallery if it["name"] in flagged_targets(marks)], + # A flag whose file is gone from the booth: no tile to stamp and + # no tray slot, so it is listed apart with its withdraw control + # rather than vanishing from the page while staying in the file. + "orphan_flags": [m for m in marks + if m.shape == "flag" and m.error is None and m.target + and m.target not in {it["name"] for it in gallery}], + "ord_width": len(str(len(gallery))), "uploaded": (booth / UPLOAD_MARKER).exists(), # The same provenance line the index card carries. Deliberate: # a booth URL handed to the operator lands HERE, never on the @@ -1144,8 +1383,32 @@ def create_app( base = f"/b/{quote(name, safe='')}/" if form.get("back") == "marks": base = f"/b/{quote(name, safe='')}/marks" + elif form.get("back") == "view": + # R2 C3: judgment made at full size lands back at full size — the + # JS-off fix for being thrown out to the grid. Only for a MEDIA item + # of this booth; anything else takes the no-`back` landing above. + # Built from the resolved rel, never echoed from the form. + f = form.get("f") + if isinstance(f, str) and f: + try: + ring = review_chain(booth_items(resolve_booth(name))) + except HTTPException: + ring = [] + if f in ring: + return RedirectResponse( + url=f"/b/{quote(name, safe='')}/view?f={quote(f, safe='/')}#rail", + status_code=303) return RedirectResponse(url=f"{base}#{anchor}", status_code=303) + def _mark_done(request: Request, name: str, form, anchor: str) -> Response: + """The one exit for every mark route (R2 C3). A request that asked for + the in-place answer gets 204 and no body — the page fetches its own + fresh regions. Everything else gets `_mark_redirect`, byte for byte what + it got before R2 (INV-4).""" + if wants_json(request.headers.get("accept")): + return Response(status_code=204) + return _mark_redirect(name, form, anchor) + @app.post("/b/{name}/answer") async def booth_answer(request: Request, name: str): """Record the operator's pick — one of N options a session declared in @@ -1196,7 +1459,7 @@ def create_app( _form_text(form, "choice"), notes, who=who) except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}") + return _mark_done(request, name, form, f"mark-{quote(mark_id, safe='')}") @app.post("/b/{name}/note") async def booth_note(request: Request, name: str): @@ -1217,7 +1480,7 @@ def create_app( who=request.client.host if request.client else "") except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}") + return _mark_done(request, name, form, f"mark-{quote(mark.id, safe='')}") @app.post("/b/{name}/flag") async def booth_flag(request: Request, name: str): @@ -1239,7 +1502,7 @@ def create_app( who=request.client.host if request.client else "") except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return _mark_redirect(name, form, f"item-{quote(target, safe='')}") + return _mark_done(request, name, form, f"item-{quote(target, safe='')}") @app.post("/b/{name}/unmark") async def booth_unmark(request: Request, name: str): @@ -1251,7 +1514,7 @@ def create_app( if not isinstance(mark_id, str) or not mark_id: raise HTTPException(status_code=400, detail="which mark?") await run_in_threadpool(delete_mark, booth, mark_id) - return _mark_redirect(name, form, "marks") + return _mark_done(request, name, form, "marks") @app.post("/b/{name}/import-asks") async def booth_import_asks(request: Request, name: str): @@ -1469,7 +1732,9 @@ def create_app( booth = resolve_booth(name) try: target = (booth / f).resolve() - except OSError: + except (OSError, ValueError): + # ValueError: an embedded NUL. Hostile input, like every other + # unresolvable `f` — a 404, never a 500. raise HTTPException(status_code=404, detail="no such file") if not str(target).startswith(str(booth) + os.sep) or not target.is_file(): raise HTTPException(status_code=404, detail="no such file") @@ -1484,6 +1749,10 @@ def create_app( # of a thing that is not an item is not a view of the booth. if item is not None: record_view(booth) + # R2 C2: WHICH item was looked at — media only, the ring the tape + # draws. Same gate as the view above, and it never raises either. + if item.kind in REVIEW_KINDS: + record_seen(booth, item.rel, items) marks = marks_for(booth) item_marks = marks_for_target(marks, f) common = { @@ -1500,20 +1769,58 @@ def create_app( # notes and the flag state travel to full size, which is the size at # which the judgment is actually being made. "marks": item_marks, - "flagged": any(m.shape == "flag" for m in item_marks), + "flagged": f in flagged_targets(marks), } - if item is not None and item.kind == "image": - # prev/next ring (wraps; only when there is more than one image) - names = image_chain(items) + if item is not None and item.kind in REVIEW_KINDS: + # THE REVIEW (R2 C6): images, video and audio at full size with the + # judgment on screen. The ring is `review_chain` — the item order + # filtered to MEDIA — and the prev/next, the filmstrip and the tape + # all read that ONE list, so they cannot disagree about "next". + ring = review_chain(items) + by_rel = {it.rel: it for it in items} + pos = ring.index(f) prev_url = next_url = None - if f in names and len(names) > 1: - i = names.index(f) - prev_url = quote(names[(i - 1) % len(names)], safe="/") - next_url = quote(names[(i + 1) % len(names)], safe="/") + if len(ring) > 1: + prev_url = quote(ring[(pos - 1) % len(ring)], safe="/") + next_url = quote(ring[(pos + 1) % len(ring)], safe="/") + flagged_rels = flagged_targets(marks) + # recorded above, before this read: the current item counts as seen + seen = read_seen(booth) & set(ring) + film = [{"name": r, "url": by_rel[r].url, "ordinal": by_rel[r].ordinal, + "kind": by_rel[r].kind, "blurred": by_rel[r].blurred, + "flagged": r in flagged_rels, "seen": r in seen, + "current": r == f} for r in ring] + # Position within the group, only when there IS grouping: two or + # more groups among the ring. One group for everything says nothing. + group = None + ring_groups = {by_rel[r].group for r in ring if by_rel[r].group} + if item.group and len(ring_groups) > 1: + members = [r for r in ring if by_rel[r].group == item.group] + group = {"key": item.group, "k": members.index(f) + 1, "n": len(members)} + open_now = open_marks(marks) return templates.TemplateResponse( - request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url} - ) + request, "view.html", { + **common, + "kind": item.kind, + "ordinal": item.ordinal, + "ord_width": len(str(len(items))), + "ring_k": pos + 1, + "ring_m": len(ring), + "prev_url": prev_url, + "next_url": next_url, + "film": film, + "seen_n": len(seen), + "group": group, + # a question ABOUT this item is answerable here; the rest are + # a count and a link — until the last item, where the end of + # the set offers them all + "item_picks": [m for m in open_now if m.target == f], + "other_picks": [m for m in open_now if m.target != f], + "is_last": pos == len(ring) - 1, + "tray": [x for x in film if x["flagged"]], + "back_url": f"/b/{quote(name, safe='')}/#item-{item.url}", + }) # .md renders, .txt/.log show as text — viewable in-booth, no download if item is not None: @@ -1777,6 +2084,9 @@ def _from_env() -> FastAPI: sweep_interval_s=interval, max_upload_mb=max_mb, max_files=max_n, + # The board the CLI's `booth link` writes (scripts/booth reads the same + # variable), so the Desk's bookmarks come from where they are written. + links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"), ) diff --git a/booth/items.py b/booth/items.py index 4f33621..5ad0a38 100644 --- a/booth/items.py +++ b/booth/items.py @@ -15,7 +15,10 @@ See docs/contracts/u1_item_record.contract.md. from __future__ import annotations +import json +import os import re +import stat from dataclasses import dataclass from pathlib import Path from typing import Sequence @@ -95,6 +98,57 @@ class Item: blurred: bool doc: str | None size: int + # R2 C1: the 1-based position in `booth_items` order over ALL items — the + # number the operator means by "the third one". Set in the resolver loop + # and nowhere else (INV-1). APPENDED, never inserted: a mid-dataclass field + # is a positional-construction break. + ordinal: int + + +# R2 C2: which items have been looked at full size. UI state, not judgment — +# never exposed to sessions, holds nothing. One viewer: this records WHAT was +# seen, never who saw it. +SEEN_FILE = ".seen" + + +# A seen marker bigger than this is not one this service wrote: a JSON array of +# every rel in a 270-item booth is a few KB. +SEEN_MAX_BYTES = 1 << 20 + + +def read_seen(booth: Path) -> set[str]: + """Rels seen at full size (R2 C2). A JSON array of strings, because a rel + may hold a leading space or a newline and must round-trip exactly. + + NEVER RAISES and NEVER BLOCKS. Any fleet session can write into a booth, + so the marker may be planted: it is opened without following a link and + without blocking (a FIFO with no writer), refused unless it is a regular + file of sane size, and anything unreadable or malformed reads as nothing + seen — a damaged marker costs the tape its memory, never the page. + """ + try: + fd = os.open(booth / SEEN_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except OSError: + return set() + try: + st = os.fstat(fd) + if not stat.S_ISREG(st.st_mode) or st.st_size > SEEN_MAX_BYTES: + return set() + raw = os.read(fd, SEEN_MAX_BYTES + 1) + except OSError: + return set() + finally: + os.close(fd) + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError, RecursionError): + # RecursionError: a deeply nested array (`[[[[...`) blows the parser's + # stack, and it is neither a ValueError nor an OSError — the same hole + # marks.py, manifest.py and benches.py already close. + return set() + if not isinstance(data, list): + return set() + return {r for r in data if isinstance(r, str)} def read_blurred(booth: Path) -> set[str]: @@ -273,6 +327,10 @@ def booth_items(booth: Path) -> list[Item]: blurred=rel in blurred, doc=doc_kind(p.name), size=size, + # Counted over items that RENDER: a caption sidecar or a name + # the quote() guard skipped takes no number, so the numbers + # stay contiguous over what the operator can see. + ordinal=len(items) + 1, ) ) return items @@ -287,6 +345,18 @@ def image_chain(items: Sequence[Item]) -> list[str]: return [it.rel for it in items if it.kind == "image"] +# R2 C2: what the review route steps through. ONE LINE: the item order +# filtered to media. It is a declared change to the zoom-ring rule, which was +# images only: a listening set is reviewed the same way a picture set is. +REVIEW_KINDS = ("image", "video", "audio") + + +def review_chain(items: Sequence[Item]) -> list[str]: + """The rels of the media items, in item order — the review's prev/next ring, + its filmstrip and its tape.""" + return [it.rel for it in items if it.kind in REVIEW_KINDS] + + def find_item(items: Sequence[Item], rel: str) -> Item | None: """The record for one rel, or None — the zoom/doc route's entry point.""" for it in items: diff --git a/booth/static/embed.js b/booth/static/embed.js index b4a3ec4..b552b10 100644 --- a/booth/static/embed.js +++ b/booth/static/embed.js @@ -28,53 +28,69 @@ window.__boothEmbed = true; var CSS = [ + /* SVOS values, written as literals: this sheet lands in a page we did not + write, so it can lean on none of base.html's tokens. Hex equivalents of + the SVOS semantic tokens (design-systems palettes/svos @ ed2f8d8). */ /* ---- the way home, and the open-asks jump ---- */ ".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;", - "display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;", - "text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}", + "display:inline-block;margin:.6rem;padding:.38rem .75rem;border-radius:8px;", + "text-decoration:none;letter-spacing:.01em;box-shadow:0 4px 14px rgba(0,0,0,.4)}", /* top-right: a top-left chip clips the page title on left-aligned report layouts, and this matches the zoom view's back affordance. */ - ".booth-nav-home{right:0;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;", - "color:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);", - "-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .18s,border-color .18s}", - ".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}", - ".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;", - "color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}", - ".booth-nav-asks:hover{filter:brightness(1.08)}", + ".booth-nav-home{right:0;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;", + "color:#dce3e5;background:rgba(12,16,20,.86);border:1px solid rgba(255,255,255,.16);", + "-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .12s,border-color .12s}", + ".booth-nav-home:hover{background:rgba(31,35,40,.96);border-color:rgba(255,255,255,.34)}", + /* amber = needs you: the one chip that asks to be clicked */ + ".booth-nav-asks{right:7.4rem;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;", + "color:#15191d;background:#fbc10f;border:1px solid #fbc10f;transition:filter .12s}", + ".booth-nav-asks:hover{filter:brightness(1.06)}", "@media print{.booth-nav-home,.booth-nav-asks{display:none}}", /* ---- ask fragments. Self-contained: the host page carries its own CSS and - nothing here may inherit from it, so the palette adapts via - prefers-color-scheme rather than borrowing. ---- */ - ".bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);", - "border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);", - "font:15px/1.5 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}", - ".bk-ask.bk-done{border-top-color:#3fae6a}", - ".bk-ask.bk-skip{border-top-color:#6f7c8c}", - ".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}", - ".bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;", - "letter-spacing:.12em;text-transform:uppercase;color:#c9a227}", - ".bk-ask.bk-done .bk-ask-tag{color:#3fae6a}", - ".bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}", + nothing here may inherit from it. The palette is a set of custom + properties SCOPED TO .bk-ask, flipped by prefers-color-scheme — so each + rule below is written once and a host page cannot reach the values + without targeting our own class. Neutrals stay translucent so the + fragment sits on a light or a dark host alike. ---- */ + ".bk-ask{--bk-accent:#b2cd12;--bk-accent-line:rgba(178,205,18,.55);--bk-accent-soft:rgba(178,205,18,.12);", + "--bk-on-accent:#0c1014;--bk-open:#d29a02;--bk-open-text:#fbc10f;--bk-done:#71a166;--bk-done-text:#9bce90;", + "--bk-skip:#868d91;--bk-err:#fea47d}", + "@media (prefers-color-scheme: light){.bk-ask{--bk-accent:#586519;--bk-accent-line:rgba(88,101,25,.55);", + "--bk-accent-soft:rgba(88,101,25,.11);--bk-on-accent:#fff;--bk-open:#7c5500;--bk-open-text:#7c5500;", + "--bk-done:#486741;--bk-done-text:#486741;--bk-skip:#52595e;--bk-err:#a42e07}}", + ".bk-ask{margin:1.1rem 0;padding:.9rem 1rem;border:1px solid rgba(128,140,160,.34);", + "border-top:2px solid var(--bk-open);border-radius:8px;background:rgba(128,140,160,.07);", + "font:15px/1.55 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}", + ".bk-ask.bk-done{border-top-color:var(--bk-done)}", + ".bk-ask.bk-skip{border-top-color:var(--bk-skip)}", + ".bk-ask-tag{display:inline-block;margin-bottom:.6rem;padding:2px 7px;border:1.5px solid currentColor;border-radius:3px;", + "font:600 10.5px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;", + "letter-spacing:.12em;text-transform:uppercase;color:var(--bk-open-text)}", + ".bk-ask.bk-done .bk-ask-tag{color:var(--bk-done-text)}", + ".bk-ask.bk-skip .bk-ask-tag{color:var(--bk-skip)}", + ".bk-ask-title{margin:0 0 .15rem;font:500 11px/1.4 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;", + "letter-spacing:.12em;text-transform:uppercase;opacity:.62}", ".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}", - ".bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}", - ".bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;", - "border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}", + ".bk-ask-opts{display:flex;flex-direction:column;gap:.35rem}", + ".bk-ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.55rem .7rem;cursor:pointer;", + "border:1px solid rgba(128,140,160,.3);border-radius:8px;background:rgba(128,140,160,.06)}", ".bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}", - ".bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}", - ".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}", + ".bk-ask-opt:has(input:checked){border-color:var(--bk-accent-line);background:var(--bk-accent-soft)}", + ".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:var(--bk-accent)}", ".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}", - ".bk-ask-det{font-size:.8rem;opacity:.68}", - ".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;", + ".bk-ask-det{font-size:.82rem;opacity:.68}", + ".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .65rem;", "font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);", - "border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}", - ".bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;", - "letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;", - "background:#2fa8a0;color:#08131a}", - ".bk-ask-go:hover{filter:brightness(1.09)}", - ".bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}", + "border:1px solid rgba(128,140,160,.34);border-radius:8px;resize:vertical}", + ".bk-ask-notes:focus{outline:2px solid var(--bk-accent);outline-offset:1px}", + /* the primary — green, because submitting is what arms the answer */ + ".bk-ask-go{margin-top:.75rem;cursor:pointer;font:600 13px/1 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;", + "padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);", + "background:var(--bk-accent);color:var(--bk-on-accent)}", + ".bk-ask-go:hover{filter:brightness(1.06)}", + ".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem;opacity:.8}", ".bk-ask-was b{opacity:1}", - ".bk-ask-err{color:#d6452a;font-size:.86rem}", - "@media (prefers-color-scheme: light){.bk-ask-tag{color:#8a6d10}.bk-ask-go{color:#fff}}", + ".bk-ask-err{color:var(--bk-err);font-size:.86rem}", "@media print{.bk-ask{break-inside:avoid}}" ].join(""); diff --git a/booth/templates/_marks.html b/booth/templates/_marks.html index 34beeb3..6a10c2a 100644 --- a/booth/templates/_marks.html +++ b/booth/templates/_marks.html @@ -26,13 +26,18 @@ {% set flags = marks | selectattr('shape', 'equalto', 'flag') | rejectattr('error') | list %}
+{# `picks_only` + `back_view`: the review rail (view.html) includes this panel + with `marks` narrowed to the open picks it should offer, and wants only the + pick forms, each landing back on the review (`back=view`, R2 C3). ONE + renderer of a pick form, whichever page it sits on. #} +{% if not picks_only %} {% for a in broken %}
⚠ broken {{ a.id }} -
+ {% if marks_page %}{% endif %} @@ -42,6 +47,7 @@
{% endfor %} +{% endif %} {% for a in picks %}
@@ -75,7 +81,7 @@ {% endif %}
{% if a.answer %}change answer{% else %}answer{% endif %} - + {# 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 %}{% endif %} + {% if 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 @@
{% endfor %} -{% for a in notes %} -
+{% if not picks_only %} +{# FLAGS come right after the picks. On the lightbox (`tray` defined) they + render as the TRAY: the flagged items in SET order — by tile number, the + declared R2 change from the click order below — each the original shown + small, blurred if the item is. The standalone marks page has no item + records, so it keeps the list, in `(created, id)` order. #} +{% if tray is defined %}{% if tray %} + {# In the lightbox the tray and the notes FOLD on a narrow screen (R2 C5): + a closed
, which base.html shows open-and-summary-less above + 1000px with no script. Below it, the question sits above the set and the + tray and notes are one tap away instead of burying it. #} +
+ ✔ flagged · {{ tray|length }} + -{% endfor %} - -{% if flags %} +
+ {% endif %} + {% if orphan_flags %} +
+
+ ✔ flagged + {{ orphan_flags|length }} on files no longer in this booth +
+
    + {% for m in orphan_flags %} +
  • {{ m.target }} +
    + + +
  • + {% endfor %} +
+
+ {% endif %} +{% elif flags %}
✔ flagged @@ -149,11 +183,34 @@
{% endif %} +{% set fold_notes = tray is defined and notes %} +{% if fold_notes %}
notes · {{ notes|length }}{% endif %} +{% for a in notes %} +
+
+ note + {% if a.target %}on {{ a.target }} + {% else %}on this booth{% endif %} + + {{ a.created }}{% if a.by %} · {{ a.by }}{% endif %} +
+ + {% if marks_page %}{% endif %} + +
+
+
{{ a.text }}
+
+{% endfor %} +{% if fold_notes %}
{% endif %} + + {# The operator volunteering a remark, which before marks had no mechanism at all — this is the direction that was running through chat. #} -
+ {% if marks_page %}{% endif %}
+{% endif %}
diff --git a/booth/templates/_svos_tokens.css b/booth/templates/_svos_tokens.css new file mode 100644 index 0000000..f5486c9 --- /dev/null +++ b/booth/templates/_svos_tokens.css @@ -0,0 +1,273 @@ +/* SVOS tokens — VENDORED BY COPY from design-systems + palettes/svos/colors.css + svos-theme.css @ ed2f8d8. Do not hand-edit values; + re-vendor from the source. The only transform: SVOS scopes its four themes + by [data-theme]; the Booth has no theme switch and follows the OS, so the + blocks are re-scoped onto prefers-color-scheme / prefers-contrast queries. + Semantic tokens only in the Booth layer below — never a primitive, never + a raw hex. */ +:root { + --graphite-10: oklch(0.17 0.01 250); + --graphite-15: oklch(0.21 0.01 248); + --graphite-20: oklch(0.255 0.011 246); + --graphite-25: oklch(0.31 0.012 244); + --graphite-30: oklch(0.37 0.012 242); + --graphite-40: oklch(0.46 0.012 238); + --graphite-50: oklch(0.55 0.011 234); + --graphite-60: oklch(0.64 0.01 230); + --graphite-65: oklch(0.69 0.009 228); + --graphite-70: oklch(0.73 0.009 226); + --graphite-75: oklch(0.79 0.009 224); + --graphite-80: oklch(0.84 0.009 222); + --graphite-90: oklch(0.91 0.008 216); + --graphite-94: oklch(0.945 0.006 214); + --graphite-96: oklch(0.965 0.005 212); + --graphite-98: oklch(0.985 0.003 210); + --green-deep: oklch(0.48 0.1 119); + --green-base: oklch(0.66 0.15 119); + --green-bright: oklch(0.8 0.185 119); + --sage-deep: oklch(0.48 0.07 140); + --sage-base: oklch(0.66 0.1 140); + --sage-bright: oklch(0.8 0.1 140); + --amber-deep: oklch(0.48 0.1 78); + --amber-base: oklch(0.72 0.148 82); + --amber-bright: oklch(0.84 0.17 86); + --orange-deep: oklch(0.48 0.16 36); + --orange-base: oklch(0.66 0.213 38.5); + --orange-bright: oklch(0.8 0.12 44); + --intel-deep: oklch(0.48 0.09 235); + --intel-base: oklch(0.66 0.1 235); + --intel-bright: oklch(0.8 0.08 235); + --armed-green: var(--green-bright); + --hazard-orange: var(--orange-base); + --graphite-ink: var(--graphite-15); +} + +/* dark — the lair default */ +:root { + color-scheme: dark; + --surface-sunken: var(--graphite-10); + --surface-base: var(--graphite-15); + --surface-raised: var(--graphite-20); + --surface-overlay: oklch(0.31 0.012 244); + --surface-card: var(--graphite-20); + --surface-input: var(--graphite-10); + --surface-scrim: oklch(0.14 0.01 250 / 0.72); + --text-heading: var(--graphite-90); + --text-body: var(--graphite-75); + --text-muted: var(--graphite-70); + --text-faint: var(--graphite-60); + --text-inverse: var(--graphite-15); + --text-link: var(--intel-bright); + --text-link-hover: var(--graphite-90); + --border-subtle: var(--graphite-25); + --border-default: var(--graphite-25); + --border-strong: var(--graphite-40); + --border-focus: var(--green-bright); + --accent: var(--green-bright); + --accent-hover: oklch(0.845 0.185 119); + --accent-active: oklch(0.73 0.17 119); + --accent-text: var(--green-bright); + --accent-contrast: var(--graphite-10); + --accent-soft: color-mix(in oklab, var(--green-bright) 12%, transparent); + --accent-soft-hover: color-mix(in oklab, var(--green-bright) 20%, transparent); + --success: var(--sage-base); + --success-text: var(--sage-bright); + --success-soft: color-mix(in oklab, var(--sage-base) 14%, transparent); + --warning: var(--amber-base); + --warning-text: var(--amber-bright); + --warning-soft: color-mix(in oklab, var(--amber-base) 13%, transparent); + --danger: var(--orange-base); + --danger-hover: oklch(0.71 0.18 38.5); + --danger-text: var(--orange-bright); + --danger-contrast: var(--graphite-10); + --danger-soft: color-mix(in oklab, var(--orange-base) 14%, transparent); + --intel: var(--intel-base); + --intel-text: var(--intel-bright); + --intel-soft: color-mix(in oklab, var(--intel-base) 14%, transparent); + --selection-bg: var(--green-bright); + --selection-fg: var(--graphite-10); +} + +/* art layer: voices, type, space, radii, motion, elevation, the devices */ +:root { + /* voices — Plex speaks, mono records, Exan appears on the letterhead */ + --font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --font-brand: "Exan", "IBM Plex Sans", sans-serif; + + /* type — 14px base, calm ladder */ + --size-display: 38px; --size-h1: 25px; --size-h2: 18px; --size-h3: 15px; + --size-body: 14px; --size-sm: 13px; --size-caption: 12px; + --size-micro: 11px; --size-mono: 12.5px; + --tracking-display: -0.02em; --tracking-h: -0.01em; --tracking-caps: 0.12em; + --leading-body: 1.55; --leading-tight: 1.12; + + /* spacing — 4px base, roomier than an ops console has any right to be */ + --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; + --space-5: 20px; --space-6: 24px; --space-8: 32px; --space-12: 48px; + --space-16: 64px; --space-24: 96px; + + /* radii — calm 8/12; the Bureau stamps square off at 3 */ + --radius-sm: 3px; --radius-md: 6px; --radius-lg: 8px; + --radius-xl: 12px; --radius-pill: 999px; + + /* motion — unhurried decel; nothing loops, nothing bounces */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --dur-1: 120ms; --dur-2: 200ms; --dur-3: 300ms; + + /* elevation (dark default: heavier, cool) */ + --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.35); + --shadow-md: 0 4px 14px rgb(0 0 0 / 0.4); + --shadow-lg: 0 14px 36px rgb(0 0 0 / 0.5); + + /* device 2 — the hazard stripe (irreversible actions ONLY) */ + --hazard-alt: color-mix(in oklab, var(--danger) 25%, var(--surface-sunken)); + --hazard-stripe: repeating-linear-gradient(-45deg, + var(--danger) 0 8px, var(--hazard-alt) 8px 16px); + + /* device 3 — the armed glow (live power ONLY) */ + --glow-armed: 0 0 12px color-mix(in oklab, var(--accent) 40%, transparent); +} + +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + --surface-sunken: var(--graphite-94); + --surface-base: var(--graphite-96); + --surface-raised: var(--graphite-98); + --surface-overlay: #ffffff; + --surface-card: var(--graphite-98); + --surface-input: #ffffff; + --surface-scrim: oklch(0.21 0.01 248 / 0.4); + --text-heading: var(--graphite-15); + --text-body: var(--graphite-30); + --text-muted: var(--graphite-40); + --text-faint: var(--graphite-50); + --text-inverse: var(--graphite-90); + --text-link: oklch(0.455 0.097 235); + --text-link-hover: var(--graphite-15); + --border-subtle: oklch(0.89 0.006 218); + --border-default: oklch(0.85 0.008 220); + --border-strong: var(--graphite-75); + --border-focus: var(--green-deep); + --accent: var(--green-deep); + --accent-hover: oklch(0.44 0.1 119); + --accent-active: oklch(0.4 0.09 119); + --accent-text: oklch(0.44 0.1 119); + --accent-contrast: #ffffff; + --accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent); + --accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent); + --success: var(--sage-deep); + --success-text: var(--sage-deep); + --success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent); + --warning: var(--amber-deep); + --warning-text: var(--amber-deep); + --warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent); + --danger: var(--orange-deep); + --danger-hover: oklch(0.44 0.15 36); + --danger-text: var(--orange-deep); + --danger-contrast: #ffffff; + --danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent); + --intel: var(--intel-deep); + --intel-text: var(--intel-deep); + --intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent); + --selection-bg: var(--green-deep); + --selection-fg: #ffffff; + --shadow-sm: 0 1px 2px rgb(23 31 38 / 0.07); + --shadow-md: 0 4px 14px rgb(23 31 38 / 0.1); + --shadow-lg: 0 14px 36px rgb(23 31 38 / 0.14); + --glow-armed: 0 0 10px color-mix(in oklab, var(--accent) 30%, transparent); + } +} + +@media (prefers-contrast: more) { + :root { + --surface-sunken: var(--graphite-10); + --surface-base: var(--graphite-15); + --surface-raised: var(--graphite-20); + --surface-overlay: oklch(0.31 0.012 244); + --surface-card: var(--graphite-20); + --surface-input: var(--graphite-10); + --surface-scrim: oklch(0.14 0.01 250 / 0.72); + --text-heading: var(--graphite-90); + --text-body: var(--graphite-75); + --text-muted: var(--graphite-70); + --text-faint: oklch(0.7142 0.0085 230); + --text-inverse: var(--graphite-15); + --text-link: var(--intel-bright); + --text-link-hover: var(--graphite-90); + --border-subtle: var(--graphite-25); + --border-default: var(--graphite-40); + --border-strong: var(--graphite-50); + --border-focus: var(--green-bright); + --accent: var(--green-bright); + --accent-hover: oklch(0.845 0.185 119); + --accent-active: oklch(0.73 0.17 119); + --accent-text: var(--green-bright); + --accent-contrast: var(--graphite-10); + --accent-soft: color-mix(in oklab, var(--green-bright) 12%, transparent); + --accent-soft-hover: color-mix(in oklab, var(--green-bright) 20%, transparent); + --success: var(--sage-base); + --success-text: var(--sage-bright); + --success-soft: color-mix(in oklab, var(--sage-base) 14%, transparent); + --warning: var(--amber-base); + --warning-text: var(--amber-bright); + --warning-soft: color-mix(in oklab, var(--amber-base) 13%, transparent); + --danger: var(--orange-base); + --danger-hover: oklch(0.71 0.18 38.5); + --danger-text: var(--orange-bright); + --danger-contrast: var(--graphite-10); + --danger-soft: color-mix(in oklab, var(--orange-base) 14%, transparent); + --intel: var(--intel-base); + --intel-text: var(--intel-bright); + --intel-soft: color-mix(in oklab, var(--intel-base) 14%, transparent); + --selection-bg: var(--green-bright); + --selection-fg: var(--graphite-10); + } +} + +@media (prefers-color-scheme: light) and (prefers-contrast: more) { + :root { + --surface-sunken: var(--graphite-94); + --surface-base: var(--graphite-96); + --surface-raised: var(--graphite-98); + --surface-overlay: #ffffff; + --surface-card: var(--graphite-98); + --surface-input: #ffffff; + --surface-scrim: oklch(0.21 0.01 248 / 0.4); + --text-heading: var(--graphite-15); + --text-body: var(--graphite-30); + --text-muted: oklch(0.4403 0.0102 238); + --text-faint: oklch(0.4403 0.00935 234); + --text-inverse: var(--graphite-90); + --text-link: oklch(0.4371 0.08245 235); + --text-link-hover: var(--graphite-15); + --border-subtle: oklch(0.89 0.006 218); + --border-default: var(--graphite-60); + --border-strong: var(--graphite-40); + --border-focus: var(--green-deep); + --accent: var(--green-deep); + --accent-hover: oklch(0.44 0.1 119); + --accent-active: oklch(0.4 0.09 119); + --accent-text: oklch(0.436 0.085 119); + --accent-contrast: #ffffff; + --accent-soft: color-mix(in oklab, var(--green-deep) 11%, transparent); + --accent-soft-hover: color-mix(in oklab, var(--green-deep) 18%, transparent); + --success: var(--sage-deep); + --success-text: oklch(0.4033 0.0595 140); + --success-soft: color-mix(in oklab, var(--sage-deep) 10%, transparent); + --warning: var(--amber-deep); + --warning-text: oklch(0.4103 0.085 78); + --warning-soft: color-mix(in oklab, var(--amber-base) 18%, transparent); + --danger: var(--orange-deep); + --danger-hover: oklch(0.44 0.15 36); + --danger-text: oklch(0.4225 0.136 36); + --danger-contrast: #ffffff; + --danger-soft: color-mix(in oklab, var(--orange-deep) 9%, transparent); + --intel: var(--intel-deep); + --intel-text: oklch(0.4373 0.0765 235); + --intel-soft: color-mix(in oklab, var(--intel-deep) 9%, transparent); + --selection-bg: var(--green-deep); + --selection-fg: #ffffff; + } +} diff --git a/booth/templates/base.html b/booth/templates/base.html index 710e4f4..9d2514c 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -4,541 +4,812 @@ {% block title %}The Booth{% endblock %} - + +{# The two SVOS voices. display=swap and the system stacks in --font-sans / + --font-mono are the failure mode booth-dev named: a dead or slow link must + degrade to instantly-readable text, never to invisible text. The request + carries the origin only (strict-origin-when-cross-origin), so booth names do + not reach Google. #} + + + @@ -546,7 +817,199 @@ The Booth ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't -
{% block content %}{% endblock %}
+
+{# R2 C3: where the in-place script says it could not save in place. Server + rendered and empty, so the script only ever sets its text — it builds no + markup (INV-6). #} + +{% block content %}{% endblock %}
+ + diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 58a5e65..131f20b 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -19,8 +19,9 @@ note field. Same macro discipline as blurtoggle above — three item branches, one definition. `marks` here is THIS item's marks, from item_marks. #} {% macro markcontrols(name_url, it, marks, cls='') -%} - {% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %} -
+ {# THE flag predicate (flagged_targets), shared with every other surface #} + {% set flagged = it.name in flagged_set %} +
@@ -45,7 +46,7 @@ {% endfor %}
+ note -
+ @@ -53,6 +54,13 @@
{%- endmacro %} +{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so + a column of them lines up. Width reads `all_items`, never the filtered list: + a filter must not change how a number is written any more than which. #} +{% macro ordinal(it) -%} + #{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }} +{%- endmacro %} + {% block title %}{{ name }} · The Booth{% endblock %} {% block content %}
@@ -66,7 +74,9 @@ {% else %}

{{ name }}

{% endif %} - {% if uploaded %}⬆ pickup {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %} + {# The open count and the lifetime line depend on marks, so they are a region + (R2 C3): answering the last pick in place must not leave "1 open" behind. #} + {% if uploaded %}⬆ pickup {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %} {% if items %}⬇ zip{% endif %} {{ provenance(manifest) }} {# A durable multi-writer board gets no one-click wipe — same rule as the @@ -109,8 +119,17 @@ a gallery, and the add-note control would be noise on it — but the suppression was unconditional, so a pick declared on a booth that happens to carry a links.md had no form to answer it and nothing said so. #} -{% if marks or not board %} +{# R2 C5: on a GALLERY booth the panel moves into the verdict aside beside the + set (below). It renders up here only where there is no set to sit beside — + a board, or a booth with marks and nothing to show. #} +{# `is_board`, not `board`: PAGE IDENTITY, not page content — the lesson the + bench panel already learned. `board` is the parsed rows, empty for a + links.md with none, and a board with an image in it must still be a board. #} +{% set lightbox = all_items and not is_board %} +{% if (marks or not board) and not lightbox %} +
{% include "_marks.html" %} +
{% endif %} {# THE BENCH REGISTRY — BLOCK LEVEL, and that placement is load-bearing. @@ -250,6 +269,18 @@ {% if not all_items and not board and not marks %}
This booth is empty.
{% elif all_items %} + {# THE LIGHTBOX (R2 C5). The verdict aside comes FIRST in the document and the + set second: on a narrow screen that is the stacking the contract wants + (the question above the work), and on a wide one the grid areas in + base.html put the aside on the right. Placement, not order — nothing in an + ordered collection moves. #} + {% if lightbox %} +