Operator: the form failed when a question was left blank. Refusing the whole
submission over one blank threw away the picks that were made, and the HTML
`required` on the radios blocked it in the browser before the server saw it.
- answered questions recorded; blank ones land in `unanswered`; `complete` says
whether the set is finished; a blank question carrying a note keeps the note
- `required` dropped from both templates so the browser cannot block a partial
- refused only when there is no pick anywhere AND no notes (a 400 — that would
flip an open ask to answered with no decision recorded); a choice outside the
option list is still an error
- new ◐ partial state with an n/N count; skipped questions render as skipped
- README + global CLAUDE.md tell reading sessions to check `complete`
- 154 tests; v0.1.15
Operator verdict on the separate /asks page: the question belongs with the
artifact it is about. A four-voice audition wants each voice's radio group
under that voice's audio, and one submit for the lot.
- booth/inline.py: data-booth-ask="stem" | "stem:key" | data-booth-ask-submit,
plus <!-- booth:ask ... --> comments; unknown stem left alone, not blanked
- _ask_inline.html: self-contained fragments (own scoped styles, no JS), per-question
groups bound to one form via the HTML5 form= attribute so a scattered
multi-question ask still POSTs once
- unplaced questions and a missing submit block are appended, so a partially
marked-up page can never produce an unsubmittable 400
- chip becomes a jump link to the first open ask; /asks page kept as a fallback
- 6 tests (one caught the partial-placement drop); v0.1.14
A custom index.html is returned verbatim, so booth.html's asks panel never
rendered there — a valid ask (emmie-anchor/anchor.ask.json) was listed by the
CLI and shown nowhere, with nothing to say so.
- panel extracted to _asks.html; new GET /b/<name>/asks standalone page
- verbatim pages get an amber '? N open asks' chip beside the back chip
- POST /answer honours back=asks so answering returns to that page
- single-question asks now keep an optional 'title' (was silently dropped)
- README + routes table; 8 regression tests; v0.1.12
- asks.py: single {prompt, options} and multi {title, questions:[{key, prompt, options, notes?}]} both normalise to questions[]; per-question notes; every question required on submit
- /answer reads choice.<key> / notes.<key> / notes for multi; single shape unchanged
- booth asks prints per-question picks; README + CLI header; install step symlinks the CLI to ~/.local/bin; v0.1.10; 135 tests
The standing link board grew from a flat oldest-first list with a per-row
× into a manageable board: newest links lead, favorites stay on top, and
several dead links can go in one pass.
- Ordering: order_for_display() renders pinned rows first, then newest-first
within each group (the board is an append log, so newest = most recently
posted — the row you usually came to grab).
- Pin/favorite: a per-row ★ toggles pinned state via POST /b/<name>/pin.
State lives in a .pins sidecar dotfile (one content id per line), NOT
inline in links.md — so links.md stays a pure atomic-append log (many
sessions post concurrently) and a row's content id never changes just
because it was pinned. remove_link_entry drops a removed row's pin;
orphaned pins are inert (renderer only stars a live id).
- Multi-select delete: checkboxes feed POST /b/<name>/unlink-many (repeated
'sel' content ids), with a select-all box and a live count. The per-row ×
stays for single removal.
- One <form> with formaction buttons, so checkboxes, ×, ★, and bulk delete
coexist without nested forms AND all work with JS off; JS only adds
select-all and the live count. Per-row × confirm reads desc/url from
data-* attrs, so an arbitrary posted description can't break into the JS.
- Every action is keyed by content id, never row position — same race-safety
the existing × has, extended to the bulk path.
- Fixed pre-existing undefined --fg/--bg CSS refs in the board styles.
Tests: +19 (pins round-trip, ordering, orphan-inert, remove-unpins, /pin
and /unlink-many endpoints, board render + order). Full suite 102 passing.
Deployed to nh3-dev booth.service; verified live (newest-first, pin
round-trip, bulk delete) against the real 31-row board with no data loss.
The standing link board is the one MULTI-WRITER booth -- every agent session
appends operator-facing URLs to it. "Delete the folder" was the only
granularity available, so removing one dead link meant hand-editing markdown.
It is 32 rows and only grows.
booth links row number, entry id, raw row
booth unlink 3 by row number
booth unlink 8b40e0a5 by entry id (what the UI's x posts)
POST /b/<name>/unlink form field `entry` = content id
ROWS ARE ADDRESSED BY CONTENT ID, NEVER BY POSITION. The board is append-only
and multi-writer: another session can post between listing it and clicking x,
and an index would then 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. Appends and prunes now take the same flock on
.links.lock, so a post cannot be lost inside a prune's read-modify-write.
UI: a booth carrying links.md renders as rows -- description, URL, provenance,
copy button, per-row x -- instead of a markdown blob. links.md is filtered out
of the gallery so it does not appear twice; the header counts LINKS not files;
the empty-state and the one-click "Wipe now" both stand down for a board (same
rule as the kept lane: nothing durable is one click from gone).
booth/links.py extracted, STDLIB ONLY. The CLI needs this logic and must not
require the service venv -- importing app.py drags in FastAPI, so deleting a
line from a text file would have needed a web framework installed.
THREE BUGS FOUND BY TESTING, all in the shell wrapper while the module was
correct throughout -- module-only tests would have caught none of them:
- `[ "$n" -eq 0 ] && echo ...` as the LAST statement made `booth links` exit 1
whenever the board had rows. `unlink`'s index lookup calls it inside $( )
under `set -e`, so a successful listing killed the caller and the removal
silently did nothing while reporting success.
- ids are 8 hex chars and roughly one in forty is ALL DIGITS; those were read
as row numbers, resolved to nothing, and removed nothing. Now disambiguated
by the id's actual shape, not by "is it numeric".
- filtering links.md out of the gallery left `items` empty, so a full board
rendered "This booth is empty" and an empty <div class="gallery"> under 32
visible rows.
87 tests (was 76): parser tolerance of hand-written prose, content-id
stability across concurrent appends, removal precision, UI branch behaviour
for board/normal/empty booths, and subprocess CLI tests pinning the two shell
bugs. Deployed to nh3-dev and verified against the live 32-row board
read-only; board file byte-identical afterwards.
Kept boards had no delete path in the UI at all. The kept lane deliberately
omits the wipe control -- a one-click wipe next to the durable stuff is a
footgun -- but "deliberate" had been implemented as "impossible": the only
routes out were ssh or a hand-written API call.
Now it is two deliberate acts. A `release` control on kept cards drops the
sentinel, the board moves to the ephemeral lane, and the existing x wipes it
from there. Release is reversible -- POST /b/<name>/keep pins it again.
POST /b/<name>/unkeep release the pin
POST /b/<name>/keep pin it (round-trip, so release is not a one-way door)
FOUND WHILE TESTING, and it invalidates the previously-documented workaround:
removing the sentinel BUMPS the booth directory's mtime, and booth age is the
newest mtime in the tree -- so a released board's clock RESETS from 10,000s to
0s and it survives another full TTL. The old comment said "remove the sentinel
first (it rejoins the sweep)", which is true but means the board lives another
24h, not that it gets reaped. Unkeep-and-wait is a delay, not a delete.
test_releasing_a_board_RESETS_its_ttl_clock pins that behaviour deliberately so
nobody re-derives the workaround. Release is what unlocks the x; the x is what
deletes.
CLI: `booth rm` already worked on kept boards but said nothing about it. It now
announces "(was KEPT -- durable board)" so wiping something durable can never
look identical to wiping run output. Not a block -- a CLI user naming a booth
is being explicit.
5 new tests (67 pass). Verified live on nh3-dev: release renders on all four
kept boards, the ephemeral lane keeps its x, and the links board is untouched
with its sentinel intact.
Agent sessions hand the operator URLs and they drown in terminal
scrollback. The Booth is the right home for them — it already has the one
property that decides adoption, which is that a session can publish with
mkdir and cp, no API key, no schema, no deploy — but everything in it dies
in 24h.
So: a booth containing `.forever` is never swept, and renders in its own
Kept lane at the top of the index. Opt-in per booth, so the ephemeral
default is untouched and nobody inherits a cleanup chore. `rm` the
sentinel and the board rejoins the sweep; the CLI verbs are sugar over
exactly that, which keeps the filesystem-is-the-state model honest.
The pin is deliberately NOT wired into is_expired(). That stays a pure age
question feeding the `expires_in` countdown; only sweep_once() honours the
sentinel. Keeping expiry arithmetic and reaper policy apart means they
cannot drift into each other.
Kept cards are visually separated per Australis: a 2px top edge in aurora
blue, the one accent border the system sanctions. They show "kept" instead
of a countdown, and they deliberately lose the one-click wipe button — a ×
next to the durable stuff is a footgun, so removing a kept board is a
two-step act.
`booth link <url> [description]` appends to the standing `links` board,
creating and keeping it on first use. Entries carry provenance (handle or
hostname, plus a timestamp) because a bare URL is unreadable three days
later. The append is one printf of one line to an O_APPEND fd — atomic
under PIPE_BUF on POSIX — which matters because many agents post to one
board and interleaved half-lines would be the obvious failure mode.
Seven tests cover the sentinel: detection, survival of a sweep that wipes
its neighbour, the deliberate is_expired/sweep_once split, the listing
flag, the sentinel not inflating item counts, and both lane-rendering
directions. Two of them originally asserted on the bare strings "Kept" and
"kept-grid", which passed for the wrong reason — those also appear in the
inlined stylesheet served on every page — so they now assert the full
class attribute. 55 pass.
Also corrects the Homepage card's description, which advertised a flat 24h
TTL that is no longer the whole story.
Clicking a gallery image now opens a dedicated viewer instead of dumping you on
the raw file.
- GET /b/<name>/view?f=<img> — full-viewport viewer (registered before the file
catch-all so /view wins; non-image f 307-redirects to the raw file, traversal
and missing f 404).
- Fit (downscale-only) / 1:1 (natural pixels, scroll-to-pan) toggle that only
appears when the image is larger than the viewport — when it already fits,
Fit ≡ 1:1 so the toggle is hidden. Re-evaluates on resize.
- Download button + ✕/Esc back to the gallery. Australis-themed, progressive
JS (degrades to fit-only, no-JS still shows the image + download + back).
- 5 new tests (34 total, all green); verified Fit/1:1/hidden-toggle states in a
real browser.
Add a reverse direction to the Booth: the operator (or any client via `curl -F`)
can upload files through the browser and pick them up by a human-readable id.
- POST /upload — streams files to a new booth named with a human-readable id
(e.g. 4-wombat / star-84), 303-redirects to /b/<id>/ (id in the Location
header so curl clients can read it). Uploads reuse the whole booth machinery
(render, per-file download links, 24h TTL sweep, delete).
- Human-readable ids: word+number in either order, collision-checked, from a
curated 140-word friendly list; secrets-based selection.
- Safety: filenames reduced to a safe basename (no traversal), streaming size
cap (BOOTH_MAX_UPLOAD_MB, default 1024) + file-count cap (BOOTH_MAX_FILES,
default 50), partial-write cleanup on any failure.
- UI: Australis-themed upload/drop panel (drag-drop, progressive-enhancement JS,
degrades to a native file input), a "⬆ pickup" badge on upload booths, a
pickup banner, and a ⬇ download link on every gallery item.
- python-multipart dependency; homepage tile description updated; 9 new tests
(24 total, all green).
A standing user-level web server (nh3-dev :8090) that renders drop-folders
under ~/booth-data as ephemeral media "booths" so Claude Code sessions can
surface A/B renders and smoke results to the operator, then let them self-wipe.
- Scan-and-serve model, no database, no upload API — a booth is just a folder.
A folder's own index.html is served verbatim; otherwise an auto-gallery of
images / webm+mp4 video / audio is rendered, with <file>.txt caption sidecars
folded in (labels A/B pairs).
- 24h TTL from newest mtime in the tree; background sweeper wipes stale booths.
- Path-traversal + symlink-escape guarded; delete via UI button or DELETE API.
- FastAPI + Jinja2, runs from the checkout under systemctl --user (booth.service),
alongside the other nh3-dev fleet sidecars. 15 tests, all green.
- Homepage tile added (Apps -> The Booth, siteMonitor /healthz).
- Harden the homepage rsync doc: exclude *.bak* and logs/ so --delete can't
wipe the host's dated services.yaml backups (footgun found deploying this).