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 047749c3cf
commit f99faabb80
9 changed files with 244 additions and 82 deletions
+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 %}