fix(booth): asks were invisible in a booth serving its own index.html

A custom index.html is returned verbatim, so booth.html's asks panel never
rendered there — a valid ask (emmie-anchor/anchor.ask.json) was listed by the
CLI and shown nowhere, with nothing to say so.

- panel extracted to _asks.html; new GET /b/<name>/asks standalone page
- verbatim pages get an amber '? N open asks' chip beside the back chip
- POST /answer honours back=asks so answering returns to that page
- single-question asks now keep an optional 'title' (was silently dropped)
- README + routes table; 8 regression tests; v0.1.12
This commit is contained in:
vh
2026-09-09 10:39:18 -07:00
parent 2a186e4762
commit a56743ade3
9 changed files with 244 additions and 82 deletions
+10 -2
View File
@@ -111,7 +111,8 @@ 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'
{"prompt": "Ship which?",
{"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"}
@@ -160,6 +161,12 @@ Rules of the primitive:
- **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.
- **A booth with its own `index.html` shows a chip, not the panel.** That page is
served verbatim by design, so the inline panel cannot appear on it: the Booth
injects an amber `? N open asks` chip (next to the back chip) linking to
**`/b/<name>/asks`**, a standalone page carrying the real forms. Answering
there returns there. Put the media in the booth and the ask beside it either
way — the ask is never lost, whichever shape the booth takes.
- 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.
@@ -213,7 +220,8 @@ to a safe basename (no path traversal).
| `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`) |
| `POST /b/<name>/answer` | Answer an ask (form fields `ask` = stem, `choice` = option id, `notes`); writes `<stem>.answer.json`, 303 back to the booth |
| `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) |
+51 -5
View File
@@ -393,6 +393,29 @@ _BACK_CHIP = (
"@media print{.booth-nav-home{display:none}}</style>"
)
# A booth's own index.html is served VERBATIM, so the asks panel — which lives in
# the auto-gallery template — can never appear on it. Without this chip an ask
# posted into a custom-report booth is INVISIBLE to the operator with nothing to
# say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page
# showed nothing). Same injection mechanism as the back chip; it links to the
# standalone /asks page, which renders the real forms.
def asks_chip(name: str, open_count: int) -> str:
if open_count < 1:
return ""
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
return (
f'<a href="/b/{quote(name, safe="")}/asks" class="booth-nav-asks">{label}</a>'
"<style>.booth-nav-asks{position:fixed;top:0;right:7.2rem;z-index:2147483647;"
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
"font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
"color:#171a23;text-decoration:none;letter-spacing:.01em;"
"background:#ffe14e;border:1px solid #ffe14e;border-radius:8px;"
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:filter .18s}"
".booth-nav-asks:hover{filter:brightness(1.08)}"
"@media print{.booth-nav-asks{display:none}}</style>"
)
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped)
_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
@@ -417,7 +440,7 @@ def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bo
return html, False
def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK) -> str:
def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK, extra: str = "") -> str:
"""Inject a floating 'all booths' back-chip — and the Booth favicon, if the page
declares none — into a booth's verbatim index.html, without altering the page's
rendered content.
@@ -443,12 +466,13 @@ def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK) -> str:
else:
html = favicon_link + html # bare fragment, no doctype: safe to prepend
chips = _BACK_CHIP + (extra or "")
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
html, done = _insert_before(html, pat, _BACK_CHIP)
html, done = _insert_before(html, pat, chips)
if done:
break
else:
html = html + _BACK_CHIP # no </body>/</html>: append to the end
html = html + chips # no </body>/</html>: append to the end
return html
@@ -612,8 +636,12 @@ def create_app(
# a pathological large file falls back to serving raw, unwrapped.
try:
if own_index.stat().st_size <= WRAP_MAX_BYTES:
open_asks = sum(1 for a in list_asks(booth) if a["answer"] is None and not a["error"])
return HTMLResponse(
wrap_verbatim_html(own_index.read_text(encoding="utf-8", errors="replace"))
wrap_verbatim_html(
own_index.read_text(encoding="utf-8", errors="replace"),
extra=asks_chip(name, open_asks),
)
)
except OSError:
pass
@@ -681,7 +709,25 @@ def create_app(
write_answer(booth, ask, form.get("choice"), form.get("notes", ""), who=who)
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return RedirectResponse(url=f"/b/{quote(name, safe='')}/#ask-{quote(ask, safe='')}", status_code=303)
# Land where the form was: the standalone /asks page for a verbatim booth
# (its own index.html cannot show the recorded answer), else the booth.
base = f"/b/{quote(name, safe='')}/"
if form.get("back") == "asks":
base = f"/b/{quote(name, safe='')}/asks"
return RedirectResponse(url=f"{base}#ask-{quote(ask, safe='')}", status_code=303)
@app.get("/b/{name}/asks", response_class=HTMLResponse)
def booth_asks_page(request: Request, name: str):
"""The asks panel on its own page. Reachable from any booth, and the ONLY
place a verbatim-index.html booth can show its asks — that page is served
untouched by design, so the inline panel never renders there."""
booth = resolve_booth(name)
return templates.TemplateResponse(
request,
"asks.html",
{**base_ctx, "name": name, "name_url": quote(name, safe=""),
"asks": list_asks(booth), "asks_page": True},
)
@app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str):
+7 -1
View File
@@ -189,10 +189,16 @@ def normalize_ask(raw: dict, stem: str) -> dict:
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": 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
+80
View File
@@ -0,0 +1,80 @@
{# 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 %} is-answered{% 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 %}✓ answered{% 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 %}<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">{{ qa.label if qa else '—' }}</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 }}" required
{% if qa and qa.choice == o.id %}checked{% endif %}>
<span class="ask-opt-main">
<span class="ask-opt-label">{{ o.label }}</span>
{% if o.detail %}<span class="ask-opt-detail">{{ o.detail }}</span>{% endif %}
</span>
</label>
{% endfor %}
</div>
{% if q.notes %}
<textarea class="ask-notes ask-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
{% endif %}
</fieldset>
{% endfor %}
{% if a.notes %}
<textarea class="ask-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %}
<div class="ask-actions">
<button type="submit" class="ask-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
</div>
</form>
</details>
{% endif %}
</article>
{% endfor %}
</section>
+19
View File
@@ -0,0 +1,19 @@
{% 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 %}
+2
View File
@@ -275,6 +275,8 @@
.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);
+1 -73
View File
@@ -26,79 +26,7 @@
{% endif %}
{% if asks %}
{# 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 %} is-answered{% 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 %}✓ answered{% 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 %}<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 %}
<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">{{ qa.label if qa else '—' }}</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 }}">
{% 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 }}" required
{% 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>
{% include "_asks.html" %}
{% endif %}
{% if board %}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "booth"
version = "0.1.10"
version = "0.1.12"
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 = [
+73
View File
@@ -308,3 +308,76 @@ def test_write_ask_accepts_full_doc(tmp_path):
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_gets_an_asks_chip(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 "booth-nav-asks" in html # ...with a chip pointing at the asks
assert "1 open ask" in html
assert "/b/b/asks" 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