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:
@@ -161,12 +161,32 @@ Rules of the primitive:
|
|||||||
- **A broken ask is shown as broken**, not hidden: if the JSON does not
|
- **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
|
validate, the page says why, so a session never thinks it posted a question
|
||||||
the operator cannot see.
|
the operator cannot see.
|
||||||
- **A booth with its own `index.html` shows a chip, not the panel.** That page is
|
- **A booth with its own `index.html` gets the ask INLINE, where you put it.**
|
||||||
served verbatim by design, so the inline panel cannot appear on it: the Booth
|
That page is served verbatim, so the Booth substitutes placeholders in your
|
||||||
injects an amber `? N open asks` chip (next to the back chip) linking to
|
markup rather than rendering the panel above a gallery it does not have:
|
||||||
**`/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
|
```html
|
||||||
way — the ask is never lost, whichever shape the booth takes.
|
<div data-booth-ask="anchors"></div> <!-- the whole ask: every question + submit -->
|
||||||
|
<div data-booth-ask="anchors:lawson"></div> <!-- just that one question's radios -->
|
||||||
|
<div data-booth-ask-submit="anchors"></div> <!-- the notes field + submit button -->
|
||||||
|
<!-- booth:ask anchors:lawson --> <!-- comment form, same thing -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-question fragments bind to **one** form with the HTML5 `form=` attribute,
|
||||||
|
so a four-voice audition can put each radio group under that voice's audio and
|
||||||
|
still submit all four picks in a single POST — which is what a multi-question
|
||||||
|
ask requires. The fragments ship their own scoped styles and inherit nothing
|
||||||
|
from your page. No JavaScript.
|
||||||
|
|
||||||
|
⚠ Put the placeholder **outside** any CSS grid or flex container, or it
|
||||||
|
becomes a cell in it. A sibling of the block it belongs to is right.
|
||||||
|
|
||||||
|
Placement is optional: a page with no placeholders gets the whole ask appended
|
||||||
|
at the end, so an ask is never invisible — markup only moves it somewhere
|
||||||
|
better. Mark up some questions and not others and the rest are appended too,
|
||||||
|
because a multi-question form that is missing a question is a 400 the operator
|
||||||
|
would only meet after filling it in. The amber chip stays as a jump link to the
|
||||||
|
first open ask, and `/b/<name>/asks` still renders every ask on its own page.
|
||||||
- Ask/answer files are not gallery items and do not count toward the booth's
|
- 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
|
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.
|
booth's mtime, so it lives another TTL — the session has 24h to read it.
|
||||||
|
|||||||
+84
-9
@@ -86,6 +86,11 @@ from booth.asks import ( # noqa: E402
|
|||||||
valid_stem,
|
valid_stem,
|
||||||
write_answer,
|
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
|
from booth.links import ( # noqa: E402
|
||||||
LINK_LOCK,
|
LINK_LOCK,
|
||||||
LINKS_FILE,
|
LINKS_FILE,
|
||||||
@@ -399,12 +404,13 @@ _BACK_CHIP = (
|
|||||||
# say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page
|
# 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
|
# showed nothing). Same injection mechanism as the back chip; it links to the
|
||||||
# standalone /asks page, which renders the real forms.
|
# 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:
|
if open_count < 1:
|
||||||
return ""
|
return ""
|
||||||
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
|
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
|
||||||
|
href = href or f"/b/{quote(name, safe='')}/asks"
|
||||||
return (
|
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;"
|
"<style>.booth-nav-asks{position:fixed;top:0;right:7.2rem;z-index:2147483647;"
|
||||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
"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;"
|
"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.
|
# a pathological large file falls back to serving raw, unwrapped.
|
||||||
try:
|
try:
|
||||||
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
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"])
|
raw = own_index.read_text(encoding="utf-8", errors="replace")
|
||||||
return HTMLResponse(
|
# Asks render INLINE, where the report author put them (or
|
||||||
wrap_verbatim_html(
|
# appended, if they marked nothing) — a question about an
|
||||||
own_index.read_text(encoding="utf-8", errors="replace"),
|
# artifact belongs beside that artifact, not on another page.
|
||||||
extra=asks_chip(name, open_asks),
|
body, tail = inject_asks(name, booth, raw)
|
||||||
)
|
return HTMLResponse(wrap_verbatim_html(body, extra=tail))
|
||||||
)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return FileResponse(str(own_index), media_type="text/html")
|
return FileResponse(str(own_index), media_type="text/html")
|
||||||
@@ -716,6 +721,76 @@ def create_app(
|
|||||||
base = f"/b/{quote(name, safe='')}/asks"
|
base = f"/b/{quote(name, safe='')}/asks"
|
||||||
return RedirectResponse(url=f"{base}#ask-{quote(ask, safe='')}", status_code=303)
|
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)
|
@app.get("/b/{name}/asks", response_class=HTMLResponse)
|
||||||
def booth_asks_page(request: Request, name: str):
|
def booth_asks_page(request: Request, name: str):
|
||||||
"""The asks panel on its own page. Reachable from any booth, and the ONLY
|
"""The asks panel on its own page. Reachable from any booth, and the ONLY
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
"""Inline ask placement inside a booth's VERBATIM index.html.
|
||||||
|
|
||||||
|
A booth that ships its own `index.html` is served untouched, so the auto-gallery
|
||||||
|
template's asks panel never renders there. The first fix was a chip linking to a
|
||||||
|
separate `/asks` page; the operator's verdict on that (2026-09-09) was that the
|
||||||
|
question belongs WITH the artifacts it is about — a four-voice audition wants the
|
||||||
|
radio group for each voice under that voice's audio, not on another page.
|
||||||
|
|
||||||
|
So the report author marks where each piece goes, with a placeholder element:
|
||||||
|
|
||||||
|
<div data-booth-ask="anchors"></div> the whole ask: every question + submit
|
||||||
|
<div data-booth-ask="anchors:lawson"></div> just that question's radios
|
||||||
|
<div data-booth-ask-submit="anchors"></div> the notes field + submit button
|
||||||
|
|
||||||
|
Per-question fragments bind to ONE form via the HTML5 `form=` attribute, so four
|
||||||
|
groups scattered down a page still submit as a single POST — which is what a
|
||||||
|
multi-question ask requires (every question or 400). No JavaScript.
|
||||||
|
|
||||||
|
An `<!-- booth:ask anchors -->` comment works the same way, for authors who would
|
||||||
|
rather not put an empty div in their markup.
|
||||||
|
|
||||||
|
Placement is OPTIONAL. A page with no placeholders gets the whole ask appended at
|
||||||
|
the end of its body, so an ask is never invisible — that guarantee is the point,
|
||||||
|
and marking it up only moves it somewhere better.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# <div data-booth-ask="stem"></div> / <span data-booth-ask="stem:key"></span>
|
||||||
|
_EL_RE = re.compile(
|
||||||
|
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask=\"(?P<spec>[^\"]+)\"[^>]*?>"
|
||||||
|
r"(?:\s*</(?P=tag)\s*>)?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_SUBMIT_EL_RE = re.compile(
|
||||||
|
r"<(?P<tag>[A-Za-z][\w-]*)\b[^>]*?\bdata-booth-ask-submit=\"(?P<spec>[^\"]+)\"[^>]*?>"
|
||||||
|
r"(?:\s*</(?P=tag)\s*>)?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# <!-- booth:ask stem --> / <!-- booth:ask stem:key --> / <!-- booth:ask-submit stem -->
|
||||||
|
_COMMENT_RE = re.compile(r"<!--\s*booth:ask\s+(?P<spec>[^\s>-][^\s>]*)\s*-->", re.IGNORECASE)
|
||||||
|
_COMMENT_SUBMIT_RE = re.compile(r"<!--\s*booth:ask-submit\s+(?P<spec>[^\s>]+)\s*-->", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def split_spec(spec: str) -> tuple[str, str | None]:
|
||||||
|
"""`"anchors:lawson"` -> `("anchors", "lawson")`; `"anchors"` -> `("anchors", None)`."""
|
||||||
|
stem, sep, key = spec.strip().partition(":")
|
||||||
|
return stem.strip(), (key.strip() or None) if sep else None
|
||||||
|
|
||||||
|
|
||||||
|
def has_placeholders(html: str) -> bool:
|
||||||
|
return bool(
|
||||||
|
_EL_RE.search(html) or _SUBMIT_EL_RE.search(html)
|
||||||
|
or _COMMENT_RE.search(html) or _COMMENT_SUBMIT_RE.search(html)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def form_id(stem: str) -> str:
|
||||||
|
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
||||||
|
|
||||||
|
|
||||||
|
def place(html: str, asks: list[dict], render) -> tuple[str, dict[str, set], set[str]]:
|
||||||
|
"""Substitute every placeholder with rendered ask HTML.
|
||||||
|
|
||||||
|
`render(kind, ask, key)` returns the fragment for kind in
|
||||||
|
{"whole", "question", "submit"}. Returns the new html; a map of stem ->
|
||||||
|
the set of question keys placed inline (with `None` in the set meaning the
|
||||||
|
WHOLE ask was placed); and the set of stems whose submit block was placed
|
||||||
|
explicitly.
|
||||||
|
|
||||||
|
The caller needs the per-key detail, not just "this stem appeared
|
||||||
|
somewhere": a multi-question ask requires EVERY question on submit, so a
|
||||||
|
page that marks up two of four questions must still be handed the other two
|
||||||
|
or the form is unsubmittable — a 400 the operator would meet only after
|
||||||
|
filling it in.
|
||||||
|
|
||||||
|
A placeholder naming an ask this booth does not have is left ALONE, not
|
||||||
|
blanked: silently eating the author's markup would hide a typo'd stem, and
|
||||||
|
an untouched empty div is invisible anyway.
|
||||||
|
"""
|
||||||
|
by_stem = {a["stem"]: a for a in asks}
|
||||||
|
placed: dict[str, set] = {}
|
||||||
|
submitted: set[str] = set()
|
||||||
|
|
||||||
|
def sub_main(m: re.Match) -> str:
|
||||||
|
stem, key = split_spec(m.group("spec"))
|
||||||
|
ask = by_stem.get(stem)
|
||||||
|
if ask is None:
|
||||||
|
return m.group(0)
|
||||||
|
if key is None:
|
||||||
|
placed.setdefault(stem, set()).add(None)
|
||||||
|
submitted.add(stem)
|
||||||
|
return render("whole", ask, None)
|
||||||
|
q = next((q for q in ask.get("questions", []) if q.get("key") == key), None)
|
||||||
|
if q is None:
|
||||||
|
return m.group(0)
|
||||||
|
placed.setdefault(stem, set()).add(key)
|
||||||
|
return render("question", ask, key)
|
||||||
|
|
||||||
|
def sub_submit(m: re.Match) -> str:
|
||||||
|
stem, _ = split_spec(m.group("spec"))
|
||||||
|
ask = by_stem.get(stem)
|
||||||
|
if ask is None:
|
||||||
|
return m.group(0)
|
||||||
|
placed.setdefault(stem, set())
|
||||||
|
submitted.add(stem)
|
||||||
|
return render("submit", ask, None)
|
||||||
|
|
||||||
|
for pat, fn in ((_EL_RE, sub_main), (_COMMENT_RE, sub_main),
|
||||||
|
(_SUBMIT_EL_RE, sub_submit), (_COMMENT_SUBMIT_RE, sub_submit)):
|
||||||
|
html = pat.sub(fn, html)
|
||||||
|
return html, placed, submitted
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{# Self-contained ask fragments injected into a booth's VERBATIM index.html.
|
||||||
|
|
||||||
|
The page is served untouched and carries its own CSS, so nothing here may
|
||||||
|
inherit from base.html: every fragment ships its own scoped `.bk-ask-*`
|
||||||
|
styles (emitted once, by `styles()`), and the palette adapts via
|
||||||
|
prefers-color-scheme rather than borrowing the host page's.
|
||||||
|
|
||||||
|
Per-question fragments are wired to ONE form with the HTML5 `form=`
|
||||||
|
attribute, so a four-voice report can put each radio group under its own
|
||||||
|
audio block and still submit all four picks in a single POST — which is what
|
||||||
|
the multi-question ask requires. The <form> element itself is empty and
|
||||||
|
lives with the submit block. No JavaScript.
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% macro styles() %}
|
||||||
|
<style>
|
||||||
|
.bk-ask{margin:1.1rem 0;padding:.85rem .95rem;border:1px solid rgba(128,140,160,.34);
|
||||||
|
border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);
|
||||||
|
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||||
|
.bk-ask.bk-done{border-top-color:#3fae6a}
|
||||||
|
.bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||||
|
letter-spacing:.12em;text-transform:uppercase;color:#c9a227}
|
||||||
|
.bk-ask.bk-done .bk-ask-tag{color:#3fae6a}
|
||||||
|
.bk-ask-title{margin:0 0 .15rem;font-size:.72rem;letter-spacing:.07em;text-transform:uppercase;opacity:.62}
|
||||||
|
.bk-ask-prompt{margin:0 0 .6rem;font-weight:600}
|
||||||
|
.bk-ask-opts{display:flex;flex-direction:column;gap:.3rem}
|
||||||
|
.bk-ask-opt{display:flex;align-items:flex-start;gap:.55rem;padding:.45rem .6rem;cursor:pointer;
|
||||||
|
border:1px solid rgba(128,140,160,.3);border-radius:6px;background:rgba(128,140,160,.06)}
|
||||||
|
.bk-ask-opt:hover{border-color:rgba(128,140,160,.62)}
|
||||||
|
.bk-ask-opt:has(input:checked){border-color:#2fa8a0;background:rgba(47,168,160,.13)}
|
||||||
|
.bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:#2fa8a0}
|
||||||
|
.bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}
|
||||||
|
.bk-ask-det{font-size:.8rem;opacity:.68}
|
||||||
|
.bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
|
||||||
|
font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);
|
||||||
|
border:1px solid rgba(128,140,160,.34);border-radius:6px;resize:vertical}
|
||||||
|
.bk-ask-go{margin-top:.7rem;cursor:pointer;font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||||
|
letter-spacing:.06em;padding:.6rem 1.1rem;border-radius:6px;border:1px solid #2fa8a0;
|
||||||
|
background:#2fa8a0;color:#08131a}
|
||||||
|
.bk-ask-go:hover{filter:brightness(1.09)}
|
||||||
|
.bk-ask-was{margin:.15rem 0 .55rem;font-size:.84rem;opacity:.8}
|
||||||
|
.bk-ask-was b{opacity:1}
|
||||||
|
.bk-ask-err{color:#d6452a;font-size:.86rem}
|
||||||
|
@media (prefers-color-scheme: light){
|
||||||
|
.bk-ask-tag{color:#8a6d10}
|
||||||
|
.bk-ask-go{color:#fff}
|
||||||
|
}
|
||||||
|
@media print{.bk-ask{break-inside:avoid}}
|
||||||
|
</style>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# One question's radio group, bound to the shared form by id. #}
|
||||||
|
{% macro question(a, q, form_id, name_url, standalone=False) %}
|
||||||
|
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||||||
|
{% set qa = (a.answer.answers.get(q.key) if a.multi else a.answer) if a.answer else None %}
|
||||||
|
<div class="bk-ask{% if qa %} bk-done{% endif %}" id="bk-ask-{{ a.stem }}{% if q.key %}-{{ q.key }}{% endif %}">
|
||||||
|
<span class="bk-ask-tag">{% if qa %}✓ answered{% else %}? your pick{% endif %}</span>
|
||||||
|
<p class="bk-ask-prompt">{{ q.prompt }}</p>
|
||||||
|
{% if qa %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>{% endif %}
|
||||||
|
<div class="bk-ask-opts">
|
||||||
|
{% for o in q.options %}
|
||||||
|
<label class="bk-ask-opt">
|
||||||
|
<input type="radio" name="{{ field }}" value="{{ o.id }}" required
|
||||||
|
{% if not standalone %}form="{{ form_id }}"{% endif %}
|
||||||
|
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
||||||
|
<span class="bk-ask-lab"><span>{{ o.label }}</span>
|
||||||
|
{% if o.detail %}<span class="bk-ask-det">{{ o.detail }}</span>{% endif %}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if q.notes %}
|
||||||
|
<textarea class="bk-ask-notes" name="notes.{{ q.key }}" rows="2"
|
||||||
|
{% if not standalone %}form="{{ form_id }}"{% endif %}
|
||||||
|
placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# The form element + hidden fields + overall notes + submit. Empty <form> on
|
||||||
|
purpose: the question groups above bind to it by id from wherever they sit. #}
|
||||||
|
{% macro submit(a, form_id, name_url) %}
|
||||||
|
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.stem }}-submit">
|
||||||
|
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
|
||||||
|
<input type="hidden" name="ask" value="{{ a.stem }}" form="{{ form_id }}">
|
||||||
|
<span class="bk-ask-tag">{% if a.answer %}✓ answered {{ a.answer.answered_at }}{% else %}? submit your picks{% endif %}</span>
|
||||||
|
{% if a.notes %}
|
||||||
|
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
|
||||||
|
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="bk-ask-go" form="{{ form_id }}">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# The whole ask as one self-contained block: title, every question, submit. #}
|
||||||
|
{% macro whole(a, form_id, name_url) %}
|
||||||
|
{% if a.error %}
|
||||||
|
<div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
|
||||||
|
<p class="bk-ask-err">{{ a.stem }}.ask.json could not be read: {{ a.error }}</p></div>
|
||||||
|
{% else %}
|
||||||
|
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.stem }}">{{ a.title }}</p>{% endif %}
|
||||||
|
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
|
||||||
|
{{ submit(a, form_id, name_url) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "booth"
|
name = "booth"
|
||||||
version = "0.1.12"
|
version = "0.1.14"
|
||||||
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."
|
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"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+91
-5
@@ -318,15 +318,17 @@ def test_write_ask_accepts_full_doc(tmp_path):
|
|||||||
# into the verbatim page plus a standalone /asks page that carries the forms.
|
# into the verbatim page plus a standalone /asks page that carries the forms.
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_booth_gets_an_asks_chip(client):
|
def test_verbatim_booth_renders_the_ask_inline(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _ask(data / "b")
|
b = _ask(data / "b")
|
||||||
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
|
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
|
||||||
html = c.get("/b/b/").text
|
html = c.get("/b/b/").text
|
||||||
assert "hi" in html # the report is still served verbatim
|
assert "hi" in html # the report is still served verbatim
|
||||||
assert "booth-nav-asks" in html # ...with a chip pointing at the asks
|
assert "Which render wins?" in html # ...with the ask ON it, not elsewhere
|
||||||
assert "1 open ask" in html
|
assert 'type="radio"' in html and 'action="/b/b/answer"' in html
|
||||||
assert "/b/b/asks" in html
|
assert "bk-ask" in html # self-contained fragment styles
|
||||||
|
assert "booth-nav-asks" in html # chip remains, as a jump link
|
||||||
|
assert "#bk-ask-winner-top" in html
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_chip_disappears_once_answered(client):
|
def test_verbatim_chip_disappears_once_answered(client):
|
||||||
@@ -381,3 +383,87 @@ def test_asks_page_shows_a_single_ask_title(client):
|
|||||||
c, data = client
|
c, data = client
|
||||||
_ask(data / "b", title="emmie — pick the anchor")
|
_ask(data / "b", title="emmie — pick the anchor")
|
||||||
assert "emmie — pick the anchor" in c.get("/b/b/asks").text
|
assert "emmie — pick the anchor" in c.get("/b/b/asks").text
|
||||||
|
|
||||||
|
|
||||||
|
# ---- inline placement in a verbatim report -----------------------------------
|
||||||
|
#
|
||||||
|
# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be
|
||||||
|
# inline with the artifacts, not on a separate page." A four-voice audition wants
|
||||||
|
# each voice's radio group under that voice's audio, and one submit for the lot.
|
||||||
|
|
||||||
|
|
||||||
|
REPORT = """<!doctype html><title>audition</title><body>
|
||||||
|
<h1>Three voices</h1>
|
||||||
|
<section id="lawson"><audio src="a.wav"></audio>
|
||||||
|
<div data-booth-ask="batch:r1"></div></section>
|
||||||
|
<section id="jo"><audio src="b.wav"></audio>
|
||||||
|
<!-- booth:ask batch:r2 --></section>
|
||||||
|
<div data-booth-ask-submit="batch"></div>
|
||||||
|
</body>"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_question_placeholders_land_where_the_author_put_them(client):
|
||||||
|
c, data = client
|
||||||
|
b = _multi(data / "b")
|
||||||
|
(b / "index.html").write_text(REPORT)
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
# each group is inside its own section, in document order
|
||||||
|
lawson = html.index('id="lawson"')
|
||||||
|
jo = html.index('id="jo"')
|
||||||
|
assert lawson < html.index('name="choice.r1"') < jo
|
||||||
|
assert jo < html.index('name="choice.r2"')
|
||||||
|
# one shared form, bound by the HTML5 form= attribute, submitted once
|
||||||
|
assert html.count('<form id="bk-ask-form-batch"') == 1
|
||||||
|
assert html.count('action="/b/b/answer"') == 1
|
||||||
|
assert html.count('form="bk-ask-form-batch"') >= 4
|
||||||
|
# the submit block landed at its own placeholder, not appended after </body>
|
||||||
|
assert html.index("bk-ask-form-batch") < html.index("</body>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_form_submits_every_question_in_one_post(client):
|
||||||
|
c, data = client
|
||||||
|
b = _multi(data / "b")
|
||||||
|
(b / "index.html").write_text(REPORT)
|
||||||
|
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
|
||||||
|
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
ans = read_answer(b, "batch")
|
||||||
|
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
||||||
|
# and the recorded pick now shows inline, on the report itself
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert "recorded:" in html and "bk-done" in html
|
||||||
|
assert 'value="keep" required checked' in html.replace("\n", " ") or "checked" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_ask_placeholder_renders_everything_there(client):
|
||||||
|
c, data = client
|
||||||
|
b = _ask(data / "b")
|
||||||
|
(b / "index.html").write_text('<!doctype html><body><p>x</p><div data-booth-ask="winner"></div></body>')
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert html.index("Which render wins?") > html.index("<p>x</p>")
|
||||||
|
assert html.index("bk-ask-go") < html.index("</body>") # submit placed inline too
|
||||||
|
|
||||||
|
|
||||||
|
def test_placeholder_for_a_missing_ask_is_left_alone(client):
|
||||||
|
c, data = client
|
||||||
|
b = _ask(data / "b")
|
||||||
|
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="typo"></div></body>')
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert 'data-booth-ask="typo"' in html # author's markup untouched, not blanked
|
||||||
|
assert "Which render wins?" in html # the real ask still appended, never lost
|
||||||
|
|
||||||
|
|
||||||
|
def test_questions_placed_without_a_submit_still_get_one(client):
|
||||||
|
c, data = client
|
||||||
|
b = _multi(data / "b")
|
||||||
|
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch:r1"></div></body>')
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert html.count('<form id="bk-ask-form-batch"') == 1 # appended, so it is submittable
|
||||||
|
assert 'name="choice.r2"' in html # r2 unplaced -> must still appear
|
||||||
|
|
||||||
|
|
||||||
|
def test_styles_are_emitted_once(client):
|
||||||
|
c, data = client
|
||||||
|
b = _multi(data / "b")
|
||||||
|
(b / "index.html").write_text(REPORT)
|
||||||
|
assert c.get("/b/b/").text.count(".bk-ask-opt:has(input:checked)") == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user