chore: extract the Booth to its own repo (vh/booth)

The Booth is now one of the most-used fleet tools -- 17 agent handles post to
it daily -- and it is taking an information-architecture rework plus a
cross-agent SVOS design retrofit from design-dev. That work wants its own
ROADMAP, contracts and blast radius, not the fleet-infrastructure repo's.

All 29 commits moved with it via `git subtree split`; the history carries real
lessons (two shipped-dead controls, the verbatim-injection traps) that a
squashed import would have thrown away.

Live service repointed and verified: the user unit and the ~/.local/bin/booth
symlink now resolve into ~/development/booth, healthz answers, all 24 booths
intact. services/booth/ keeps a pointer README, same shape as the
chatterbox-fast and tts-stack extractions.
This commit is contained in:
vh
2026-09-21 21:54:47 -07:00
parent ddc7926cb9
commit 14bd95d76d
23 changed files with 30 additions and 6080 deletions
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
-7
View File
@@ -1,7 +0,0 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
.pytest_cache/
booth-data/
uv.lock
+27 -497
View File
@@ -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
<a python with playwright> 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 <name> <file>... # hide from a glance
booth unblur <name> <file>...
```
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 `<figure>` 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 <name>` / `booth unkeep <name>`.
⚠ 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 <url> [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:
```
<booth>/<stem>.ask.json the question (a session writes it)
<booth>/<stem>.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/<name>/answer` is what the form submits — fields `ask` plus
`choice` / `notes` (single) or `choice.<key>` / `notes.<key>` / `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
<div data-booth-ask="anchors"></div> <!-- the whole ask: every question + submit -->
<div data-booth-ask="anchors:lawson"></div> <!-- just that one question's radios -->
<div data-booth-ask-submit="anchors"></div> <!-- the notes field + submit button -->
<!-- booth:ask anchors:lawson --> <!-- comment form, identical behaviour -->
```
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/<name>/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/<id>/` (download links), or on
nh3-dev straight off disk at `~/booth-data/<id>/`.
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`) → `<img>` (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`) → `<video controls>`
- audio (`mp3 wav ogg flac m4a opus aac`) → `<audio controls>`
- anything else → a download link
- **Captions:** a `<file>.txt` or same-stem `<stem>.txt` sidecar is folded in as
that item's caption — the natural way to label an A/B pair:
```
a.png b.png
a.txt "baseline" b.png.txt "cudaMallocAsync (winner)"
```
## Routes
| Route | Purpose |
|---|---|
| `GET /` | Index — one card per booth (newest first), with expiry countdown |
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
| `GET /b/<name>/<file>` | Serve a file out of the booth |
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
| `GET /b/<name>/asks` | The asks panel on its own page — the only place a verbatim-`index.html` booth can show its asks |
| `POST /b/<name>/answer` | Answer an ask (fields `ask` = stem, `choice`/`choice.<key>`, `notes`/`notes.<key>`, `back`); writes `<stem>.answer.json`, 303 back |
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
| `POST /b/<name>/unlink` | Remove ONE row from a link board (form field `entry` = content id) |
| `POST /b/<name>/unlink-many` | Remove SEVERAL rows — the multi-select delete (repeated form field `sel` = content ids) |
| `POST /b/<name>/pin` | Toggle a row's pinned/favorite state (form field `entry` = content id) |
| `DELETE /b/<name>` | Wipe a booth (curl/API) |
| `GET /healthz` | `{ok, ttl_hours, booths}` — Homepage siteMonitor target |
### The standing link board
A booth containing `links.md` is the fleet's **standing link board**: every
agent session appends operator-facing URLs to it so they outlive the terminal
scrollback that would bury them. It is the one booth where the useful
granularity is the **row**, not the folder — a dead link has to be removable
without taking the other thirty with it.
It renders as real UI, not a markdown blob: each row shows the description,
URL and provenance (who posted it, when), with a copy button and a per-row ×.
**Order: pinned first, then newest on top.** The board is an append log, so the
most recently posted link leads — the one you almost certainly came to grab.
Rows you want to keep in view regardless of churn get the **★** (pin/favorite),
which floats them to a group at the very top; click it again to unpin. The
header shows `N pinned` when any are.
**Multi-select delete.** Tick the checkbox on any set of rows and hit
**🗑 delete** to remove them all in one go (with a count confirmation). The
select-all box in the header toggles the lot. The per-row × is still there for
a single quick removal. Everything — checkboxes, ×, ★, bulk delete — works with
JavaScript off (plain form POSTs via `formaction`); JS only adds select-all and
the live count.
```bash
booth links # row number, entry id, raw row
booth unlink 3 # by row number
booth unlink 8b40e0a5 # by entry id — what the × posts
```
**Rows are addressed by CONTENT ID, never by position.** The board is
append-only and multi-writer: another session can post between the moment you
list it and the moment you remove a row, so an index would delete a neighbour.
An id either matches the row you saw or matches nothing. A row number typed at
the CLI is resolved to its id *before* anything is deleted. The multi-select
delete (`/unlink-many`) carries the same guarantee per selected id.
An id is exactly 8 hex characters, which is how the CLI tells ids from row
numbers — roughly one id in forty is all digits, so "is it numeric" is not a
safe test.
**Pin state lives in a `.pins` sidecar** (one content id per line), never inline
in `links.md`. That keeps `links.md` a pure append log — `booth link` stays a
single atomic write, which is what lets many sessions post concurrently — and
means pinning a row never changes its content id. A pin whose row is later
removed is dropped automatically; a pin orphaned by a hand-edit is inert (the
renderer only stars a row a live id still matches). Pins are a UI action; there
is no `booth pin` CLI yet.
Appends (`booth link`) and prunes (`booth unlink`, `unlink-many`, the ×) take
the same `flock` on `.links.lock`, and pin toggles take it too, so a post cannot
be lost inside a prune's or a toggle's read-modify-write window.
### Deleting a kept board
Kept boards have no × in the UI on purpose — a one-click wipe next to the
durable stuff is a footgun. But *deliberate* must not mean *impossible*, which
is what it meant until 2026-08-23: the only routes out were ssh or a
hand-written API call.
Now it is two deliberate steps. **Release** on the kept card drops the
sentinel and the board moves to the ephemeral lane, where the × already lives;
wipe it from there. Release is reversible — press keep again and nothing was
lost. From the CLI, `booth rm <name>` deletes a kept board immediately and
tells you it was kept.
**Do not "unkeep and let it expire."** Removing the sentinel *bumps the booth
directory's mtime*, and a booth's age is the newest mtime in its tree — so a
released board's clock **resets** and it survives another full TTL.
Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or `booth rm` when
you mean now.
## Ops
Runs as a **user-level** systemd service on nh3-dev (no root, no Docker),
alongside the other fleet sidecars (herald, zellij-web, ttyd).
```bash
systemctl --user status booth.service
systemctl --user restart booth.service
journalctl --user -u booth.service -f # sweeper logs "[booth] swept …"
systemctl --user restart booth.service # "deploy" = edit the checkout + restart
journalctl --user -u booth.service -f
```
Config is env in the unit (`booth.service`):
`BOOTH_DATA_DIR`, `BOOTH_TTL_HOURS`, `BOOTH_HOST_LABEL`, `BOOTH_SWEEP_INTERVAL_MIN`,
`BOOTH_MAX_UPLOAD_MB` (default 1024), `BOOTH_MAX_FILES` (default 50).
The `booth` CLI on `PATH` is a symlink into the new repo:
`~/.local/bin/booth` → `/home/lkraven/development/booth/scripts/booth`.
### Install / update
## Data
```bash
cd services/booth
uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 python-multipart # runtime deps
ln -sfn "$PWD/scripts/booth" ~/.local/bin/booth # the `booth` CLI on PATH (nh3-dev has this)
cp booth.service ~/.config/systemd/user/booth.service
systemctl --user daemon-reload && systemctl --user enable --now booth.service
```
`~/booth-data/` on nh3-dev — unchanged by the extraction, and never tracked by
either repo. One subfolder per booth; the filesystem is the state.
Code runs straight from this checkout (the unit's `WorkingDirectory` /
`ExecStart` point here), so "deploy an update" = edit + `systemctl --user
restart booth.service`.
## What stays here
### Tests
```bash
cd services/booth && uv pip install pytest httpx && .venv/bin/python -m pytest -q
```
## Notes / non-goals
- **No auth.** LAN/WG-internal only, ephemeral content — don't drop secrets in a
booth, and note anyone on the LAN can upload (bounded by the size/file limits).
Uploaded files are served back with their own content-type, so an uploaded
`index.html` renders as a page (a feature for custom reports; keep it in mind).
- Booth names with `/`, `..`, or a leading `.` are rejected; file serving and
uploaded filenames are guarded against path traversal and symlink escape.
Nothing but this pointer. The unit file, code, tests, CLI, and all Booth
documentation moved with the repo.
-21
View File
@@ -1,21 +0,0 @@
[Unit]
Description=The Booth — ephemeral media drop board (scan+serve ~/booth-data, 24h TTL)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/lkraven/development/eshpfi-management/services/booth
ExecStart=/home/lkraven/development/eshpfi-management/services/booth/.venv/bin/uvicorn booth.app:app --host 0.0.0.0 --port 8090
Environment=BOOTH_DATA_DIR=/home/lkraven/booth-data
Environment=BOOTH_TTL_HOURS=24
Environment=BOOTH_HOST_LABEL=nh3-dev 10.100.10.50
Environment=BOOTH_SWEEP_INTERVAL_MIN=15
Restart=on-failure
RestartSec=3
# User-level unit: install to ~/.config/systemd/user/booth.service and
# systemctl --user daemon-reload && systemctl --user enable --now booth.service
# (loginctl enable-linger lkraven — so it survives logout, already set on nh3-dev)
[Install]
WantedBy=default.target
-3
View File
@@ -1,3 +0,0 @@
"""The Booth — ephemeral media drop board. See booth.app for the server."""
__version__ = "0.1.0"
File diff suppressed because it is too large Load Diff
-385
View File
@@ -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
-114
View File
@@ -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
-196
View File
@@ -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"]]
@@ -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 %}
-81
View File
@@ -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>
-19
View File
@@ -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 %}
-462
View File
@@ -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 %}</title>
<link rel="icon" 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">
<style>
/* ============================================================
The Booth — Corviduo "Australis" theme (aurora accents, dark-first).
Token values adopted from ratatoskr-web (colors_and_type.css canonical
palette). Self-contained, no webfont CDN — system fallback stacks.
============================================================ */
:root{
/* ---- Australis raw palette ---- */
--aus-black:#222531; --aus-white:#a9bcc3; --aus-bright-white:#cce7ec; --aus-bright-black:#373b46;
--aus-dark-30:#414751; --aus-dark-40:#565f69; --aus-dark-50:#6e7882; --aus-dark-60:#86929d;
--aus-bright-70:#9daeb6; --aus-bright-80:#b3cbcf;
--aus-blue:#6388d8; --aus-bright-blue:#a4c4ff;
--aus-cyan:#00b1a8; --aus-bright-cyan:#42dcd1;
--aus-green:#16b866; --aus-bright-green:#51e08a;
--aus-red:#ff491a; --aus-bright-red:#ff854f;
--aus-yellow:#e1c631; --aus-bright-yellow:#ffe14e;
--aus-magenta:#9d78ff; --aus-bright-magenta:#d8adff;
/* ---- semantic surface / foreground ---- */
--border-subtle:var(--aus-dark-30); --border-default:var(--aus-dark-40); --border-strong:var(--aus-dark-50);
--fg-0:var(--aus-bright-white); --fg-1:var(--aus-white); --fg-2:var(--aus-bright-70);
--fg-3:var(--aus-dark-60); --fg-muted:var(--aus-dark-50); --fg-on-accent:var(--aus-black);
/* ---- ratatoskr console surfaces ---- */
--rk-deep:#14161d; --rk-canvas:#171a23; --rk-well:#1b1e28; --rk-panel:#1d2029; --rk-ghost:#2c3040;
/* ---- type ---- */
--font-display:"Space Grotesk","Inter",ui-sans-serif,system-ui,sans-serif;
--font-sans:"Inter",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
--font-mono:"Berkeley Mono","JetBrains Mono","IBM Plex Mono",ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;
/* ---- radius / shadow / motion ---- */
--radius-sm:4px; --radius-md:6px; --radius-lg:10px; --radius-pill:9999px;
--shadow-2:0 2px 6px rgba(10,12,18,.45),0 1px 2px rgba(10,12,18,.4);
--shadow-3:0 8px 24px rgba(10,12,18,.5),0 2px 6px rgba(10,12,18,.4);
--glow-cyan:0 0 0 3px rgba(66,220,209,.26);
--ease-out:cubic-bezier(.2,.8,.2,1);
--ease-aurora:cubic-bezier(.65,0,.35,1);
}
@media (prefers-color-scheme: light){
:root{
--rk-deep:#e5edef; --rk-canvas:#eef4f6; --rk-well:#ffffff; --rk-panel:#f6fafb; --rk-ghost:#cad8dd;
--border-subtle:#d3dfe3; --border-default:#bccad0; --border-strong:#9fb0b7;
--fg-0:#10151c; --fg-1:#26313a; --fg-2:#48555f; --fg-3:#64727c; --fg-muted:#8a99a1; --fg-on-accent:#ffffff;
--aus-bright-cyan:#0a938b; --aus-cyan:#0a938b; --aus-bright-green:#12925a; --aus-green:#12925a;
--aus-bright-blue:#3f66bd; --aus-blue:#4a6fc0; --aus-bright-magenta:#7a52d8; --aus-magenta:#7a52d8;
--aus-bright-red:#d63a15; --aus-red:#d63a15; --aus-bright-yellow:#a9820a; --aus-yellow:#b98f0c;
--aus-dark-40:#b0bec4;
--shadow-3:0 8px 24px rgba(30,50,60,.16),0 2px 6px rgba(30,50,60,.1);
--glow-cyan:0 0 0 3px rgba(10,147,139,.2);
}
}
*{box-sizing:border-box}
html,body{margin:0}
body{background:var(--rk-canvas);color:var(--fg-1);font-family:var(--font-sans);
line-height:1.5;-webkit-font-smoothing:antialiased;min-height:100vh;display:flex;flex-direction:column;
transition:background-color .32s var(--ease-aurora),color .32s var(--ease-aurora)}
a{color:var(--aus-bright-cyan);text-decoration:none}
a:hover{color:var(--aus-cyan);text-decoration:underline}
code{font-family:var(--font-mono);font-size:.84em;background:var(--rk-well);color:var(--fg-2);
padding:.12em .42em;border-radius:var(--radius-sm);border:1px solid var(--border-subtle)}
.topbar{display:flex;align-items:center;gap:1.1rem;flex-wrap:wrap;
padding:1rem 1.5rem;border-bottom:1px solid var(--border-subtle);
background:linear-gradient(180deg,var(--rk-panel),transparent)}
.brand{display:inline-flex;align-items:center;gap:.6rem;color:var(--fg-0)}
.brand:hover{text-decoration:none}
.brand .dot{width:.62rem;height:.62rem;border-radius:50%;background:var(--aus-bright-cyan);
box-shadow:0 0 0 4px rgba(66,220,209,.15),0 0 12px rgba(66,220,209,.5);
animation:pulse 2.8s var(--ease-aurora) infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.brand .name{font-family:var(--font-display);font-size:1.22rem;font-weight:600;letter-spacing:-.01em}
.tagline{color:var(--aus-cyan);font-size:.66rem;font-family:var(--font-mono);
letter-spacing:.2em;text-transform:uppercase}
main{flex:1;width:100%;max-width:1240px;margin:0 auto;padding:1.7rem 1.5rem 3rem}
/* ---- upload / pickup ---- */
.uploader{display:flex;gap:.9rem;align-items:stretch;margin-bottom:1.7rem;flex-wrap:wrap}
.drop{flex:1 1 300px;position:relative;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:.15rem;text-align:center;cursor:pointer;padding:1.1rem 1rem;
border:1.5px dashed var(--border-default);border-radius:var(--radius-lg);background:var(--rk-well);
transition:border-color .16s var(--ease-out),background .16s var(--ease-out),box-shadow .16s var(--ease-out)}
.drop:hover{border-color:var(--aus-cyan)}
.drop.over{border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.06);box-shadow:var(--glow-cyan)}
.drop.has{border-style:solid;border-color:var(--aus-cyan)}
.drop input[type=file]{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}
.drop-icon{font-size:1.2rem;color:var(--aus-bright-cyan)}
.drop-main{font-family:var(--font-display);font-weight:600;color:var(--fg-0);font-size:.98rem}
.drop-sub{font-family:var(--font-mono);font-size:.7rem;letter-spacing:.03em;color:var(--fg-3)}
.up-go{flex:0 0 auto;align-self:stretch;cursor:pointer;font-family:var(--font-mono);font-size:.74rem;
letter-spacing:.1em;text-transform:uppercase;padding:0 1.15rem;border-radius:var(--radius-lg);
background:transparent;border:1px solid var(--aus-cyan);color:var(--aus-bright-cyan);
transition:.14s var(--ease-out)}
.up-go:hover{background:var(--aus-bright-cyan);color:var(--fg-on-accent);border-color:var(--aus-bright-cyan)}
.badge{display:inline-block;font-family:var(--font-mono);font-size:.6rem;letter-spacing:.1em;
text-transform:uppercase;font-weight:600;color:var(--fg-on-accent);background:var(--aus-bright-cyan);
padding:.08rem .42rem;border-radius:var(--radius-pill);vertical-align:middle}
.card .thumb{position:relative}
.thumb .badge{position:absolute;top:.5rem;left:.5rem;box-shadow:var(--shadow-2)}
/* Kept lane. The accent is a 2px TOP edge in aurora blue — the one accent
border Australis sanctions (never a coloured left border), and it marks the
card as featured without changing its fill, so kept and ephemeral still
read as the same family of object. */
.lane-head{margin:1.9rem 0 .8rem;font-family:var(--font-mono);font-size:.68rem;font-weight:600;
letter-spacing:.14em;text-transform:uppercase;color:var(--aus-bright-cyan);
display:flex;align-items:center;gap:.7rem}
.lane-head::after{content:"";flex:1;height:1px;background:var(--border-subtle)}
.lane-note{font-weight:400;letter-spacing:.06em;color:var(--fg-3);text-transform:none}
.lane-note code{font-size:.95em;color:var(--fg-2)}
.kept-grid{margin-bottom:.4rem}
.card-kept{border-top:2px solid var(--aus-blue)}
.card-kept:hover{border-color:var(--aus-blue);border-top-color:var(--aus-bright-blue)}
.badge-kept{background:var(--aus-blue);color:var(--fg-on-accent)}
/* Release sits where the ephemeral card's × sits, but reads as a word rather
than a destructive glyph — it is not the delete, it is what unlocks it. */
/* One positioned row holds BOTH kept-card controls. They used to pin
themselves to the same corner independently and the later sibling won. */
.kept-actions{position:absolute;top:.4rem;right:.4rem;display:flex;gap:.3rem;
align-items:center;opacity:0;transition:opacity .12s}
.card-kept:hover .kept-actions,.kept-actions:focus-within{opacity:1}
.kept-actions form{position:static;opacity:1;margin:0}
.release{opacity:0;transition:opacity .12s}
.card-kept:hover .release,.release:focus-within{opacity:1}
.release button{font:inherit;font-size:.72rem;line-height:1;padding:.22rem .45rem;
border-radius:.3rem;cursor:pointer;border:1px solid var(--aus-blue);
background:var(--rk-panel);color:var(--aus-blue)}
.release button:hover{background:var(--aus-blue);color:var(--fg-on-accent)}
/* ---- the standing link board ------------------------------------------
Rows, not a markdown blob. Dense enough that thirty entries stay
scannable, with provenance de-emphasised so the description leads and the
× only surfaces on hover — destructive controls should not compete for
attention with the thing you came to read. */
.board{border:1px solid var(--border-subtle);border-radius:.5rem;overflow:hidden;
background:var(--rk-panel);margin:.6rem 0 1rem}
.board-head{display:flex;align-items:center;gap:.6rem;padding:.45rem .75rem;
border-bottom:1px solid var(--border-subtle);background:var(--rk-well)}
.board-selall{display:inline-flex;align-items:center;cursor:pointer;flex:0 0 auto}
.board-title{font-weight:600;font-size:.85rem}
.board-note{font-size:.72rem;color:var(--fg-3)}
.board-spacer{flex:1 1 auto}
/* Bulk delete: quiet until something is selected. Red-outline danger word,
matching the .wipe-lg language — fills on hover, greys out when disabled. */
.board-del-sel{flex:0 0 auto;cursor:pointer;font-family:var(--font-mono);font-size:.7rem;
letter-spacing:.06em;padding:.28rem .6rem;border-radius:var(--radius-sm);
background:transparent;border:1px solid var(--aus-red);color:var(--aus-bright-red);
transition:.14s var(--ease-out)}
.board-del-sel:hover:not(:disabled){background:var(--aus-red);border-color:var(--aus-red);color:#fff}
.board-del-sel:disabled{opacity:.4;cursor:default;border-color:var(--border-default);color:var(--fg-3)}
.board-row{display:flex;align-items:center;gap:.6rem;padding:.45rem .75rem;
border-bottom:1px solid var(--border-subtle);transition:background .1s}
.board-row:last-child{border-bottom:0}
.board-row:hover{background:var(--rk-well)}
/* Pinned rows: a subtle cyan wash + a filled star. NOT a coloured left border
(Australis forbids it); the tint is enough to group them, and the ★ carries
the state at the row level. */
.board-row.is-pinned{background:rgba(66,220,209,.05)}
.board-row.is-pinned:hover{background:rgba(66,220,209,.09)}
.board-check{flex:0 0 auto;width:1rem;height:1rem;cursor:pointer;accent-color:var(--aus-bright-cyan)}
.board-selall input{width:1rem;height:1rem;cursor:pointer;accent-color:var(--aus-bright-cyan)}
/* ★ favorite toggle. Always visible (unlike ×) because it carries state you
need to see; dim-hollow when off, warm-gold and filled when pinned. */
.board-pin{flex:0 0 auto;border:0;background:none;cursor:pointer;line-height:1;
font-size:1.02rem;padding:.05rem .2rem;border-radius:.25rem;color:var(--fg-3);
transition:color .12s,transform .12s var(--ease-out)}
.board-pin:hover{color:var(--aus-bright-yellow);transform:scale(1.15)}
.board-pin.on{color:var(--aus-bright-yellow)}
.board-main{flex:1 1 auto;min-width:0}
.board-link{font-size:.9rem;text-decoration:none;font-weight:500}
.board-link:hover{text-decoration:underline}
.board-url{font-size:.7rem;color:var(--fg-3);opacity:.8;overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;font-family:var(--font-mono)}
.board-meta{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;
gap:.05rem;font-size:.68rem;color:var(--fg-3);white-space:nowrap}
.board-who{font-weight:600}
.board-copy,.board-rm-btn{opacity:0;transition:opacity .12s;flex:0 0 auto}
.board-row:hover .board-copy,.board-row:hover .board-rm-btn,
.board-copy:focus,.board-rm-btn:focus{opacity:1}
.board-rm-btn{font:inherit;font-size:1rem;line-height:1;padding:.1rem .35rem;
border:0;background:none;cursor:pointer;color:var(--fg-2);border-radius:.25rem}
.board-rm-btn:hover{background:var(--aus-red);color:#fff}
@media (max-width:600px){
/* No hover on touch — controls must be permanently visible or unreachable. */
.board-copy,.board-rm-btn{opacity:1}
.board-meta{display:none}
}
.pickup-note{margin:-.5rem 0 1.5rem;padding:.6rem .85rem;border:1px solid var(--border-subtle);
border-left:3px solid var(--aus-bright-cyan);border-radius:var(--radius-md);background:var(--rk-well);
font-family:var(--font-mono);font-size:.78rem;color:var(--fg-2)}
.copy-btn{cursor:pointer;font-family:var(--font-mono);font-size:.68rem;letter-spacing:.04em;
padding:.12rem .5rem;margin:0 .25rem;border:1px solid var(--border-default);border-radius:var(--radius-sm);
background:transparent;color:var(--aus-bright-cyan);vertical-align:middle;transition:.12s var(--ease-out)}
.copy-btn:hover{border-color:var(--aus-cyan);background:rgba(66,220,209,.08)}
.copy-btn.copied{border-color:var(--aus-green);color:var(--aus-bright-green)}
.dl-link{color:var(--aus-bright-cyan);text-decoration:none;margin-right:.4rem;font-size:.95em}
.dl-link:hover{color:var(--aus-cyan)}
.cap-text{color:var(--fg-2)}
/* ---- image viewer (fixed full-viewport overlay) ---- */
.viewer{position:fixed;inset:0;z-index:50;background:var(--rk-canvas);display:flex;flex-direction:column}
.vbar{display:flex;align-items:center;gap:.7rem;padding:.5rem .8rem;
border-bottom:1px solid var(--border-subtle);background:var(--rk-panel)}
.vname{font-family:var(--font-mono);font-size:.8rem;color:var(--fg-2);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50vw}
.vspacer{flex:1}
.vbtn{display:inline-flex;align-items:center;justify-content:center;min-width:2rem;height:2rem;
padding:0 .55rem;border:1px solid var(--border-default);border-radius:var(--radius-md);
color:var(--fg-1);background:transparent;font-family:var(--font-mono);font-size:.9rem;
transition:.14s var(--ease-out)}
.vbtn:hover{border-color:var(--aus-cyan);color:var(--aus-bright-cyan);text-decoration:none}
.vx:hover{border-color:var(--aus-red);color:#fff;background:var(--aus-red)}
.vtoggle{border:1px solid var(--border-default);border-radius:var(--radius-md);overflow:hidden}
.vseg{cursor:pointer;border:0;background:transparent;color:var(--fg-3);font-family:var(--font-mono);
font-size:.72rem;letter-spacing:.06em;padding:.4rem .75rem;transition:.14s var(--ease-out)}
.vseg+.vseg{border-left:1px solid var(--border-default)}
.vseg:hover{color:var(--fg-1)}
.vseg.on{background:var(--aus-bright-cyan);color:var(--fg-on-accent)}
.vstage{flex:1;min-height:0;background:var(--rk-deep)}
.vstage.fit{display:flex;align-items:center;justify-content:center;overflow:hidden;padding:1rem}
.vstage.fit img{max-width:100%;max-height:100%;width:auto;height:auto;box-shadow:var(--shadow-3)}
.vstage.one{overflow:auto;text-align:center}
.vstage.one img{max-width:none;max-height:none;margin:auto}
.foot{border-top:1px solid var(--border-subtle);color:var(--fg-muted);
font-size:.72rem;font-family:var(--font-mono);letter-spacing:.04em;padding:1rem 1.5rem;text-align:center}
.empty{border:1px dashed var(--border-default);border-radius:var(--radius-lg);
padding:3rem 1.5rem;text-align:center;color:var(--fg-3);background:var(--rk-well);font-family:var(--font-mono);font-size:.92rem}
/* index grid */
.grid{display:grid;gap:1.1rem;grid-template-columns:repeat(auto-fill,minmax(248px,1fr))}
.card{position:relative;background:var(--rk-panel);border:1px solid var(--border-subtle);
border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-2);
transition:transform .16s var(--ease-out),border-color .16s var(--ease-out),box-shadow .16s var(--ease-out)}
.card:hover{transform:translateY(-3px);border-color:var(--aus-cyan);box-shadow:var(--shadow-3),var(--glow-cyan)}
.card .thumb{display:block;aspect-ratio:16/10;background:var(--rk-deep);overflow:hidden}
.card .thumb img{width:100%;height:100%;object-fit:cover;display:block}
.card .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;
color:var(--fg-3);font-family:var(--font-mono);font-size:.82rem;letter-spacing:.16em;text-transform:uppercase}
.card .meta{padding:.7rem .85rem .85rem}
.card .name{display:block;font-family:var(--font-display);font-weight:600;color:var(--fg-0);word-break:break-word}
.card .name:hover{text-decoration:none;color:var(--aus-bright-cyan)}
.card .sub{color:var(--fg-3);font-size:.72rem;font-family:var(--font-mono);letter-spacing:.03em;margin-top:.3rem}
.wipe{position:absolute;top:.5rem;right:.5rem;margin:0}
/* ★ keep, mirroring .wipe on the other shoulder of the card. Same
hover-to-reveal language as .release in the kept lane. */
.keepit{position:absolute;top:.5rem;left:.5rem;margin:0;opacity:0;transition:opacity .12s}
/* × on a KEPT card. Same shoulder as the ephemeral ×, deliberately tinted so
it does not read as the same weight of action. */
/* positioned by .kept-actions, not by itself */
.wipe-kept button{font:inherit;line-height:1;cursor:pointer;border:1px solid var(--line);
border-radius:.3rem;padding:.02rem .3rem;background:var(--bg);color:var(--muted)}
.wipe-kept button:hover{background:var(--aus-red,#ff6b6b);color:var(--fg-on-accent,#fff);
border-color:var(--aus-red,#ff6b6b)}
/* keep / release from inside a booth, beside "Wipe now" */
.keep-lg{display:inline-block;margin:0 .4rem 0 0}
.keep-lg button{font:inherit;font-size:.8rem;line-height:1;padding:.32rem .6rem;
cursor:pointer;border:1px solid var(--line);border-radius:.3rem;
background:var(--bg);color:var(--fg)}
.keep-lg button:hover{background:var(--aus-blue);color:var(--fg-on-accent)}
.card:hover .keepit,.keepit:focus-within{opacity:1}
.keepit button{font:inherit;font-size:.9rem;line-height:1;padding:.1rem .34rem;
cursor:pointer;border:1px solid var(--line);border-radius:.3rem;
background:var(--bg);color:var(--fg)}
.keepit button:hover{background:var(--aus-blue);color:var(--fg-on-accent)}
/* ⚠ Blur is COSMETIC. The file is still served at its own URL and still in
the zip. This hides an item from a glance, nothing more. */
.item.blurred img,.item.blurred video,
.item.blurred .doc-body,.item.blurred .textview{filter:blur(22px);transition:filter .15s}
.item.blurred.revealed img,.item.blurred.revealed video,
.item.blurred.revealed .doc-body,.item.blurred.revealed .textview{filter:none}
.item.blurred{position:relative}
.item.blurred .reveal{position:absolute;top:.5rem;left:.5rem;z-index:2;
font:inherit;font-size:.72rem;line-height:1;padding:.24rem .5rem;cursor:pointer;
border:1px solid var(--line);border-radius:.3rem;background:var(--bg);color:var(--fg)}
.item.blurred .reveal:hover{background:var(--aus-blue);color:var(--fg-on-accent)}
/* Legible on purpose. v1 was a bare muted glyph with no border and no label,
and the operator's reaction to it was "no UI option to blur/unblur?" — a
control nobody can find is a control that is not there. */
.blurtoggle{display:inline-block;margin:0}
.blurtoggle button{font:inherit;font-size:.7rem;line-height:1;padding:.2rem .4rem;
cursor:pointer;border:1px solid var(--line);border-radius:.3rem;
background:var(--bg);color:var(--fg);white-space:nowrap}
.blurtoggle button:hover{background:var(--aus-blue);color:var(--fg-on-accent)}
/* In the doc bar it sits beside ⤢ ⬇ ✕ and should not out-shout them. */
.doc-bar .blurtoggle{margin-left:.35rem}
.doc-bar .blurtoggle button{font-size:.66rem;padding:.14rem .34rem}
/* Cover thumbs on the index inherit the blur so the front page cannot undo it. */
.blurred-thumb{filter:blur(16px)}
/* opaque dark control-scrim + always-light glyph — legible over any thumbnail
AND in both themes (glyph must NOT follow --fg-*, which flips dark on light). */
.wipe button{cursor:pointer;border:1px solid rgba(255,255,255,.16);background:rgba(16,18,25,.86);
color:#e9eef0;width:1.9rem;height:1.9rem;border-radius:var(--radius-md);font-size:1.1rem;line-height:1;
backdrop-filter:blur(6px);transition:.14s var(--ease-out)}
.wipe button:hover{border-color:var(--aus-red);color:#fff;background:var(--aus-red)}
/* Asks — a session's multiple-choice question awaiting the operator.
Amber = "needs you" while open (the one colour the page does not otherwise
use for state), green check once answered; the accent is a TOP edge, per
Australis, never a coloured left border. */
.badge-ask{background:var(--aus-bright-yellow);color:var(--fg-on-accent)}
.thumb .badge+.badge-ask{top:2.2rem}
.asks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem}
.ask{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow);
border-radius:.5rem;background:var(--rk-panel);overflow:hidden}
.ask.is-answered{border-top-color:var(--aus-bright-green)}
/* Partial: answered SOME questions. Not a failure and not done — blanks are a
legal outcome (operator ruling 2026-09-09), so it gets its own state rather
than being forced into one of the other two. */
.ask.is-partial{border-top-color:var(--aus-bright-blue)}
.ask.is-partial .ask-state{color:var(--aus-bright-blue)}
.ask-part{font-family:var(--font-mono);font-size:.68rem;color:var(--aus-bright-blue);font-weight:700}
.ask-answer-choice.is-skipped{opacity:.55;font-style:italic}
.ask-answer-choice.is-skipped::before{content:"— ";color:var(--fg-3)}
.ask.is-broken{border-top-color:var(--aus-bright-red)}
.ask-head{display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem;
border-bottom:1px solid var(--border-subtle);background:var(--rk-well);
font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
.ask-state{font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--aus-bright-yellow)}
.ask.is-answered .ask-state{color:var(--aus-bright-green)}
.ask.is-broken .ask-state{color:var(--aus-bright-red)}
.ask-when{white-space:nowrap}
.ask-title{margin:.8rem .9rem -.35rem;font-family:var(--font-mono);font-size:.7rem;
letter-spacing:.08em;text-transform:uppercase;color:var(--fg-3)}
.ask-prompt{margin:.85rem .9rem .5rem;font-size:1.02rem;font-weight:600;color:var(--fg-0);white-space:pre-wrap}
.ask-error{margin:.8rem .9rem;color:var(--aus-bright-red);font-size:.85rem}
.ask-answer{margin:.2rem .9rem .6rem;padding:.55rem .75rem;border:1px solid var(--border-subtle);
border-radius:var(--radius-md);background:rgba(81,224,138,.06)}
.ask-answer-choice{font-weight:600;color:var(--fg-0)}
.ask-answer-choice::before{content:"✓ ";color:var(--aus-bright-green)}
.ask-answer-notes{margin:.4rem 0 0;white-space:pre-wrap;font-family:var(--font-sans);font-size:.86rem;
color:var(--fg-1)}
.ask-answer-file{display:block;margin-top:.35rem;font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
.ask-answer-file a{color:var(--fg-2)}
.ask-formwrap{margin:0 .9rem .8rem}
.ask-change{cursor:pointer;font-family:var(--font-mono);font-size:.7rem;letter-spacing:.06em;
text-transform:uppercase;color:var(--fg-3);list-style:none;user-select:none}
.ask-change::-webkit-details-marker{display:none}
.ask-formwrap[open]>.ask-change{margin-bottom:.4rem}
.ask-formwrap:not([open])>.ask-change{color:var(--aus-bright-cyan)}
.ask-q{border:0;margin:0 0 .7rem;padding:0;min-width:0}
.ask-q:last-of-type{margin-bottom:0}
.ask-q-prompt{padding:0;margin:0 0 .35rem;font-size:.9rem;font-weight:600;color:var(--fg-0)}
.ask-qnotes{margin-top:.35rem;font-size:.82rem}
.ask-answer-q{padding:.3rem 0;border-bottom:1px dashed var(--border-subtle)}
.ask-answer-q:last-of-type{border-bottom:0}
.ask-answer-qprompt{display:block;font-size:.76rem;color:var(--fg-3)}
.ask-options{display:flex;flex-direction:column;gap:.35rem}
.ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.5rem .65rem;cursor:pointer;
border:1px solid var(--border-subtle);border-radius:var(--radius-md);background:var(--rk-well);
transition:border-color .12s,background .12s}
.ask-opt:hover{border-color:var(--border-strong)}
.ask-opt:has(input:checked){border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.07)}
.ask-opt input{margin:.2rem 0 0;accent-color:var(--aus-bright-cyan);flex:0 0 auto}
.ask-opt-main{display:flex;flex-direction:column;gap:.1rem;min-width:0}
.ask-opt-label{font-size:.92rem;color:var(--fg-0)}
.ask-opt-detail{font-size:.76rem;color:var(--fg-3);white-space:pre-wrap}
.ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
font:inherit;font-size:.88rem;color:var(--fg-0);background:var(--rk-well);
border:1px solid var(--border-subtle);border-radius:var(--radius-md);resize:vertical}
.ask-notes:focus{outline:none;border-color:var(--aus-bright-cyan);box-shadow:var(--glow-cyan)}
.ask-actions{display:flex;justify-content:flex-end;margin-top:.6rem}
.ask-submit{cursor:pointer;font-family:var(--font-mono);font-size:.74rem;letter-spacing:.06em;
padding:.42rem .9rem;border-radius:var(--radius-sm);border:1px solid var(--aus-bright-cyan);
background:var(--aus-bright-cyan);color:var(--fg-on-accent);font-weight:700;transition:.14s var(--ease-out)}
.ask-submit:hover{background:var(--aus-cyan);border-color:var(--aus-cyan)}
/* booth page */
.boothhead{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
padding-bottom:1rem;margin-bottom:1.4rem;border-bottom:1px solid var(--border-subtle)}
.boothhead .back{font-family:var(--font-mono);font-size:.76rem;letter-spacing:.08em;color:var(--fg-3)}
.boothhead h1{margin:0;font-family:var(--font-display);font-weight:600;font-size:1.5rem;
letter-spacing:-.01em;word-break:break-word;flex:1 1 auto;color:var(--fg-0)}
.boothhead .sub{color:var(--fg-3);font-size:.74rem;font-family:var(--font-mono);letter-spacing:.06em}
.wipe-lg{position:static}
/* red-outline danger button — legible on the dark canvas, fills on hover */
.wipe-lg button{width:auto;height:auto;padding:.42rem .85rem;border-radius:var(--radius-md);
font-size:.72rem;font-family:var(--font-mono);letter-spacing:.1em;text-transform:uppercase;
background:transparent;border-color:var(--aus-red);color:var(--aus-bright-red)}
.wipe-lg button:hover{background:var(--aus-red);border-color:var(--aus-red);color:#fff}
.gallery{display:grid;gap:1.4rem;grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}
.item{margin:0;background:var(--rk-panel);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);
overflow:hidden;display:flex;flex-direction:column;box-shadow:var(--shadow-2)}
.item img,.item video{width:100%;height:auto;display:block;background:var(--rk-deep)}
.item audio{width:100%;margin:1.3rem .9rem .4rem;max-width:calc(100% - 1.8rem)}
.item .dl{padding:1.4rem .9rem;font-family:var(--font-mono);font-size:.86rem;word-break:break-all}
.item figcaption{padding:.6rem .85rem .75rem;color:var(--fg-2);font-size:.78rem;
font-family:var(--font-mono);letter-spacing:.02em;border-top:1px solid var(--border-subtle);word-break:break-word}
.item-audio figcaption,.item-other figcaption{border-top:none}
/* Inline doc rendering — a .md/.txt/.log shows in place, collapsible and
closable, instead of a link to a separate page. The item spans the full
grid width so prose has a readable measure. */
.item-doc{grid-column:1 / -1}
.item-doc.is-closed{display:none}
.doc-inline{display:block}
.doc-inline > .doc-bar{list-style:none;cursor:pointer;display:flex;align-items:center;gap:.55rem;
padding:.6rem .85rem;font-family:var(--font-mono);font-size:.8rem;color:var(--fg-2);
background:var(--rk-well);border-bottom:1px solid var(--border-subtle);user-select:none}
.doc-inline > .doc-bar::-webkit-details-marker{display:none}
.doc-chevron{color:var(--fg-3);transition:transform .12s ease;font-size:.7rem}
.doc-inline[open] > .doc-bar .doc-chevron{transform:rotate(90deg)}
.doc-name{color:var(--fg-1);word-break:break-all}
.doc-spacer{flex:1}
.doc-act{color:var(--fg-3);text-decoration:none;padding:.1rem .35rem;border-radius:5px;
font-size:.9rem;line-height:1;background:none;border:0;cursor:pointer;font-family:inherit}
.doc-act:hover{color:var(--aus-bright-cyan,#42dcd1);background:var(--rk-deep)}
.doc-close:hover{color:var(--aus-red,#ff6b6b)}
.doc-body{margin:0;border:0;border-radius:0;max-height:32rem;overflow:auto;padding:1rem 1.15rem}
.doc-body.textview{background:var(--rk-panel)}
/* Shared doc typography — used by the inline body above AND the full-page
doc view (doc.html). Kept here so both surfaces render identically. */
.textview{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);
font-size:.86rem;line-height:1.5;color:var(--fg-1);background:var(--rk-well);
border:1px solid var(--rk-line,#252a35);border-radius:10px;padding:1rem 1.15rem;overflow-x:auto}
.markdown-body{color:var(--fg-1);line-height:1.62;font-size:.98rem;overflow-wrap:break-word}
.markdown-body h1,.markdown-body h2,.markdown-body h3{line-height:1.25;margin:1.6em 0 .5em}
.markdown-body h1{font-size:1.7em}.markdown-body h2{font-size:1.35em}.markdown-body h3{font-size:1.12em}
.markdown-body h1,.markdown-body h2{border-bottom:1px solid var(--rk-line,#252a35);padding-bottom:.3em}
.markdown-body :first-child{margin-top:0}
.markdown-body p,.markdown-body ul,.markdown-body ol,.markdown-body blockquote{margin:.7em 0}
.markdown-body a{color:var(--aus-bright-cyan,#42dcd1)}
.markdown-body code{font-family:var(--font-mono);font-size:.86em;background:var(--rk-well);
padding:.12em .38em;border-radius:5px}
.markdown-body pre{background:var(--rk-well);border:1px solid var(--rk-line,#252a35);
border-radius:10px;padding:.9rem 1.05rem;overflow-x:auto}
.markdown-body pre code{background:none;padding:0}
.markdown-body blockquote{border-left:3px solid var(--aus-bright-cyan,#42dcd1);
padding-left:1em;color:var(--fg-2);margin-left:0}
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto}
.markdown-body th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em}
.markdown-body img{max-width:100%}
</style>
</head>
<body>
<header class="topbar">
<a class="brand" href="/"><span class="dot"></span><span class="name">The&nbsp;Booth</span></a>
<span class="tagline">ephemeral media · auto-wipes {{ ttl_hours }}h · kept boards don't</span>
</header>
<main>{% block content %}{% endblock %}</main>
<footer class="foot">
drop a folder into <code>{{ data_dir }}</code>{% if host %} · {{ host }}{% endif %}
</footer>
</body>
</html>
-319
View File
@@ -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='') -%}
<form class="blurtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/blur">
<input type="hidden" name="f" value="{{ it.name }}">
<input type="hidden" name="on" value="{{ '0' if it.blurred else '1' }}">
<button title="{{ 'un-blur this item' if it.blurred else 'blur this item — cosmetic only, the file is still served' }}"
aria-label="{{ 'un-blur' if it.blurred else 'blur' }} {{ it.name }}"
>{{ '◉ blurred' if it.blurred else '◌ blur' }}</button>
</form>
{%- endmacro %}
{% block title %}{{ name }} · The Booth{% endblock %}
{% block content %}
<div class="boothhead">
<a class="back" href="/">‹ all booths</a>
<h1>{{ name }}</h1>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }}</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% 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 %}
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep">
<input type="hidden" name="next" value="/b/{{ name_url }}/">
<button title="release — rejoins the TTL sweep">★ kept — release</button>
</form>
{% else %}
<form class="keep-lg" method="post" action="/b/{{ name_url }}/keep">
<input type="hidden" name="next" value="/b/{{ name_url }}/">
<button title="keep — exempt from the TTL sweep">☆ keep</button>
</form>
{% endif %}
{% if not board %}
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
onsubmit="return confirm('Wipe this booth now?')">
<button>Wipe now</button>
</form>
{% endif %}
</div>
{% if uploaded %}
<div class="pickup-note">
📦 Pickup <code>{{ name }}</code>
<button type="button" class="copy-btn" data-copy="{{ name }}" title="copy id to clipboard">⧉ copy</button>
— download files below, or on nh3-dev grab <code>~/booth-data/{{ name }}/</code>
</div>
{% 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 <form>, 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 %}
<form class="board" method="post" action="/b/{{ name_url }}/unlink-many" id="boardform">
<div class="board-head">
<label class="board-selall" title="select all"><input type="checkbox" id="board-selall"></label>
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if pinned_n %} · {{ pinned_n }} pinned{% endif %}</span>
<span class="board-note">pinned first · newest on top · ★ pins a row · tick rows to delete</span>
<span class="board-spacer"></span>
<button type="submit" class="board-del-sel" id="board-del-sel"
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
</div>
{% for e in board %}
<div class="board-row{% if e.pinned %} is-pinned{% endif %}">
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
<div class="board-main">
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
<div class="board-url">{{ e.url }}</div>
</div>
<div class="board-meta">
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
</div>
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
name="entry" value="{{ e.id }}" title="remove this link"
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
</div>
{% endfor %}
</form>
{% endif %}
{% if not items and not board and not asks %}
<div class="empty">This booth is empty.</div>
{% 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 <div class="gallery"> under the board. #}
<div class="gallery">
{% for it in items %}
{% if it.doc and it.rendered is not none %}
{# Docs render INLINE, collapsible, and closable — not a link to a
separate page. <details open> is native collapse (works with JS off);
the ✕ hides the item for the session (JS, progressive enhancement).
The item spans the full grid width so prose has room to read. #}
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}">
{% if it.blurred %}
{# Inline docs need this MORE than images, not less: a rendered doc puts
its text straight on the page, so "blur the picture" logic that skips
the doc branch leaves the most readable content unblurred. Missed on
the first pass; caught by a live check, not by the suite. #}
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
{% endif %}
<details class="doc-inline" open>
<summary class="doc-bar">
<span class="doc-chevron" aria-hidden="true">▸</span>
<span class="doc-name">{{ it.name }}</span>
<span class="doc-spacer"></span>
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
{{ blurtoggle(name_url, it, 'doc-act') }}
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
</summary>
{% if it.rendered_html %}
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
{% else %}
<pre class="textview doc-body">{{ it.rendered }}</pre>
{% endif %}
</details>
</figure>
{% else %}
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}" data-item="{{ it.name }}">
{% if it.blurred %}
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
safe direction to fail in. #}
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
{% endif %}
{% if it.kind == 'image' %}
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
{% elif it.kind == 'video' %}
{# preload="none": a booth of a dozen webms was fetching them
all at page load ("metadata" still pulls real ranges per
file); nothing loads until the viewer hits play #}
<video controls preload="none" src="{{ it.url }}"></video>
{% elif it.kind == 'audio' %}
<audio controls preload="none" src="{{ it.url }}"></audio>
{% elif it.doc %}
{# a doc too large to inline (over DOC_MAX_BYTES) still links out #}
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
{% else %}
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
{% endif %}
{% if it.kind == 'other' %}
{# Always render the caption row now: it carries the blur toggle, and
"no caption" is not a reason to deny an item its controls. #}
<figcaption>
{% if it.caption %}<span class="cap-text">{{ it.caption }}</span>{% endif %}
{{ blurtoggle(name_url, it) }}
</figcaption>
{% else %}
<figcaption>
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
<span class="cap-text">{{ it.caption or it.name }}</span>
{{ blurtoggle(name_url, it) }}
</figcaption>
{% endif %}
</figure>
{% endif %}
{% endfor %}
</div>
{% endif %}
<script>
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
only) — so fall back to a hidden-textarea execCommand('copy'). */
(function () {
function copyText(t) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(t);
}
var ta = document.createElement('textarea');
ta.value = t;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '-1000px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
return Promise.resolve();
}
document.querySelectorAll('.copy-btn').forEach(function (btn) {
var label = btn.textContent;
btn.addEventListener('click', function () {
copyText(btn.getAttribute('data-copy')).then(function () {
btn.classList.add('copied');
btn.textContent = '✓ copied';
setTimeout(function () { btn.classList.remove('copied'); btn.textContent = label; }, 1300);
});
});
});
})();
/* Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
inside <summary>, so without this its click would just toggle the <details>
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
"collapse". Collapse stays available via the rest of the summary bar. With
JS off the button is inert and collapse via <details> still works. */
(function () {
/* A form inside <summary> would otherwise collapse the doc on submit. */
document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) {
f.addEventListener('click', function (ev) { ev.stopPropagation(); });
});
document.querySelectorAll('.doc-close').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var item = btn.closest('.item-doc');
if (item) item.classList.add('is-closed');
});
});
})();
/* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row
× / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only
adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
per-row × confirm reads desc/url from data-* attributes rather than being
interpolated into an inline handler, so an arbitrary agent-posted description
(quotes, newlines) can never break out into the page's JS. */
(function () {
var form = document.getElementById('boardform');
if (!form) return;
var boxes = Array.prototype.slice.call(form.querySelectorAll('.board-check'));
var selall = document.getElementById('board-selall');
var delBtn = document.getElementById('board-del-sel');
var countEl = document.getElementById('board-selcount');
function selected() { return boxes.filter(function (b) { return b.checked; }); }
function refresh() {
var n = selected().length;
if (countEl) countEl.textContent = n;
if (delBtn) delBtn.disabled = n === 0;
if (selall) {
selall.checked = n > 0 && n === boxes.length;
selall.indeterminate = n > 0 && n < boxes.length;
}
}
if (selall) {
selall.addEventListener('change', function () {
boxes.forEach(function (b) { b.checked = selall.checked; });
refresh();
});
}
boxes.forEach(function (b) { b.addEventListener('change', refresh); });
// Bulk delete: confirm with the count. Attached to the button (not the form's
// submit) so the per-row × / ★ submits — which share this form — are unaffected.
if (delBtn) {
delBtn.addEventListener('click', function (ev) {
var n = selected().length;
if (n === 0) { ev.preventDefault(); return; }
if (!confirm('Delete ' + n + ' selected link' + (n === 1 ? '' : 's') + '?\n\nThe rest of the board is untouched.')) {
ev.preventDefault();
}
});
}
form.querySelectorAll('.board-rm-btn').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
var d = btn.getAttribute('data-desc') || '';
var u = btn.getAttribute('data-url') || '';
if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) {
ev.preventDefault();
}
});
});
refresh();
})();
/* Blur reveal. WARNING: this handler previously sat after the content
block's closing tag, which in a
child template Jinja DISCARDS — the button rendered and did nothing, and
two commits plus a README claimed click-to-reveal worked. Anything that
must reach the page belongs inside the content block. Verified now by
grepping the SERVED html for this function, not the template for the text.
Per-viewer and never persisted: a reload re-hides. */
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
btn.addEventListener('click', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var fig = btn.closest('.item');
var on = fig.classList.toggle('revealed');
btn.textContent = on ? '🙈 hide' : '👁 reveal';
});
});
</script>
{% endblock %}
-28
View File
@@ -1,28 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block content %}
<div class="docview">
<div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
<span class="vname">{{ file }}</span>
<span class="vspacer"></span>
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}">⬇</a>
</div>
{% if is_html %}
<article class="markdown-body">{{ body|safe }}</article>
{% else %}
<pre class="textview">{{ body }}</pre>
{% endif %}
</div>
<style>
/* .markdown-body and .textview now live in base.html (shared with the inline
gallery view). Only the full-page layout wrapper is page-specific. */
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem}
.docview .textview{overflow-x:auto}
</style>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
});
</script>
{% endblock %}
-161
View File
@@ -1,161 +0,0 @@
{% extends "base.html" %}
{% block content %}
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
<label class="drop" for="booth-files">
<span class="drop-icon">⬆</span>
<span class="drop-main">Upload files for pickup</span>
<span class="drop-sub" id="drop-sub">drop here, or click to choose · one pickup id, wiped in {{ ttl_hours }}h</span>
<input id="booth-files" name="files" type="file" multiple>
</label>
<button class="up-go" type="submit">Get pickup id →</button>
</form>
{% if kept %}
{# Kept boards render FIRST and look different on purpose: they are durable
operator-facing things (the agent link board, standing reports) and the
point of the lane is that they cannot be lost in a feed that turns over
every day. No countdown — they have no expiry to advertise. #}
<h2 class="lane-head">Kept <span class="lane-note">· no expiry · <code>{{ keep_marker }}</code></span></h2>
<div class="grid kept-grid">
{% for b in kept %}
<article class="card card-kept">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
{# A cover blurred inside the booth must be blurred here too, or the
front page undoes the censoring the booth page applied. #}
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
<div class="ph">▶ video</div>
{% elif b.kinds.audio %}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
<span class="badge badge-kept">★ kept</span>
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
</div>
{# There IS a × here now (operator, 2026-09-21). The old rule was
release-then-find-it-in-the-other-lane, on the theory that two
deliberate acts protect durable boards. In practice it protects
nothing and costs a hunt: the board you just released is loose in a
feed that turns over, and you have to go find it to finish the job
you had already decided on.
The protection now lives in the CONFIRMATION, not in the number of
lanes you must traverse — this one names the booth and says the word
KEPT, where the ephemeral × just asks. A deliberate act, one click,
reachable.
Release still exists and is still the reversible option. Note it
BUMPS the directory mtime, so the board's age resets and it survives
another full TTL — unkeep-and-wait is a 24h delay, not a delete,
which is exactly why a direct × was worth adding. #}
{# ⚠ BOTH OF THESE WERE position:absolute ON THE SAME CORNER, and `release`
is the later sibling, so it painted over the × completely: measured
30x22 px of overlap on a 30px button, and elementFromPoint at the ×'s
centre returned the release form. The × was unclickable from the day
it shipped.
One flex row, positioned once, instead of two independently guessed
offsets — so neither control can drift back on top of the other when
a label changes width. #}
<div class="kept-actions">
<form class="release" method="post" action="/b/{{ b.name_url }}/unkeep"
onsubmit="return confirm('Release \u201c{{ b.name }}\u201d?\n\nIt moves to the ephemeral lane so you can wipe it from there. Nothing is deleted by this step.')">
<button title="release this board so it can be wiped">release</button>
</form>
<form class="wipe wipe-kept" method="post" action="/b/{{ b.name_url }}/delete"
onsubmit="return confirm('WIPE the KEPT booth \u201c{{ b.name }}\u201d?\n\nThis deletes it and its files immediately. Kept booths are the ones nothing else will clean up, so nobody else is going to do this for you — and nothing brings it back.')">
<button title="wipe this KEPT booth now" aria-label="wipe kept booth">×</button>
</form>
</div>
</article>
{% endfor %}
</div>
{% if booths %}<h2 class="lane-head">Ephemeral <span class="lane-note">· wiped {{ ttl_hours }}h after last activity</span></h2>{% endif %}
{% endif %}
{% if not booths %}
{% if not kept %}
<div class="empty">
No booths yet. Upload files above, or drop a folder into <code>{{ data_dir }}</code>.
</div>
{% endif %}
{% else %}
<div class="grid">
{% for b in booths %}
<article class="card">
<a class="thumb" href="/b/{{ b.name_url }}/">
{% if b.thumb_url %}
<img class="{{ 'blurred-thumb' if b.thumb_blurred }}" loading="lazy"
src="/b/{{ b.name_url }}/{{ b.thumb_url }}" alt="">
{% elif b.has_index %}
<div class="ph">▦ page</div>
{% elif b.kinds.video %}
<div class="ph">▶ video</div>
{% elif b.kinds.audio %}
<div class="ph">♪ audio</div>
{% else %}
<div class="ph">◆ files</div>
{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
{% if b.asks_open %}<span class="badge badge-ask">? {{ b.asks_open }} ask{{ '' if b.asks_open == 1 else 's' }}</span>{% endif %}
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
</div>
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
both predate this button; until 2026-09-19 the UI could only RELEASE a
kept booth, never keep an ephemeral one, so the round trip was only
closed if you had a shell. Reversible, so no confirmation — the × next
to it is the destructive one and keeps its prompt. #}
<form class="keepit" method="post" action="/b/{{ b.name_url }}/keep">
<button title="keep — exempt from the {{ ttl_hours }}h sweep" aria-label="keep booth">★</button>
</form>
<form class="wipe" method="post" action="/b/{{ b.name_url }}/delete"
onsubmit="return confirm('Wipe booth “{{ b.name }}”?')">
<button title="wipe now" aria-label="wipe booth">×</button>
</form>
</article>
{% endfor %}
</div>
{% endif %}
<script>
/* progressive enhancement: reflect chosen files + drag-drop onto the panel.
With JS off, the native file input + submit still works. */
(function () {
var input = document.getElementById('booth-files');
var sub = document.getElementById('drop-sub');
var drop = document.querySelector('.drop');
if (!input) return;
function show() {
var n = input.files ? input.files.length : 0;
if (n) {
sub.textContent = n + ' file' + (n > 1 ? 's' : '') + ' ready — hit “Get pickup id”';
drop.classList.add('has');
}
}
input.addEventListener('change', show);
['dragover', 'dragenter'].forEach(function (e) {
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.add('over'); });
});
['dragleave', 'drop'].forEach(function (e) {
drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.remove('over'); });
});
drop.addEventListener('drop', function (ev) {
if (ev.dataTransfer && ev.dataTransfer.files.length) {
try { input.files = ev.dataTransfer.files; } catch (_) {}
show();
}
});
})();
</script>
{% endblock %}
-73
View File
@@ -1,73 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block content %}
<div class="viewer">
<div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)">✕</a>
<span class="vname">{{ file }}</span>
<span class="vspacer"></span>
<span class="vtoggle" id="vtoggle" style="display:none">
<button type="button" class="vseg on" id="btn-fit">Fit</button><button type="button" class="vseg" id="btn-one">1:1</button>
</span>
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
</div>
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous image">‹</a>{% endif %}
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next image">›</a>{% endif %}
<div class="vstage fit" id="vstage"><img id="vimg" src="{{ file_url }}" alt="{{ file }}"></div>
</div>
<style>
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
align-items:center;justify-content:center;width:2.6rem;height:3.4rem;font-size:2rem;
line-height:1;text-decoration:none;color:var(--fg-1);background:rgba(20,23,32,.55);
border:1px solid rgba(255,255,255,.10);border-radius:10px;margin:0 .5rem;user-select:none;
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);transition:background .15s,border-color .15s}
.vnav:hover{background:rgba(28,33,46,.92);border-color:var(--aus-bright-cyan,#42dcd1)}
.vprev{left:0}.vnext{right:0}
@media print{.vnav{display:none}}
</style>
<script>
(function () {
var img = document.getElementById('vimg');
var stage = document.getElementById('vstage');
var toggle = document.getElementById('vtoggle');
var bFit = document.getElementById('btn-fit');
var bOne = document.getElementById('btn-one');
var BACK = {{ ('/b/' ~ name_url ~ '/')|tojson }};
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
function setMode(mode) {
var fit = mode === 'fit';
stage.classList.toggle('fit', fit);
stage.classList.toggle('one', !fit);
bFit.classList.toggle('on', fit);
bOne.classList.toggle('on', !fit);
}
// "fits" == the image at natural size already sits inside the stage, so Fit
// and 1:1 would render identically — in that case we hide the toggle entirely.
function fits() {
return img.naturalWidth <= stage.clientWidth && img.naturalHeight <= stage.clientHeight;
}
function evaluate() {
if (!img.naturalWidth) return;
if (fits()) {
toggle.style.display = 'none';
setMode('fit');
} else {
toggle.style.display = 'inline-flex';
if (!stage.classList.contains('one')) setMode('fit');
}
}
bFit.addEventListener('click', function () { setMode('fit'); });
bOne.addEventListener('click', function () { setMode('one'); });
img.addEventListener('load', evaluate);
window.addEventListener('resize', evaluate);
if (img.complete) evaluate();
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') window.location.href = BACK;
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
});
})();
</script>
{% endblock %}
-28
View File
@@ -1,28 +0,0 @@
[project]
name = "booth"
version = "0.1.15"
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
"jinja2>=3.1",
"python-multipart>=0.0.9",
"markdown>=3.5",
]
[project.optional-dependencies]
test = [
"pytest>=8.0",
"httpx>=0.27", # fastapi TestClient
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["booth"]
[tool.pytest.ini_options]
testpaths = ["tests"]
-297
View File
@@ -1,297 +0,0 @@
#!/usr/bin/env bash
# booth — post media and links to The Booth (dead simple). A booth is just a
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
# back.
#
# booth new <name> make an empty booth, print its URL
# booth add <name> <file>... copy files into a booth (creates it), print URL
# booth url <name> print a booth's URL
# booth ls list booths (kept ones marked ★)
# booth rm <name> wipe a booth now (TTL would eventually anyway)
#
# booth keep <name> exempt a booth from the 24h sweep, forever
# booth unkeep <name> hand it back to the sweeper
# booth link <url> [description] append a link to the standing link board
# booth links list the board, numbered, with entry ids
# booth unlink <id|index> remove ONE link from the board
#
# booth ask <name> <stem> <prompt> <option>... [--no-notes]
# pose a multiple-choice question in a booth
# booth asks <name> list a booth's asks and whether each is answered
# booth answer <name> <stem> [--wait [SECS]]
# print the answer JSON (exit 1 if unanswered);
# --wait polls until it lands (default 3600 s)
#
# ASKS. A session needs the operator to pick one of N things — which render,
# which plan, go/no-go — and act on the pick. `ask` writes <stem>.ask.json into
# a booth; the page renders it as a radio form with a notes field; submitting
# writes <stem>.answer.json next to it. `answer --wait` blocks until that file
# exists and prints it, so a session can `booth ask … && booth answer --wait …`
# and carry on. Re-answering overwrites: the sidecar is the CURRENT answer.
# Several questions in ONE form: write <stem>.ask.json by hand with a
# `questions` list (see services/booth/README.md § Asks); `asks` and `answer`
# handle both shapes.
# Remote sessions: rsync the ask in, then poll
# http://10.100.10.50:8090/b/<name>/<stem>.answer.json (404 until answered).
#
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
# activity — that is the contract, and it is why nobody has to clean up after
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
# sweep and moves it into its own lane at the top of the index. Use it for
# durable operator-facing boards, not for run output. `unkeep` is just `rm` of
# the sentinel, so putting a board back under the sweeper costs nothing.
#
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
# NOW — it announces that the board was kept, so wiping something durable is
# never silent. In the web UI it is two deliberate steps: `release` on the kept
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
# it from there.
#
# DO NOT "unkeep and let it expire". Removing the sentinel BUMPS the booth
# directory's mtime, and a booth's age is the newest mtime in its tree — so a
# released board's clock RESETS and it survives another full 24h. Unkeep-and-wait
# is a delay, not a delete. Use `rm` (or the UI ×) when you mean now.
#
# `link` is the reason the exception exists: agent sessions hand the operator
# URLs that then drown in terminal scrollback. They go on a standing kept board
# instead, with provenance, so they outlive the session that produced them.
#
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
# rsync -a ./out/ nh3-dev:booth-data/my-run/
set -euo pipefail
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
KEEP=".forever" # must match KEEP_MARKER in booth/app.py
BLUR=".blurred" # one booth-relative item path per line; see `blur` below
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
usage() {
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <stem> <prompt> <option>... [--no-notes]|asks <name>|answer <name> <stem> [--wait [SECS]]}" >&2
exit 2
}
cmd="${1:-}"; shift || true
case "$cmd" in
new)
[ $# -ge 1 ] || usage
mkdir -p -- "$DATA/$1"
echo "$URL/b/$1/"
;;
add)
[ $# -ge 2 ] || usage
name="$1"; shift
mkdir -p -- "$DATA/$name"
cp -- "$@" "$DATA/$name/"
echo "$URL/b/$name/"
;;
url)
[ $# -ge 1 ] || usage
echo "$URL/b/$1/"
;;
ls)
[ -d "$DATA" ] || exit 0
for d in "$DATA"/*/; do
[ -d "$d" ] || continue
n="$(basename -- "$d")"
if [ -e "$d$KEEP" ]; then echo "★ $n"; else echo " $n"; fi
done
;;
rm)
[ $# -ge 1 ] || usage
# Say so when the thing destroyed was durable. Not a block — a CLI user
# naming a booth is being explicit — but a kept board disappearing must not
# look identical to run output disappearing.
was_kept=""
[ -e "$DATA/$1/$KEEP" ] && was_kept=" (was KEPT — durable board)"
rm -rf -- "${DATA:?}/$1"
echo "wiped $1$was_kept"
;;
keep)
[ $# -ge 1 ] || usage
[ -d "$DATA/$1" ] || { echo "no such booth: $1" >&2; exit 1; }
: > "$DATA/$1/$KEEP"
echo "kept (exempt from the sweep): $URL/b/$1/"
;;
unkeep)
[ $# -ge 1 ] || usage
rm -f -- "$DATA/$1/$KEEP"
echo "unkept — $1 rejoins the 24h sweep"
;;
blur|unblur)
# ⚠ COSMETIC ONLY. A blurred item is still served at its own URL, still in
# the zip, still on disk. This hides it from a glance — a shoulder, a
# screen-share, a scroll past something you did not want full-size. The
# Booth has no auth by design: if a thing must not be SEEN, it must not be
# in a booth.
[ $# -ge 2 ] || usage
b="$1"; shift
[ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }
f="$DATA/$b/$BLUR"
for item in "$@"; do
item="${item#"$DATA/$b/"}"; item="${item#/}"
case "$item" in
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
esac
[ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
touch "$f"
if [ "$cmd" = blur ]; then
grep -qxF -- "$item" "$f" || printf '%s\n' "$item" >> "$f"
else
grep -vxF -- "$item" "$f" > "$f.tmp" || true
mv -- "$f.tmp" "$f"
fi
done
# An empty marker is a lie by omission — `ls -a` should say whether
# anything here is blurred at all.
[ -s "$f" ] || rm -f -- "$f"
if [ "$cmd" = blur ]; then
echo "blurred (cosmetic — still served): $URL/b/$b/"
else
echo "un-blurred: $URL/b/$b/"
fi
;;
link)
[ $# -ge 1 ] || usage
link_url="$1"; shift
desc="${*:-}"
board="$DATA/$LINKS_BOARD"
mkdir -p -- "$board"
: > "$board/$KEEP" # the board is durable by definition
# Provenance, because a bare URL is unreadable three days later: who posted
# it, from where, and when.
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
when="$(date '+%Y-%m-%d %H:%M')"
# ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is
# atomic on POSIX, so concurrent sessions cannot interleave a line — which
# matters here precisely because many agents post to one board.
# flock on the same sidecar the Python remover uses. The append is
# atomic by itself, but `unlink` does read-modify-write, and without a
# shared lock this line could land inside that window and be rewritten
# away by the prune.
touch -- "$board/.links.lock"
flock "$board/.links.lock" \
printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
"${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
echo "$URL/b/$LINKS_BOARD/"
;;
links)
board="$DATA/$LINKS_BOARD/links.md"
[ -f "$board" ] || { echo "no link board yet"; exit 0; }
# The id is the same content hash the web UI and `unlink` use, so a row can
# be named unambiguously even while other sessions are appending to the board.
n=0
while IFS= read -r line; do
case "$line" in "- ["*) ;; *) continue ;; esac
n=$((n+1))
id="$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sha1sum | cut -c1-8)"
printf '%3d %s %s\n' "$n" "$id" "$line"
done < "$board"
# `if`, NOT `[ ... ] && echo`: as the LAST statement of the branch that
# idiom returns 1 whenever the board is non-empty, so `booth links` exits
# non-zero on success — and `unlink`'s index lookup, which calls it inside
# $( ) under `set -e`, then dies silently.
if [ "$n" -eq 0 ]; then echo "board has no link rows"; fi
;;
unlink)
[ $# -ge 1 ] || usage
board="$DATA/$LINKS_BOARD"
[ -f "$board/links.md" ] || { echo "no link board" >&2; exit 1; }
target="$1"
# A bare number is accepted for convenience but resolved to the row's
# CONTENT ID before anything is deleted: between `booth links` and
# `booth unlink` another session may have appended, and deleting by POSITION
# would then take the wrong row. An id either matches the row you saw or
# matches nothing.
# DISAMBIGUATE BY SHAPE, not by "is it numeric". A content id is exactly 8
# hex chars, and roughly one id in forty is all digits — those were being
# read as row numbers and silently resolving to nothing. Match the id's
# actual shape first; anything else numeric is an index.
case "$target" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
;; # already a content id
''|*[!0-9]*)
echo "not an entry id (8 hex chars) or a row number: $target" >&2; exit 1 ;;
*)
target="$("$0" links | awk -v n="$target" '$1==n{print $2}')"
[ -n "$target" ] || { echo "no row $1 on the board" >&2; exit 1; } ;;
esac
# `|| exit 1` so a failure is reported rather than swallowed; `set -e` inside
# a command substitution elsewhere in this script has bitten us already.
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.links import remove_link_entry # stdlib only — no venv needed
removed = remove_link_entry(pathlib.Path(sys.argv[1]), sys.argv[2])
if removed is None:
sys.exit("no such entry: %s (already removed?)" % sys.argv[2])
print("removed: %s %s" % (removed["desc"], removed["url"]))
' "$board" "$target"
;;
ask)
# booth ask <name> <stem> <prompt> <opt>... [--no-notes]
[ $# -ge 5 ] || usage
name="$1"; stem="$2"; prompt="$3"; shift 3
notes=1; opts=()
for a in "$@"; do
case "$a" in --no-notes) notes=0 ;; *) opts+=("$a") ;; esac
done
[ "${#opts[@]}" -ge 2 ] || { echo "an ask needs at least 2 options" >&2; exit 1; }
# Validated through the SAME normaliser the page uses, so a session cannot
# post a question the renderer would refuse. stdlib only — no venv needed.
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import AskError, write_ask
booth, stem, prompt, *opts = sys.argv[1:]
try:
write_ask(pathlib.Path(booth), stem, prompt, opts, notes=os.environ["ASK_NOTES"] == "1")
except AskError as exc:
sys.exit("bad ask: %s" % exc)
' "$DATA/$name" "$stem" "$prompt" "${opts[@]}"
echo "$URL/b/$name/#ask-$stem"
;;
asks)
[ $# -ge 1 ] || usage
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import list_asks
asks = list_asks(pathlib.Path(sys.argv[1]))
if not asks:
print("no asks in this booth")
for a in asks:
if a["error"]:
state = "BROKEN " + a["error"]
elif a["answer"] and a["multi"]:
picks = ", ".join("%s=%s" % (k, v["label"]) for k, v in a["answer"]["answers"].items())
state = "answered %s (%s)" % (picks, a["answer"]["answered_at"])
elif a["answer"]:
state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"])
elif a["multi"]:
state = "open (%d questions)" % len(a["questions"])
else:
state = "open"
print("%-24s %s" % (a["stem"], state))
' "$DATA/$1"
;;
answer)
# booth answer <name> <stem> [--wait [SECS]]
[ $# -ge 2 ] || usage
name="$1"; stem="$2"; shift 2
wait_s=0
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
f="$DATA/$name/$stem.answer.json"
[ -f "$DATA/$name/$stem.ask.json" ] || { echo "no such ask: $name/$stem" >&2; exit 1; }
# Poll, do not inotify: the answer is written by a different process via
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
deadline=$(( $(date +%s) + wait_s ))
while [ ! -f "$f" ]; do
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#ask-$stem" >&2; exit 1; fi
if [ "$(date +%s)" -ge "$deadline" ]; then echo "timed out after ${wait_s}s waiting on $name/$stem" >&2; exit 1; fi
sleep 2
done
cat -- "$f"
;;
*) usage ;;
esac
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""layout-probe — find controls that render but cannot be clicked.
WHY THIS EXISTS. On 2026-09-21 the operator reported "release button covers
delete button". Both controls were `position:absolute` on the same corner of a
kept card, and `release` was the later sibling, so it painted over the × with
30x22 px of overlap on a 30px button. `elementFromPoint` at the ×'s centre
returned the release form: the × was 100% unclickable from the day it shipped.
Nothing in the test suite could have caught it. The markup was correct, the
route was correct, the CSS was individually valid. OCCLUSION IS A PROPERTY OF
THE RENDERED LAYOUT, and the only instrument that sees it is a browser.
This is the second control shipped inert in two days — the first was a reveal
button whose handler Jinja discarded. Both were reported by the operator, both
would have taken ten seconds to catch by looking at the page.
USAGE
<a python with playwright> scripts/layout-probe.py [URL ...]
Exits 0 if every control is hittable, 1 if any is occluded. No arguments
probes the booth index and every booth linked from it.
"""
import sys
from playwright.sync_api import sync_playwright
DEFAULT = "http://10.100.10.50:8090/"
# Does a click at this element's centre actually reach it?
HIT = """(el) => {
// ⚠ Use getClientRects()[0], NOT getBoundingClientRect(). For an INLINE
// element that WRAPS, the bounding rect is the union of its line boxes and
// its geometric centre can land in the gutter between lines — on the parent,
// not on the element. The third version of this probe reported three zip
// links as OCCLUDED for exactly that reason: long booth names wrapped the
// link, and `elementFromPoint` correctly returned the parent .sub div. Real
// geometry, wrong question. Per-line rects ask the right one.
const rects = el.getClientRects();
const r = rects.length ? rects[0] : el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return 'ZERO-SIZE';
const x = r.x + r.width / 2, y = r.y + r.height / 2;
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return 'OFF-SCREEN';
const top = document.elementFromPoint(x, y);
if (!top) return 'OFF-SCREEN';
// `top.contains(el)` is NOT a hit and must never be added back. An ANCESTOR
// receiving the click is precisely what occlusion looks like when the
// overlay is a parent or a parent's ::after, and an ancestor trivially
// contains its descendant — that clause made version one report OK for a
// real overlay. A DESCENDANT receiving it is fine: <a><img> resolves to the
// img and the anchor still gets the click.
return (el === top || el.contains(top)) ? 'OK' : 'OCCLUDED';
}"""
def probe(page, url: str) -> list[str]:
bad = []
page.goto(url, wait_until="networkidle")
# Hover every card first: these UIs reveal controls on hover, and an
# opacity-0 control still occupies layout and still occludes.
for card in page.locator("article.card").all():
try:
card.hover(timeout=1500)
except Exception:
pass
for el in page.locator("button, a.dl-link, a.thumb").all():
try:
# ⚠ elementFromPoint is VIEWPORT-relative. Without scrolling first,
# every control below the fold reports OCCLUDED and the probe
# drowns its real findings in noise — which is what the second
# version did on a page with sixteen kept booths.
el.scroll_into_view_if_needed(timeout=1500)
verdict = el.evaluate(HIT)
except Exception:
continue
if verdict in ("OCCLUDED", "ZERO-SIZE"):
label = (el.get_attribute("aria-label")
or el.get_attribute("title")
or (el.text_content() or "").strip()[:30] or "?")
bad.append(f"{url} {verdict:<10} {label}")
return bad
def main(argv: list[str]) -> int:
urls = argv[1:] or [DEFAULT]
failures = []
with sync_playwright() as pw:
b = pw.chromium.launch()
pg = b.new_page(viewport={"width": 1400, "height": 900})
for u in urls:
failures += probe(pg, u)
b.close()
if failures:
print("UNCLICKABLE CONTROLS:")
for f in failures:
print(" ", f)
return 1
print(f"all controls hittable across {len(urls)} page(s)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
-530
View File
@@ -1,530 +0,0 @@
"""Asks: session poses a multiple-choice question; operator answers in the
browser; the answer lands as a sidecar the session reads."""
import json
import pathlib
import pytest
from fastapi.testclient import TestClient
from booth.app import build_gallery, create_app, list_booths
from booth.asks import (
ANSWER_SUFFIX,
ASK_SUFFIX,
AskError,
list_asks,
load_ask,
normalize_ask,
read_answer,
write_answer,
write_ask,
)
def _ask(booth, stem="winner", **kw):
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
doc.update(kw)
booth.mkdir(parents=True, exist_ok=True)
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(doc))
return booth
@pytest.fixture
def client(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), tmp_path
# ---- normalisation ----------------------------------------------------------
def test_normalize_string_options():
a = normalize_ask({"prompt": " Pick ", "options": ["x", "y"]}, "s")
assert a["prompt"] == "Pick"
assert a["options"] == [{"id": "x", "label": "x", "detail": ""}, {"id": "y", "label": "y", "detail": ""}]
assert a["notes"] is True and a["notes_label"] == "notes"
def test_normalize_object_options_and_flags():
a = normalize_ask(
{"prompt": "p", "options": [{"id": "a", "label": "A", "detail": "d"}, {"label": "B"}],
"notes": False, "notes_label": "why"},
"s",
)
assert a["options"][0] == {"id": "a", "label": "A", "detail": "d"}
assert a["options"][1] == {"id": "B", "label": "B", "detail": ""}
assert a["notes"] is False and a["notes_label"] == "why"
@pytest.mark.parametrize(
"doc",
[
{"options": ["a", "b"]},
{"prompt": "", "options": ["a", "b"]},
{"prompt": "p", "options": ["only"]},
{"prompt": "p", "options": "a,b"},
{"prompt": "p", "options": ["a", "a"]},
{"prompt": "p", "options": [{"id": "a"}, "b"]},
{"prompt": "p", "options": ["a", "b"], "notes": "yes"},
[],
],
)
def test_normalize_rejects(doc):
with pytest.raises(AskError):
normalize_ask(doc, "s")
# ---- files ------------------------------------------------------------------
def test_load_ask_reports_bad_json(tmp_path):
(tmp_path / f"x{ASK_SUFFIX}").write_text("{not json")
with pytest.raises(AskError):
load_ask(tmp_path, "x")
with pytest.raises(AskError):
load_ask(tmp_path, "missing")
def test_list_asks_folds_answer_and_surfaces_errors(tmp_path):
_ask(tmp_path, "one")
_ask(tmp_path, "two")
(tmp_path / f"broken{ASK_SUFFIX}").write_text("[]")
(tmp_path / ".hidden.ask.json").write_text("{}") # dotfiles never listed
write_answer(tmp_path, "two", "B — async", "less banding", who="10.0.0.1")
asks = list_asks(tmp_path)
by = {a["stem"]: a for a in asks}
assert set(by) == {"one", "two", "broken"}
assert by["one"]["answer"] is None and by["one"]["error"] is None
assert by["two"]["answer"]["choice"] == "B — async"
assert by["two"]["answer"]["choice_index"] == 1
assert by["two"]["answer"]["notes"] == "less banding"
assert by["two"]["answer"]["answered_by"] == "10.0.0.1"
assert by["broken"]["error"] and by["broken"]["options"] == []
def test_write_answer_validates_choice_and_is_atomic(tmp_path):
_ask(tmp_path)
with pytest.raises(AskError):
write_answer(tmp_path, "winner", "C — nope")
with pytest.raises(AskError):
write_answer(tmp_path, "nosuch", "A — baseline")
ans = write_answer(tmp_path, "winner", "A — baseline", " ok \r\n")
assert ans["notes"] == "ok"
assert ans["answered_at"]
assert read_answer(tmp_path, "winner") == ans
assert not (tmp_path / f"winner{ANSWER_SUFFIX}.tmp").exists()
# re-answer overwrites — the sidecar is the CURRENT answer, not a log
write_answer(tmp_path, "winner", "B — async")
assert read_answer(tmp_path, "winner")["choice_index"] == 1
def test_write_answer_drops_notes_when_ask_disables_them(tmp_path):
_ask(tmp_path, notes=False)
assert write_answer(tmp_path, "winner", "A — baseline", "ignored")["notes"] == ""
def test_write_ask_roundtrip_and_stem_guard(tmp_path):
p = write_ask(tmp_path / "b", "pick", "Pick one", ["x", {"id": "y", "label": "Y"}], notes=False)
assert p.name == f"pick{ASK_SUFFIX}"
a = load_ask(tmp_path / "b", "pick")
assert [o["id"] for o in a["options"]] == ["x", "y"] and a["notes"] is False
for bad in ("../x", ".hidden", "a/b", ""):
with pytest.raises(AskError):
write_ask(tmp_path / "b", bad, "p", ["a", "b"])
with pytest.raises(AskError):
write_ask(tmp_path / "b", "ok", "p", ["solo"])
# ---- gallery + index integration -------------------------------------------
def test_gallery_hides_ask_and_answer_files(tmp_path):
b = _ask(tmp_path / "b")
(b / "a.png").write_bytes(b"x")
write_answer(b, "winner", "A — baseline")
names = {it["name"] for it in build_gallery(b)}
assert names == {"a.png"}
def test_list_booths_counts_open_asks(tmp_path):
b = _ask(tmp_path / "b", "one")
_ask(b, "two")
write_answer(b, "two", "A — baseline")
(tmp_path / "plain").mkdir()
by = {x["name"]: x for x in list_booths(tmp_path, 3600)}
assert by["b"]["asks_open"] == 1 and by["b"]["asks_total"] == 2
assert by["plain"]["asks_open"] == 0 and by["plain"]["asks_total"] == 0
assert by["b"]["count"] == 0 # ask/answer files are not "items"
# ---- routes -----------------------------------------------------------------
def test_booth_page_renders_open_ask_as_form(client):
c, data = client
_ask(data / "b")
html = c.get("/b/b/").text
assert "Which render wins?" in html
assert 'type="radio"' in html and 'name="choice"' in html
assert 'value="B — async"' in html
assert 'action="/b/b/answer"' in html
assert "<textarea" in html
assert "1 open ask" in html
def test_answer_route_writes_sidecar_and_page_shows_it(client):
c, data = client
_ask(data / "b")
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "notes": "less banding"},
follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/b/b/#ask-winner"
ans = json.loads((data / "b" / f"winner{ANSWER_SUFFIX}").read_text())
assert ans["choice"] == "B — async" and ans["notes"] == "less banding"
assert ans["answered_by"] # TestClient's client addr
html = c.get("/b/b/").text
assert "answered" in html and "less banding" in html
assert "1 open ask" not in html
# the sidecar is fetchable over HTTP for remote sessions
assert c.get("/b/b/winner.answer.json").json()["choice"] == "B — async"
def test_answer_route_rejects_bad_choice_and_unknown_ask(client):
c, data = client
_ask(data / "b")
assert c.post("/b/b/answer", data={"ask": "winner", "choice": "Z"}).status_code == 400
assert c.post("/b/b/answer", data={"ask": "nosuch", "choice": "A — baseline"}).status_code == 404
assert c.post("/b/b/answer", data={"ask": "../x", "choice": "A — baseline"}).status_code == 404
assert not (data / "b" / f"winner{ANSWER_SUFFIX}").exists()
def test_answer_json_404s_until_answered(client):
c, data = client
_ask(data / "b")
assert c.get("/b/b/winner.answer.json").status_code == 404
def test_notes_field_hidden_when_disabled(client):
c, data = client
_ask(data / "b", notes=False)
assert "<textarea" not in c.get("/b/b/").text
def test_index_card_shows_open_ask_badge(client):
c, data = client
_ask(data / "b")
html = c.get("/").text
assert "1 ask" in html
# ---- multi-question asks ----------------------------------------------------
MULTI = {
"title": "R18 batch review",
"questions": [
{"key": "r1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": True},
{"key": "r2", "prompt": "Render 2?", "options": [{"id": "k", "label": "keep"}, {"id": "d", "label": "drop"}]},
],
"notes": True,
}
def _multi(booth, stem="batch", **kw):
doc = json.loads(json.dumps(MULTI)); doc.update(kw)
booth.mkdir(parents=True, exist_ok=True)
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(doc))
return booth
def test_normalize_multi():
a = normalize_ask(MULTI, "batch")
assert a["multi"] is True and a["title"] == "R18 batch review"
assert [q["key"] for q in a["questions"]] == ["r1", "r2"]
assert a["questions"][0]["notes"] is True and a["questions"][1]["notes"] is False
assert a["questions"][1]["options"][0] == {"id": "k", "label": "keep", "detail": ""}
# single stays single, and exposes ONE question with key None
s = normalize_ask({"prompt": "p", "options": ["a", "b"]}, "s")
assert s["multi"] is False and s["questions"][0]["key"] is None
@pytest.mark.parametrize(
"doc",
[
{"questions": []},
{"questions": [{"prompt": "p", "options": ["a", "b"]}]}, # no key
{"questions": [{"key": "bad key", "prompt": "p", "options": ["a", "b"]}]},
{"questions": [{"key": "x", "prompt": "p", "options": ["a", "b"]},
{"key": "x", "prompt": "q", "options": ["a", "b"]}]}, # dup key
{"questions": [{"key": "x", "prompt": "p", "options": ["only"]}]},
{"prompt": "p", "options": ["a", "b"], "questions": [{"key": "x", "prompt": "p", "options": ["a", "b"]}]},
],
)
def test_normalize_multi_rejects(doc):
with pytest.raises(AskError):
normalize_ask(doc, "s")
def test_write_answer_multi_accepts_a_partial_answer(tmp_path):
"""Blanks are legal (operator ruling 2026-09-09): refusing the whole
submission because one of four was skipped threw away the three that were
made."""
_multi(tmp_path)
a = write_answer(tmp_path, "batch", {"r1": "keep"}) # r2 not submitted at all
assert a["complete"] is False and a["unanswered"] == ["r2"]
assert list(a["answers"]) == ["r1"]
b = write_answer(tmp_path, "batch", {"r1": "keep", "r2": ""}) # r2 an empty radio group
assert b["unanswered"] == ["r2"] and b["complete"] is False
# a note without a pick is still worth keeping
c = write_answer(tmp_path, "batch", {"r1": "", "r2": "k"}, qnotes={"r1": "undecided"})
assert c["answers"]["r1"] == {"prompt": "Render 1?", "choice": None,
"choice_index": None, "label": "", "notes": "undecided"}
assert c["unanswered"] == ["r1"]
# nothing at all is refused: it would flip the ask to answered with no decision
with pytest.raises(AskError):
write_answer(tmp_path, "batch", {"r1": "", "r2": ""})
# ...but notes alone are a real submission
d = write_answer(tmp_path, "batch", {"r1": "", "r2": ""}, "ask me tomorrow")
assert d["complete"] is False and d["notes"] == "ask me tomorrow" and d["answers"] == {}
def test_single_ask_may_be_answered_with_notes_only(tmp_path):
_ask(tmp_path)
with pytest.raises(AskError):
write_answer(tmp_path, "winner", "")
a = write_answer(tmp_path, "winner", "", "neither is right, rerun")
assert a["choice"] is None and a["complete"] is False
assert a["notes"] == "neither is right, rerun"
def test_write_answer_multi_still_rejects_a_bad_option(tmp_path):
_multi(tmp_path)
with pytest.raises(AskError):
write_answer(tmp_path, "batch", {"r1": "keep", "r2": "nope"})
with pytest.raises(AskError):
write_answer(tmp_path, "batch", "keep") # wrong shape
ans = write_answer(tmp_path, "batch", {"r1": "drop", "r2": "k"}, "overall fine",
qnotes={"r1": "banding", "r2": "ignored: notes off"})
assert list(ans["answers"]) == ["r1", "r2"]
assert ans["answers"]["r1"] == {"prompt": "Render 1?", "choice": "drop", "choice_index": 1,
"label": "drop", "notes": "banding"}
assert ans["answers"]["r2"]["choice"] == "k" and ans["answers"]["r2"]["notes"] == ""
assert ans["notes"] == "overall fine" and ans["title"] == "R18 batch review"
assert read_answer(tmp_path, "batch") == ans
def test_multi_page_and_route(client):
c, data = client
_multi(data / "b")
html = c.get("/b/b/").text
assert "R18 batch review" in html and "2 questions" in html
assert 'name="choice.r1"' in html and 'name="choice.r2"' in html
assert 'name="notes.r1"' in html and 'name="notes.r2"' not in html
assert 'name="notes"' in html
# a partial submission is RECORDED, not refused
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"},
follow_redirects=False).status_code == 303
part = json.loads((data / "b" / f"batch{ANSWER_SUFFIX}").read_text())
assert part["complete"] is False and part["unanswered"] == ["r2"]
assert "1/2" in c.get("/b/b/").text and "partial" in c.get("/b/b/").text
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
assert r.status_code == 303
ans = c.get("/b/b/batch.answer.json").json()
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r1"]["notes"] == "crisp"
assert ans["answers"]["r2"]["choice"] == "d" and ans["notes"] == "ship r1"
html = c.get("/b/b/").text
assert "answered" in html and "crisp" in html and "ship r1" in html
def test_write_ask_accepts_full_doc(tmp_path):
write_ask(tmp_path / "b", "batch", doc=MULTI)
assert load_ask(tmp_path / "b", "batch")["multi"] is True
with pytest.raises(AskError):
write_ask(tmp_path / "b", "bad", doc={"questions": []})
# ---- verbatim-index booths ---------------------------------------------------
#
# A booth's own index.html is served VERBATIM, so the inline asks panel can never
# render on it. Found 2026-09-09 on `emmie-anchor`: a valid ask, listed by the
# CLI, invisible on the page with nothing to say so. The fix is a chip injected
# into the verbatim page plus a standalone /asks page that carries the forms.
def test_verbatim_booth_renders_the_ask_inline(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
html = c.get("/b/b/").text
assert "hi" in html # the report is still served verbatim
assert "Which render wins?" in html # ...with the ask ON it, not elsewhere
assert 'type="radio"' in html and 'action="/b/b/answer"' in html
assert "bk-ask" in html # self-contained fragment styles
assert "booth-nav-asks" in html # chip remains, as a jump link
assert "#bk-ask-winner-top" in html
def test_verbatim_chip_disappears_once_answered(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text("<!doctype html><body>hi</body>")
write_answer(b, "winner", "A — baseline")
assert "booth-nav-asks" not in c.get("/b/b/").text
def test_verbatim_booth_without_asks_is_untouched(client):
c, data = client
(data / "b").mkdir()
(data / "b" / "index.html").write_text("<!doctype html><body>hi</body>")
assert "booth-nav-asks" not in c.get("/b/b/").text
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text("<!doctype html><body>hi</body>")
page = c.get("/b/b/asks").text
assert "Which render wins?" in page and 'type="radio"' in page
assert 'name="back" value="asks"' in page
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "back": "asks"},
follow_redirects=False)
assert r.headers["location"] == "/b/b/asks#ask-winner"
assert read_answer(b, "winner")["choice"] == "B — async"
assert "answered" in c.get("/b/b/asks").text
def test_asks_page_on_a_booth_with_none(client):
c, data = client
(data / "b").mkdir()
assert "no asks" in c.get("/b/b/asks").text
def test_asks_page_404s_for_unknown_booth(client):
c, _ = client
assert c.get("/b/nope/asks").status_code == 404
def test_single_ask_keeps_its_title(tmp_path):
a = normalize_ask({"title": "emmie — pick the anchor", "prompt": "Which?",
"options": ["a", "b"]}, "s")
assert a["multi"] is False and a["title"] == "emmie — pick the anchor"
with pytest.raises(AskError):
normalize_ask({"title": 7, "prompt": "p", "options": ["a", "b"]}, "s")
def test_asks_page_shows_a_single_ask_title(client):
c, data = client
_ask(data / "b", title="emmie — pick the anchor")
assert "emmie — pick the anchor" in c.get("/b/b/asks").text
# ---- inline placement in a verbatim report -----------------------------------
#
# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be
# inline with the artifacts, not on a separate page." A four-voice audition wants
# each voice's radio group under that voice's audio, and one submit for the lot.
REPORT = """<!doctype html><title>audition</title><body>
<h1>Three voices</h1>
<section id="lawson"><audio src="a.wav"></audio>
<div data-booth-ask="batch:r1"></div></section>
<section id="jo"><audio src="b.wav"></audio>
<!-- booth:ask batch:r2 --></section>
<div data-booth-ask-submit="batch"></div>
</body>"""
def test_per_question_placeholders_land_where_the_author_put_them(client):
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text(REPORT)
html = c.get("/b/b/").text
# each group is inside its own section, in document order
lawson = html.index('id="lawson"')
jo = html.index('id="jo"')
assert lawson < html.index('name="choice.r1"') < jo
assert jo < html.index('name="choice.r2"')
# one shared form, bound by the HTML5 form= attribute, submitted once
assert html.count('<form id="bk-ask-form-batch"') == 1
assert html.count('action="/b/b/answer"') == 1
assert html.count('form="bk-ask-form-batch"') >= 4
# the submit block landed at its own placeholder, not appended after </body>
assert html.index("bk-ask-form-batch") < html.index("</body>")
def test_inline_form_submits_every_question_in_one_post(client):
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text(REPORT)
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
assert r.status_code == 303
ans = read_answer(b, "batch")
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
# and the recorded pick now shows inline, on the report itself
html = c.get("/b/b/").text
assert "recorded:" in html and "bk-done" in html
assert 'value="keep" required checked' in html.replace("\n", " ") or "checked" in html
def test_whole_ask_placeholder_renders_everything_there(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text('<!doctype html><body><p>x</p><div data-booth-ask="winner"></div></body>')
html = c.get("/b/b/").text
assert html.index("Which render wins?") > html.index("<p>x</p>")
assert html.index("bk-ask-go") < html.index("</body>") # submit placed inline too
def test_placeholder_for_a_missing_ask_is_left_alone(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="typo"></div></body>')
html = c.get("/b/b/").text
assert 'data-booth-ask="typo"' in html # author's markup untouched, not blanked
assert "Which render wins?" in html # the real ask still appended, never lost
def test_questions_placed_without_a_submit_still_get_one(client):
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch:r1"></div></body>')
html = c.get("/b/b/").text
assert html.count('<form id="bk-ask-form-batch"') == 1 # appended, so it is submittable
assert 'name="choice.r2"' in html # r2 unplaced -> must still appear
def test_styles_are_emitted_once(client):
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text(REPORT)
assert c.get("/b/b/").text.count(".bk-ask-opt:has(input:checked)") == 1
def test_radios_are_not_html_required_anywhere(client):
"""The browser must not block a partial submit — `required` on a radio group
is exactly what stopped the operator leaving one blank."""
c, data = client
b = _multi(data / "b")
assert "required" not in c.get("/b/b/").text
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
assert "required" not in c.get("/b/b/").text
assert "required" not in c.get("/b/b/asks").text
def test_partial_answer_renders_as_skipped_inline(client):
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
html = c.get("/b/b/").text
assert "bk-skip" in html and "left blank" in html
assert "1 of 2 answered" in html
def test_empty_submission_is_refused_with_400(client):
c, data = client
b = _multi(data / "b")
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "", "choice.r2": ""}).status_code == 400
assert read_answer(b, "batch") is None # the ask stays OPEN, not falsely answered
File diff suppressed because it is too large Load Diff