feat(u3): a verbatim report declares the seam, the Booth mounts into it
A booth that ships its own index.html was served through ten regular
expressions applied to markup the Booth did not write: six in
wrap_verbatim_html hunting for somewhere to hang a favicon and a chip, four
in booth/inline.py substituting rendered ask markup into the author's own
tags. Both worked. Both were the most fragile thing in the service, on the
path the operator uses most.
The whole class is replaced by a declared seam. A report carries one line —
<script src="/_booth/embed.js" defer></script> — and the chrome mounts
through DOM APIs. What the server does to author HTML is now, in full:
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
Two substring tests and a concatenation. Both of the old wrapper's hard
constraints stop existing rather than being satisfied more carefully:
nothing can displace a leading doctype into quirks mode and nothing can push
the charset meta out of its detection window, because nothing in front of
them ever moves. A page that declares the seam is served exactly as written.
Fragments are still rendered by the _ask_inline.html macros and handed over
GET /b/<name>/embed.json; embed.js places them and decides nothing. Openness
comes from open_marks, order from (created, id), questions in declaration
order. A single-question pick normalizes to key None, so the payload carries
questions as a list rather than an object — keying by name would serialize
that as the string "null".
Placement is an anchor fill, not a replacement: el.insertAdjacentHTML(
'beforeend'), so an author's wrapper and its contents survive. The regex it
replaces was eating the opening tag of dfa-concepts' styled .ask blocks and
orphaning their headings, live, unreported.
data-booth-mark is canonical; data-booth-ask stays a kept alias because two
live reports use it. The comment placeholders are dropped — no users.
Declared cost: the verbatim path now needs JavaScript. The never-invisible
guarantee holds through the index badge and /b/<name>/marks, both of which
render server-side.
Deleted: booth/inline.py entire, wrap_verbatim_html and its six patterns,
_BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK, the styles() macro.
Tests 410 -> 434. tests/test_embed_browser.py drives a real Chromium: the
placement algorithm and the form= binding of a scattered multi-question form
cannot be observed any other way, and that binding was measured rather than
assumed (N=3 per condition, with a form-first positive control and a
points-at-nothing negative control).
Contract: docs/contracts/u3_declared_embed_seam.contract.md, with the
in-session seam review and the cold contract panel both recorded. Two of the
panel's findings were code fixes: a vacuous INV-3 falsifier that a renamed
regex walked straight through, and a bare-substring seam detection that read
a report merely quoting the path as declaring it and silently served it with
no chrome.
This commit is contained in:
+154
-173
@@ -158,11 +158,6 @@ from booth.marks import ( # noqa: E402
|
||||
set_flag,
|
||||
write_note,
|
||||
)
|
||||
from booth.inline import ( # noqa: E402
|
||||
form_id as ask_form_id,
|
||||
has_placeholders,
|
||||
place as place_asks,
|
||||
)
|
||||
from booth.manifest import ( # noqa: E402
|
||||
MANIFEST_FILE,
|
||||
SERVICE_HANDLE,
|
||||
@@ -545,119 +540,77 @@ def _zip_filename(name: str) -> str:
|
||||
return f"{safe or 'booth'}.zip"
|
||||
|
||||
|
||||
# ---- verbatim-index.html wrapper -------------------------------------------
|
||||
# ---- the declared embed seam (U3) ------------------------------------------
|
||||
|
||||
# Mirror of base.html's favicon (the app templates set it there; this is the copy
|
||||
# injected into a booth's *verbatim* index.html so a raw page inherits the same
|
||||
# icon). Keep the two in sync if the Booth's icon ever changes.
|
||||
# Mirror of base.html's favicon. The app templates set it there; this copy is
|
||||
# what `/b/<name>/embed.json` hands to a VERBATIM report, so a raw page inherits
|
||||
# the same icon. Keep the two in sync if the Booth's icon ever changes.
|
||||
FAVICON_HREF = (
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
|
||||
"%3E%3Crect width='32' height='32' rx='7' fill='%23171a23'/%3E%3Ccircle cx='16' "
|
||||
"cy='16' r='6' fill='none' stroke='%2342dcd1' stroke-width='2.5'/%3E%3Ccircle "
|
||||
"cx='16' cy='16' r='2.2' fill='%2342dcd1'/%3E%3C/svg%3E"
|
||||
)
|
||||
FAVICON_LINK = f'<link rel="icon" href="{FAVICON_HREF}">'
|
||||
|
||||
# A self-contained floating "back to all booths" chip injected into verbatim
|
||||
# booths. Scoped class + fixed positioning + max z-index so it overlays the raw
|
||||
# page without touching its layout; hidden in print so downloaded reports stay clean.
|
||||
_BACK_CHIP = (
|
||||
'<a href="/" class="booth-nav-home" aria-label="back to all booths">‹ all booths</a>'
|
||||
# top-right: empty on left-aligned report layouts (a top-left chip clips the
|
||||
# page title), and consistent with the zoom view's top-right back affordance.
|
||||
"<style>.booth-nav-home{position:fixed;top:0;right:0;z-index:2147483647;"
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
||||
"font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
|
||||
"color:#dfe7ef;text-decoration:none;letter-spacing:.01em;"
|
||||
"background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);border-radius:8px;"
|
||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);"
|
||||
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:background .18s,border-color .18s}"
|
||||
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}"
|
||||
"@media print{.booth-nav-home{display:none}}</style>"
|
||||
)
|
||||
# The seam a verbatim report declares to get the Booth's chrome. ONE line, and
|
||||
# the Booth appends it only when the page has not declared it itself.
|
||||
EMBED_SRC = "/_booth/embed.js"
|
||||
EMBED_SCRIPT_TAG = f'<script src="{EMBED_SRC}" defer></script>'
|
||||
EMBED_JS_PATH = Path(__file__).parent / "static" / "embed.js"
|
||||
|
||||
# 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, 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="{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;"
|
||||
"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>"
|
||||
)
|
||||
# What counts as DECLARING the seam. Two substring tests, one per quote style,
|
||||
# and each requires `src=` immediately before the path.
|
||||
#
|
||||
# The bare path was the first draft and it was wrong in the dangerous
|
||||
# direction. A report that merely MENTIONS `/_booth/embed.js` — in a code
|
||||
# sample, a comment, a sentence about this very feature — would have been read
|
||||
# as declaring it, served untouched, and silently shown no chrome at all. The
|
||||
# Booth's own design reports are exactly the pages that would quote it.
|
||||
#
|
||||
# These tests fail in the harmless direction instead. An unusual spelling
|
||||
# (`src = "…"` with spaces, an unquoted attribute, a `?v=2` suffix) is read as
|
||||
# NOT declared, so a second tag is appended — and embed.js mounts once
|
||||
# regardless, because it guards on `window.__boothEmbed`. A missed declaration
|
||||
# costs a duplicate tag; a false one costs the operator his chrome.
|
||||
_EMBED_DECLARATIONS = (f'src="{EMBED_SRC}"', f"src='{EMBED_SRC}'")
|
||||
|
||||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw
|
||||
|
||||
|
||||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped)
|
||||
def declares_embed(html: str) -> bool:
|
||||
"""Whether a verbatim page already asks for the Booth's chrome.
|
||||
|
||||
_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
|
||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", re.IGNORECASE)
|
||||
_HTML_OPEN_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
|
||||
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
|
||||
_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE)
|
||||
_HTML_CLOSE_RE = re.compile(r"</html\s*>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _insert_before(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
|
||||
m = pattern.search(html)
|
||||
if m:
|
||||
return html[: m.start()] + snippet + html[m.start() :], True
|
||||
return html, False
|
||||
|
||||
|
||||
def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bool]:
|
||||
m = pattern.search(html)
|
||||
if m:
|
||||
return html[: m.end()] + snippet + html[m.end() :], True
|
||||
return html, False
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Robust to the compact HTML real booths use (`<!doctype html><meta charset><title>
|
||||
<style>…content`, no explicit head/body). The two hard constraints:
|
||||
* NEVER put anything ahead of a leading <!doctype> — that forces quirks mode.
|
||||
* Keep the charset <meta> within the first 1024 bytes so it's still honoured.
|
||||
So the favicon lands at the first head-ish seam (before </head>, else after
|
||||
<html>, else right after the doctype — a ~250B link keeps charset in range), and
|
||||
the fixed-position chip is appended at the END of the document (before </body> /
|
||||
</html> or appended), which renders top-left regardless and disturbs nothing.
|
||||
TWO SUBSTRING TESTS. This is the entire detection half of what used to be
|
||||
six regular expressions run against arbitrary author HTML — and the other
|
||||
half, the insertion, is a `+`. See `_EMBED_DECLARATIONS` for why it matches
|
||||
`src="…"` rather than the bare path: both spellings fail toward appending a
|
||||
harmless duplicate rather than toward silently withholding the chrome.
|
||||
"""
|
||||
if favicon_link and not _ICON_RE.search(html):
|
||||
for inserter, pat in (
|
||||
(_insert_before, _HEAD_CLOSE_RE), # inside an explicit <head>
|
||||
(_insert_after, _HTML_OPEN_RE), # top of an explicit <html>
|
||||
(_insert_after, _DOCTYPE_RE), # right after the doctype (compact HTML)
|
||||
):
|
||||
html, done = inserter(html, pat, favicon_link)
|
||||
if done:
|
||||
break
|
||||
else:
|
||||
html = favicon_link + html # bare fragment, no doctype: safe to prepend
|
||||
return any(d in html for d in _EMBED_DECLARATIONS)
|
||||
|
||||
chips = _BACK_CHIP + (extra or "")
|
||||
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
|
||||
html, done = _insert_before(html, pat, chips)
|
||||
if done:
|
||||
break
|
||||
else:
|
||||
html = html + chips # no </body>/</html>: append to the end
|
||||
return html
|
||||
|
||||
def embed_verbatim(html: str) -> str:
|
||||
"""The ONLY thing the Booth does to a verbatim report.
|
||||
|
||||
Appended, never inserted, and never prepended. That is what retires both of
|
||||
the old wrapper's hard constraints rather than satisfying them more
|
||||
carefully: nothing can displace a leading doctype into quirks mode and
|
||||
nothing can push the charset <meta> out of its first-1024-byte detection
|
||||
window, because nothing in front of them moves. Content after `</html>` is
|
||||
parsed into the body by every browser, so there is no seam to find.
|
||||
"""
|
||||
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
|
||||
|
||||
|
||||
def ask_form_id(stem: str) -> str:
|
||||
"""The shared `<form>` id a pick's scattered question groups bind to with
|
||||
the HTML5 `form=` attribute.
|
||||
|
||||
Moved here from `booth/inline.py` when U3 deleted that module: it is not
|
||||
placement machinery, it is what makes four radio groups spread down a report
|
||||
submit as ONE POST, which is what a multi-question ask requires.
|
||||
"""
|
||||
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
||||
|
||||
|
||||
# ---- uploads (browser drop-off for pickup) ---------------------------------
|
||||
@@ -765,6 +718,13 @@ def create_app(
|
||||
env.filters["dur"] = human_dur
|
||||
templates = Jinja2Templates(env=env)
|
||||
|
||||
# embed.js IS READ ONCE, HERE, for exactly the reason above. It is the third
|
||||
# kind of thing this repo serves, and the only one that would otherwise be
|
||||
# free to hot-reload from the deployment root — which is the skew that put
|
||||
# 19 of 25 booths at 500. One rule: nothing takes effect until you restart.
|
||||
embed_js = EMBED_JS_PATH.read_text(encoding="utf-8")
|
||||
embed_etag = '"%s"' % hashlib.sha256(embed_js.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
task = None
|
||||
@@ -855,6 +815,20 @@ def create_app(
|
||||
def healthz():
|
||||
return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
|
||||
|
||||
@app.get(EMBED_SRC)
|
||||
def embed_script():
|
||||
"""The declared seam's one static asset.
|
||||
|
||||
Served from the startup read, with an ETag over its content so a
|
||||
browser revalidates instead of holding a stale copy across a restart —
|
||||
`no-cache` here means "ask me", not "do not store".
|
||||
"""
|
||||
return Response(
|
||||
content=embed_js,
|
||||
media_type="text/javascript; charset=utf-8",
|
||||
headers={"ETag": embed_etag, "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
@app.get("/b/{name}", include_in_schema=False)
|
||||
def booth_redirect(name: str):
|
||||
resolve_booth(name)
|
||||
@@ -877,18 +851,17 @@ def create_app(
|
||||
)
|
||||
own_index = booth / "index.html"
|
||||
if own_index.is_file():
|
||||
# Serve the operator's verbatim report, but inject a floating
|
||||
# back-to-booths chip + the Booth favicon (if it declares none) so a
|
||||
# raw page still has a way home. Small HTML -> read + wrap in memory;
|
||||
# a pathological large file falls back to serving raw, unwrapped.
|
||||
# The operator's verbatim report. U3: the page declares the seam and
|
||||
# the Booth mounts into it — so a page carrying the script tag is
|
||||
# served exactly as written, and one that is not gets that single
|
||||
# line appended. Nothing is parsed, matched or inserted.
|
||||
#
|
||||
# The read is still bounded: a pathological file falls back to
|
||||
# serving raw, which costs it the chrome exactly as it did before.
|
||||
try:
|
||||
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
||||
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))
|
||||
return HTMLResponse(embed_verbatim(raw))
|
||||
except OSError:
|
||||
pass
|
||||
return FileResponse(str(own_index), media_type="text/html")
|
||||
@@ -1098,73 +1071,81 @@ def create_app(
|
||||
|
||||
_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>.
|
||||
def _pick_fragments(name: str, mark) -> dict:
|
||||
"""One pick, rendered into the pieces a page can mount independently.
|
||||
|
||||
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.
|
||||
Rendered HERE, by the same Jinja macros the gallery page uses, so there
|
||||
is exactly ONE renderer of an ask. embed.js places these; it never
|
||||
builds one. A second renderer in JavaScript is the shape INV-1 was
|
||||
written to stop after the zoom view re-derived an item and lost its
|
||||
captions doing it.
|
||||
"""
|
||||
picks = [m for m in marks_for(booth) if m.shape == "pick"]
|
||||
if not picks:
|
||||
return html, ""
|
||||
url = quote(name, safe="")
|
||||
fid = ask_form_id(mark.id)
|
||||
if mark.error:
|
||||
# `whole` renders the broken-ask box. A question the session
|
||||
# believes it posted has to be visible; the pieces of a pick that
|
||||
# could not be read do not exist to offer.
|
||||
return {"id": mark.id, "error": mark.error,
|
||||
"whole": str(_frag.whole(mark, fid, url)), "submit": "",
|
||||
"questions": []}
|
||||
return {
|
||||
"id": mark.id,
|
||||
"error": None,
|
||||
"whole": str(_frag.whole(mark, fid, url)),
|
||||
"submit": str(_frag.submit(mark, fid, url)),
|
||||
# A LIST, not an object keyed by question key: a single-question
|
||||
# pick normalizes to one question whose key is None, which JSON
|
||||
# would write as the string "null" and so invent a name. The list
|
||||
# also carries declaration order in the format itself.
|
||||
"questions": [
|
||||
{"key": q.get("key"), "html": str(_frag.question(mark, q, fid, url))}
|
||||
for q in mark.questions
|
||||
],
|
||||
}
|
||||
|
||||
seen: set[str] = set()
|
||||
@app.get("/b/{name}/embed.json")
|
||||
def booth_embed_json(name: str):
|
||||
"""Everything a verbatim report needs to mount the Booth's chrome.
|
||||
|
||||
def render(kind: str, mark, key: str | None) -> str:
|
||||
fid = ask_form_id(mark.id)
|
||||
if kind == "whole":
|
||||
frag = str(_frag.whole(mark, fid, url))
|
||||
elif kind == "submit":
|
||||
frag = str(_frag.submit(mark, fid, url))
|
||||
else:
|
||||
q = next(q for q in mark.questions if q.get("key") == key)
|
||||
frag = str(_frag.question(mark, q, fid, url))
|
||||
# An anchor on the FIRST fragment of each pick, 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 mark.id not in seen:
|
||||
seen.add(mark.id)
|
||||
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + frag
|
||||
return frag
|
||||
The READ half of the declared seam. `embed.js` fetches this and places
|
||||
what comes back; every decision — what a mark says, whether it is still
|
||||
open, what order the marks come in — is made here and never re-derived
|
||||
on the page.
|
||||
|
||||
tail = [str(_frag.styles())]
|
||||
if has_placeholders(html):
|
||||
html, placed, submitted = place_asks(html, picks, render)
|
||||
for m in picks:
|
||||
keys = placed.get(m.id)
|
||||
if keys is None:
|
||||
tail.append(render("whole", m, None)) # unmarked: never dropped
|
||||
continue
|
||||
if m.error:
|
||||
continue
|
||||
if None not in keys:
|
||||
# Partially marked up: append every question the author did
|
||||
# NOT place. A multi-question pick needs all of them or the
|
||||
# POST is a 400 — met only after the operator fills it in.
|
||||
for q in m.questions:
|
||||
if q.get("key") not in keys:
|
||||
tail.append(render("question", m, q.get("key")))
|
||||
if m.id not in submitted:
|
||||
tail.append(render("submit", m, None)) # scattered but submittable
|
||||
else:
|
||||
for m in picks:
|
||||
tail.append(render("whole", m, None))
|
||||
Marks are ordered `(created, id)`, which is what both readers below
|
||||
sort by. Questions are in declaration order. `open` is `open_marks`,
|
||||
the ONE openness predicate, so a half-answered multi-question pick
|
||||
counts as open here exactly as it does on the index badge.
|
||||
|
||||
# The chip is 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.
|
||||
still_open = open_marks(picks) # INV-2: not re-derived here
|
||||
if still_open:
|
||||
tail.append(asks_chip(name, len(still_open),
|
||||
href=f'#bk-ask-{still_open[0].id}-top'))
|
||||
return html, "".join(tail)
|
||||
DOES NOT RECORD A VIEW. `booth_view` already did, above both of its
|
||||
early returns; counting a script's fetch of the page it is already on
|
||||
would reset the TTL on machinery rather than on the operator.
|
||||
|
||||
The read is LENIENT and the status stays 200, copied from
|
||||
`/marks.json`: a damaged `.marks.json` must cost the chrome, never the
|
||||
operator's report. That is the v0.2.2 lesson.
|
||||
"""
|
||||
booth = resolve_booth(name)
|
||||
marks, read_err = hold_read(booth) # ONE read; see list_booths
|
||||
if read_err is not None:
|
||||
marks = marks_for(booth)
|
||||
picks = [m for m in marks if m.shape == "pick"]
|
||||
body = {
|
||||
"booth": name,
|
||||
"home": "/",
|
||||
"favicon": FAVICON_HREF,
|
||||
# Picks only. It is also what keeps a flag's `flag:<target>` id —
|
||||
# the one mark id containing the separator an anchor spec splits
|
||||
# on — out of a payload whose specs split on the first colon.
|
||||
"marks": [_pick_fragments(name, m) for m in picks],
|
||||
"open": [m.id for m in open_marks(picks)],
|
||||
}
|
||||
if read_err is not None:
|
||||
body["marks"] = []
|
||||
body["error"] = "this booth's .marks.json cannot be read"
|
||||
body["detail"] = read_err
|
||||
return JSONResponse(body)
|
||||
|
||||
@app.get("/b/{name}/marks", response_class=HTMLResponse)
|
||||
def booth_marks_page(request: Request, name: str):
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
"""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, 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.
|
||||
"""
|
||||
# Marks index by ATTRIBUTE, not subscript: `place` was the one consumer in
|
||||
# the service that did `a["stem"]`, which a frozen dataclass refuses. Caught
|
||||
# by the U2 seam review (SR-1) — the cold contract pass cannot see a sibling
|
||||
# module's surface by design, so nothing else would have found it before the
|
||||
# first verbatim booth 500'd.
|
||||
by_stem = {a.id: 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.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,283 @@
|
||||
/* The Booth - the declared embed seam (U3).
|
||||
*
|
||||
* A booth that ships its own index.html is served verbatim. This script is how
|
||||
* the Booth's chrome gets onto that page WITHOUT the Booth reaching into it:
|
||||
* the report carries one line,
|
||||
*
|
||||
* <script src="/_booth/embed.js" defer></script>
|
||||
*
|
||||
* and everything below mounts through real DOM APIs. It replaced ten regular
|
||||
* expressions applied to author HTML - six hunting for a place to hang a
|
||||
* favicon and a chip, four substituting rendered markup into the author's own
|
||||
* tags. A page that declares this line is now served exactly as written.
|
||||
*
|
||||
* WHAT THIS SCRIPT DOES NOT DECIDE: what a mark says, whether it is still open,
|
||||
* or what order marks come in. Every fragment below is rendered server-side by
|
||||
* the same Jinja macros the gallery page uses, and `open` is computed by
|
||||
* `open_marks`. Two renderers of one truth is the bug INV-1 exists to stop -
|
||||
* the zoom view once re-derived an item and lost its captions doing it.
|
||||
*
|
||||
* Served from a read taken ONCE at app startup. Editing this file does nothing
|
||||
* until `systemctl --user restart booth.service`, exactly like the templates,
|
||||
* and for the same reason: on 2026-09-21 a hot-reloading template put 19 of 25
|
||||
* booths at 500 against Python that had never heard of the context it wanted.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
if (window.__boothEmbed) return; // declared AND appended: mount once
|
||||
window.__boothEmbed = true;
|
||||
|
||||
var CSS = [
|
||||
/* ---- the way home, and the open-asks jump ---- */
|
||||
".booth-nav-home,.booth-nav-asks{position:fixed;top:0;z-index:2147483647;",
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;border-radius:8px;",
|
||||
"text-decoration:none;letter-spacing:.01em;box-shadow:0 2px 10px rgba(0,0,0,.35)}",
|
||||
/* top-right: a top-left chip clips the page title on left-aligned report
|
||||
layouts, and this matches the zoom view's back affordance. */
|
||||
".booth-nav-home{right:0;font:600 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#dfe7ef;background:rgba(20,23,32,.82);border:1px solid rgba(66,220,209,.35);",
|
||||
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .18s,border-color .18s}",
|
||||
".booth-nav-home:hover{background:rgba(28,33,46,.95);border-color:rgba(66,220,209,.75)}",
|
||||
".booth-nav-asks{right:7.2rem;font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
|
||||
"color:#171a23;background:#ffe14e;border:1px solid #ffe14e;transition:filter .18s}",
|
||||
".booth-nav-asks:hover{filter:brightness(1.08)}",
|
||||
"@media print{.booth-nav-home,.booth-nav-asks{display:none}}",
|
||||
/* ---- ask fragments. Self-contained: the host page carries its own CSS and
|
||||
nothing here may inherit from it, so the palette adapts via
|
||||
prefers-color-scheme rather than borrowing. ---- */
|
||||
".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.bk-skip{border-top-color:#6f7c8c}",
|
||||
".bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}",
|
||||
".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}}"
|
||||
].join("");
|
||||
|
||||
/* The anchor attributes. `data-booth-mark` is canonical - U2 made an ask one
|
||||
shape of mark - and `data-booth-ask` is kept because two of the four live
|
||||
verbatim booths spell it that way, in the operator's own reports. */
|
||||
var MAIN_SEL = "[data-booth-mark],[data-booth-ask]";
|
||||
var SUBMIT_SEL = "[data-booth-mark-submit],[data-booth-ask-submit]";
|
||||
var WHOLE = " whole"; // the set member meaning "the whole ask landed here"
|
||||
|
||||
function attr(el, a, b) {
|
||||
var v = el.getAttribute(a);
|
||||
return v === null ? el.getAttribute(b) : v;
|
||||
}
|
||||
|
||||
/* "batch:r1" -> ["batch", "r1"]; "batch" -> ["batch", null]. Split on the
|
||||
FIRST colon: a question key cannot contain one (asks._KEY_RE) and neither
|
||||
can a pick id (asks.valid_stem), so this is unambiguous for everything the
|
||||
payload carries. A flag's id IS `flag:<target>`, which is why the payload
|
||||
carries picks only. */
|
||||
function splitSpec(spec) {
|
||||
var s = (spec || "").trim();
|
||||
var i = s.indexOf(":");
|
||||
if (i < 0) return [s, null];
|
||||
return [s.slice(0, i).trim(), s.slice(i + 1).trim() || null];
|
||||
}
|
||||
|
||||
function boothName() {
|
||||
var tag = document.querySelector("script[data-booth]");
|
||||
if (tag) return tag.getAttribute("data-booth");
|
||||
var parts = location.pathname.split("/"); // ["", "b", "<name>", ...]
|
||||
if (parts.length < 3 || parts[1] !== "b" || !parts[2]) return null;
|
||||
try { return decodeURIComponent(parts[2]); } catch (e) { return parts[2]; }
|
||||
}
|
||||
|
||||
function mount(el, html) {
|
||||
/* beforeend, NOT replaceWith: the author's element and its contents survive
|
||||
and the fragment lands inside it. `<div class="ask" data-booth-ask="...">
|
||||
<h3>heading</h3>` is live markup today, and the regex it replaced ate
|
||||
both the wrapper class and the heading's framing. */
|
||||
el.insertAdjacentHTML("beforeend", html);
|
||||
}
|
||||
|
||||
function styles() {
|
||||
if (document.getElementById("booth-embed-css")) return;
|
||||
var st = document.createElement("style");
|
||||
st.id = "booth-embed-css";
|
||||
st.textContent = CSS;
|
||||
(document.head || document.documentElement).appendChild(st);
|
||||
}
|
||||
|
||||
function favicon(href) {
|
||||
/* The question `_ICON_RE` and its three head-seam siblings were asking of
|
||||
raw text. Same question, asked of a parsed document. */
|
||||
if (!href || document.querySelector('link[rel~="icon"]')) return;
|
||||
var link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
link.href = href;
|
||||
(document.head || document.documentElement).appendChild(link);
|
||||
}
|
||||
|
||||
function homeChip(href) {
|
||||
var a = document.createElement("a");
|
||||
a.className = "booth-nav-home";
|
||||
a.href = href || "/";
|
||||
a.setAttribute("aria-label", "back to all booths");
|
||||
a.textContent = "‹ all booths";
|
||||
document.body.appendChild(a);
|
||||
}
|
||||
|
||||
function asksChip(openIds) {
|
||||
if (!openIds.length) return;
|
||||
/* A JUMP LINK, not a way out to another 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. Target: the FIRST element in document order
|
||||
whose id belongs to the first open mark - the fragments already carry
|
||||
ids, so the separate `bk-ask-<id>-top` anchor is not needed. */
|
||||
var first = null;
|
||||
var all = document.querySelectorAll('[id^="bk-ask-"]');
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
var id = all[i].id;
|
||||
if (id === "bk-ask-" + openIds[0] || id.indexOf("bk-ask-" + openIds[0] + "-") === 0) {
|
||||
first = all[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
var a = document.createElement("a");
|
||||
a.className = "booth-nav-asks";
|
||||
a.href = first ? "#" + first.id : "/b/" + encodeURIComponent(boothName() || "") + "/marks";
|
||||
a.textContent = "? " + openIds.length + " open ask" + (openIds.length === 1 ? "" : "s");
|
||||
document.body.appendChild(a);
|
||||
}
|
||||
|
||||
function reassociate() {
|
||||
/* A control bound to its <form> by the HTML5 `form=` attribute resolves its
|
||||
form owner when it is inserted. The fragments go in in VISUAL order, so a
|
||||
question can land before the submit block that carries the <form>.
|
||||
Chromium 151 re-resolves this correctly - measured 2026-09-22, N=3 per
|
||||
condition, with a form-first positive control and a points-at-nothing
|
||||
negative control. The sensitivity floor of that probe is ONE ENGINE, and
|
||||
the failure it would hide is a form that looks filled in and POSTs a 400.
|
||||
Three lines, so the engine stops mattering. */
|
||||
var bound = document.querySelectorAll(".bk-ask [form]");
|
||||
for (var i = 0; i < bound.length; i++) {
|
||||
var v = bound[i].getAttribute("form");
|
||||
bound[i].removeAttribute("form");
|
||||
bound[i].setAttribute("form", v);
|
||||
}
|
||||
}
|
||||
|
||||
function place(marks) {
|
||||
var by = {};
|
||||
for (var i = 0; i < marks.length; i++) by[marks[i].id] = marks[i];
|
||||
var placed = {}; // id -> {key or WHOLE: true}
|
||||
var submitted = {};
|
||||
|
||||
function note(id, key) {
|
||||
if (!placed[id]) placed[id] = {};
|
||||
if (key !== undefined) placed[id][key] = true;
|
||||
}
|
||||
|
||||
// 1. whole / per-question anchors, in DOCUMENT ORDER.
|
||||
var anchors = document.querySelectorAll(MAIN_SEL);
|
||||
for (var a = 0; a < anchors.length; a++) {
|
||||
var el = anchors[a];
|
||||
var spec = splitSpec(attr(el, "data-booth-mark", "data-booth-ask"));
|
||||
var mark = by[spec[0]];
|
||||
if (!mark) continue; // a typo'd id is LEFT ALONE, not blanked
|
||||
if (spec[1] === null) {
|
||||
mount(el, mark.whole);
|
||||
note(mark.id, WHOLE);
|
||||
submitted[mark.id] = true;
|
||||
continue;
|
||||
}
|
||||
var q = null;
|
||||
for (var k = 0; k < mark.questions.length; k++) {
|
||||
if (mark.questions[k].key === spec[1]) { q = mark.questions[k]; break; }
|
||||
}
|
||||
if (!q) continue; // names no question: also left alone
|
||||
mount(el, q.html);
|
||||
note(mark.id, spec[1]);
|
||||
}
|
||||
|
||||
// 2. explicit submit anchors.
|
||||
var subs = document.querySelectorAll(SUBMIT_SEL);
|
||||
for (var s = 0; s < subs.length; s++) {
|
||||
var sel = subs[s];
|
||||
var sid = splitSpec(attr(sel, "data-booth-mark-submit", "data-booth-ask-submit"))[0];
|
||||
var sm = by[sid];
|
||||
if (!sm) continue;
|
||||
mount(sel, sm.submit);
|
||||
note(sm.id);
|
||||
submitted[sm.id] = true;
|
||||
}
|
||||
|
||||
// 3. the tail, in PAYLOAD order - `(created, id)`. An ask is never
|
||||
// invisible: an unmarked page gets the whole thing, and a partially
|
||||
// marked one gets every question the author did not place, because a
|
||||
// multi-question pick needs ALL of them or the POST is a 400 the
|
||||
// operator meets only after filling it in.
|
||||
var holder = document.createElement("div");
|
||||
for (var m = 0; m < marks.length; m++) {
|
||||
var mk = marks[m];
|
||||
var got = placed[mk.id];
|
||||
if (!got) { holder.insertAdjacentHTML("beforeend", mk.whole); continue; }
|
||||
if (mk.error) continue;
|
||||
if (!got[WHOLE]) {
|
||||
for (var q2 = 0; q2 < mk.questions.length; q2++) {
|
||||
var qq = mk.questions[q2];
|
||||
if (!got[qq.key]) holder.insertAdjacentHTML("beforeend", qq.html);
|
||||
}
|
||||
}
|
||||
if (!submitted[mk.id]) holder.insertAdjacentHTML("beforeend", mk.submit);
|
||||
}
|
||||
var tail = document.createDocumentFragment();
|
||||
while (holder.firstChild) tail.appendChild(holder.firstChild);
|
||||
document.body.appendChild(tail);
|
||||
}
|
||||
|
||||
function start() {
|
||||
var name = boothName();
|
||||
if (!name || !document.body) return;
|
||||
styles();
|
||||
homeChip("/"); // needs no payload, so a failed fetch still
|
||||
// leaves the operator a way out
|
||||
fetch("/b/" + encodeURIComponent(name) + "/embed.json", { credentials: "same-origin" })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
favicon(data.favicon);
|
||||
place(data.marks || []);
|
||||
reassociate();
|
||||
asksChip(data.open || []);
|
||||
document.dispatchEvent(new CustomEvent("booth:mounted", { detail: { booth: name } }));
|
||||
})
|
||||
.catch(function () { /* the report is the operator's; a failed fetch costs
|
||||
the chrome, never the page. */ });
|
||||
}
|
||||
|
||||
/* The declared line carries `defer`, but an author may not copy it exactly. */
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
@@ -1,56 +1,22 @@
|
||||
{# Self-contained ask fragments injected into a booth's VERBATIM index.html.
|
||||
{# Self-contained ask fragments for 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.
|
||||
inherit from base.html. Since U3 these fragments do not reach the page by
|
||||
string substitution: they are rendered here, handed over
|
||||
`/b/<name>/embed.json`, and MOUNTED INTO THE DOM by `/_booth/embed.js`. The
|
||||
scoped `.bk-ask-*` styles live in that file alongside the code that needs
|
||||
them, which is why this template no longer emits a `styles()` block.
|
||||
|
||||
These macros stay the ONE renderer of an ask fragment. embed.js places what
|
||||
comes back and never builds one.
|
||||
|
||||
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
|
||||
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.
|
||||
lives with the submit block.
|
||||
#}
|
||||
|
||||
{% 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.bk-skip{border-top-color:#6f7c8c}
|
||||
.bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}
|
||||
.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' %}
|
||||
|
||||
Reference in New Issue
Block a user