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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
booth-data/
|
||||
uv.lock
|
||||
+27
-497
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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
Reference in New Issue
Block a user