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:
vh
2026-09-22 10:43:41 -07:00
parent 42ea67f33f
commit 87e2c5364c
17 changed files with 2015 additions and 503 deletions
+154 -173
View File
@@ -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):