diff --git a/services/booth/README.md b/services/booth/README.md index 72a0372..302b163 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -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//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//` | A booth (its `index.html`, else auto-gallery) | | `GET /b//` | Serve a file out of the booth | | `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b//` (id in `Location`) | -| `POST /b//answer` | Answer an ask (form fields `ask` = stem, `choice` = option id, `notes`); writes `.answer.json`, 303 back to the booth | +| `GET /b//asks` | The asks panel on its own page — the only place a verbatim-`index.html` booth can show its asks | +| `POST /b//answer` | Answer an ask (fields `ask` = stem, `choice`/`choice.`, `notes`/`notes.`, `back`); writes `.answer.json`, 303 back | | `POST /b//delete` | Wipe a booth (the UI's "Wipe now" button) | | `POST /b//keep` | Pin a booth — exempt from the sweep | | `POST /b//unkeep` | Release the pin (the UI's "release" button on kept cards) | diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index a4bd335..aa508b6 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -393,6 +393,29 @@ _BACK_CHIP = ( "@media print{.booth-nav-home{display:none}}" ) +# 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'{label}' + "" + ) + + WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped) _ICON_RE = re.compile(r"]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE) @@ -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 /: append to the end + html = html + chips # no /: 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): diff --git a/services/booth/booth/asks.py b/services/booth/booth/asks.py index b73ff56..56d2885 100644 --- a/services/booth/booth/asks.py +++ b/services/booth/booth/asks.py @@ -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 diff --git a/services/booth/booth/templates/_asks.html b/services/booth/booth/templates/_asks.html new file mode 100644 index 0000000..f215c15 --- /dev/null +++ b/services/booth/booth/templates/_asks.html @@ -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 + (`.ask.json`). Open ones render as a radio form; answering POSTs to + /answer, which writes `.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. #} +
+ {% for a in asks %} +
+
+ {% if a.error %}⚠ broken{% elif a.answer %}✓ answered{% else %}? open{% endif %} + {{ a.stem }}.ask.json{% if a.multi %} · {{ a.questions|length }} questions{% endif %} + + {% if a.answer %}{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}{% endif %} +
+ {% if a.error %} +

This ask could not be read: {{ a.error }}

+ {% else %} + {% if a.title and not a.multi %}

{{ a.title }}

{% endif %} +

{{ a.prompt }}

+ {% if a.answer %} +
+ {% if a.multi %} + {% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %} +
+ {{ q.prompt }} +
{{ qa.label if qa else '—' }}
+ {% if qa and qa.notes %}
{{ qa.notes }}
{% endif %} +
+ {% endfor %} + {% else %} +
{{ a.answer.label }}
+ {% endif %} + {% if a.answer.notes %}
{{ a.answer.notes }}
{% endif %} + → {{ a.stem }}.answer.json +
+ {% endif %} +
+ {% if a.answer %}change answer{% else %}answer{% endif %} +
+ + {# 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 %}{% 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 %} +
+ {% if a.multi %}{{ loop.index }}. {{ q.prompt }}{% endif %} +
+ {% for o in q.options %} + + {% endfor %} +
+ {% if q.notes %} + + {% endif %} +
+ {% endfor %} + {% if a.notes %} + + {% endif %} +
+ +
+
+
+ {% endif %} +
+ {% endfor %} +
diff --git a/services/booth/booth/templates/asks.html b/services/booth/booth/templates/asks.html new file mode 100644 index 0000000..bc2fc36 --- /dev/null +++ b/services/booth/booth/templates/asks.html @@ -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. #} +
+ ‹ {{ name }} +

Asks

+ {% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %} + {% if open_asks %}{{ open_asks }} open · {% endif %}{{ asks|length }} ask{{ '' if asks|length == 1 else 's' }} +
+{% if asks %} + {% include "_asks.html" %} +{% else %} +
This booth has no asks.
+{% endif %} +{% endblock %} diff --git a/services/booth/booth/templates/base.html b/services/booth/booth/templates/base.html index 6796260..b8c336c 100644 --- a/services/booth/booth/templates/base.html +++ b/services/booth/booth/templates/base.html @@ -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); diff --git a/services/booth/booth/templates/booth.html b/services/booth/booth/templates/booth.html index c5a009d..2cd3de9 100644 --- a/services/booth/booth/templates/booth.html +++ b/services/booth/booth/templates/booth.html @@ -26,79 +26,7 @@ {% endif %} {% if asks %} - {# ASKS. A session left multiple-choice questions here for the operator - (`.ask.json`). Open ones render as a radio form; answering POSTs to - /answer, which writes `.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. #} -
- {% for a in asks %} -
-
- {% if a.error %}⚠ broken{% elif a.answer %}✓ answered{% else %}? open{% endif %} - {{ a.stem }}.ask.json{% if a.multi %} · {{ a.questions|length }} questions{% endif %} - - {% if a.answer %}{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}{% endif %} -
- {% if a.error %} -

This ask could not be read: {{ a.error }}

- {% else %} -

{{ a.prompt }}

- {% if a.answer %} -
- {% if a.multi %} - {% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %} -
- {{ q.prompt }} -
{{ qa.label if qa else '—' }}
- {% if qa and qa.notes %}
{{ qa.notes }}
{% endif %} -
- {% endfor %} - {% else %} -
{{ a.answer.label }}
- {% endif %} - {% if a.answer.notes %}
{{ a.answer.notes }}
{% endif %} - → {{ a.stem }}.answer.json -
- {% endif %} -
- {% if a.answer %}change answer{% else %}answer{% 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 %} -
- {% if a.multi %}{{ loop.index }}. {{ q.prompt }}{% endif %} -
- {% for o in q.options %} - - {% endfor %} -
- {% if q.notes %} - - {% endif %} -
- {% endfor %} - {% if a.notes %} - - {% endif %} -
- -
-
-
- {% endif %} -
- {% endfor %} -
+ {% include "_asks.html" %} {% endif %} {% if board %} diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml index 24156d6..009e053 100644 --- a/services/booth/pyproject.toml +++ b/services/booth/pyproject.toml @@ -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 = [ diff --git a/services/booth/tests/test_asks.py b/services/booth/tests/test_asks.py index 2146756..3986466 100644 --- a/services/booth/tests/test_asks.py +++ b/services/booth/tests/test_asks.py @@ -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("reporthi") + 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("hi") + 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("hi") + 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("hi") + 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