feat(booth): asks render INLINE in a verbatim report, placed by the author

Operator verdict on the separate /asks page: the question belongs with the
artifact it is about. A four-voice audition wants each voice's radio group
under that voice's audio, and one submit for the lot.

- booth/inline.py: data-booth-ask="stem" | "stem:key" | data-booth-ask-submit,
  plus <!-- booth:ask ... --> comments; unknown stem left alone, not blanked
- _ask_inline.html: self-contained fragments (own scoped styles, no JS), per-question
  groups bound to one form via the HTML5 form= attribute so a scattered
  multi-question ask still POSTs once
- unplaced questions and a missing submit block are appended, so a partially
  marked-up page can never produce an unsubmittable 400
- chip becomes a jump link to the first open ask; /asks page kept as a fallback
- 6 tests (one caught the partial-placement drop); v0.1.14
This commit is contained in:
vh
2026-09-09 14:16:03 -07:00
parent f99faabb80
commit d7361e8b44
6 changed files with 420 additions and 21 deletions
+84 -9
View File
@@ -86,6 +86,11 @@ from booth.asks import ( # noqa: E402
valid_stem,
write_answer,
)
from booth.inline import ( # noqa: E402
form_id as ask_form_id,
has_placeholders,
place as place_asks,
)
from booth.links import ( # noqa: E402
LINK_LOCK,
LINKS_FILE,
@@ -399,12 +404,13 @@ _BACK_CHIP = (
# 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:
def asks_chip(name: str, open_count: int, href: str | None = None) -> str:
if open_count < 1:
return ""
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
href = href or f"/b/{quote(name, safe='')}/asks"
return (
f'<a href="/b/{quote(name, safe="")}/asks" class="booth-nav-asks">{label}</a>'
f'<a href="{href}" 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;"
@@ -636,13 +642,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"),
extra=asks_chip(name, open_asks),
)
)
raw = own_index.read_text(encoding="utf-8", errors="replace")
# Asks render INLINE, where the report author put them (or
# appended, if they marked nothing) — a question about an
# artifact belongs beside that artifact, not on another page.
body, tail = inject_asks(name, booth, raw)
return HTMLResponse(wrap_verbatim_html(body, extra=tail))
except OSError:
pass
return FileResponse(str(own_index), media_type="text/html")
@@ -716,6 +721,76 @@ def create_app(
base = f"/b/{quote(name, safe='')}/asks"
return RedirectResponse(url=f"{base}#ask-{quote(ask, safe='')}", status_code=303)
_frag = templates.env.get_template("_ask_inline.html").module
def inject_asks(name: str, booth: Path, html: str) -> tuple[str, str]:
"""(body, tail) for a verbatim booth: placeholders substituted in place,
and whatever still has to be appended before </body>.
Marked-up pages get each fragment exactly where the author put it. An
unmarked page gets the whole ask appended — an ask is NEVER invisible,
which is the guarantee; markup only moves it somewhere better. A stem
whose questions were placed but whose submit block was not gets that
block appended, so a scattered form is always submittable.
"""
asks = list_asks(booth)
if not asks:
return html, ""
url = quote(name, safe="")
seen: set[str] = set()
def render(kind: str, ask: dict, key: str | None) -> str:
fid = ask_form_id(ask["stem"])
if kind == "whole":
frag = str(_frag.whole(ask, fid, url))
elif kind == "submit":
frag = str(_frag.submit(ask, fid, url))
else:
q = next(q for q in ask["questions"] if q.get("key") == key)
frag = str(_frag.question(ask, q, fid, url))
# An anchor on the FIRST fragment of each stem, wherever it landed,
# so the floating chip can jump to it on a long report. Computed
# here rather than in the macros because only the caller knows
# which fragment came first.
if ask["stem"] not in seen:
seen.add(ask["stem"])
frag = f'<a id="bk-ask-{ask["stem"]}-top"></a>' + frag
return frag
tail = [str(_frag.styles())]
if has_placeholders(html):
html, placed, submitted = place_asks(html, asks, render)
for a in asks:
keys = placed.get(a["stem"])
if keys is None:
tail.append(render("whole", a, None)) # unmarked: never dropped
continue
if a["error"]:
continue
if None not in keys:
# Partially marked up: append every question the author did
# NOT place. A multi-question ask needs all of them or the
# POST is a 400 — met only after the operator fills it in.
for q in a["questions"]:
if q.get("key") not in keys:
tail.append(render("question", a, q.get("key")))
if a["stem"] not in submitted:
tail.append(render("submit", a, None)) # scattered but submittable
else:
for a in asks:
tail.append(render("whole", a, None))
# The chip is now a JUMP LINK to the inline block, not a way out to a
# separate page: on a long report the question can be well below the
# fold, and "there is a question waiting" still has to be visible at
# first paint.
first_open = next((a for a in asks if a["answer"] is None and not a["error"]), None)
open_n = sum(1 for a in asks if a["answer"] is None and not a["error"])
if first_open is not None:
tail.append(asks_chip(name, open_n, href=f'#bk-ask-{first_open["stem"]}-top'))
return html, "".join(tail)
@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