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:
+51
-5
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user