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.
535 lines
27 KiB
JavaScript
535 lines
27 KiB
JavaScript
/* 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();
|
||
}
|
||
})();
|