// The flowRS app shell's one script. The page draws without it; this only brings in the // next frame as the program prints, switches the skin, runs the tabs, and sends a change a // person makes to a control the program takes while it runs. It sets no innerHTML and no // style: a frame is parsed by DOMParser (the server escaped every value in it) and moved // in as nodes, every look is a class, and a slider's fill is an SVG attribute. It talks to // no one but the server that sent it. (() => { "use strict"; const page = document.querySelector("[data-testid=app-shell]"); const app = document.getElementById("frs-app"); const status = document.querySelector("[data-testid=app-status]"); if (!page || !app) return; let version = Number(page.getAttribute("data-version")) || 0; // Which
are open and what has focus, so a new frame keeps both. const keep = () => ({ open: [...app.querySelectorAll("details")].map((d) => d.open), focus: document.activeElement && app.contains(document.activeElement) ? document.activeElement.id : null, }); const restore = (k) => { app.querySelectorAll("details").forEach((d, i) => { if (k.open[i]) d.open = true; }); if (k.focus) { const el = document.getElementById(k.focus); if (el) el.focus({ preventScroll: true }); } }; // The control a person is using right now (focused, or being dragged) stays as it is // across a new frame: the frame's copy of it is swapped for the one in use. const inUse = () => { const el = document.activeElement; const field = el && app.contains(el) && el.closest("[data-frs-control]"); if (!field) return null; const same = [...app.querySelectorAll("[data-frs-control]")].filter((f) => f.getAttribute("data-frs-control") === field.getAttribute("data-frs-control")); return { field, target: field.getAttribute("data-frs-control"), nth: same.indexOf(field) }; }; const swap = (html) => { const k = keep(); const using = inUse(); const doc = new DOMParser().parseFromString("
" + html + "
", "text/html"); const next = doc.body.firstElementChild; if (using) { const same = [...next.querySelectorAll("[data-frs-control]")].filter((f) => f.getAttribute("data-frs-control") === using.target); const twin = same[using.nth]; if (twin) twin.replaceWith(using.field); } app.replaceChildren(...next.childNodes); restore(k); }; // ---- Live controls -------------------------------------------------------------- // A change goes to the shell (POST /control); the shell checks it against the control's // kind and range and sends it to the program, which applies it at its next pass. A // refused entry stays in the field it was typed in, marked invalid, beside what the // program goes on using, until the person edits it; a control that cannot hold a refused // value (a slider, a switch, a select) goes back to the value it holds. const fieldOf = (el) => el && el.closest && el.closest("[data-frs-control]"); // The field's typed entry: a number or text field (never a slider). const entryOf = (field) => field.querySelector('input:not([type="range"]), textarea'); const unitOf = (field) => { const u = field.querySelector(".fr-number-unit"); return u ? u.textContent : ""; }; const say = (field, why) => { const err = field.querySelector(".fr-field-error"); if (err) { err.textContent = why || ""; err.hidden = !why; } field.classList.toggle("fr-field-invalid", Boolean(why)); const entry = entryOf(field); if (entry) entry.setAttribute("aria-invalid", why ? "true" : "false"); const hint = field.querySelector(".fr-field-hint"); if (hint && hint.hasAttribute("data-frs-hint")) { const held = field.getAttribute("data-frs-value") || ""; const unit = unitOf(field); hint.textContent = why ? "The program is using " + (held ? held + (unit ? "\u2009" + unit : "") : "nothing yet") + "." : hint.getAttribute("data-frs-hint"); } }; const show = (field, value) => { const input = field.querySelector("input, select, textarea"); const sw = field.querySelector(".fr-toggle"); if (sw) { const on = value === "true"; sw.setAttribute("aria-checked", String(on)); const state = sw.querySelector(".fr-toggle-state"); if (state) state.textContent = on ? sw.getAttribute("data-frs-on") : sw.getAttribute("data-frs-off"); return; } if (!input) return; if (input.tagName === "SELECT" && input.getAttribute("data-frs-by") === "text") { const i = [...input.options].findIndex((o) => o.text === value); if (i >= 0) input.selectedIndex = i; } else { input.value = value; } if (input.type === "range") slid(input); }; // A slider's value beside its label, its fill and its spoken value, as it moves. const slid = (input) => { const field = fieldOf(input); const lo = Number(input.min), hi = Number(input.max), v = Number(input.value); const fill = field && field.querySelector(".frs-slider-fill"); if (fill && hi > lo) fill.setAttribute("width", ((v - lo) / (hi - lo)) * 100 + "%"); const unit = input.getAttribute("data-frs-unit") || ""; const words = unit ? v + "\u2009" + unit : String(v); const out = field && field.querySelector(".fr-field-value"); if (out) out.textContent = words; input.setAttribute("aria-valuetext", words); }; const send = async (field, value) => { const target = field.getAttribute("data-frs-control"); const nth = [...app.querySelectorAll("[data-frs-control]")].filter((f) => f.getAttribute("data-frs-control") === target).indexOf(field); let said; try { const r = await fetch("/control", { method: "POST", headers: { "Content-Type": "application/json", "X-Frs-Control": "1" }, body: JSON.stringify({ control: target, value, field: Math.max(nth, 0) }), }); said = await r.json(); } catch (_) { said = { ok: false, why: "The app's server did not answer" }; } if (said && said.ok) { field.setAttribute("data-frs-value", String(said.value)); say(field, ""); return; } const why = (said && said.why) || "Not taken"; if (entryOf(field)) { // Kept as typed, beside what the program goes on using. say(field, why); } else { // A slider, a switch or a select holds only what it takes: back to it, and why in // its hint. show(field, field.getAttribute("data-frs-value") || ""); const hint = field.querySelector(".fr-field-hint"); if (hint) hint.textContent = why; } }; // What a control holds, as the page sends it. const valueOf = (input) => { if (input.type === "range") return Number(input.value); if (input.tagName === "SELECT") { return input.getAttribute("data-frs-by") === "index" ? Number(input.value) : input.options[input.selectedIndex].text; } return input.value; }; document.addEventListener("input", (ev) => { const field = fieldOf(ev.target); if (!field || !app.contains(field)) return; if (ev.target.type === "range") slid(ev.target); // Editing a refused entry takes its error away: what is in the field is new. else if (field.classList.contains("fr-field-invalid")) say(field, ""); }); document.addEventListener("change", (ev) => { const field = fieldOf(ev.target); if (field && app.contains(field)) send(field, valueOf(ev.target)); }); // One request waiting for the next frame at a time; the server answers when a new one // is drawn (or after 25 s with the same one). const poll = async () => { for (;;) { let r; try { r = await fetch("/frame?v=" + version, { cache: "no-store" }); } catch (_) { if (status) status.textContent = "The app's server stopped"; return; } if (!r.ok) return; const v = Number(r.headers.get("X-Frs-Version")) || version; const state = r.headers.get("X-Frs-Status") || ""; const words = r.headers.get("X-Frs-Status-Words"); const html = await r.text(); if (v !== version) { version = v; page.setAttribute("data-version", String(v)); swap(html); } if (status && words) { try { status.textContent = JSON.parse(words); } catch (_) { // keep the words the page has } } if (state === "finished" || state === "failed") return; } }; // The skin: every element that carries one, and the address, so a reload keeps it. document.addEventListener("click", (ev) => { const to = ev.target.closest && ev.target.closest("[data-skin-to]"); if (to) { ev.preventDefault(); const skin = to.getAttribute("data-skin-to"); const back = skin === "paper" ? "instrument" : "paper"; document.querySelectorAll("[data-skin]").forEach((el) => el.setAttribute("data-skin", skin)); to.setAttribute("data-skin-to", back); to.setAttribute("href", "?skin=" + back); to.firstChild.textContent = back === "paper" ? "Light" : "Dark"; history.replaceState(null, "", "?skin=" + skin); return; } const off = ev.target.closest && ev.target.closest("[aria-disabled=true]"); if (off) ev.preventDefault(); const field = fieldOf(ev.target); if (field && app.contains(field)) { const sw = ev.target.closest(".fr-toggle"); if (sw) { const on = sw.getAttribute("aria-checked") !== "true"; show(field, String(on)); send(field, on); return; } const bump = ev.target.closest("[data-frs-bump]"); if (bump) { const input = field.querySelector("input"); const step = Number(input.getAttribute("data-frs-step")) || 1; let v = (Number(input.value) || 0) + Number(bump.getAttribute("data-frs-bump")) * step; const lo = input.getAttribute("data-frs-min"), hi = input.getAttribute("data-frs-max"); if (lo !== null) v = Math.max(v, Number(lo)); if (hi !== null) v = Math.min(v, Number(hi)); input.value = String(Math.round(v * 1e9) / 1e9); send(field, input.value); return; } } const tab = ev.target.closest && ev.target.closest(".fr-tab"); if (tab) select(tab); }); document.addEventListener("submit", (ev) => ev.preventDefault()); // Tabs (WAI-ARIA): one tab stop; arrows, Home and End move and select. const select = (tab) => { const list = tab.closest(".fr-tablist"); const tabs = [...list.querySelectorAll(".fr-tab")]; tabs.forEach((t) => { const on = t === tab; t.setAttribute("aria-selected", String(on)); t.setAttribute("tabindex", on ? "0" : "-1"); const panel = document.getElementById(t.getAttribute("aria-controls")); if (panel) panel.hidden = !on; }); }; document.addEventListener("keydown", (ev) => { const tab = ev.target.closest && ev.target.closest(".fr-tab"); if (!tab) return; const tabs = [...tab.closest(".fr-tablist").querySelectorAll(".fr-tab")]; const i = tabs.indexOf(tab); const n = tabs.length; const next = { ArrowRight: (i + 1) % n, ArrowLeft: (i + n - 1) % n, Home: 0, End: n - 1 }[ev.key]; if (next === undefined) return; ev.preventDefault(); select(tabs[next]); tabs[next].focus(); }); poll(); })();