diff --git a/docs/fleettools/booth.md b/docs/fleettools/booth.md index 35f7f9b..b0a8eb1 100644 --- a/docs/fleettools/booth.md +++ b/docs/fleettools/booth.md @@ -63,4 +63,4 @@ chat drown in scrollback; the board is a kept booth rendered at the top of the B front page. Don't post noise: if the operator would not click it a week from now, it does not belong there. -**Full schema + rules:** `~/development/eshpfi-management/services/booth/README.md` +**Full schema + rules:** `~/development/booth/README.md` (gitea `vh/booth`; extracted from eshpfi 2026-09-21) diff --git a/servers/nh3-dev/README.md b/servers/nh3-dev/README.md index 6f8d36d..b4243be 100644 --- a/servers/nh3-dev/README.md +++ b/servers/nh3-dev/README.md @@ -136,13 +136,13 @@ local Bash already executes here — no SSH-to-self needed for non-privileged wo - **bloom_music dev** — `~/development/bloom_music`; its `web/` test harness uses Playwright headless Chromium for OSMD browser-geometry assertions. - **The Booth** — ephemeral media drop board (`:8090`, `booth.service`), from - eshpfi `services/booth/`. Lets CC sessions surface A/B renders + smoke results + `~/development/booth` (gitea `vh/booth`, extracted from eshpfi 2026-09-21). Lets CC sessions surface A/B renders + smoke results (and browser uploads for pickup) to the operator; 24h TTL, Homepage-linked. Since 2026-09-09 it also carries **asks** — a session poses a multiple-choice question in a booth, the operator answers a radio form + notes in the browser, and the pick lands as an answer sidecar the session reads (`booth ask` / `booth answer --wait`). ⚠ The **`booth` CLI is on PATH via - `~/.local/bin/booth` → `services/booth/scripts/booth`**, symlinked 2026-09-09; + `~/.local/bin/booth` → `~/development/booth/scripts/booth`**, symlinked 2026-09-09; before that it was on no PATH at all, so every session following the global link-board convention was hitting `command not found` unless it used the full path. `~/.zshenv` puts `~/.local/bin` in PATH for non-interactive `ssh nh3-dev diff --git a/services/booth/.gitignore b/services/booth/.gitignore deleted file mode 100644 index 5ce9c8b..0000000 --- a/services/booth/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -.venv/ -__pycache__/ -*.pyc -*.egg-info/ -.pytest_cache/ -booth-data/ -uv.lock diff --git a/services/booth/README.md b/services/booth/README.md index 4a9210f..a68e606 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -1,515 +1,45 @@ -# The Booth +# The Booth — moved to its own repository -A dead-simple standing web server for shuttling **ephemeral files** between the -operator and CC sessions — A/B renders, smoke-test screenshots, audio/video -samples, or anything you want to hand off. It works both directions: +The Booth now lives in a dedicated repo: -- **Session → operator:** a session drops a folder of files on disk; the Booth - renders it as a browsable "booth". -- **Operator/anyone → pickup:** upload files through the browser (or `curl -F`) - and get a **human-readable pickup id** like `4-wombat` or `star-84`. +> **`~/development/booth`** → gitea `vh/booth` on gitea.phasefinal.com -Either way it **wipes 24h after the last activity**. No database — the -filesystem *is* the state. +Extracted from this workspace on 2026-09-21, with all 29 commits of history +preserved (`git subtree split`). Like chatterbox-fast and tts-stack, the Booth +is **authored software with a test suite** — 173 tests, ~3,000 lines, a CLI, and +a consumer surface used by 17 agent handles daily — so it follows the +sister-repo pattern rather than staying a service directory here. -- **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth* -- **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth) -- **TTL:** 24h, measured from the newest mtime in a booth's tree (it lives while - you're touching it, self-destructs 24h after you stop) +The extraction was triggered by the 2026-09-21 polish pass: the Booth is taking +an information-architecture rework and a cross-agent SVOS design retrofit from +`design-dev`, and that work wants its own ROADMAP, contracts, and blast radius — +not the fleet-infrastructure repo's. -## How a session posts +## Deployed service -A booth is **just a folder** under the data dir. Three ways, cheapest first: - -```bash -# 1. On nh3-dev — the helper (services/booth/scripts/booth): -booth add my-run out/a.png out/b.png # creates booth + copies, prints URL -booth new my-run # empty booth, then cp/mv into ~/booth-data/my-run/ -booth url my-run # just print the URL -booth ls # list booths -booth rm my-run # wipe now (TTL would anyway) - -# 2. On nh3-dev — raw, no helper: -mkdir -p ~/booth-data/my-run && cp out/*.png ~/booth-data/my-run/ -# -> http://10.100.10.50:8090/b/my-run/ - -# 3. From another host — rsync into the data dir: -rsync -a ./out/ nh3-dev:booth-data/my-run/ -``` - -Then hand the operator `http://10.100.10.50:8090/b/my-run/`. - -## Checking that controls can actually be clicked - -```bash - scripts/layout-probe.py [URL ...] -``` - -⚠ **Markup inspection structurally cannot catch occlusion, and this UI has -shipped two dead controls in two days** — a reveal button whose handler Jinja -discarded, and a `×` that a sibling `release` form painted over completely -(30x22 px overlap on a 30px button; `elementFromPoint` at its centre returned -the other form). Both were reported by the operator. Both passed every test, -because the markup, the routes and the CSS were each individually correct. - -The probe walks every button and link, scrolls it into view, and asks the -browser what a click at its centre would actually hit. It took four iterations -to become trustworthy, and each failure is worth knowing because they are the -traps in writing this kind of check at all: - -1. `top.contains(el)` counted an **ancestor** overlay as a hit — which is the - exact case the probe exists to catch. It reported OK for a real overlay. -2. `elementFromPoint` is **viewport-relative**, so everything below the fold - read as occluded. Scroll first. -3. `getBoundingClientRect()` on a **wrapped inline** element is the union of - its line boxes, whose centre can sit in the gutter between them, on the - parent. Use `getClientRects()[0]`. -4. Only after all three does the positive control (a real overlay) fire while - the negative control (the clean page) stays silent. **Both were run.** A - probe that has never been seen to fail is not evidence of anything. - -## Blurring an item (cosmetic censoring) - -⚠⚠ **Blur is NOT access control.** A blurred item is still served at its own -URL, still included in the zip, still on disk. It hides a thing from a *glance* -— a shoulder, a screen-share, a scroll past something you did not want -full-size — and nothing else. The Booth has no auth by design: **if a thing must -not be SEEN by whoever can reach port 8090, it must not be in a booth.** - -```bash -booth blur ... # hide from a glance -booth unblur ... -``` - -Or the **`◌ blur` / `◉ blurred`** button on every item in the booth page — -in the caption row for images, video, audio and plain files, and in the doc bar -beside ⤢ ⬇ ✕ for inline docs. - -⚠ The toggle is emitted by ONE Jinja macro (`blurtoggle`) called from all three -item branches. booth.html renders docs, media and everything-else through -separate `
` blocks, and this feature was twice shipped having patched -only some of them — first the blur class, then the toggle itself. Add a fourth -branch and you must call the macro from it; -`test_every_item_kind_gets_exactly_one_blur_toggle` counts toggles against -figures across mixed kinds and will fail if you don't. - -- **State** is `.blurred` in the booth dir — one booth-relative item path per - line, the same filesystem-is-the-state idiom as `.pins` and `.forever`. An - empty set deletes the file rather than leaving a zero-byte one, so `ls -a` - tells the truth about whether anything here is blurred. -- **Reveal is per-viewer and never persisted.** Click 👁 reveal; a reload - re-hides. With JS off it stays blurred, which is the safe direction to fail. - - ⚠ This button shipped INERT on 2026-09-20 and stayed that way for a day. Its - handler sat after the content block's closing tag, and a child template's - out-of-block content is silently DISCARDED by Jinja — the button rendered, - the handler never reached the browser, and two commits plus this README said - it worked. The suite passed throughout because nothing asserted against the - served page. `test_reveal_handler_actually_reaches_the_served_page` now greps - the HTTP RESPONSE, and `test_no_orphaned_markup_after_the_content_block` - guards the structure. Both were confirmed to FAIL when the defect is - reintroduced, which is the only way to know a guard guards anything. -- **Covers inherit it.** If a booth's cover image is blurred, the index card's - thumb is blurred too — otherwise the front page undoes the censoring. -- **Inline docs are blurred too**, not just images and video. That branch puts - readable text straight on the page, so it needs this more than a picture does. - -## Keeping a booth (round trip, both directions) - -Three places, all doing the same thing: - -- **Index, ephemeral card** — `★` promotes to the kept lane. -- **Index, kept card** — `release` demotes, and `×` now WIPES DIRECTLY. The old - rule was release-then-find-it-in-the-other-lane; that protected nothing and - cost a hunt, because the board you just released is loose in a feed that - turns over. Protection lives in the confirmation now, which names the booth - and says KEPT. -- **Inside a booth** — `☆ keep` / `★ kept — release`, beside *Wipe now*, so you - do not have to go back to the index. These post a `next` field to stay on the - page; `next` is a form field and therefore attacker-controlled, so only - same-site absolute paths are honoured (`//host`, schemes and backslashes are - refused). - -Equivalent CLI: `booth keep ` / `booth unkeep `. - -⚠ Release BUMPS the directory mtime, so a released board's age resets and it -survives another full TTL. Unkeep-and-wait is a 24h delay, not a delete — which -is exactly why the direct `×` was worth adding. - -⚠ Until 2026-09-19 the UI only went one way — the kept lane could release, but -an ephemeral booth could only be kept from a shell. The `/keep` route and the -CLI verb both already existed; only the button was missing. - -## Kept boards — the one exception to the 24h rule - -A booth containing a **`.forever`** dotfile is **never swept**, and renders in -its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no -countdown, no one-click wipe). Everything else is unchanged: the default is -still ephemeral, so nobody inherits a cleanup chore they didn't ask for. - -```bash -booth keep my-board # drop the sentinel — exempt from the sweep, forever -booth unkeep my-board # release the pin — the board rejoins the sweep -booth rm my-board # delete it NOW (works on kept boards; says so when it was kept) - -booth links # list the standing link board: row number, entry id, the row -booth unlink 3 # remove row 3 -booth unlink 8b40e0a5 # or remove by entry id (what the web UI's × posts) -``` - -It is just a file, so the manual forms work identically and are the honest -mental model: - -```bash -touch ~/booth-data/my-board/.forever # keep -rm ~/booth-data/my-board/.forever # unkeep -rm -rf ~/booth-data/my-board # delete outright, whenever you like -``` - -**Why this exists:** agent sessions hand the operator URLs — a booth of renders, -a PR, a dashboard — and they drown in terminal scrollback. Kept boards are where -those go instead. - -### The standing link board - -```bash -booth link [description] -``` - -Appends one line to the **`links`** board (`$BOOTH_LINKS_BOARD`, default -`links`), creating it and marking it kept on first use. Each entry carries -provenance — who posted it and when — because a bare URL is unreadable three -days later. `links.md` renders as a readable page in the booth. - -The append is a single `printf` of a single line to an `O_APPEND` fd, which is -atomic under `PIPE_BUF` on POSIX. That matters here specifically: many agents -post to one board, and interleaved half-lines would be the obvious failure. - -Deliberately **not** a database. The board is a markdown file — editable with -any editor, greppable, and trivially prunable by hand, which is the whole point -of the Booth's filesystem-is-the-state model. - -## Asks — let the operator pick one of N, and read the pick back - -The one **interactive** primitive. A session needs a human decision — which -render wins, which plan, go/no-go — and wants to act on it without a chat -round-trip. Drop a question in a booth; the page renders it as a radio form -with a notes field; the operator's submit writes an **answer sidecar** the -session reads. Filesystem is still the state: +Live on **nh3-dev** at `http://10.100.10.50:8090/`, as a **user-level** systemd +unit (no root, no Docker) running straight from the new checkout: ``` -/.ask.json the question (a session writes it) -/.answer.json the answer (the web UI writes it, atomically) +WorkingDirectory=/home/lkraven/development/booth +ExecStart=/home/lkraven/development/booth/.venv/bin/uvicorn booth.app:app --host 0.0.0.0 --port 8090 ``` -```bash -# On nh3-dev — pose, then block until answered (default 1h), then act on it: -booth ask r18-ab winner "Which render wins?" "A — baseline" "B — cudaMallocAsync" -booth answer r18-ab winner --wait # prints the answer JSON when it lands -booth answer r18-ab winner # non-blocking: exit 1 while unanswered -booth asks r18-ab # list a booth's asks + state - -# Options can carry an id + detail line instead of a bare label — write the -# JSON yourself (booth.asks.write_ask validates the same way): -cat > ~/booth-data/r18-ab/plan.ask.json <<'EOF' -{"title": "optional short label above the question", - "prompt": "Ship which?", - "options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"}, - {"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}], - "notes": true, "notes_label": "why / conditions"} -EOF - -# From another host: rsync the ask in, then poll the sidecar over HTTP: -curl -sf http://10.100.10.50:8090/b/r18-ab/winner.answer.json # 404 until answered -``` - -**Several questions, one form.** Give the ask a `questions` list instead of -`prompt`+`options`; the page renders one form with a radio group per question -and a single submit, every question required. Per-question `notes: true` adds -a small text field under that question; the form-level `notes` stays one field -for the whole ask. The answer is keyed by question: - -```bash -cat > ~/booth-data/r18-ab/batch.ask.json <<'EOF' -{"title": "R18 batch review", - "questions": [ - {"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": true}, - {"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]}, - {"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}], - "notes": true, "notes_label": "anything else"} -EOF -# -> batch.answer.json: {"stem", "title", "answers": {"r1": {"prompt", "choice", -# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"} -``` - -Both shapes also carry **`unanswered`** (the question keys left blank; `[null]` -for a blank single-question ask) and **`complete`** (false until every question -has a pick). A reading session should check `complete` before acting on a -multi-question answer, and treat a key in `unanswered` as "not decided", never -as "declined". - -The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes", -"unanswered", "complete", "answered_at", "answered_by"}` — `choice` is the option id (the label itself -for string options), `choice_index` its 0-based position, `answered_by` the -client address. `POST /b//answer` is what the form submits — fields `ask` plus -`choice` / `notes` (single) or `choice.` / `notes.` / `notes` (multi); -a missing or bad choice is a 400, an unknown stem a 404. - -Rules of the primitive: - -- **Radio, one pick per question.** ≥ 2 options, ≤ 40 per question, ≤ 30 - questions per ask. No multi-select checkboxes (not yet asked for). Many asks - per booth are fine — each is its own form and its own sidecar; use - `questions` when the picks belong together and should land as one answer. -- **Re-answering overwrites.** The sidecar is the *current* answer, not a log. - The page shows the recorded answer with a collapsed *change answer* form. -- **Blanks are legal — a partial answer is recorded, not refused.** Leaving a - question alone is a real outcome ("none of these", "not listened to yet"), and - refusing the whole submission over one blank threw away the picks that WERE - made. So every answered question is recorded, every blank one lands in - `unanswered`, and `complete` says whether the set is finished. The radios carry - no HTML `required`, so the browser does not block the submit either. A question - left blank but carrying a note keeps the note (`choice: null`). The one refusal - is a submission with **no pick anywhere and no notes** — a 400, because it would - flip an open ask to "answered" while recording no decision, which is worse for - the reading session than leaving it open. A choice that is not in the option - list is still an error: that is a broken form, not a skipped question. - Partially-answered asks show as `◐ partial` with an `n/N` count; re-submitting - fills in the rest. -- **Open asks are flagged** — an amber `? N asks` badge on the index card and in - the booth header — so a waiting question is visible from the front page. -- **A broken ask is shown as broken**, not hidden: if the JSON does not - validate, the page says why, so a session never thinks it posted a question - the operator cannot see. -- Ask/answer files are not gallery items and do not count toward the booth's - item count; they render as the panel above the gallery. Answering bumps the - booth's mtime, so it lives another TTL — the session has 24h to read it. -- Works with JavaScript off (plain form POST). No auth, same as everything here. - -### Where the form renders - -Two booth shapes, two placements. Either way the ask is never invisible — that -is the guarantee; markup only moves it somewhere better. - -**Auto-gallery booth** (no `index.html` of its own): the asks panel renders -above the gallery, styled like the rest of the Booth. Nothing to do. - -**A booth serving its own `index.html`**: that page is returned verbatim, so the -Booth substitutes **placeholders in your markup** rather than rendering a panel -above a gallery that does not exist. The question then sits with the artifact it -is about (operator ruling 2026-09-09: *"the asks should be inline with the -artifacts, not on a separate page"*). - -**When inline is worth the markup, and when it is not.** The test is whether the -artifact can be held in the head while the form is on screen. Two short images -side by side — no, the appended form is fine. Twenty audio clips, five per voice -across four voices — yes: on a separate page the operator is choosing from -*memory of the audio*, not from the audio, and by the fourth voice that memory is -gone. That is the case this mechanism exists for (framing owed to tts-dev, -2026-09-09, from the `redo-anchors` audition). - -```html -
-
-
- -``` - -Per-question fragments bind to **one** form via the HTML5 `form=` attribute, so a -four-voice audition puts each radio group under that voice's audio and still -submits every pick in a single POST — which is what a multi-question ask -requires. Fragments ship their own scoped styles, inherit nothing from your page, -and use no JavaScript. - -⚠ **Put the placeholder outside any CSS grid or flex container**, or it becomes a -cell in it — measured on `redo-anchors`, where the first attempt rendered as a -224 px sixth grid cell wedged between two audio players. A sibling of the block -it belongs to is right. - -The fallbacks, so a page can never strand a question: - -| you marked up | what happens | -|---|---| -| nothing | the whole ask is appended at the end of the page | -| some questions, no submit | the rest of the questions **and** a submit block are appended | -| a stem this booth does not have | your markup is left alone, untouched; the real ask is still appended | - -An amber `? N open asks` chip floats top-right as a jump link to the first open -ask, and `GET /b//asks` still renders every ask on a plain page of its own -— useful when you want to hand someone only the question. - -## Upload for pickup - -The reverse direction — put files in through the web, pick them up by id: - -- **Browser:** the index page has an *Upload files for pickup* panel - (drag-drop or click). Submit → you land on a booth with a **human-readable - id** (`4-wombat`, `star-84`) whose files each have a ⬇ download link. -- **curl (a remote session with no ssh to nh3-dev can use this too):** - ```bash - curl -sS -i -F 'files=@out/a.png' -F 'files=@out/b.png' \ - http://10.100.10.50:8090/upload | grep -i location - # Location: /b/star-84/ <- the pickup id - ``` -- **Pick up** at `http://10.100.10.50:8090/b//` (download links), or on - nh3-dev straight off disk at `~/booth-data//`. - -Uploads are stamped as pickup booths (a `⬆ pickup` badge in the UI) and expire -on the same 24h TTL. Limits: `BOOTH_MAX_FILES` files (default 50) and -`BOOTH_MAX_UPLOAD_MB` total per submission (default 1024); filenames are reduced -to a safe basename (no path traversal). - -## What a booth renders - -- **Has its own `index.html`?** → served **verbatim** (its relative assets — - `chart.png`, `report.css` — resolve out of the same folder). Build whatever - page you want. -- **No `index.html`?** → **auto-gallery** of the folder's media: - - images (`png jpg jpeg gif webp avif svg bmp`) → `` (click → full-screen - viewer with **Fit** / **1:1** — the toggle only appears when the image is - larger than the viewport — plus download and ✕/Esc back to the gallery) - - video (`webm mp4 ogv m4v mov`) → `
."""
-    if kind == "markdown" and _markdown is not None:
-        html = _markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
-        return html, True
-    return text, False
-
-
-def booth_image_names(child: Path) -> list[str]:
-    """Image files in a booth, in gallery (sorted-rel) order — for viewer prev/next."""
-    return sorted(
-        p.relative_to(child).as_posix()
-        for p in child.rglob("*")
-        if p.is_file() and not p.name.startswith(".") and classify(p.name) == "image"
-    )
-
-
-def classify(name: str) -> str:
-    """image | video | audio | other, by extension."""
-    ext = Path(name).suffix.lower()
-    if ext in IMAGE_EXTS:
-        return "image"
-    if ext in VIDEO_EXTS:
-        return "video"
-    if ext in AUDIO_EXTS:
-        return "audio"
-    return "other"
-
-
-def human_dur(seconds: float) -> str:
-    s = int(seconds)
-    if s <= 0:
-        return "expired"
-    h, rem = divmod(s, 3600)
-    m, _ = divmod(rem, 60)
-    if h and m:
-        return f"{h}h {m}m"
-    if h:
-        return f"{h}h"
-    if m:
-        return f"{m}m"
-    return "<1m"
-
-
-def _newest_mtime(path: Path) -> float:
-    """Newest mtime among a folder and everything under it."""
-    try:
-        newest = path.stat().st_mtime
-    except OSError:
-        return 0.0
-    for p in path.rglob("*"):
-        try:
-            m = p.stat().st_mtime
-        except OSError:
-            continue
-        if m > newest:
-            newest = m
-    return newest
-
-
-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)
-
-
-def is_expired(path: Path, ttl_seconds: float, now: float | None = None) -> bool:
-    """Pure age question. Deliberately does NOT consider the keep sentinel.
-
-    Expiry arithmetic (what `expires_in` renders) and reaper policy (what
-    actually gets deleted) are kept apart so they cannot drift into each other.
-    Only `sweep_once` honours the pin.
-    """
-    return booth_age_seconds(path, now) > ttl_seconds
-
-
-def is_kept(path: Path) -> bool:
-    """True if this booth carries the keep sentinel and must never be swept."""
-    return (path / KEEP_MARKER).exists()
-
-
-def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[str]:
-    """Wipe every direct-child booth older than the TTL. Returns names wiped.
-
-    Only ever removes direct children of data_dir (never data_dir itself), and
-    skips dotfolders so a stray control dir can opt out.
-
-    A booth carrying KEEP_MARKER is exempt no matter how stale it is. That is
-    the one escape hatch from the 24h contract, and it is opt-in per booth: the
-    default stays ephemeral, so nobody inherits a cleanup chore they did not ask
-    for. Removing the sentinel hands the booth straight back to the sweeper.
-    """
-    wiped: list[str] = []
-    if not data_dir.is_dir():
-        return wiped
-    for child in data_dir.iterdir():
-        if not child.is_dir() or child.name.startswith("."):
-            continue
-        try:
-            if is_kept(child):
-                continue
-            if is_expired(child, ttl_seconds, now):
-                shutil.rmtree(child)
-                wiped.append(child.name)
-        except OSError:
-            pass
-    return wiped
-
-
-def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[dict]:
-    now = time.time() if now is None else now
-    booths: list[dict] = []
-    if not data_dir.is_dir():
-        return booths
-    for child in data_dir.iterdir():
-        if not child.is_dir() or child.name.startswith("."):
-            continue
-        files = [
-            p for p in child.rglob("*")
-            if p.is_file() and not p.name.startswith(".")
-            and not is_ask_file(p.name) and not is_answer_file(p.name)
-        ]
-        # Asks are questions, not items: counted separately so the index can
-        # flag a booth that is waiting on the operator.
-        asks = list_asks(child)
-        kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
-        thumb_url = None
-        for f in files:
-            k = classify(f.name)
-            kinds[k] += 1
-            if k == "image" and thumb_url is None:
-                thumb_url = quote(f.relative_to(child).as_posix(), safe="/")
-        mtime = _newest_mtime(child)
-        booths.append(
-            {
-                "name": child.name,
-                "name_url": quote(child.name, safe=""),
-                "count": len(files),
-                "kinds": kinds,
-                "thumb_url": thumb_url,
-                # If the cover image is blurred inside the booth, blur it on the
-                # index too — otherwise the front page cheerfully displays the
-                # exact thing someone asked to hide.
-                "thumb_blurred": thumb_url is not None
-                and unquote(thumb_url) in read_blurred(child),
-                "has_index": (child / "index.html").is_file(),
-                "uploaded": (child / UPLOAD_MARKER).exists(),
-                "kept": is_kept(child),
-                "asks_total": len(asks),
-                "asks_open": sum(1 for a in asks if a["answer"] is None and not a["error"]),
-                "expires_in": max(0.0, ttl_seconds - (now - mtime)),
-                "mtime": mtime,
-            }
-        )
-    booths.sort(key=lambda b: b["mtime"], reverse=True)
-    return booths
-
-
-def build_gallery(child: Path) -> list[dict]:
-    """Files in a booth as render items, with caption sidecars folded in.
-
-    A `.txt` (e.g. `a.png.txt`) or a same-stem `.txt` (e.g. `a.txt`
-    next to `a.png`) is consumed as that item's caption rather than shown itself —
-    the natural way to label an A/B pair.
-    """
-    all_files = [
-        p for p in child.rglob("*")
-        if p.is_file() and not p.name.startswith(".")
-        # `*.ask.json` / `*.answer.json` render as the asks panel, not as tiles
-        and not is_ask_file(p.name) and not is_answer_file(p.name)
-    ]
-    by_rel = {p.relative_to(child).as_posix(): p for p in all_files}
-    caption: dict[str, str] = {}
-    sidecars: set[str] = set()
-
-    for rel, p in by_rel.items():
-        if not rel.lower().endswith(".txt"):
-            continue
-        target = None
-        base_full = rel[:-4]  # strip ".txt"  -> "a.png.txt" => "a.png"
-        if base_full in by_rel:
-            target = base_full
-        else:  # "a.txt" beside "a.png"
-            parent = str(Path(rel).parent)
-            stem = Path(rel).stem
-            for q_rel, q in by_rel.items():
-                if q_rel == rel:
-                    continue
-                if (
-                    str(Path(q_rel).parent) == parent
-                    and Path(q_rel).stem == stem
-                    and classify(q.name) != "other"
-                ):
-                    target = q_rel
-                    break
-        if target is not None:
-            try:
-                caption[target] = p.read_text(errors="replace").strip()[:CAPTION_MAX]
-            except OSError:
-                pass
-            sidecars.add(rel)
-
-    blurred = read_blurred(child)
-    items = []
-    for rel in sorted(by_rel):
-        if rel in sidecars:
-            continue
-        p = by_rel[rel]
-        dkind = doc_kind(p.name)
-        rendered = None
-        rendered_html = False
-        # Pre-render docs so the gallery can show them INLINE (collapsible)
-        # instead of linking out to a separate page. Bounded by DOC_MAX_BYTES:
-        # a giant log stays a download link rather than being inlined into every
-        # index render. Markdown → HTML (marked safe in the template); plain text
-        # is returned RAW and the template escapes it inside 
 — pre-escaping
-        # here would double-encode under Jinja autoescape.
-        if dkind is not None:
-            try:
-                if p.stat().st_size <= DOC_MAX_BYTES:
-                    text = p.read_text(errors="replace")
-                    rendered, rendered_html = render_doc(text, dkind)
-            except OSError:
-                rendered = None
-        items.append(
-            {
-                "name": rel,
-                "kind": classify(p.name),
-                "doc": dkind,
-                "url": quote(rel, safe="/"),
-                "caption": caption.get(rel),
-                "rendered": rendered,
-                "rendered_html": rendered_html,
-                "blurred": rel in blurred,
-            }
-        )
-    return items
-
-
-def zip_booth(booth: Path) -> bytes:
-    """Zip a booth's whole tree (dotfiles excluded) into an in-memory archive.
-
-    Lets a booth be downloaded as one artifact regardless of shape — the case a
-    verbatim `index.html` booth (e.g. a rendered brief + its assets) has no
-    per-file download affordance for, since the page is served raw.
-    """
-    buf = io.BytesIO()
-    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
-        for p in sorted(booth.rglob("*")):
-            if p.is_file() and not p.name.startswith("."):
-                zf.write(p, p.relative_to(booth).as_posix())
-    return buf.getvalue()
-
-
-def _zip_filename(name: str) -> str:
-    """A Content-Disposition-safe `.zip` (strip quotes/control chars)."""
-    safe = "".join(c for c in name if c.isprintable() and c != '"')
-    return f"{safe or 'booth'}.zip"
-
-
-# ---- verbatim-index.html wrapper -------------------------------------------
-
-# Mirror of base.html's favicon (the app templates set it there; this is the copy
-# injected into a booth's *verbatim* index.html so a raw page inherits 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"
-)
-FAVICON_LINK = f''
-
-# A self-contained floating "back to all booths" chip injected into verbatim
-# booths. Scoped class + fixed positioning + max z-index so it overlays the raw
-# page without touching its layout; hidden in print so downloaded reports stay clean.
-_BACK_CHIP = (
-    '‹ all booths'
-    # top-right: empty on left-aligned report layouts (a top-left chip clips the
-    # page title), and consistent with the zoom view's top-right back affordance.
-    ""
-)
-
-# A booth's own index.html is served VERBATIM, so the asks panel — which lives in
-# the auto-gallery template — can never appear on it. Without this chip an ask
-# posted into a custom-report booth is INVISIBLE to the operator with nothing to
-# say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page
-# showed nothing). Same injection mechanism as the back chip; it links to the
-# standalone /asks page, which renders the real forms.
-def asks_chip(name: str, open_count: int, href: str | None = None) -> str:
-    if open_count < 1:
-        return ""
-    label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
-    href = href or f"/b/{quote(name, safe='')}/asks"
-    return (
-        f'{label}'
-        ""
-    )
-
-
-WRAP_MAX_BYTES = 8 * 1024 * 1024  # above this, serve the verbatim page raw (unwrapped)
-
-_ICON_RE = re.compile(r"]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
-_HEAD_CLOSE_RE = re.compile(r"", re.IGNORECASE)
-_HTML_OPEN_RE = re.compile(r"]*>", re.IGNORECASE)
-_DOCTYPE_RE = re.compile(r"]*>", re.IGNORECASE)
-_BODY_CLOSE_RE = re.compile(r"", re.IGNORECASE)
-_HTML_CLOSE_RE = re.compile(r"", re.IGNORECASE)
-
-
-def _insert_before(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
-    m = pattern.search(html)
-    if m:
-        return html[: m.start()] + snippet + html[m.start() :], True
-    return html, False
-
-
-def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
-    m = pattern.search(html)
-    if m:
-        return html[: m.end()] + snippet + html[m.end() :], True
-    return html, False
-
-
-def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK, extra: str = "") -> str:
-    """Inject a floating 'all booths' back-chip — and the Booth favicon, if the page
-    declares none — into a booth's verbatim index.html, without altering the page's
-    rendered content.
-
-    Robust to the compact HTML real booths use (`
-    <style>…content`, no explicit head/body). The two hard constraints:
-      * NEVER put anything ahead of a leading <!doctype> — that forces quirks mode.
-      * Keep the charset <meta> within the first 1024 bytes so it's still honoured.
-    So the favicon lands at the first head-ish seam (before </head>, else after
-    <html>, else right after the doctype — a ~250B link keeps charset in range), and
-    the fixed-position chip is appended at the END of the document (before </body> /
-    </html> or appended), which renders top-left regardless and disturbs nothing.
-    """
-    if favicon_link and not _ICON_RE.search(html):
-        for inserter, pat in (
-            (_insert_before, _HEAD_CLOSE_RE),  # inside an explicit <head>
-            (_insert_after, _HTML_OPEN_RE),    # top of an explicit <html>
-            (_insert_after, _DOCTYPE_RE),      # right after the doctype (compact HTML)
-        ):
-            html, done = inserter(html, pat, favicon_link)
-            if done:
-                break
-        else:
-            html = favicon_link + html  # bare fragment, no doctype: safe to prepend
-
-    chips = _BACK_CHIP + (extra or "")
-    for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
-        html, done = _insert_before(html, pat, chips)
-        if done:
-            break
-    else:
-        html = html + chips  # no </body>/</html>: append to the end
-    return html
-
-
-# ---- uploads (browser drop-off for pickup) ---------------------------------
-
-UPLOAD_MARKER = ".uploaded"  # dotfile stamped into upload booths (excluded from listings)
-
-# Friendly, unambiguous words for human-readable pickup ids (4-wombat / star-84).
-PICKUP_WORDS = (
-    "wombat otter panda koala tiger walrus gecko heron badger beaver falcon marmot "
-    "lemur narwhal ocelot puffin quokka raccoon tapir urchin vulture weasel yak zebra "
-    "alpaca bison cobra dingo egret ferret gibbon hare ibis jaguar llama moose newt "
-    "osprey possum quail robin seal toad viper wren lynx mole swan crane finch sloth "
-    "shrew stoat skunk heronry orca walnut sparrow "
-    "star comet moon cloud river maple cedar birch fern moss reef dune mesa cove glade "
-    "brook pine cedarwood kelp coral amber opal jade onyx slate flint ember spark frost "
-    "storm tide wave ridge peak vale marsh delta atoll canyon fjord geyser lagoon prairie "
-    "anchor beacon lantern kettle copper brass velvet cobalt indigo crimson violet olive "
-    "hazel cocoa mango guava papaya plum kiwi lime pear quince radish turnip acorn clover "
-    "thistle poppy aster dahlia iris lily sage thyme basil clove nutmeg ginger honey"
-).split()
-
-
-def safe_upload_name(name: str, fallback: str) -> str:
-    """Reduce a client-supplied filename to a safe basename (no path, no hidden)."""
-    base = (name or "").replace("\\", "/").split("/")[-1].strip()
-    base = base.lstrip(".")  # a leading dot would hide the file from every listing
-    return base[:200] or fallback
-
-
-def _dedupe_name(name: str, used: set) -> str:
-    if name not in used:
-        return name
-    stem, dot, ext = name.partition(".")
-    i = 1
-    while f"{stem}-{i}{dot}{ext}" in used:
-        i += 1
-    return f"{stem}-{i}{dot}{ext}"
-
-
-def generate_pickup_id(exists) -> str:
-    """A human-readable id like '4-wombat' or 'star-84'. `exists(name)->bool` gates collisions."""
-    for _ in range(400):
-        word = secrets.choice(PICKUP_WORDS)
-        num = secrets.randbelow(99) + 1
-        name = f"{num}-{word}" if secrets.randbelow(2) else f"{word}-{num}"
-        if not exists(name):
-            return name
-    # astronomically unlikely fallback: two words keep it human-readable
-    while True:
-        name = f"{secrets.choice(PICKUP_WORDS)}-{secrets.choice(PICKUP_WORDS)}-{secrets.randbelow(999) + 1}"
-        if not exists(name):
-            return name
-
-
-def create_app(
-    data_dir,
-    ttl_hours: float = 24.0,
-    host_label: str = "",
-    start_sweeper: bool = True,
-    sweep_interval_s: int = 900,
-    max_upload_mb: float = 1024.0,
-    max_files: int = 50,
-) -> FastAPI:
-    data_dir = Path(data_dir).expanduser().resolve()
-    data_dir.mkdir(parents=True, exist_ok=True)
-    ttl_seconds = ttl_hours * 3600.0
-    max_upload_bytes = int(max_upload_mb * 1024 * 1024)
-
-    templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
-    templates.env.filters["dur"] = human_dur
-
-    @asynccontextmanager
-    async def lifespan(app: FastAPI):
-        task = None
-        if start_sweeper:
-
-            async def loop():
-                while True:
-                    try:
-                        wiped = sweep_once(data_dir, ttl_seconds)
-                        if wiped:
-                            print(f"[booth] swept {len(wiped)} expired: {', '.join(wiped)}", flush=True)
-                    except Exception as exc:  # never let the sweeper die
-                        print(f"[booth] sweep error: {exc}", flush=True)
-                    await asyncio.sleep(sweep_interval_s)
-
-            task = asyncio.create_task(loop())
-        try:
-            yield
-        finally:
-            if task is not None:
-                task.cancel()
-
-    app = FastAPI(title="The Booth", lifespan=lifespan)
-
-    ttl_display = int(ttl_hours) if float(ttl_hours).is_integer() else ttl_hours
-    base_ctx = {
-        "ttl_hours": ttl_display,
-        "host": host_label,
-        "data_dir": str(data_dir),
-        "keep_marker": KEEP_MARKER,  # shown in the kept lane so the mechanism is discoverable
-    }
-
-    def resolve_booth(name: str) -> Path:
-        if not name or name.startswith(".") or "/" in name or "\\" in name or ".." in name:
-            raise HTTPException(status_code=404, detail="no such booth")
-        candidate = data_dir / name
-        try:
-            resolved = candidate.resolve()
-        except OSError:
-            raise HTTPException(status_code=404, detail="no such booth")
-        # resolved.parent must be the data dir itself — blocks symlink escape + nesting.
-        if resolved.parent != data_dir or not resolved.is_dir():
-            raise HTTPException(status_code=404, detail="no such booth")
-        return resolved
-
-    @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.
-        everything = list_booths(data_dir, ttl_seconds)
-        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"]],
-            },
-        )
-
-    @app.get("/healthz")
-    def healthz():
-        return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
-
-    @app.get("/b/{name}", include_in_schema=False)
-    def booth_redirect(name: str):
-        resolve_booth(name)
-        return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=307)
-
-    @app.get("/b/{name}/", response_class=HTMLResponse)
-    def booth_view(request: Request, name: str, download: int = 0):
-        booth = resolve_booth(name)
-        if download:
-            # whole-booth zip — the download path for a verbatim index.html booth
-            # (which has no gallery/per-file chrome), and a "download all" for any.
-            return Response(
-                content=zip_booth(booth),
-                media_type="application/zip",
-                headers={"Content-Disposition": f'attachment; filename="{_zip_filename(name)}"'},
-            )
-        own_index = booth / "index.html"
-        if own_index.is_file():
-            # Serve the operator's verbatim report, but inject a floating
-            # back-to-booths chip + the Booth favicon (if it declares none) so a
-            # raw page still has a way home. Small HTML -> read + wrap in memory;
-            # a pathological large file falls back to serving raw, unwrapped.
-            try:
-                if own_index.stat().st_size <= WRAP_MAX_BYTES:
-                    raw = own_index.read_text(encoding="utf-8", errors="replace")
-                    # Asks render INLINE, where the report author put them (or
-                    # appended, if they marked nothing) — a question about an
-                    # artifact belongs beside that artifact, not on another page.
-                    body, tail = inject_asks(name, booth, raw)
-                    return HTMLResponse(wrap_verbatim_html(body, extra=tail))
-            except OSError:
-                pass
-            return FileResponse(str(own_index), media_type="text/html")
-        return templates.TemplateResponse(
-            request,
-            "booth.html",
-            {
-                **base_ctx,
-                "name": name,
-                "name_url": quote(name, safe=""),
-                # The page could not previously tell keep from release, so it
-                # offered neither and you had to go back to the index.
-                "kept": is_kept(booth),
-                # links.md is rendered AS the board below, so it must not also
-                # appear as a markdown doc tile — that would show the same
-                # content twice, once interactive and once not.
-                "items": [
-                    it for it in build_gallery(booth)
-                    if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
-                ],
-                # A booth carrying links.md is the standing link board: render
-                # its rows as real UI (link, provenance, pin, per-row + bulk
-                # remove) instead of a markdown blob you can only edit by hand.
-                # Ordered pinned-first then newest-first, each row stamped with a
-                # `pinned` flag. Empty list for every other booth, so the template
-                # branch simply does not fire.
-                "board": (
-                    order_for_display(
-                        parse_link_entries((booth / LINKS_FILE).read_text()),
-                        read_pins(booth),
-                    )
-                    if (booth / LINKS_FILE).is_file() else []
-                ),
-                # Asks: multiple-choice questions a session left for the
-                # operator, rendered as forms above the gallery (open ones)
-                # or as their recorded answer. See booth/asks.py.
-                "asks": list_asks(booth),
-                "uploaded": (booth / UPLOAD_MARKER).exists(),
-                "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
-            },
-        )
-
-    @app.post("/b/{name}/answer")
-    async def booth_answer(request: Request, name: str):
-        """Record the operator's answer to one ask: validates every choice
-        against the ask and writes `<stem>.answer.json` atomically.
-        Re-submitting overwrites — the sidecar is the current answer.
-
-        Form fields: `ask` (stem); single-question → `choice` + `notes`;
-        multi-question → `choice.<key>` per question, optional `notes.<key>`,
-        plus the form-level `notes`. 404 for an unknown/invalid stem, 400 for
-        a missing choice or one the ask does not offer.
-        """
-        booth = resolve_booth(name)
-        form = await request.form()
-        ask = form.get("ask")
-        if not isinstance(ask, str) or not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file():
-            raise HTTPException(status_code=404, detail="no such ask")
-        who = request.client.host if request.client else ""
-        try:
-            spec = load_ask(booth, ask)
-            if spec["multi"]:
-                choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec["questions"]}
-                qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec["questions"]}
-                write_answer(booth, ask, choice, form.get("notes", ""), who=who, qnotes=qnotes)
-            else:
-                write_answer(booth, ask, form.get("choice"), form.get("notes", ""), who=who)
-        except AskError as exc:
-            raise HTTPException(status_code=400, detail=str(exc))
-        # Land where the form was: the standalone /asks page for a verbatim booth
-        # (its own index.html cannot show the recorded answer), else the booth.
-        base = f"/b/{quote(name, safe='')}/"
-        if form.get("back") == "asks":
-            base = f"/b/{quote(name, safe='')}/asks"
-        return RedirectResponse(url=f"{base}#ask-{quote(ask, safe='')}", status_code=303)
-
-    _frag = templates.env.get_template("_ask_inline.html").module
-
-    def inject_asks(name: str, booth: Path, html: str) -> tuple[str, str]:
-        """(body, tail) for a verbatim booth: placeholders substituted in place,
-        and whatever still has to be appended before </body>.
-
-        Marked-up pages get each fragment exactly where the author put it. An
-        unmarked page gets the whole ask appended — an ask is NEVER invisible,
-        which is the guarantee; markup only moves it somewhere better. A stem
-        whose questions were placed but whose submit block was not gets that
-        block appended, so a scattered form is always submittable.
-        """
-        asks = list_asks(booth)
-        if not asks:
-            return html, ""
-        url = quote(name, safe="")
-
-        seen: set[str] = set()
-
-        def render(kind: str, ask: dict, key: str | None) -> str:
-            fid = ask_form_id(ask["stem"])
-            if kind == "whole":
-                frag = str(_frag.whole(ask, fid, url))
-            elif kind == "submit":
-                frag = str(_frag.submit(ask, fid, url))
-            else:
-                q = next(q for q in ask["questions"] if q.get("key") == key)
-                frag = str(_frag.question(ask, q, fid, url))
-            # An anchor on the FIRST fragment of each stem, wherever it landed,
-            # so the floating chip can jump to it on a long report. Computed
-            # here rather than in the macros because only the caller knows
-            # which fragment came first.
-            if ask["stem"] not in seen:
-                seen.add(ask["stem"])
-                frag = f'<a id="bk-ask-{ask["stem"]}-top"></a>' + frag
-            return frag
-
-        tail = [str(_frag.styles())]
-        if has_placeholders(html):
-            html, placed, submitted = place_asks(html, asks, render)
-            for a in asks:
-                keys = placed.get(a["stem"])
-                if keys is None:
-                    tail.append(render("whole", a, None))       # unmarked: never dropped
-                    continue
-                if a["error"]:
-                    continue
-                if None not in keys:
-                    # Partially marked up: append every question the author did
-                    # NOT place. A multi-question ask needs all of them or the
-                    # POST is a 400 — met only after the operator fills it in.
-                    for q in a["questions"]:
-                        if q.get("key") not in keys:
-                            tail.append(render("question", a, q.get("key")))
-                if a["stem"] not in submitted:
-                    tail.append(render("submit", a, None))      # scattered but submittable
-        else:
-            for a in asks:
-                tail.append(render("whole", a, None))
-
-        # The chip is now a JUMP LINK to the inline block, not a way out to a
-        # separate page: on a long report the question can be well below the
-        # fold, and "there is a question waiting" still has to be visible at
-        # first paint.
-        first_open = next((a for a in asks if a["answer"] is None and not a["error"]), None)
-        open_n = sum(1 for a in asks if a["answer"] is None and not a["error"])
-        if first_open is not None:
-            tail.append(asks_chip(name, open_n, href=f'#bk-ask-{first_open["stem"]}-top'))
-        return html, "".join(tail)
-
-    @app.get("/b/{name}/asks", response_class=HTMLResponse)
-    def booth_asks_page(request: Request, name: str):
-        """The asks panel on its own page. Reachable from any booth, and the ONLY
-        place a verbatim-index.html booth can show its asks — that page is served
-        untouched by design, so the inline panel never renders there."""
-        booth = resolve_booth(name)
-        return templates.TemplateResponse(
-            request,
-            "asks.html",
-            {**base_ctx, "name": name, "name_url": quote(name, safe=""),
-             "asks": list_asks(booth), "asks_page": True},
-        )
-
-    @app.get("/b/{name}/view", response_class=HTMLResponse)
-    def booth_view_file(request: Request, name: str, f: str):
-        booth = resolve_booth(name)
-        try:
-            target = (booth / f).resolve()
-        except OSError:
-            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")
-        file_url = quote(f, safe="/")
-        common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
-        if classify(target.name) == "image":
-            # prev/next image nav (wraps around; only when >1 image in the booth)
-            names = booth_image_names(booth)
-            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="/")
-            return templates.TemplateResponse(
-                request, "view.html", {**common, "prev_url": prev_url, "next_url": next_url}
-            )
-        # .md renders, .txt/.log show as text — viewable in-booth, no download
-        dk = doc_kind(target.name)
-        if dk:
-            try:
-                if target.stat().st_size <= DOC_MAX_BYTES:
-                    body, is_html = render_doc(target.read_text(encoding="utf-8", errors="replace"), dk)
-                    return templates.TemplateResponse(
-                        request, "doc.html", {**common, "kind": dk, "body": body, "is_html": is_html}
-                    )
-            except OSError:
-                raise HTTPException(status_code=404, detail="no such file")
-        # nothing to render — hand back the raw file
-        return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
-
-    @app.get("/b/{name}/{filepath:path}")
-    def booth_file(name: str, filepath: str, dl: int = 0):
-        booth = resolve_booth(name)
-        try:
-            target = (booth / filepath).resolve()
-        except OSError:
-            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")
-        # ?dl=1 forces a download (Content-Disposition: attachment) instead of the
-        # browser rendering inline — the fix for html/md/text that otherwise opens
-        # in-page with no easy "save".
-        if dl:
-            return FileResponse(str(target), filename=target.name)
-        return FileResponse(str(target))
-
-    @app.post("/upload")
-    async def upload(files: list[UploadFile] = File(...)):
-        """Browser/curl drop-off: files land in a new booth with a human-readable
-        pickup id (e.g. 4-wombat), sweep-expiring in the usual TTL. Redirects (303)
-        to the pickup page; curl clients read the Location header for the id."""
-        files = [f for f in files if f and f.filename]
-        if not files:
-            raise HTTPException(status_code=400, detail="no files uploaded")
-        if len(files) > max_files:
-            raise HTTPException(status_code=413, detail=f"too many files (max {max_files})")
-
-        booth_id = generate_pickup_id(lambda n: (data_dir / n).exists())
-        dest = data_dir / booth_id
-        dest.mkdir(parents=True)
-        (dest / UPLOAD_MARKER).write_text("")  # stamp as an upload (dotfile, not listed)
-
-        total = 0
-        used: set = {UPLOAD_MARKER}
-        try:
-            for i, f in enumerate(files):
-                name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used)
-                used.add(name)
-                with (dest / name).open("wb") as out:
-                    while chunk := await f.read(1024 * 1024):
-                        total += len(chunk)
-                        if total > max_upload_bytes:
-                            raise HTTPException(
-                                status_code=413,
-                                detail=f"upload too large (max {max_upload_mb:g} MB)",
-                            )
-                        out.write(chunk)
-                await f.close()
-        except Exception:
-            shutil.rmtree(dest, ignore_errors=True)  # never leave a half-written booth
-            raise
-
-        return RedirectResponse(url=f"/b/{quote(booth_id, safe='')}/", status_code=303)
-
-    # Releasing a kept board. The kept lane has no wipe control on purpose —
-    # destroying a durable board should not be one misclick — but "deliberate"
-    # had been built as "impossible from the UI": the only ways out were ssh or
-    # a hand-written API call. These two routes make the release step reachable
-    # while keeping deletion two deliberate acts (release, then wipe).
-    #
-    # NOTE ON THE TTL, which is not intuitive: removing the sentinel BUMPS the
-    # booth directory's mtime, and booth_age_seconds reads the newest mtime in
-    # the tree — so a released board's clock resets to zero and it survives
-    # another full TTL. "Unkeep and let the sweeper take it" therefore does NOT
-    # delete promptly. Release is the step that makes the × available; the ×
-    # is what deletes. Anything relying on release-then-sweep is relying on a
-    # 24h delay it probably did not intend.
-
-    @app.post("/b/{name}/unlink")
-    def board_unlink(name: str, entry: str = Form(...)):
-        """Remove ONE row from a link board, by content id.
-
-        Deliberately not by index: the board is append-only and multi-writer,
-        so between rendering the page and clicking × another session may have
-        posted. A content id either matches the row the operator saw or matches
-        nothing — it can never resolve to a neighbour.
-        """
-        removed = remove_link_entry(resolve_booth(name), entry)
-        if removed is None:
-            # Already gone (double-click, stale tab, someone else pruned it).
-            # Not an error worth a 404 page — the desired end state holds.
-            pass
-        return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
-
-    @app.post("/b/{name}/unlink-many")
-    def board_unlink_many(name: str, sel: list[str] = Form(default=[])):
-        """Remove SEVERAL rows in one go — the multi-select delete.
-
-        Each `sel` is a content id (same identity the per-row × uses), so the same
-        race-safety holds: an id either matches the row the operator selected or
-        matches nothing, never a neighbour that another session appended in the
-        meantime. An empty selection is a no-op, not an error.
-        """
-        board = resolve_booth(name)
-        for entry_id in sel:
-            remove_link_entry(board, entry_id)
-        return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
-
-    @app.post("/b/{name}/pin")
-    def board_pin(name: str, entry: str = Form(...)):
-        """Toggle a row's pinned (favorite) state, by content id. Pinned rows
-        float to the top of the board; toggling again unpins. Reversible, so no
-        confirmation — unlike removal."""
-        toggle_pin(resolve_booth(name), entry)
-        return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
-
-    def _safe_next(nxt: str) -> str:
-        """Where to land after keep/unkeep. Defaults to the index; a booth page
-        can ask to stay put. Only same-site absolute paths are honoured — `//`
-        and any scheme are refused, because a redirect target taken from a form
-        field is an open redirect if you do not check it."""
-        if nxt.startswith("/") and not nxt.startswith("//") and "\\" not in nxt:
-            return nxt
-        return "/"
-
-    @app.post("/b/{name}/keep")
-    def booth_keep(name: str, next: str = Form("/")):
-        (resolve_booth(name) / KEEP_MARKER).touch()
-        return RedirectResponse(url=_safe_next(next), status_code=303)
-
-    @app.post("/b/{name}/unkeep")
-    def booth_unkeep(name: str, next: str = Form("/")):
-        # missing_ok: releasing an already-released board is a no-op, not a 500.
-        (resolve_booth(name) / KEEP_MARKER).unlink(missing_ok=True)
-        return RedirectResponse(url=_safe_next(next), status_code=303)
-
-    @app.post("/b/{name}/blur")
-    def booth_blur(name: str, f: str = Form(...), on: str = Form("1")):
-        """Toggle one item's blur. Reversible and cosmetic, so no confirmation.
-        See BLUR_FILE: this hides an item from a glance, it does not protect it."""
-        booth = resolve_booth(name)
-        # Guard the path the same way the file route must: a blur entry is only
-        # ever a booth-relative path, never an escape.
-        rel = f.strip().lstrip("/")
-        if ".." in Path(rel).parts:
-            raise HTTPException(status_code=400, detail="bad item path")
-        set_blurred(booth, rel, on not in ("0", "false", ""))
-        return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
-
-    @app.post("/b/{name}/delete")
-    def booth_delete_form(name: str):
-        shutil.rmtree(resolve_booth(name))
-        return RedirectResponse(url="/", status_code=303)
-
-    @app.delete("/b/{name}")
-    def booth_delete_api(name: str):
-        shutil.rmtree(resolve_booth(name))
-        return JSONResponse({"wiped": name})
-
-    return app
-
-
-def _from_env() -> FastAPI:
-    data = os.environ.get("BOOTH_DATA_DIR", str(Path.home() / "booth-data"))
-    ttl = float(os.environ.get("BOOTH_TTL_HOURS", "24"))
-    host = os.environ.get("BOOTH_HOST_LABEL", "")
-    interval = int(float(os.environ.get("BOOTH_SWEEP_INTERVAL_MIN", "15")) * 60)
-    max_mb = float(os.environ.get("BOOTH_MAX_UPLOAD_MB", "1024"))
-    max_n = int(os.environ.get("BOOTH_MAX_FILES", "50"))
-    return create_app(
-        data,
-        ttl_hours=ttl,
-        host_label=host,
-        sweep_interval_s=interval,
-        max_upload_mb=max_mb,
-        max_files=max_n,
-    )
-
-
-app = _from_env()
diff --git a/services/booth/booth/asks.py b/services/booth/booth/asks.py
deleted file mode 100644
index 3b631b5..0000000
--- a/services/booth/booth/asks.py
+++ /dev/null
@@ -1,385 +0,0 @@
-"""Asks: a session poses a multiple-choice question in a booth; the operator
-answers it in the browser; the answer lands as a sidecar the session reads.
-
-STDLIB ONLY, like links.py, so the `booth` CLI can write an ask and read an
-answer without the service's venv.
-
-Filesystem is the state, same as everything else in the Booth:
-
-    <booth>/<stem>.ask.json      the question   (written by a session)
-    <booth>/<stem>.answer.json   the answer     (written by the web UI)
-
-Ask schema (what a session writes):
-
-    {"prompt": "Which render wins?",
-     "options": ["A — baseline", "B — cudaMallocAsync"],      # ≥ 2, strings or
-     #           [{"id": "a", "label": "A — baseline", "detail": "…"}, …]
-     "notes": true,                # optional, default true: show a free-text field
-     "notes_label": "why?"}        # optional placeholder for that field
-
-Answer schema (what the operator's submit writes, atomically):
-
-    {"stem": "winner", "prompt": "…",
-     "choice": "b",                # the option id (== label for string options)
-     "choice_index": 1,            # 0-based position in `options`
-     "label": "B — cudaMallocAsync",
-     "notes": "less banding on the gradient",
-     "answered_at": "2026-09-09T07:12:03-07:00",
-     "answered_by": "10.100.10.20"}
-
-Multi-question form (one submit, one sidecar):
-
-    {"title": "R18 batch review",
-     "questions": [{"key": "q1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": true},
-                   {"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]}],
-     "notes": true}
-    -> {"stem", "title", "answers": {"q1": {"prompt", "choice", "choice_index", "label", "notes"}, …},
-        "unanswered": ["q2"], "complete": false, "notes", "answered_at", "answered_by"}
-
-A question left blank is legal: it lands in `unanswered` and is absent from
-`answers` (unless it carried a note). `complete` is false until every question
-has a pick. Only a submission with no pick AND no notes anywhere is refused.
-
-Re-answering overwrites: the sidecar is the current answer, not a log. A
-session that wants history keeps its own.
-"""
-
-from __future__ import annotations
-
-import json
-import os
-import re
-from datetime import datetime
-from pathlib import Path
-
-ASK_SUFFIX = ".ask.json"
-ANSWER_SUFFIX = ".answer.json"
-
-PROMPT_MAX = 2000
-LABEL_MAX = 400
-DETAIL_MAX = 1000
-NOTES_MAX = 8000
-MAX_OPTIONS = 40
-MAX_QUESTIONS = 30
-
-_STEM_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$")
-_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$")
-
-
-class AskError(ValueError):
-    """An ask file that cannot be rendered — reported, never a crash."""
-
-
-def is_ask_file(name: str) -> bool:
-    return name.endswith(ASK_SUFFIX) and len(name) > len(ASK_SUFFIX)
-
-
-def is_answer_file(name: str) -> bool:
-    return name.endswith(ANSWER_SUFFIX) and len(name) > len(ANSWER_SUFFIX)
-
-
-def ask_stem(name: str) -> str:
-    return name[: -len(ASK_SUFFIX)]
-
-
-def valid_stem(stem: str) -> bool:
-    return bool(_STEM_RE.match(stem)) and ".." not in stem
-
-
-def _normalize_options(opts_in, where: str) -> list[dict]:
-    if not isinstance(opts_in, list) or len(opts_in) < 2:
-        raise AskError(f"{where} needs a list `options` with at least 2 entries")
-    if len(opts_in) > MAX_OPTIONS:
-        raise AskError(f"{where}: too many options (max {MAX_OPTIONS})")
-    options: list[dict] = []
-    seen: set[str] = set()
-    for i, o in enumerate(opts_in):
-        if isinstance(o, str):
-            oid, label, detail = o, o, ""
-        elif isinstance(o, dict):
-            label = o.get("label")
-            if not isinstance(label, str) or not label.strip():
-                raise AskError(f"{where} option {i} needs a non-empty string `label`")
-            oid = o.get("id", label)
-            detail = o.get("detail", "") or ""
-            if not isinstance(oid, str) or not oid.strip():
-                raise AskError(f"{where} option {i} has a bad `id`")
-            if not isinstance(detail, str):
-                raise AskError(f"{where} option {i} has a non-string `detail`")
-        else:
-            raise AskError(f"{where} option {i} must be a string or an object")
-        oid = oid.strip()
-        if oid in seen:
-            raise AskError(f"{where}: duplicate option id {oid!r}")
-        seen.add(oid)
-        options.append({"id": oid, "label": label.strip()[:LABEL_MAX], "detail": detail.strip()[:DETAIL_MAX]})
-    return options
-
-
-def _bool(raw: dict, key: str, default: bool, where: str) -> bool:
-    v = raw.get(key, default)
-    if not isinstance(v, bool):
-        raise AskError(f"{where}: `{key}` must be true/false")
-    return v
-
-
-def normalize_ask(raw: dict, stem: str) -> dict:
-    """Validate + normalise an ask document. Raises AskError on anything the
-    renderer could not honour.
-
-    Two shapes are accepted and both come back as `questions: [...]`:
-
-      single   {"prompt", "options", "notes"?, "notes_label"?}
-               -> one question, key None, `multi` False. Its answer keeps the
-                  flat {choice, choice_index, label, notes} shape.
-      multi    {"title"?, "questions": [{"key", "prompt", "options", "notes"?}, ...],
-                "notes"?, "notes_label"?}
-               -> one FORM, one submit, every question required; the answer is
-                  {answers: {key: {...}}, notes}. Per-question `notes` (default
-                  false) adds a small text field under that question; the
-                  form-level `notes` (default true) is one field for the whole ask.
-    """
-    if not isinstance(raw, dict):
-        raise AskError("ask must be a JSON object")
-    notes = _bool(raw, "notes", True, "ask")
-    notes_label = raw.get("notes_label", "notes")
-    if not isinstance(notes_label, str):
-        raise AskError("`notes_label` must be a string")
-    notes_label = notes_label.strip()[:80] or "notes"
-
-    if "questions" in raw:
-        if "prompt" in raw or "options" in raw:
-            raise AskError("an ask has EITHER `prompt`+`options` OR `questions`, not both")
-        qs_in = raw.get("questions")
-        if not isinstance(qs_in, list) or not qs_in:
-            raise AskError("`questions` must be a non-empty list")
-        if len(qs_in) > MAX_QUESTIONS:
-            raise AskError(f"too many questions (max {MAX_QUESTIONS})")
-        title = raw.get("title", "")
-        if not isinstance(title, str):
-            raise AskError("`title` must be a string")
-        questions: list[dict] = []
-        keys: set[str] = set()
-        for i, q in enumerate(qs_in):
-            where = f"question {i}"
-            if not isinstance(q, dict):
-                raise AskError(f"{where} must be an object")
-            key = q.get("key")
-            if not isinstance(key, str) or not _KEY_RE.match(key):
-                raise AskError(f"{where} needs a `key` (letters, digits, . _ -)")
-            if key in keys:
-                raise AskError(f"duplicate question key {key!r}")
-            keys.add(key)
-            prompt = q.get("prompt")
-            if not isinstance(prompt, str) or not prompt.strip():
-                raise AskError(f"{where} needs a non-empty string `prompt`")
-            questions.append({
-                "key": key,
-                "prompt": prompt.strip()[:PROMPT_MAX],
-                "options": _normalize_options(q.get("options"), where),
-                "notes": _bool(q, "notes", False, where),
-            })
-        return {
-            "stem": stem,
-            "multi": True,
-            "title": title.strip()[:PROMPT_MAX],
-            "prompt": title.strip()[:PROMPT_MAX] or f"{len(questions)} questions",
-            "questions": questions,
-            "notes": notes,
-            "notes_label": notes_label,
-        }
-
-    prompt = raw.get("prompt")
-    if not isinstance(prompt, str) or not prompt.strip():
-        raise AskError("ask needs a non-empty string `prompt` (or a `questions` list)")
-    options = _normalize_options(raw.get("options"), "ask")
-    # `title` is optional on a single-question ask too — a short label above the
-    # question. It used to be accepted and silently dropped, which is worse than
-    # rejecting it: the session sees no error and the operator sees no title.
-    title = raw.get("title", "")
-    if not isinstance(title, str):
-        raise AskError("`title` must be a string")
-    return {
-        "stem": stem,
-        "multi": False,
-        "title": title.strip()[:PROMPT_MAX],
-        "prompt": prompt.strip()[:PROMPT_MAX],
-        "questions": [{"key": None, "prompt": prompt.strip()[:PROMPT_MAX], "options": options, "notes": False}],
-        "options": options,  # kept for single-question callers
-        "notes": notes,
-        "notes_label": notes_label,
-    }
-
-
-def load_ask(booth: Path, stem: str) -> dict:
-    """Parsed + normalised ask for `stem`. Raises AskError if unreadable/invalid."""
-    path = Path(booth) / f"{stem}{ASK_SUFFIX}"
-    try:
-        raw = json.loads(path.read_text(encoding="utf-8"))
-    except FileNotFoundError:
-        raise AskError("no such ask")
-    except (OSError, ValueError) as exc:
-        raise AskError(f"unreadable ask: {exc}")
-    return normalize_ask(raw, stem)
-
-
-def read_answer(booth: Path, stem: str) -> dict | None:
-    path = Path(booth) / f"{stem}{ANSWER_SUFFIX}"
-    try:
-        data = json.loads(path.read_text(encoding="utf-8"))
-    except FileNotFoundError:
-        return None
-    except (OSError, ValueError):
-        return None
-    return data if isinstance(data, dict) else None
-
-
-def list_asks(booth: Path) -> list[dict]:
-    """Every ask in a booth (top level only), oldest first by file mtime, each
-    with its current answer folded in (`answer` is None while open). An invalid
-    ask file is returned with `error` set so the page can say so instead of
-    silently hiding the question a session thinks it posted."""
-    booth = Path(booth)
-    out: list[dict] = []
-    if not booth.is_dir():
-        return out
-    files = [p for p in booth.iterdir() if p.is_file() and not p.name.startswith(".") and is_ask_file(p.name)]
-    files.sort(key=lambda p: (p.stat().st_mtime, p.name))
-    for p in files:
-        stem = ask_stem(p.name)
-        try:
-            ask = load_ask(booth, stem)
-        except AskError as exc:
-            out.append({"stem": stem, "multi": False, "title": "", "prompt": None, "questions": [],
-                        "options": [], "notes": False, "notes_label": "notes",
-                        "error": str(exc), "answer": None})
-            continue
-        ask["error"] = None
-        ask["answer"] = read_answer(booth, stem)
-        out.append(ask)
-    return out
-
-
-def _pick(options: list[dict], choice, where: str) -> tuple[int, dict]:
-    idx = next((i for i, o in enumerate(options) if o["id"] == choice), None)
-    if idx is None:
-        raise AskError(f"{where}: choice is not one of the options")
-    return idx, options[idx]
-
-
-def _blank(choice) -> bool:
-    """A question the operator left alone. An empty string is what an unchecked
-    radio group posts, and None is what a missing field looks like — both mean
-    'no pick', neither is an error."""
-    return choice is None or (isinstance(choice, str) and not choice.strip())
-
-
-def _clean_notes(text) -> str:
-    return (text or "").replace("\r\n", "\n").strip()[:NOTES_MAX]
-
-
-def write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "",
-                 qnotes: dict | None = None) -> dict:
-    """Record the operator's answer. Validates the choices that were MADE,
-    writes `<stem>.answer.json` via temp-file + os.replace so a reader never
-    sees a half-written document. Returns the answer written.
-
-    PARTIAL ANSWERS ARE LEGAL (operator ruling 2026-09-09). A question left
-    blank is a deliberate outcome — "none of these", "I did not listen to that
-    one yet", "ask me later" — and refusing the whole submission because one of
-    four was skipped threw away the three that were made. So:
-
-      * every question the operator DID answer is recorded and validated;
-      * every one left blank is listed in `unanswered`, absent from `answers`;
-      * `complete` says whether all of them were answered.
-
-    The one thing refused is a submission carrying NOTHING — no choice anywhere
-    and no notes. That would flip an open ask to "answered" while recording no
-    decision, which is strictly worse for the reading session than leaving it
-    open. A choice that is offered but not in the option list is still an error:
-    that is a broken form, not a skipped question.
-
-    `choice` is the option id (str) for a single-question ask, or a
-    {key: option id} dict for a multi-question ask. `qnotes` is {key: text} for
-    per-question notes fields (multi only).
-    """
-    ask = load_ask(booth, stem)  # raises AskError if the ask is gone/invalid
-    stamp = {
-        "answered_at": datetime.now().astimezone().isoformat(timespec="seconds"),
-        "answered_by": who or "",
-    }
-    form_notes = _clean_notes(notes) if ask["notes"] else ""
-    if ask["multi"]:
-        if not isinstance(choice, dict):
-            raise AskError("a multi-question ask needs a {key: choice} mapping")
-        qnotes = qnotes or {}
-        answers: dict[str, dict] = {}
-        unanswered: list[str] = []
-        for q in ask["questions"]:
-            c = choice.get(q["key"])
-            note = _clean_notes(qnotes.get(q["key"])) if q["notes"] else ""
-            if _blank(c):
-                unanswered.append(q["key"])
-                if note:  # a note without a pick is still worth keeping
-                    answers[q["key"]] = {"prompt": q["prompt"], "choice": None,
-                                         "choice_index": None, "label": "", "notes": note}
-                continue
-            idx, opt = _pick(q["options"], c, f"question {q['key']!r}")
-            answers[q["key"]] = {
-                "prompt": q["prompt"],
-                "choice": opt["id"],
-                "choice_index": idx,
-                "label": opt["label"],
-                "notes": note,
-            }
-        picked = [k for k, v in answers.items() if v["choice"] is not None]
-        if not picked and not form_notes and not any(v["notes"] for v in answers.values()):
-            raise AskError("nothing to record — no choice made and no notes")
-        answer = {"stem": stem, "title": ask["title"], "answers": answers,
-                  "unanswered": unanswered, "complete": not unanswered,
-                  "notes": form_notes, **stamp}
-    else:
-        if _blank(choice):
-            if not form_notes:
-                raise AskError("nothing to record — no choice made and no notes")
-            answer = {"stem": stem, "prompt": ask["prompt"], "choice": None,
-                      "choice_index": None, "label": "", "unanswered": [None],
-                      "complete": False, "notes": form_notes, **stamp}
-        else:
-            idx, opt = _pick(ask["options"], choice, "ask")
-            answer = {
-                "stem": stem,
-                "prompt": ask["prompt"],
-                "choice": opt["id"],
-                "choice_index": idx,
-                "label": opt["label"],
-                "unanswered": [],
-                "complete": True,
-                "notes": form_notes,
-                **stamp,
-            }
-    path = Path(booth) / f"{stem}{ANSWER_SUFFIX}"
-    tmp = path.with_suffix(path.suffix + ".tmp")
-    tmp.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
-    os.replace(tmp, path)
-    return answer
-
-
-def write_ask(booth: Path, stem: str, prompt: str | None = None, options: list | None = None,
-              notes: bool = True, notes_label: str = "notes", doc: dict | None = None) -> Path:
-    """Author an ask from code/CLI. Either (prompt, options, ...) for a
-    single-question ask, or `doc=` a full document (single or multi shape).
-    Validated through the same normaliser the renderer uses, so a session
-    cannot post a question the page would reject."""
-    if not valid_stem(stem):
-        raise AskError("bad stem: letters, digits, . _ - only")
-    if doc is None:
-        doc = {"prompt": prompt, "options": options, "notes": notes, "notes_label": notes_label}
-    normalize_ask(doc, stem)
-    booth = Path(booth)
-    booth.mkdir(parents=True, exist_ok=True)
-    path = booth / f"{stem}{ASK_SUFFIX}"
-    tmp = path.with_suffix(path.suffix + ".tmp")
-    tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
-    os.replace(tmp, path)
-    return path
diff --git a/services/booth/booth/inline.py b/services/booth/booth/inline.py
deleted file mode 100644
index 060a074..0000000
--- a/services/booth/booth/inline.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""Inline ask placement inside a booth's VERBATIM index.html.
-
-A booth that ships its own `index.html` is served untouched, so the auto-gallery
-template's asks panel never renders there. The first fix was a chip linking to a
-separate `/asks` page; the operator's verdict on that (2026-09-09) was that the
-question belongs WITH the artifacts it is about — a four-voice audition wants the
-radio group for each voice under that voice's audio, not on another page.
-
-So the report author marks where each piece goes, with a placeholder element:
-
-    <div data-booth-ask="anchors"></div>          the whole ask: every question + submit
-    <div data-booth-ask="anchors:lawson"></div>   just that question's radios
-    <div data-booth-ask-submit="anchors"></div>   the notes field + submit button
-
-Per-question fragments bind to ONE form via the HTML5 `form=` attribute, so four
-groups scattered down a page still submit as a single POST — which is what a
-multi-question ask requires (every question or 400). No JavaScript.
-
-An `<!-- booth:ask anchors -->` comment works the same way, for authors who would
-rather not put an empty div in their markup.
-
-Placement is OPTIONAL. A page with no placeholders gets the whole ask appended at
-the end of its body, so an ask is never invisible — that guarantee is the point,
-and marking it up only moves it somewhere better.
-"""
-
-from __future__ import annotations
-
-import re
-
-# <div data-booth-ask="stem"></div>  /  <span data-booth-ask="stem:key"></span>
-_EL_RE = re.compile(
-    r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask=\"(?P<spec>[^\"]+)\"[^>]*?>"
-    r"(?:\s*</(?P=tag)\s*>)?",
-    re.IGNORECASE,
-)
-_SUBMIT_EL_RE = re.compile(
-    r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask-submit=\"(?P<spec>[^\"]+)\"[^>]*?>"
-    r"(?:\s*</(?P=tag)\s*>)?",
-    re.IGNORECASE,
-)
-# <!-- booth:ask stem --> / <!-- booth:ask stem:key --> / <!-- booth:ask-submit stem -->
-_COMMENT_RE = re.compile(r"<!--\s*booth:ask\s+(?P<spec>[^\s>-][^\s>]*)\s*-->", re.IGNORECASE)
-_COMMENT_SUBMIT_RE = re.compile(r"<!--\s*booth:ask-submit\s+(?P<spec>[^\s>]+)\s*-->", re.IGNORECASE)
-
-
-def split_spec(spec: str) -> tuple[str, str | None]:
-    """`"anchors:lawson"` -> `("anchors", "lawson")`; `"anchors"` -> `("anchors", None)`."""
-    stem, sep, key = spec.strip().partition(":")
-    return stem.strip(), (key.strip() or None) if sep else None
-
-
-def has_placeholders(html: str) -> bool:
-    return bool(
-        _EL_RE.search(html) or _SUBMIT_EL_RE.search(html)
-        or _COMMENT_RE.search(html) or _COMMENT_SUBMIT_RE.search(html)
-    )
-
-
-def form_id(stem: str) -> str:
-    return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
-
-
-def place(html: str, asks: list[dict], render) -> tuple[str, dict[str, set], set[str]]:
-    """Substitute every placeholder with rendered ask HTML.
-
-    `render(kind, ask, key)` returns the fragment for kind in
-    {"whole", "question", "submit"}. Returns the new html; a map of stem ->
-    the set of question keys placed inline (with `None` in the set meaning the
-    WHOLE ask was placed); and the set of stems whose submit block was placed
-    explicitly.
-
-    The caller needs the per-key detail, not just "this stem appeared
-    somewhere": a multi-question ask requires EVERY question on submit, so a
-    page that marks up two of four questions must still be handed the other two
-    or the form is unsubmittable — a 400 the operator would meet only after
-    filling it in.
-
-    A placeholder naming an ask this booth does not have is left ALONE, not
-    blanked: silently eating the author's markup would hide a typo'd stem, and
-    an untouched empty div is invisible anyway.
-    """
-    by_stem = {a["stem"]: a for a in asks}
-    placed: dict[str, set] = {}
-    submitted: set[str] = set()
-
-    def sub_main(m: re.Match) -> str:
-        stem, key = split_spec(m.group("spec"))
-        ask = by_stem.get(stem)
-        if ask is None:
-            return m.group(0)
-        if key is None:
-            placed.setdefault(stem, set()).add(None)
-            submitted.add(stem)
-            return render("whole", ask, None)
-        q = next((q for q in ask.get("questions", []) if q.get("key") == key), None)
-        if q is None:
-            return m.group(0)
-        placed.setdefault(stem, set()).add(key)
-        return render("question", ask, key)
-
-    def sub_submit(m: re.Match) -> str:
-        stem, _ = split_spec(m.group("spec"))
-        ask = by_stem.get(stem)
-        if ask is None:
-            return m.group(0)
-        placed.setdefault(stem, set())
-        submitted.add(stem)
-        return render("submit", ask, None)
-
-    for pat, fn in ((_EL_RE, sub_main), (_COMMENT_RE, sub_main),
-                    (_SUBMIT_EL_RE, sub_submit), (_COMMENT_SUBMIT_RE, sub_submit)):
-        html = pat.sub(fn, html)
-    return html, placed, submitted
diff --git a/services/booth/booth/links.py b/services/booth/booth/links.py
deleted file mode 100644
index 895b7c2..0000000
--- a/services/booth/booth/links.py
+++ /dev/null
@@ -1,196 +0,0 @@
-"""Standing link board: parse and prune the multi-writer link log.
-
-STDLIB ONLY, ON PURPOSE. This lives apart from app.py because the `booth` CLI
-needs it and the CLI must not require the service's venv — importing app.py
-drags in FastAPI, so a shell tool that only wants to delete a line would need
-a web framework installed. The board is a text file; its logic should cost a
-text file's worth of dependencies.
-"""
-
-from __future__ import annotations
-
-import fcntl
-import hashlib
-import os
-import re
-from pathlib import Path
-
-# ---- the standing link board ------------------------------------------------
-#
-# One booth (`links` by convention) is a MULTI-WRITER append log: every agent
-# session on the fleet posts operator-facing URLs to it so they outlive the
-# terminal scrollback that would otherwise bury them. That makes it the one
-# booth where "delete the whole folder" is the wrong granularity — a single
-# dead link has to be removable without taking the other thirty with it.
-#
-# Entries are identified by a CONTENT HASH, never by line number. Indexes are
-# racy here by construction: another session can append between the moment you
-# list the board and the moment you remove a row, and index-based removal would
-# then delete the wrong line. A content id is stable against concurrent
-# appends — the worst case is that the row is already gone, which is reported
-# rather than silently deleting a neighbour.
-LINKS_FILE = "links.md"
-LINK_LOCK = ".links.lock"
-
-# Pin state lives in a sidecar dotfile — one content id per line — NOT inline in
-# links.md. Three reasons this is the right seam:
-#   * links.md stays a pure append log: `booth link` remains a single atomic
-#     O_APPEND write, which is what lets many fleet sessions post concurrently
-#     without a lock on the common path.
-#   * pinning never rewrites a row, so a row's content id (its identity for
-#     removal) never changes just because it was pinned.
-#   * it mirrors the `.forever` sentinel already in play — a dotfile the listing
-#     code skips, so it costs nothing in item counts or galleries.
-# Orphaned ids (a row hand-edited so its id drifts, or removed) are inert: the
-# renderer only marks a row pinned when a live row still carries that id, and
-# remove_link_entry drops the id as it deletes the row.
-PINS_FILE = ".pins"
-
-# - [description](url) <sub>· who · when</sub>
-_LINK_RE = re.compile(
-    r"^- \[(?P<desc>.*?)\]\((?P<url>[^)]*)\)"
-    r"(?:\s*<sub>·\s*(?P<who>[^·]*?)\s*·\s*(?P<when>[^<]*?)\s*</sub>)?\s*$"
-)
-
-
-def link_entry_id(raw: str) -> str:
-    """Stable short id for a board row. Content-addressed, so it survives
-    concurrent appends by other sessions and cannot drift like an index."""
-    return hashlib.sha1(raw.strip().encode()).hexdigest()[:8]
-
-
-def parse_link_entries(text: str) -> list[dict]:
-    """Rows of the standing link board, newest last (posting order).
-
-    Non-matching lines (a heading someone added by hand, a blank) are skipped
-    rather than rejected: the board is a plain markdown file the operator is
-    explicitly allowed to edit, so the parser must tolerate prose around the
-    rows it understands.
-    """
-    out: list[dict] = []
-    for i, raw in enumerate(text.splitlines()):
-        m = _LINK_RE.match(raw.strip())
-        if not m:
-            continue
-        out.append({
-            "id": link_entry_id(raw),
-            "raw": raw,
-            "line": i,
-            "desc": (m.group("desc") or "").strip(),
-            "url": (m.group("url") or "").strip(),
-            "who": (m.group("who") or "").strip(),
-            "when": (m.group("when") or "").strip(),
-        })
-    return out
-
-
-def remove_link_entry(board: Path, entry_id: str) -> dict | None:
-    """Remove one row by content id. Returns the removed entry, or None.
-
-    Held under an exclusive flock on a sidecar lock file for the whole
-    read-modify-write, and the CLI's append path takes the same lock — so a
-    concurrent `booth link` cannot be lost to this rewrite. Written to a temp
-    file and os.replace'd, so a crash mid-write cannot truncate the board.
-    """
-    path = board / LINKS_FILE
-    if not path.exists():
-        return None
-    lock = board / LINK_LOCK
-    lock.touch(exist_ok=True)
-    with lock.open("r+") as lf:
-        fcntl.flock(lf, fcntl.LOCK_EX)
-        try:
-            text = path.read_text()
-            kept, removed = [], None
-            for raw in text.splitlines(keepends=True):
-                if removed is None and link_entry_id(raw) == entry_id:
-                    m = _LINK_RE.match(raw.strip())
-                    if m:
-                        removed = {"id": entry_id, "raw": raw.rstrip("\n"),
-                                   "desc": (m.group("desc") or "").strip(),
-                                   "url": (m.group("url") or "").strip()}
-                        continue
-                kept.append(raw)
-            if removed is None:
-                return None
-            tmp = path.with_suffix(path.suffix + ".tmp")
-            tmp.write_text("".join(kept))
-            os.replace(tmp, path)
-            # The row is gone; drop any pin that referenced it so .pins does not
-            # accumulate dead ids. Same critical section, so a concurrent pin
-            # toggle cannot race this rewrite.
-            pins = _read_pins_unlocked(board)
-            if entry_id in pins:
-                pins.discard(entry_id)
-                _write_pins_unlocked(board, pins)
-            return removed
-        finally:
-            fcntl.flock(lf, fcntl.LOCK_UN)
-
-
-# ---- pins: favorite a row so it floats to the top --------------------------
-
-
-def _read_pins_unlocked(board: Path) -> set[str]:
-    path = board / PINS_FILE
-    if not path.exists():
-        return set()
-    try:
-        return {ln.strip() for ln in path.read_text().splitlines() if ln.strip()}
-    except OSError:
-        return set()
-
-
-def _write_pins_unlocked(board: Path, ids: set[str]) -> None:
-    """Atomic replace of the pins file. Caller must hold the board lock."""
-    path = board / PINS_FILE
-    tmp = path.with_suffix(path.suffix + ".tmp")
-    tmp.write_text("".join(f"{i}\n" for i in sorted(ids)))
-    os.replace(tmp, path)
-
-
-def read_pins(board: Path) -> set[str]:
-    """Pinned entry ids for a board. Missing file → empty set. Lock-free: a set
-    read of a dotfile the sweeper never touches, safe to call on the render path."""
-    return _read_pins_unlocked(Path(board))
-
-
-def toggle_pin(board: Path, entry_id: str) -> bool:
-    """Flip one row's pinned state. Returns the NEW state (True = now pinned).
-
-    Held under the same sidecar flock as append and remove, so a toggle cannot
-    interleave with a board rewrite. Pure add/remove of the id — orphan pruning
-    is the remover's job (remove_link_entry) and the renderer's (a pin with no
-    live row is simply not shown as pinned)."""
-    board = Path(board)
-    lock = board / LINK_LOCK
-    lock.touch(exist_ok=True)
-    with lock.open("r+") as lf:
-        fcntl.flock(lf, fcntl.LOCK_EX)
-        try:
-            pins = _read_pins_unlocked(board)
-            if entry_id in pins:
-                pins.discard(entry_id)
-                new_state = False
-            else:
-                pins.add(entry_id)
-                new_state = True
-            _write_pins_unlocked(board, pins)
-            return new_state
-        finally:
-            fcntl.flock(lf, fcntl.LOCK_UN)
-
-
-def order_for_display(entries: list[dict], pinned: set[str]) -> list[dict]:
-    """Board rows for the web view: pinned first, then newest-first in each group.
-
-    `entries` arrive from parse_link_entries in file order (oldest first). Each
-    returned row is a copy stamped with a `pinned` bool (the input dicts are left
-    untouched, so parse output stays a faithful file-order view for callers that
-    want it — e.g. the CLI). Within both the pinned and the unpinned group the
-    most recently appended row leads, which is what "newest on top" means for an
-    append log.
-    """
-    stamped = [{**e, "pinned": e["id"] in pinned} for e in entries]
-    stamped.reverse()  # newest first
-    return [e for e in stamped if e["pinned"]] + [e for e in stamped if not e["pinned"]]
diff --git a/services/booth/booth/templates/_ask_inline.html b/services/booth/booth/templates/_ask_inline.html
deleted file mode 100644
index f5c406e..0000000
--- a/services/booth/booth/templates/_ask_inline.html
+++ /dev/null
@@ -1,112 +0,0 @@
-{# Self-contained ask fragments injected into a booth's VERBATIM index.html.
-
-   The page is served untouched and carries its own CSS, so nothing here may
-   inherit from base.html: every fragment ships its own scoped `.bk-ask-*`
-   styles (emitted once, by `styles()`), and the palette adapts via
-   prefers-color-scheme rather than borrowing the host page's.
-
-   Per-question fragments are wired to ONE form with the HTML5 `form=`
-   attribute, so a four-voice report can put each radio group under its own
-   audio block and still submit all four picks in a single POST — which is what
-   the multi-question ask requires. The <form> element itself is empty and
-   lives with the submit block. No JavaScript.
-#}
-
-{% macro styles() %}
-<style>
-.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}
-.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-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-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;
-  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}
-.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}
-}
-@media print{.bk-ask{break-inside:avoid}}
-</style>
-{% endmacro %}
-
-{# One question's radio group, bound to the shared form by id. #}
-{% macro question(a, q, form_id, name_url, standalone=False) %}
-{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
-{% set qa = (a.answer.answers.get(q.key) if a.multi else a.answer) if a.answer else None %}
-{% set picked = qa and qa.choice is not none %}
-{% set skipped = a.answer and not picked %}
-<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.stem }}{% if q.key %}-{{ q.key }}{% endif %}">
-  <span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
-  <p class="bk-ask-prompt">{{ q.prompt }}</p>
-  {% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
-  {% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %}
-  <div class="bk-ask-opts">
-  {% for o in q.options %}
-    <label class="bk-ask-opt">
-      <input type="radio" name="{{ field }}" value="{{ o.id }}"
-             {% if not standalone %}form="{{ form_id }}"{% endif %}
-             {% if qa and qa.choice == o.id %}checked{% endif %}>
-      <span class="bk-ask-lab"><span>{{ o.label }}</span>
-        {% if o.detail %}<span class="bk-ask-det">{{ o.detail }}</span>{% endif %}</span>
-    </label>
-  {% endfor %}
-  </div>
-  {% if q.notes %}
-    <textarea class="bk-ask-notes" name="notes.{{ q.key }}" rows="2"
-              {% if not standalone %}form="{{ form_id }}"{% endif %}
-              placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
-  {% endif %}
-</div>
-{% endmacro %}
-
-{# The form element + hidden fields + overall notes + submit. Empty <form> on
-   purpose: the question groups above bind to it by id from wherever they sit. #}
-{% macro submit(a, form_id, name_url) %}
-<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.stem }}-submit">
-  <form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
-  <input type="hidden" name="ask" value="{{ a.stem }}" form="{{ form_id }}">
-  <span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
-    {%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
-    {%- else %}? submit your picks{% endif %}</span>
-  {% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
-  {% if a.notes %}
-    <textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
-              placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
-  {% endif %}
-  <button type="submit" class="bk-ask-go" form="{{ form_id }}">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
-</div>
-{% endmacro %}
-
-{# The whole ask as one self-contained block: title, every question, submit. #}
-{% macro whole(a, form_id, name_url) %}
-{% if a.error %}
-  <div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
-    <p class="bk-ask-err">{{ a.stem }}.ask.json could not be read: {{ a.error }}</p></div>
-{% else %}
-  {% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.stem }}">{{ a.title }}</p>{% endif %}
-  {% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
-  {{ submit(a, form_id, name_url) }}
-{% endif %}
-{% endmacro %}
diff --git a/services/booth/booth/templates/_asks.html b/services/booth/booth/templates/_asks.html
deleted file mode 100644
index 6577767..0000000
--- a/services/booth/booth/templates/_asks.html
+++ /dev/null
@@ -1,81 +0,0 @@
-{# Shared asks panel — included by booth.html (auto-gallery view) and by
-   asks.html (the standalone page a VERBATIM index.html booth links to, since
-   a verbatim page is served as-is and can never render this inline). #}
-  {# ASKS. A session left multiple-choice questions here for the operator
-     (`<stem>.ask.json`). Open ones render as a radio form; answering POSTs to
-     /answer, which writes `<stem>.answer.json` for the session to read. Works
-     with JS off — plain form POST. Answered asks show the recorded answer and a
-     collapsed "change" form, since the sidecar is the CURRENT answer. #}
-  <section class="asks">
-  {% for a in asks %}
-    <article class="ask{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="ask-{{ a.stem }}">
-      <header class="ask-head">
-        <span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %}</span>
-        <span class="ask-stem"><code>{{ a.stem }}.ask.json</code>{% if a.multi %} · {{ a.questions|length }} questions{% endif %}</span>
-        <span class="board-spacer"></span>
-        {% if a.answer and not a.answer.complete %}<span class="ask-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
-        {% if a.answer %}<span class="ask-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
-      </header>
-      {% if a.error %}
-        <p class="ask-error">This ask could not be read: {{ a.error }}</p>
-      {% else %}
-        {% if a.title and not a.multi %}<p class="ask-title">{{ a.title }}</p>{% endif %}
-        <p class="ask-prompt">{{ a.prompt }}</p>
-        {% if a.answer %}
-          <div class="ask-answer">
-            {% if a.multi %}
-              {% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %}
-              <div class="ask-answer-q">
-                <span class="ask-answer-qprompt">{{ q.prompt }}</span>
-                <div class="ask-answer-choice{% if not (qa and qa.choice is not none) %} is-skipped{% endif %}">{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}</div>
-                {% if qa and qa.notes %}<pre class="ask-answer-notes">{{ qa.notes }}</pre>{% endif %}
-              </div>
-              {% endfor %}
-            {% else %}
-              <div class="ask-answer-choice">{{ a.answer.label }}</div>
-            {% endif %}
-            {% if a.answer.notes %}<pre class="ask-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
-            <span class="ask-answer-file">→ <a href="{{ a.stem }}.answer.json">{{ a.stem }}.answer.json</a></span>
-          </div>
-        {% endif %}
-        <details class="ask-formwrap"{% if not a.answer %} open{% endif %}>
-          <summary class="ask-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
-          <form class="ask-form" method="post" action="/b/{{ name_url }}/answer">
-            <input type="hidden" name="ask" value="{{ a.stem }}">
-            {# On the standalone page, come back HERE — the booth's own page is a
-               verbatim report that cannot show the recorded answer. #}
-            {% if asks_page %}<input type="hidden" name="back" value="asks">{% 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 %}
-            <fieldset class="ask-q">
-              {% if a.multi %}<legend class="ask-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
-              <div class="ask-options">
-              {% for o in q.options %}
-                <label class="ask-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
-                  <input type="radio" name="{{ field }}" value="{{ o.id }}"
-                         {% if qa and qa.choice == o.id %}checked{% endif %}>
-                  <span class="ask-opt-main">
-                    <span class="ask-opt-label">{{ o.label }}</span>
-                    {% if o.detail %}<span class="ask-opt-detail">{{ o.detail }}</span>{% endif %}
-                  </span>
-                </label>
-              {% endfor %}
-              </div>
-              {% if q.notes %}
-                <textarea class="ask-notes ask-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
-              {% endif %}
-            </fieldset>
-            {% endfor %}
-            {% if a.notes %}
-              <textarea class="ask-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
-            {% endif %}
-            <div class="ask-actions">
-              <button type="submit" class="ask-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
-            </div>
-          </form>
-        </details>
-      {% endif %}
-    </article>
-  {% endfor %}
-  </section>
diff --git a/services/booth/booth/templates/asks.html b/services/booth/booth/templates/asks.html
deleted file mode 100644
index bc2fc36..0000000
--- a/services/booth/booth/templates/asks.html
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends "base.html" %}
-{% block title %}{{ name }} · asks · The Booth{% endblock %}
-{% block content %}
-{# The asks page for a booth whose own index.html is served VERBATIM. That page
-   cannot render the panel inline (it is returned untouched by design), so the
-   injected chip links here instead. Same forms, same POST target — only the
-   redirect differs, so answering lands back here rather than on the report. #}
-<div class="boothhead">
-  <a class="back" href="/b/{{ name_url }}/">‹ {{ name }}</a>
-  <h1>Asks</h1>
-  {% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}
-  <span class="sub">{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open</span> · {% endif %}{{ asks|length }} ask{{ '' if asks|length == 1 else 's' }}</span>
-</div>
-{% if asks %}
-  {% include "_asks.html" %}
-{% else %}
-  <div class="empty">This booth has no asks.</div>
-{% endif %}
-{% endblock %}
diff --git a/services/booth/booth/templates/base.html b/services/booth/booth/templates/base.html
deleted file mode 100644
index c6bfe5b..0000000
--- a/services/booth/booth/templates/base.html
+++ /dev/null
@@ -1,462 +0,0 @@
-<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{% block title %}The Booth{% endblock %}
-
-
-
-
-
- The Booth - ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't -
-
{% block content %}{% endblock %}
-
- drop a folder into {{ data_dir }}{% if host %} · {{ host }}{% endif %} -
- - diff --git a/services/booth/booth/templates/booth.html b/services/booth/booth/templates/booth.html deleted file mode 100644 index 4958aea..0000000 --- a/services/booth/booth/templates/booth.html +++ /dev/null @@ -1,319 +0,0 @@ -{% extends "base.html" %} -{# The blur toggle, defined ONCE. There are three item branches in this file - (doc / media / other) and the first cut of this feature patched only one of - them, so docs rendered with no control at all. A macro makes "patched two of - three" impossible rather than merely unlikely. #} -{% macro blurtoggle(name_url, it, cls='') -%} -
- - - -
-{%- endmacro %} - -{% block title %}{{ name }} · The Booth{% endblock %} -{% block content %} -
- ‹ all booths -

{{ name }}

- {% 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 %}{% else %}{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}{% if open_asks %}{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }} · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %} - {% if items %}⬇ zip{% endif %} - {# A durable multi-writer board gets no one-click wipe — same rule as the - kept lane on the index. Remove rows with the per-row ×, or release the - board from the index and wipe it from there. #} - {# Promote or release without going back to the index. `next` keeps you on - this page instead of bouncing you to /. #} - {% if kept %} -
- - -
- {% else %} -
- - -
- {% endif %} - {% if not board %} -
- -
- {% endif %} -
- -{% if uploaded %} -
- 📦 Pickup {{ name }} - - — download files below, or on nh3-dev grab ~/booth-data/{{ name }}/ -
-{% endif %} - -{% if asks %} - {% include "_asks.html" %} -{% endif %} - -{% if board %} - {# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so - this is the one booth where the useful granularity is the ROW, not the - folder. Rendered as real UI rather than a markdown blob so a dead link can - be removed without hand-editing the file — and so provenance (who posted - it, when) is readable at a glance, which is the whole reason a bare URL - three days old is useless. - - ORDER: pinned rows first, then newest-first (order_for_display). Pin a row - with the ★ so the ones you care about stop scrolling off the bottom. - - ONE
, not one-per-row: checkboxes drive the bulk delete, while the - per-row × and ★ are submit buttons with their own `formaction`. That keeps - all three actions in a single form (nested forms are invalid HTML) AND lets - every one work with JS off — JS only adds select-all and the live count. - - Every action posts a CONTENT ID, never a row number: another session can - append between this page rendering and a click, and an index would then hit - a neighbour. An id matches the row the operator saw, or nothing. #} - {% set pinned_n = board | selectattr('pinned') | list | length %} - -
- - {{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if pinned_n %} · {{ pinned_n }} pinned{% endif %} - pinned first · newest on top · ★ pins a row · tick rows to delete - - -
- {% for e in board %} -
- - -
- {{ e.desc }} -
{{ e.url }}
-
-
- {% if e.who %}{{ e.who }}{% endif %} - {% if e.when %}{{ e.when }}{% endif %} -
- - -
- {% endfor %} -
-{% endif %} - -{% if not items and not board and not asks %} -
This booth is empty.
-{% elif items %} - {# `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