Files
booth/booth/static/embed.js
T
vh 50bfc7b4ec fix(asks): one submit saves every ask on the page
Operator report (via infra-ops): on a page with several asks, a submit
saved only the pressed one and the reload wiped the rest. Confirmed on
auk-audition: one POST at 15:02:23 saved the last ask on the page, then a
400 from the submit of an ask the reload had just blanked.

Client-side on both surfaces; /answer is unchanged. A submit on a pick
form, while another pick form on the page holds unsent input, sends every
changed ("dirty") pick form: one POST each, to its own action, with
Accept: application/json, in document order. A refusal stops nothing, and
untouched forms are never re-sent. With no other dirty form, a submit is
exactly what it was.

- embed.js (verbatim reports): reloads only when nothing was refused and
  nothing of ours is dirty. Otherwise a server-rendered status line in the
  submit block says what did not save, and input stays. A form the server
  took gets a new baseline. A press during the flight is ignored.
- base.html (marks page, lightbox, review rail): one refresh in place. A
  batch never reloads. Only forms the server took count as sent. In-flight
  state and "just sent" are keyed by form identity (formKey) plus the fields
  at the press, not the DOM node.

Two heid bug-hunt rounds: a four-arm panel on the first cut, then Hulda
alone on the fold. Ten findings reproduced red in a browser before their
fixes. Contracts: U3 "Submitting several asks at once" + INV-8, R2 C3
steps 2, 3 and 3a. Mutation tables u3_submit_all (15) and r2_submit_all
(11), all proved. Suite 928 -> 951.
2026-09-27 17:05:11 -07:00

535 lines
27 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)}",
/* 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 10.5px/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;opacity:.62}",
".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}",
".bk-ask-det{font-size:.82rem;opacity:.68}",
".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 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-was{margin:.15rem 0 .55rem;font-size:.86rem;opacity:.8}",
".bk-ask-was b{opacity:1}",
".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;
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;
}
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; }
var others = forms.some(function (f) { return f !== form && dirty(f); });
if (!others) return; // the browser's own POST and 303
ev.preventDefault();
sending = true;
var batch = forms.filter(dirty);
// 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);
if (!failed.length && !changed) { 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."
: "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.";
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);
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();
}
})();