Files
booth/booth/static/embed.js
T
vh 213071b6ce fix(embed,mutation): SPYRJA fold — report input on every path; an instrument that cannot certify what it did not run
The heid bug-hunt (hulda, with heid's second voice) on 377e652 found eight
issues. Every fixed one has a red-first test.

embed.js:
- H1: the report-input guard covered only the batch path. A lone changed
  answer went out as a native POST, whose 303 navigation took the report's
  typed text with it. Unsaved report input now routes even a lone answer
  in place. With nothing of ours to send, the submit block says so.
- H7 / V1: a bare <select>, and a range or color input with no value
  attribute, read as typed-into by their default attributes, so every clean
  batch refused its reload with a false message. Report controls are now
  measured against how they stood when the Booth mounted. A control added
  later falls back to its defaults, counting a select's first option as its
  default.
- H2 / V2: a contenteditable region counts as report input.

scripts/mutation_check.py:
- H5: any non-zero exit counted as proof, including a collection error
  where the test never ran. Only pytest's "tests failed" (1) proves now.
- H6: the test run has a timeout (300 s). A hang reports "timed out" and
  the source is still restored.
- H3: source is read and restored as bytes, so a CRLF file comes back
  byte-exact.
- H4: one run per tree, enforced by a lock. The in-flight marker lives with
  the tree it guards.
- H8: anchors are counted with overlaps. The check is `matches()`, not
  str.count.

Tool controls +5 (tests/test_mutation_check.py). u3_submit_all +3 rows.
2026-09-28 17:49:13 -07:00

648 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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;
/* The ask palette's LIGHT values, written once and used by both light rules
below (OS light, and forced light), so the two can never drift apart. */
var BK_LIGHT = "--bk-accent:#586519;--bk-accent-line:rgba(88,101,25,.55);" +
"--bk-accent-soft:rgba(88,101,25,.11);--bk-on-accent:#fff;--bk-open:#7c5500;--bk-open-text:#7c5500;" +
"--bk-done:#486741;--bk-done-text:#486741;--bk-skip:#52595e;--bk-err:#a42e07";
var CSS = [
/* SVOS values, written as literals: this sheet lands in a page we did not
write, so it can lean on none of base.html's tokens. Hex equivalents of
the SVOS semantic tokens (design-systems palettes/svos @ ed2f8d8). */
/* ---- 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:.38rem .75rem;border-radius:8px;",
"text-decoration:none;letter-spacing:.01em;box-shadow:0 4px 14px rgba(0,0,0,.4)}",
/* 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 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"color:#dce3e5;background:rgba(12,16,20,.86);border:1px solid rgba(255,255,255,.16);",
"-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);transition:background .12s,border-color .12s}",
".booth-nav-home:hover{background:rgba(31,35,40,.96);border-color:rgba(255,255,255,.34)}",
/* as S5a: our own focus rings, so a host page's `outline:none` cannot take
them. The chips sit on any host: a white ring inside a dark halo reads on both. */
".booth-nav-home:focus-visible,.booth-nav-asks:focus-visible{outline:2px solid #fff;outline-offset:1px;box-shadow:0 0 0 4px #15191d}",
/* amber = needs you: the one chip that asks to be clicked */
".booth-nav-asks{right:7.4rem;font:600 13px/1.25 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"color:#15191d;background:#fbc10f;border:1px solid #fbc10f;transition:filter .12s}",
".booth-nav-asks:hover{filter:brightness(1.06)}",
"@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. The palette is a set of custom
properties SCOPED TO .bk-ask, flipped by prefers-color-scheme — so each
rule below is written once and a host page cannot reach the values
without targeting our own class. Neutrals stay translucent so the
fragment sits on a light or a dark host alike. ---- */
".bk-ask{--bk-accent:#b2cd12;--bk-accent-line:rgba(178,205,18,.55);--bk-accent-soft:rgba(178,205,18,.12);",
"--bk-on-accent:#0c1014;--bk-open:#d29a02;--bk-open-text:#fbc10f;--bk-done:#71a166;--bk-done-text:#9bce90;",
"--bk-skip:#868d91;--bk-err:#fea47d}",
/* r2b D3 — the operator's theme reaches inside ("theme toggle reaches
inside"): light when the OS asks and dark is not forced, or when light
is forced — the Booth sheet's own rule, carried by `data-bk-theme` on
each fragment (bkTheme, below), never by the host page's <html>. */
"@media (prefers-color-scheme: light){.bk-ask:not([data-bk-theme=dark]){" + BK_LIGHT + "}}",
".bk-ask[data-bk-theme=light]{" + BK_LIGHT + "}",
".bk-ask{margin:1.1rem 0;padding:.9rem 1rem;border:1px solid rgba(128,140,160,.34);",
"border-top:2px solid var(--bk-open);border-radius:8px;background:rgba(128,140,160,.07);",
"font:15px/1.55 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif}",
".bk-ask.bk-done{border-top-color:var(--bk-done)}",
".bk-ask.bk-skip{border-top-color:var(--bk-skip)}",
".bk-ask-tag{display:inline-block;margin-bottom:.6rem;padding:2px 7px;border:1.5px solid currentColor;border-radius:3px;",
"font:600 11px/1.3 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.12em;text-transform:uppercase;color:var(--bk-open-text)}",
".bk-ask.bk-done .bk-ask-tag{color:var(--bk-done-text)}",
".bk-ask.bk-skip .bk-ask-tag{color:var(--bk-skip)}",
".bk-ask-title{margin:0 0 .15rem;font:500 11px/1.4 'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;",
"letter-spacing:.12em;text-transform:uppercase}",
".bk-ask-prompt{margin:0 0 .6rem;font-weight:600}",
".bk-ask-opts{display:flex;flex-direction:column;gap:.35rem}",
".bk-ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.55rem .7rem;cursor:pointer;",
"border:1px solid rgba(128,140,160,.3);border-radius:8px;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:var(--bk-accent-line);background:var(--bk-accent-soft)}",
".bk-ask-opt input{margin:.25rem 0 0;flex:0 0 auto;accent-color:var(--bk-accent)}",
".bk-ask-lab{display:flex;flex-direction:column;gap:.1rem;min-width:0}",
/* as S2: nothing here fades. Details inherit the HOST's text colour at full
strength, so they carry the host's contrast whatever the host is; the
smaller size is the de-emphasis. */
".bk-ask-det{font-size:.82rem}",
".bk-ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .65rem;",
"font:inherit;font-size:.9rem;color:inherit;background:rgba(128,140,160,.09);",
"border:1px solid rgba(128,140,160,.34);border-radius:8px;resize:vertical}",
".bk-ask-notes:focus{outline:2px solid var(--bk-accent);outline-offset:1px}",
/* the host's colour at 75%, not the browser's grey (3.5-4.3:1 on a dark host) */
".bk-ask-notes::placeholder{color:inherit;opacity:.75}",
/* the primary — green, because submitting is what arms the answer */
".bk-ask-go{margin-top:.75rem;cursor:pointer;font:600 13px/1 'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;",
"padding:.7rem 1.1rem;border-radius:8px;border:1px solid var(--bk-accent);",
"background:var(--bk-accent);color:var(--bk-on-accent)}",
".bk-ask-go:hover{filter:brightness(1.06)}",
".bk-ask-go:focus-visible{outline:2px solid var(--bk-accent);outline-offset:2px}",
".bk-ask-opt:has(input:focus-visible){outline:2px solid var(--bk-accent);outline-offset:1px}",
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem}",
".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
/* [hidden] restated at class specificity: a host rule as plain as
`p{display:block}` outranks the UA's own [hidden] and would show it. */
".bk-ask-status{margin:.6rem 0 0;font-size:.86rem;color:var(--bk-err)}",
".bk-ask-status[hidden]{display:none}",
"@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;
}
/* r2b D3: mark every fragment WE mounted with the operator's stored theme
choice (the same localStorage key the Booth's toggle writes — same
origin). Absent or unreadable = follow the OS, as before. The host page's
own <html> is the author's and is never touched. */
var ours = []; // every element this script mounted
function bkTheme() {
var t = null;
try { t = localStorage.getItem("booth.theme"); } catch (e) {}
var forced = t === "light" || t === "dark";
/* OUR fragments only (heid bug-hunt): an author's own `.bk-ask` in the
host page is theirs, and is never marked. */
ours.forEach(function (root) {
var els = [root].concat(Array.prototype.slice.call(root.querySelectorAll(".bk-ask")));
els.forEach(function (el) {
if (!el.classList.contains("bk-ask")) return;
if (forced) el.setAttribute("data-bk-theme", t); else el.removeAttribute("data-bk-theme");
});
});
}
/* A choice made in another tab moves an open report live. */
window.addEventListener("storage", function (e) {
if (e.key === "booth.theme" || e.key === null) bkTheme();
});
/* ONE SUBMIT SAVES EVERY ASK ON THE PAGE (2026-09-27; U3 contract,
"Submitting several asks at once"). One pick is one <form> is one POST,
and that POST's 303 reload wiped every pick made in the others: the
operator answered top to bottom, pressed the last button, and lost the
rest (`auk-audition`, 15:02:23 in the access log).
A submit on one of OUR forms, while ANOTHER of ours holds input the
operator changed, sends every changed ("dirty") form of ours: one POST
each, to its own action, with `Accept: application/json` for the route's
204 (r2 C3), one after another in DOCUMENT ORDER of the forms. A form
nobody touched is not sent - re-sending re-dates an answer nobody gave, and
a blank one is refused - and that includes the pressed one. A refused form
stops nothing. Each form the server took becomes its own new baseline, so
it is never sent again unless it changes again.
Then the page reloads - so what shows is the server's record - ONLY if
nothing is left unsaved: no refusal, and nothing of ours changed while
the batch was in flight. Otherwise NO reload: the pressed form's status line says
what did not save, and everything he entered stays on the page. The page
stays live during the flight, which is why that second condition exists
(heid bug-hunt, 4 of 4 arms). A press during the flight is ignored, never
handed to the browser; nothing is re-sent without a fresh press.
With no batch in flight and no OTHER dirty form, this does nothing and
the browser submits: the plain POST and 303 it always was. */
var sending = false;
/* as S5b (G13): LEAVING WITH AN UNSENT ANSWER ASKS FIRST, when any form of
ours is dirty (against what the server last took). Our own leaving does
not ask: the one-form path is the pressed form going to the server, so
that form is skipped for the ONE navigation its submit starts. A host
handler that cancels the submit after ours ran, or a navigation that
does not replace the page (stopped, or a 204), leaves the answer unsent
on screen, and the next leave asks (heid bug-hunt R3). A clean batch's
reload finds nothing dirty. */
var nativeSent = null;
window.addEventListener("beforeunload", function (ev) {
var skip = nativeSent;
nativeSent = null;
var forms = ourForms();
for (var i = 0; i < forms.length; i++) {
if (forms[i] !== skip && dirty(forms[i])) { ev.preventDefault(); ev.returnValue = ""; return; }
}
});
function serial(form) {
return new URLSearchParams(new FormData(form)).toString();
}
function dirty(form) {
/* Against what the server last TOOK from this page, once it has taken
something (`__bkSaved`, set per form on its 204). Before that, against
the server-rendered default: `form.elements` includes every control
bound by `form=`, wherever it sits in the document. */
if (form.__bkSaved !== undefined) return serial(form) !== form.__bkSaved;
var els = form.elements;
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.type === "radio" || el.type === "checkbox") {
if (el.checked !== el.defaultChecked) return true;
} else if (el.tagName === "TEXTAREA" || el.type === "text") {
if (el.value !== el.defaultValue) return true;
}
}
return false;
}
function ourForms() {
/* Document order (querySelectorAll), and only forms inside something this
script mounted: an author's own form is theirs, never ours to send. */
var all = document.querySelectorAll('form[id^="bk-ask-form-"]'), out = [];
for (var i = 0; i < all.length; i++) {
for (var j = 0; j < ours.length; j++) {
if (ours[j].contains(all[i])) { out.push(all[i]); break; }
}
}
return out;
}
/* THE REPORT'S OWN INPUTS, which a reload or a navigation of ours would
clear. Measured against how each stood WHEN WE MOUNTED, never against its
default attributes: a bare <select> shows its first option selected while
no option is defaultSelected, a range or color input has a value and no
value attribute, and a host script may fill fields at load — all of them
read "typed into" by the defaults before anyone touched them (SPYRJA H7,
heid V1). A contenteditable region is input too (H2, V2). */
var HOST = "input, textarea, select, [contenteditable]:not([contenteditable='false'])";
function hostControls(forms) {
var out = [], els = document.querySelectorAll(HOST);
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.form && forms.indexOf(el.form) >= 0) continue; // ours
if (/^(hidden|submit|button|reset|image)$/.test((el.type || "").toLowerCase())) continue;
out.push(el);
}
return out;
}
function hostState(el) {
if (el.isContentEditable && !("value" in el)) return el.innerHTML;
var type = (el.type || "").toLowerCase();
if (type === "radio" || type === "checkbox") return String(el.checked);
if (el.tagName === "SELECT") {
var on = [];
for (var j = 0; j < el.options.length; j++) if (el.options[j].selected) on.push(j);
return on.join(",");
}
return el.value;
}
function baselineHost() {
var els = hostControls(ourForms());
for (var i = 0; i < els.length; i++) els[i].__bkHost = hostState(els[i]);
}
function hostDirty(forms) {
var els = hostControls(forms);
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.__bkHost !== undefined) {
if (hostState(el) !== el.__bkHost) return true;
continue;
}
/* Added after we mounted: no baseline, so the defaults it is — with a
select's implicit first option counted as its default. */
if (el.isContentEditable && !("value" in el)) continue; // nothing to compare against
var type = (el.type || "").toLowerCase();
if (type === "radio" || type === "checkbox") {
if (el.checked !== el.defaultChecked) return true;
} else if (el.tagName === "SELECT") {
var any = false;
for (var j = 0; j < el.options.length; j++) if (el.options[j].defaultSelected) any = true;
for (var k = 0; k < el.options.length; k++) {
var dflt = any ? el.options[k].defaultSelected : k === 0;
if (el.options[k].selected !== dflt) return true;
}
} else if (el.value !== el.defaultValue) {
return true;
}
}
return false;
}
function askOf(form) {
var els = form.elements;
for (var i = 0; i < els.length; i++) {
if (els[i].name === "ask") return els[i].value;
}
return "?";
}
function send(form, body) {
/* Resolves to null when saved, or to why it was not. NEVER rejects, so one
refusal cannot stop the chain behind it. */
return fetch(form.action, {
method: "POST", body: body, credentials: "same-origin",
headers: { "Accept": "application/json" }
}).then(function (r) {
if (r.status === 204) return null;
return r.json().then(function (j) { return (j && j.detail) || "status " + r.status; },
function () { return "status " + r.status; });
}, function () { return "the Booth did not answer"; });
}
document.addEventListener("submit", function (ev) {
var form = ev.target;
if (ev.defaultPrevented || !window.fetch || !window.URLSearchParams || !window.FormData) return;
var forms = ourForms();
if (forms.indexOf(form) < 0) return;
/* In flight FIRST: a press on a form with no other dirty form beside it
would otherwise fall through to the browser, a native POST racing the
batch (heid bug-hunt, hulda). */
if (sending) { ev.preventDefault(); return; }
/* Unsaved text in the REPORT also keeps a lone answer off the browser's
own POST: its 303 is a navigation, and it took that text with it
(SPYRJA H1). */
var others = forms.some(function (f) { return f !== form && dirty(f); }) || hostDirty(forms);
if (!others) {
nativeSent = form;
setTimeout(function () { if (ev.defaultPrevented && nativeSent === form) nativeSent = null; }, 0);
}
if (!others) return; // the browser's own POST and 303
ev.preventDefault();
sending = true;
var batch = forms.filter(dirty);
if (!batch.length) { // report input only: nothing of ours to send
sending = false;
var none = form.parentNode && form.parentNode.querySelector(".bk-ask-status");
if (none) { none.textContent = "Nothing new to save in this answer."; none.hidden = false; }
return;
}
// every form's fields read NOW, at the press
var bodies = batch.map(function (f) { return new URLSearchParams(new FormData(f)); });
var failed = [], chain = Promise.resolve();
batch.forEach(function (f, n) {
chain = chain.then(function () {
return send(f, bodies[n]).then(function (why) {
if (why === null) f.__bkSaved = bodies[n].toString();
else failed.push(askOf(f) + " (" + why + ")");
});
});
});
chain.then(function () {
sending = false; // before anything here can throw
/* A refusal blocks the reload ON ITS OWN: a refused form the operator
then set back to its first value reads clean, and "nothing dirty"
alone reloaded over the failure without a word (heid bug-hunt,
hulda). Otherwise anything dirty was touched mid-flight. */
var changed = forms.some(dirty);
/* ...and the REPORT's own inputs: the reload would take whatever the
operator typed into the author's page too (design-dev's report,
2026-09-28). ourForms cannot see those, so this looks at the rest. */
var host = hostDirty(forms);
if (!failed.length && !changed && !host) { location.reload(); return; }
var saved = batch.length - failed.length;
var st = form.parentNode && form.parentNode.querySelector(".bk-ask-status");
if (!st) return;
st.textContent = failed.length
? "Saved " + saved + " of " + batch.length + ". Not saved: " + failed.join("; ") +
". Nothing you entered was cleared; reload to see what was saved."
: changed
? "Saved " + saved + " of " + batch.length + ". You changed an answer while " +
"that was saving, and it is not saved yet: press Submit again to save it."
: "Saved " + saved + " of " + batch.length + ". The page was not reloaded, because " +
"something else on it holds text you have not saved; reload when you are ready.";
st.hidden = false;
});
});
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 || []);
ours = [];
Object.keys(mounted).forEach(function (id) { ours = ours.concat(mounted[id]); });
bkTheme();
reassociate();
asksChip(data.open || [], mounted);
baselineHost();
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();
}
})();