flowrs-engine/crates/flowrs-appshell/assets/shell.js
flowRS fd958c0547 flowRS engine, cut from flowRS 3451b065
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:04:39 -05:00

269 lines
11 KiB
JavaScript

// 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 <details> 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("<div>" + html + "</div>", "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();
})();