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
+26 -6
View File
@@ -161,12 +161,32 @@ 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/<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
way — the ask is never lost, whichever shape the booth takes.
- **A booth with its own `index.html` gets the ask INLINE, where you put it.**
That page is served verbatim, so the Booth substitutes placeholders in your
markup rather than rendering the panel above a gallery it does not have:
```html
<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
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.
+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
+114
View File
@@ -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
+104
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
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."
requires-python = ">=3.11"
dependencies = [
+91 -5
View File
@@ -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.
def test_verbatim_booth_gets_an_asks_chip(client):
def test_verbatim_booth_renders_the_ask_inline(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
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
assert "hi" in html # the report is still served verbatim
assert "Which render wins?" in html # ...with the ask ON it, not elsewhere
assert 'type="radio"' in html and 'action="/b/b/answer"' 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):
@@ -381,3 +383,87 @@ 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
# ---- 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