Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7996fbd597 | ||
|
|
5c20e2f4d5 | ||
|
|
87e2c5364c | ||
|
|
42ea67f33f | ||
|
|
8f81d8f9d0 |
@@ -85,6 +85,12 @@ doing so, never resolved the caption — the operator's "zoomed images lose
|
|||||||
their annotations" bug. It was not a rendering bug; it was three readers of one
|
their annotations" bug. It was not a rendering bug; it was three readers of one
|
||||||
truth.
|
truth.
|
||||||
|
|
||||||
|
**U3 extended this to the verbatim path.** A booth's own `index.html` now gets
|
||||||
|
its chrome from `/_booth/embed.js`, which *places* server-rendered fragments and
|
||||||
|
never builds one. The fragments come from the same `_ask_inline.html` macros the
|
||||||
|
gallery page uses, handed over `/b/<name>/embed.json`. A second renderer in
|
||||||
|
JavaScript would be the same bug in a new language.
|
||||||
|
|
||||||
### 4. Re-export, don't move-and-break
|
### 4. Re-export, don't move-and-break
|
||||||
|
|
||||||
Names that moved from `app.py` to `items.py` (`classify`, `doc_kind`,
|
Names that moved from `app.py` to `items.py` (`classify`, `doc_kind`,
|
||||||
@@ -122,9 +128,14 @@ the operator's judgment being quietly misfiled.
|
|||||||
|
|
||||||
Current rules: items `sorted(rel)`; the zoom ring is that order filtered to
|
Current rules: items `sorted(rel)`; the zoom ring is that order filtered to
|
||||||
images; captions resolve over a sorted scan; marks `(created, id)`; legacy
|
images; captions resolve over a sorted scan; marks `(created, id)`; legacy
|
||||||
import `(mtime, name)`; link rows pinned-then-newest. `ROADMAP.md` carries the
|
import `(mtime, name)`; link rows pinned-then-newest; a verbatim report's embed
|
||||||
table and the two places still undecided (U7 sections and compare pairing, U6
|
anchors in document order, its tail in payload order, its questions in
|
||||||
bench listing).
|
declaration order. `ROADMAP.md` carries the table and the two places still
|
||||||
|
undecided (U7 sections and compare pairing, U6 bench listing).
|
||||||
|
|
||||||
|
U3's rows are the first that bind **across a language boundary** — decided in
|
||||||
|
Python, honoured in JavaScript. A string assertion cannot see that, which is
|
||||||
|
why `tests/test_embed_browser.py` exists.
|
||||||
|
|
||||||
When you add an ordered surface, state its rule in the docstring. If you cannot
|
When you add an ordered surface, state its rule in the docstring. If you cannot
|
||||||
state it in one line, it does not have one.
|
state it in one line, it does not have one.
|
||||||
@@ -179,8 +190,15 @@ one caused an outage.
|
|||||||
is that template work needs a restart to see, and that price is the point.
|
is that template work needs a restart to see, and that price is the point.
|
||||||
`test_templates_do_not_hot_reload_from_disk` holds the line.
|
`test_templates_do_not_hot_reload_from_disk` holds the line.
|
||||||
|
|
||||||
**So: after ANY edit here — Python or template — the live service is stale until
|
3. **`booth/static/embed.js` is the third thing that would have hot-reloaded,
|
||||||
you restart it.** If you are touching this repo while the operator may be using
|
and it does not.** U3 gave the service a static asset living in the
|
||||||
|
deployment root; it is read ONCE in `create_app` and served from memory with
|
||||||
|
an ETag over its content, for exactly the reason above. Same rule, same test
|
||||||
|
shape (`test_embed_js_does_not_hot_reload_from_disk`). Anything else this
|
||||||
|
repo learns to serve from disk inherits the rule — read it at startup.
|
||||||
|
|
||||||
|
**So: after ANY edit here — Python, template or static asset — the live service
|
||||||
|
is stale until you restart it.** If you are touching this repo while the operator may be using
|
||||||
the service, either restart promptly or expect him to be looking at the old
|
the service, either restart promptly or expect him to be looking at the old
|
||||||
version. Never leave the tree in a state where a restart would 500.
|
version. Never leave the tree in a state where a restart would 500.
|
||||||
|
|
||||||
@@ -197,6 +215,15 @@ curl -s localhost:8090/healthz # the live service (systemd --user)
|
|||||||
systemctl --user restart booth.service # after a code change, to see it live
|
systemctl --user restart booth.service # after a code change, to see it live
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tests/test_embed_browser.py` drives a real Chromium against a real uvicorn on
|
||||||
|
an ephemeral port — the only place U3's placement and `form=` binding can be
|
||||||
|
observed at all. Browsers are NOT downloaded per project; they live box-wide in
|
||||||
|
`/opt/ms-playwright`. The file **skips rather than fails** when playwright or a
|
||||||
|
usable browser is missing, so the suite stays green anywhere. If those tests
|
||||||
|
start skipping on this box, the pinned `playwright>=1.60,<1.63` in
|
||||||
|
`pyproject.toml` has drifted past the shared store — read the comment there
|
||||||
|
before raising the bound.
|
||||||
|
|
||||||
`booth.service` is a user unit installed to `~/.config/systemd/user/`. The repo
|
`booth.service` is a user unit installed to `~/.config/systemd/user/`. The repo
|
||||||
copy is the source; edits there need a `daemon-reload`.
|
copy is the source; edits there need a `daemon-reload`.
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -1,7 +1,7 @@
|
|||||||
# The Booth — roadmap
|
# The Booth — roadmap
|
||||||
|
|
||||||
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md).
|
||||||
Current version: `0.4.0` (U1, U2, U4 and U5 landed; extracted from eshpfi 2026-09-21).
|
Current version: `0.5.0` (U1, U2, U3, U4 and U5 landed; extracted from eshpfi 2026-09-21).
|
||||||
|
|
||||||
## v1 target
|
## v1 target
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ defect — not a wish. The measurements are in the IA doc.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 |
|
| 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 |
|
||||||
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
|
| 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 |
|
||||||
| 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
|
| 3 | ~~**Declared embed seam**~~ — **landed `87e2c53`, released `v0.5.0`** | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 |
|
||||||
| 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
|
| 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 |
|
||||||
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
|
| 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 |
|
||||||
| 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
|
| 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 |
|
||||||
@@ -22,8 +22,10 @@ Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5}
|
|||||||
U7**, with **U6 independent** of all of them (different storage, different
|
U7**, with **U6 independent** of all of them (different storage, different
|
||||||
surface) and therefore the safest thing to land first or in parallel.
|
surface) and therefore the safest thing to land first or in parallel.
|
||||||
|
|
||||||
**U1, U2, U4 and U5 are landed.** U3 is unblocked and unstarted; U6 remains
|
**U1, U2, U3, U4 and U5 are landed — the whole middle tier is closed.** U6
|
||||||
independent and unstarted; U7 waits on the rest.
|
remains independent and unstarted; **U7 is now unblocked**, since its only
|
||||||
|
dependency was `{U3, U4, U5}`. Two units left to v1, and they do not depend on
|
||||||
|
each other, so either can go next.
|
||||||
|
|
||||||
**U5's adoption is a measured prediction, not a finished result**, and it is
|
**U5's adoption is a measured prediction, not a finished result**, and it is
|
||||||
TWO predictions rather than one. The operator declined a fleetwide announcement
|
TWO predictions rather than one. The operator declined a fleetwide announcement
|
||||||
@@ -73,6 +75,13 @@ Where it already binds, and what the rule is in each case:
|
|||||||
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
|
| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them |
|
||||||
| link board rows | pinned first, then newest-first |
|
| link board rows | pinned first, then newest-first |
|
||||||
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
|
| a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) |
|
||||||
|
| embed anchors in a verbatim report | **document order** — what `querySelectorAll` yields, so the author's markup decides (U3) |
|
||||||
|
| the embed tail (fragments the author did not place) | **payload order**, which is the marks order `(created, id)` — one rule, whether a fragment lands at an anchor or at the end (U3) |
|
||||||
|
| questions within a pick | declaration order, in the payload's `questions` LIST — carried by the format rather than by object-key insertion order (U3) |
|
||||||
|
|
||||||
|
U3's three rows are the first case where the rule binds across a language
|
||||||
|
boundary: the order is decided in Python and honoured in JavaScript, and a
|
||||||
|
browser test asserts it rather than a string assertion that could not see it.
|
||||||
|
|
||||||
U4 added no ordered collection — a booth's lifetime is one state per booth,
|
U4 added no ordered collection — a booth's lifetime is one state per booth,
|
||||||
not a sequence — so the rule above did not need a new row. The three lifetime
|
not a sequence — so the rule above did not need a new row. The three lifetime
|
||||||
|
|||||||
+201
-175
@@ -44,6 +44,7 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
@@ -158,11 +159,6 @@ from booth.marks import ( # noqa: E402
|
|||||||
set_flag,
|
set_flag,
|
||||||
write_note,
|
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
|
from booth.manifest import ( # noqa: E402
|
||||||
MANIFEST_FILE,
|
MANIFEST_FILE,
|
||||||
SERVICE_HANDLE,
|
SERVICE_HANDLE,
|
||||||
@@ -545,119 +541,91 @@ def _zip_filename(name: str) -> str:
|
|||||||
return f"{safe or 'booth'}.zip"
|
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
|
# Mirror of base.html's favicon. The app templates set it there; this copy is
|
||||||
# injected into a booth's *verbatim* index.html so a raw page inherits the same
|
# what `/b/<name>/embed.json` hands to a VERBATIM report, so a raw page inherits
|
||||||
# icon). Keep the two in sync if the Booth's icon ever changes.
|
# the same icon. Keep the two in sync if the Booth's icon ever changes.
|
||||||
FAVICON_HREF = (
|
FAVICON_HREF = (
|
||||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'"
|
"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' "
|
"%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 "
|
"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"
|
"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
|
# The seam a verbatim report declares to get the Booth's chrome. ONE line, and
|
||||||
# booths. Scoped class + fixed positioning + max z-index so it overlays the raw
|
# the Booth appends it only when the page has not declared it itself.
|
||||||
# page without touching its layout; hidden in print so downloaded reports stay clean.
|
EMBED_SRC = "/_booth/embed.js"
|
||||||
_BACK_CHIP = (
|
EMBED_SCRIPT_TAG = f'<script src="{EMBED_SRC}" defer></script>'
|
||||||
'<a href="/" class="booth-nav-home" aria-label="back to all booths">‹ all booths</a>'
|
EMBED_JS_PATH = Path(__file__).parent / "static" / "embed.js"
|
||||||
# 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>"
|
|
||||||
)
|
|
||||||
|
|
||||||
# A booth's own index.html is served VERBATIM, so the asks panel — which lives in
|
# What counts as DECLARING the seam. Two substring tests, one per quote style,
|
||||||
# the auto-gallery template — can never appear on it. Without this chip an ask
|
# and each requires `src=` immediately before the path.
|
||||||
# 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
|
# The bare path was the first draft and it was wrong in the dangerous
|
||||||
# showed nothing). Same injection mechanism as the back chip; it links to the
|
# direction. A report that merely MENTIONS `/_booth/embed.js` — in a code
|
||||||
# standalone /asks page, which renders the real forms.
|
# sample, a comment, a sentence about this very feature — would have been read
|
||||||
def asks_chip(name: str, open_count: int, href: str | None = None) -> str:
|
# as declaring it, served untouched, and silently shown no chrome at all. The
|
||||||
if open_count < 1:
|
# Booth's own design reports are exactly the pages that would quote it.
|
||||||
return ""
|
#
|
||||||
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
|
# These tests fail in the harmless direction instead. An unusual spelling
|
||||||
href = href or f"/b/{quote(name, safe='')}/asks"
|
# (`src = "…"` with spaces, an unquoted attribute, a `?v=2` suffix) is read as
|
||||||
return (
|
# NOT declared, so a second tag is appended — and embed.js mounts once
|
||||||
f'<a href="{href}" class="booth-nav-asks">{label}</a>'
|
# regardless, because it guards on `window.__boothEmbed`. A missed declaration
|
||||||
"<style>.booth-nav-asks{position:fixed;top:0;right:7.2rem;z-index:2147483647;"
|
# costs a duplicate tag; a false one costs the operator his chrome.
|
||||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
_EMBED_DECLARATIONS = (f'src="{EMBED_SRC}"', f"src='{EMBED_SRC}'")
|
||||||
"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;"
|
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw
|
||||||
"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>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
TWO SUBSTRING TESTS. This is the entire detection half of what used to be
|
||||||
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", re.IGNORECASE)
|
six regular expressions run against arbitrary author HTML — and the other
|
||||||
_HTML_OPEN_RE = re.compile(r"<html\b[^>]*>", re.IGNORECASE)
|
half, the insertion, is a `+`. See `_EMBED_DECLARATIONS` for why it matches
|
||||||
_DOCTYPE_RE = re.compile(r"<!doctype[^>]*>", re.IGNORECASE)
|
`src="…"` rather than the bare path: both spellings fail toward appending a
|
||||||
_BODY_CLOSE_RE = re.compile(r"</body\s*>", re.IGNORECASE)
|
harmless duplicate rather than toward silently withholding the chrome.
|
||||||
_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.
|
|
||||||
"""
|
"""
|
||||||
if favicon_link and not _ICON_RE.search(html):
|
return any(d in html for d in _EMBED_DECLARATIONS)
|
||||||
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
|
|
||||||
|
|
||||||
chips = _BACK_CHIP + (extra or "")
|
|
||||||
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
|
def embed_verbatim(raw: bytes) -> bytes:
|
||||||
html, done = _insert_before(html, pat, chips)
|
"""The ONLY thing the Booth does to a verbatim report. BYTES IN, BYTES OUT.
|
||||||
if done:
|
|
||||||
break
|
Appended, never inserted, and never prepended. That is what retires both of
|
||||||
else:
|
the old wrapper's hard constraints rather than satisfying them more
|
||||||
html = html + chips # no </body>/</html>: append to the end
|
carefully: nothing can displace a leading doctype into quirks mode and
|
||||||
return html
|
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.
|
||||||
|
|
||||||
|
⚠ IT TAKES BYTES BECAUSE TEXT WAS QUIETLY EDITING THE DOCUMENT. The first
|
||||||
|
version read the file with `read_text()` and returned a str. That opens in
|
||||||
|
UNIVERSAL-NEWLINE mode, so a report written with CRLF came back with LF —
|
||||||
|
and `errors="replace"` turned any byte that was not valid UTF-8 into U+FFFD.
|
||||||
|
A declaring page was therefore NOT served as its author wrote it, which is
|
||||||
|
this unit's headline promise, and the test could not see it because its
|
||||||
|
fixture was LF-only ASCII. Found by a cross-frontier bug-hunt panel.
|
||||||
|
|
||||||
|
Decoding still happens — `declares_embed` needs a string to look in — but
|
||||||
|
the decoded copy is used ONLY to answer that question. What goes on the wire
|
||||||
|
is the original bytes, plus the tag's bytes when it is appended, so the
|
||||||
|
source is a byte-exact prefix of the response.
|
||||||
|
"""
|
||||||
|
text = raw.decode("utf-8", errors="replace")
|
||||||
|
return raw if declares_embed(text) else raw + EMBED_SCRIPT_TAG.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
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) ---------------------------------
|
# ---- uploads (browser drop-off for pickup) ---------------------------------
|
||||||
@@ -765,6 +733,13 @@ def create_app(
|
|||||||
env.filters["dur"] = human_dur
|
env.filters["dur"] = human_dur
|
||||||
templates = Jinja2Templates(env=env)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
task = None
|
task = None
|
||||||
@@ -855,6 +830,20 @@ def create_app(
|
|||||||
def healthz():
|
def healthz():
|
||||||
return {"ok": True, "ttl_hours": ttl_hours, "booths": len(list_booths(data_dir, ttl_seconds))}
|
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)
|
@app.get("/b/{name}", include_in_schema=False)
|
||||||
def booth_redirect(name: str):
|
def booth_redirect(name: str):
|
||||||
resolve_booth(name)
|
resolve_booth(name)
|
||||||
@@ -877,18 +866,20 @@ def create_app(
|
|||||||
)
|
)
|
||||||
own_index = booth / "index.html"
|
own_index = booth / "index.html"
|
||||||
if own_index.is_file():
|
if own_index.is_file():
|
||||||
# Serve the operator's verbatim report, but inject a floating
|
# The operator's verbatim report. U3: the page declares the seam and
|
||||||
# back-to-booths chip + the Booth favicon (if it declares none) so a
|
# the Booth mounts into it — so a page carrying the script tag is
|
||||||
# raw page still has a way home. Small HTML -> read + wrap in memory;
|
# served exactly as written, and one that is not gets that single
|
||||||
# a pathological large file falls back to serving raw, unwrapped.
|
# 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:
|
try:
|
||||||
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
||||||
raw = own_index.read_text(encoding="utf-8", errors="replace")
|
# ONE read, and it is a byte read: see embed_verbatim.
|
||||||
# Asks render INLINE, where the report author put them (or
|
return Response(
|
||||||
# appended, if they marked nothing) — a question about an
|
content=embed_verbatim(own_index.read_bytes()),
|
||||||
# artifact belongs beside that artifact, not on another page.
|
media_type="text/html; charset=utf-8",
|
||||||
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")
|
||||||
@@ -1098,73 +1089,108 @@ def create_app(
|
|||||||
|
|
||||||
_frag = templates.env.get_template("_ask_inline.html").module
|
_frag = templates.env.get_template("_ask_inline.html").module
|
||||||
|
|
||||||
def inject_asks(name: str, booth: Path, html: str) -> tuple[str, str]:
|
def _pick_fragments(name: str, mark) -> dict:
|
||||||
"""(body, tail) for a verbatim booth: placeholders substituted in place,
|
"""One pick, rendered into the pieces a page can mount independently.
|
||||||
and whatever still has to be appended before </body>.
|
|
||||||
|
|
||||||
Marked-up pages get each fragment exactly where the author put it. An
|
Rendered HERE, by the same Jinja macros the gallery page uses, so there
|
||||||
unmarked page gets the whole ask appended — an ask is NEVER invisible,
|
is exactly ONE renderer of an ask. embed.js places these; it never
|
||||||
which is the guarantee; markup only moves it somewhere better. A stem
|
builds one. A second renderer in JavaScript is the shape INV-1 was
|
||||||
whose questions were placed but whose submit block was not gets that
|
written to stop after the zoom view re-derived an item and lost its
|
||||||
block appended, so a scattered form is always submittable.
|
captions doing it.
|
||||||
"""
|
"""
|
||||||
picks = [m for m in marks_for(booth) if m.shape == "pick"]
|
|
||||||
if not picks:
|
|
||||||
return html, ""
|
|
||||||
url = quote(name, safe="")
|
url = quote(name, safe="")
|
||||||
|
|
||||||
seen: set[str] = set()
|
|
||||||
|
|
||||||
def render(kind: str, mark, key: str | None) -> str:
|
|
||||||
fid = ask_form_id(mark.id)
|
fid = ask_form_id(mark.id)
|
||||||
if kind == "whole":
|
if mark.error:
|
||||||
frag = str(_frag.whole(mark, fid, url))
|
# `whole` renders the broken-ask box. A question the session
|
||||||
elif kind == "submit":
|
# believes it posted has to be visible; the pieces of a pick that
|
||||||
frag = str(_frag.submit(mark, fid, url))
|
# could not be read do not exist to offer.
|
||||||
else:
|
return {"id": mark.id, "error": mark.error,
|
||||||
q = next(q for q in mark.questions if q.get("key") == key)
|
"whole": str(_frag.whole(mark, fid, url)), "submit": "",
|
||||||
frag = str(_frag.question(mark, q, fid, url))
|
"questions": []}
|
||||||
# An anchor on the FIRST fragment of each pick, wherever it landed,
|
return {
|
||||||
# so the floating chip can jump to it on a long report. Computed
|
"id": mark.id,
|
||||||
# here rather than in the macros because only the caller knows
|
"error": None,
|
||||||
# which fragment came first.
|
"whole": str(_frag.whole(mark, fid, url)),
|
||||||
if mark.id not in seen:
|
"submit": str(_frag.submit(mark, fid, url)),
|
||||||
seen.add(mark.id)
|
# A LIST, not an object keyed by question key: a single-question
|
||||||
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + frag
|
# pick normalizes to one question whose key is None, which JSON
|
||||||
return frag
|
# 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
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
tail = [str(_frag.styles())]
|
def _safe_fragments(name: str, mark) -> dict:
|
||||||
if has_placeholders(html):
|
"""`_pick_fragments`, with the promise that it cannot raise.
|
||||||
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))
|
|
||||||
|
|
||||||
# The chip is a JUMP LINK to the inline block, not a way out to a
|
`marks_for` hydrates an entry whose JSON is well-formed but whose SHAPE
|
||||||
# separate page: on a long report the question can be well below the
|
is wrong — `{"answer": {"answers": []}}` survives `_hydrate` with no
|
||||||
# fold, and "there is a question waiting" still has to be visible at
|
error and then raises `UndefinedError` in the template, because the
|
||||||
# first paint.
|
macro asks a list for `.get`. Verified, not assumed.
|
||||||
still_open = open_marks(picks) # INV-2: not re-derived here
|
|
||||||
if still_open:
|
This endpoint renders every pick in the booth on every page load of the
|
||||||
tail.append(asks_chip(name, len(still_open),
|
operator's report, so one such entry would 500 the whole seam and the
|
||||||
href=f'#bk-ask-{still_open[0].id}-top'))
|
report would show no chrome at all — while `hold_read` reported the file
|
||||||
return html, "".join(tail)
|
as perfectly readable. Same leniency `_hydrate_safe` already applies one
|
||||||
|
layer down, at the layer that actually renders: one unreadable pick
|
||||||
|
costs that pick, never the page.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _pick_fragments(name, mark)
|
||||||
|
except Exception as exc: # noqa: BLE001 - deliberate
|
||||||
|
broken = replace(mark, error=f"this question could not be rendered: {exc}")
|
||||||
|
return {"id": mark.id, "error": broken.error,
|
||||||
|
"whole": str(_frag.whole(broken, ask_form_id(mark.id),
|
||||||
|
quote(name, safe=""))),
|
||||||
|
"submit": "", "questions": []}
|
||||||
|
|
||||||
|
@app.get("/b/{name}/embed.json")
|
||||||
|
def booth_embed_json(name: str):
|
||||||
|
"""Everything a verbatim report needs to mount the Booth's chrome.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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,
|
||||||
|
# No `home`: the way-home chip mounts from a constant BEFORE this
|
||||||
|
# fetch, so that a failed one still leaves the operator a way out.
|
||||||
|
# Carrying the value anyway would put a second representation of it
|
||||||
|
# on the wire for nothing to read.
|
||||||
|
"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": [_safe_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)
|
@app.get("/b/{name}/marks", response_class=HTMLResponse)
|
||||||
def booth_marks_page(request: Request, name: str):
|
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,351 @@
|
|||||||
|
/* 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.
|
||||||
|
|
||||||
|
Returns the elements it actually inserted. The chip needs to jump to a
|
||||||
|
fragment WE mounted, not to whatever the document happens to have with a
|
||||||
|
matching id — see chipTarget. */
|
||||||
|
var before = el.children.length;
|
||||||
|
el.insertAdjacentHTML("beforeend", html);
|
||||||
|
return Array.prototype.slice.call(el.children, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 chipTarget(mounted, markId) {
|
||||||
|
/* The earliest IN DOCUMENT ORDER of the elements WE mounted for this mark.
|
||||||
|
Not an id-prefix search over the whole document: a panel pointed out that
|
||||||
|
an author's own `<section id="bk-ask-winner-background">` satisfies any
|
||||||
|
prefix rule — hyphen boundary included — and would hijack the jump. Only
|
||||||
|
elements this script inserted are candidates, which is the identity the
|
||||||
|
deleted `bk-ask-<id>-top` anchor used to guarantee. */
|
||||||
|
var mine = mounted[markId] || [];
|
||||||
|
var first = null;
|
||||||
|
for (var i = 0; i < mine.length; i++) {
|
||||||
|
var el = mine[i];
|
||||||
|
if (!el.id || !document.contains(el)) continue;
|
||||||
|
if (first === null ||
|
||||||
|
(first.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)) {
|
||||||
|
first = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asksChip(openIds, mounted) {
|
||||||
|
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. */
|
||||||
|
var first = chipTarget(mounted, openIds[0]);
|
||||||
|
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() {
|
||||||
|
/* SCOPED TO OUR OWN FRAGMENTS (`.bk-ask [form]`), deliberately: the Booth
|
||||||
|
does not rewrite attributes on elements the author wrote, even to help.
|
||||||
|
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 the operator fills in whose controls
|
||||||
|
reach no form at all, so the button does nothing and nothing is saved.
|
||||||
|
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 hasForm(markId) {
|
||||||
|
/* `form_id` in booth/app.py builds the same string. Kept in step by the
|
||||||
|
fragments themselves: the submit macro emits exactly this id. */
|
||||||
|
return !!document.getElementById(
|
||||||
|
"bk-ask-form-" + markId.replace(/[^A-Za-z0-9_-]/g, "-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(marks) {
|
||||||
|
/* Object.create(null), NOT {} — three times, and it is not style.
|
||||||
|
A mark id and a question key are both `[A-Za-z0-9][A-Za-z0-9._-]*`
|
||||||
|
(asks.valid_stem, asks._KEY_RE), so `toString` and `constructor` are
|
||||||
|
legal in both. Against a plain object, an author writing
|
||||||
|
`data-booth-mark="toString"` — an anchor naming NO mark — gets
|
||||||
|
Object.prototype.toString back, passes the `if (!mark)` guard it was
|
||||||
|
supposed to fail, and throws on `mark.questions.length`. That aborts
|
||||||
|
`place` before the tail, so the page loses EVERY ask, from one typo in
|
||||||
|
the author's own markup. The `placed` set has the mirror bug: inherited
|
||||||
|
`got.constructor` reads as "already placed" and silently drops a real
|
||||||
|
question. Found by a cross-frontier code-review panel. */
|
||||||
|
var by = Object.create(null);
|
||||||
|
for (var i = 0; i < marks.length; i++) by[marks[i].id] = marks[i];
|
||||||
|
var placed = Object.create(null); // id -> {key or WHOLE: true}
|
||||||
|
var submitted = Object.create(null);
|
||||||
|
var mounted = Object.create(null); // id -> [elements this script inserted]
|
||||||
|
|
||||||
|
function note(id, key) {
|
||||||
|
if (!placed[id]) placed[id] = Object.create(null);
|
||||||
|
if (key !== undefined) placed[id][key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(id, els) {
|
||||||
|
if (!mounted[id]) mounted[id] = [];
|
||||||
|
for (var n = 0; n < els.length; n++) mounted[id].push(els[n]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
record(mark.id, mount(el, mark.whole));
|
||||||
|
note(mark.id, WHOLE);
|
||||||
|
if (hasForm(mark.id)) 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
|
||||||
|
record(mark.id, 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;
|
||||||
|
/* A BROKEN pick has no submit block — its `submit` is the empty string and
|
||||||
|
its diagnostic lives in `whole`. Mounting nothing here and then marking
|
||||||
|
it placed made the tail skip it, so the "broken ask" box never rendered
|
||||||
|
at the one surface built to show it. Leave the anchor alone, exactly as
|
||||||
|
an anchor naming no mark is left alone, and let the tail mount the
|
||||||
|
diagnostic. */
|
||||||
|
if (sm.error) continue;
|
||||||
|
record(sm.id, mount(sel, sm.submit));
|
||||||
|
note(sm.id);
|
||||||
|
/* ...and only count it submitted if the <form> SURVIVED. An author who
|
||||||
|
puts this anchor inside their own <form> loses ours: the HTML parser
|
||||||
|
drops a nested form element outright. Every control's `form=` would
|
||||||
|
then point at nothing, the tail would not add a fallback because we
|
||||||
|
said it was handled, and the operator would fill the whole thing in and
|
||||||
|
click a button that does nothing. */
|
||||||
|
if (hasForm(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
|
||||||
|
// question the operator cannot see is a question he cannot answer, and
|
||||||
|
// a submission with NOTHING picked is refused outright (400), so a page
|
||||||
|
// showing two of four questions can strand a pick that looks answerable.
|
||||||
|
// (A PARTIAL answer is accepted and recorded — that is deliberate.)
|
||||||
|
var holder = document.createElement("div");
|
||||||
|
for (var m = 0; m < marks.length; m++) {
|
||||||
|
var mk = marks[m];
|
||||||
|
var got = placed[mk.id];
|
||||||
|
var was = holder.children.length;
|
||||||
|
if (!got) {
|
||||||
|
holder.insertAdjacentHTML("beforeend", mk.whole);
|
||||||
|
record(mk.id, Array.prototype.slice.call(holder.children, was));
|
||||||
|
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);
|
||||||
|
record(mk.id, Array.prototype.slice.call(holder.children, was));
|
||||||
|
}
|
||||||
|
var tail = document.createDocumentFragment();
|
||||||
|
while (holder.firstChild) tail.appendChild(holder.firstChild);
|
||||||
|
document.body.appendChild(tail);
|
||||||
|
return mounted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
var name = boothName();
|
||||||
|
if (!name || !document.body) return;
|
||||||
|
styles();
|
||||||
|
// Mounted BEFORE the fetch and from a constant, so a failed or slow fetch
|
||||||
|
// still leaves the operator a way out. That is why the payload carries no
|
||||||
|
// `home` — a value on the wire that nothing reads is a second
|
||||||
|
// representation of one fact, waiting to disagree with the first.
|
||||||
|
homeChip("/");
|
||||||
|
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);
|
||||||
|
var mounted = place(data.marks || []);
|
||||||
|
reassociate();
|
||||||
|
asksChip(data.open || [], mounted);
|
||||||
|
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
|
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-*`
|
inherit from base.html. Since U3 these fragments do not reach the page by
|
||||||
styles (emitted once, by `styles()`), and the palette adapts via
|
string substitution: they are rendered here, handed over
|
||||||
prefers-color-scheme rather than borrowing the host page's.
|
`/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=`
|
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
|
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
|
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. #}
|
{# One question's radio group, bound to the shared form by id. #}
|
||||||
{% macro question(a, q, form_id, name_url, standalone=False) %}
|
{% macro question(a, q, form_id, name_url, standalone=False) %}
|
||||||
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
---
|
||||||
|
contract_version: "1.0"
|
||||||
|
module: "booth.app (verbatim serving) + booth/static/embed.js"
|
||||||
|
purpose: "A booth that ships its own index.html is the operator's most important surface -- his design reviews, his audition reports, his briefs -- and the Booth reaches into it with six regular expressions against arbitrary author HTML plus a placeholder DSL that substitutes rendered markup by pattern. Both work today and both are the single most fragile thing in the service. This unit replaces the whole class with a DECLARED SEAM: the page carries one line (`<script src=\"/_booth/embed.js\" defer></script>`), the Booth mounts its chrome through real DOM APIs, and a page that declares the line is served with ZERO Booth markup added to it. A page that does not declare it gets that one line appended at the end -- the only remaining mutation, and it needs no pattern matching at all. Operator ruling, 2026-09-21: the page declares itself, the Booth mounts into it."
|
||||||
|
depends_on:
|
||||||
|
- "booth.marks (marks_for, open_marks, hold_read -- the pick records the payload renders. UNCHANGED by this unit: U3 changes how fragments REACH the page, never what a mark is. Read against booth/marks.py, not against the U2 contract's prose -- see the seam review.)"
|
||||||
|
- "booth/templates/_ask_inline.html (the `whole` / `question` / `submit` macros stay the ONE renderer of an ask fragment, called from the embed payload instead of from inject_asks. Its `styles()` macro is DELETED -- inject_asks was its only caller and the CSS moves into embed.js so the chrome is one asset. Macro signatures are otherwise untouched.)"
|
||||||
|
- "booth.asks.normalize_ask (TRANSITIVE, through `marks._hydrate`, and named because the payload shape depends on it: a MULTI ask normalizes to questions whose `key` matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$`, and a SINGLE-question ask normalizes to exactly one question whose `key` is `None`. Both facts are load-bearing -- the first makes splitting an anchor spec on the first colon unambiguous, the second is why `questions` is a list. Read against booth/asks.py:138-223.)"
|
||||||
|
- "booth.app.FAVICON_HREF (the data-URI icon, carried in the payload rather than copied into embed.js -- a third copy of that string is exactly the multiple-readers-of-one-truth shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3 -- NOT this contract's INV-1, which is the untouched-page rule; a cold arm read the two as one label and was right to) exists to stop. base.html's literal copy predates this unit and is out of scope.)"
|
||||||
|
language: "python + javascript"
|
||||||
|
complexity: "medium"
|
||||||
|
estimated_loc: 420
|
||||||
|
confidence: 0.82
|
||||||
|
used_by:
|
||||||
|
- "booth.app.booth_view (the verbatim branch: one read, one substring check, one conditional append -- replacing inject_asks + wrap_verbatim_html entirely)"
|
||||||
|
- "the operator's verbatim reports (4 of 21 live booths ship their own index.html; 2 of those 4 use the placement DSL, so the migration is not hypothetical)"
|
||||||
|
- "report authors (the declared line is the new public API for a booth that wants Booth chrome where it chooses)"
|
||||||
|
touches:
|
||||||
|
- "booth/static/embed.js (NEW -- the mount script and the chrome CSS, one asset. Read ONCE at app startup, never per request; see INV-5.)"
|
||||||
|
- "booth/app.py (DELETE wrap_verbatim_html, _insert_before, _insert_after, _ICON_RE, _HEAD_CLOSE_RE, _HTML_OPEN_RE, _DOCTYPE_RE, _BODY_CLOSE_RE, _HTML_CLOSE_RE, _BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK and the `from booth.inline import` block. ADD EMBED_SRC/EMBED_SCRIPT_TAG, the startup read of embed.js, GET /_booth/embed.js, GET /b/{name}/embed.json, and the rewritten verbatim branch of booth_view.)"
|
||||||
|
- "booth/inline.py (DELETED ENTIRELY -- 119 lines. Nothing else imports it; `scripts/booth` never did, so the stdlib-only CLI surface is untouched. ONE line survives the module: `form_id`, which builds the shared form element id the fragments bind to, moves into booth/app.py beside the route that renders them. It is not placement machinery and dying with the placement engine would take the fragments with it. Seam review, SR-3.)"
|
||||||
|
- "booth/templates/_ask_inline.html (DELETE the `styles()` macro and rewrite the header comment: the fragments are now mounted by embed.js, not substituted by regex, and `No JavaScript` stops being true.)"
|
||||||
|
- "tests/test_booth.py (DELETE the five test_wrap_* tests and test_verbatim_booth_wrapped_with_back_chip -- they test a mechanism this unit removes; the FAVICON_LINK import goes with them)"
|
||||||
|
- "tests/test_asks.py (the inline-placement block, ~L480-590: assertions that server-rendered fragments appear in the page body become assertions about the embed payload. The BEHAVIOUR they encode -- every question reachable, a scattered form still submittable, a typo'd id left alone -- is preserved and re-asserted, half in Python and half in the browser.)"
|
||||||
|
- "tests/test_embed.py (NEW -- the payload, the injection rule, the no-hot-reload guard)"
|
||||||
|
- "tests/test_embed_browser.py (NEW -- Playwright against a real Chromium: the placement algorithm and the form association, neither of which the Python suite can see. SKIPS, never fails, when playwright or the shared browser is unavailable.)"
|
||||||
|
- "pyproject.toml (test extra gains `playwright>=1.60,<1.63` -- the range is the set of releases whose pinned Chromium revision is present in the box-wide /opt/ms-playwright store. Stated explicitly because it is invisible otherwise: 1.63 wants chromium-1243, which is NOT there, and the failure is an opaque `Executable doesn't exist`.)"
|
||||||
|
- "docs/design/information-architecture.md (the `What this deletes` list becomes what this DID delete; the standalone /asks bullet is corrected -- U2 already reduced it to a 308)"
|
||||||
|
- "ROADMAP.md (U3 row struck through; the ordering table gains the two orders this unit states)"
|
||||||
|
assumptions:
|
||||||
|
- "THE OPERATOR ALREADY RULED ON THE SEAM (2026-09-21, recorded in the IA doc): the page declares itself and the Booth mounts into it, via `<script src=\"/_booth/embed.js\" defer></script>`. That ruling ACCEPTS a JavaScript dependency on the verbatim path, which today has none. This contract does not re-open it. What the contract DOES do is state the consequence plainly so it is not discovered later -- see the degradation assumption below."
|
||||||
|
- "THE ONLY REMAINING SERVER-SIDE MUTATION IS A CONDITIONAL APPEND, AND IT NEEDS NO PATTERN AT ALL. Two substring tests (`src=\"/_booth/embed.js\"` and its single-quoted twin), then a concatenation. ⚠ THE BARE PATH WAS THE FIRST DRAFT AND IT FAILED IN THE DANGEROUS DIRECTION: a report that merely MENTIONS the path -- a code sample, a comment, a sentence about this feature, which the Booth's own design reports are the likeliest pages to contain -- would have counted as declaring it, been served untouched, and shown no chrome at all, silently. Requiring `src=` immediately before the path flips the failure direction: an unusual spelling (`src = \"...\"`, an unquoted attribute, a `?v=2` suffix) reads as NOT declared, so a second tag is appended and embed.js mounts once anyway on its `window.__boothEmbed` guard. A missed declaration costs a duplicate tag; a false one costs the operator his chrome. Three of four cold-panel arms found this independently. This is why all six regexes die rather than collapsing to one: content appended AFTER `</html>` is parsed into the body by every browser, so there is nothing to find. Nothing is ever PREPENDED, which is what retires both of wrap_verbatim_html's hard constraints in one stroke -- no doctype can be displaced into quirks mode and no charset meta can be pushed out of the first 1024 bytes, because nothing moves."
|
||||||
|
- "THE FAVICON MOVES FROM A REGEX TO A DOM QUERY. `_ICON_RE` existed to answer `does this page already declare an icon`, against raw text, and three more regexes existed to find a head-ish seam to put one in. embed.js asks `document.querySelector('link[rel~=\"icon\"]')` and appends to `document.head`. That is the same question and the same action, asked of a parsed document instead of a string -- and it is four of the six regexes."
|
||||||
|
- "FRAGMENTS ARE STILL RENDERED BY JINJA, ONLY PLACED BY JAVASCRIPT. The payload carries server-rendered HTML from the EXISTING `_ask_inline.html` macros. Re-implementing the ask form in JavaScript would make two renderers of one truth, which is precisely the shape the repo's ONE-RESOLVER rule (CLAUDE.md invariant 3) was written to stop after the zoom view lost its captions. embed.js does DOM placement and nothing else: it never decides what a mark says, whether it is open, or what order marks come in."
|
||||||
|
- "PLACEMENT IS AN ANCHOR-FILL, NOT A REPLACEMENT, AND THAT IS A DELIBERATE CHANGE FROM TODAY. `_EL_RE` matches an author's opening tag and SUBSTITUTES it, so `<div class=\"ask\" data-booth-ask=\"dfa:logo\"><h3>The one asset that must survive</h3>` loses both the wrapper's class and -- visually -- its framing, leaving the author's heading orphaned and the closing `</div>` stray. That is live today on `dfa-concepts`. embed.js uses `el.insertAdjacentHTML('beforeend', frag)`: the author's element and its contents survive and the fragment lands inside, under the heading. Strictly closer to what the markup says, and it is the behaviour a DOM API gives for free."
|
||||||
|
- "`data-booth-mark` IS CANONICAL; `data-booth-ask` IS A KEPT ALIAS. U2 made an ask one shape of mark and the IA doc names the anchor `data-booth-mark`. But 2 of the 4 live verbatim booths use the `data-booth-ask` spelling, in the operator's own reports, so the selector accepts both -- one extra clause in one selector string. Same for `data-booth-mark-submit` / `data-booth-ask-submit`. Renaming without the alias would break a live report to save nothing."
|
||||||
|
- "THE HTML-COMMENT PLACEHOLDERS ARE DROPPED, NOT PORTED. `<!-- booth:ask stem -->` and `<!-- booth:ask-submit stem -->` have ZERO users across all 21 live booths. Walking comment nodes to keep them would be real complexity bought for nobody, in the unit whose entire point is deletion. A page that used one degrades to the append path -- the ask still renders, at the end -- so the never-invisible guarantee holds even for a caller we do not know about."
|
||||||
|
- "DEGRADATION WITH JAVASCRIPT OFF IS A REAL LOSS AND IT IS NAMED HERE. Today the verbatim path is zero-JS: an ask renders server-side and submits through a plain form. After this unit, no JS means no chrome on the report -- no ask, no way home, no icon. The guarantee that an ask is NEVER INVISIBLE survives in a weaker and still-true form, through surfaces that need no script: the index card carries the open-mark badge, and `/b/<name>/marks` renders every mark server-side. This is the cost of the operator's ruling, stated once so nobody meets it as a surprise."
|
||||||
|
- "EMBED.JS IS READ ONCE AT STARTUP, FOR THE REASON TEMPLATES ARE. Serving it from disk per request would give the service a third staleness rule, and a live asset editable under a running process is exactly what put 19 of 25 booths at 500 on 2026-09-21. One rule in this repo: nothing takes effect until you restart. INV-5 holds the line the same way `test_templates_do_not_hot_reload_from_disk` does."
|
||||||
|
- "THE PAYLOAD ENDPOINT DOES NOT RECORD A VIEW. `booth_view` already calls `record_view` above both early returns (U4), and `.viewed` is a deliberate look. A fetch issued by a script on a page that has ALREADY been recorded would double-count activity and reset the TTL on machinery rather than on the operator -- the same distinction the `.lock` exemption draws in `_newest_mtime`."
|
||||||
|
- "THE READ IS LENIENT AND THE STATUS STAYS 200, copied deliberately from `/b/{name}/marks.json`. A damaged `.marks.json` must not 500 the operator's report; it returns an `error` in the body and embed.js mounts the nav anyway. This is the v0.2.2 lesson and the posture every read path in this service already takes."
|
||||||
|
- "WRAP_MAX_BYTES SURVIVES UNCHANGED, at 8 MiB, with the same raw-FileResponse fallback. The work behind it is now trivial, but the READ is not: the largest live verbatim booth is 280 KB and a pathological one still should not be pulled into memory. A booth over the cap loses its chrome exactly as it does today -- no regression, and the constant keeps its existing test."
|
||||||
|
open_questions:
|
||||||
|
- "Whether `/_booth/embed.js` should eventually carry the gallery page's chrome too, making one embed for both surfaces. Out of scope: the gallery page is server-rendered end to end and has no seam problem to solve."
|
||||||
|
- "Whether a booth should be able to suppress injection entirely (a `.no-embed` dotfile) for a report that wants to be served truly untouched. No live booth wants it; declaring the line and then not using it is already most of the way there. Parked rather than designed."
|
||||||
|
---
|
||||||
|
|
||||||
|
# U3 — the declared embed seam
|
||||||
|
|
||||||
|
## The defect, stated precisely
|
||||||
|
|
||||||
|
A booth that ships its own `index.html` is served verbatim. That is the whole
|
||||||
|
promise of the verbatim path, and the Booth breaks it twice on the way out:
|
||||||
|
|
||||||
|
1. **`wrap_verbatim_html`** searches arbitrary author HTML with six regular
|
||||||
|
expressions — `_ICON_RE`, `_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`,
|
||||||
|
`_BODY_CLOSE_RE`, `_HTML_CLOSE_RE` — to find somewhere to put a favicon and
|
||||||
|
somewhere to put a floating chip, while threading two constraints it cannot
|
||||||
|
verify: never put anything ahead of a leading doctype, and keep the charset
|
||||||
|
meta inside the first 1024 bytes.
|
||||||
|
2. **`booth/inline.py`** matches a placeholder DSL with four more patterns and
|
||||||
|
substitutes rendered HTML into the author's markup by string replacement.
|
||||||
|
|
||||||
|
Ten patterns, applied to documents the Booth did not write, does not parse, and
|
||||||
|
cannot validate. It works. It is also the single most fragile thing in the
|
||||||
|
service, and it is load-bearing for the operator's most important workflow.
|
||||||
|
|
||||||
|
The failure this invites is not a crash. It is a report that renders *slightly*
|
||||||
|
wrong — and there is a live specimen already. `dfa-concepts/index.html` writes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="ask" data-booth-ask="dfa:logo"><h3>The one asset that must survive</h3>
|
||||||
|
```
|
||||||
|
|
||||||
|
`_EL_RE` matches the opening `<div …>` and replaces it. The author's `.ask`
|
||||||
|
wrapper class is gone, the `<h3>` is orphaned, and the `</div>` further down is
|
||||||
|
stray. Nobody filed a bug, because a page that is 95% right does not look broken.
|
||||||
|
|
||||||
|
## The seam
|
||||||
|
|
||||||
|
Operator ruling, 2026-09-21. A report carries one line:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="/_booth/embed.js" defer></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
and the Booth mounts its chrome through real DOM APIs. Three consequences, and
|
||||||
|
the third is the one worth stating out loud:
|
||||||
|
|
||||||
|
- **A page that declares the line is served with nothing added to it.** Not
|
||||||
|
"one small injection" — nothing. The body is what the author wrote.
|
||||||
|
- **A page that does not declare it gets that one line appended at the end.**
|
||||||
|
A substring test and a concatenation; no pattern, nothing prepended, no
|
||||||
|
constraint to thread.
|
||||||
|
- **Both of `wrap_verbatim_html`'s hard constraints stop existing** rather than
|
||||||
|
being satisfied more carefully. You cannot displace a doctype you never move,
|
||||||
|
and you cannot push a charset meta out of the detection window by appending.
|
||||||
|
|
||||||
|
## What crosses the seam
|
||||||
|
|
||||||
|
`GET /b/{name}/embed.json` — server-rendered fragments, and nothing embed.js has
|
||||||
|
to decide for itself:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"booth": "dfa-concepts",
|
||||||
|
"home": "/",
|
||||||
|
"favicon": "data:image/svg+xml,…",
|
||||||
|
"open": ["dfa"],
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"id": "dfa",
|
||||||
|
"error": null,
|
||||||
|
"whole": "<div class=\"bk-ask\" …>",
|
||||||
|
"submit": "<div class=\"bk-ask\" …>",
|
||||||
|
"questions": [
|
||||||
|
{"key": "logo", "html": "<div …>"},
|
||||||
|
{"key": "display", "html": "<div …>"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every HTML string comes from the `_ask_inline.html` macros that render the same
|
||||||
|
fragments today. `open` is `open_marks(picks)` — computed once, server-side, and
|
||||||
|
never re-derived in JavaScript.
|
||||||
|
|
||||||
|
**A payload whose `.marks.json` could not be read has a stated shape**, because
|
||||||
|
an arm asked and the first draft did not say: `marks` is `[]`, `open` is `[]`,
|
||||||
|
`booth` / `home` / `favicon` are present as normal, and top-level `error` and
|
||||||
|
`detail` carry the verdict. Status stays 200, copied from `/marks.json` — a
|
||||||
|
pinned status code is a promise to remote clients, and the information goes in
|
||||||
|
the body instead. The nav mounts; nothing else does. A per-mark `error` is a
|
||||||
|
different thing: that is ONE unreadable pick inside a file that read fine.
|
||||||
|
|
||||||
|
**`questions` is a LIST, and `key` may be `null`.** This is not a style choice.
|
||||||
|
A single-question pick normalizes to `questions: [{"key": None, …}]`
|
||||||
|
(`asks.normalize_ask`), so a JSON object keyed by question key would serialize
|
||||||
|
that key as the string `"null"` — inventing a name that collides with a real key
|
||||||
|
and that JavaScript would have to translate back. A list also carries declaration
|
||||||
|
order in the format itself rather than leaning on object-key insertion order.
|
||||||
|
A `null` key matches no anchor spec, which is correct and is exactly what
|
||||||
|
`place` does today: a single-question pick is addressed as a whole or not at all.
|
||||||
|
Found by the seam review; see SR-2.
|
||||||
|
|
||||||
|
## How the script learns which booth it is on
|
||||||
|
|
||||||
|
**The find of the contract-review round, three arms independently**, and the one
|
||||||
|
gap that made the rest unimplementable as first written: the declared line is
|
||||||
|
byte-identical for every booth, the payload endpoint needs `{name}`, and the
|
||||||
|
name arrives *inside* the response the fetch needs the name to make.
|
||||||
|
|
||||||
|
The rule, stated once:
|
||||||
|
|
||||||
|
> **The booth name is the second path segment of the page's own address.** A
|
||||||
|
> verbatim report is served at `/b/<name>/`, so `embed.js` reads
|
||||||
|
> `location.pathname`, takes segment 2, and `decodeURIComponent`s it. A page
|
||||||
|
> whose address is not `/b/<name>/...` mounts nothing and returns quietly.
|
||||||
|
>
|
||||||
|
> **Override:** a `<script data-booth="...">` attribute wins if present. The
|
||||||
|
> Booth never writes one — the appended tag is exactly the documented line — but
|
||||||
|
> an author embedding a report elsewhere needs a way to say so, and one optional
|
||||||
|
> attribute is cheaper than a second endpoint.
|
||||||
|
|
||||||
|
This makes the URL grammar part of the public seam, which is the honest reading:
|
||||||
|
an author who writes the line is relying on where the Booth serves them, and
|
||||||
|
that should be written down rather than inferred.
|
||||||
|
|
||||||
|
## The placement algorithm
|
||||||
|
|
||||||
|
The same algorithm `inject_asks` runs today, expressed against a DOM instead of
|
||||||
|
a string. It is written out here because it is the part that moves languages,
|
||||||
|
and a reviewer has to be able to check the two against each other.
|
||||||
|
|
||||||
|
```
|
||||||
|
placed : Map<markId, Set<key | WHOLE>>
|
||||||
|
submitted : Set<markId>
|
||||||
|
|
||||||
|
1. every element matching
|
||||||
|
[data-booth-mark], [data-booth-ask] -- in document order
|
||||||
|
spec -> (id, key?) by splitting on the first ":"
|
||||||
|
mark unknown -> leave the element ALONE (a typo stays visible)
|
||||||
|
key absent -> mount whole; placed[id] += WHOLE; submitted += id
|
||||||
|
key names no question -> leave the element ALONE
|
||||||
|
key present -> mount question; placed[id] += key
|
||||||
|
|
||||||
|
2. every element matching
|
||||||
|
[data-booth-mark-submit], [data-booth-ask-submit]
|
||||||
|
mark unknown -> leave alone
|
||||||
|
otherwise -> mount submit; placed[id] ||= {}; submitted += id
|
||||||
|
|
||||||
|
3. tail, appended to <body> in payload order. THE ARROWS ARE EXCLUSIVE, NOT
|
||||||
|
CUMULATIVE -- first match wins and the mark is done. An arm read them as
|
||||||
|
four independent tests, under which one unplaced mark would mount its whole
|
||||||
|
form AND every question AND a submit block; the notation allowed it and the
|
||||||
|
prose did not forbid it:
|
||||||
|
|
||||||
|
if id not in placed: append whole; NEXT MARK
|
||||||
|
elif mark.error: append nothing; NEXT MARK
|
||||||
|
else:
|
||||||
|
if WHOLE not in placed[id]: append every question not in placed[id]
|
||||||
|
if id not in submitted: append submit (scattered, still submittable)
|
||||||
|
|
||||||
|
4. re-associate: for every control carrying form="…", remove and re-set the
|
||||||
|
attribute, so its form owner is resolved after all fragments are in place.
|
||||||
|
|
||||||
|
5. chip: if `open` is non-empty, link it to the FIRST element in document order
|
||||||
|
whose id is EXACTLY `bk-ask-<open[0]>` or begins `bk-ask-<open[0]>-`.
|
||||||
|
A bare prefix match would send the chip to `bk-ask-batch2-r1` for the mark
|
||||||
|
`batch`, or to an author's own element -- flagged by a cold arm, and the
|
||||||
|
trailing hyphen is what rules it out.
|
||||||
|
|
||||||
|
Two more rules the first draft left to the selector rather than stating:
|
||||||
|
|
||||||
|
- **An element carrying BOTH `data-booth-mark` and `data-booth-ask` uses the
|
||||||
|
canonical one.** The alias exists for reports written before the rename, not
|
||||||
|
to double a mount.
|
||||||
|
- **A submit anchor's spec is its stem; any `:key` on it is IGNORED.** There is
|
||||||
|
no per-question submit block — one pick has one `<form>`, which is the whole
|
||||||
|
reason the `form=` binding exists.
|
||||||
|
```
|
||||||
|
|
||||||
|
**`mount` is `el.insertAdjacentHTML('beforeend', frag)`** — the anchor element
|
||||||
|
and its existing contents survive; the fragment lands inside. See the assumption
|
||||||
|
on anchor-fill for why this is a deliberate change and not an accident.
|
||||||
|
|
||||||
|
**Step 4 is measured, not assumed.** Chromium 151 resolves a control's form owner
|
||||||
|
correctly even when the control is inserted before its `<form>`: a probe run
|
||||||
|
2026-09-22 (N=3 per condition, with a form-first positive control and a
|
||||||
|
points-at-nothing negative control) returned `F, F, F` for control-first and
|
||||||
|
`null, null, null` for the negative. So the pass is *not* needed in Chromium.
|
||||||
|
It is three lines, it costs nothing, and the sensitivity floor of that probe is
|
||||||
|
**one engine** — the operator's own browser was not measured. The failure it
|
||||||
|
guards against is a form the operator fills in whose controls reach no form,
|
||||||
|
so the button does nothing.
|
||||||
|
|
||||||
|
**Step 5 deletes an element.** Today `inject_asks` injects `<a id="bk-ask-<id>-top">`
|
||||||
|
before the first fragment of each pick so the chip has somewhere to jump. The
|
||||||
|
fragments already carry ids; document order in a live DOM is directly queryable;
|
||||||
|
the extra anchor is not needed.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
Each is falsifiable by a change that a test must catch going red. The
|
||||||
|
*Falsifiable:* line names that change — not a test that merely mentions the
|
||||||
|
invariant. (Five of seven U4 falsifiers were vacuous; see
|
||||||
|
`persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.)
|
||||||
|
|
||||||
|
**INV-1 — A page that declares the seam is served BYTE FOR BYTE.**
|
||||||
|
The response body for a verbatim booth whose `index.html` contains
|
||||||
|
`src="/_booth/embed.js"` (either quote style) is exactly the bytes on disk.
|
||||||
|
⚠ **Bytes, not text, and that is a correction.** The first implementation read
|
||||||
|
with `read_text()`, which opens in universal-newline mode: a CRLF report came
|
||||||
|
back LF, and `errors="replace"` turned any non-UTF-8 byte into U+FFFD. A
|
||||||
|
declaring page was NOT served as its author wrote it — the headline promise —
|
||||||
|
and the test could not see it, because its fixture was LF-only ASCII. The file
|
||||||
|
is decoded only to ask whether it declares the seam; what goes on the wire is
|
||||||
|
the original bytes. A page that only mentions the path is NOT declaring it — see the
|
||||||
|
conditional-append assumption for which way that has to fail.
|
||||||
|
*Falsifiable:* append anything — a chip, a comment, a newline — to the declaring
|
||||||
|
branch's response and `test_declaring_page_is_served_untouched` fails on a
|
||||||
|
whole-body equality, not on a substring absence.
|
||||||
|
|
||||||
|
**INV-2 — A page that does not declare the seam, AND IS UNDER `WRAP_MAX_BYTES`,
|
||||||
|
is mutated exactly once, at the end.** The response is the source BYTES plus
|
||||||
|
`EMBED_SCRIPT_TAG`'s bytes and nothing else, with the source a byte-exact
|
||||||
|
prefix of it.
|
||||||
|
⚠ **The size cap is an explicit exception, not an oversight** — two cold arms
|
||||||
|
read the invariant's universal wording against the raw-`FileResponse`
|
||||||
|
assumption and found them prescribing different responses for the same page. An
|
||||||
|
over-cap page is mutated ZERO times and loses its chrome, exactly as it did
|
||||||
|
before this unit.
|
||||||
|
*Falsifiable:* insert the tag before `</head>` instead of appending, or add the
|
||||||
|
favicon link back, and `test_undeclared_page_gains_only_the_tag` fails the
|
||||||
|
prefix assertion. The exception has its own test,
|
||||||
|
`test_an_oversize_verbatim_page_is_served_raw`, which fails if the append starts
|
||||||
|
firing above the cap.
|
||||||
|
|
||||||
|
**INV-3 — No regular expression is applied to author HTML.**
|
||||||
|
The verbatim branch of `booth_view` performs two `in` tests and one `+`.
|
||||||
|
⚠ **The first draft of this falsifier was VACUOUS and three arms caught it.**
|
||||||
|
It name-matched the six deleted patterns, so reintroducing the same regex under
|
||||||
|
a new name — `_TAIL_RE`, applied in the verbatim branch — left the test green,
|
||||||
|
on this contract's central promise. Worse, this repo's own vacuity pass missed
|
||||||
|
it, because the mutation it tried was the named one: **a vacuity pass is only as
|
||||||
|
good as the mutation it picks, and picking the one the contract names is how it
|
||||||
|
agrees with itself.**
|
||||||
|
*Falsifiable:* `test_no_regex_touches_author_html` walks the AST of
|
||||||
|
`booth/app.py` and asserts the module performs **exactly one** regex operation
|
||||||
|
— `ask_form_id`'s `re.sub` over a mark id, which is not a page — plus that
|
||||||
|
`booth/inline.py` does not exist. Any regex anywhere in the module, under any
|
||||||
|
name, fails it. Verified by mutation: a renamed `_TAIL_RE.sub` in
|
||||||
|
`embed_verbatim` goes red, and the unmutated control stays green.
|
||||||
|
|
||||||
|
**INV-4 — The payload is the only source of what a mark says.**
|
||||||
|
embed.js never decides openness, order, or content. `open` comes from
|
||||||
|
`open_marks`; `marks` order is `marks_for` order; `questions` order is
|
||||||
|
declaration order.
|
||||||
|
*Falsifiable:* the claim ranges over three things and so does the check.
|
||||||
|
**Openness:** have embed.js derive open marks from a `bk-done` class and
|
||||||
|
`test_the_chip_count_comes_from_the_server` fails on a half-answered pick, which
|
||||||
|
`open_marks` calls open and the rendered state does not. **Order:** reverse the
|
||||||
|
tail iteration and `test_the_tail_follows_payload_order` fails. **Content:** the
|
||||||
|
fragments are strings the page never authors, which
|
||||||
|
`test_every_piece_the_author_can_place_is_offered` pins on the server side.
|
||||||
|
|
||||||
|
**INV-5 — `/_booth/embed.js` is read once at startup.**
|
||||||
|
*Falsifiable:* change the route to `read_text()` per request and
|
||||||
|
`test_embed_js_does_not_hot_reload_from_disk` fails — it mutates the file on
|
||||||
|
disk after the app is built and asserts the served body is unchanged.
|
||||||
|
|
||||||
|
**INV-6 — Every ordered collection this unit renders has a stated rule.**
|
||||||
|
Anchors are visited in **document order** (`querySelectorAll`). The tail is
|
||||||
|
appended in **payload order**, which is `(created, id)` — the rule `marks_for`
|
||||||
|
and `hold_read` both sort by, stated here as the rule rather than as one
|
||||||
|
function's name. Questions
|
||||||
|
within a mark are in **declaration order**. The chip targets the **first element
|
||||||
|
in document order** whose id starts with the open mark's prefix.
|
||||||
|
*Falsifiable:* sort the tail by anything else — id, key, insertion — and
|
||||||
|
`test_tail_order_is_payload_order` fails against a fixture whose creation order
|
||||||
|
and id order disagree.
|
||||||
|
|
||||||
|
**INV-7 — Every question of every READABLE pick reaches the document, on a
|
||||||
|
page that runs the script.** Either placed at an anchor or appended, and every
|
||||||
|
pick with a placed question has a submit block.
|
||||||
|
⚠ **Two qualifiers, both added because arms read the first wording literally and
|
||||||
|
were right.** *Readable*: a pick carrying `error` has no questions to place —
|
||||||
|
`marks._hydrate` gives it an empty list — so the tail mounts its broken-ask box
|
||||||
|
and stops, and an unqualified "every pick" would have demanded placement the
|
||||||
|
algorithm forbids in exactly the damaged-data case the leniency posture exists
|
||||||
|
for. *Reaches the document*, not "is visible": the Booth cannot police an author
|
||||||
|
who hides their own anchor, and a guarantee that claimed to would be unenforceable
|
||||||
|
rather than strict.
|
||||||
|
*Falsifiable:* drop the "append the questions the author did not place" branch
|
||||||
|
and `test_partially_marked_page_still_shows_every_question` fails in the browser
|
||||||
|
with 2 of 4 radio groups present.
|
||||||
|
|
||||||
|
## Out of scope (deferred or never)
|
||||||
|
|
||||||
|
Named so a reviewer does not read them as drift.
|
||||||
|
|
||||||
|
- **The gallery page's chrome.** Only a booth's own `index.html` is served
|
||||||
|
verbatim; every other surface is server-rendered end to end and has no seam
|
||||||
|
problem. `/_booth/embed.js` is not loaded there and is not meant to be.
|
||||||
|
- **Re-rendering an ask in JavaScript.** The payload carries server-rendered
|
||||||
|
HTML and embed.js places it. A JS renderer would be a second renderer of one
|
||||||
|
truth — the bug the repo's one-resolver rule exists to stop.
|
||||||
|
- **A no-JavaScript fallback on the verbatim path.** The operator's 2026-09-21
|
||||||
|
ruling accepts the script dependency. The never-invisible guarantee degrades
|
||||||
|
to surfaces that need no script (the index card's badge, `/b/<name>/marks`),
|
||||||
|
and that is the stated cost, not an oversight to be fixed here.
|
||||||
|
- **The HTML-comment placeholders** `<!-- booth:ask … -->`. Zero users across
|
||||||
|
all 21 live booths; dropped rather than ported. A page that used one falls
|
||||||
|
back to the append path, so its ask still renders.
|
||||||
|
- **`_ask_inline.html`'s dead `standalone=False` macro parameter.** No caller
|
||||||
|
has passed `True` since U2 turned the standalone asks page into a 308.
|
||||||
|
Deleting it is tidy-up and changes a macro signature for no behavioural gain.
|
||||||
|
- **`base.html`'s literal duplicate of the favicon data URI.** It predates this
|
||||||
|
unit. The payload reads `FAVICON_HREF`, so this unit adds no third copy; it
|
||||||
|
does not remove the second.
|
||||||
|
- **`WRAP_MAX_BYTES` and its raw-serve fallback.** Unchanged at 8 MiB. A booth
|
||||||
|
over the cap loses its chrome exactly as it did before — no regression, and
|
||||||
|
the constant keeps its existing test.
|
||||||
|
- **`GET /b/<name>/asks`.** Already a 308 into `/marks` since U2. Left alone:
|
||||||
|
the URL is in the operator's history and in landed reports.
|
||||||
|
- **Pushing, and the version bump tier.** Minor needs the operator's approval.
|
||||||
|
|
||||||
|
## Slices
|
||||||
|
|
||||||
|
| # | slice | red→green on |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `GET /b/{name}/embed.json` — payload shape, order, leniency, no view recorded | payload tests; existing 410 stay green |
|
||||||
|
| 2 | `GET /_booth/embed.js` — served from a startup read, ETag, no hot reload | INV-5 |
|
||||||
|
| 3 | the verbatim branch rewritten; `inject_asks` and `wrap_verbatim_html` deleted | INV-1, INV-2, INV-3 |
|
||||||
|
| 4 | `booth/static/embed.js` — nav, favicon, styles, no marks yet | browser: chip present, icon set, declaring page untouched |
|
||||||
|
| 5 | placement: anchors, tail, submit, re-association | browser: INV-4, INV-6, INV-7; the live `dfa-concepts` and `sindra-voice-1` shapes as fixtures |
|
||||||
|
| 6 | delete `inline.py`; retire the six tests that test the deleted mechanism; docs | suite green, IA doc and ROADMAP updated |
|
||||||
|
|
||||||
|
## Seam review
|
||||||
|
|
||||||
|
The sibling-aware pass, run in-session against the real module surfaces rather
|
||||||
|
than against the sibling contracts' prose. `/heid-contract-review` is
|
||||||
|
artifact-only by design and structurally cannot see `booth/marks.py`, so this is
|
||||||
|
the only gate that can check what the contract borrows from it.
|
||||||
|
|
||||||
|
| # | finding | disposition |
|
||||||
|
|---|---|---|
|
||||||
|
| **SR-1** | The order invariant named `marks_for`'s ordering. The route actually reads through `hold_read` — one read answering both "what is here" and "can it be read", per the TOCTOU lesson — and only falls back to `marks_for` on the error path. Both sort `(created, id)`, so the contract was not wrong, but it named a function where it meant a rule. | **Amended.** INV-6 states the rule. The route's reader is named in the payload section. |
|
||||||
|
| **SR-2** | **The payload shape was wrong.** `questions` as a JSON object keyed by question key breaks on a single-question pick, whose only question has `key: None` (`asks.normalize_ask`, the `multi: False` branch) — `json.dumps` writes that key as the string `"null"`. Every one-question ask in the fleet hits it, including the live `sindra-voice-1`. | **Scope fix.** `questions` is a list of `{key, html}`; `key` is nullable; declaration order is carried by the format. `booth.asks.normalize_ask` added to `depends_on`. |
|
||||||
|
| **SR-3** | `inline.form_id` was inside the module the contract deletes entirely, but it is not placement machinery — it builds the shared `<form>` id the question fragments bind to with `form=`. Deleting the module as written would delete the fragments' ability to submit. | **Scope miss.** `form_id` moves to `booth/app.py`; `touches` says so. |
|
||||||
|
| **SR-4** | A FLAG mark's id is literally `flag:<target>` (`marks.flag_id`) — it contains the separator the anchor spec splits on. It never reaches the payload only because the payload filters `shape == "pick"`, and pick ids are `valid_stem`-checked (no colon). | **No change, stated.** The filter is load-bearing, not incidental; a later widening of the payload to all shapes would break the split rule silently. |
|
||||||
|
| **SR-5** | `_ask_inline.html`'s `question(a, q, form_id, name_url, standalone=False)` has had no caller passing `standalone=True` since the standalone asks page became a 308 in U2. Dead parameter on a macro this unit edits. | **Out of scope, noted.** Deleting it is tidy-up, not this unit's work, and it changes a macro signature for no behavioural gain. |
|
||||||
|
|
||||||
|
## Contract review — the cold panel
|
||||||
|
|
||||||
|
`/heid-contract-review`, four arms, dispatched `01M351WKV666D681SSRNY7D7X6`.
|
||||||
|
Triaged per the cross-frontier discipline: adopted on merits, not on authority.
|
||||||
|
|
||||||
|
| # | finding | arms | disposition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **CR-1** | **The seam never tells `embed.js` which booth it is on.** The declared line is byte-identical for every booth, the payload endpoint needs `{name}`, and the name arrives inside the response the fetch needs it to make. Every other section depends on this unstated hop. | 3 of 4, independently | **Genuine add, and the round's headline.** The code already derived it from `location.pathname`; the CONTRACT did not say so, which makes a "public API" whose discovery mechanism is unspecified not fully one. New section: *How the script learns which booth it is on*. No code change. |
|
||||||
|
| **CR-2** | **INV-3's falsifier was vacuous** — it name-matched the six deleted patterns, so a renamed regex applied to the page body kept it green, on this contract's central promise. | 3 of 4 | **Genuine add, and a CODE-side fix.** The test now asserts `booth/app.py` performs exactly one regex operation anywhere in the module. Verified by mutation in both directions. The lesson is sharper than the fix: **this repo's own vacuity pass missed it because it tried the mutation the contract named** — a pass that picks the named mutation agrees with itself. |
|
||||||
|
| **CR-3** | **Declaration by bare substring fails in the dangerous direction.** A report that merely mentions `/_booth/embed.js` — a code sample, a comment — counted as declaring it and was served with no chrome at all, silently. | 3 of 4 | **Genuine add, CODE-side.** Detection now requires `src="…"` (either quote style), which fails toward a harmless duplicate tag instead. New test covers prose, comment and `?v=2` spellings. |
|
||||||
|
| **CR-4** | **INV-2 and the size cap prescribe different responses** for an over-cap non-declaring page, and neither the invariant's wording nor a named falsifier carved the exception. | 2 of 4 | **Genuine add.** INV-2 now states the cap as an explicit exception and names the test that holds it. Code and test were already right. |
|
||||||
|
| **CR-5** | **The tail's four arrows read as independent tests**, under which one unplaced mark mounts its whole form AND every question AND a submit block. | 1 | **Genuine add.** The notation allowed it and the prose did not forbid it. The block is now explicit if/elif/else. Code was already exclusive. |
|
||||||
|
| **CR-6** | **INV-7 quantified over picks the algorithm filters** (errored picks) and over "visible", which placement cannot guarantee. | 2 of 4 | **Genuine add, wording.** INV-7 is now scoped to READABLE picks and claims *reaches the document*, not *is visible*. |
|
||||||
|
| **CR-7** | The chip's prefix rule can select `bk-ask-batch2-r1` for mark `batch`, or an author's own element. | 1 | **Sharpening.** The code always matched exactly-or-hyphen; the contract said "starts with". Wording fixed, and `test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix` now holds it. |
|
||||||
|
| **CR-8** | Precedence undefined when one element carries both attribute spellings; submit-anchor key handling unstated. | 1 | **Sharpening.** Both stated; `test_the_canonical_attribute_wins_when_both_are_present` added. |
|
||||||
|
| **CR-9** | The damaged-`.marks.json` payload shape was never stated — per-mark `error` was the only error shown. | 1 | **Genuine add, wording.** Stated in *What crosses the seam*. Test already existed. |
|
||||||
|
| **CR-10** | "INV-1" names two different obligations — this contract's untouched-page rule, and the repo's one-resolver rule the assumptions cite. | 1 | **Genuine add, wording.** The assumptions now name CLAUDE.md invariant 3 explicitly. A real collision: the local falsifier goes red on an added newline and stays green if embed.js becomes a second renderer. |
|
||||||
|
| **CR-11** | INV-4's falsifier covered openness while the invariant claimed openness, order AND content. | 1 | **Sharpening.** The falsifier now names a test per clause. |
|
||||||
|
| **CR-12** | `html.questions` keyed by question name vs the top-level `questions` list — which is authoritative? And INV-4 naming `marks_for`'s order while INV-6 fixed `(created, id)`. | 2 | **Settled before the reply landed.** The in-session seam review collapsed both (SR-1, SR-2) while the panel was in flight. Independent convergence on the same two spots — worth recording, not re-fixing. |
|
||||||
|
|
||||||
|
**One arm's finding not adopted**, and the reason: that a question mounted into
|
||||||
|
an author-hidden anchor is still invisible. True, and out of reach — the Booth
|
||||||
|
cannot police an author hiding their own markup. Answered by narrowing INV-7's
|
||||||
|
claim rather than by chasing actual visibility (CR-6).
|
||||||
|
|
||||||
|
**Methodology note the panel raised on its own**, relayed by heid: 5 of 8 arms
|
||||||
|
across two unrelated callers the same evening independently proposed promoting
|
||||||
|
the end-to-end seam-walk from a conditional deliverable to a mandatory one.
|
||||||
|
CR-1 is a direct product of that exercise. Recorded here as evidence; the skill
|
||||||
|
change is the operator's call, not this repo's.
|
||||||
|
|
||||||
|
## Bug hunt — the cold panel
|
||||||
|
|
||||||
|
`/heid-bug-hunt`, four arms, artifact-only over the merge-base diff, dispatched
|
||||||
|
`01M352TPCSN52G6NGJ07T5WSGY`. ⚠ **The snapshot predates the contract-review
|
||||||
|
fixes**, so two of its findings were already closed when the reply landed; the
|
||||||
|
arms flagged the staleness themselves.
|
||||||
|
|
||||||
|
| # | finding | arms | disposition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **BH-1** | **A declaring page was NOT served as written.** `read_text()` opens in universal-newline mode, so a CRLF report came back LF, and `errors="replace"` replaced any non-UTF-8 byte. The headline promise, broken by the read itself — and invisible to a test whose fixture is LF-only ASCII. | 1 | **Genuine add, and the best finding of the round.** The verbatim branch reads and serves BYTES; the decoded copy answers only "does it declare?". INV-1 and INV-2 now state the byte-level promise, with a CRLF-plus-invalid-byte fixture. |
|
||||||
|
| **BH-2** | **A submit anchor inside the author's own `<form>` loses ours** — the HTML parser drops a nested form outright. Every control's `form=` then points at nothing, and the code recorded the pick as submitted so the tail added no fallback. The operator fills it in and the button does nothing. | 1 | **Genuine add.** A submit anchor counts as submitted only if the form actually survived (`hasForm`); otherwise the tail supplies one at body level, where no form encloses it. |
|
||||||
|
| **BH-3** | **A broken pick's diagnostic never rendered from a submit-only anchor.** An errored pick's `submit` is empty; mounting that and marking it placed made the tail skip it, so the "broken ask" box vanished from the one surface built to show it. | 3 of 4 | **Genuine add.** A submit anchor for an errored pick is left alone, exactly as an anchor naming no mark is, and the tail mounts the diagnostic. |
|
||||||
|
| **BH-4** | **An author's own element can hijack the chip.** `<section id="bk-ask-winner-background">` satisfies any id-prefix rule — the hyphen boundary from CR-7 included. | 4 of 4 | **Genuine add, and it supersedes CR-7's fix.** The chip now searches only the elements THIS SCRIPT MOUNTED, which is the identity the deleted `bk-ask-<id>-top` anchor used to guarantee, and takes the earliest of those by `compareDocumentPosition`. |
|
||||||
|
| **BH-5** | **No error boundary around fragment rendering.** A `.marks.json` that is well-formed JSON with a wrong-shaped `answer` hydrates with no error and then raises in the macro. | 1, `needs-repro` | **Genuine add — reproduced before building for it.** `_safe_fragments` returns a per-mark error record, the same leniency `_hydrate_safe` applies one layer down. ⚠ **The gallery and marks pages still 500 on it, and that is PRE-EXISTING** — measured at `42ea67f`. Out of scope here and recorded rather than quietly widened: `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`. |
|
||||||
|
| **BH-6** | Prototype pollution in the placement maps (`toString` as a mark id, `constructor` as a question key). | 1 | **Already fixed this round** as CR-13, from the code-review panel. Two panels, two lenses, the same defect independently — the strongest signal of the evening that the lenses are not redundant. |
|
||||||
|
| **BH-7** | Bare-substring declaration suppresses the chrome. | 4 of 4 | **Already fixed** as CR-3, before the reply landed. |
|
||||||
|
|
||||||
|
**One correction the panel made to this repo's own prose, adopted:** several
|
||||||
|
comments claimed a multi-question pick POSTs a 400 unless every question is
|
||||||
|
answered. It does not — `test_empty_submission_is_refused_with_400` refuses a
|
||||||
|
WHOLLY EMPTY submission, and a partial answer is accepted and recorded on
|
||||||
|
purpose. The real reason an unplaced question must still be appended is simpler
|
||||||
|
and was being obscured: **a question that never reaches the page cannot be
|
||||||
|
answered at all.** Fixed in `embed.js`, the browser tests and this contract.
|
||||||
|
|
||||||
|
**Not adopted:** the bundle's framing called the service Flask. It is FastAPI;
|
||||||
|
the arm noticed and declined to reason from it, which is the right handling.
|
||||||
|
|
||||||
|
## Vacuity pass — final
|
||||||
|
|
||||||
|
21 mutations, each drawn from an invariant's CLAIM rather than its falsifier's
|
||||||
|
example, each run against its named test, plus an unmutated control run.
|
||||||
|
**21/21 caught, control green.**
|
||||||
|
|
||||||
|
The pass earned its place three times over and none of them was the first run:
|
||||||
|
|
||||||
|
1. It reported **7/7** before the contract panel, which then showed INV-3 was
|
||||||
|
vacuous — because the mutation applied was the one the contract named.
|
||||||
|
2. Re-run **against that fix**, it found the fix's own hole (an aliased
|
||||||
|
`import re as _r`).
|
||||||
|
3. Re-run after the bug-hunt fixes, it reported seven **MUTATION-MISS** rows —
|
||||||
|
its loud-failure mode, firing correctly because the fixes had moved the code
|
||||||
|
out from under stale mutations — and then one genuine **VACUOUS**: the
|
||||||
|
sibling-mark chip test had its fixture arranged so the right answer was also
|
||||||
|
the first answer. Rewritten so the sibling comes first, which is the only
|
||||||
|
arrangement that can tell the two implementations apart.
|
||||||
@@ -206,21 +206,41 @@ a real DOM API. Marks land at `data-booth-mark="<id>"` anchors, which keeps the
|
|||||||
page*. If the line is absent, the Booth injects it at **one** insertion point, so
|
page*. If the line is absent, the Booth injects it at **one** insertion point, so
|
||||||
every existing verbatim booth keeps working untouched.
|
every existing verbatim booth keeps working untouched.
|
||||||
|
|
||||||
**What this deletes**, and this is the whole point of the decision:
|
**What this deleted** — landed as U3, 2026-09-22:
|
||||||
|
|
||||||
- `booth/inline.py` — 114 lines of placeholder DSL, entirely
|
- `booth/inline.py` — 119 lines of placeholder DSL, entirely. One line survived:
|
||||||
|
`form_id`, which builds the shared `<form>` id scattered question groups bind
|
||||||
|
to, and which moved to `app.py` beside the route that renders them.
|
||||||
- `wrap_verbatim_html` and its six regexes against arbitrary HTML
|
- `wrap_verbatim_html` and its six regexes against arbitrary HTML
|
||||||
(`_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`, `_BODY_CLOSE_RE`,
|
(`_HEAD_CLOSE_RE`, `_HTML_OPEN_RE`, `_DOCTYPE_RE`, `_BODY_CLOSE_RE`,
|
||||||
`_HTML_CLOSE_RE`, `_ICON_RE`) and the doctype/charset-ordering constraints
|
`_HTML_CLOSE_RE`, `_ICON_RE`) **and both of the constraints they were
|
||||||
they are threading
|
threading.** Not satisfied more carefully — gone: nothing can displace a
|
||||||
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets
|
leading doctype into quirks mode and nothing can push the charset `<meta>`
|
||||||
- `GET /b/<name>/asks` — the standalone page that existed only because a verbatim
|
out of its detection window, because the Booth only ever APPENDS now.
|
||||||
booth could not show its own asks
|
- `_BACK_CHIP`, `asks_chip` — two floating chips positioned by guessed offsets.
|
||||||
|
embed.js builds both in the DOM.
|
||||||
|
- the `styles()` macro. The scoped `.bk-ask-*` rules live in embed.js next to
|
||||||
|
the code that mounts them, emitted once by construction instead of by a
|
||||||
|
seen-set.
|
||||||
|
- `GET /b/<name>/asks` was already a 308 into `/marks` by U2; this unit left it
|
||||||
|
there. The standalone page it named is gone, but the URL is in the operator's
|
||||||
|
history and in landed reports, and a dead link teaches nothing.
|
||||||
|
|
||||||
Regex-injecting into arbitrary author HTML is the single most fragile thing in
|
**What replaced them is a substring test and a `+`.** `if EMBED_SRC not in
|
||||||
the service, and it is load-bearing for the operator's most important workflow.
|
html: html += EMBED_SCRIPT_TAG`. A page that declares the line is served with
|
||||||
|
nothing added to it at all.
|
||||||
|
|
||||||
|
Regex-injecting into arbitrary author HTML was the single most fragile thing in
|
||||||
|
the service, and it was load-bearing for the operator's most important workflow.
|
||||||
A declared seam costs the author one line and removes the whole class.
|
A declared seam costs the author one line and removes the whole class.
|
||||||
|
|
||||||
|
**What it cost, stated because it is real.** The verbatim path used to work with
|
||||||
|
no JavaScript: an ask rendered server-side and submitted through a plain form.
|
||||||
|
It now needs the script. The guarantee that an ask is never invisible survives
|
||||||
|
in a weaker and still-true form through surfaces that need no script — the index
|
||||||
|
card's open-mark badge, and `/b/<name>/marks`, which renders every mark
|
||||||
|
server-side.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# A wrong-shaped answer 500s the gallery and the marks page — PRE-EXISTING, NOT U3
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**Found by the U3 bug-hunt panel, measured against `42ea67f` — the commit
|
||||||
|
BEFORE U3 — so it is not this unit's doing and was not fixed by it.** U3's own
|
||||||
|
surface is guarded; these two are not.
|
||||||
|
|
||||||
|
## The defect
|
||||||
|
|
||||||
|
`.marks.json` that is **well-formed JSON with a wrong-shaped value** passes
|
||||||
|
every reader and then raises in the renderer:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "batch", "shape": "pick", "answer": {"answers": [], "notes": ""}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`_hydrate` only checks `isinstance(entry.get("answer"), dict)` — it never
|
||||||
|
validates `answer["answers"]`. So `marks_for` and `hold_read` both return the
|
||||||
|
mark with `error = None` and **no read error at all**, and then
|
||||||
|
`_ask_inline.html` does `a.answer.answers.get(q.key)`, Jinja asks a list for
|
||||||
|
`.get`, and it raises `UndefinedError`.
|
||||||
|
|
||||||
|
Measured, not reasoned:
|
||||||
|
|
||||||
|
PRE-U3 (42ea67f) gallery page: 500
|
||||||
|
PRE-U3 (42ea67f) marks page: 500
|
||||||
|
PRE-U3 (42ea67f) index: 200
|
||||||
|
|
||||||
|
The index survives because it never renders a fragment.
|
||||||
|
|
||||||
|
## Why it matters more than it looks
|
||||||
|
|
||||||
|
This is **the v0.2.2 shape with a different trigger**. That outage was a
|
||||||
|
`.marks.json` that could not be PARSED; the reader was made lenient and the
|
||||||
|
index stopped 500ing. This one parses perfectly and breaks one layer further in,
|
||||||
|
at render time, where no leniency exists — so the lesson "one damaged file must
|
||||||
|
cost its own tile, not the page" is only half-implemented. `read_error` is
|
||||||
|
answering a narrower question than every caller assumes.
|
||||||
|
|
||||||
|
## What U3 did and did not do
|
||||||
|
|
||||||
|
U3 added `_safe_fragments` around `_pick_fragments`, so `/b/<name>/embed.json`
|
||||||
|
returns a per-mark `error` record instead of a 500 — the same posture
|
||||||
|
`_hydrate_safe` takes one layer down. That protects **the verbatim path only**.
|
||||||
|
|
||||||
|
`booth.html` and `marks.html` call the same macros with no such guard. Left
|
||||||
|
alone deliberately: the gallery is named out of scope in the U3 contract, and
|
||||||
|
widening a unit mid-flight to cover a pre-existing defect in a surface it never
|
||||||
|
touched is the scope drift the roadmap gate exists to stop.
|
||||||
|
|
||||||
|
## The design question it deserves, when it is picked up
|
||||||
|
|
||||||
|
Not "wrap the other two call sites" — that is the third copy of one guard. The
|
||||||
|
real question is **where the boundary belongs**:
|
||||||
|
|
||||||
|
1. **In `_hydrate`**, validating the answer shape so a wrong-shaped answer
|
||||||
|
becomes `error` at hydration and every surface inherits the fix. Cleanest,
|
||||||
|
and consistent with declarations already being normalized on read — but it
|
||||||
|
widens what `error` means.
|
||||||
|
2. **At each render site**, per-mark, as U3 did. Honest and local; three copies.
|
||||||
|
3. **In the template**, defensively. Cheapest and worst — it hides the fact
|
||||||
|
that anything is wrong.
|
||||||
|
|
||||||
|
(1) is the shape the rest of this module already argues for: one predicate,
|
||||||
|
one place. Worth an operator decision because it changes what a `Mark` can be.
|
||||||
|
|
||||||
|
⚠ Reproduce with the fixture in
|
||||||
|
`tests/test_embed.py::test_a_wrongly_shaped_answer_costs_its_pick_not_the_report`,
|
||||||
|
whose closing comment points back here.
|
||||||
|
|
||||||
|
Related: [[2026-09-21-marks-write-wiped-judgment]],
|
||||||
|
[[2026-09-22-lenient-reader-blast-radius]],
|
||||||
|
[[2026-09-22-u3-declared-embed-seam-landed]].
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# No fleetwide notice for U4 — and what that does to the prediction
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**Operator decision, 2026-09-22: do NOT tell the 17 consuming handles that
|
||||||
|
`keep` has stopped being the way to say "waiting on an answer".** No broadcast.
|
||||||
|
Same posture he took on U5, and the same instrument: adoption gets told apart
|
||||||
|
from design because nobody was primed.
|
||||||
|
|
||||||
|
⚠ **THIS CHANGES HOW THE 2026-10-06 RE-COUNT MUST BE READ, and a session that
|
||||||
|
misses this will draw the wrong conclusion from a true number.**
|
||||||
|
|
||||||
|
U4 has two halves and they do NOT have the same adoption cost:
|
||||||
|
|
||||||
|
- **The hold rides for free.** A session that runs `booth ask` gets its booth
|
||||||
|
held with no knowledge of anything. The operator answering releases it. No
|
||||||
|
peer has to learn a thing for the mechanism to work.
|
||||||
|
- **NOT PRESSING `keep` HAS TO BE LEARNED.** U4 makes the sentinel unnecessary
|
||||||
|
for the "not yet" case; it does not make it unavailable, and nothing stops a
|
||||||
|
habit. An agent that has always pressed `keep` while waiting will keep
|
||||||
|
pressing it.
|
||||||
|
|
||||||
|
**So a flat `.forever` rate on 2026-10-06 does NOT falsify the diagnosis.** It
|
||||||
|
is exactly what "the mechanism works and nobody was told" looks like — the U5
|
||||||
|
shape, one unit later. Reporting a null result without this caveat would retire
|
||||||
|
a correct diagnosis on the strength of an uncontrolled measurement.
|
||||||
|
|
||||||
|
**Use these instead, and report all three.** The raw rate stays as context, not
|
||||||
|
as the verdict:
|
||||||
|
|
||||||
|
1. **The overlap — booths with an open pick AND `.forever`.** Was **3** on
|
||||||
|
2026-09-22, which is the positive control for the whole diagnosis. It falls
|
||||||
|
only if peers learn; it is the *adoption* number.
|
||||||
|
|
||||||
|
comm -12 <(grep -l '"shape": "pick"' ~/booth-data/*/.marks.json | xargs -n1 dirname | sort) \
|
||||||
|
<(dirname ~/booth-data/*/.forever | sort) | wc -l
|
||||||
|
|
||||||
|
2. **Did the hold ever bind?** Count booths that were held past their TTL and
|
||||||
|
therefore survived a sweep they would otherwise have lost. This needs no
|
||||||
|
peer to change anything, so it is the honest test of whether the mechanism
|
||||||
|
is load-bearing at all. **If it is ZERO over a fortnight, the diagnosis was
|
||||||
|
wrong about VOLUME** — the "not yet" case is rarer than the sentinel rate
|
||||||
|
suggested — and that is a real finding. The sweeper logs what it wipes;
|
||||||
|
nothing yet logs what it spares, so **this counter does not exist and would
|
||||||
|
have to be added before it can be read.** Say so rather than guessing.
|
||||||
|
3. **The raw `.forever` rate** — 17 of 24 (70%) on 2026-09-22. Context only,
|
||||||
|
now that the no-notice decision has made it a measurement of habit rather
|
||||||
|
than of need.
|
||||||
|
|
||||||
|
⚠ **Sensitivity floor, stated because a bare "no effect" is unfalsifiable:**
|
||||||
|
only **4 of 24** booths carried marks at all on 2026-09-22. The hold cannot
|
||||||
|
bind on a booth with no marks, so at that population the mechanism can touch at
|
||||||
|
most a sixth of the fleet, and an effect smaller than one or two booths is not
|
||||||
|
resolvable by any of these counts. Related: `[[2026-09-22-forever-had-a-live-positive-control]]`.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# A vacuity pass that tries the contract's own mutation agrees with itself
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
The contract-time **vacuity pass** — for each invariant, name a change that
|
||||||
|
defeats it and check the named test goes red — was proposed independently by
|
||||||
|
Regin and Kimi on U4's paraphrase round, and U4's own code-review panel then
|
||||||
|
showed **five of seven** U4 falsifiers were vacuous: a green test *cited* by an
|
||||||
|
`INV` rather than a test that would *fail* if the invariant broke. See
|
||||||
|
[[2026-09-22-vacuous-falsifiers]].
|
||||||
|
|
||||||
|
U3 ran the pass as a real instrument rather than a promise. Script in the
|
||||||
|
session scratchpad; for each invariant it applies the mutation the contract's
|
||||||
|
*Falsifiable:* line names, runs the single named test, and asserts a **non-zero**
|
||||||
|
exit, restoring the file in a `finally` either way.
|
||||||
|
|
||||||
|
| INV | mutation applied | verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 declaring page untouched | append `<!-- booth -->` to the declaring branch | FALSIFIED |
|
||||||
|
| 2 appended, never inserted | insert the tag before `<title>` instead | FALSIFIED |
|
||||||
|
| 3 no regex on author HTML | re-declare `_ICON_RE` in `app.py` | FALSIFIED |
|
||||||
|
| 4 openness is the server's | have `embed.js` derive open from `bk-done` | FALSIFIED |
|
||||||
|
| 5 embed.js read once | `read_text()` per request in the route | FALSIFIED |
|
||||||
|
| 6 tail in payload order | iterate the marks list backwards | FALSIFIED |
|
||||||
|
| 7 unplaced questions appended | short-circuit the append branch to `if (false)` | FALSIFIED |
|
||||||
|
|
||||||
|
**7/7**, and — the part that makes it a measurement rather than a ritual — an
|
||||||
|
**unmutated control run** confirming all seven named tests are green when
|
||||||
|
nothing is broken. Without that control, a script whose mutation silently failed
|
||||||
|
to apply (the text not found, the wrong file) reports the same clean-looking
|
||||||
|
table. The script halts with `MUTATION-MISS` if its target string is absent,
|
||||||
|
for exactly that reason.
|
||||||
|
|
||||||
|
## Why it is worth the ten minutes
|
||||||
|
|
||||||
|
Three of the seven falsifiers are in `embed.js`, which the Python suite cannot
|
||||||
|
see at all. INV-4, INV-6 and INV-7 are held **only** by browser tests, and
|
||||||
|
"there is a browser test named after this invariant" is precisely the kind of
|
||||||
|
claim that feels like coverage and can be empty. Two of those three mutations
|
||||||
|
are one-token edits — `marks.length - 1` and `if (false)` — so the cost of
|
||||||
|
checking was minutes and the cost of being wrong was an invariant nobody was
|
||||||
|
holding.
|
||||||
|
|
||||||
|
**The general shape:** an instrument that cannot fail loudly will fail quietly.
|
||||||
|
Same family as the `(gasp)` tag-detection specimen in the global measurement
|
||||||
|
rule, and as the zsh word-splitting bug that shipped an empty heid bundle —
|
||||||
|
[[2026-09-22-four-paths-to-one-fail-open-delete]]. A clean result and a broken
|
||||||
|
method are indistinguishable from the output alone unless something in the
|
||||||
|
method is designed to go red.
|
||||||
|
|
||||||
|
## ⚠ AND THEN THE COLD PANEL SHOWED ONE OF THE SEVEN WAS VACUOUS ANYWAY
|
||||||
|
|
||||||
|
The table above is real and it was **not sufficient**. The `/heid-contract-review`
|
||||||
|
panel (`01M351WKV666D681SSRNY7D7X6`) — three of four arms, independently —
|
||||||
|
showed **INV-3's falsifier was vacuous**, on this contract's central promise, and
|
||||||
|
the pass above had passed it.
|
||||||
|
|
||||||
|
**Why the pass missed it.** INV-3 claims *no regular expression is applied to
|
||||||
|
author HTML*. The test name-matched the six DELETED patterns. The mutation the
|
||||||
|
pass applied was re-declaring `_ICON_RE` — **the pattern the contract named** —
|
||||||
|
which the name-match caught. The mutation the invariant actually forbids is a
|
||||||
|
regex under a *new* name (`_TAIL_RE.sub(...)` in the verbatim branch), and that
|
||||||
|
sailed through green.
|
||||||
|
|
||||||
|
> **The mutation has to come from the INVARIANT'S CLAIM, not from the
|
||||||
|
> FALSIFIER'S EXAMPLE.** A pass that applies the contract's own suggested
|
||||||
|
> mutation is testing the contract against itself, and it will agree.
|
||||||
|
|
||||||
|
**Then the fix had a hole too, and only a re-run found it.** The repaired test
|
||||||
|
asserts `booth/app.py` performs exactly one regex operation. Re-running the pass
|
||||||
|
*against the fix* showed an aliased `import re as _r` routes around the call
|
||||||
|
check under a name it does not know — still VACUOUS. Closed with an import-shape
|
||||||
|
assertion. **Run the pass on the repair, not only on the draft.**
|
||||||
|
|
||||||
|
Final state: **10/10 falsifiable**, control green, the two extra rows being the
|
||||||
|
panel's own findings turned into falsifiers.
|
||||||
|
|
||||||
|
## What this is evidence for
|
||||||
|
|
||||||
|
U4 measured the problem (five of seven vacuous). U3 measured a pass working
|
||||||
|
(7/7), then measured **the pass's own blind spot**, then measured the fix's
|
||||||
|
blind spot. All three belong in the case if the vacuity-pass proposal is ever
|
||||||
|
put to the operator as a `/heid*` skill amendment — and the second and third
|
||||||
|
are the parts that stop it being adopted as a ritual that always passes.
|
||||||
|
|
||||||
|
Related: [[2026-09-22-u3-declared-embed-seam-landed]],
|
||||||
|
[[2026-09-22-the-browser-became-a-test-surface]].
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# The browser became a test surface, and the version bound is the foot-gun
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
U3 moved load-bearing logic out of Python and into JavaScript: which fragment
|
||||||
|
lands at which anchor, what gets appended, and whether a `<form>` scattered down
|
||||||
|
a report still owns the controls pointing at it. **The Python suite is blind to
|
||||||
|
every one of those.** Shipping U3 with only payload-shape tests would have
|
||||||
|
deleted ~10 real tests and replaced them with assertions that cannot see the
|
||||||
|
thing the operator actually depends on.
|
||||||
|
|
||||||
|
So `tests/test_embed_browser.py` drives a real Chromium against a real uvicorn
|
||||||
|
on an ephemeral port. 12 tests. It found nothing on the first run — but the
|
||||||
|
probe that preceded it settled a design question no amount of spec-reading
|
||||||
|
would have.
|
||||||
|
|
||||||
|
## The probe, and why it had controls
|
||||||
|
|
||||||
|
**Question:** if a control carrying `form="F"` is inserted into the DOM *before*
|
||||||
|
`<form id="F">` exists, does it become that form's control? The HTML spec resets
|
||||||
|
form owner on insertion and on the `form` attribute changing — it does NOT list
|
||||||
|
"a matching form was inserted later". The U3 design inserts fragments in visual
|
||||||
|
order, so this happens routinely.
|
||||||
|
|
||||||
|
Four conditions, N=3 each, in Chromium 151 headless:
|
||||||
|
|
||||||
|
| condition | `input.form?.id` |
|
||||||
|
|---|---|
|
||||||
|
| A — form inserted first (**positive control**) | `F, F, F` |
|
||||||
|
| B — control inserted first (**the question**) | `F, F, F` |
|
||||||
|
| C — `form="NOPE"`, no such form (**negative control**) | `null, null, null` |
|
||||||
|
| D — remove and re-set the attribute (the proposed fix) | `F, F, F` |
|
||||||
|
|
||||||
|
The positive control proves the instrument can see association at all; the
|
||||||
|
negative proves it is not manufacturing it. Without both, B's answer means
|
||||||
|
nothing — that is the whole lesson of
|
||||||
|
[[2026-09-22-vacuous-falsifiers]] applied before the code instead of after.
|
||||||
|
|
||||||
|
**The answer is: Chromium re-resolves it, so the fix is unnecessary there.**
|
||||||
|
The fix shipped anyway. **Sensitivity floor: ONE ENGINE.** The operator's own
|
||||||
|
browser was not measured, the failure mode is a form that looks filled in and
|
||||||
|
POSTs a 400, and the guard is three lines. The measurement says "not needed
|
||||||
|
here"; it does not say "not needed".
|
||||||
|
|
||||||
|
## The foot-gun, which bit before the tests were written
|
||||||
|
|
||||||
|
Browsers are **box-wide** in `/opt/ms-playwright` with
|
||||||
|
`PLAYWRIGHT_BROWSERS_PATH` wired globally — there is no per-project
|
||||||
|
`playwright install`. Each playwright release pins **one** Chromium revision, and
|
||||||
|
a release wanting a revision the shared store lacks dies with:
|
||||||
|
|
||||||
|
Executable doesn't exist at /opt/ms-playwright/chromium_headless_shell-1243/…
|
||||||
|
|
||||||
|
That is not a missing-dependency error and it does not name the real problem.
|
||||||
|
The store had 1223 / 1228 / 1234; `playwright` 1.63 wanted 1243. The mapping:
|
||||||
|
|
||||||
|
1.60 -> 1223 1.61 -> 1228 1.62 -> 1234 1.63 -> 1243
|
||||||
|
|
||||||
|
Hence `playwright>=1.60,<1.63` in `pyproject.toml`, **with the upper bound as the
|
||||||
|
point** and the reason in a comment beside it. A bare `playwright` would break
|
||||||
|
the suite on the next resolve, opaquely.
|
||||||
|
|
||||||
|
## The hermeticity trade, and how it is paid
|
||||||
|
|
||||||
|
A browser layer makes the suite non-hermetic — it can go red for an environment
|
||||||
|
reason. `tests/test_embed_browser.py` therefore **skips, never fails**, when
|
||||||
|
playwright or a usable browser is missing (`pytest.importorskip`, plus a
|
||||||
|
`pytest.skip` on any launch failure). `pytest -q` stays green anywhere; the
|
||||||
|
browser layer is purely additive.
|
||||||
|
|
||||||
|
⚠ **The failure mode of that choice: if those 12 tests start SKIPPING on this
|
||||||
|
box, U3's placement logic is untested and the suite still says green.** If the
|
||||||
|
count drops from 431, check the skip reason before anything else — the pinned
|
||||||
|
bound has probably drifted past the shared store.
|
||||||
|
|
||||||
|
Related: [[2026-09-22-u3-declared-embed-seam-landed]].
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# U3 landed — the page declares the seam, the Booth mounts into it
|
||||||
|
|
||||||
|
_2026-09-22 · booth_
|
||||||
|
|
||||||
|
**Ten regular expressions against author-written HTML are gone.** 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.
|
||||||
|
What replaced them, in full:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
|
||||||
|
```
|
||||||
|
|
||||||
|
A substring test and a `+`. **Both of the old wrapper's hard constraints stopped
|
||||||
|
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 first-1024-byte window, because nothing in front of them ever moves.
|
||||||
|
|
||||||
|
## What moved where
|
||||||
|
|
||||||
|
| was | is |
|
||||||
|
|---|---|
|
||||||
|
| `wrap_verbatim_html` + 6 regexes | `embed_verbatim` — one `in`, one `+` |
|
||||||
|
| `booth/inline.py`, 119 lines | deleted; `form_id` survived into `app.py` |
|
||||||
|
| `_BACK_CHIP`, `asks_chip` | built in the DOM by `embed.js` |
|
||||||
|
| `inject_asks` | `GET /b/<name>/embed.json` + placement in `embed.js` |
|
||||||
|
| `_ask_inline.html`'s `styles()` | the CSS lives in `embed.js` |
|
||||||
|
| `FAVICON_LINK` string injection | `document.querySelector('link[rel~="icon"]')` |
|
||||||
|
|
||||||
|
**The fragments are still rendered by Jinja.** `embed.js` places what comes back
|
||||||
|
and never builds one — a second renderer in JavaScript would be the same bug
|
||||||
|
INV-1 exists to stop, in a new language. The payload also decides openness
|
||||||
|
(`open_marks`) and order, so the page has no opinion about either.
|
||||||
|
|
||||||
|
## The thing the contract got wrong, and the seam review caught
|
||||||
|
|
||||||
|
The payload first keyed `questions` by question key. **A single-question pick
|
||||||
|
normalizes to `questions: [{"key": None, …}]`** (`asks.normalize_ask`, the
|
||||||
|
`multi: False` branch), and `json.dumps` writes that key as the string `"null"`
|
||||||
|
— inventing a name that collides with a real key. Every one-question ask in the
|
||||||
|
fleet would have hit it, including the live `sindra-voice-1`. `questions` is a
|
||||||
|
LIST of `{key, html}` now; the key is nullable, and declaration order rides in
|
||||||
|
the format instead of leaning on object-key insertion order.
|
||||||
|
|
||||||
|
The cold contract panel could not have found this: it is a fact about
|
||||||
|
`booth/asks.py`, which an artifact-only reader never sees. Third time the seam
|
||||||
|
review has caught what the cold pass structurally cannot — see
|
||||||
|
[[2026-09-21-two-gates-are-complementary]].
|
||||||
|
|
||||||
|
## The live report that was already subtly broken
|
||||||
|
|
||||||
|
`dfa-concepts/index.html` writes `<div class="ask" data-booth-ask="dfa:logo">
|
||||||
|
<h3>The one asset that must survive</h3>`. `_EL_RE` matched the **opening tag**
|
||||||
|
and replaced it, so the author's `.ask` wrapper class vanished, the heading was
|
||||||
|
orphaned and the `</div>` went stray. Nobody filed a bug, because a page that is
|
||||||
|
95% right does not look broken.
|
||||||
|
|
||||||
|
`el.insertAdjacentHTML("beforeend", frag)` keeps the element and its contents
|
||||||
|
and puts the fragment inside. Verified live in a real browser: 5 author `.ask`
|
||||||
|
wrappers intact, 5 headings intact, 14 radios mounted inside them, zero console
|
||||||
|
errors. **The replacement is not just less fragile, it renders the operator's
|
||||||
|
own report more faithfully than the thing it replaced.**
|
||||||
|
|
||||||
|
## The cost, stated because it is real
|
||||||
|
|
||||||
|
The verbatim path used to work with **no JavaScript** — server-rendered ask, plain
|
||||||
|
form POST, HTML5 `form=` binding resolved at parse time. It needs the script now.
|
||||||
|
The operator's 2026-09-21 ruling accepts that; this entry records the consequence
|
||||||
|
so nobody meets it as a surprise. The never-invisible guarantee survives in a
|
||||||
|
weaker and still-true form through surfaces needing no script: the index card's
|
||||||
|
open-mark badge, and `/b/<name>/marks`.
|
||||||
|
|
||||||
|
## Anchor syntax
|
||||||
|
|
||||||
|
`data-booth-mark` is canonical (U2 made an ask one shape of mark).
|
||||||
|
`data-booth-ask` is a kept alias — 2 of the 4 live verbatim booths spell it that
|
||||||
|
way, in the operator's own reports, and the alias is one clause in one selector
|
||||||
|
string. The `<!-- booth:ask … -->` comment forms were **dropped, not ported**:
|
||||||
|
zero users across all 21 live booths, and a page that used one falls back to the
|
||||||
|
append path, so its ask still renders.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
431 tests (410 → 431). Live: all 21 booths 200, and each of the four verbatim
|
||||||
|
booths grew by exactly 46 bytes — `len(EMBED_SCRIPT_TAG)`, one append, nothing
|
||||||
|
else. Related: [[2026-09-22-the-browser-became-a-test-surface]],
|
||||||
|
[[2026-09-22-seven-of-seven-falsifiers]],
|
||||||
|
[[2026-09-21-regex-injecting-chrome]].
|
||||||
+60
-26
@@ -21,28 +21,51 @@ _As of 2026-09-22:_
|
|||||||
|
|
||||||
- **v1 is gated on seven units** in `ROADMAP.md`, dependency-ordered
|
- **v1 is gated on seven units** in `ROADMAP.md`, dependency-ordered
|
||||||
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
|
**U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent**.
|
||||||
- **U1, U2, U4 and U5 are landed.** U1 `ce598b3`; U2 `c7f9437` → `v0.2.0`,
|
- **U1, U2, U3, U4 and U5 are landed — the whole middle tier is closed.** U1
|
||||||
`5e41108` → `v0.2.1`, `026a1fc` → `v0.2.2`; U5 `c015a91` + `95beede` →
|
`ce598b3`; U2 `c7f9437` → `v0.2.0`, `5e41108` → `v0.2.1`, `026a1fc` →
|
||||||
`v0.3.0`. **U4 landed 2026-09-22** — 396 tests green (341 → 396), deployed and
|
`v0.2.2`; U5 `c015a91` + `95beede` → `v0.3.0`; U4 `c3a97c1` → `v0.4.0`.
|
||||||
verified live, 24/24 booth pages 200, layout probe clean.
|
**U3 landed 2026-09-22 and released as `v0.5.0`** — 444 tests green
|
||||||
|
(410 → 444), deployed and verified live, 23/23 booth pages 200, and each of
|
||||||
|
the four verbatim booths served at exactly +46 bytes, which is
|
||||||
|
`len(EMBED_SCRIPT_TAG)` — one append, nothing else. `87e2c53` is the unit,
|
||||||
|
`5c20e2f` the panel fixes. Operator approved the minor and authorised the
|
||||||
|
push on 2026-09-22; **this is the first push of this repo's history** — it was
|
||||||
|
26 commits ahead of `origin/main` before it, so every earlier tag reached the
|
||||||
|
remote at the same time.
|
||||||
- **U4 released as `v0.4.0`** (operator approved the minor on 2026-09-22).
|
- **U4 released as `v0.4.0`** (operator approved the minor on 2026-09-22).
|
||||||
`c3a97c1` is the unit; the release commit carries the pre-existing fixes the
|
`c3a97c1` is the unit; the release commit carries the pre-existing fixes the
|
||||||
bug-hunt panel surfaced in touched files. The tag waited for the last gate to
|
bug-hunt panel surfaced in touched files. The tag waited for the last gate to
|
||||||
close, per the `v0.2.0` lesson — see Tried and abandoned.
|
close, per the `v0.2.0` lesson — see Tried and abandoned.
|
||||||
- ⚠ **The 17 consuming handles have NOT been told** that `keep` no longer means
|
- ⚠ **The 17 consuming handles are NOT being told** that `keep` no longer means
|
||||||
"waiting on an answer". That is the one coordination this release genuinely
|
"waiting on an answer" — operator decision, 2026-09-22, no broadcast. This is
|
||||||
warrants, and a fleetwide post needs operator approval before it is sent.
|
deliberate and it CHANGES HOW THE 2026-10-06 RE-COUNT READS: the hold rides
|
||||||
- **THE NEXT UNIT IS THE OPERATOR'S CALL.** U3 (declared embed seam) and U6
|
for free, but not-pressing-`keep` has to be learned, so a flat `.forever` rate
|
||||||
(benches) are both unblocked; U7 waits on the rest. U6 is independent of
|
does not falsify anything. Read its entry before measuring.
|
||||||
everything and was conceptually unblocked by U5 giving job 5 a home; U3 is
|
- **TWO UNITS LEFT TO v1, and they do not depend on each other.** U6 (benches,
|
||||||
where verbatim-booth provenance was deferred to, and U4 added a fourth reason
|
independent, closes the 69% link-board rot) and U7 (navigation at 270 items,
|
||||||
to want it — a verbatim booth has no Booth-rendered header, so its lifetime
|
which U3 just unblocked — its only dependency was {U3, U4, U5}). Which goes
|
||||||
line lives only on the index card and the marks page.
|
next is the operator's call. ⚠ Before starting U7, read
|
||||||
- **No gate is outstanding.** All three ran on U4 and were folded in: the
|
`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`: every booth
|
||||||
`/heid-contract-review` panel (`01M34VX0SH23Y3VC92E7GM4S70`), the
|
that actually needs navigation is FLAT, so half its premise is already known
|
||||||
`/heid-code-review` panel (`01M34WAFJC3RTERFYBBZJN1SVG`) and the
|
to be wrong.
|
||||||
`/heid-bug-hunt` (`01M34Y2R0RAJRSN36Q8K4KAB36`). All loops closed with heid.
|
- **U3's tier was MINOR and the operator approved it** (2026-09-22). The
|
||||||
The U5 round's three are also closed (`01M340PNVRS21HPASZT38PXQPN`,
|
argument that settled it, recorded because the tie-break rule says patch: a
|
||||||
|
capability arrived AND one left — the verbatim path gained a declared public
|
||||||
|
API (`<script src="/_booth/embed.js" defer>`) and lost no-JavaScript
|
||||||
|
operation. That asymmetry is what made it not a tie.
|
||||||
|
- **ALL FOUR U3 GATES ARE CLOSED.** In-session seam review (5 findings, SR-2 a
|
||||||
|
real payload-shape bug); `/heid-contract-review`
|
||||||
|
(`01M351WKV666D681SSRNY7D7X6`, 12 findings, 10 adopted, 2 already settled by
|
||||||
|
the seam review while it was in flight, 1 declined);
|
||||||
|
`/heid-code-review` (`01M352RXV1ZET566KV73C7TSB8`, 3 more vacuous falsifiers
|
||||||
|
+ the prototype-pollution bug); `/heid-bug-hunt`
|
||||||
|
(`01M352TPCSN52G6NGJ07T5WSGY`, 5 net-new, incl. the byte-exactness break).
|
||||||
|
**Seven of the adopted findings were CODE fixes, not wording** — the cold
|
||||||
|
gates were not ceremony on this unit. The **seam review ran in-session and is
|
||||||
|
folded in** — five findings as a table at the end of the U3 contract, and SR-2
|
||||||
|
was a real payload-shape bug the cold panel structurally could not see. U4's three and U5's three are all closed
|
||||||
|
(`01M34VX0SH23Y3VC92E7GM4S70`, `01M34WAFJC3RTERFYBBZJN1SVG`,
|
||||||
|
`01M34Y2R0RAJRSN36Q8K4KAB36`; `01M340PNVRS21HPASZT38PXQPN`,
|
||||||
`01M341E9XAPZEFBSPK9HPGAM0S`, `01M343SXX27Z47C3STXXRC7M42`).
|
`01M341E9XAPZEFBSPK9HPGAM0S`, `01M343SXX27Z47C3STXXRC7M42`).
|
||||||
- **Two dated predictions are pending and must not be forgotten.** U5's adoption
|
- **Two dated predictions are pending and must not be forgotten.** U5's adoption
|
||||||
re-measure on **2026-09-29** (two counts, see its entry — already at 3 of 24
|
re-measure on **2026-09-29** (two counts, see its entry — already at 3 of 24
|
||||||
@@ -51,19 +74,30 @@ _As of 2026-09-22:_
|
|||||||
U4's success criterion. ⚠ Only 4 booths carry marks at all, so the hold's live
|
U4's success criterion. ⚠ Only 4 booths carry marks at all, so the hold's live
|
||||||
blast radius is small and the prediction rests on both halves of U4 — see its
|
blast radius is small and the prediction rests on both halves of U4 — see its
|
||||||
entry for what a null result would and would not mean.
|
entry for what a null result would and would not mean.
|
||||||
- **Three methodology proposals from this session sit with the operator**, routed
|
- **FOUR methodology proposals sit with the operator, all UNTRACKED BY OPERATOR
|
||||||
by heid rather than decided unilaterally: reshaping the paraphrase gate toward
|
CHOICE** (no issue, no ticket — they are `/heid*` skill changes, not this
|
||||||
a drift-check for narrative-heavy contracts, a standing
|
repo's work, and are recorded here only so they are not lost). Three are from
|
||||||
"green-tests-prove-nothing" direction for the code-review gate, and regin's
|
the U5 round: reshaping the paraphrase gate toward a drift-check for
|
||||||
table-vs-signature consistency pass. They are changes to the `/heid*` skills,
|
narrative-heavy contracts, a standing "green-tests-prove-nothing" direction
|
||||||
not to this repo.
|
for the code-review gate, and regin's table-vs-signature consistency pass.
|
||||||
- The booth set churns hard: 26 → 24 during this session as the sweeper ran.
|
The fourth is new and is the one with evidence behind it: a **contract-time
|
||||||
Re-count rather than trusting any number written here.
|
VACUITY PASS** — for each invariant, name a change that defeats it and check
|
||||||
|
the test goes red. Regin and Kimi proposed it independently on the U4
|
||||||
|
paraphrase round; the code-review panel then showed five of seven U4
|
||||||
|
falsifiers were vacuous, and heid rates that the strongest single data point
|
||||||
|
for it so far. See `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`.
|
||||||
|
- The booth set churns hard: 26 → 24 → 25 across the last two sessions as the
|
||||||
|
sweeper ran. Re-count rather than trusting any number written here.
|
||||||
|
|
||||||
## Recent decisions
|
## Recent decisions
|
||||||
|
|
||||||
|
- `[2026-09-22]` **U3 landed — the page declares the seam, the Booth mounts into it** — ten regexes against author HTML replaced by a substring test and a `+` → `persistent-memory.d/2026-09-22-u3-declared-embed-seam-landed.md`
|
||||||
|
- `[2026-09-22]` **A wrong-shaped answer 500s the gallery and the marks page** — PRE-EXISTING (measured at `42ea67f`), NOT U3; the v0.2.2 lesson is only half-implemented → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md`
|
||||||
|
- `[2026-09-22]` **The browser became a test surface** — READ BEFORE TOUCHING `playwright` IN pyproject; the pinned upper bound is the foot-gun, and these tests SKIP rather than fail → `persistent-memory.d/2026-09-22-the-browser-became-a-test-surface.md`
|
||||||
|
- `[2026-09-22]` **A vacuity pass that tries the contract's own mutation agrees with itself** — U3 ran one, reported 7/7, and a cold panel then showed one of the seven was vacuous; READ BEFORE WRITING A *Falsifiable:* LINE → `persistent-memory.d/2026-09-22-seven-of-seven-falsifiers.md`
|
||||||
- `[2026-09-22]` **U4 landed — lifetime is derived, not declared** — three states, viewing is activity, and no new arithmetic anywhere → `persistent-memory.d/2026-09-22-u4-derived-lifetime-landed.md`
|
- `[2026-09-22]` **U4 landed — lifetime is derived, not declared** — three states, viewing is activity, and no new arithmetic anywhere → `persistent-memory.d/2026-09-22-u4-derived-lifetime-landed.md`
|
||||||
- `[2026-09-22]` **The `.forever` diagnosis got a live positive control** — 3 of the 4 booths awaiting an answer were ALSO hand-pinned — RE-COUNT 2026-10-06 → `persistent-memory.d/2026-09-22-forever-had-a-live-positive-control.md`
|
- `[2026-09-22]` **The `.forever` diagnosis got a live positive control** — 3 of the 4 booths awaiting an answer were ALSO hand-pinned — RE-COUNT 2026-10-06 → `persistent-memory.d/2026-09-22-forever-had-a-live-positive-control.md`
|
||||||
|
- `[2026-09-22]` **No fleetwide notice for U4, and what that does to the prediction** — READ BEFORE THE 2026-10-06 RE-COUNT; a flat rate does not falsify the diagnosis → `persistent-memory.d/2026-09-22-no-notice-and-what-it-does-to-the-prediction.md`
|
||||||
- `[2026-09-22]` **Four independent paths to one fail-open delete** — the bug-hunt panel's class, and the zsh word-splitting trap that shipped an empty bundle → `persistent-memory.d/2026-09-22-four-paths-to-one-fail-open-delete.md`
|
- `[2026-09-22]` **Four independent paths to one fail-open delete** — the bug-hunt panel's class, and the zsh word-splitting trap that shipped an empty bundle → `persistent-memory.d/2026-09-22-four-paths-to-one-fail-open-delete.md`
|
||||||
- `[2026-09-22]` **Two reads of one file are not one read of one state** — a TOCTOU seam that composes two correct readers into a fail-open delete → `persistent-memory.d/2026-09-22-two-reads-are-not-one-state.md`
|
- `[2026-09-22]` **Two reads of one file are not one read of one state** — a TOCTOU seam that composes two correct readers into a fail-open delete → `persistent-memory.d/2026-09-22-two-reads-are-not-one-state.md`
|
||||||
- `[2026-09-22]` **Five of seven INV falsifiers did not falsify anything** — read before writing a *Falsifiable:* line; a green test cited one rather than being one → `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`
|
- `[2026-09-22]` **Five of seven INV falsifiers did not falsify anything** — read before writing a *Falsifiable:* line; a green test cited one rather than being one → `persistent-memory.d/2026-09-22-vacuous-falsifiers.md`
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "booth"
|
name = "booth"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
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 = [
|
||||||
@@ -15,6 +15,15 @@ dependencies = [
|
|||||||
test = [
|
test = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"httpx>=0.27", # fastapi TestClient
|
"httpx>=0.27", # fastapi TestClient
|
||||||
|
# U3's embed seam moves placement into the browser, where no string
|
||||||
|
# assertion can see it. Browsers are NOT downloaded per project: they live
|
||||||
|
# box-wide in /opt/ms-playwright with PLAYWRIGHT_BROWSERS_PATH wired
|
||||||
|
# globally. THE UPPER BOUND IS THE POINT -- each playwright release pins a
|
||||||
|
# Chromium revision, and one that wants a revision the shared store does
|
||||||
|
# not have dies with an opaque "Executable doesn't exist" rather than a
|
||||||
|
# missing-dependency error. 1.60-1.62 map to chromium 1223/1228/1234, all
|
||||||
|
# present. Raise the bound only after the store has the newer revision.
|
||||||
|
"playwright>=1.60,<1.63",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
+71
-67
@@ -13,7 +13,7 @@ import pathlib
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from booth.app import build_gallery, create_app, list_booths
|
from booth.app import EMBED_SCRIPT_TAG, build_gallery, create_app, list_booths
|
||||||
from booth.asks import (
|
from booth.asks import (
|
||||||
ANSWER_SUFFIX,
|
ANSWER_SUFFIX,
|
||||||
ASK_SUFFIX,
|
ASK_SUFFIX,
|
||||||
@@ -410,32 +410,37 @@ def test_declare_pick_accepts_a_full_multi_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_renders_the_ask_inline(client):
|
def test_verbatim_booth_offers_the_ask_over_the_seam(client):
|
||||||
|
"""U3: the report is served as written and the ask crosses the declared seam.
|
||||||
|
Before U3 the fragments were substituted into the page body by regex; the
|
||||||
|
guarantee that the ask is reachable FROM THE REPORT, not from another page,
|
||||||
|
is unchanged — it is the delivery that moved."""
|
||||||
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 "Which render wins?" in html # ...with the ask ON it, not elsewhere
|
assert "Which render wins?" not in html # ...and NOTHING was injected into it
|
||||||
assert 'type="radio"' in html and 'action="/b/b/answer"' in html
|
assert html.endswith(EMBED_SCRIPT_TAG)
|
||||||
assert "bk-ask" in html # self-contained fragment styles
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
assert "booth-nav-asks" in html # chip remains, as a jump link
|
assert "Which render wins?" in m["whole"]
|
||||||
assert "#bk-ask-winner-top" in html
|
assert 'type="radio"' in m["whole"] and 'action="/b/b/answer"' in m["submit"]
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_chip_disappears_once_answered(client):
|
def test_verbatim_chip_disappears_once_answered(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _ask(data / "b")
|
b = _ask(data / "b")
|
||||||
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||||
|
assert c.get("/b/b/embed.json").json()["open"] == ["winner"]
|
||||||
answer_pick(b, "winner", "A — baseline")
|
answer_pick(b, "winner", "A — baseline")
|
||||||
assert "booth-nav-asks" not in c.get("/b/b/").text
|
assert c.get("/b/b/embed.json").json()["open"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_booth_without_asks_is_untouched(client):
|
def test_verbatim_booth_without_asks_is_untouched(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
(data / "b").mkdir()
|
(data / "b").mkdir()
|
||||||
(data / "b" / "index.html").write_text("<!doctype html><body>hi</body>")
|
(data / "b" / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||||
assert "booth-nav-asks" not in c.get("/b/b/").text
|
assert c.get("/b/b/embed.json").json()["marks"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
|
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
|
||||||
@@ -477,42 +482,64 @@ def test_asks_page_shows_a_single_ask_title(client):
|
|||||||
assert "emmie — pick the anchor" in c.get("/b/b/marks").text
|
assert "emmie — pick the anchor" in c.get("/b/b/marks").text
|
||||||
|
|
||||||
|
|
||||||
# ---- inline placement in a verbatim report -----------------------------------
|
# ---- placement in a verbatim report ------------------------------------------
|
||||||
#
|
#
|
||||||
# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be
|
# 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
|
# 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.
|
# each voice's radio group under that voice's audio, and one submit for the lot.
|
||||||
|
#
|
||||||
|
# U3 kept the semantics and moved the mechanism. The author still marks up where
|
||||||
|
# each piece goes; the pieces are still rendered by the `_ask_inline.html`
|
||||||
|
# macros; they now reach the page through `/b/<name>/embed.json` and are mounted
|
||||||
|
# by `/_booth/embed.js` instead of substituted into the author's tags by regex.
|
||||||
|
#
|
||||||
|
# So the placement ASSERTIONS moved too, and where each half lives is not
|
||||||
|
# arbitrary: what the server offers is checked here, in Python; where it LANDS,
|
||||||
|
# and whether a form scattered down a report actually submits, is checked in
|
||||||
|
# tests/test_embed_browser.py against a real DOM. No string assertion can see
|
||||||
|
# the second thing, and that is exactly the part the operator depends on.
|
||||||
|
|
||||||
REPORT = """<!doctype html><title>audition</title><body>
|
REPORT = """<!doctype html><title>audition</title><body>
|
||||||
<h1>Three voices</h1>
|
<h1>Three voices</h1>
|
||||||
<section id="lawson"><audio src="a.wav"></audio>
|
<section id="lawson"><audio src="a.wav"></audio>
|
||||||
<div data-booth-ask="batch:r1"></div></section>
|
<div data-booth-ask="batch:r1"></div></section>
|
||||||
<section id="jo"><audio src="b.wav"></audio>
|
<section id="jo"><audio src="b.wav"></audio>
|
||||||
<!-- booth:ask batch:r2 --></section>
|
<div data-booth-mark="batch:r2"></div></section>
|
||||||
<div data-booth-ask-submit="batch"></div>
|
<div data-booth-ask-submit="batch"></div>
|
||||||
|
<script src="/_booth/embed.js" defer></script>
|
||||||
</body>"""
|
</body>"""
|
||||||
|
|
||||||
|
|
||||||
def test_per_question_placeholders_land_where_the_author_put_them(client):
|
def test_the_author_markup_is_never_touched_by_the_server(client):
|
||||||
|
"""The whole point of the seam. A page that declares it comes back exactly
|
||||||
|
as written — placeholders still empty, waiting for the DOM."""
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
(b / "index.html").write_text(REPORT)
|
(b / "index.html").write_text(REPORT)
|
||||||
html = c.get("/b/b/").text
|
assert c.get("/b/b/").text == REPORT
|
||||||
# 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):
|
def test_every_piece_the_author_can_place_is_offered(client):
|
||||||
|
"""One fragment per addressable piece: the whole ask, each question, and the
|
||||||
|
submit block that carries the shared <form>. The author's markup decides
|
||||||
|
which are used; the payload never decides for them."""
|
||||||
|
c, data = client
|
||||||
|
b = _multi(data / "b")
|
||||||
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
|
assert [q["key"] for q in m["questions"]] == ["r1", "r2"]
|
||||||
|
assert 'name="choice.r1"' in m["questions"][0]["html"]
|
||||||
|
assert 'name="choice.r2"' in m["questions"][1]["html"]
|
||||||
|
# ONE form, and it lives with the submit block, so question groups scattered
|
||||||
|
# down a report bind to it by id from wherever they sit.
|
||||||
|
assert m["submit"].count('<form id="bk-ask-form-batch"') == 1
|
||||||
|
assert m["submit"].count('action="/b/b/answer"') == 1
|
||||||
|
assert 'form="bk-ask-form-batch"' in m["questions"][0]["html"]
|
||||||
|
assert 'form="bk-ask-form-batch"' in m["questions"][1]["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_scattered_form_still_posts_as_one_answer(client):
|
||||||
|
"""The POST half of the multi-question guarantee, which U3 did not touch:
|
||||||
|
every question in one request, or the route refuses it."""
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
(b / "index.html").write_text(REPORT)
|
(b / "index.html").write_text(REPORT)
|
||||||
@@ -521,44 +548,21 @@ def test_inline_form_submits_every_question_in_one_post(client):
|
|||||||
assert r.status_code == 303
|
assert r.status_code == 303
|
||||||
ans = _answer_of(b, "batch")
|
ans = _answer_of(b, "batch")
|
||||||
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
||||||
# and the recorded pick now shows inline, on the report itself
|
# and the recorded pick comes back marked answered, on the report's own seam
|
||||||
html = c.get("/b/b/").text
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
assert "recorded:" in html and "bk-done" in html
|
assert "recorded:" in m["whole"] and "bk-done" in m["whole"]
|
||||||
assert 'value="keep" required checked' in html.replace("\n", " ") or "checked" in html
|
assert "checked" in m["questions"][0]["html"]
|
||||||
|
|
||||||
|
|
||||||
def test_whole_ask_placeholder_renders_everything_there(client):
|
def test_the_page_carries_no_fragment_styles(client):
|
||||||
c, data = client
|
"""`styles()` is gone from the template: the scoped `.bk-ask-*` rules live in
|
||||||
b = _ask(data / "b")
|
embed.js, next to the code that mounts them. One asset, emitted once by
|
||||||
(b / "index.html").write_text('<!doctype html><body><p>x</p><div data-booth-ask="winner"></div></body>')
|
construction rather than by a seen-set."""
|
||||||
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
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
(b / "index.html").write_text(REPORT)
|
(b / "index.html").write_text(REPORT)
|
||||||
assert c.get("/b/b/").text.count(".bk-ask-opt:has(input:checked)") == 1
|
assert ".bk-ask-opt:has(input:checked)" not in c.get("/b/b/").text
|
||||||
|
assert c.get("/_booth/embed.js").text.count(".bk-ask-opt:has(input:checked)") == 1
|
||||||
|
|
||||||
|
|
||||||
def test_radios_are_not_html_required_anywhere(client):
|
def test_radios_are_not_html_required_anywhere(client):
|
||||||
@@ -566,20 +570,20 @@ def test_radios_are_not_html_required_anywhere(client):
|
|||||||
is exactly what stopped the operator leaving one blank."""
|
is exactly what stopped the operator leaving one blank."""
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
assert "required" not in c.get("/b/b/").text
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
assert "required" not in m["whole"]
|
||||||
assert "required" not in c.get("/b/b/").text
|
assert not any("required" in q["html"] for q in m["questions"])
|
||||||
assert "required" not in c.get("/b/b/marks").text
|
assert "required" not in c.get("/b/b/marks").text
|
||||||
|
|
||||||
|
|
||||||
def test_partial_answer_renders_as_skipped_inline(client):
|
def test_partial_answer_renders_as_skipped(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
(b / "index.html").write_text(REPORT)
|
||||||
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
|
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
|
||||||
html = c.get("/b/b/").text
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
assert "bk-skip" in html and "left blank" in html
|
assert "bk-skip" in m["whole"] and "left blank" in m["whole"]
|
||||||
assert "1 of 2 answered" in html
|
assert "1 of 2 answered" in m["submit"]
|
||||||
|
|
||||||
|
|
||||||
def test_empty_submission_is_refused_with_400(client):
|
def test_empty_submission_is_refused_with_400(client):
|
||||||
|
|||||||
+17
-57
@@ -16,7 +16,7 @@ from booth.app import (
|
|||||||
remove_link_entry,
|
remove_link_entry,
|
||||||
toggle_pin,
|
toggle_pin,
|
||||||
booth_age_seconds,
|
booth_age_seconds,
|
||||||
FAVICON_LINK,
|
EMBED_SCRIPT_TAG,
|
||||||
KEEP_MARKER,
|
KEEP_MARKER,
|
||||||
build_gallery,
|
build_gallery,
|
||||||
classify,
|
classify,
|
||||||
@@ -30,7 +30,6 @@ from booth.app import (
|
|||||||
render_doc,
|
render_doc,
|
||||||
safe_upload_name,
|
safe_upload_name,
|
||||||
sweep_once,
|
sweep_once,
|
||||||
wrap_verbatim_html,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
|
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
|
||||||
@@ -459,58 +458,21 @@ def test_view_nonviewable_redirects_to_raw(client):
|
|||||||
assert r.headers["location"] == "/b/run1/data.bin"
|
assert r.headers["location"] == "/b/run1/data.bin"
|
||||||
|
|
||||||
|
|
||||||
# ---- verbatim-index.html wrapper --------------------------------------------
|
# ---- verbatim-index.html serving -------------------------------------------
|
||||||
|
#
|
||||||
|
# U3 replaced the injection wrapper with a declared seam. The five `test_wrap_*`
|
||||||
|
# tests and `test_verbatim_booth_wrapped_with_back_chip` that stood here tested
|
||||||
|
# `wrap_verbatim_html` — six regexes hunting a head-ish seam for a favicon and a
|
||||||
|
# body-ish seam for a chip, plus the doctype and charset-window constraints they
|
||||||
|
# threaded. None of those constraints can be violated by an append, so there is
|
||||||
|
# nothing left of them to assert. What replaced them lives in tests/test_embed.py
|
||||||
|
# (the payload, the one appended tag, whole-body equality for a declaring page)
|
||||||
|
# and tests/test_embed_browser.py (the mount, in a real DOM).
|
||||||
|
#
|
||||||
|
# What stays here is what did NOT change: the file route is still raw.
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_injects_chip_and_favicon():
|
def test_verbatim_booth_is_served_with_the_seam(client):
|
||||||
html = "<html><head><title>Brief</title></head><body><h1>REPORT</h1></body></html>"
|
|
||||||
out = wrap_verbatim_html(html)
|
|
||||||
assert 'class="booth-nav-home"' in out # floating back chip
|
|
||||||
assert 'href="/"' in out # points at the main booth index
|
|
||||||
assert "all booths" in out
|
|
||||||
assert FAVICON_LINK in out # favicon inherited
|
|
||||||
assert "<h1>REPORT</h1>" in out # original content preserved
|
|
||||||
# favicon lands in the head, chip lands in the body
|
|
||||||
assert out.index(FAVICON_LINK) < out.index("</head>")
|
|
||||||
assert out.index("booth-nav-home") > out.index("<body>")
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_respects_existing_favicon():
|
|
||||||
html = '<html><head><link rel="icon" href="data:image/png;base64,AAAA"></head><body>x</body></html>'
|
|
||||||
out = wrap_verbatim_html(html)
|
|
||||||
assert FAVICON_LINK not in out # the page's own icon wins
|
|
||||||
assert out.count('rel="icon"') == 1
|
|
||||||
assert 'class="booth-nav-home"' in out # chip is still added
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_bare_fragment_appends_chip():
|
|
||||||
out = wrap_verbatim_html("<h1>bare fragment</h1>") # no doctype/head/body
|
|
||||||
assert 'class="booth-nav-home"' in out
|
|
||||||
assert out.rstrip().endswith("</style>") # chip appended at the end
|
|
||||||
assert FAVICON_LINK in out # no doctype -> safe to prepend the icon
|
|
||||||
assert out.index(FAVICON_LINK) < out.index("bare") # icon ahead of content (implied head)
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_no_head_injects_favicon():
|
|
||||||
out = wrap_verbatim_html("<body><h1>no head</h1></body>")
|
|
||||||
assert 'class="booth-nav-home"' in out
|
|
||||||
assert FAVICON_LINK in out # injected even without an explicit <head>
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrap_compact_doctype_stays_first():
|
|
||||||
# the real-booth shape: compact HTML, no explicit head/body. The injection must
|
|
||||||
# not push anything ahead of the doctype (quirks mode) or past the charset window.
|
|
||||||
html = "<!doctype html><meta charset=utf-8><title>T</title><style>body{margin:0}</style><h1>REPORT</h1>"
|
|
||||||
out = wrap_verbatim_html(html)
|
|
||||||
assert out.lstrip().lower().startswith("<!doctype") # doctype still first -> standards mode
|
|
||||||
assert FAVICON_LINK in out
|
|
||||||
assert out.index(FAVICON_LINK) < out.index("<h1>") # icon in the implied head, before content
|
|
||||||
assert out.index("charset") < 1024 # charset meta stays in the detection window
|
|
||||||
assert 'class="booth-nav-home"' in out
|
|
||||||
assert out.index("booth-nav-home") > out.index("<h1>REPORT</h1>") # chip appended after content
|
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_booth_wrapped_with_back_chip(client):
|
|
||||||
c, data = client
|
c, data = client
|
||||||
d = data / "brief"
|
d = data / "brief"
|
||||||
d.mkdir()
|
d.mkdir()
|
||||||
@@ -518,13 +480,11 @@ def test_verbatim_booth_wrapped_with_back_chip(client):
|
|||||||
r = c.get("/b/brief/")
|
r = c.get("/b/brief/")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "BRIEF" in r.text # content preserved
|
assert "BRIEF" in r.text # content preserved
|
||||||
assert 'class="booth-nav-home"' in r.text # back chip injected
|
assert r.text.endswith(EMBED_SCRIPT_TAG) # ...and the seam, appended
|
||||||
assert 'href="/"' in r.text
|
|
||||||
assert 'rel="icon"' in r.text # favicon inherited
|
|
||||||
|
|
||||||
|
|
||||||
def test_verbatim_index_raw_file_route_unwrapped(client):
|
def test_verbatim_index_raw_file_route_unwrapped(client):
|
||||||
# the file route (/b/<name>/index.html) still serves the raw bytes — the chip
|
# the file route (/b/<name>/index.html) still serves the raw bytes — the seam
|
||||||
# only rides on the booth view (/b/<name>/), so downloads/assets stay verbatim
|
# only rides on the booth view (/b/<name>/), so downloads/assets stay verbatim
|
||||||
c, data = client
|
c, data = client
|
||||||
d = data / "brief"
|
d = data / "brief"
|
||||||
@@ -532,7 +492,7 @@ def test_verbatim_index_raw_file_route_unwrapped(client):
|
|||||||
(d / "index.html").write_text("<html><body><h1>BRIEF</h1></body></html>")
|
(d / "index.html").write_text("<html><body><h1>BRIEF</h1></body></html>")
|
||||||
r = c.get("/b/brief/index.html")
|
r = c.get("/b/brief/index.html")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "booth-nav-home" not in r.text
|
assert "_booth/embed.js" not in r.text
|
||||||
|
|
||||||
|
|
||||||
# ---- .md / .txt in-booth doc viewer -----------------------------------------
|
# ---- .md / .txt in-booth doc viewer -----------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""U3 — the declared embed seam, server side.
|
||||||
|
|
||||||
|
The Booth used to reach into a verbatim report with ten regular expressions: six
|
||||||
|
to find somewhere to hang a favicon and a chip, four to substitute rendered ask
|
||||||
|
markup into the author's own tags. This unit replaces all of it with a seam the
|
||||||
|
page declares:
|
||||||
|
|
||||||
|
<script src="/_booth/embed.js" defer></script>
|
||||||
|
|
||||||
|
What is tested here is the SERVER half — the payload that crosses the seam, the
|
||||||
|
one static asset, and the single conditional append that is now the only thing
|
||||||
|
the Booth does to author HTML. The half that mounts fragments into a live DOM
|
||||||
|
lives in tests/test_embed_browser.py, because no amount of string assertion can
|
||||||
|
see whether a form actually submits.
|
||||||
|
|
||||||
|
Contract: docs/contracts/u3_declared_embed_seam.contract.md
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from booth.app import EMBED_SCRIPT_TAG, EMBED_SRC, create_app
|
||||||
|
from booth.marks import answer_pick, declare_pick, set_flag, write_note
|
||||||
|
|
||||||
|
DECLARED = f'<!doctype html><title>r</title><body>hi<script src="{EMBED_SRC}" defer></script></body>'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||||
|
return TestClient(app), tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(booth, stem="winner", **kw):
|
||||||
|
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
||||||
|
doc.update(kw)
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, stem, doc)
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
def _multi(booth, stem="batch"):
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, stem, {"title": "Round one", "questions": [
|
||||||
|
{"key": "r1", "prompt": "First?", "options": ["a", "b"]},
|
||||||
|
{"key": "r2", "prompt": "Second?", "options": ["a", "b"]},
|
||||||
|
]})
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 1: the payload ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_payload_carries_a_fragment_for_every_shape(client):
|
||||||
|
c, data = client
|
||||||
|
_multi(data / "b")
|
||||||
|
body = c.get("/b/b/embed.json").json()
|
||||||
|
assert body["booth"] == "b"
|
||||||
|
assert "home" not in body, "a value nothing reads is a second copy waiting to drift"
|
||||||
|
assert body["favicon"].startswith("data:image/svg+xml,")
|
||||||
|
(m,) = body["marks"]
|
||||||
|
assert m["id"] == "batch" and m["error"] is None
|
||||||
|
assert "First?" in m["whole"] and "Second?" in m["whole"]
|
||||||
|
assert 'action="/b/b/answer"' in m["submit"]
|
||||||
|
assert [q["key"] for q in m["questions"]] == ["r1", "r2"] # declaration order
|
||||||
|
assert "First?" in m["questions"][0]["html"]
|
||||||
|
assert 'type="radio"' in m["questions"][0]["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_single_question_pick_has_one_question_with_a_null_key(client):
|
||||||
|
"""SR-2. `normalize_ask` gives a single-question ask `key: None`, so the
|
||||||
|
payload cannot key questions by name — JSON would write that as "null" and
|
||||||
|
invent a name. Every one-question ask in the fleet hits this."""
|
||||||
|
c, data = client
|
||||||
|
_pick(data / "b")
|
||||||
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
|
assert [q["key"] for q in m["questions"]] == [None]
|
||||||
|
assert "Which render wins?" in m["questions"][0]["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_are_ordered_by_creation_before_id(client):
|
||||||
|
"""`created` LEADS. The fixture makes creation order and alphabetical order
|
||||||
|
disagree, because a fixture where they agree cannot tell the stated rule
|
||||||
|
from a plain id sort — which is what the first version of this test did, and
|
||||||
|
what a panel caught by reading the fixture rather than the assertion."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b", "zebra")
|
||||||
|
_pick(b, "alpha")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
stamps = {"zebra": "2026-09-22T10:00:00.000000-07:00", # first
|
||||||
|
"alpha": "2026-09-22T11:00:00.000000-07:00"} # second
|
||||||
|
for e in raw["marks"]:
|
||||||
|
e["created"] = stamps[e["id"]]
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["zebra", "alpha"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_id_is_only_the_tie_break(client):
|
||||||
|
"""And with `created` equal, the id decides — so two marks written in the
|
||||||
|
same second cannot swap between renders."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b", "zebra")
|
||||||
|
_pick(b, "alpha")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
for e in raw["marks"]:
|
||||||
|
e["created"] = "2026-09-22T10:00:00.000000-07:00"
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["alpha", "zebra"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_is_computed_by_the_server_not_the_page(client):
|
||||||
|
"""INV-4. The chip count follows `open_marks`, which is the ONE openness
|
||||||
|
predicate — a half-answered multi-question pick is still open."""
|
||||||
|
c, data = client
|
||||||
|
b = _multi(data / "b")
|
||||||
|
assert c.get("/b/b/embed.json").json()["open"] == ["batch"]
|
||||||
|
answer_pick(b, "batch", {"r1": "a"})
|
||||||
|
assert c.get("/b/b/embed.json").json()["open"] == ["batch"] # partial is OPEN
|
||||||
|
answer_pick(b, "batch", {"r1": "a", "r2": "b"})
|
||||||
|
assert c.get("/b/b/embed.json").json()["open"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_payload_carries_picks_only(client):
|
||||||
|
"""SR-4. Notes and flags never reach it, which is also what keeps a flag's
|
||||||
|
`flag:<target>` id — the one mark id containing the anchor separator — out
|
||||||
|
of a payload whose specs split on the first colon."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / "shot.png").write_bytes(b"x")
|
||||||
|
write_note(b, None, "a remark")
|
||||||
|
set_flag(b, "shot.png", True)
|
||||||
|
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["winner"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_damaged_marks_file_does_not_500_the_report(client):
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / ".marks.json").write_text("{not json")
|
||||||
|
r = c.get("/b/b/embed.json")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["marks"] == [] and r.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_pick_offers_whole_and_nothing_else(client):
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||||
|
assert m["error"] and m["questions"] == [] and m["submit"] == ""
|
||||||
|
assert "broken ask" in m["whole"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_payload_does_not_record_a_view(client):
|
||||||
|
"""`booth_view` already recorded the look, above both of its early returns.
|
||||||
|
A script's fetch of the page it is already on must not count a second time
|
||||||
|
or reset the TTL on machinery instead of on the operator."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / "index.html").write_text(DECLARED)
|
||||||
|
c.get("/b/b/")
|
||||||
|
before = os.stat(b / ".viewed").st_mtime_ns
|
||||||
|
time.sleep(0.01)
|
||||||
|
c.get("/b/b/embed.json")
|
||||||
|
assert os.stat(b / ".viewed").st_mtime_ns == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_payload_404s_for_an_unknown_booth(client):
|
||||||
|
c, _ = client
|
||||||
|
assert c.get("/b/nope/embed.json").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 2: the one static asset -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_js_is_served_as_javascript(client):
|
||||||
|
c, _ = client
|
||||||
|
r = c.get(EMBED_SRC)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("text/javascript")
|
||||||
|
assert "data-booth-mark" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_js_does_not_hot_reload_from_disk(tmp_path):
|
||||||
|
"""INV-5, and the 2026-09-21 lesson restated. A live asset editable under a
|
||||||
|
running process is how 19 of 25 booths hit 500 with the Python from 22:03
|
||||||
|
and the templates from 23:40. One rule in this repo: nothing takes effect
|
||||||
|
until you restart."""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import booth.app as app_mod
|
||||||
|
|
||||||
|
src = pathlib.Path(app_mod.__file__).parent / "static" / "embed.js"
|
||||||
|
original = src.read_text()
|
||||||
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||||
|
c = TestClient(app)
|
||||||
|
try:
|
||||||
|
# Poisoned BEFORE the first request, not between two of them. The
|
||||||
|
# earlier shape passed for a route that read the file lazily and cached
|
||||||
|
# on first use — which is not "read once at startup", and is exactly the
|
||||||
|
# staleness this invariant exists to forbid.
|
||||||
|
src.write_text("/* POISONED */\n")
|
||||||
|
first = c.get(EMBED_SRC).text
|
||||||
|
assert "POISONED" not in first, "embed.js is read at request time, not at startup"
|
||||||
|
assert first == original
|
||||||
|
assert c.get(EMBED_SRC).text == first
|
||||||
|
finally:
|
||||||
|
src.write_text(original)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 3: what the Booth does to author HTML -----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_declaring_page_is_served_untouched(client):
|
||||||
|
"""INV-1. Whole-body equality, not a substring absence: the promise is that
|
||||||
|
NOTHING is added, and an absence assertion cannot tell a clean page from one
|
||||||
|
carrying something nobody thought to look for."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / "index.html").write_text(DECLARED)
|
||||||
|
assert c.get("/b/b/").text == DECLARED
|
||||||
|
|
||||||
|
|
||||||
|
def test_undeclared_page_gains_only_the_tag(client):
|
||||||
|
"""INV-2. Appended, so the source is a strict prefix — nothing is inserted,
|
||||||
|
nothing is prepended, and neither the doctype nor the charset window moves."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
src = "<!doctype html><meta charset=utf-8><title>r</title><h1>REPORT</h1>"
|
||||||
|
(b / "index.html").write_text(src)
|
||||||
|
out = c.get("/b/b/").text
|
||||||
|
# The literal, not just the constant: `out == src + EMBED_SCRIPT_TAG` also
|
||||||
|
# holds when EMBED_SCRIPT_TAG is the empty string, which is a mutation this
|
||||||
|
# test exists to catch. A panel found it by reading the assertion, not the
|
||||||
|
# code.
|
||||||
|
assert out == src + '<script src="/_booth/embed.js" defer></script>'
|
||||||
|
assert out == src + EMBED_SCRIPT_TAG
|
||||||
|
assert len(out) > len(src)
|
||||||
|
assert out.lower().lstrip().startswith("<!doctype")
|
||||||
|
assert out.index("charset") < 1024
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_booth_with_no_marks_still_gets_the_seam(client):
|
||||||
|
"""The seam carries the way home and the icon too, so it is not conditional
|
||||||
|
on there being an ask — the old chip was not either."""
|
||||||
|
c, data = client
|
||||||
|
(data / "b").mkdir()
|
||||||
|
(data / "b" / "index.html").write_text("<h1>bare fragment</h1>")
|
||||||
|
assert c.get("/b/b/").text == "<h1>bare fragment</h1>" + EMBED_SCRIPT_TAG
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_declaring_page_is_served_BYTE_for_byte(client):
|
||||||
|
"""INV-1, at the level the promise is actually made.
|
||||||
|
|
||||||
|
The first version read the file with `read_text()`, which opens in
|
||||||
|
universal-newline mode: a report written with CRLF came back with LF, and
|
||||||
|
`errors="replace"` turned any non-UTF-8 byte into U+FFFD. A declaring page
|
||||||
|
was NOT served as its author wrote it — the headline promise — and the
|
||||||
|
original test could not see it, because its fixture was LF-only ASCII.
|
||||||
|
Found by a cross-frontier bug-hunt panel.
|
||||||
|
"""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
src = (b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n'
|
||||||
|
b'<script src="/_booth/embed.js" defer></script>\r\n</body>')
|
||||||
|
(b / "index.html").write_bytes(src)
|
||||||
|
r = c.get("/b/b/")
|
||||||
|
assert r.content == src, "the operator's document was edited on the way out"
|
||||||
|
assert b"\r\n" in r.content and b"\xff" in r.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_undeclared_page_keeps_every_byte_and_gains_the_tag(client):
|
||||||
|
"""INV-2, same level: the source is a BYTE-exact prefix of the response."""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
src = b'<!doctype html>\r\n<title>r</title>\r\n<body>caf\xe9 \xff\r\n</body>'
|
||||||
|
(b / "index.html").write_bytes(src)
|
||||||
|
r = c.get("/b/b/")
|
||||||
|
assert r.content == src + EMBED_SCRIPT_TAG.encode("utf-8")
|
||||||
|
assert r.content.startswith(src)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_wrongly_shaped_answer_costs_its_pick_not_the_report(client):
|
||||||
|
"""`marks_for` hydrates `{"answer": {"answers": []}}` with no error — the
|
||||||
|
JSON is well formed, the SHAPE is not — and the template then asks a list
|
||||||
|
for `.get`. This endpoint renders every pick on every load of the operator's
|
||||||
|
report, so an unguarded raise here is the whole seam gone while `hold_read`
|
||||||
|
calls the file perfectly readable. Verified reachable, not assumed."""
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(b, "batch", {"title": "T", "questions": [
|
||||||
|
{"key": "r1", "prompt": "A?", "options": ["x", "y"]},
|
||||||
|
{"key": "r2", "prompt": "B?", "options": ["x", "y"]}]})
|
||||||
|
_pick(b, "healthy")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
for e in raw["marks"]:
|
||||||
|
if e["id"] == "batch":
|
||||||
|
e["answer"] = {"answers": [], "notes": ""}
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
|
||||||
|
(b / "index.html").write_text(DECLARED)
|
||||||
|
r = c.get("/b/b/embed.json")
|
||||||
|
assert r.status_code == 200
|
||||||
|
by = {m["id"]: m for m in r.json()["marks"]}
|
||||||
|
assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"]
|
||||||
|
assert "broken ask" in by["batch"]["whole"]
|
||||||
|
# and the booth's other pick is untouched — one bad entry costs one entry
|
||||||
|
assert by["healthy"]["error"] is None
|
||||||
|
assert "Which render wins?" in by["healthy"]["whole"]
|
||||||
|
assert c.get("/b/b/").status_code == 200
|
||||||
|
|
||||||
|
# ⚠ THE GALLERY AND MARKS PAGES STILL 500 ON THIS ENTRY, and that is NOT
|
||||||
|
# U3's doing — measured at 42ea67f, the commit before this unit. They render
|
||||||
|
# the same macro without this guard. Out of scope here (the gallery is named
|
||||||
|
# out of scope in the contract) and recorded rather than quietly widened:
|
||||||
|
# see persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_regex_touches_author_html():
|
||||||
|
"""INV-3, and the version that actually falsifies it.
|
||||||
|
|
||||||
|
The first draft of this test name-matched the six deleted patterns. A cold
|
||||||
|
panel pointed out — correctly, and on the contract's CENTRAL promise — that
|
||||||
|
reintroducing the same regex under a new name (`_TAIL_RE`, applied in the
|
||||||
|
verbatim branch) would leave it green. A test that guards names does not
|
||||||
|
guard behaviour, and this repo's own vacuity pass missed it because the
|
||||||
|
mutation it tried was the named one.
|
||||||
|
|
||||||
|
So: `booth/app.py` is allowed EXACTLY ONE regex operation, and it is
|
||||||
|
`ask_form_id`'s `re.sub` over a mark id — not over a page. Any other regex
|
||||||
|
anywhere in the module fails here, whatever it is called. If a future
|
||||||
|
change genuinely needs one, the failure is the conversation: say which
|
||||||
|
string it reads and why it is not author HTML.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import booth.app as app_mod
|
||||||
|
|
||||||
|
root = pathlib.Path(app_mod.__file__).parent
|
||||||
|
assert not (root / "inline.py").exists(), "booth/inline.py survived U3"
|
||||||
|
tree = ast.parse((root / "app.py").read_text())
|
||||||
|
|
||||||
|
# every node -> the function it sits in, so a finding names its site
|
||||||
|
site = {}
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
for child in ast.walk(node):
|
||||||
|
site.setdefault(child, node.name)
|
||||||
|
|
||||||
|
# `re` reaches this module ONE way: a plain module-level `import re`. An
|
||||||
|
# alias (`import re as _r`) or a direct name import (`from re import sub`)
|
||||||
|
# would route around the call check below under a name it does not know —
|
||||||
|
# found by re-running the vacuity pass against the FIXED test, which is the
|
||||||
|
# only reason it is here and is the argument for running that pass on a fix
|
||||||
|
# and not only on a draft.
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for a in node.names:
|
||||||
|
assert not (a.name == "re" and a.asname), f"`re` aliased as {a.asname}"
|
||||||
|
elif isinstance(node, ast.ImportFrom):
|
||||||
|
assert node.module != "re", f"names imported from re: {[a.name for a in node.names]}"
|
||||||
|
|
||||||
|
METHODS = {"search", "sub", "subn", "match", "fullmatch", "finditer",
|
||||||
|
"findall", "split", "compile", "escape"}
|
||||||
|
found = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
||||||
|
continue
|
||||||
|
f = node.func
|
||||||
|
on_re = isinstance(f.value, ast.Name) and f.value.id == "re"
|
||||||
|
on_pattern = (isinstance(f.value, ast.Name) and f.value.id.endswith("_RE")
|
||||||
|
and f.attr in METHODS)
|
||||||
|
if on_re or on_pattern:
|
||||||
|
found.append((site.get(node, "<module level>"), f.attr))
|
||||||
|
|
||||||
|
assert found == [("ask_form_id", "sub")], (
|
||||||
|
f"booth/app.py performs regex operations outside ask_form_id: {found}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# the six named patterns and the chips are gone, and stay gone
|
||||||
|
assigned = {
|
||||||
|
t.id
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Assign)
|
||||||
|
for t in node.targets
|
||||||
|
if isinstance(t, ast.Name)
|
||||||
|
}
|
||||||
|
gone = {"_ICON_RE", "_HEAD_CLOSE_RE", "_HTML_OPEN_RE", "_DOCTYPE_RE",
|
||||||
|
"_BODY_CLOSE_RE", "_HTML_CLOSE_RE", "_BACK_CHIP", "FAVICON_LINK"}
|
||||||
|
assert not (assigned & gone), f"deleted names are back: {sorted(assigned & gone)}"
|
||||||
|
funcs = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||||
|
assert not ({"wrap_verbatim_html", "asks_chip", "inject_asks",
|
||||||
|
"_insert_before", "_insert_after"} & funcs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_that_only_mentions_the_path_is_not_declaring_it(client):
|
||||||
|
"""A report that QUOTES the seam — a code sample, a comment, a sentence
|
||||||
|
about this very feature — is not declaring it, and the Booth's own design
|
||||||
|
reports are the pages most likely to do that. Read as declared, such a page
|
||||||
|
would be served untouched and show no chrome at all, silently.
|
||||||
|
|
||||||
|
The detection therefore fails the other way: an unrecognised spelling gets a
|
||||||
|
duplicate tag, and embed.js mounts once regardless.
|
||||||
|
"""
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
for body in (
|
||||||
|
"<!doctype html><body><p>add <code>/_booth/embed.js</code> to your report</p></body>",
|
||||||
|
"<!doctype html><body><!-- src=/_booth/embed.js --></body>",
|
||||||
|
'<!doctype html><body><script src="/_booth/embed.js?v=2"></script></body>',
|
||||||
|
):
|
||||||
|
(b / "index.html").write_text(body)
|
||||||
|
assert c.get("/b/b/").text == body + EMBED_SCRIPT_TAG, body
|
||||||
|
|
||||||
|
# and the real declaration, in either quote style, is honoured
|
||||||
|
for decl in (f'<script src="{EMBED_SRC}" defer></script>',
|
||||||
|
f"<script src='{EMBED_SRC}' defer></script>"):
|
||||||
|
body = f"<!doctype html><body>hi{decl}</body>"
|
||||||
|
(b / "index.html").write_text(body)
|
||||||
|
assert c.get("/b/b/").text == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_oversize_verbatim_page_is_served_raw(client, monkeypatch):
|
||||||
|
"""WRAP_MAX_BYTES survives: a pathological file is still not pulled into
|
||||||
|
memory, and it loses its chrome exactly as it does today."""
|
||||||
|
import booth.app as app_mod
|
||||||
|
|
||||||
|
c, data = client
|
||||||
|
b = _pick(data / "b")
|
||||||
|
(b / "index.html").write_text("<h1>huge</h1>")
|
||||||
|
monkeypatch.setattr(app_mod, "WRAP_MAX_BYTES", 4)
|
||||||
|
assert c.get("/b/b/").text == "<h1>huge</h1>"
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
"""U3 — the declared embed seam, in a real DOM.
|
||||||
|
|
||||||
|
The Python suite can prove what the server OFFERS. It cannot prove where a
|
||||||
|
fragment lands, whether the author's own markup survived the mount, or whether
|
||||||
|
four radio groups scattered down a report still submit as one POST — and that
|
||||||
|
last one is the operator's most important workflow. Before U3 those properties
|
||||||
|
were true by construction, because the server did the placing and the `form=`
|
||||||
|
bindings were static by the time the page was parsed. Now they are true because
|
||||||
|
`/_booth/embed.js` does it in a live document, which is a different kind of
|
||||||
|
claim and needs a different kind of test.
|
||||||
|
|
||||||
|
So: a real uvicorn on an ephemeral port, a real Chromium.
|
||||||
|
|
||||||
|
SKIPS, NEVER FAILS, when playwright or the shared browser is unavailable. The
|
||||||
|
box-wide store at /opt/ms-playwright pins specific Chromium revisions and a
|
||||||
|
playwright release that wants a newer one dies with an opaque "Executable
|
||||||
|
doesn't exist" — see pyproject's version bound. A test layer that goes red for
|
||||||
|
an environment reason teaches nothing and trains people to ignore it.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from booth.app import create_app
|
||||||
|
|
||||||
|
playwright_api = pytest.importorskip(
|
||||||
|
"playwright.sync_api", reason="playwright is not installed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def browser():
|
||||||
|
with playwright_api.sync_playwright() as pw:
|
||||||
|
try:
|
||||||
|
b = pw.chromium.launch()
|
||||||
|
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
|
||||||
|
pytest.skip(f"no usable chromium: {exc}")
|
||||||
|
yield b
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def live(tmp_path):
|
||||||
|
"""A real server, because a browser cannot talk to a TestClient."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||||
|
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
thread = threading.Thread(target=server.run, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while not server.started and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
if not server.started:
|
||||||
|
pytest.skip("uvicorn did not come up")
|
||||||
|
try:
|
||||||
|
yield f"http://127.0.0.1:{port}", tmp_path
|
||||||
|
finally:
|
||||||
|
server.should_exit = True
|
||||||
|
thread.join(timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
SEAM = '<script src="/_booth/embed.js" defer></script>'
|
||||||
|
|
||||||
|
|
||||||
|
def _multi(booth):
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, "batch", {"title": "Round one", "questions": [
|
||||||
|
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
|
||||||
|
{"key": "r2", "prompt": "Second?", "options": ["keep", "cut"]},
|
||||||
|
]})
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
def _single(booth):
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, "winner", {"prompt": "Which render wins?",
|
||||||
|
"options": ["A — baseline", "B — async"]})
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
def _open(browser, base, name, html, booth):
|
||||||
|
(booth / "index.html").write_text(html, encoding="utf-8")
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def _answer(booth):
|
||||||
|
raw = json.loads((booth / ".marks.json").read_text())
|
||||||
|
return raw["marks"][0].get("answer")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the chrome --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_declaring_page_gets_its_chrome_mounted(browser, live):
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
page = _open(browser, base, "b", f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".booth-nav-home")
|
||||||
|
assert page.locator("h1").inner_text() == "R" # the report is intact
|
||||||
|
assert page.locator(".booth-nav-home").get_attribute("href").endswith("/")
|
||||||
|
# the favicon question, asked of a parsed document instead of raw text
|
||||||
|
assert page.locator('link[rel="icon"]').count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_that_never_declared_the_seam_still_mounts(browser, live):
|
||||||
|
"""The appended path: every verbatim booth that predates U3 keeps working
|
||||||
|
without its author touching it."""
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
page = _open(browser, base, "b", "<!doctype html><body><h1>OLD</h1></body>", b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator("h1").inner_text() == "OLD"
|
||||||
|
assert page.locator(".booth-nav-home").count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_that_declares_its_own_icon_keeps_it(browser, live):
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
page = _open(
|
||||||
|
browser, base, "b",
|
||||||
|
f'<!doctype html><head><link rel="icon" href="data:image/png;base64,AAAA">'
|
||||||
|
f"</head><body>x{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".booth-nav-home")
|
||||||
|
icons = page.locator('link[rel="icon"]')
|
||||||
|
assert icons.count() == 1
|
||||||
|
assert icons.get_attribute("href").startswith("data:image/png")
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- placement ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
REPORT = f"""<!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>
|
||||||
|
<div data-booth-mark="batch:r2"></div></section>
|
||||||
|
<div data-booth-ask-submit="batch"></div>
|
||||||
|
{SEAM}
|
||||||
|
</body>"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_question_lands_where_the_author_put_it(browser, live):
|
||||||
|
"""The 2026-09-09 ruling, enforced in the DOM: the question for a voice sits
|
||||||
|
under that voice, not on another page and not in a pile at the end. Both
|
||||||
|
attribute spellings, because live reports use the older one."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", REPORT, b)
|
||||||
|
page.wait_for_selector("#lawson .bk-ask")
|
||||||
|
assert page.locator('#lawson input[name="choice.r1"]').count() == 2
|
||||||
|
assert page.locator('#jo input[name="choice.r2"]').count() == 2
|
||||||
|
# nothing spilled to the end of the body: every piece had an anchor
|
||||||
|
assert page.locator("body > .bk-ask").count() == 0
|
||||||
|
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_authors_wrapper_and_its_contents_survive_the_mount(browser, live):
|
||||||
|
"""The live `dfa-concepts` shape — a non-empty styled wrapper carrying the
|
||||||
|
anchor attribute. The regex this replaced matched the opening tag and
|
||||||
|
SUBSTITUTED it, eating the class and orphaning the heading. beforeend keeps
|
||||||
|
both and puts the radios under the heading, which is what the markup says."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><div class="ask" data-booth-ask="batch:r1">'
|
||||||
|
f"<h3>The one asset that must survive</h3></div>{SEAM}</body>"), b)
|
||||||
|
page.wait_for_selector(".ask .bk-ask")
|
||||||
|
assert page.locator("div.ask").count() == 1 # class kept
|
||||||
|
assert page.locator(".ask h3").inner_text() == "The one asset that must survive"
|
||||||
|
assert page.locator('.ask input[name="choice.r1"]').count() == 2 # radios inside
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live):
|
||||||
|
"""INV-7. A multi-question pick needs EVERY question on submit or the POST is
|
||||||
|
unanswerable: a question that never reaches the page cannot be picked, and
|
||||||
|
a submission with nothing picked at all is refused outright."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b",
|
||||||
|
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
|
||||||
|
# attached, not visible: the shared <form> is deliberately empty and so has
|
||||||
|
# no box — the controls that bind to it are what the operator sees.
|
||||||
|
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||||
|
# BOTH halves. Asserting only the appended r2 let a mutation that silently
|
||||||
|
# swallowed the anchored r1 — while still recording it as placed — pass.
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2 # anchored, mounted
|
||||||
|
assert page.locator('input[name="choice.r2"]').count() == 2 # unplaced, appended
|
||||||
|
assert page.locator("form#bk-ask-form-batch").count() == 1 # submittable
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_with_no_anchors_gets_the_whole_ask(browser, live):
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", f"<!doctype html><body><p>x</p>{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
assert page.locator('input[name="choice.r2"]').count() == 2
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_anchor_naming_no_mark_is_left_alone(browser, live):
|
||||||
|
"""A typo'd id stays visible as the author's own empty element rather than
|
||||||
|
being blanked — and the real ask is still never lost."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b",
|
||||||
|
f'<!doctype html><body><div id="t" data-booth-mark="typo"></div>{SEAM}</body>', b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator("#t").inner_html().strip() == ""
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tail_follows_payload_order(browser, live):
|
||||||
|
"""INV-6. Creation order and alphabetical order DISAGREE here on purpose:
|
||||||
|
`winner` is created first, `batch` second, so payload order is
|
||||||
|
winner-then-batch while an id sort would give the reverse. The first version
|
||||||
|
of this fixture made the two identical, so sorting the tail alphabetically
|
||||||
|
in JavaScript passed it — caught by a panel reading the fixture."""
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
_multi(b)
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
stamps = {"winner": "2026-09-22T10:00:00.000000-07:00", # first
|
||||||
|
"batch": "2026-09-22T11:00:00.000000-07:00"} # second
|
||||||
|
for e in raw["marks"]:
|
||||||
|
e["created"] = stamps[e["id"]]
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
page = _open(browser, base, "b", f"<!doctype html><body>{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
|
||||||
|
batch = [i for i, v in enumerate(ids) if "batch" in v]
|
||||||
|
winner = [i for i, v in enumerate(ids) if "winner" in v]
|
||||||
|
assert batch and winner, ids
|
||||||
|
# winner (created first) ahead of batch (created second) — the OPPOSITE of
|
||||||
|
# alphabetical, so an id sort cannot pass this.
|
||||||
|
assert max(winner) < min(batch), ids
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the one that actually matters ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_form_scattered_down_the_report_submits_every_question(browser, live):
|
||||||
|
"""THE load-bearing browser test.
|
||||||
|
|
||||||
|
Four radio groups under four different artifacts, one <form> somewhere else
|
||||||
|
entirely, bound only by the HTML5 `form=` attribute — and now inserted into
|
||||||
|
a live document in visual order, which means a control can land before the
|
||||||
|
form it points at. If form-owner resolution does not survive that, the
|
||||||
|
operator fills the whole thing in and the button saves nothing.
|
||||||
|
|
||||||
|
It was true by construction before U3 (static HTML, resolved at parse). It
|
||||||
|
is true by measurement now. That is the trade this test pays for.
|
||||||
|
"""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", REPORT, b)
|
||||||
|
page.wait_for_selector("#lawson .bk-ask")
|
||||||
|
page.check('#lawson input[name="choice.r1"][value="keep"]')
|
||||||
|
page.check('#jo input[name="choice.r2"][value="cut"]')
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.click("button.bk-ask-go")
|
||||||
|
ans = _answer(b)
|
||||||
|
assert ans is not None, "the scattered form submitted nothing"
|
||||||
|
assert ans["answers"]["r1"]["choice"] == "keep"
|
||||||
|
assert ans["answers"]["r2"]["choice"] == "cut", \
|
||||||
|
"a question bound by form= did not reach the POST"
|
||||||
|
assert ans["complete"] is True
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_chip_jumps_to_the_first_fragment_of_the_open_ask(browser, live):
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", REPORT, b)
|
||||||
|
page.wait_for_selector(".booth-nav-asks")
|
||||||
|
chip = page.locator(".booth-nav-asks")
|
||||||
|
assert chip.inner_text() == "? 1 open ask"
|
||||||
|
target = chip.get_attribute("href")
|
||||||
|
# the EARLIEST match in document order, not merely a match: the report
|
||||||
|
# anchors r1 above r2 above the submit block, so any later one is wrong.
|
||||||
|
ids = page.eval_on_selector_all("[id^='bk-ask-batch']", "els => els.map(e => e.id)")
|
||||||
|
assert ids, "no batch fragment mounted"
|
||||||
|
assert target == "#" + ids[0], (target, ids)
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_anchor_named_like_an_object_property_does_not_kill_the_page(browser, live):
|
||||||
|
"""`toString` is a legal mark id (`asks.valid_stem`) and therefore a legal
|
||||||
|
thing for an author to typo into an anchor. Against a plain `{}` lookup it
|
||||||
|
came back as Object.prototype.toString — truthy, so it sailed past the
|
||||||
|
unknown-mark guard and threw on `.questions.length`, aborting placement
|
||||||
|
before the tail and costing the page EVERY ask. One typo, no chrome, no
|
||||||
|
error the operator would see. Found by a cross-frontier panel."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><div id="t" data-booth-mark="toString"></div>'
|
||||||
|
f'<div id="v" data-booth-mark="valueOf:r1"></div>{SEAM}</body>'), b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator("#t").inner_html().strip() == "" # left alone
|
||||||
|
assert page.locator("#v").inner_html().strip() == "" # left alone
|
||||||
|
# and the real ask still mounted, which is what the bug destroyed
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
assert page.locator('input[name="choice.r2"]').count() == 2
|
||||||
|
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_question_keyed_like_an_object_property_is_not_swallowed(browser, live):
|
||||||
|
"""The mirror of the same bug, on the `placed` set. `constructor` matches
|
||||||
|
`asks._KEY_RE`, and against a plain object an inherited `got.constructor`
|
||||||
|
read as ALREADY PLACED — so a question the author did not anchor was
|
||||||
|
silently dropped from the tail, which is INV-7's whole subject."""
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(b, "batch", {"title": "Round one", "questions": [
|
||||||
|
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
|
||||||
|
{"key": "constructor", "prompt": "Second?", "options": ["keep", "cut"]},
|
||||||
|
]})
|
||||||
|
page = _open(browser, base, "b",
|
||||||
|
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
|
||||||
|
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
assert page.locator('input[name="choice.constructor"]').count() == 2, \
|
||||||
|
"an unplaced question was swallowed by an inherited property"
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_submit_anchor_inside_the_authors_own_form_still_submits(browser, live):
|
||||||
|
"""A submit anchor placed inside the author's own `<form>` loses ours: the
|
||||||
|
HTML parser drops a nested form element outright. The controls' `form=`
|
||||||
|
then points at nothing, the button does nothing, and the operator finds out
|
||||||
|
by filling the whole thing in. Found by a cross-frontier bug-hunt panel; the
|
||||||
|
fix is to count the anchor submitted only if the form actually survived, so
|
||||||
|
the tail supplies one at body level where no form encloses it."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body>'
|
||||||
|
'<div data-booth-mark="batch:r1"></div>'
|
||||||
|
'<div data-booth-mark="batch:r2"></div>'
|
||||||
|
'<form id="mine" action="/elsewhere">'
|
||||||
|
'<div data-booth-ask-submit="batch"></div></form>'
|
||||||
|
f"{SEAM}</body>"), b)
|
||||||
|
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
||||||
|
assert page.locator("form#bk-ask-form-batch").count() == 1
|
||||||
|
page.check('input[name="choice.r1"][value="keep"]')
|
||||||
|
page.check('input[name="choice.r2"][value="cut"]')
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.click("button.bk-ask-go")
|
||||||
|
ans = _answer(b)
|
||||||
|
assert ans is not None, "the button reached no form"
|
||||||
|
assert ans["answers"]["r1"]["choice"] == "keep"
|
||||||
|
assert ans["answers"]["r2"]["choice"] == "cut"
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_pick_shows_its_diagnostic_even_from_a_submit_anchor(browser, live):
|
||||||
|
"""A broken pick has no submit block — its `submit` is empty and the
|
||||||
|
diagnostic lives in `whole`. Mounting that empty string and then recording
|
||||||
|
the pick as placed made the tail skip it, so the 'broken ask' box never
|
||||||
|
rendered at the one surface built to show it. A question the session
|
||||||
|
believes it posted has to be visible."""
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
raw = json.loads((b / ".marks.json").read_text())
|
||||||
|
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
|
||||||
|
(b / ".marks.json").write_text(json.dumps(raw))
|
||||||
|
page = _open(browser, base, "b",
|
||||||
|
f'<!doctype html><body><div id="s" data-booth-ask-submit="winner">'
|
||||||
|
f"</div>{SEAM}</body>", b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator("#s").inner_html().strip() == "" # anchor left alone
|
||||||
|
# inner_text() is the RENDERED text, and `.bk-ask-tag` is uppercased by CSS —
|
||||||
|
# so assert the diagnostic itself, which is the part that has to reach him.
|
||||||
|
shown = page.locator("body").inner_text()
|
||||||
|
assert "this question could not be read" in shown, shown
|
||||||
|
assert "options" in shown # the actual reason
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_author_element_cannot_hijack_the_chip(browser, live):
|
||||||
|
"""`<section id="bk-ask-winner-background">` satisfies any id-prefix rule,
|
||||||
|
hyphen boundary included. The chip therefore searches only the elements THIS
|
||||||
|
SCRIPT MOUNTED — the identity the deleted `bk-ask-<id>-top` anchor used to
|
||||||
|
guarantee — and takes the earliest of those in document order."""
|
||||||
|
base, data = live
|
||||||
|
b = _single(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><section id="bk-ask-winner-background">notes</section>'
|
||||||
|
f"<p>report</p>{SEAM}</body>"), b)
|
||||||
|
page.wait_for_selector(".booth-nav-asks")
|
||||||
|
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||||
|
assert target != "#bk-ask-winner-background"
|
||||||
|
landed = page.locator(target)
|
||||||
|
assert landed.count() == 1
|
||||||
|
assert landed.evaluate("e => e.classList.contains('bk-ask')"), \
|
||||||
|
"the chip jumped to something the Booth did not mount"
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, live):
|
||||||
|
"""A SIBLING MARK's fragment must not take the chip, even when it is earlier
|
||||||
|
in the document. `batch2`'s id starts with `batch`, so the original
|
||||||
|
id-prefix rule could land on it; the mounted-elements rule cannot, because
|
||||||
|
the candidates are partitioned by mark.
|
||||||
|
|
||||||
|
⚠ The first version of this test put the sibling's fragment AFTER the open
|
||||||
|
mark's, so the right answer was also the first answer and pooling every
|
||||||
|
mark's elements passed it. The vacuity pass caught that; the fixture now
|
||||||
|
puts the sibling FIRST, which is the only arrangement that can tell the two
|
||||||
|
implementations apart.
|
||||||
|
"""
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b") # `batch`, created first
|
||||||
|
declare_pick(b, "batch2", {"prompt": "Unrelated?", "options": ["x", "y"]})
|
||||||
|
|
||||||
|
# batch2 is anchored at the very top; batch is unanchored and so lands in
|
||||||
|
# the tail, at the END of the body. Document order is therefore batch2's
|
||||||
|
# fragments, then batch's.
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><div data-booth-mark="batch2"></div>'
|
||||||
|
f"<p>report</p>{SEAM}</body>"), b)
|
||||||
|
page.wait_for_selector(".booth-nav-asks")
|
||||||
|
|
||||||
|
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
|
||||||
|
assert any("batch2" in i for i in ids) and any(
|
||||||
|
"batch2" not in i and "batch" in i for i in ids), ids
|
||||||
|
assert ids.index(next(i for i in ids if "batch2" in i)) < \
|
||||||
|
ids.index(next(i for i in ids if "batch2" not in i and "batch" in i)), \
|
||||||
|
f"fixture is wrong: the sibling must come FIRST, got {ids}"
|
||||||
|
|
||||||
|
# `open` is (created, id) -> batch before batch2, so the chip targets batch
|
||||||
|
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||||
|
assert "batch2" not in target, f"the chip landed on the sibling mark: {target}"
|
||||||
|
assert target.startswith("#bk-ask-batch")
|
||||||
|
assert page.locator(target).count() == 1
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_canonical_attribute_wins_when_both_are_present(browser, live):
|
||||||
|
"""`data-booth-mark` is canonical and `data-booth-ask` is the kept alias.
|
||||||
|
An element carrying both is not a case any live report has, but the
|
||||||
|
precedence has to be decided somewhere rather than by selector order."""
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
page = _open(browser, base, "b", (
|
||||||
|
'<!doctype html><body><div id="a" data-booth-mark="batch:r2" '
|
||||||
|
f'data-booth-ask="batch:r1"></div>{SEAM}</body>'), b)
|
||||||
|
page.wait_for_selector("#a .bk-ask")
|
||||||
|
assert page.locator('#a input[name="choice.r2"]').count() == 2 # canonical
|
||||||
|
assert page.locator('#a input[name="choice.r1"]').count() == 0 # alias ignored
|
||||||
|
# r1 was never placed, so INV-7 still puts it somewhere
|
||||||
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_chip_count_comes_from_the_server(browser, live):
|
||||||
|
"""INV-4. A half-answered multi-question pick is STILL OPEN, and the page
|
||||||
|
does not get to have an opinion about that — `open_marks` decides."""
|
||||||
|
from booth.marks import answer_pick
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
answer_pick(b, "batch", {"r1": "keep"})
|
||||||
|
page = _open(browser, base, "b", REPORT, b)
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator(".booth-nav-asks").count() == 1
|
||||||
|
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"})
|
||||||
|
page.reload(wait_until="networkidle")
|
||||||
|
page.wait_for_selector(".bk-ask")
|
||||||
|
assert page.locator(".booth-nav-asks").count() == 0
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_chip_follows_a_payload_that_disagrees_with_the_fragments(browser, live):
|
||||||
|
"""INV-4, and the version that actually falsifies it.
|
||||||
|
|
||||||
|
The test above uses honest fixtures, so a client that INFERRED openness from
|
||||||
|
the rendered fragments would pass it — the fragments and `open` always agree
|
||||||
|
when the server computes both. A panel pointed out that this never creates
|
||||||
|
the disagreement it claims to test.
|
||||||
|
|
||||||
|
So: intercept the response and make `open` lie. The fragments say fully
|
||||||
|
answered; the payload says two are open. The chip must follow the payload,
|
||||||
|
because the payload is the only thing that decides.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
from booth.marks import answer_pick
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _multi(data / "b")
|
||||||
|
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"}) # nothing is open
|
||||||
|
(b / "index.html").write_text(f"<!doctype html><body>{SEAM}</body>", encoding="utf-8")
|
||||||
|
|
||||||
|
def lie(route):
|
||||||
|
body = _json.loads(route.fetch().text())
|
||||||
|
assert body["open"] == [], "fixture is not answered; the lie would be true"
|
||||||
|
body["open"] = ["batch", "batch"]
|
||||||
|
route.fulfill(status=200, content_type="application/json",
|
||||||
|
body=_json.dumps(body))
|
||||||
|
|
||||||
|
page = browser.new_page()
|
||||||
|
page.route("**/embed.json", lie)
|
||||||
|
page.goto(f"{base}/b/b/", wait_until="networkidle")
|
||||||
|
page.wait_for_selector(".booth-nav-asks")
|
||||||
|
assert page.locator(".booth-nav-asks").inner_text() == "? 2 open asks"
|
||||||
|
page.close()
|
||||||
Reference in New Issue
Block a user