diff --git a/app/ui/boot.js b/app/ui/boot.js
new file mode 100644
index 00000000..d1490d66
--- /dev/null
+++ b/app/ui/boot.js
@@ -0,0 +1,8 @@
+// Entry point: build the frame, load the features. Nothing here names a feature.
+import { build } from "./shell/frame.js";
+import { loadFeatures } from "./shell/features.js";
+import paths from "./modules.js";
+
+const frame = build(document.getElementById("app"));
+const result = await loadFeatures(paths);
+window.FunPackUI = { frame, ...result };
diff --git a/app/ui/catalogue/catalogue.css b/app/ui/catalogue/catalogue.css
new file mode 100644
index 00000000..78f77a22
--- /dev/null
+++ b/app/ui/catalogue/catalogue.css
@@ -0,0 +1,84 @@
+/* The catalogue is a development tool, not a module — but it still uses only
+ * tokens, so a drifted token shows up here first. */
+.cat-page { padding: var(--sp-9); max-width: 1400px; margin: 0 auto; }
+
+.cat-bar {
+ display: flex; align-items: center; gap: var(--sp-4);
+ padding: var(--sp-4) var(--sp-9);
+ border-bottom: var(--bw) solid var(--line);
+ background: var(--elev-1);
+ position: sticky; top: 0; z-index: 30;
+}
+.cat-bar h1 { font-family: var(--font-display); font-size: var(--fs-2); font-weight: var(--fw-bold); }
+.cat-bar-spacer { flex: 1; }
+.cat-bar button {
+ height: var(--ctl-h-lg); padding: 0 var(--ctl-pad-x-lg);
+ border: var(--bw) solid var(--line); border-radius: var(--ctl-r);
+ background: var(--elev-2); color: var(--text);
+ font-size: var(--fs-5); cursor: pointer;
+ transition: border-color var(--dur-1) var(--ease-standard);
+}
+.cat-bar button:hover { border-color: var(--line-hover); }
+.cat-bar button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
+
+.cat-section { margin-bottom: var(--sp-11); }
+.cat-section > h2 {
+ font-family: var(--font-display); font-size: var(--fs-1); line-height: var(--lh-1);
+ font-weight: var(--fw-semibold); margin-bottom: var(--sp-2);
+}
+.cat-section > p { color: var(--muted); font-size: var(--fs-5); margin-bottom: var(--sp-7); }
+
+.cat-panes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-7); align-items: start; }
+.cat-panes.single { grid-template-columns: 1fr; }
+.cat-pane {
+ background: var(--elev-0); color: var(--text);
+ border: var(--bw) solid var(--line); border-radius: var(--r-5);
+ padding: var(--sp-7); overflow: hidden;
+}
+.cat-pane > h3 {
+ font-size: var(--fs-7); line-height: var(--lh-7); letter-spacing: .08em;
+ text-transform: uppercase; color: var(--faint);
+ margin-bottom: var(--sp-6);
+}
+
+.cat-rows { display: flex; flex-direction: column; gap: var(--sp-3); }
+.cat-row { display: grid; grid-template-columns: 190px 90px 1fr; gap: var(--sp-4); align-items: center; }
+.cat-name { font-family: var(--font-mono); font-size: var(--fs-7); color: var(--muted); }
+.cat-val { font-family: var(--font-mono); font-size: var(--fs-7); color: var(--faint); text-align: right; }
+
+.cat-swatch { height: 26px; border-radius: var(--r-2); border: var(--bw) solid var(--line-2); }
+.cat-bar-fill { height: 12px; border-radius: var(--r-1); background: var(--accent); }
+.cat-box { border: var(--bw) solid var(--accent); background: var(--accent-soft); height: 34px; width: 90px; }
+.cat-ctl { border: var(--bw) solid var(--line); background: var(--elev-2); border-radius: var(--ctl-r);
+ display: flex; align-items: center; padding: 0 var(--ctl-pad-x-lg); font-size: var(--fs-5); width: max-content; }
+.cat-elev { height: 40px; border-radius: var(--r-4); border: var(--bw) solid var(--line); display: flex;
+ align-items: center; padding: 0 var(--sp-5); font-size: var(--fs-6); color: var(--muted); }
+.cat-type { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.cat-motion { height: 12px; width: 12px; border-radius: var(--r-pill); background: var(--accent); }
+.cat-motion-track { border: var(--bw) dashed var(--line); border-radius: var(--r-pill); padding: 3px; }
+.cat-motion-track:hover .cat-motion { transform: translateX(140px); }
+.cat-motion { transition: transform var(--dur-2) var(--ease-standard); }
+
+/* elements.html */
+.cat-variant { font-family: var(--font-mono); font-size: var(--fs-6); color: var(--accent);
+ margin: var(--sp-6) 0 var(--sp-3); }
+.cat-specimens { display: flex; flex-wrap: wrap; gap: var(--sp-5); align-items: flex-start; }
+.cat-specimen { display: flex; }
+.cat-broke { font-size: var(--fs-7); color: var(--danger); font-family: var(--font-mono); }
+.cat-link { color: var(--muted); font-size: var(--fs-5); text-decoration: none;
+ padding: 0 var(--sp-3); }
+.cat-link:hover { color: var(--accent); }
+
+.cat-launch {
+ height: var(--ctl-h-lg); padding: 0 var(--ctl-pad-x-lg);
+ border: var(--bw) dashed var(--accent); border-radius: var(--ctl-r);
+ background: var(--accent-soft); color: var(--accent-hi);
+ font-family: var(--font-mono); font-size: var(--fs-6); cursor: pointer;
+}
+
+/* hide.html */
+.cat-mount { display: flex; flex-direction: column; gap: var(--sp-5); }
+.cat-ledger { display: grid; grid-template-columns: 160px 1fr 1fr; gap: var(--sp-3) var(--sp-5);
+ font-size: var(--fs-6); align-items: baseline; }
+.cat-why { color: var(--muted); }
+.cat-caught { color: var(--faint); font-family: var(--font-mono); font-size: var(--fs-7); }
diff --git a/app/ui/catalogue/catalogue.js b/app/ui/catalogue/catalogue.js
new file mode 100644
index 00000000..0c2599a2
--- /dev/null
+++ b/app/ui/catalogue/catalogue.js
@@ -0,0 +1,255 @@
+// The token sheet.
+//
+// It reads the custom properties out of the loaded stylesheet rather than
+// listing them, so it cannot drift from tokens.css: a token added there shows up
+// here on reload, and one deleted there disappears. Same inversion the kit uses.
+
+const GROUPS = [
+ { id: "font", title: "Font stacks", match: (n) => n.startsWith("--font-"), render: renderFont },
+ { id: "type", title: "Type scale", match: (n) => /^--fs-/.test(n), render: renderType,
+ // each step shows its own leading, so --lh-N is spoken for here
+ also: (names) => names.map((n) => n.replace("--fs-", "--lh-")) },
+ { id: "lh", title: "Line heights", match: (n) => /^--lh-(tight|snug|loose)$/.test(n), render: renderPlain },
+ { id: "weight", title: "Weights", match: (n) => n.startsWith("--fw-"), render: renderWeight },
+ { id: "space", title: "Spacing", match: (n) => /^--sp-\d+$/.test(n), render: renderBar },
+ { id: "radius", title: "Radius", match: (n) => /^--r-/.test(n), render: renderRadius },
+ { id: "control", title: "Controls", match: (n) => n.startsWith("--ctl-"), render: renderControl },
+ { id: "elev", title: "Elevation", match: (n) => n.startsWith("--elev-"), render: renderElev },
+ { id: "shadow", title: "Shadows", match: (n) => /^--shadow-\d|^--focus-ring$/.test(n), render: renderShadow },
+ { id: "motion", title: "Motion", match: (n) => /^--dur-|^--ease-/.test(n), render: renderMotion },
+ { id: "state", title: "States", match: (n) => /^--(bw|disabled-opacity)$/.test(n), render: renderState },
+ { id: "density", title: "Density", match: (n) => n.startsWith("--cell-"), render: renderBar },
+ { id: "layout", title: "Layout", match: (n) => /^--menubar-h$/.test(n), render: renderBar },
+ // Colour is last and greedy: anything left that a browser accepts as a colour.
+ { id: "colour", title: "Colour", match: isColourName, render: renderSwatch },
+];
+
+const COLOUR_HINT = /^--(elev|hover|active|line|text|muted|faint|accent|on-accent|teal|violet|danger|warn|good|pink|void|shadow-ink|backdrop)/;
+
+function isColourName(name) {
+ return COLOUR_HINT.test(name);
+}
+
+/** Every custom property declared in the kit's stylesheet, in declaration order. */
+function declaredTokens() {
+ const names = [];
+ const seen = new Set();
+
+ const walk = (sheet) => {
+ let rules;
+ try { rules = sheet.cssRules; } catch { return; } // cross-origin: skip
+ if (!rules) return;
+ for (const rule of rules) {
+ if (rule.styleSheet) { walk(rule.styleSheet); continue; } // @import
+
+ // Collect BEFORE recursing. Since CSS nesting shipped, every CSSStyleRule
+ // carries a (usually empty) .cssRules list — treating that as "this is a
+ // container, skip its properties" silently finds nothing at all.
+ if (rule.style && rule.selectorText && /:root|\[data-theme/.test(rule.selectorText)) {
+ for (const prop of rule.style) {
+ if (prop.startsWith("--") && !seen.has(prop)) { seen.add(prop); names.push(prop); }
+ }
+ }
+
+ if (rule.cssRules && rule.cssRules.length) walk(rule); // @media, nesting
+ }
+ };
+
+ for (const sheet of document.styleSheets) walk(sheet);
+ return names;
+}
+
+const el = (tag, cls, text) => {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text != null) n.textContent = text;
+ return n;
+};
+
+function row(name, value, visual) {
+ const r = el("div", "cat-row");
+ r.append(el("code", "cat-name", name), el("code", "cat-val", value));
+ r.append(visual || el("div"));
+ return r;
+}
+
+// ── renderers ──────────────────────────────────────────────────────────────
+
+function renderPlain(name, value) { return row(name, value, el("div")); }
+
+function renderFont(name, value) {
+ const s = el("div", "cat-type", "Grid tuning — 0123456789");
+ s.style.fontFamily = value;
+ s.style.fontSize = "15px";
+ return row(name, value.split(",")[0].replace(/"/g, ""), s);
+}
+
+function renderType(name, value, read) {
+ const step = name.replace("--fs-", "");
+ const lh = read(`--lh-${step}`);
+ const s = el("div", "cat-type", "The quick brown fox");
+ s.style.fontSize = value;
+ s.style.lineHeight = lh;
+ if (step.startsWith("d")) s.style.fontFamily = "var(--font-display)";
+ return row(name, `${value} / ${lh}`, s);
+}
+
+function renderWeight(name, value) {
+ const s = el("div", "cat-type", "The quick brown fox");
+ s.style.fontWeight = value;
+ s.style.fontSize = "15px";
+ return row(name, value, s);
+}
+
+function renderBar(name, value) {
+ const b = el("div", "cat-bar-fill");
+ b.style.width = value;
+ return row(name, value, b);
+}
+
+function renderRadius(name, value) {
+ const b = el("div", "cat-box");
+ b.style.borderRadius = value;
+ return row(name, value, b);
+}
+
+function renderControl(name, value) {
+ if (/^--ctl-h/.test(name)) {
+ const c = el("div", "cat-ctl", "Button");
+ c.style.height = value;
+ return row(name, value, c);
+ }
+ return renderBar(name, value);
+}
+
+function renderElev(name, value) {
+ const b = el("div", "cat-elev", value);
+ b.style.background = value;
+ return row(name, value, b);
+}
+
+function renderShadow(name, value) {
+ const b = el("div", "cat-elev", "");
+ b.style.background = "var(--elev-2)";
+ b.style.boxShadow = value;
+ b.style.width = "150px";
+ return row(name, value.length > 34 ? value.slice(0, 32) + "…" : value, b);
+}
+
+function renderMotion(name, value) {
+ if (name.startsWith("--ease-")) return renderPlain(name, value);
+ const track = el("div", "cat-motion-track");
+ const dot = el("div", "cat-motion");
+ dot.style.transitionDuration = value;
+ track.append(dot);
+ return row(name, `${value} · hover`, track);
+}
+
+function renderState(name, value) {
+ const b = el("div", "cat-ctl", name === "--bw" ? "border width" : "disabled");
+ b.style.height = "var(--ctl-h-lg)";
+ if (name === "--bw") b.style.borderWidth = value;
+ else b.style.opacity = value;
+ return row(name, value, b);
+}
+
+function renderSwatch(name, value) {
+ const s = el("div", "cat-swatch");
+ s.style.background = value;
+ return row(name, value, s);
+}
+
+// ── page ───────────────────────────────────────────────────────────────────
+
+function buildPane(theme, names, group) {
+ const pane = el("section", "cat-pane");
+ pane.setAttribute("data-theme", theme);
+ pane.append(el("h3", null, theme));
+
+ // Appended before reading, so getComputedStyle resolves against this pane's
+ // own data-theme rather than the document's.
+ document.body.append(pane);
+ const read = (n) => getComputedStyle(pane).getPropertyValue(n).trim();
+
+ const rows = el("div", "cat-rows");
+ for (const name of names) rows.append(group.render(name, read(name), read));
+ pane.append(rows);
+ return pane;
+}
+
+function render(sideBySide) {
+ const page = document.querySelector("#catalogue");
+ page.textContent = "";
+
+ const all = declaredTokens();
+ const taken = new Set();
+
+ for (const group of GROUPS) {
+ const names = all.filter((n) => !taken.has(n) && group.match(n));
+ names.forEach((n) => taken.add(n));
+ if (group.also) group.also(names).forEach((n) => taken.add(n));
+ if (!names.length) continue;
+
+ const section = el("section", "cat-section");
+ section.append(el("h2", null, group.title));
+ section.append(el("p", null, `${names.length} token${names.length === 1 ? "" : "s"}`));
+
+ const panes = el("div", "cat-panes" + (sideBySide ? "" : " single"));
+ section.append(panes);
+ page.append(section);
+
+ const themes = sideBySide ? ["dark", "light"] : [document.documentElement.getAttribute("data-theme") || "dark"];
+ for (const t of themes) panes.append(buildPane(t, names, group));
+ }
+
+ const leftover = all.filter((n) => !taken.has(n));
+ if (leftover.length) {
+ const s = el("section", "cat-section");
+ s.append(el("h2", null, "Ungrouped"));
+ s.append(el("p", null, "Declared in tokens.css but not claimed by any group above — either the token is new or the catalogue needs a renderer for it."));
+ const panes = el("div", "cat-panes single");
+ panes.append(buildPane(document.documentElement.getAttribute("data-theme") || "dark", leftover, { render: renderPlain }));
+ s.append(panes);
+ page.append(s);
+ }
+}
+
+let sideBySide = true;
+
+document.querySelector("#toggle-panes").addEventListener("click", (e) => {
+ sideBySide = !sideBySide;
+ e.currentTarget.setAttribute("aria-pressed", String(sideBySide));
+ render(sideBySide);
+});
+
+for (const btn of document.querySelectorAll("[data-theme-set]")) {
+ btn.addEventListener("click", () => {
+ window.ComposerTheme.apply(btn.getAttribute("data-theme-set"));
+ if (!sideBySide) render(false);
+ });
+}
+
+// composer.css reaches the tokens through @import, and an imported sheet's
+// `.styleSheet` is still null while it is in flight — a module script runs well
+// before that. Wait for load, when every sheet in the chain is parsed.
+function stylesheetsReady() {
+ if (document.readyState === "complete") return Promise.resolve();
+ return new Promise((r) => window.addEventListener("load", r, { once: true }));
+}
+
+stylesheetsReady().then(() => {
+ if (!declaredTokens().length) {
+ // A blank catalogue is the one thing this page must never be: it looks like
+ // "no tokens" when it means "could not read them".
+ const page = document.querySelector("#catalogue");
+ page.textContent = "";
+ const s = el("section", "cat-section");
+ s.append(el("h2", null, "No tokens found"));
+ s.append(el("p", null,
+ "composer.css loaded but no custom properties could be read from it. " +
+ "Either tokens.css failed to load, or its :root rules were renamed."));
+ page.append(s);
+ return;
+ }
+ render(sideBySide);
+});
diff --git a/app/ui/catalogue/demos.js b/app/ui/catalogue/demos.js
new file mode 100644
index 00000000..2b538c93
--- /dev/null
+++ b/app/ui/catalogue/demos.js
@@ -0,0 +1,369 @@
+// One demo per element: the props the catalogue renders it with, and the props
+// the rule tests attack it with.
+//
+// It lives in one place because two things need it and they must not drift: the
+// catalogue shows what an element looks like, and rule_content_is_data feeds
+// every string in here a payload to prove it comes back as text. A registered
+// element with no entry here fails the suite -- that is how coverage keeps up
+// with the registry instead of lagging behind it.
+
+// A demo is either a plain props object, or a function given `composer` -- the
+// containers take other elements' handles, and those have to be built.
+export const DEMOS = {
+ "header.display": { text: "Cutting Room" },
+ "header.xl": { text: "Scene 3 — rooftop, dusk" },
+ "header.lg": { text: "Sampling" },
+ "header.md": { text: "Second pass" },
+ "header.sm": { text: "Guidance" },
+
+ "text.lg": { text: "The chain sampler runs each scene in order, carrying the last frame forward." },
+ "text.md": { text: "The chain sampler runs each scene in order, carrying the last frame forward." },
+ "text.sm": { text: "The chain sampler runs each scene in order, carrying the last frame forward." },
+ "text.xs": { text: "The chain sampler runs each scene in order, carrying the last frame forward." },
+
+ "hint.default": { text: "Locks frame timing to the audio stream." },
+ "label.field": { text: "Lock strength", for: "demo-field" },
+ "label.section": { text: "Sampling" },
+ "code.inline": { text: "bong_tangent" },
+ "code.block": { text: "steps: 7\nshift: 3.0\nscheduler: bong_tangent", label: "Pass settings" },
+
+ "button.xl": { label: "Generate", tone: "primary" },
+ "button.lg": { label: "Save project" },
+ "button.md": { label: "Cancel" },
+ "button.sm": { label: "Reset" },
+
+ "iconButton.md": { icon: "✕", label: "Close" },
+ "iconButton.sm": { icon: "✎", label: "Rename" },
+ "iconButton.micro": { icon: "✕", label: "Remove" },
+
+
+ "input.md": { value: "rooftop, dusk", placeholder: "Scene prompt", label: "Prompt" },
+ "input.sm": { value: "121", label: "Frames" },
+ "search.md": { placeholder: "Search shortcuts", label: "Search" },
+ "number.md": { value: 3, min: 1, max: 10, step: 0.5, precision: 1, unit: "x", label: "Shift" },
+ "stepper.md": { value: 7, min: 1, max: 40, step: 1, label: "Steps" },
+ "textarea.md": { value: "A slow push in on the rooftop as the light goes.", rows: 3, label: "Scene prompt" },
+ "filterList.md": {
+ value: "bong",
+ items: [
+ { id: "bong", label: "bong_tangent", hint: "validated" },
+ { id: "beta", label: "beta57", hint: "" },
+ { id: "karras", label: "karras", hint: "2.3 era" },
+ ],
+ },
+
+ "checkbox.default": { checked: true, label: "Enabled" },
+ "checkboxRow.default": { label: "Sync to audio clock", hint: "Locks frame timing to the audio stream.", checked: true },
+ "checklist.default": {
+ label: "Passes",
+ values: ["high"],
+ items: [
+ { value: "high", label: "High noise" },
+ { value: "low", label: "Low noise" },
+ { value: "detail", label: "Detail tail" },
+ ],
+ },
+ "color.swatch": { label: "Overlay colour", value: "#f3a93c" },
+ "radioGroup.default": {
+ label: "Alignment",
+ value: "beat",
+ options: [
+ { value: "beat", label: "Beat grid", hint: "Follows the tempo" },
+ { value: "onset", label: "Onset", hint: "Follows transients" },
+ { value: "flat", label: "Flat" },
+ ],
+ },
+ "select.md": {
+ label: "Scheduler", value: "bong_tangent",
+ options: [
+ { value: "bong_tangent", label: "bong_tangent" },
+ { value: "beta57", label: "beta57" },
+ { value: "karras", label: "karras" },
+ ],
+ },
+ "select.sm": {
+ label: "Sort", value: "recent",
+ options: [{ value: "recent", label: "Most recent" }, { value: "name", label: "Name" }],
+ },
+ "segmented.md": {
+ label: "Mode", value: "i2v",
+ options: [{ value: "t2v", label: "Text" }, { value: "i2v", label: "Image" }, { value: "v2v", label: "Video" }],
+ },
+ "segmented.sm": {
+ label: "Columns", value: "2",
+ options: [{ value: "0", label: "Auto" }, { value: "2", label: "2" }, { value: "3", label: "3" }],
+ },
+ "toggle.default": { label: "Second pass", hint: "Runs both schedules in full.", checked: true },
+
+ "slider.md": { value: 0.65, min: 0, max: 1, step: 0.05, label: "Lock strength" },
+ "slider.sm": { value: 0.3, min: 0, max: 1, step: 0.05, label: "Blend" },
+ "slider.readout": { value: 0.65, min: 0, max: 1, step: 0.05, unit: "x", label: "Lock strength" },
+ "slider.macro": {
+ value: 0.4, min: 0, max: 1, step: 0.05, label: "Variability",
+ presets: [{ label: "Chill", value: 0.2 }, { label: "Spicy", value: 0.5 }, { label: "Chaos", value: 0.9 }],
+ },
+ "range.md": { from: 0.3, to: 0.7, min: 0, max: 1, step: 0.05, label: "Sigma window" },
+
+ "buttonGroup.md": {
+ label: "Alignment",
+ value: "beat",
+ items: [
+ { value: "beat", label: "Beat grid" },
+ { value: "onset", label: "Onset" },
+ { value: "flat", label: "Flat" },
+ ],
+ },
+
+ // --- layout: these compose other elements, so their demos are functions ---
+ "field.default": (c) => ({
+ label: "Lock strength", hint: "Locks frame timing to the audio stream.",
+ control: c.slider.readout({ value: 0.65, min: 0, max: 1, step: 0.05, unit: "x" }),
+ }),
+ "field.row": (c) => ({
+ fields: [
+ c.field.default({ label: "Width", control: c.number.md({ value: 1216, step: 32 }) }),
+ c.field.default({ label: "Height", control: c.number.md({ value: 704, step: 32 }) }),
+ ],
+ }),
+ "settingsRow.default": (c) => ({
+ label: "Second pass", hint: "Runs both schedules in full.",
+ control: c.toggle.default({ checked: true }),
+ }),
+ "group.default": (c) => ({
+ label: "Sampling",
+ rows: [
+ c.settingsRow.default({ label: "Steps", control: c.stepper.md({ value: 7, min: 1, max: 40 }) }),
+ c.settingsRow.default({ label: "Shift", hint: "Structure versus fine detail.",
+ control: c.number.md({ value: 3, min: 1, max: 10, step: 0.5, precision: 1 }) }),
+ c.settingsRow.default({ label: "Reset engine", danger: true,
+ control: c.button.md({ label: "Reset", tone: "danger" }) }),
+ ],
+ }),
+ "brand.default": { name: "FunPack" },
+ "panel.default": (c) => ({
+ title: "Scene 3",
+ actions: [c.iconButton.sm({ icon: "✎", label: "Rename" }), c.iconButton.sm({ icon: "✕", label: "Remove" })],
+ body: c.text.sm({ text: "A slow push in on the rooftop as the light goes." }),
+ }),
+ "panel.zone": (c) => ({
+ title: "Assets",
+ actions: [c.iconButton.sm({ icon: "▦", label: "Grid" })],
+ body: c.text.sm({ text: "A region of the app: square, edge to edge, divided by a hairline." }),
+ }),
+ "region.stack": (c) => ({
+ label: "Pipeline",
+ children: [
+ c.text.sm({ text: "Contents are replaced by whoever holds the handle." }),
+ c.button.md({ label: "Redraw" }),
+ ],
+ }),
+ "toolbar.default": (c) => ({
+ label: "Timeline",
+ items: [
+ c.button.sm({ label: "Split" }), c.button.sm({ label: "Duplicate" }),
+ c.button.sm({ label: "Delete", tone: "danger" }),
+ ],
+ }),
+ "actionBar.sticky": (c) => ({
+ note: "Nothing is saved until you press Save.",
+ actions: [c.button.lg({ label: "Cancel" }), c.button.lg({ label: "Save", tone: "primary" })],
+ }),
+ "list.rows": (c) => ({
+ reorder: true,
+ addLabel: "Add LoRA",
+ onAdd: () => {},
+ onRemove: () => {},
+ items: [
+ { label: "detail_v3.safetensors",
+ control: c.number.md({ value: 0.8, min: -2, max: 2, step: 0.05, precision: 2, label: "Weight" }) },
+ { label: "motion_lift.safetensors",
+ control: c.number.md({ value: 0.4, min: -2, max: 2, step: 0.05, precision: 2, label: "Weight" }) },
+ ],
+ }),
+ "tabs.underline": { value: "compose", tabs: [
+ { value: "compose", label: "Compose" }, { value: "shortcuts", label: "Shortcuts" }, { value: "files", label: "Files" },
+ ] },
+ "tabs.dock": { value: "assets", tabs: [
+ { value: "assets", label: "Assets" }, { value: "preview", label: "Preview" }, { value: "props", label: "Properties" },
+ ] },
+ "splitPane.h": (c) => ({
+ size: 40, label: "Assets and preview",
+ panes: [
+ c.panel.default({ title: "Assets", body: c.hint.default({ text: "Media bin" }) }),
+ c.panel.default({ title: "Preview", body: c.hint.default({ text: "Program monitor" }) }),
+ ],
+ }),
+ "viewer.media": { src: "", empty: "Nothing generated yet" },
+ "frame.app": (c) => ({
+ header: c.toolbar.default({ label: "App", items: [c.button.sm({ label: "Models and pipeline" })] }),
+ main: c.panel.default({ title: "Workspace", body: c.hint.default({ text: "Everything that fills the window" }) }),
+ footer: c.actionBar.sticky({ note: "Ready", actions: [c.button.lg({ label: "Generate", tone: "primary" })] }),
+ }),
+ "workspace.docked": (c) => ({
+ id: "demo",
+ leftLabel: "Assets", rightLabel: "Properties",
+ left: c.panel.default({ title: "Assets", body: c.hint.default({ text: "Media bin" }) }),
+ centre: c.panel.default({ title: "Preview", body: c.hint.default({ text: "Program monitor" }) }),
+ right: c.panel.default({ title: "Properties", body: c.hint.default({ text: "Scene settings" }) }),
+ }),
+ "splitPane.v": (c) => ({
+ size: 60, label: "Preview and timeline",
+ panes: [
+ c.panel.default({ title: "Preview", body: c.hint.default({ text: "Program monitor" }) }),
+ c.panel.default({ title: "Timeline", body: c.hint.default({ text: "Scenes" }) }),
+ ],
+ }),
+ "collapsible.default": (c) => ({
+ label: "Advanced", hint: "rarely needed",
+ body: c.checkboxRow.default({ label: "Bypass this node", hint: "Passes inputs straight through." }),
+ }),
+ "sidebar.rail": { value: "models", items: [
+ { value: "general", icon: "◐", label: "General" },
+ { value: "models", icon: "▦", label: "Models" },
+ { value: "engine", icon: "⚙", label: "Engine" },
+ ] },
+
+ // --- status ---
+ "chip.neutral": { label: "experimental" },
+ "chip.good": { label: "validated", dot: true },
+ "chip.warn": { label: "needs audio", dot: true },
+ "chip.danger": { label: "missing model", dot: true },
+ "chip.info": { label: "flow-match", dot: true },
+ "dot.default": { tone: "good", label: "Ready" },
+ "banner.info": { text: "This project was made with an older engine." },
+ "banner.warn": { text: "No audio stream: the audio clock will not run." },
+ "banner.danger": { text: "The diffusion model could not be loaded." },
+ "inlineError.default": { text: "Steps must be between 1 and 40." },
+ "progress.bar": { value: 62, max: 100, label: "Rendering" },
+ "progress.indeterminate": { label: "Preparing" },
+ "spinner.md": { label: "Working" },
+ "spinner.sm": { label: "Working" },
+ "emptyState.default": { icon: "▦", title: "No media yet", hint: "Drop files here, or import from the menu." },
+ "dropzone.default": { label: "Drop video or images", hint: "mp4, png, jpg" },
+ "skeleton.line": { count: 3, label: "Loading scenes" },
+ "skeleton.block": { count: 2, label: "Loading panels" },
+ "skeleton.grid": { count: 6, label: "Loading media" },
+ "toast.info": { text: "Project saved.", duration: 0 },
+ "toast.good": { text: "Render finished.", duration: 0 },
+ "toast.warn": { text: "Audio track is silent.", duration: 0 },
+ "toast.danger": { text: "Generation failed.", duration: 0 },
+
+ // --- overlays: these mount themselves into the portal ---
+ "modal.generic": (c) => ({
+ title: "Export settings", subtitle: "Written into the picture's metadata.",
+ body: c.checkboxRow.default({ label: "Include prompt", hint: "Anyone with the file can read it." }),
+ actions: [c.button.lg({ label: "Cancel" }), c.button.lg({ label: "Export", tone: "primary" })],
+ }),
+ "modal.stacked": (c) => ({
+ title: "Pick a model", size: "sm",
+ body: c.filterList.md({ items: [{ id: "a", label: "ltx-2.5-distilled" }, { id: "b", label: "minimax-h3" }] }),
+ }),
+ "modal.dialogue": { title: "Delete scene 3?", message: "This removes the scene and its render. It cannot be undone.",
+ tone: "danger", confirmLabel: "Delete", cancelLabel: "Keep" },
+ "modal.prompt": { title: "New project", label: "Name", value: "Untitled", confirmLabel: "Create" },
+ "modal.choice": { title: "Start from", subtitle: "You can change this later.", items: [
+ { id: "blank", label: "Empty project", hint: "Nothing on the timeline" },
+ { id: "image", label: "From an image", hint: "Adds it as the first anchor" },
+ ] },
+
+ "popover.anchored": (c) => ({
+ anchor: c.button.md({ label: "Anchor" }),
+ body: c.hint.default({ text: "Anchored, flipped and clamped by the kit." }),
+ }),
+ "menu.dropdown": (c) => ({
+ anchor: c.button.md({ label: "Scene ▾" }),
+ items: [
+ { id: "split", label: "Split at playhead", hint: "S" },
+ { id: "dup", label: "Duplicate" },
+ { separator: true },
+ { id: "del", label: "Remove", danger: true },
+ ],
+ }),
+ "menu.context": { x: 200, y: 200, items: [
+ { id: "rename", label: "Rename" }, { id: "export", label: "Export" },
+ ] },
+ "tooltip.default": (c) => ({ anchor: c.iconButton.md({ icon: "✎", label: "Rename" }),
+ text: "Rename this scene", trigger: false }),
+ "autocomplete.default": (c) => ({
+ input: c.input.md({ value: "", placeholder: "Type a shortcut" }),
+ source: () => [{ label: "rooftop", hint: "location" }, { label: "rain", hint: "weather" }],
+ }),
+ "splitButton.md": { label: "Generate", items: [
+ { id: "all", label: "All scenes" }, { id: "one", label: "This scene only" },
+ ] },
+
+ "floating.window": (c) => ({
+ id: "demo-window", title: "Composer", subtitle: "prompt",
+ width: 320, height: 200, x: 40, y: 40,
+ body: c.textarea.md({ value: "A slow push in on the rooftop.", rows: 3, label: "Prompt" }),
+ }),
+ "overlay.blocking": { message: "Restarting ComfyUI…" },
+ "slideOver.md": (c) => ({ side: "right", title: "Properties",
+ body: c.hint.default({ text: "Simple mode slides this over the preview." }) }),
+
+ // --- gallery + wheel ---
+ "gallery.adaptive": { id: "demo-gallery", cols: 3, items: [
+ { id: "1", label: "rooftop_dusk_01.mp4", badge: "video", duration: "0:04" },
+ { id: "2", label: "anchor_frame.png", badge: "image" },
+ { id: "3", label: "rooftop_dusk_02.mp4", badge: "video", duration: "0:05" },
+ { id: "4", label: "reference_face.png", badge: "ref" },
+ { id: "5", label: "score.wav", badge: "audio", duration: "1:12" },
+ { id: "6", label: "rooftop_dusk_03.mp4", badge: "video", duration: "0:04" },
+ ] },
+ "gallery.icons": { selection: ["2"], items: [
+ { id: "1", label: "rooftop_dusk_01.mp4", icon: "▶" },
+ { id: "2", label: "anchor_frame.png" },
+ { id: "3", label: "rooftop_dusk_02.mp4", icon: "▶" },
+ { id: "4", label: "reference_face.png" },
+ ] },
+ "gallery.list": { selection: ["1"], items: [
+ { id: "1", label: "rooftop_dusk_01.mp4", icon: "▶", hint: "video" },
+ { id: "2", label: "anchor_frame.png", hint: "image" },
+ { id: "3", label: "a_very_long_generated_filename_00042_.png", hint: "image" },
+ ] },
+ "gallery.cards": { value: "image", items: [
+ { id: "blank", icon: "▦", label: "Empty project", hint: "Nothing on the timeline" },
+ { id: "image", icon: "◐", label: "From an image", hint: "Adds it as the first anchor" },
+ { id: "import", icon: "🎞", label: "Import a workflow", hint: "Reads an existing graph" },
+ ] },
+ "gallery.strip": { label: "Segments", items: [
+ { id: "1", icon: "▦", label: "Scene 1" }, { id: "2", icon: "▦", label: "Scene 2" },
+ { id: "3", icon: "▦", label: "Scene 3" }, { id: "4", icon: "▦", label: "Scene 4" },
+ ] },
+ "ruler.default": { label: "Scenes", total: 240, marks: [
+ { at: 0, label: "1", hint: "0" }, { at: 0.4, label: "2", hint: "96" },
+ { at: 0.75, label: "3", hint: "180" },
+ ] },
+ "track.default": { label: "Timeline", pxPerSecond: 40, playhead: 6.5, selection: ["2"], items: [
+ { id: "1", icon: "▦", label: "Scene 1", badge: "1", start: 0, duration: 4 },
+ { id: "2", icon: "▦", label: "Scene 2", badge: "2", start: 4, duration: 6, rating: "liked" },
+ { id: "3", icon: "▦", label: "Scene 3", badge: "3", start: 10, duration: 3, excluded: true },
+ ] },
+ "wheel.half": { edge: "right", items: [
+ { icon: "✂", label: "Split" }, { icon: "⧉", label: "Duplicate" },
+ { icon: "★", label: "Rate" }, { icon: "◐", label: "Anchor" },
+ ] },
+ "wheel.picker": { items: [
+ { icon: "✂", label: "Split" }, { icon: "⧉", label: "Duplicate" },
+ { icon: "★", label: "Rate" }, { icon: "⇄", label: "Reverse" },
+ { icon: "◐", label: "Anchor" }, { icon: "✕", label: "Remove" },
+ ] },
+};
+
+/** Extra variants worth seeing in the catalogue but not worth a second demo. */
+export const VARIANTS = {
+ "button.xl": [
+ { label: "Generate", tone: "primary" },
+ { label: "Neutral" },
+ { label: "Delete", tone: "danger" },
+ { label: "Ghost", tone: "ghost" },
+ { label: "Disabled", disabled: true },
+ { label: "Working", tone: "primary", busy: true },
+ ],
+ "button.md": [
+ { label: "Cancel" },
+ { label: "Primary", tone: "primary" },
+ { label: "Danger", tone: "danger" },
+ { label: "Ghost", tone: "ghost" },
+ ],
+};
diff --git a/app/ui/catalogue/elements.html b/app/ui/catalogue/elements.html
new file mode 100644
index 00000000..60645156
--- /dev/null
+++ b/app/ui/catalogue/elements.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Composer elements
+
+
+
+
+
+
+
+
+
+
diff --git a/app/ui/catalogue/elements.js b/app/ui/catalogue/elements.js
new file mode 100644
index 00000000..bb46d22f
--- /dev/null
+++ b/app/ui/catalogue/elements.js
@@ -0,0 +1,120 @@
+// Every registered element, in both themes.
+//
+// It iterates the registry rather than a list, so an element appears here the
+// moment it calls define() -- and one with no demo shows up as a gap instead of
+// quietly not existing.
+
+import { composer, entries } from "../composer/composer.js";
+import { DEMOS, VARIANTS } from "./demos.js";
+
+const el = (tag, cls, text) => {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text != null) n.textContent = text;
+ return n;
+};
+
+const resolve = (demo) => (typeof demo === "function" ? demo(composer) : demo);
+
+function renderInto(pane, name, factory) {
+ const variants = VARIANTS[name] || [resolve(DEMOS[name])];
+ const row = el("div", "cat-specimens");
+ for (const props of variants) {
+ const cell = el("div", "cat-specimen");
+ try {
+ // An overlay mounts itself into the portal; appending its node here would
+ // drag it out of that root and out of its stacking context. Probe once,
+ // close it, and offer a launcher instead -- which is also the only honest
+ // way to look at a modal.
+ const probe = factory(props);
+ if (probe.isOverlay) {
+ probe.destroy();
+ const open = el("button", null, `Open ${name}`);
+ open.className = "cat-launch";
+ open.addEventListener("click", () => {
+ const live = factory(resolve(DEMOS[name]));
+ if (live.result) live.result.then((r) => console.log(`${name} resolved:`, r));
+ });
+ cell.append(open);
+ } else {
+ cell.append(probe.node);
+ }
+ } catch (err) {
+ cell.append(el("div", "cat-broke", `threw: ${err.message}`));
+ }
+ row.append(cell);
+ }
+ pane.append(row);
+}
+
+function render(sideBySide) {
+ const page = document.querySelector("#catalogue");
+ page.textContent = "";
+
+ const all = [...entries()];
+ if (!all.length) {
+ const s = el("section", "cat-section");
+ s.append(el("h2", null, "No elements defined yet"));
+ s.append(el("p", null, "elements/index.js imports nothing, or every element file failed to load."));
+ page.append(s);
+ return;
+ }
+
+ const byGroup = new Map();
+ for (const e of all) {
+ if (!byGroup.has(e.group)) byGroup.set(e.group, []);
+ byGroup.get(e.group).push(e);
+ }
+
+ const missing = [];
+
+ for (const [group, items] of byGroup) {
+ const section = el("section", "cat-section");
+ section.append(el("h2", null, `composer.${group}`));
+ section.append(el("p", null, items.map((i) => i.variant).join(" · ")));
+
+ for (const { variant, factory } of items) {
+ const name = `${group}.${variant}`;
+ if (!DEMOS[name]) { missing.push(name); continue; }
+
+ section.append(el("h3", "cat-variant", `.${variant}`));
+ const panes = el("div", "cat-panes" + (sideBySide ? "" : " single"));
+ const themes = sideBySide
+ ? ["dark", "light"]
+ : [document.documentElement.getAttribute("data-theme") || "dark"];
+ for (const theme of themes) {
+ const pane = el("section", "cat-pane");
+ pane.setAttribute("data-theme", theme);
+ pane.append(el("h3", null, theme));
+ renderInto(pane, name, factory);
+ panes.append(pane);
+ }
+ section.append(panes);
+ }
+ page.append(section);
+ }
+
+ if (missing.length) {
+ const s = el("section", "cat-section");
+ s.append(el("h2", null, "Registered but not shown"));
+ s.append(el("p", null, `${missing.join(", ")} — add a demo to catalogue/demos.js.`));
+ page.append(s);
+ }
+}
+
+let sideBySide = true;
+
+document.querySelector("#toggle-panes").addEventListener("click", (e) => {
+ sideBySide = !sideBySide;
+ e.currentTarget.setAttribute("aria-pressed", String(sideBySide));
+ render(sideBySide);
+});
+
+for (const btn of document.querySelectorAll("[data-theme-set]")) {
+ btn.addEventListener("click", () => {
+ window.ComposerTheme.apply(btn.getAttribute("data-theme-set"));
+ if (!sideBySide) render(false);
+ });
+}
+
+render(sideBySide);
diff --git a/app/ui/catalogue/fixtures.js b/app/ui/catalogue/fixtures.js
new file mode 100644
index 00000000..95d5889d
--- /dev/null
+++ b/app/ui/catalogue/fixtures.js
@@ -0,0 +1,63 @@
+// Hide, don't warn -- shown rather than asserted.
+//
+// One good module and five broken ones, put through the SAME shell code the app
+// uses. The good one's panel appears. The broken ones do not appear at all: no
+// placeholder, no greyed row, no warning chip. What you see on this page is the
+// whole of what a user would see.
+//
+// These mirror core/tests/fixtures/fixture_modules, so the browser and the
+// Python suite are looking at the same five ways of being wrong.
+
+export const GOOD = {
+ id: "audio_clock",
+ title: "Audio clock",
+ mount: "demo.panel",
+ stage: "conditioning",
+ status: "proven",
+ requires: ["audio_stream"],
+ settings: {
+ enabled: { type: "bool", default: true, label: "Sync to audio clock",
+ hint: "Locks frame timing to the audio stream." },
+ strength: { type: "float", default: 0.65, min: 0, max: 1, step: 0.05,
+ label: "Lock strength", unit: "x", ui: "slider", when: { enabled: true } },
+ mode: { type: "enum", default: "beat", label: "Alignment", ui: "segmented",
+ options: [{ value: "beat", label: "Beat grid" },
+ { value: "onset", label: "Onset" },
+ { value: "flat", label: "Flat" }],
+ when: { enabled: true } },
+ },
+};
+
+// Each of these is refused somewhere different, which is the point: they are
+// caught at the earliest layer that can catch them.
+export const BROKEN = [
+ {
+ why: "raises on import",
+ caughtBy: "core: the scan imports it in a try",
+ spec: { id: "explodes", title: "Explodes", mount: "demo.panel", settings: {}, ui: "/nope/missing.js" },
+ },
+ {
+ why: "asks for a renderer its type does not have",
+ caughtBy: "core: schema validation, before the manifest",
+ spec: { id: "bad_ui", title: "Bad renderer", mount: "demo.panel",
+ settings: { on: { type: "bool", default: true, label: "On", ui: "hologram" } } },
+ },
+ {
+ why: "a label that is not text",
+ caughtBy: "core: schema validation; the kit would refuse it too",
+ spec: { id: "bad_label", title: "Bad label", mount: "demo.panel",
+ settings: { on: { type: "bool", default: true, label: { markup: "no " } } } },
+ },
+ {
+ why: "no default, so a headless run has no value",
+ caughtBy: "core: schema validation",
+ spec: { id: "no_default", title: "No default", mount: "demo.panel",
+ settings: { strength: { type: "float", min: 0, max: 1, label: "Strength" } } },
+ },
+ {
+ why: "names a mount point no region offers",
+ caughtBy: "shell: mounts.js has no host for it",
+ spec: { id: "lost", title: "Lost module", mount: "somewhere.else",
+ settings: { on: { type: "bool", default: true, label: "On" } } },
+ },
+];
diff --git a/app/ui/catalogue/hide.html b/app/ui/catalogue/hide.html
new file mode 100644
index 00000000..67702060
--- /dev/null
+++ b/app/ui/catalogue/hide.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Hide, don't warn
+
+
+
+
+
+
+
+
+
+
diff --git a/app/ui/catalogue/hide.js b/app/ui/catalogue/hide.js
new file mode 100644
index 00000000..a9f37fc8
--- /dev/null
+++ b/app/ui/catalogue/hide.js
@@ -0,0 +1,86 @@
+// Renders the fixture modules through the real shell.
+
+import { mountAll } from "../shell/panels.js";
+import { offer, _reset as resetMounts } from "../shell/mounts.js";
+import { _reset as resetValues, all as allValues } from "../shell/values.js";
+import { GOOD, BROKEN } from "./fixtures.js";
+
+const el = (tag, cls, text) => {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text != null) n.textContent = text;
+ return n;
+};
+
+async function render(theme) {
+ resetMounts();
+ resetValues();
+
+ const page = document.querySelector("#catalogue");
+ page.textContent = "";
+
+ const section = el("section", "cat-section");
+ section.append(el("h2", null, "What a user sees"));
+ section.append(el("p", null,
+ "Six modules go in: one valid, five broken in different ways. Everything below " +
+ "is the whole of what reaches the screen."));
+
+ const pane = el("section", "cat-pane");
+ pane.setAttribute("data-theme", theme);
+ pane.append(el("h3", null, theme));
+ const host = el("div", "cat-mount");
+ pane.append(host);
+ section.append(pane);
+ page.append(section);
+
+ offer("demo.panel", host);
+
+ // The broken ones are handed to the shell exactly as the good one is. In the
+ // real app most never get this far -- core refuses them at import or at
+ // validation -- so this is the worst case: everything reaching the browser.
+ const manifest = { modules: [GOOD, ...BROKEN.map((b) => b.spec)] };
+ const { mounted, hidden } = await mountAll(manifest);
+
+ if (!host.childElementCount) host.append(el("p", "cat-broke", "nothing mounted at all"));
+
+ // Everything from here down is developer commentary, NOT part of what a user
+ // would see -- the panel above is.
+ const ledger = el("section", "cat-section");
+ ledger.append(el("h2", null, "Why each one is absent"));
+ ledger.append(el("p", null, "This table exists for you, not for the user. None of it appears in the app."));
+
+ const table = el("div", "cat-ledger");
+ for (const { id } of mounted) {
+ table.append(el("div", "cat-ledger-row cat-ok"), el("code", "cat-name", id));
+ table.append(el("span", null, "mounted"));
+ }
+ for (const { id, why } of hidden) {
+ const source = BROKEN.find((b) => b.spec.id === id);
+ table.append(el("code", "cat-name", id));
+ table.append(el("span", "cat-why", source ? source.why : "—"));
+ table.append(el("span", "cat-caught", why));
+ }
+ ledger.append(table);
+ page.append(ledger);
+
+ const values = el("section", "cat-section");
+ values.append(el("h2", null, "Values a headless run would get"));
+ values.append(el("p", null,
+ "Seeded from the same declaration the panel renders, so a generation with no " +
+ "browser open starts from the same numbers."));
+ const pre = el("pre", "cx-code-block");
+ pre.textContent = JSON.stringify(allValues(), null, 2);
+ values.append(pre);
+ page.append(values);
+}
+
+let theme = "dark";
+for (const btn of document.querySelectorAll("[data-theme-set]")) {
+ btn.addEventListener("click", () => {
+ theme = btn.getAttribute("data-theme-set");
+ window.ComposerTheme.apply(theme === "auto" ? "auto" : theme);
+ render(theme === "auto" ? window.ComposerTheme.resolved() : theme);
+ });
+}
+
+render(theme);
diff --git a/app/ui/catalogue/index.html b/app/ui/catalogue/index.html
new file mode 100644
index 00000000..e6231ca1
--- /dev/null
+++ b/app/ui/catalogue/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+ Composer catalogue
+
+
+
+
+
+
+
+
+
+
diff --git a/app/ui/catalogue/log.html b/app/ui/catalogue/log.html
new file mode 100644
index 00000000..bf4fb155
--- /dev/null
+++ b/app/ui/catalogue/log.html
@@ -0,0 +1,6 @@
+
+
+FunPack log
+
+
+
diff --git a/app/ui/catalogue/log.js b/app/ui/catalogue/log.js
new file mode 100644
index 00000000..cf463ae4
--- /dev/null
+++ b/app/ui/catalogue/log.js
@@ -0,0 +1,70 @@
+// The log, where a person can read it.
+//
+// The terminal is where this ends up by default, buried among ComfyUI's own
+// output. The whole reason for a severity and a source is that a line can be
+// found later, so there has to be somewhere to look.
+
+import { composer } from "../composer/composer.js";
+
+const root = document.querySelector("#app");
+
+// Severity -> the chip tone that already exists in the kit. Info is deliberately
+// quiet: most lines are Info, and a wall of coloured chips says nothing.
+const TONE = { Info: "neutral", Alert: "info", Warning: "warn", Critical: "danger" };
+
+let filter = null;
+
+async function load() {
+ const url = "/funpack/api/log/funpack" + (filter ? `?level=${encodeURIComponent(filter)}` : "");
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`);
+ return response.json();
+}
+
+function render(data) {
+ const counts = {};
+ for (const level of data.levels) counts[level] = 0;
+ for (const record of data.records) counts[record.level] = (counts[record.level] || 0) + 1;
+
+ const filters = composer.segmented.md({
+ label: "Show",
+ value: filter || "all",
+ options: [{ value: "all", label: "Everything" },
+ ...data.levels.map((l) => ({ value: l, label: `${l} (${counts[l] || 0})` }))],
+ onChange: (value) => { filter = value === "all" ? null : value; start(); },
+ });
+
+ // Newest last, like a terminal: the eye expects the end to be the present.
+ const items = data.records.map((record, i) => ({
+ id: `r${i}`,
+ label: record.message,
+ hint: `${record.level} · ${record.source || "FunPack"}`,
+ }));
+
+ root.replaceChildren(composer.panel.default({
+ title: "Log",
+ body: composer.group.default({
+ rows: [
+ filters,
+ items.length
+ ? composer.list.rows({ items, reorder: false })
+ : composer.emptyState.default({
+ icon: "○", title: "Nothing logged yet",
+ hint: filter ? `No ${filter} messages.` : "Run a generation and come back.",
+ }),
+ ],
+ }),
+ }).node);
+}
+
+async function start() {
+ try {
+ render(await load());
+ } catch (err) {
+ root.replaceChildren(composer.emptyState.default({
+ icon: "▲", title: "Could not read the log", hint: err.message,
+ }).node);
+ }
+}
+
+start();
diff --git a/app/ui/catalogue/modules.html b/app/ui/catalogue/modules.html
new file mode 100644
index 00000000..4b255767
--- /dev/null
+++ b/app/ui/catalogue/modules.html
@@ -0,0 +1,6 @@
+
+
+FunPack modules
+
+
+
diff --git a/app/ui/catalogue/modules.js b/app/ui/catalogue/modules.js
new file mode 100644
index 00000000..dbaef13a
--- /dev/null
+++ b/app/ui/catalogue/modules.js
@@ -0,0 +1,85 @@
+// What is installed, what announced itself, and what did not make it.
+//
+// The plan called this a required tool rather than a nicety, and it was right:
+// a module that fails to import is ABSENT, which is the correct behaviour and
+// also indistinguishable from one nobody installed. Somewhere has to say the
+// difference out loud, and it must not be the main app, where an absent feature
+// is supposed to be simply absent.
+
+import { composer } from "../composer/composer.js";
+import { fetchManifest } from "../shell/manifest.js";
+import { build } from "../shell/layout.js";
+import { offered, _reset } from "../shell/mounts.js";
+
+const root = document.querySelector("#app");
+
+function rows(items) {
+ return composer.list.rows({ items, reorder: false });
+}
+
+async function start() {
+ let manifest;
+ try {
+ manifest = await fetchManifest();
+ } catch (err) {
+ root.replaceChildren(composer.emptyState.default({
+ icon: "▲", title: "Could not reach FunPack", hint: err.message,
+ }).node);
+ return;
+ }
+
+ // Build the real shell off-screen purely to learn which mount points exist.
+ // Asking the layout is the only honest answer: nothing else knows.
+ _reset();
+ build(document.createElement("div"));
+ const available = new Set(offered());
+
+ const modules = manifest.modules || [];
+ const failed = manifest.failed || [];
+ const unclaimed = modules.filter(
+ (m) => Object.keys(m.settings || {}).length && m.mount && !available.has(m.mount));
+
+ const sections = [];
+
+ sections.push(composal("Loaded", `${modules.length} module(s) announced themselves.`,
+ modules.map((m) => ({
+ id: m.id,
+ label: m.id,
+ hint: `${m.stage} · ${m.status}` +
+ (m.mount ? ` · ${m.mount}` : " · no panel") +
+ (Object.keys(m.settings || {}).length ? ` · ${Object.keys(m.settings).length} setting(s)` : ""),
+ }))));
+
+ if (unclaimed.length) {
+ sections.push(composal(
+ "Asking for a mount point nothing offers",
+ "These loaded correctly and their panels will not appear anywhere. " +
+ `Offered: ${[...available].join(", ")}.`,
+ unclaimed.map((m) => ({ id: m.id, label: m.id, hint: `wants "${m.mount}"` }))));
+ }
+
+ if (failed.length) {
+ sections.push(composal("Did not load", "Absent from the app, and from the graph.",
+ failed.map((f, i) => ({ id: `f${i}`, label: f.where, hint: f.why }))));
+ }
+
+ if (manifest.incompatible?.length) {
+ sections.push(composal("Not for this model", "Filtered out by what the model is.",
+ manifest.incompatible.map((m) => ({
+ id: m.id, label: m.id, hint: `needs ${m.requires.join(", ")}` }))));
+ }
+
+ root.replaceChildren(...sections.map((s) => s.node));
+}
+
+function composal(title, hint, items) {
+ return composer.panel.default({
+ title,
+ body: composer.group.default({
+ hint,
+ rows: items.length ? [rows(items)] : [composer.hint.default({ text: "None." })],
+ }),
+ });
+}
+
+start();
diff --git a/app/ui/composer/composer.css b/app/ui/composer/composer.css
new file mode 100644
index 00000000..ff344820
--- /dev/null
+++ b/app/ui/composer/composer.css
@@ -0,0 +1,18 @@
+/* The kit's single stylesheet. Order matters: reset, then tokens, then the
+ * shared bases, then one file per element (added as each element lands). */
+@import "./tokens/fonts.css";
+@import "./tokens/reset.css";
+@import "./tokens/tokens.css";
+@import "./tokens/base.css";
+
+@import "./elements/text.css";
+@import "./elements/button.css";
+@import "./elements/input.css";
+@import "./elements/choice.css";
+@import "./elements/slider.css";
+@import "./elements/layout.css";
+@import "./elements/status.css";
+@import "./elements/overlay.css";
+@import "./elements/gallery.css";
+@import "./elements/track.css";
+@import "./elements/wheel.css";
diff --git a/app/ui/composer/composer.js b/app/ui/composer/composer.js
new file mode 100644
index 00000000..29940e7a
--- /dev/null
+++ b/app/ui/composer/composer.js
@@ -0,0 +1,16 @@
+// The kit's public surface.
+//
+// A module receives exactly this and nothing else: element factories, reached by
+// name. There is no handle here to el(), to document, or to any internal -- which
+// is what makes "a module never styles anything" a fact about the code rather
+// than a rule people are asked to remember.
+
+import "./elements/index.js"; // elements register themselves on import
+import { composer } from "./internals/register.js";
+
+export { composer };
+export default composer;
+
+// Re-exported for the shell and the catalogue, which legitimately need to know
+// what registered. Modules never import this file directly.
+export { entries, has, UnknownElement } from "./internals/register.js";
diff --git a/app/ui/composer/elements/button.css b/app/ui/composer/elements/button.css
new file mode 100644
index 00000000..79a628f7
--- /dev/null
+++ b/app/ui/composer/elements/button.css
@@ -0,0 +1,79 @@
+.cx-btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
+ border: var(--bw) solid var(--line); border-radius: var(--ctl-r);
+ background: var(--grad-raise); color: var(--text);
+ font-family: var(--font-ui); font-weight: var(--fw-medium);
+ white-space: nowrap; cursor: pointer;
+ transition: background var(--dur-1) var(--ease-standard),
+ border-color var(--dur-1) var(--ease-standard),
+ color var(--dur-1) var(--ease-standard);
+}
+.cx-btn-xl { height: var(--ctl-h-xl); padding: 0 var(--ctl-pad-x-xl); font-size: var(--fs-4); }
+.cx-btn-lg { height: var(--ctl-h-lg); padding: 0 var(--ctl-pad-x-lg); font-size: var(--fs-5); }
+.cx-btn-md { height: var(--ctl-h-md); padding: 0 var(--ctl-pad-x-md); font-size: var(--fs-5); }
+.cx-btn-sm { height: var(--ctl-h-sm); padding: 0 var(--ctl-pad-x-sm); font-size: var(--fs-6); }
+
+.cx-btn:hover:not(:disabled) { border-color: var(--line-hover); background: var(--hover); }
+.cx-btn:active:not(:disabled) { border-color: var(--line-active); }
+.cx-btn:disabled { opacity: var(--disabled-opacity); cursor: default; }
+
+.cx-btn-primary {
+ background: var(--grad-accent); border-color: var(--accent);
+ color: var(--on-accent); font-weight: var(--fw-bold);
+ box-shadow: var(--glow-accent);
+}
+/* The SURFACE brightens, not the button.
+ `filter: brightness()` applies to the whole element, text included -- so the
+ label was lightened along with the fill under it and the one thing that had
+ to stay readable lost contrast in both themes. The label's colour is fixed
+ and only the fill moves, one step up the same accent ramp. */
+.cx-btn-primary:hover:not(:disabled) {
+ background: var(--grad-accent-hover); border-color: var(--accent-hover-edge);
+}
+.cx-btn-primary:active:not(:disabled) { background: var(--accent-lo); }
+
+.cx-btn-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--line)); }
+.cx-btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: var(--danger); }
+
+.cx-btn-ghost { background: transparent; border-color: transparent; color: var(--muted); }
+.cx-btn-ghost:hover:not(:disabled) { background: var(--hover); color: var(--text); }
+
+.cx-btn-icon { line-height: 1; }
+.cx-btn-label:empty { display: none; }
+
+.cx-icon-btn {
+ display: inline-flex; align-items: center; justify-content: center;
+ border: var(--bw) solid transparent; border-radius: var(--r-2);
+ background: transparent; color: var(--muted); cursor: pointer; padding: 0;
+ transition: background var(--dur-1) var(--ease-standard), color var(--dur-1) var(--ease-standard);
+}
+.cx-icon-btn-md { width: var(--ctl-h-md); height: var(--ctl-h-md); font-size: var(--fs-4); }
+.cx-icon-btn-sm { width: var(--ctl-h-icon); height: var(--ctl-h-icon); font-size: var(--fs-6); }
+.cx-icon-btn-micro { width: var(--ctl-h-icon-sm); height: var(--ctl-h-icon-sm); font-size: var(--fs-7); }
+.cx-icon-btn:hover:not(:disabled) { background: var(--hover); color: var(--text); }
+.cx-icon-btn[aria-pressed="true"] { color: var(--accent); background: var(--accent-soft); }
+.cx-icon-btn:disabled { opacity: var(--disabled-opacity); cursor: default; }
+
+.cx-btn-group { display: inline-flex; }
+.cx-btn-group .cx-btn { border-radius: 0; margin-left: calc(var(--bw) * -1); }
+.cx-btn-group .cx-btn:first-child { border-radius: var(--ctl-r) 0 0 var(--ctl-r); margin-left: 0; }
+.cx-btn-group .cx-btn:last-child { border-radius: 0 var(--ctl-r) var(--ctl-r) 0; }
+.cx-btn-group .cx-btn:only-child { border-radius: var(--ctl-r); }
+/* A pressed toggle, grouped or not. Scoping this to .cx-btn-group meant a
+ standalone toggle -- the region buttons in the timeline bar -- carried
+ aria-pressed and looked identical either way: a control that reports its state
+ to a screen reader and not to the eye. */
+.cx-btn.cx-on {
+ background: var(--accent-soft); border-color: var(--accent); color: var(--accent-hi);
+}
+.cx-btn-group .cx-btn.cx-on {
+ position: relative; z-index: 1; /* its border wins over its neighbours' */
+}
+
+.cx-btn-spin {
+ width: 11px; height: 11px; border-radius: var(--r-pill);
+ border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
+ border-top-color: currentColor;
+ animation: cx-spin 800ms linear infinite;
+}
+@keyframes cx-spin { to { transform: rotate(360deg); } }
diff --git a/app/ui/composer/elements/button.js b/app/ui/composer/elements/button.js
new file mode 100644
index 00000000..ee0914e9
--- /dev/null
+++ b/app/ui/composer/elements/button.js
@@ -0,0 +1,142 @@
+// Buttons.
+//
+// Every one is focusable with a visible ring -- v4 styled a focus ring for
+// inputs and gave buttons none, so keyboard users lost their place the moment
+// they tabbed onto one.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { setText } from "../internals/text.js";
+import { roving } from "../internals/focus.js";
+
+const TONES = new Set(["neutral", "primary", "danger", "ghost"]);
+
+function base(size, { label, onClick, tone = "neutral", icon, disabled = false, busy = false, type = "button", title, pressed } = {}) {
+ if (!TONES.has(tone)) throw new RangeError(`Unknown button tone "${tone}". Known: ${[...TONES].join(", ")}.`);
+
+ const node = el("button", {
+ cls: ["cx-btn", `cx-btn-${size}`, `cx-btn-${tone}`, "cx-focusable"],
+ attrs: { type, disabled: disabled || busy, title, "aria-busy": busy || undefined,
+ // A toggle says so, or a screen reader reads "Assets" the same
+ // whether the panel is open or shut. Absent unless the caller
+ // passes one: most buttons are not toggles and must not claim to be.
+ "aria-pressed": pressed === undefined ? undefined : String(Boolean(pressed)) },
+ });
+ if (pressed) node.classList.add("cx-on");
+
+ const spinner = el("span", { cls: "cx-btn-spin", attrs: { "aria-hidden": "true" } });
+ const glyph = icon ? el("span", { cls: "cx-btn-icon", text: icon, attrs: { "aria-hidden": "true" } }) : null;
+ const text = el("span", { cls: "cx-btn-label", text: label });
+
+ if (glyph) node.appendChild(glyph);
+ node.appendChild(text);
+ if (busy) node.appendChild(spinner);
+
+ if (onClick) node.addEventListener("click", onClick);
+
+ // Busy and disabled are two independent reasons a button is not clickable, so
+ // both are tracked. Deriving the attribute from the constructor's `disabled`
+ // meant finishing a busy spell re-enabled a button that had been disabled
+ // since -- the later call silently lost to the earlier value.
+ let isDisabled = Boolean(disabled);
+ let isBusy = Boolean(busy);
+ const sync = () => node.toggleAttribute("disabled", isDisabled || isBusy);
+
+ return {
+ node,
+ setLabel: (value) => setText(text, value),
+ setBusy(value) {
+ isBusy = Boolean(value);
+ sync();
+ if (isBusy) { node.setAttribute("aria-busy", "true"); node.appendChild(spinner); }
+ else { node.removeAttribute("aria-busy"); spinner.remove(); }
+ },
+ setDisabled(value) { isDisabled = Boolean(value); sync(); },
+ setPressed(value) {
+ node.setAttribute("aria-pressed", String(Boolean(value)));
+ node.classList.toggle("cx-on", Boolean(value));
+ },
+ get disabled() { return isDisabled; },
+ get busy() { return isBusy; },
+ destroy: () => node.remove(),
+ };
+}
+
+for (const size of ["xl", "lg", "md", "sm"]) define("button", size, (props) => base(size, props));
+
+// Icon-only controls take `label` as their accessible name, not as decoration.
+// Making it required is the cheapest way to stop a toolbar of unlabelled glyphs.
+for (const size of ["md", "sm", "micro"]) {
+ define("iconButton", size, ({ icon, label, onClick, tone = "ghost", disabled, pressed } = {}) => {
+ if (!label) throw new TypeError("iconButton needs a label: it is the accessible name and the tooltip.");
+ const node = el("button", {
+ cls: ["cx-icon-btn", `cx-icon-btn-${size}`, `cx-btn-${tone}`, "cx-focusable"],
+ attrs: {
+ type: "button", disabled, title: label, "aria-label": label,
+ "aria-pressed": pressed === undefined ? undefined : String(Boolean(pressed)),
+ },
+ children: el("span", { cls: "cx-btn-icon", text: icon, attrs: { "aria-hidden": "true" } }),
+ });
+ if (onClick) node.addEventListener("click", onClick);
+ return {
+ node,
+ setPressed(value) { node.setAttribute("aria-pressed", String(Boolean(value))); },
+ destroy: () => node.remove(),
+ };
+ });
+}
+
+/** A row of buttons where one (or several) stay active: B / I, align, flips. */
+define("buttonGroup", "md", ({ items = [], value, onChange, multi = false, label } = {}) => {
+ const selected = new Set(multi ? [].concat(value || []) : value == null ? [] : [value]);
+ const node = el("div", {
+ cls: "cx-btn-group",
+ attrs: { role: multi ? "group" : "radiogroup", "aria-label": label },
+ });
+
+ const buttons = items.map((item) => {
+ const b = el("button", {
+ cls: ["cx-btn", "cx-btn-md", "cx-btn-neutral", "cx-focusable"],
+ text: item.label,
+ attrs: {
+ type: "button", title: item.title,
+ role: multi ? undefined : "radio",
+ "aria-checked": multi ? undefined : String(selected.has(item.value)),
+ "aria-pressed": multi ? String(selected.has(item.value)) : undefined,
+ },
+ });
+ b.classList.toggle("cx-on", selected.has(item.value));
+ b.addEventListener("click", () => {
+ if (multi) selected.has(item.value) ? selected.delete(item.value) : selected.add(item.value);
+ else { selected.clear(); selected.add(item.value); }
+ sync();
+ if (onChange) onChange(multi ? [...selected] : item.value);
+ });
+ node.appendChild(b);
+ return { item, b };
+ });
+
+ function sync() {
+ for (const { item, b } of buttons) {
+ const on = selected.has(item.value);
+ b.classList.toggle("cx-on", on);
+ if (multi) b.setAttribute("aria-pressed", String(on));
+ else b.setAttribute("aria-checked", String(on));
+ }
+ }
+
+ // One tab stop for the group; arrows move inside it. Otherwise Tab walks
+ // every option, which is tedious in a row of eight.
+ const stopRoving = roving(node);
+
+ return {
+ node,
+ get value() { return multi ? [...selected] : [...selected][0]; },
+ setValue(next) {
+ selected.clear();
+ for (const v of multi ? [].concat(next || []) : [next]) if (v != null) selected.add(v);
+ sync();
+ },
+ destroy() { stopRoving(); node.remove(); },
+ };
+});
diff --git a/app/ui/composer/elements/choice.css b/app/ui/composer/elements/choice.css
new file mode 100644
index 00000000..a237b096
--- /dev/null
+++ b/app/ui/composer/elements/choice.css
@@ -0,0 +1,133 @@
+/* The native input stays in the DOM -- it carries the state, the keyboard
+ * behaviour and the accessibility -- but it is invisible; .cx-check-box is what
+ * you see. Hiding it with opacity rather than display:none keeps it focusable. */
+.cx-check-input, .cx-radio-input, .cx-toggle-input {
+ position: absolute; opacity: 0; width: 0; height: 0; margin: 0;
+}
+
+.cx-check { display: inline-flex; }
+.cx-check-box {
+ width: 16px; height: 16px; flex: none;
+ border: var(--bw) solid var(--line); border-radius: var(--r-2);
+ background: var(--elev-2);
+ display: inline-flex; align-items: center; justify-content: center;
+ transition: background var(--dur-1) var(--ease-standard), border-color var(--dur-1) var(--ease-standard);
+}
+.cx-check-box::after {
+ content: ""; width: 9px; height: 5px;
+ border-left: 2px solid var(--on-accent); border-bottom: 2px solid var(--on-accent);
+ transform: rotate(-45deg) translateY(-1px) scale(.6);
+ opacity: 0; transition: opacity var(--dur-1) var(--ease-standard), transform var(--dur-1) var(--ease-standard);
+}
+.cx-check-input:checked + .cx-check-face .cx-check-box { background: var(--accent); border-color: var(--accent); }
+.cx-check-input:checked + .cx-check-face .cx-check-box::after { opacity: 1; transform: rotate(-45deg) translateY(-1px) scale(1); }
+.cx-check-input:indeterminate + .cx-check-face .cx-check-box { background: var(--accent); border-color: var(--accent); }
+.cx-check-input:indeterminate + .cx-check-face .cx-check-box::after {
+ opacity: 1; width: 8px; height: 0; border-bottom: 2px solid var(--on-accent);
+ border-left: 0; transform: none;
+}
+.cx-check-input:focus-visible + .cx-check-face .cx-check-box { border-color: var(--accent); box-shadow: var(--focus-ring); }
+.cx-check-input:disabled + .cx-check-face .cx-check-box { opacity: var(--disabled-opacity); }
+
+/* The input sits alongside its label, not inside it -- see choice.js. The row
+ * is the positioning context; the .cx-check-face label is what you click. */
+.cx-check-row, .cx-radio, .cx-toggle-row { position: relative; }
+/* Room around the words. A checkbox in this app is a switch with a sentence
+ under it, not a tick in a list: at 4px the label and its explanation sat hard
+ against the top and bottom of the card they are in, and a hint that wrapped
+ to two lines had nowhere to wrap into. */
+.cx-check-face {
+ display: flex; align-items: flex-start; gap: var(--sp-4);
+ cursor: pointer; padding: var(--sp-4) 0;
+}
+.cx-toggle-face { align-items: center; justify-content: space-between; gap: var(--sp-6); }
+/* min-inline-size, or a long word in a hint pushes the row wider than the panel
+ it is in instead of wrapping. */
+.cx-check-text { display: flex; flex-direction: column; gap: 2px; min-inline-size: 0; }
+.cx-check-label { font-size: var(--fs-4); line-height: var(--lh-4); color: var(--text); }
+.cx-checklist {
+ display: flex; flex-direction: column;
+ max-height: 200px; overflow-y: auto;
+ border: var(--bw) solid var(--line); border-radius: var(--r-3);
+ padding: var(--sp-3) var(--sp-4);
+}
+
+.cx-radio-group { display: flex; gap: var(--sp-4); }
+.cx-radio-vertical { flex-direction: column; gap: var(--sp-2); }
+.cx-radio-dot {
+ width: 16px; height: 16px; flex: none; border-radius: var(--r-pill);
+ border: var(--bw) solid var(--line); background: var(--elev-2);
+ display: inline-flex; align-items: center; justify-content: center;
+}
+.cx-radio-dot::after {
+ content: ""; width: 8px; height: 8px; border-radius: var(--r-pill);
+ background: var(--accent); transform: scale(0);
+ transition: transform var(--dur-1) var(--ease-standard);
+}
+.cx-radio-input:checked + .cx-check-face .cx-radio-dot { border-color: var(--accent); }
+.cx-radio-input:checked + .cx-check-face .cx-radio-dot::after { transform: scale(1); }
+.cx-radio-input:focus-visible + .cx-check-face .cx-radio-dot { border-color: var(--accent); box-shadow: var(--focus-ring); }
+
+.cx-select-md { height: var(--ctl-h-lg); padding: 0 var(--ctl-pad-x-md); font-size: var(--fs-4); }
+.cx-select-sm { height: var(--ctl-h-md); padding: 0 var(--ctl-pad-x-sm); font-size: var(--fs-5); }
+
+.cx-segmented {
+ display: inline-flex; border: var(--bw) solid var(--line);
+ border-radius: var(--ctl-r); overflow: hidden; background: var(--elev-2);
+}
+.cx-segment {
+ border: 0; background: transparent; color: var(--muted);
+ cursor: pointer; font-family: var(--font-ui);
+ border-left: var(--bw) solid var(--line);
+ transition: background var(--dur-1) var(--ease-standard), color var(--dur-1) var(--ease-standard);
+}
+.cx-segment:first-child { border-left: 0; }
+.cx-segmented-md .cx-segment { height: var(--ctl-h-lg); padding: 0 var(--ctl-pad-x-lg); font-size: var(--fs-5); }
+.cx-segmented-sm .cx-segment { height: var(--ctl-h-md); padding: 0 var(--ctl-pad-x-md); font-size: var(--fs-6); }
+.cx-segment:hover { background: var(--hover); color: var(--text); }
+.cx-segment.cx-on { background: var(--accent-soft); color: var(--accent-hi); }
+
+
+.cx-toggle-track {
+ width: 34px; height: 20px; flex: none; border-radius: var(--r-pill);
+ background: var(--line); position: relative;
+ transition: background var(--dur-1) var(--ease-standard);
+}
+.cx-toggle-knob {
+ position: absolute; top: 2px; left: 2px; width: 16px; height: 16px;
+ border-radius: var(--r-pill); background: var(--elev-1);
+ transition: transform var(--dur-1) var(--ease-standard);
+}
+.cx-toggle-input:checked + .cx-check-face .cx-toggle-track { background: var(--accent); }
+.cx-toggle-input:checked + .cx-check-face .cx-toggle-track .cx-toggle-knob { transform: translateX(14px); }
+.cx-toggle-input:focus-visible + .cx-check-face .cx-toggle-track { box-shadow: var(--focus-ring); }
+
+/* Colour: the swatch sized to the control height so it lines up with every
+ other field in a settings column, and the hex field kept narrow because six
+ characters in a full-width input reads as an empty box. */
+.cx-swatch-row {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-4);
+}
+
+.cx-swatch {
+ inline-size: var(--ctl-h-lg);
+ block-size: var(--ctl-h-lg);
+ padding: 0;
+ border: var(--bw) solid var(--line);
+ border-radius: var(--ctl-r);
+ background: var(--elev-1);
+ cursor: pointer;
+}
+
+.cx-swatch::-webkit-color-swatch-wrapper { padding: 2px; }
+.cx-swatch::-webkit-color-swatch { border: 0; border-radius: var(--r-2); }
+.cx-swatch::-moz-color-swatch { border: 0; border-radius: var(--r-2); }
+
+.cx-swatch:disabled { opacity: var(--disabled-opacity); cursor: default; }
+
+.cx-swatch-hex {
+ inline-size: 10ch;
+ font-family: var(--font-mono);
+}
diff --git a/app/ui/composer/elements/choice.js b/app/ui/composer/elements/choice.js
new file mode 100644
index 00000000..ffa4eb79
--- /dev/null
+++ b/app/ui/composer/elements/choice.js
@@ -0,0 +1,271 @@
+// Choosing: on/off, one-of, several-of.
+//
+// The checkbox is drawn rather than native. v4 used the OS control with only
+// accent-color, which meant its size, focus ring and disabled look were the
+// browser's opinion, not the kit's -- and it needed `width: auto` undone in four
+// places because a global `input { width: 100% }` had stretched it.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { uid } from "../internals/ids.js";
+import { roving } from "../internals/focus.js";
+
+// `ariaLabel` is passed only by the bare checkbox, which has no visible text of
+// its own. In a checkbox ROW the adjacent text is the label (via `for`), and
+// adding aria-label there would override what the user can actually see.
+function checkboxNode({ checked = false, indeterminate = false, disabled, id, ariaLabel, describedBy }) {
+ const input = el("input", {
+ cls: "cx-check-input",
+ attrs: { type: "checkbox", id, disabled, checked: checked || undefined,
+ "aria-describedby": describedBy, "aria-label": ariaLabel },
+ });
+ input.checked = Boolean(checked);
+ input.indeterminate = Boolean(indeterminate);
+ const box = el("span", { cls: "cx-check-box", attrs: { "aria-hidden": "true" } });
+ return { input, box };
+}
+
+define("checkbox", "default", ({ checked, indeterminate, disabled, onChange, label } = {}) => {
+ if (!label) throw new TypeError("checkbox needs a label: with no visible text it is its only accessible name. Use checkboxRow for a labelled row.");
+ const id = uid("check");
+ const { input, box } = checkboxNode({ checked, indeterminate, disabled, id, ariaLabel: label });
+ if (onChange) input.addEventListener("change", () => onChange(input.checked));
+ const node = el("span", { cls: ["cx-check", "cx-focusable"], children: [input, box] });
+ return {
+ node,
+ get value() { return input.checked; },
+ setValue(v) { input.checked = Boolean(v); input.indeterminate = false; },
+ destroy: () => node.remove(),
+ };
+});
+
+define("checkboxRow", "default", ({ label, hint, checked, disabled, onChange } = {}) => {
+ const id = uid("check");
+ const hintId = hint ? uid("hint") : undefined;
+ const { input, box } = checkboxNode({ checked, disabled, id, describedBy: hintId });
+ if (onChange) input.addEventListener("change", () => onChange(input.checked));
+
+ const text = el("span", { cls: "cx-check-text", children: [
+ el("span", { cls: "cx-check-label", text: label }),
+ hint ? el("span", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null,
+ ].filter(Boolean) });
+
+ // The input is a SIBLING of its label, not a child of it.
+ //
+ // Both forms are valid, and browsers guard a nested control against being
+ // activated twice by its own label. Keeping them separate means never relying
+ // on that guard -- the CSS reaches the box through the label instead of past
+ // it, which costs one selector and removes a whole class of subtlety.
+ const node = el("div", { cls: "cx-check-row", children: [
+ input,
+ el("label", { cls: "cx-check-face", attrs: { for: id }, children: [box, text] }),
+ ] });
+ return {
+ node,
+ get value() { return input.checked; },
+ setValue(v) { input.checked = Boolean(v); },
+ destroy: () => node.remove(),
+ };
+});
+
+define("checklist", "default", ({ items = [], values = [], onChange, label } = {}) => {
+ const chosen = new Set(values);
+ const node = el("div", { cls: "cx-checklist", attrs: { role: "group", "aria-label": label } });
+ for (const item of items) {
+ const id = uid("check");
+ const { input, box } = checkboxNode({ checked: chosen.has(item.value), id, disabled: item.disabled });
+ input.addEventListener("change", () => {
+ input.checked ? chosen.add(item.value) : chosen.delete(item.value);
+ if (onChange) onChange([...chosen]);
+ });
+ node.append(el("div", { cls: "cx-check-row", children: [
+ input,
+ el("label", { cls: "cx-check-face", attrs: { for: id }, children: [
+ box, el("span", { cls: "cx-check-label", text: item.label }),
+ ] }),
+ ] }));
+ }
+ return {
+ node,
+ get value() { return [...chosen]; },
+ setValue(next) {
+ chosen.clear();
+ for (const v of next || []) chosen.add(v);
+ node.querySelectorAll(".cx-check-input").forEach((input, i) => {
+ input.checked = chosen.has(items[i].value);
+ });
+ },
+ destroy: () => node.remove(),
+ };
+});
+
+define("radioGroup", "default", ({ options = [], value, onChange, orientation = "vertical", label } = {}) => {
+ const name = uid("radio");
+ let current = value;
+ const node = el("div", {
+ cls: ["cx-radio-group", `cx-radio-${orientation}`],
+ attrs: { role: "radiogroup", "aria-label": label },
+ });
+
+ const inputs = options.map((option) => {
+ const id = uid("radio-opt");
+ const input = el("input", { cls: "cx-radio-input",
+ attrs: { type: "radio", name, id, disabled: option.disabled } });
+ input.checked = option.value === current;
+ input.addEventListener("change", () => {
+ if (!input.checked) return;
+ current = option.value;
+ if (onChange) onChange(current);
+ });
+ node.append(el("div", { cls: "cx-radio", children: [
+ input,
+ el("label", { cls: "cx-check-face", attrs: { for: id }, children: [
+ el("span", { cls: "cx-radio-dot", attrs: { "aria-hidden": "true" } }),
+ el("span", { cls: "cx-check-text", children: [
+ el("span", { cls: "cx-check-label", text: option.label }),
+ option.hint ? el("span", { cls: "cx-hint", text: option.hint }) : null,
+ ].filter(Boolean) }),
+ ] }),
+ ] }));
+ return { option, input };
+ });
+
+ return {
+ node,
+ get value() { return current; },
+ setValue(v) { current = v; for (const { option, input } of inputs) input.checked = option.value === v; },
+ destroy: () => node.remove(),
+ };
+});
+
+for (const size of ["md", "sm"]) {
+ define("select", size, ({ options = [], value, onChange, disabled, label, id } = {}) => {
+ const node = el("select", {
+ cls: ["cx-select", `cx-select-${size}`, "cx-focusable"],
+ attrs: { disabled, "aria-label": label, id: id || uid("select") },
+ });
+ for (const option of options) {
+ const o = el("option", { text: option.label, attrs: { value: option.value, disabled: option.disabled } });
+ node.append(o);
+ }
+ if (value !== undefined) node.value = value;
+ if (onChange) node.addEventListener("change", () => onChange(node.value));
+ return {
+ node,
+ get value() { return node.value; },
+ setValue(v) { node.value = v; },
+ destroy: () => node.remove(),
+ };
+ });
+}
+
+for (const size of ["md", "sm"]) {
+ define("segmented", size, ({ options = [], value, onChange, label } = {}) => {
+ let current = value ?? options[0]?.value;
+ const node = el("div", {
+ cls: ["cx-segmented", `cx-segmented-${size}`],
+ attrs: { role: "radiogroup", "aria-label": label },
+ });
+
+ const buttons = options.map((option) => {
+ const b = el("button", {
+ cls: ["cx-segment", "cx-focusable"],
+ text: option.label,
+ attrs: { type: "button", role: "radio", "aria-checked": String(option.value === current) },
+ });
+ b.classList.toggle("cx-on", option.value === current);
+ b.addEventListener("click", () => { current = option.value; sync(); if (onChange) onChange(current); });
+ node.append(b);
+ return { option, b };
+ });
+
+ function sync() {
+ for (const { option, b } of buttons) {
+ const on = option.value === current;
+ b.classList.toggle("cx-on", on);
+ b.setAttribute("aria-checked", String(on));
+ }
+ }
+
+ const stopRoving = roving(node);
+ return {
+ node,
+ get value() { return current; },
+ setValue(v) { current = v; sync(); },
+ destroy() { stopRoving(); node.remove(); },
+ };
+ });
+}
+
+define("toggle", "default", ({ label, hint, checked, disabled, onChange } = {}) => {
+ const id = uid("toggle");
+ const input = el("input", { cls: "cx-toggle-input",
+ attrs: { type: "checkbox", id, disabled, role: "switch" } });
+ input.checked = Boolean(checked);
+ if (onChange) input.addEventListener("change", () => onChange(input.checked));
+
+ const node = el("div", { cls: "cx-toggle-row", children: [
+ input,
+ el("label", { cls: "cx-check-face cx-toggle-face", attrs: { for: id }, children: [
+ el("span", { cls: "cx-check-text", children: [
+ el("span", { cls: "cx-check-label", text: label }),
+ hint ? el("span", { cls: "cx-hint", text: hint }) : null,
+ ].filter(Boolean) }),
+ el("span", { cls: "cx-toggle-track", attrs: { "aria-hidden": "true" },
+ children: el("span", { cls: "cx-toggle-knob" }) }),
+ ] }),
+ ] });
+
+ return {
+ node,
+ get value() { return input.checked; },
+ setValue(v) { input.checked = Boolean(v); },
+ destroy: () => node.remove(),
+ };
+});
+
+// A colour, picked or typed.
+//
+// Two controls over one value because neither is enough alone: the swatch is how
+// you choose one you have not named, the field is how you paste the one you were
+// given. They stay in step, and the field only commits when what it holds is a
+// real colour -- a half-typed "#ff8" must not repaint the app mid-keystroke.
+define("color", "swatch", ({ label, value = "#000000", onChange, disabled = false } = {}) => {
+ const full = (v) => (v.length === 4
+ ? "#" + v.slice(1).split("").map((c) => c + c).join("")
+ : v.slice(0, 7));
+
+ const swatch = el("input", { cls: ["cx-swatch", "cx-focusable"],
+ attrs: { type: "color", value: full(value), disabled: disabled || null,
+ "aria-label": label ? `${label} colour` : "colour" } });
+
+ const field = el("input", { cls: ["cx-input", "cx-input-md", "cx-swatch-hex", "cx-focusable"],
+ attrs: { type: "text", value, spellcheck: "false", disabled: disabled || null,
+ "aria-label": label ? `${label} hex value` : "hex value" } });
+
+ let current = value;
+
+ const valid = (v) => /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v);
+
+ function commit(next, from) {
+ if (!valid(next) || next === current) return;
+ current = next;
+ if (from !== "swatch") swatch.value = full(next);
+ if (from !== "field") field.value = next;
+ if (onChange) onChange(next);
+ }
+
+ swatch.addEventListener("input", () => commit(swatch.value, "swatch"));
+ field.addEventListener("change", () => {
+ if (valid(field.value)) commit(field.value, "field");
+ else field.value = current; // reject rather than half-apply
+ });
+
+ const node = el("div", { cls: "cx-swatch-row", children: [swatch, field] });
+ return {
+ node,
+ get value() { return current; },
+ setValue: (next) => commit(next, null),
+ destroy: () => node.remove(),
+ };
+});
diff --git a/app/ui/composer/elements/floating.js b/app/ui/composer/elements/floating.js
new file mode 100644
index 00000000..35fefc07
--- /dev/null
+++ b/app/ui/composer/elements/floating.js
@@ -0,0 +1,270 @@
+// Windows and panels that live above the app.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { setText } from "../internals/text.js";
+import { uid } from "../internals/ids.js";
+import { mount, unmount } from "../internals/portal.js";
+import { claimFor } from "../internals/zlayer.js";
+import { push } from "../internals/dismiss.js";
+import { trap } from "../internals/focus.js";
+import { drag } from "../internals/drag.js";
+
+function nodeOf(control, where) {
+ if (!control) return null;
+ if (control.node && control.node.nodeType === 1) return control.node;
+ throw new TypeError(`${where} takes a Composer element handle, not a ${typeof control}.`);
+}
+
+const remember = (id, box) => { try { localStorage.setItem(`cx.win.${id}`, JSON.stringify(box)); } catch { /* private mode */ } };
+const recall = (id) => { try { return JSON.parse(localStorage.getItem(`cx.win.${id}`)) || null; } catch { return null; } };
+
+/**
+ * An OS-style window: drag by the titlebar, roll up, maximise, resize from the
+ * corner, click to front. Position and size are remembered per `id`.
+ */
+define("floating", "window", ({
+ id = uid("win"), title, subtitle, body,
+ width = 420, height = 320, x = 80, y = 80,
+ resizable = true, rollup = true, onClose,
+} = {}) => {
+ const saved = recall(id) || {};
+ const box = { x: saved.x ?? x, y: saved.y ?? y, width: saved.width ?? width, height: saved.height ?? height };
+
+ const titleId = uid("win-title");
+ const node = el("div", { cls: "cx-window",
+ attrs: { role: "dialog", "aria-labelledby": titleId } });
+ // Bound, because another window coming to the front shifts this one's z.
+ const layer = claimFor("floatingWindow", node);
+
+ // Enough of the window to grab: a strip of titlebar wide enough to hit and
+ // tall enough to see. Everything that positions the window goes through here,
+ // because the one path that did not was the one that lost it.
+ const GRABBABLE = 80;
+ const TITLEBAR = 32;
+ const MIN_SIZE = 120;
+
+ // True only while the user is dragging or resizing THIS window. A window may
+ // be put partly off the right edge on purpose; it may not end up there because
+ // the screen changed under it.
+ let direct = false;
+
+ const clampIntoView = () => {
+ // SIZE first, because position is clamped against it. Resizing had no upper
+ // bound at all: a corner drag grew the window to 4937px and carried its own
+ // close and maximise buttons off the screen with it. Position was clamped
+ // and the controls were still unreachable, so "a window can never be put
+ // somewhere it cannot be recovered from" was only ever half true.
+ box.width = Math.max(MIN_SIZE, Math.min(box.width, window.innerWidth));
+ box.height = Math.max(MIN_SIZE, Math.min(box.height, window.innerHeight));
+
+ // Two rules, because the two situations are not the same one.
+ //
+ // Dragging: a grabbable strip is guaranteed and the rest may hang off the
+ // edge, because that is a placement the user just made.
+ //
+ // Everything else -- opening, restoring a remembered box, the viewport
+ // shrinking -- is not a placement anybody made HERE, so the window comes
+ // fully back on screen. Clamping the width against the viewport and the
+ // position against GRABBABLE are two budgets that do not add up: a 900px
+ // window remembered at x=1000 came back at x=720 of an 800px screen, still
+ // carrying its close and maximise buttons past the right edge. The size
+ // clamp above guarantees the window FITS, so it can be placed whole.
+ //
+ // Math.max(0, ...) matters on a viewport narrower than the reachable strip:
+ // without it the upper bound goes negative and the clamp pushes the window
+ // further off rather than back on.
+ const leftMost = direct ? -box.width + GRABBABLE : 0;
+ const rightMost = direct ? window.innerWidth - GRABBABLE : window.innerWidth - box.width;
+ const lowest = direct ? window.innerHeight - TITLEBAR : window.innerHeight - box.height;
+ box.x = Math.min(Math.max(box.x, leftMost), Math.max(0, rightMost));
+ box.y = Math.min(Math.max(box.y, 0), Math.max(0, lowest));
+ };
+
+ const apply = () => {
+ // Clamped on EVERY apply, not only while dragging. A position is remembered
+ // per window id, so a window moved to the right of a wide screen and reopened
+ // on a narrow one opened entirely off it -- no titlebar to grab, nothing to
+ // drag back, and the only way out was clearing localStorage by hand.
+ clampIntoView();
+ node.style.left = `${box.x}px`;
+ node.style.top = `${box.y}px`;
+ node.style.width = `${box.width}px`;
+ node.style.height = rolled ? "auto" : `${box.height}px`;
+ };
+
+ const titlebar = el("div", { cls: "cx-window-bar", children: [
+ el("div", { cls: "cx-window-titles", children: [
+ el("span", { cls: "cx-window-title", text: title, attrs: { id: titleId } }),
+ subtitle ? el("span", { cls: "cx-hint", text: subtitle }) : null,
+ ].filter(Boolean) }),
+ ] });
+
+ const controls = el("div", { cls: "cx-window-controls" });
+ let rolled = false;
+ let maximised = false;
+ let restoreBox = null;
+
+ if (rollup) {
+ controls.append(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"], text: "—",
+ attrs: { type: "button", "aria-label": "Roll up" },
+ on: { click: () => { rolled = !rolled; node.classList.toggle("cx-rolled", rolled); apply(); } } }));
+ }
+ controls.append(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"], text: "▢",
+ attrs: { type: "button", "aria-label": "Maximise" },
+ on: { click: () => {
+ if (maximised) { Object.assign(box, restoreBox); maximised = false; }
+ else {
+ restoreBox = { ...box };
+ Object.assign(box, { x: 8, y: 8, width: window.innerWidth - 16, height: window.innerHeight - 16 });
+ maximised = true;
+ }
+ apply();
+ } } }));
+ controls.append(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"], text: "✕",
+ attrs: { type: "button", "aria-label": "Close" },
+ on: { click: () => handle.close("close-button") } }));
+ titlebar.append(controls);
+
+ const content = el("div", { cls: "cx-window-body", children: nodeOf(body, "floating.window") });
+ node.append(titlebar, content);
+
+ const grip = resizable ? el("div", { cls: "cx-window-grip", attrs: { "aria-hidden": "true" } }) : null;
+ if (grip) node.append(grip);
+
+ apply();
+ mount(node);
+
+ // Click anywhere in the window brings it to the front of its rung.
+ const toFront = () => { layer.raise(); };
+ node.addEventListener("pointerdown", toFront);
+
+ // A window open while the viewport shrinks has the same problem as one
+ // reopened on a smaller screen.
+ const onViewportResize = () => apply();
+ window.addEventListener("resize", onViewportResize);
+
+ let start = null;
+ const stopDrag = drag(titlebar, {
+ onStart: () => { start = { ...box }; direct = true; toFront(); },
+ onMove: ({ dx, dy }) => {
+ // apply() does the clamping, so a window can never be dragged somewhere
+ // it cannot be dragged back from -- and the rule lives in one place.
+ box.x = start.x + dx;
+ box.y = start.y + dy;
+ apply();
+ },
+ onEnd: () => { direct = false; remember(id, box); },
+ });
+
+ const stopResize = grip ? drag(grip, {
+ onStart: () => { start = { ...box }; direct = true; },
+ onMove: ({ dx, dy }) => {
+ // Resizing may not push the window's own controls off the screen. The
+ // grip is bottom-right and the close button is top-right, so growing
+ // right takes the buttons with it -- a corner drag reached 4937px wide
+ // in a 900px viewport and left Escape as the only way out.
+ //
+ // Different rule from dragging on purpose: a window may be DRAGGED partly
+ // off (that is ordinary, and a grabbable strip is guaranteed), but it may
+ // not be GROWN past the edge it is anchored to.
+ const roomRight = Math.max(MIN_SIZE, window.innerWidth - Math.max(0, box.x));
+ const roomBelow = Math.max(MIN_SIZE, window.innerHeight - Math.max(0, box.y));
+ box.width = Math.min(Math.max(220, start.width + dx), roomRight);
+ box.height = Math.min(Math.max(120, start.height + dy), roomBelow);
+ apply();
+ },
+ onEnd: () => { direct = false; remember(id, box); },
+ }) : () => {};
+
+ // Escape closes, but a window does not close on an outside click: it is a
+ // window, not a menu, and clicking the app behind it is normal.
+ const dismissal = push({ nodes: node, closeOnOutside: false, onDismiss: (r) => handle.close(r) });
+
+ let closed = false;
+ const handle = {
+ node,
+ isOverlay: true,
+ toFront,
+ close(reason = "manual") {
+ if (closed) return;
+ closed = true;
+ remember(id, box);
+ dismissal.release();
+ stopDrag(); stopResize();
+ window.removeEventListener("resize", onViewportResize);
+ layer.release();
+ unmount(node);
+ if (onClose) onClose(reason);
+ },
+ destroy() { handle.close("destroy"); },
+ };
+ return handle;
+});
+
+/** A full-screen "please wait" that genuinely blocks. */
+define("overlay", "blocking", ({ message, progress, cancel } = {}) => {
+ const words = el("p", { cls: "cx-t cx-t-sm", text: message });
+ const card = el("div", { cls: "cx-blocking-card", children: [
+ el("span", { cls: ["cx-spinner", "cx-spinner-md"], attrs: { "aria-hidden": "true" } }),
+ words,
+ progress && progress.node ? progress.node : null,
+ cancel && cancel.node ? cancel.node : null,
+ ].filter(Boolean) });
+
+ const node = el("div", { cls: "cx-blocking",
+ attrs: { role: "alertdialog", "aria-busy": "true", "aria-label": message },
+ children: card });
+ const layer = claimFor("wizard", node);
+ mount(node);
+ const release = trap(card);
+
+ let closed = false;
+ const handle = {
+ node, isOverlay: true,
+ // What it is waiting for can change while it waits -- "restarting" becomes
+ // "it has not come back", and a spinner that cannot say so is a spinner
+ // somebody watches for ten minutes.
+ setMessage(next) {
+ setText(words, next);
+ node.setAttribute("aria-label", next);
+ },
+ close() { if (closed) return; closed = true; release(); layer.release(); unmount(node); },
+ destroy() { handle.close(); },
+ };
+ return handle;
+});
+
+/** A panel that slides in from an edge. Simple mode uses these over the preview. */
+define("slideOver", "md", ({ side = "right", title, body, onClose } = {}) => {
+ const titleId = uid("slide-title");
+
+ const panel = el("aside", { cls: ["cx-slideover", `cx-slideover-${side}`],
+ attrs: { role: "dialog", "aria-labelledby": titleId } });
+ panel.append(el("header", { cls: "cx-slideover-head", children: [
+ el("h2", { cls: "cx-panel-title", text: title, attrs: { id: titleId } }),
+ el("button", { cls: ["cx-icon-btn", "cx-icon-btn-md", "cx-focusable"], text: "✕",
+ attrs: { type: "button", "aria-label": "Close" }, on: { click: () => handle.close("close-button") } }),
+ ] }));
+ panel.append(el("div", { cls: "cx-slideover-body", children: nodeOf(body, "slideOver.md") }));
+
+ const root = el("div", { cls: "cx-slideover-layer", children: [el("div", { cls: "cx-backdrop" }), panel] });
+ const layer = claimFor("modal", root);
+ mount(root);
+
+ const release = trap(panel);
+ const dismissal = push({ nodes: panel, onDismiss: (r) => handle.close(r) });
+
+ let closed = false;
+ const handle = {
+ node: panel, isOverlay: true,
+ close(reason = "manual") {
+ if (closed) return;
+ closed = true;
+ dismissal.release(); release(); layer.release(); unmount(root);
+ if (onClose) onClose(reason);
+ },
+ destroy() { handle.close("destroy"); },
+ };
+ return handle;
+});
diff --git a/app/ui/composer/elements/gallery.css b/app/ui/composer/elements/gallery.css
new file mode 100644
index 00000000..5d14a2ef
--- /dev/null
+++ b/app/ui/composer/elements/gallery.css
@@ -0,0 +1,146 @@
+/* Sized from the CONTAINER, not the viewport: a 280px panel lays out like a
+ * 280px panel however wide the window is. */
+.cx-gallery-wrap { container-type: inline-size; width: 100%; }
+.cx-gallery {
+ display: grid; gap: clamp(6px, 2.5cqw, 12px);
+ grid-template-columns: repeat(auto-fill, minmax(var(--cell, var(--cell-thumb)), 1fr));
+}
+.cx-gallery[data-cols="1"] { grid-template-columns: repeat(1, minmax(0, 1fr)); }
+.cx-gallery[data-cols="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.cx-gallery[data-cols="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.cx-gallery[data-cols="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+
+.cx-cell {
+ display: flex; flex-direction: column; gap: var(--sp-2); padding: 0;
+ border: var(--bw) solid var(--line); border-radius: var(--r-3);
+ background: var(--elev-2); color: var(--text); cursor: pointer; overflow: hidden;
+ transition: border-color var(--dur-1) var(--ease-standard);
+}
+.cx-cell:hover { border-color: var(--line-hover); }
+.cx-cell.cx-on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
+.cx-cell-thumb {
+ position: relative; aspect-ratio: 16 / 10; background: var(--void);
+ display: flex; align-items: center; justify-content: center;
+}
+.cx-cell-img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.cx-cell-glyph { font-size: clamp(18px, calc(var(--cell, 96px) * .3), 34px); color: var(--faint); }
+.cx-cell-badge, .cx-cell-duration {
+ position: absolute; bottom: var(--sp-1); font-size: var(--fs-8);
+ padding: 1px var(--sp-2); border-radius: var(--r-1);
+ background: color-mix(in srgb, var(--void) 70%, transparent); color: var(--hi, #fff);
+}
+.cx-cell-badge { left: var(--sp-1); }
+.cx-cell-duration { right: var(--sp-1); font-variant-numeric: tabular-nums; }
+.cx-cell-name {
+ font-size: clamp(10px, calc(var(--cell, 96px) * .115), 13px);
+ line-height: var(--lh-snug); padding: 0 var(--sp-3) var(--sp-3);
+ text-align: left; display: -webkit-box; -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical; overflow: hidden;
+}
+
+/* Icons: as many captionless tiles as fit. Square, because without a name
+ under it a 16:10 tile reads as a cropped picture rather than a tile. */
+.cx-gallery-icons {
+ grid-template-columns: repeat(auto-fill, minmax(var(--cell-icon), 1fr));
+ gap: var(--sp-2);
+}
+.cx-cell-icon { border-radius: var(--r-2); }
+.cx-cell-icon .cx-cell-thumb { aspect-ratio: 1 / 1; }
+
+/* List: the whole name, which is the reason to be in this view at all. */
+.cx-media-list { display: flex; flex-direction: column; gap: var(--sp-1); }
+.cx-media-row {
+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center; gap: var(--sp-3);
+ padding: var(--sp-1) var(--sp-2); text-align: left; cursor: pointer;
+ border: var(--bw) solid transparent; border-radius: var(--r-2);
+ background: none; color: var(--text);
+ transition: border-color var(--dur-1) var(--ease-standard);
+}
+.cx-media-row:hover { border-color: var(--line-hover); background: var(--elev-2); }
+.cx-media-row.cx-on { border-color: var(--accent); background: var(--accent-soft); }
+.cx-media-row-thumb {
+ position: relative; flex: none; inline-size: 40px; aspect-ratio: 16 / 10;
+ display: flex; align-items: center; justify-content: center;
+ background: var(--void); border-radius: var(--r-1); overflow: hidden;
+}
+.cx-media-row-name {
+ font-size: var(--fs-7); line-height: var(--lh-snug);
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.cx-media-row-hint { font-size: var(--fs-8); color: var(--faint); }
+
+.cx-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--cell-card), 1fr)); gap: var(--sp-5); }
+.cx-card {
+ display: flex; flex-direction: column; gap: var(--sp-2); text-align: left;
+ padding: var(--sp-6); border: var(--bw) solid var(--line); border-radius: var(--r-5);
+ background: var(--elev-1); color: var(--text); cursor: pointer;
+ transition: border-color var(--dur-1) var(--ease-standard), background var(--dur-1) var(--ease-standard);
+}
+.cx-card:hover { border-color: var(--line-hover); }
+.cx-card.cx-on { border-color: var(--accent); background: var(--accent-soft); }
+.cx-card-icon { font-size: var(--fs-d3); line-height: 1; color: var(--accent); }
+.cx-card-title { font-size: var(--fs-3); font-weight: var(--fw-semibold); }
+
+/* A scroller that can actually shrink. Without an explicit floor a flex item's
+ automatic minimum is its CONTENT, so a strip of a dozen scenes made the
+ window as wide as the strip -- it scrolled sideways as a page instead of
+ scrolling inside itself, which is the one thing it exists to do. */
+.cx-strip {
+ display: flex; gap: var(--sp-2); overflow-x: auto; padding-bottom: var(--sp-2);
+ min-inline-size: 0;
+}
+.cx-strip-cell {
+ /* A floor and a ceiling. A clip grows with its length (flex-grow, set by the
+ element), and one clip in a project must not become the whole strip.
+ --strip-w is the zoom level, set on .cx-strip itself and inherited. */
+ flex: none; width: var(--strip-w, 72px); min-inline-size: var(--strip-w, 72px);
+ max-inline-size: var(--cell-panel);
+ aspect-ratio: 16 / 10; padding: 0;
+ border: var(--bw) solid var(--line); border-radius: var(--r-2);
+ background: var(--void); overflow: hidden; cursor: grab;
+ display: flex; align-items: center; justify-content: center;
+}
+.cx-strip-cell:hover { border-color: var(--accent); }
+.cx-strip-cell.cx-dragging { opacity: 0.4; cursor: grabbing; }
+/* Still in the timeline, still there to bring back -- excluded from a batch
+ run reads as dimmed, not gone, the way a deleted scene would be. */
+.cx-strip-cell.cx-excluded { opacity: 0.45; }
+.cx-strip-face { inline-size: 100%; block-size: 100%; display: flex; align-items: center; justify-content: center; }
+
+/* The result, shown whole. `contain` rather than `cover`: a generated frame
+ cropped to fit its box is a different picture from the one that was made. */
+.cx-viewer { display: flex; min-block-size: 0; block-size: 100%; }
+/* Letterbox black, not a panel colour: what is around a frame is part of how
+ the frame reads, and a dark grey surround makes a dark shot look washed. */
+.cx-viewer-stage {
+ flex: 1 1 auto;
+ display: flex; align-items: center; justify-content: center;
+ min-block-size: 0; min-inline-size: 0;
+ background: var(--void);
+}
+.cx-viewer-media {
+ max-inline-size: 100%; max-block-size: 100%;
+ inline-size: auto; block-size: auto;
+ object-fit: contain;
+}
+
+/* A clip carries its rating as a colour on its edge -- the one thing about a
+ result you want to see without selecting it. */
+.cx-strip-cell.cx-rated { border-color: var(--muted); }
+.cx-strip-cell[data-rating="liked"] { border-color: var(--good); }
+.cx-strip-cell[data-rating="disliked"] { border-color: var(--danger); }
+
+/* Where one scene becomes the next. Positioned in percent so it tracks the row
+ above it at any width, and never taller than the numbers it draws. */
+.cx-ruler {
+ position: relative; block-size: var(--lh-8); flex: none;
+ border-block-end: var(--bw) solid var(--line-2);
+}
+.cx-ruler-mark {
+ position: absolute; inset-block-start: 0;
+ padding-inline-start: var(--sp-1);
+ border-inline-start: var(--bw) solid var(--line);
+ font-size: var(--fs-8); line-height: var(--lh-8); color: var(--faint);
+ font-variant-numeric: tabular-nums;
+}
diff --git a/app/ui/composer/elements/gallery.js b/app/ui/composer/elements/gallery.js
new file mode 100644
index 00000000..6b1b4f6f
--- /dev/null
+++ b/app/ui/composer/elements/gallery.js
@@ -0,0 +1,330 @@
+// Grids of things.
+//
+// The adaptive one is v4's media bin generalised: it sizes off its CONTAINER,
+// not the viewport, so a 280px panel lays out like a 280px panel whether the
+// window is 1200px or 3000px wide.
+//
+// Three of these show the SAME items in different shapes -- grid, list, icons
+// -- so they share one item shape and one selection behaviour. A bin that
+// forgot what was selected when the view changed would be three galleries, not
+// one bin in three views.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { applyDensity, recallCols, rememberCols, normaliseCols } from "../internals/density.js";
+import { wireDragReorder } from "../internals/dragReorder.js";
+
+const EMPTY = "Nothing here yet";
+
+/** One item's picture: a real thumbnail, or the glyph that stands in for one. */
+function thumbOf(item, cls = "cx-cell-thumb", marks = true) {
+ const thumb = el("div", { cls });
+ const glyph = () => el("span", { cls: "cx-cell-glyph", text: item.icon || "▦", attrs: { "aria-hidden": "true" } });
+ if (item.thumb) {
+ // Decorative: the caption or the row's name carries it, so an alt would
+ // just make a screen reader say everything twice.
+ const img = el("img", { cls: "cx-cell-img", attrs: { src: item.thumb, alt: "", loading: "lazy" } });
+ // A thumbnail that does not arrive falls back to the glyph rather than to
+ // the browser's broken-image icon, which reads as a damaged FILE. In a bin
+ // of fifty, one server hiccup would otherwise look like lost work.
+ img.addEventListener("error", () => img.replaceWith(glyph()));
+ thumb.append(img);
+ } else {
+ thumb.append(glyph());
+ }
+ if (marks && item.badge) thumb.append(el("span", { cls: "cx-cell-badge", text: item.badge }));
+ if (marks && item.duration) thumb.append(el("span", { cls: "cx-cell-duration", text: item.duration }));
+ return thumb;
+}
+
+/**
+ * The part every view of a collection has: items, which are selected, and what
+ * a click on one means. `cellFor` is the only difference between them.
+ */
+function collection({ items = [], empty = EMPTY, selection = [], onActivate, onContext } = {},
+ host, cellFor) {
+ const chosen = new Set(selection);
+
+ function draw() {
+ host.replaceChildren();
+ if (!items.length) {
+ host.append(el("p", { cls: "cx-list-empty", text: empty }));
+ return;
+ }
+ for (const item of items) {
+ const cell = cellFor(item, chosen.has(item.id));
+ cell.addEventListener("click", () => { if (onActivate) onActivate(item); });
+ if (onContext) {
+ cell.addEventListener("contextmenu", (e) => { e.preventDefault(); onContext(item, e); });
+ }
+ host.append(cell);
+ }
+ }
+ draw();
+
+ return {
+ draw,
+ get value() { return [...chosen]; },
+ setValue(next) { chosen.clear(); for (const v of next || []) chosen.add(v); draw(); },
+ setItems(next) { items = next || []; draw(); },
+ };
+}
+
+const option = (on, extra = {}) => ({ type: "button", role: "option", "aria-selected": String(on), ...extra });
+
+define("gallery", "adaptive", (props = {}) => {
+ const { id = "gallery", cols } = props;
+ const grid = el("div", { cls: "cx-gallery", attrs: { role: "listbox", "aria-multiselectable": "true" } });
+ applyDensity(grid, cols === undefined ? recallCols(id) : cols);
+
+ const api = collection(props, grid, (item, on) => {
+ const cell = el("button", { cls: ["cx-cell", on ? "cx-on" : null, "cx-focusable"], attrs: option(on) });
+ cell.append(thumbOf(item), el("span", { cls: "cx-cell-name", text: item.label }));
+ return cell;
+ });
+
+ const node = el("div", { cls: "cx-gallery-wrap", children: grid });
+ return {
+ node,
+ get value() { return api.value; },
+ setValue: api.setValue,
+ setItems: api.setItems,
+ setCols(next) {
+ const n = normaliseCols(next);
+ applyDensity(grid, n);
+ rememberCols(id, n);
+ },
+ destroy: () => node.remove(),
+ };
+});
+
+/**
+ * The same items as tiles with no captions: as many as fit, the name on hover
+ * and to a screen reader. For a bin holding dozens of results, where what is
+ * being read is the picture and not its filename.
+ */
+define("gallery", "icons", (props = {}) => {
+ const grid = el("div", { cls: ["cx-gallery", "cx-gallery-icons"],
+ attrs: { role: "listbox", "aria-multiselectable": "true" } });
+
+ const api = collection(props, grid, (item, on) => {
+ // title AND aria-label: without a caption the tile has no accessible name
+ // at all, and a title alone is not one on a control.
+ const cell = el("button", { cls: ["cx-cell", "cx-cell-icon", on ? "cx-on" : null, "cx-focusable"],
+ attrs: option(on, { title: item.label, "aria-label": item.label }) });
+ // No badge or duration: 9px of text over a 48px tile covers the picture the
+ // tile exists to show, and this view is chosen to see many pictures at once.
+ cell.append(thumbOf(item, "cx-cell-thumb", false));
+ return cell;
+ });
+
+ const node = el("div", { cls: "cx-gallery-wrap", children: grid });
+ return {
+ node,
+ get value() { return api.value; },
+ setValue: api.setValue,
+ setItems: api.setItems,
+ destroy: () => node.remove(),
+ };
+});
+
+/** The same items as rows: a small picture, the whole name, and what it is. */
+define("gallery", "list", (props = {}) => {
+ const body = el("div", { cls: "cx-media-list", attrs: { role: "listbox" } });
+
+ const api = collection(props, body, (item, on) => el("button", {
+ cls: ["cx-media-row", on ? "cx-on" : null, "cx-focusable"],
+ attrs: option(on),
+ children: [
+ // No badge or duration over a 40px picture -- they would cover it. The
+ // row has room to say both in words instead.
+ thumbOf(item, "cx-media-row-thumb", false),
+ el("span", { cls: "cx-media-row-name", text: item.label }),
+ item.hint ? el("span", { cls: "cx-media-row-hint", text: item.hint }) : null,
+ ].filter(Boolean),
+ }));
+
+ const node = el("div", { cls: "cx-gallery-wrap", children: body });
+ return {
+ node,
+ get value() { return api.value; },
+ setValue: api.setValue,
+ setItems: api.setItems,
+ destroy: () => node.remove(),
+ };
+});
+
+/** Large clickable cards: the wizard's "what are you making?" choices. */
+define("gallery", "cards", ({ items = [], value, onActivate } = {}) => {
+ let current = value;
+ const node = el("div", { cls: "cx-cards", attrs: { role: "radiogroup" } });
+ const cards = items.map((item) => {
+ const card = el("button", {
+ cls: ["cx-card", "cx-focusable"],
+ attrs: { type: "button", role: "radio", "aria-checked": String(item.id === current) },
+ children: [
+ item.icon ? el("span", { cls: "cx-card-icon", text: item.icon, attrs: { "aria-hidden": "true" } }) : null,
+ el("span", { cls: "cx-card-title", text: item.label }),
+ item.hint ? el("span", { cls: "cx-hint", text: item.hint }) : null,
+ ].filter(Boolean),
+ });
+ card.classList.toggle("cx-on", item.id === current);
+ card.addEventListener("click", () => {
+ current = item.id;
+ for (const { item: i, card: c } of cards) {
+ c.classList.toggle("cx-on", i.id === current);
+ c.setAttribute("aria-checked", String(i.id === current));
+ }
+ if (onActivate) onActivate(item);
+ });
+ node.append(card);
+ return { item, card };
+ });
+ return { node, get value() { return current; }, destroy: () => node.remove() };
+});
+
+/**
+ * A single scrolling row: filmstrips, segment strips, recent items.
+ *
+ * Selectable like its siblings, and through the same `collection` -- a strip is
+ * the one shape whose whole job is "which of these am I on", and one that could
+ * only be clicked THROUGH left the caller drawing its own current-item marker
+ * over a row that did not have the concept.
+ */
+define("gallery", "strip", ({ onReorder, ...props } = {}) => {
+ const node = el("div", { cls: "cx-strip", attrs: { role: "listbox", "aria-label": props.label } });
+
+ const api = collection(props, node, (item, on) => {
+ const cell = el("button", {
+ cls: ["cx-strip-cell", on ? "cx-on" : null, item.rating ? "cx-rated" : null,
+ item.excluded ? "cx-excluded" : null, "cx-focusable"],
+ attrs: option(on, { title: item.label, "aria-label": item.label,
+ "data-rating": item.rating || undefined,
+ // The item's OWN id, not just its position -- a
+ // reorder tracked by index goes stale the moment
+ // anything redraws the strip mid-drag (a scene
+ // removed by keyboard while the mouse is still
+ // held down, for one), since a redraw can happen on
+ // its own input channel independent of the drag.
+ "data-id": item.id,
+ // Draggable whether or not `onReorder` is given: a
+ // drag with nowhere to report to is inert, not wired
+ // -- the delegated listeners below are the only thing
+ // that acts on it, and they no-op without a handler.
+ draggable: "true" }) });
+ // As wide as what it stands for is long, which is what turns a row of equal
+ // boxes into something that reads as time. Bounded at both ends: one very
+ // long clip beside short ones must not squeeze the rest to a sliver.
+ if (item.weight) cell.style.flexGrow = String(Math.min(6, Math.max(1, Number(item.weight) || 1)));
+ // The same fallback the grid has: a thumbnail that does not arrive shows the
+ // glyph, not the browser's broken-image icon, which reads as a damaged file.
+ cell.append(thumbOf(item, "cx-strip-face", false));
+ if (item.badge) cell.append(el("span", { cls: "cx-cell-badge", text: item.badge }));
+ return cell;
+ });
+
+ wireDragReorder(node, ".cx-strip-cell", onReorder);
+
+ return {
+ node,
+ get value() { return api.value; },
+ setValue: api.setValue,
+ setItems: api.setItems,
+ destroy: () => node.remove(),
+ };
+});
+
+/**
+ * A scale over something laid out in a row: where each part begins.
+ *
+ * The marks are given rather than computed from an interval -- what a person
+ * looks for on a timeline is where one scene becomes the next, and at 24 frames
+ * a second a tick per second is a picket fence.
+ */
+define("ruler", "default", ({ marks = [], total = 1, label } = {}) => {
+ const node = el("div", { cls: "cx-ruler", attrs: { role: "presentation", "aria-label": label } });
+
+ function set(next = [], span = 1) {
+ node.replaceChildren();
+ for (const mark of next) {
+ const tick = el("span", { cls: "cx-ruler-mark", text: mark.label, attrs: { title: mark.hint } });
+ // Percent, so the ruler tracks the row above it at any width.
+ tick.style.insetInlineStart = `${Math.min(100, Math.max(0, (mark.at || 0) * 100))}%`;
+ node.append(tick);
+ }
+ node.dataset.total = String(span);
+ }
+
+ set(marks, total);
+ return { node, set, destroy: () => node.remove() };
+});
+
+/**
+ * The result, big. One image or one video, or an empty state before there is
+ * either -- because "nothing has been generated" and "the picture failed to
+ * load" have to look different, and a bare with a broken src looks like
+ * neither.
+ */
+define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError } = {}) => {
+ const stage = el("div", { cls: "cx-viewer-stage" });
+ let current = null;
+ let currentKind = kind;
+ let currentFile = null;
+ let mediaEl = null;
+
+ function show(next, nextKind = kind, file = null) {
+ current = next || null;
+ currentKind = nextKind;
+ currentFile = current ? file : null;
+ mediaEl = null;
+ stage.replaceChildren();
+ if (!current) {
+ stage.append(el("p", { cls: "cx-list-empty", text: empty }));
+ return;
+ }
+ const media = nextKind === "video"
+ ? el("video", { cls: "cx-viewer-media", attrs: { src: current, controls: "", loop: "", playsinline: "" } })
+ : el("img", { cls: "cx-viewer-media", attrs: { src: current, alt: "" } });
+ mediaEl = media;
+ // A failed load is reported rather than left as a broken icon: the file is
+ // produced by a run, so it not arriving is a fault worth naming.
+ media.addEventListener("error", () => {
+ stage.replaceChildren(el("p", { cls: "cx-list-empty", text: "This result could not be loaded." }));
+ mediaEl = null;
+ // Nothing is actually showing any more -- `file` saying otherwise is
+ // what let "Save to bin" silently bin a dead reference from this exact
+ // state, the one place the panel itself says there is nothing here.
+ currentFile = null;
+ if (onError) onError(current);
+ });
+ stage.append(media);
+ }
+
+ show(src, kind);
+ const node = el("div", { cls: "cx-viewer", children: stage });
+ return {
+ node,
+ get value() { return current; },
+ get kind() { return currentKind; },
+ // The file this came from -- {filename, subfolder, type} -- when the
+ // caller had one to give. Null for anything shown by URL alone, which is
+ // not enough to name a file back to the server with.
+ get file() { return currentFile; },
+ setValue: (next) => show(next, kind),
+ setSource: (next, nextKind, file) => show(next, nextKind || kind, file),
+ /**
+ * The video frame on screen right now, as a PNG blob -- null if nothing is
+ * playing or there is no frame yet to draw (a video that has not loaded
+ * its first frame has no dimensions to capture at).
+ */
+ captureFrame() {
+ if (currentKind !== "video" || !mediaEl || !mediaEl.videoWidth) return Promise.resolve(null);
+ const canvas = document.createElement("canvas");
+ canvas.width = mediaEl.videoWidth;
+ canvas.height = mediaEl.videoHeight;
+ canvas.getContext("2d").drawImage(mediaEl, 0, 0);
+ return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
+ },
+ destroy: () => node.remove(),
+ };
+});
diff --git a/app/ui/composer/elements/index.js b/app/ui/composer/elements/index.js
new file mode 100644
index 00000000..78e56aa3
--- /dev/null
+++ b/app/ui/composer/elements/index.js
@@ -0,0 +1,20 @@
+// Every element file, imported for its side effect: each one calls define() and
+// so announces itself into the registry. This list is the only place element
+// files are named, and it is the only enumeration in the kit -- ES modules give
+// no way to discover siblings at runtime without a build step.
+//
+// Elements land here one at a time, each with its catalogue section and tests.
+
+import "./text.js";
+import "./button.js";
+import "./input.js";
+import "./choice.js";
+import "./slider.js";
+import "./layout.js";
+import "./status.js";
+import "./modal.js";
+import "./popover.js";
+import "./floating.js";
+import "./gallery.js";
+import "./track.js";
+import "./wheel.js";
diff --git a/app/ui/composer/elements/input.css b/app/ui/composer/elements/input.css
new file mode 100644
index 00000000..3ba3f70f
--- /dev/null
+++ b/app/ui/composer/elements/input.css
@@ -0,0 +1,159 @@
+.cx-input, .cx-textarea, .cx-select {
+ width: 100%;
+ border: var(--bw) solid var(--line); border-radius: var(--ctl-r);
+ background: var(--elev-2); color: var(--text);
+ font-family: var(--font-ui);
+ transition: border-color var(--dur-1) var(--ease-standard);
+}
+.cx-input-md { height: var(--ctl-h-lg); padding: 0 var(--ctl-pad-x-md); font-size: var(--fs-4); }
+.cx-input-sm { height: var(--ctl-h-md); padding: 0 var(--ctl-pad-x-sm); font-size: var(--fs-5); }
+.cx-input:hover:not(:disabled), .cx-textarea:hover:not(:disabled) { border-color: var(--line-hover); }
+.cx-input:disabled, .cx-textarea:disabled, .cx-select:disabled { opacity: var(--disabled-opacity); }
+.cx-input::placeholder, .cx-textarea::placeholder { color: var(--faint); }
+
+.cx-number { font-variant-numeric: tabular-nums; }
+.cx-number-wrap { display: inline-flex; align-items: center; gap: var(--sp-2); width: 100%; }
+.cx-unit { font-size: var(--fs-6); color: var(--muted); }
+
+.cx-stepper { display: inline-flex; align-items: stretch; gap: var(--sp-1); }
+.cx-stepper .cx-number { text-align: center; }
+.cx-step-btn {
+ width: var(--ctl-h-lg); height: var(--ctl-h-lg); flex: none;
+ border: var(--bw) solid var(--line); border-radius: var(--ctl-r);
+ background: var(--elev-2); color: var(--muted);
+ font-size: var(--fs-4); line-height: 1; cursor: pointer;
+}
+.cx-step-btn:hover { border-color: var(--line-hover); color: var(--text); }
+
+.cx-textarea {
+ padding: var(--sp-3) var(--ctl-pad-x-md);
+ font-size: var(--fs-4); line-height: var(--lh-snug);
+ resize: vertical; min-height: var(--ctl-h-xl);
+}
+.cx-autogrow { resize: none; overflow: hidden; }
+
+.cx-search { appearance: none; }
+.cx-search::-webkit-search-cancel-button { filter: grayscale(1) opacity(.6); }
+
+.cx-filter { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 220px; }
+/* filterList always builds its own search box, which is right everywhere it
+ is the ONLY way to narrow the list -- and wrong stacked under a search box
+ that already re-queries the server on every keystroke: two boxes doing the
+ same job, one of them silently filtering an already-server-filtered
+ subset, with no way to tell which one produced what is on screen. The
+ node-add picker (pipeline_window.js) is the one place that already has an
+ outer search; this hides filterList's own for exactly that container. */
+.cx-node-picker-results .cx-filter > .cx-search { display: none; }
+.cx-filter-list {
+ display: flex; flex-direction: column; gap: 1px;
+ max-height: 240px; overflow-y: auto; overflow-x: hidden;
+ border: var(--bw) solid var(--line); border-radius: var(--r-3);
+ background: var(--elev-1); padding: var(--sp-1);
+}
+.cx-filter-row {
+ display: flex; align-items: center; gap: var(--sp-3);
+ padding: var(--sp-3) var(--sp-4); border: 0; border-radius: var(--r-2);
+ background: transparent; color: var(--text);
+ font-size: var(--fs-5); text-align: left; cursor: pointer; width: 100%;
+}
+.cx-filter-row:hover { background: var(--hover); }
+.cx-filter-row.cx-on { background: var(--accent-soft); color: var(--accent-hi); }
+.cx-filter-row.cx-on .cx-filter-label { font-weight: var(--fw-semibold); }
+/* A column so a wrapping hint sits directly under its own label, both flush
+ to the same left edge, instead of floating as a baseline-aligned sibling
+ of the icon and the label. Centred on the icon: with the hint gone (a
+ nav row now, not a compact picker's two-line entry) top-aligning the two
+ just looks like a mistake. */
+.cx-filter-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; justify-content: center; }
+.cx-filter-label { display: block; }
+.cx-filter-hint { display: block; font-size: var(--fs-7); color: var(--muted); line-height: var(--lh-snug); }
+.cx-filter-empty { padding: var(--sp-5); font-size: var(--fs-6); color: var(--faint); text-align: center; }
+
+/* A chip, not a bare glyph floating in the row -- the thing a nav list reads
+ as unfinished without. `tone` is a closed set of pre-approved looks
+ (accent/danger/warn/good/neutral), the same rule as everywhere else in the
+ kit; there is no path from item data to a raw colour. */
+.cx-filter-icon {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 22px; height: 22px; flex: 0 0 auto; border-radius: var(--r-2);
+ background: var(--elev-3); color: var(--text); font-size: var(--fs-6); line-height: 1;
+ /* Optical, against the label's cap-height, not the row's own top edge. */
+ margin-block-start: 1px;
+}
+.cx-filter-icon-accent { background: var(--grad-accent); color: var(--on-accent); }
+.cx-filter-icon-danger { background: linear-gradient(180deg, var(--danger), color-mix(in srgb, var(--danger) 65%, black)); color: #fff; }
+.cx-filter-icon-warn { background: linear-gradient(180deg, var(--warn), color-mix(in srgb, var(--warn) 65%, black)); color: #fff; }
+.cx-filter-icon-good { background: linear-gradient(180deg, var(--good), color-mix(in srgb, var(--good) 65%, black)); color: #fff; }
+.cx-filter-icon-neutral { background: linear-gradient(180deg, var(--muted), color-mix(in srgb, var(--muted) 65%, black)); color: #fff; }
+.cx-filter-row.cx-on .cx-filter-icon { box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-hi) 50%, transparent); }
+
+/* The Settings sidebar: a 52px icon rail by default, expanding to a full list
+ on hover or keyboard focus and OVERLAYING the content rather than pushing
+ it -- v4's own sidebar, and the reason nothing here uses splitPane's
+ resizable two-pane shape: this one is not meant to stay open. */
+.cx-settings-body { position: relative; min-block-size: 0; block-size: 100%; }
+.cx-settings-rail {
+ position: absolute; inset-block: 0; inset-inline-start: 0; z-index: 2;
+ /* .cx-filter's own min-width:220px is a floor for its NORMAL (small,
+ always-220px-ish) role; here it would keep this element from ever
+ actually reaching 52px regardless of inline-size below, since min-width
+ always wins over a smaller width -- the search box then rendered at its
+ un-shrunk content width and this clipped it to "Sear", not an icon. */
+ min-inline-size: 52px; inline-size: 52px; overflow: hidden;
+ background: var(--elev-2); border-inline-end: var(--bw) solid var(--line);
+ transition: inline-size var(--dur-2) var(--ease-standard),
+ box-shadow var(--dur-2) var(--ease-standard);
+}
+.cx-settings-rail:hover, .cx-settings-rail:focus-within {
+ inline-size: 218px;
+ box-shadow: 18px 0 40px -18px color-mix(in srgb, var(--shadow-ink) 80%, transparent);
+}
+/* The label, and the search box's placeholder -- the icon chip stays put so
+ the collapsed rail still says something, the way a dock of icons does.
+ The label's WIDTH collapses too, not just its opacity: an invisible label
+ still claiming its flex share left the icon sitting off-centre with dead
+ space after it, rather than centred in the icon-only rail. */
+.cx-settings-rail .cx-filter-text {
+ flex: 0 1 0; inline-size: 0; overflow: hidden;
+ transition: flex-basis var(--dur-2) var(--ease-standard);
+}
+.cx-settings-rail .cx-filter-row {
+ justify-content: center;
+ /* The row's own gap still opens a space before a zero-width label -- two
+ flex items with nothing between them still get one gap's worth of
+ nothing, which shifted the centred GROUP (icon + gap) off the icon. */
+ gap: 0;
+}
+.cx-settings-rail .cx-filter-label {
+ opacity: 0; transition: opacity var(--dur-1) var(--ease-standard); white-space: nowrap;
+}
+.cx-settings-rail .cx-search::placeholder {
+ opacity: 0; transition: opacity var(--dur-1) var(--ease-standard);
+}
+.cx-settings-rail:hover .cx-search::placeholder,
+.cx-settings-rail:focus-within .cx-search::placeholder { opacity: 1; }
+.cx-settings-rail:hover .cx-filter-label, .cx-settings-rail:focus-within .cx-filter-label { opacity: 1; }
+.cx-settings-rail:hover .cx-filter-text, .cx-settings-rail:focus-within .cx-filter-text {
+ flex: 1 1 auto; inline-size: auto;
+}
+.cx-settings-rail:hover .cx-filter-row, .cx-settings-rail:focus-within .cx-filter-row {
+ justify-content: flex-start; gap: var(--sp-3);
+}
+.cx-settings-content {
+ /* The rail's own right edge, plus real breathing room from the scale --
+ flush against it read as a layout bug, not a deliberate two regions
+ meeting. sp-8, not the gutter (sp-5): the gutter is for regions that
+ border each other structurally: this is content clearing an overlay. */
+ margin-inline-start: calc(52px + var(--sp-8)); block-size: 100%; overflow-y: auto; min-inline-size: 0;
+}
+
+/* Used as a whole pane (the Settings sidebar) rather than a compact dropdown
+ list -- the 240px cap and boxed border/background are right for the small
+ version and wrong for this one, so a host opts in with this class instead
+ of the base rules changing under every other consumer. */
+.cx-filter-full { min-block-size: 0; block-size: 100%; }
+.cx-filter-full .cx-filter-list {
+ max-height: none; flex: 1 1 auto; min-block-size: 0;
+ border: 0; background: transparent; padding: 0;
+}
+.cx-filter-full .cx-filter-row { padding: var(--sp-3) var(--sp-3); }
diff --git a/app/ui/composer/elements/input.js b/app/ui/composer/elements/input.js
new file mode 100644
index 00000000..0cef4a0b
--- /dev/null
+++ b/app/ui/composer/elements/input.js
@@ -0,0 +1,213 @@
+// Text entry.
+//
+// onInput fires on every keystroke; onCommit fires on blur and Enter. Panels
+// almost always want onCommit -- a settings value that updates per keystroke
+// writes nine intermediate values while you type "0.65".
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { uid } from "../internals/ids.js";
+
+function wire(node, { onInput, onCommit }) {
+ if (onInput) node.addEventListener("input", () => onInput(node.value));
+ if (onCommit) {
+ let last = node.value;
+ const commit = () => { if (node.value !== last) { last = node.value; onCommit(node.value); } };
+ node.addEventListener("blur", commit);
+ // And `change`, which is what autofill, a password manager and a script
+ // setting the value all fire without ever taking focus. commit() only acts
+ // on a real difference, so the pair costs nothing when both arrive.
+ node.addEventListener("change", commit);
+ // Enter commits a one-line field. In a TEXTAREA it is a newline, and
+ // stealing it there means a multi-line value cannot be typed at all -- the
+ // prompt included, which is the longest multi-line thing in the app.
+ if (node.tagName !== "TEXTAREA") {
+ node.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } });
+ }
+ }
+}
+
+function textLike(tag, cls, { value = "", placeholder, onInput, onCommit, disabled, id, type, label } = {}) {
+ const node = el(tag, {
+ cls: [cls, "cx-focusable"],
+ attrs: { placeholder, disabled, id: id || uid("input"), type, "aria-label": label },
+ });
+ node.value = value;
+ wire(node, { onInput, onCommit });
+ return {
+ node,
+ get value() { return node.value; },
+ setValue(v) { node.value = v ?? ""; },
+ focus: () => node.focus(),
+ destroy: () => node.remove(),
+ };
+}
+
+for (const size of ["md", "sm"]) {
+ define("input", size, (props = {}) => textLike("input", `cx-input cx-input-${size}`, { type: "text", ...props }));
+}
+
+define("search", "md", (props = {}) => {
+ const handle = textLike("input", "cx-input cx-input-md cx-search", { type: "search", placeholder: "Search", ...props });
+ return handle;
+});
+
+define("number", "md", ({ value = 0, min, max, step = 1, precision, unit, onChange, disabled, id, label } = {}) => {
+ const input = el("input", {
+ cls: ["cx-input", "cx-input-md", "cx-number", "cx-focusable"],
+ attrs: { type: "number", min, max, step, disabled, id: id || uid("num"), "aria-label": label },
+ });
+ input.value = String(value);
+
+ let committed = Number(value);
+
+ const clamp = (n) => {
+ if (min !== undefined && n < min) return min;
+ if (max !== undefined && n > max) return max;
+ return precision === undefined ? n : Number(n.toFixed(precision));
+ };
+
+ // Clamping on commit rather than on input: clamping mid-typing makes "-" or
+ // a leading "0." impossible to type.
+ //
+ // An emptied or unparseable field reverts to the last committed value rather
+ // than being read as a number. A browser normalises invalid text in a number
+ // input to "", and Number("") is 0 -- which is finite, so it sails past any
+ // isFinite guard and lands on min, silently discarding what the user had.
+ const commit = () => {
+ const raw = input.value.trim();
+ const parsed = raw === "" ? NaN : Number(raw);
+ const next = Number.isFinite(parsed) ? clamp(parsed) : committed;
+ committed = next;
+ input.value = String(next);
+ if (onChange) onChange(next);
+ };
+ input.addEventListener("blur", commit);
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } });
+
+ const node = unit
+ ? el("span", { cls: "cx-number-wrap", children: [input, el("span", { cls: "cx-unit", text: unit })] })
+ : input;
+
+ return {
+ node,
+ get value() { return committed; },
+ setValue(v) {
+ const parsed = Number(v);
+ committed = Number.isFinite(parsed) ? clamp(parsed) : committed;
+ input.value = String(committed);
+ },
+ destroy: () => node.remove(),
+ };
+});
+
+define("stepper", "md", ({ value = 0, min, max, step = 1, onChange, label } = {}) => {
+ let current = value;
+ const out = el("input", { cls: ["cx-input", "cx-input-md", "cx-number", "cx-focusable"],
+ attrs: { type: "number", min, max, step, "aria-label": label } });
+ out.value = String(current);
+
+ const set = (next) => {
+ if (min !== undefined) next = Math.max(min, next);
+ if (max !== undefined) next = Math.min(max, next);
+ current = Number(next.toFixed(6)); // 0.1 + 0.2 must not become 0.30000000000000004
+ out.value = String(current);
+ if (onChange) onChange(current);
+ };
+
+ const bump = (dir) => el("button", {
+ cls: ["cx-step-btn", "cx-focusable"],
+ text: dir > 0 ? "+" : "−",
+ attrs: { type: "button", "aria-label": `${dir > 0 ? "Increase" : "Decrease"}${label ? ` ${label}` : ""}` },
+ on: { click: () => set(current + dir * step) },
+ });
+
+ out.addEventListener("change", () => set(Number(out.value)));
+ const node = el("span", { cls: "cx-stepper", children: [bump(-1), out, bump(1)] });
+
+ return { node, get value() { return current; }, setValue: set, destroy: () => node.remove() };
+});
+
+define("textarea", "md", ({ value = "", rows = 4, autoGrow = false, placeholder, onInput, onCommit, label } = {}) => {
+ const node = el("textarea", {
+ cls: ["cx-textarea", "cx-focusable", autoGrow ? "cx-autogrow" : null],
+ attrs: { rows, placeholder, "aria-label": label },
+ });
+ node.value = value;
+
+ const grow = () => { node.style.height = "auto"; node.style.height = `${node.scrollHeight}px`; };
+ if (autoGrow) { node.addEventListener("input", grow); queueMicrotask(grow); }
+ wire(node, { onInput, onCommit });
+
+ return {
+ node,
+ get value() { return node.value; },
+ setValue(v) { node.value = v ?? ""; if (autoGrow) grow(); },
+ destroy: () => node.remove(),
+ };
+});
+
+/**
+ * A search box over a list. v4 had seven of these, each with its own filter.
+ * Items are DATA -- {id, label, hint, icon} -- never a render callback, so a
+ * module cannot smuggle markup in through a "just render this" escape hatch.
+ */
+const FILTER_ICON_TONES = new Set(["accent", "danger", "warn", "good", "neutral"]);
+
+define("filterList", "md", ({ items = [], value, onChange, placeholder = "Search", empty = "Nothing matches" } = {}) => {
+ let selected = value;
+ const search = el("input", { cls: ["cx-input", "cx-input-sm", "cx-search", "cx-focusable"],
+ attrs: { type: "search", placeholder } });
+ const list = el("div", { cls: "cx-filter-list", attrs: { role: "listbox" } });
+
+ function draw() {
+ const q = search.value.trim().toLowerCase();
+ // `keywords` is searched but never drawn -- a way in that does not name
+ // itself in the visible label/hint (a section called "About FunPack"
+ // found by typing "commit", say).
+ const shown = items.filter((i) =>
+ !q || `${i.label} ${i.hint || ""} ${i.keywords || ""}`.toLowerCase().includes(q));
+ list.replaceChildren();
+ if (!shown.length) { list.append(el("div", { cls: "cx-filter-empty", text: empty })); return; }
+ for (const item of shown) {
+ // `tone` picks one of a FIXED set of chip colours -- never a raw
+ // colour from data, which is the rule `el()`'s style refusal exists to
+ // hold everywhere else too. Enforced, not just claimed: an unknown
+ // tone throws, the same as button.js's own tone check, rather than
+ // silently rendering an unstyled chip.
+ if (item.tone && !FILTER_ICON_TONES.has(item.tone)) {
+ throw new RangeError(`Unknown filterList icon tone "${item.tone}". Known: ${[...FILTER_ICON_TONES].join(", ")}.`);
+ }
+ const row = el("button", {
+ cls: ["cx-filter-row", "cx-focusable", item.id === selected ? "cx-on" : null],
+ attrs: { type: "button", role: "option", "aria-selected": String(item.id === selected) },
+ children: [
+ item.icon ? el("span", {
+ cls: ["cx-filter-icon", item.tone ? `cx-filter-icon-${item.tone}` : null],
+ text: item.icon,
+ }) : null,
+ // A column, not two more flex siblings beside the icon -- a hint
+ // long enough to wrap needs to wrap UNDER its own label, not float
+ // as its own baseline-aligned sibling next to it.
+ el("div", { cls: "cx-filter-text", children: [
+ el("span", { cls: "cx-filter-label", text: item.label }),
+ item.hint ? el("span", { cls: "cx-filter-hint", text: item.hint }) : null,
+ ].filter(Boolean) }),
+ ].filter(Boolean),
+ });
+ row.addEventListener("click", () => { selected = item.id; draw(); if (onChange) onChange(item.id); });
+ list.append(row);
+ }
+ }
+
+ search.addEventListener("input", draw);
+ draw();
+
+ const node = el("div", { cls: "cx-filter", children: [search, list] });
+ return {
+ node,
+ get value() { return selected; },
+ setValue(v) { selected = v; draw(); },
+ destroy: () => node.remove(),
+ };
+});
diff --git a/app/ui/composer/elements/layout.css b/app/ui/composer/elements/layout.css
new file mode 100644
index 00000000..32e84d8f
--- /dev/null
+++ b/app/ui/composer/elements/layout.css
@@ -0,0 +1,428 @@
+.cx-field { display: flex; flex-direction: column; gap: var(--sp-2); }
+.cx-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-5); }
+.cx-field-error { font-size: var(--fs-6); color: var(--danger); }
+
+.cx-settings-row {
+ display: flex; align-items: center; justify-content: space-between; gap: var(--sp-7);
+ padding: var(--sp-4) 0; border-bottom: var(--bw) solid var(--line-2);
+}
+.cx-settings-row:last-child { border-bottom: 0; }
+.cx-settings-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.cx-settings-label { font-size: var(--fs-4); line-height: var(--lh-4); color: var(--text); }
+.cx-settings-control {
+ flex: 0 0 var(--control-col); min-inline-size: 0;
+ display: flex; align-items: center; justify-content: flex-end;
+}
+/* A control fills its column, or is at most as wide as it. Left to itself a
+ takes the width of its widest option, which is how one row in a
+ list of five ended up 124px wider than the rest. */
+.cx-settings-control > * { max-inline-size: 100%; }
+.cx-settings-control > .cx-select,
+.cx-settings-control > .cx-input,
+.cx-settings-control > .cx-textarea,
+.cx-settings-control > .cx-slider { inline-size: 100%; }
+.cx-settings-row.cx-danger .cx-settings-label { color: var(--danger); }
+/* Below the width where a label and a control fit side by side, they stop
+ sitting side by side. The control column will not give up its width, so in a
+ docked panel the label was squeezed to one word per line and its hint to a
+ four-character column -- readable as damage, not as a layout. The container
+ is the ROWS, not the viewport: the same group is used in a 190px panel and in
+ a 600px window, and only its own width decides.
+
+ AFTER the rules it overrides, and it has to stay there: a container query
+ adds no specificity, so written above them it lost -- and `flex: 0 0 180px`
+ in a COLUMN is 180px of HEIGHT, which turned four settings into a panel two
+ screens long. */
+/* 360, not 300: the control column is 180px and a label needs at least as much
+ again beside it. Written as 300 it depended on the padding around the rows --
+ trimming 4px off two nested boxes widened this container past the threshold
+ and the shredding came straight back, in a commit about something else. */
+@container (max-width: 360px) {
+ .cx-settings-row { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
+ .cx-settings-control { flex: 0 0 auto; justify-content: flex-start; }
+}
+
+
+.cx-group { display: flex; flex-direction: column; gap: var(--sp-3); }
+.cx-group-rows {
+ border: var(--bw) solid var(--line); border-radius: var(--r-4);
+ background: var(--elev-1);
+ /* Weighted to the left. A row's content -- a checkbox, a label, a stepper --
+ starts at the same edge every time, and sitting it hard against the border
+ made the whole card read as leaning left, because what is beside it on the
+ right is usually the shorter half of a wrapped line rather than a control.
+ A few pixels of extra lead-in settles it without costing the text a line. */
+ padding-inline: var(--sp-6) var(--sp-4);
+ container-type: inline-size;
+}
+
+.cx-panel {
+ border: var(--bw) solid var(--line); border-radius: var(--r-4);
+ background: var(--elev-1); overflow: hidden;
+ box-shadow: var(--shadow-1);
+ /* A zone is as wide as it is given, never as wide as its head wants to be. */
+ min-inline-size: 0;
+ /* A column, so a panel given a height passes it to its body. Without this a
+ viewer asking for 100% of its parent got 100% of `auto` and collapsed to
+ one line, inside a panel that was the right size around it. */
+ display: flex; flex-direction: column; min-block-size: 0;
+}
+/* Every panel head is the SAME height, whatever is in it. Padding-block plus a
+ min-height meant a head holding a control was 41px and a head holding only a
+ title was 34, so two panels side by side had their titles on different lines
+ -- the whole row read as if it had been assembled by hand. The height is the
+ band's; a control inside is centred in it. */
+/* A zone is the app's own surface, not a card on it: no corners to round, no
+ shadow to cast, and no border of its own -- the hairline between it and the
+ next region belongs to the region, so two neighbours never draw two lines. */
+.cx-zone { border: 0; border-radius: 0; box-shadow: none; }
+
+.cx-panel-head {
+ display: flex; align-items: center; gap: var(--sp-2) var(--sp-5);
+ padding: var(--sp-1) var(--sp-6); border-bottom: var(--bw) solid var(--line);
+ background: var(--grad-band);
+ /* A MINIMUM height, and it wraps. Fixed, it could not shrink below the width
+ of everything in it -- and a head now holds a title, two or three controls
+ and a status. That min-content width became the whole centre column's, so a
+ 768px window laid out 878px wide and scrolled sideways. v4 hit this and
+ landed in the same place: the right-hand block wraps, the head grows a
+ line, and nothing is ever clipped. */
+ min-block-size: var(--menubar-h); flex: none; flex-wrap: wrap;
+ min-inline-size: 0;
+}
+.cx-panel-title {
+ font-family: var(--font-display); font-size: var(--fs-3); font-weight: var(--fw-semibold);
+ display: flex; align-items: center; gap: var(--sp-3);
+ min-inline-size: 0; white-space: nowrap;
+}
+/* One accent mark per panel. Carried over from v4, where it is what makes a
+ head read as the top of something rather than as a strip of grey. */
+.cx-panel-title::before {
+ content: ""; flex: none; inline-size: 5px; block-size: 5px;
+ border-radius: var(--r-pill); background: var(--accent);
+}
+/* Wraps, like the head around it. The head was made to wrap when a zone got
+ narrow, but the group of controls inside it was still one unbreakable row --
+ so the head's min-content was the whole row, and a 768px window laid out
+ 860px wide and scrolled sideways. */
+.cx-panel-actions { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; min-inline-size: 0; }
+/* Whatever the zone has to say, at the far end of its own head: a count, a
+ duration, where a run has got to. Quiet by default -- it is read at a glance
+ and never pressed. */
+.cx-panel-status {
+ margin-inline-start: auto; display: flex; align-items: center; gap: var(--sp-4);
+ min-inline-size: 0; font-size: var(--fs-6); color: var(--faint);
+}
+/* A column with a gap, not a block: two things put in a body by two different
+ parts of the app had whatever space their own line boxes happened to leave,
+ which is how a prompt box ended up 8px under one label and 3px under the
+ next. The body decides the rhythm; what goes in it brings nothing. */
+.cx-panel-body {
+ padding: var(--sp-6); flex: 1 1 auto; min-block-size: 0; overflow: auto;
+ /* Both axes. A body is a scroller, and a scroller that cannot shrink below
+ its content is not one -- it hands the width up to the zone, the column and
+ the window, which then scrolls sideways as a page. */
+ min-inline-size: 0;
+ display: flex; flex-direction: column; gap: var(--sp-5);
+}
+/* One child fills the body -- a viewer, a bin. Several are each their own
+ height and the body scrolls, which is what a panel of controls does. */
+.cx-panel-body > .cx-viewer, .cx-panel-body > .cx-stack-fill { flex: 1 1 auto; }
+.cx-panel-flush > .cx-panel-body { padding: 0; }
+
+.cx-toolbar { display: flex; align-items: center; gap: var(--sp-2); min-inline-size: 0; }
+.cx-toolbar-trailing {
+ margin-inline-start: auto; display: flex; align-items: center; gap: var(--sp-3);
+ font-size: var(--fs-6); color: var(--muted);
+}
+
+.cx-action-bar {
+ display: flex; align-items: center; gap: var(--sp-6);
+ padding: var(--sp-5) var(--gutter);
+ border-top: var(--bw) solid var(--line); background: var(--grad-band);
+ position: sticky; bottom: 0;
+}
+/* What the app is SAYING sits at the start of the row and what is PRESSED at
+ the end. Everything shared one right-aligned group before, so "Ready" sat
+ against the Generate button with 1300px of empty bar to its left. */
+.cx-action-bar-lead { display: flex; align-items: center; gap: var(--sp-5); min-inline-size: 0; }
+.cx-action-bar-buttons { display: flex; align-items: center; gap: var(--sp-3); margin-inline-start: auto; }
+
+.cx-list-wrap { display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; }
+.cx-list {
+ width: 100%; border: var(--bw) solid var(--line); border-radius: var(--r-3);
+ background: var(--elev-1); overflow: hidden;
+}
+.cx-list-row {
+ display: flex; align-items: center; gap: var(--sp-4);
+ padding: var(--sp-3) var(--sp-5); border-bottom: var(--bw) solid var(--line-2);
+}
+.cx-list-row:last-child { border-bottom: 0; }
+.cx-list-label { font-size: var(--fs-4); flex: 1; min-width: 0; }
+.cx-list-hint { font-size: var(--fs-7); color: var(--muted); }
+.cx-list-control { flex: none; width: 92px; }
+/* Separated from the row's own control by a rule, so the arrows read as
+ * "reorder this row" rather than "adjust that number". */
+.cx-list-actions {
+ display: flex; gap: var(--sp-1); flex: none;
+ padding-left: var(--sp-4); margin-left: var(--sp-2);
+ border-left: var(--bw) solid var(--line-2);
+}
+.cx-list-move { color: var(--faint); }
+.cx-list-move:hover:not(:disabled) { color: var(--accent); }
+.cx-list-empty { padding: var(--sp-6); font-size: var(--fs-6); color: var(--faint); text-align: center; }
+
+.cx-tabs { display: flex; gap: var(--sp-1); }
+.cx-tab {
+ border: 0; background: transparent; color: var(--muted);
+ cursor: pointer; font-family: var(--font-ui); font-size: var(--fs-5);
+ transition: color var(--dur-1) var(--ease-standard);
+}
+.cx-tabs-underline { border-bottom: var(--bw) solid var(--line); gap: var(--sp-5); }
+.cx-tabs-underline .cx-tab {
+ padding: var(--sp-3) 0; border-bottom: 2px solid transparent; margin-bottom: -1px;
+}
+.cx-tabs-underline .cx-tab.cx-on { color: var(--text); border-bottom-color: var(--accent); }
+.cx-tabs-dock .cx-tab {
+ height: var(--ctl-h-md); padding: 0 var(--ctl-pad-x-md);
+ border: var(--bw) solid var(--line); border-radius: var(--r-3);
+ font-size: var(--fs-6);
+}
+.cx-tabs-dock .cx-tab.cx-on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
+.cx-tab:hover { color: var(--text); }
+
+.cx-split { display: flex; width: 100%; min-height: 120px; min-inline-size: 0; }
+.cx-split-v { flex-direction: column; }
+/* A column, so what is inside a pane inherits the pane's height instead of
+ sizing to its own content. A panel in a pane was the right size with a body
+ one line tall inside it, which looks like the panel is fine and the content
+ is empty. */
+.cx-split-pane {
+ flex: 1 1 0; min-inline-size: 0; min-block-size: 0; overflow: auto;
+ display: flex; flex-direction: column;
+}
+.cx-split-pane > * { flex: 1 1 auto; min-block-size: 0; }
+.cx-split > .cx-split-pane:first-child { flex: 0 0 auto; }
+
+/* A horizontal split stacks rather than overflowing when there is not room for
+ two columns. The first pane carries an inline size, so on a narrow screen it
+ could not shrink and the whole page scrolled sideways -- 407px of content in
+ a 360px viewport. Wrapping rather than a media query because the kit sizes
+ itself from its container, not from the window. */
+.cx-split-h { flex-wrap: wrap; }
+.cx-split-h > .cx-split-pane { min-inline-size: min(100%, 260px); }
+.cx-split-h > .cx-split-pane:first-child { max-inline-size: 100%; }
+.cx-split-bar { flex: none; background: transparent; position: relative; }
+.cx-split-h > .cx-split-bar { width: var(--gutter); cursor: col-resize; }
+.cx-split-v > .cx-split-bar { height: var(--gutter); cursor: row-resize; }
+.cx-split-bar::after {
+ content: ""; position: absolute; inset: 0; margin: auto;
+ background: var(--line); transition: background var(--dur-1) var(--ease-standard);
+}
+.cx-split-h > .cx-split-bar::after { width: 1px; }
+.cx-split-v > .cx-split-bar::after { height: 1px; }
+.cx-split-bar:hover::after, .cx-split-bar:focus-visible::after { background: var(--accent); }
+.cx-split-bar:focus-visible { outline: none; }
+
+/* The same radius as a panel: these stand side by side in the properties
+ column, and two different corner radii on two boxes in one column reads as
+ two different kinds of thing. */
+.cx-collapsible { border: var(--bw) solid var(--line); border-radius: var(--r-4); background: var(--elev-1); }
+.cx-collapsible-head {
+ display: flex; align-items: center; gap: var(--sp-3);
+ padding: 0 var(--sp-6); block-size: var(--menubar-h); cursor: pointer; list-style: none;
+ font-size: var(--fs-5); background: var(--grad-band);
+ border-radius: calc(var(--r-4) - var(--bw));
+}
+.cx-collapsible[open] > .cx-collapsible-head {
+ border-end-start-radius: 0; border-end-end-radius: 0;
+ border-block-end: var(--bw) solid var(--line);
+}
+.cx-collapsible-head::-webkit-details-marker { display: none; }
+.cx-collapsible-head::before {
+ content: "›"; color: var(--faint); display: inline-block;
+ transition: transform var(--dur-1) var(--ease-standard);
+}
+.cx-collapsible[open] > .cx-collapsible-head::before { transform: rotate(90deg); }
+/* Its own top padding, not the head's. With none, the first thing in the body
+ -- usually a section label -- sat directly on the head's dividing line, which
+ read as the label belonging to the divider rather than to what is under it.
+ Narrower at the sides than at the top: this is the third box a setting's text
+ is nested inside, and each level of padding comes out of the words. */
+.cx-collapsible-body { padding: var(--sp-6) var(--sp-4) var(--sp-5); }
+
+.cx-rail { display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-2); }
+.cx-rail-item {
+ display: flex; align-items: center; gap: var(--sp-4);
+ height: var(--ctl-h-xl); padding: 0 var(--sp-4);
+ border: 0; border-radius: var(--r-3); background: transparent;
+ color: var(--muted); font-size: var(--fs-5); cursor: pointer; text-align: left;
+}
+.cx-rail-item:hover { background: var(--hover); color: var(--text); }
+.cx-rail-item.cx-on { background: var(--accent-soft); color: var(--accent-hi); }
+.cx-rail-icon { width: 18px; text-align: center; }
+
+/* Workspace: a centre that shrinks, panels docked either side.
+ The rails hold the toggles and never collapse, so the control for a panel is
+ never inside the panel it controls. */
+.cx-workspace {
+ display: flex; align-items: stretch; min-block-size: 0; block-size: 100%; inline-size: 100%;
+}
+/* Flex containers, not blocks with overflow. A region whose height comes from
+ its content gives a vertical split inside it a basis of `auto`, so the split's
+ second pane resolves to zero and simply is not there -- the prompt under the
+ preview vanished exactly that way, with no error and nothing to see. */
+.cx-workspace-main {
+ flex: 1 1 auto; min-inline-size: 0; min-block-size: 0;
+ display: flex; flex-direction: column;
+ background: var(--elev-1);
+}
+.cx-workspace-main > *, .cx-workspace-side > * { flex: 1 1 auto; min-block-size: 0; }
+.cx-workspace-side {
+ /* A width the CONTENT can live in, not a share of the window. Sizing these
+ as a fraction of the viewport gave a 900px-wide window a 198px column, in
+ which "Loosen the starting image" wrapped over three lines and a section
+ heading broke mid-word. A panel is as wide as a settings row needs; when
+ the window cannot afford two of them it stops docking them (below). */
+ flex: 0 0 auto;
+ inline-size: clamp(268px, 24vw, 360px);
+ min-block-size: 0;
+ overflow: auto;
+ display: flex; flex-direction: column;
+ background: var(--elev-1);
+ /* The divider between one region and the next. One hairline, the same one a
+ splitter draws, so the app reads as a single surface that has been cut up
+ rather than as three things put next to each other. */
+ border-inline: var(--bw) solid var(--line);
+}
+/* Width nil rather than display:none -- the panel stays in the flow, stays
+ measurable, and can come back without being rebuilt.
+
+ Deliberately not animated. A remembered-closed panel would animate shut on
+ every page load, which reads as the app doing something rather than as a
+ remembered preference, and an in-flight width is a state anything measuring
+ the layout has to wait out. */
+.cx-workspace-side.cx-collapsed {
+ inline-size: 0;
+ overflow: hidden;
+ border-inline-width: 0;
+}
+/* The toggle sits in the head band's height, so it lines up with the panel
+ titles either side of it instead of floating a few pixels above them like a
+ mark left on the glass. */
+/* The rails are the frame's own edge, so they carry the page colour rather than
+ a zone's -- the one strip of background the app shows. */
+.cx-workspace-rail {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ background: var(--elev-0);
+ padding-inline: var(--sp-1);
+}
+.cx-workspace-rail > * { block-size: var(--menubar-h); }
+
+/* The page frame. The one place a definite height is established, so everything
+ below can fill instead of each pane guessing at a percentage of nothing. */
+.cx-frame { display: flex; flex-direction: column; block-size: 100%; min-block-size: 0; }
+/* Film grain over the app, and only over the app: no z-index, so anything that
+ opens above the frame -- a modal, a popover -- paints over it and stays
+ crisp. Fixed, so it does not scroll and cannot be captured by a scroller's
+ own stacking context. */
+.cx-frame::after {
+ content: ""; position: fixed; inset: 0; pointer-events: none;
+ opacity: var(--grain-opacity); mix-blend-mode: var(--grain-blend);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+}
+.cx-frame-main { flex: 1 1 auto; min-block-size: 0; min-inline-size: 0; display: flex; }
+.cx-frame-main > * { flex: 1 1 auto; min-inline-size: 0; }
+.cx-frame-head, .cx-frame-foot { flex: 0 0 auto; }
+/* Bands: edge to edge, the same height as a panel head, with the gutter inside
+ them. So the top bar reads as part of the frame rather than as a stray
+ button floating above the first panel. */
+.cx-frame-head {
+ display: flex; align-items: center;
+ block-size: var(--menubar-h); padding-inline: var(--gutter);
+ border-block-end: var(--bw) solid var(--line); background: var(--grad-band);
+}
+.cx-frame-head > * { flex: 1 1 auto; min-inline-size: 0; }
+
+/* Not enough room to dock. Below this the two panels plus a usable centre do
+ not fit -- three columns in 375px gave clipped headings and a preview no
+ wider than a thumbnail -- so a panel stops taking space from the centre and
+ sits over it instead. The rails do not move, so the way in and out is the
+ same control in the same place at every size.
+
+ The element closes both panels when it enters this mode and opens only one
+ at a time within it, because two overlays over a narrow centre is the same
+ problem with extra steps. What was open before is restored on the way out,
+ and none of that touches the remembered preference -- a window being small
+ is not the user changing their mind.
+
+ This is also what makes a phone possible later rather than a rewrite: the
+ difference between docked and overlaid is where the panel is drawn, not what
+ it is or how it is opened. */
+@media (max-width: 760px) {
+ .cx-workspace { position: relative; }
+ .cx-workspace-side {
+ position: absolute;
+ inset-block: 0;
+ z-index: 1;
+ inline-size: min(86vw, 360px);
+ background: var(--elev-1);
+ box-shadow: var(--shadow-3);
+ }
+ .cx-workspace-left { inset-inline-start: 0; }
+ .cx-workspace-right { inset-inline-end: 0; }
+ /* Above the overlay, or the panel covers the only control that closes it --
+ which is the whole fault this arrangement was built to avoid, arriving by a
+ different route the moment the panel stopped taking its own space. */
+ .cx-workspace-rail { position: relative; z-index: 2; }
+ /* A collapsed overlay must not keep catching pointers over the centre. */
+ .cx-workspace-side.cx-collapsed { inline-size: 0; box-shadow: none; }
+}
+
+/* region.stack -- the app's redrawable host. Spacing only: what goes in it
+ brings its own everything else. */
+/* min-inline-size on the stack ITSELF, not only on what is in it. A flex item's
+ automatic minimum is its content, so a stack holding anything that scrolls --
+ a strip of scenes, a wide table -- handed its content's full width up to
+ whatever contains it, and the page scrolled sideways instead of the scroller
+ scrolling. The rule below covers a stack's children; this covers the stack.
+ No min-block-size:0 here, on purpose: that would let a plain grouping stack
+ -- rows of fields nested inside a scrollable panel -- get flex-shrunk below
+ its own content height the moment its container runs short on room, and
+ since nothing here clips it, the excess paints over the next row instead of
+ the panel scrolling. .cx-stack-fill below is the one stack meant to give up
+ height for that; a stack that hasn't opted into that must keep its natural
+ size so a tight column scrolls at the real scroll boundary instead of
+ silently overlapping. */
+.cx-stack { display: flex; flex-direction: column; min-inline-size: 0; }
+.cx-stack > * { min-inline-size: 0; }
+.cx-stack-none { gap: 0; }
+.cx-stack-sm { gap: var(--sp-4); }
+.cx-stack-md { gap: var(--sp-6); }
+.cx-stack-lg { gap: var(--sp-9); }
+
+/* Filling the room it was given, rather than sizing to its content. Without a
+ definite height nothing inside can scroll: the whole page scrolls instead,
+ and a bar meant to hold the bottom scrolls away with it. */
+.cx-stack-fill { flex: 1 1 auto; min-block-size: 0; }
+/* A split inside a stack is what stretches. The rows around it -- a toolbar, a
+ banner, an action bar -- are each their own height. */
+.cx-stack > .cx-split { flex: 1 1 auto; min-block-size: 0; }
+
+/* A stack that is a small poster rather than a form -- an "About" pane's
+ mark, name and facts, centred instead of ranged left like every settings
+ list around it. */
+.cx-about { align-items: center; text-align: center; padding-block: var(--sp-7) 0; }
+/* v4's own mark size, from the scale (--fs-d1) rather than a literal --
+ the whole point of naming display steps at all. */
+.cx-about > .cx-t-lg { font-size: var(--fs-d1); line-height: var(--lh-tight); color: var(--accent); }
+.cx-about .cx-settings-row {
+ justify-content: center; gap: var(--sp-4); border-bottom: 0; padding-block: var(--sp-2);
+ inline-size: auto; align-self: center;
+}
+.cx-about .cx-settings-control { flex: 0 0 auto; justify-content: flex-start; }
+/* The fact key ("Chip", "Memory", ...) reads as a label, not a value. */
+.cx-about .cx-settings-label { font-weight: var(--fw-semibold); }
diff --git a/app/ui/composer/elements/layout.js b/app/ui/composer/elements/layout.js
new file mode 100644
index 00000000..81be0222
--- /dev/null
+++ b/app/ui/composer/elements/layout.js
@@ -0,0 +1,495 @@
+// Containers.
+//
+// These take other elements' HANDLES, never raw nodes: a handle is something the
+// kit made, and insisting on one is what stops "just let me pass a div" becoming
+// the hole through which modules build their own markup.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { setText } from "../internals/text.js";
+import { uid } from "../internals/ids.js";
+import { drag } from "../internals/drag.js";
+import { roving } from "../internals/focus.js";
+
+function nodeOf(control, where) {
+ if (!control) return null;
+ if (control.node && control.node.nodeType === 1) return control.node;
+ throw new TypeError(
+ `${where} takes a Composer element handle, not a ${control.nodeType === 1 ? "raw DOM node" : typeof control}. ` +
+ "Build the control with composer..() and pass what it returns."
+ );
+}
+
+/** Label above a control, with an optional hint and error beneath. */
+define("field", "default", ({ label, hint, control, error } = {}) => {
+ const id = uid("field");
+ const hintId = hint ? uid("hint") : null;
+ const inner = nodeOf(control, "field.default");
+ if (inner && !inner.id) inner.id = id;
+ if (inner && hintId) inner.setAttribute("aria-describedby", hintId);
+
+ const node = el("div", { cls: "cx-field", children: [
+ label ? el("label", { cls: "cx-label", text: label, attrs: { for: inner ? inner.id : undefined } }) : null,
+ inner,
+ hint ? el("p", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null,
+ error ? el("p", { cls: "cx-field-error", text: error, attrs: { role: "alert" } }) : null,
+ ].filter(Boolean) });
+
+ return { node, control, destroy: () => node.remove() };
+});
+
+define("field", "row", ({ fields = [] } = {}) =>
+ ({ node: el("div", { cls: "cx-field-row", children: fields.map((f) => nodeOf(f, "field.row")) }),
+ destroy() { this.node.remove(); } }));
+
+/** Title and hint on the left, control on the right. The settings-window row. */
+define("settingsRow", "default", ({ label, hint, control, danger } = {}) => {
+ const inner = nodeOf(control, "settingsRow.default");
+ const hintId = hint ? uid("hint") : null;
+ if (inner && hintId) inner.setAttribute("aria-describedby", hintId);
+
+ const node = el("div", { cls: ["cx-settings-row", danger ? "cx-danger" : null], children: [
+ el("div", { cls: "cx-settings-text", children: [
+ el("div", { cls: "cx-settings-label", text: label }),
+ hint ? el("p", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null,
+ ].filter(Boolean) }),
+ inner ? el("div", { cls: "cx-settings-control", children: inner }) : null,
+ ].filter(Boolean) });
+
+ return { node, destroy: () => node.remove() };
+});
+
+define("group", "default", ({ label, rows = [], hint } = {}) => {
+ const node = el("section", { cls: "cx-group", children: [
+ label ? el("div", { cls: "cx-eyebrow", text: label }) : null,
+ hint ? el("p", { cls: "cx-hint", text: hint }) : null,
+ el("div", { cls: "cx-group-rows", children: rows.map((r) => nodeOf(r, "group.default")) }),
+ ].filter(Boolean) });
+ return { node, destroy: () => node.remove() };
+});
+
+/**
+ * A titled box. Two of them, and the difference is only where they are used.
+ *
+ * `default` is a card: rounded, bordered, with a shadow, for a panel that
+ * floats on something -- inside a window, on a page.
+ *
+ * `zone` is a REGION OF THE APP: square, edge to edge, divided from its
+ * neighbours by the same hairline everything else uses. The app is one surface
+ * cut into areas, not a tray of cards with the desk showing between them --
+ * which is what an editing suite looks like, and what v4 was.
+ */
+const panel = (variant) => ({ title, actions = [], status = [], body, flush = false } = {}) => {
+ // Title, then what ACTS on this zone, then -- pushed to the far end -- what
+ // this zone has to SAY about itself. That order is the whole difference
+ // between an editing suite and a dashboard: the controls for a region live
+ // in that region's own head, next to the thing they change, instead of in one
+ // bar at the bottom that acts on whatever happens to be in front.
+ const head = (title || actions.length || status.length)
+ ? el("header", { cls: "cx-panel-head", children: [
+ title ? el("h2", { cls: "cx-panel-title", text: title }) : null,
+ actions.length ? el("div", { cls: "cx-panel-actions", children: actions.map((a) => nodeOf(a, `panel.${variant}`)) }) : null,
+ status.length ? el("div", { cls: "cx-panel-status", children: status.map((a) => nodeOf(a, `panel.${variant} status`)) }) : null,
+ ].filter(Boolean) })
+ : null;
+ const title_ = head && head.querySelector(".cx-panel-title");
+ const content = el("div", { cls: "cx-panel-body", children: body ? nodeOf(body, `panel.${variant}`) : null });
+ // `flush` is for a body that IS the content -- a picture, a video, a canvas.
+ // Padding around one of those is a frame nobody asked for, and the thing
+ // being looked at is the reason the zone exists.
+ const node = el("section", { cls: ["cx-panel", variant === "zone" ? "cx-zone" : null,
+ flush ? "cx-panel-flush" : null],
+ children: [head, content].filter(Boolean) });
+ return {
+ node, body: content,
+ /** A zone that is ABOUT something says which: "Scene · 2", not "Scene". */
+ setTitle(next) { if (title_) setText(title_, next); },
+ destroy: () => node.remove(),
+ };
+};
+
+define("panel", "default", panel("default"));
+define("panel", "zone", panel("zone"));
+
+// A region whose contents are replaced.
+//
+// Every part of the app that redraws needs one host it can empty and refill.
+// Without it the shell reaches for the document to make a div of its own, and a
+// div the shell made is a div the shell has to lay out -- which is the one thing
+// that is supposed to live only here.
+define("region", "stack", ({ children = [], gap = "md", label, fill = false } = {}) => {
+ const node = el("div", { cls: ["cx-stack", `cx-stack-${gap}`, fill ? "cx-stack-fill" : null],
+ attrs: { "aria-label": label } });
+ const set = (next = []) => {
+ // Handles, not nodes, like everywhere else -- so a caller cannot slip a
+ // document node in through the one element that takes children late.
+ node.replaceChildren(...next.filter(Boolean).map((c) => nodeOf(c, "region.stack")));
+ };
+ set(children);
+ return { node, set, destroy: () => node.remove() };
+});
+
+/** A bar has a start and an end: what you press, and what it tells you. */
+define("toolbar", "default", ({ items = [], trailing = [], label } = {}) =>
+ ({ node: el("div", { cls: "cx-toolbar", attrs: { role: "toolbar", "aria-label": label },
+ children: [
+ ...items.map((i) => nodeOf(i, "toolbar.default")),
+ trailing.length
+ ? el("div", { cls: "cx-toolbar-trailing",
+ children: trailing.map((i) => nodeOf(i, "toolbar.default trailing")) })
+ : null,
+ ].filter(Boolean) }),
+ destroy() { this.node.remove(); } }));
+
+/**
+ * The row a thing is started from: what is being SAID at the start of it, what
+ * is PRESSED at the end.
+ *
+ * `lead` exists because everything used to go in `actions`, which is
+ * right-aligned as a group -- so a status line sat against the button with the
+ * whole width of the bar empty to its left, and read as part of the button.
+ */
+define("actionBar", "sticky", ({ lead = [], actions = [], note } = {}) =>
+ ({ node: el("div", { cls: "cx-action-bar", children: [
+ el("div", { cls: "cx-action-bar-lead", children: [
+ note ? el("p", { cls: "cx-hint", text: note }) : null,
+ ...lead.map((a) => nodeOf(a, "actionBar.sticky lead")),
+ ].filter(Boolean) }),
+ el("div", { cls: "cx-action-bar-buttons", children: actions.map((a) => nodeOf(a, "actionBar.sticky")) }),
+ ] }),
+ destroy() { this.node.remove(); } }));
+
+/** A reorderable list of rows: LoRA stacks, text encoders, anything repeatable. */
+define("list", "rows", ({ items = [], reorder = false, onRemove, onAdd, onReorder, addLabel = "Add", empty = "Nothing here yet" } = {}) => {
+ const body = el("div", { cls: "cx-list" });
+
+ function draw() {
+ body.replaceChildren();
+ if (!items.length) {
+ body.append(el("p", { cls: "cx-list-empty", text: empty }));
+ }
+ items.forEach((item, index) => {
+ const controls = [];
+ if (reorder) {
+ // "Move up/down", not bare arrows: next to a numeric control, an
+ // unlabelled ▲▼ pair reads as increment/decrement.
+ controls.push(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-list-move", "cx-focusable"], text: "▲",
+ attrs: { type: "button", title: `Move ${item.label} up`,
+ "aria-label": `Move ${item.label} up`, disabled: index === 0 },
+ on: { click: () => move(index, index - 1) } }));
+ controls.push(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-list-move", "cx-focusable"], text: "▼",
+ attrs: { type: "button", title: `Move ${item.label} down`,
+ "aria-label": `Move ${item.label} down`, disabled: index === items.length - 1 },
+ on: { click: () => move(index, index + 1) } }));
+ }
+ if (onRemove) {
+ controls.push(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"], text: "✕",
+ attrs: { type: "button", "aria-label": `Remove ${item.label}` },
+ on: { click: () => onRemove(item, index) } }));
+ }
+ // A row's control is a real element -- a LoRA's weight is edited here,
+ // not implied by a number printed beside the reorder arrows, which reads
+ // as a stepper for that number and is not one.
+ const control = item.control ? nodeOf(item.control, "list.rows item.control") : null;
+
+ body.append(el("div", { cls: "cx-list-row", children: [
+ el("span", { cls: "cx-list-label", text: item.label }),
+ item.hint ? el("span", { cls: "cx-list-hint", text: item.hint }) : null,
+ control ? el("div", { cls: "cx-list-control", children: control }) : null,
+ controls.length ? el("div", { cls: "cx-list-actions", children: controls }) : null,
+ ].filter(Boolean) }));
+ });
+ }
+
+ function move(from, to) {
+ if (to < 0 || to >= items.length) return;
+ const [moved] = items.splice(from, 1);
+ items.splice(to, 0, moved);
+ draw();
+ if (onReorder) onReorder([...items]);
+ }
+
+ draw();
+
+ const foot = onAdd
+ ? el("button", { cls: ["cx-btn", "cx-btn-sm", "cx-btn-ghost", "cx-focusable"], text: `+ ${addLabel}`,
+ attrs: { type: "button" }, on: { click: onAdd } })
+ : null;
+
+ const node = el("div", { cls: "cx-list-wrap", children: [body, foot].filter(Boolean) });
+ return { node, redraw: draw, destroy: () => node.remove() };
+});
+
+for (const kind of ["underline", "dock"]) {
+ define("tabs", kind, ({ tabs = [], value, onChange, label } = {}) => {
+ let current = value ?? tabs[0]?.value;
+ const node = el("div", { cls: ["cx-tabs", `cx-tabs-${kind}`], attrs: { role: "tablist", "aria-label": label } });
+
+ const buttons = tabs.map((tab) => {
+ const b = el("button", { cls: ["cx-tab", "cx-focusable"], text: tab.label,
+ attrs: { type: "button", role: "tab", "aria-selected": String(tab.value === current) } });
+ b.classList.toggle("cx-on", tab.value === current);
+ b.addEventListener("click", () => { current = tab.value; sync(); if (onChange) onChange(current); });
+ node.append(b);
+ return { tab, b };
+ });
+
+ function sync() {
+ for (const { tab, b } of buttons) {
+ const on = tab.value === current;
+ b.classList.toggle("cx-on", on);
+ b.setAttribute("aria-selected", String(on));
+ }
+ }
+
+ const stopRoving = roving(node);
+ return {
+ node,
+ get value() { return current; },
+ setValue(v) { current = v; sync(); },
+ destroy() { stopRoving(); node.remove(); },
+ };
+ });
+}
+
+for (const axis of ["h", "v"]) {
+ define("splitPane", axis, ({ panes = [], size = 50, min = 10, onResize, label } = {}) => {
+ const horizontal = axis === "h";
+ const node = el("div", { cls: ["cx-split", `cx-split-${axis}`] });
+ const first = el("div", { cls: "cx-split-pane", children: nodeOf(panes[0], "splitPane") });
+ const second = el("div", { cls: "cx-split-pane", children: nodeOf(panes[1], "splitPane") });
+ const bar = el("div", { cls: "cx-split-bar",
+ attrs: { role: "separator", tabindex: "0", "aria-label": label || "Resize",
+ "aria-orientation": horizontal ? "vertical" : "horizontal",
+ "aria-valuenow": String(size), "aria-valuemin": String(min), "aria-valuemax": String(100 - min) } });
+
+ let current = size;
+ const apply = (value) => {
+ current = Math.min(100 - min, Math.max(min, value));
+ first.style.flexBasis = `${current}%`;
+ bar.setAttribute("aria-valuenow", String(Math.round(current)));
+ if (onResize) onResize(current);
+ };
+ apply(size);
+
+ let start = 0;
+ const dispose = drag(bar, {
+ onStart: () => { start = current; },
+ onMove: ({ dx, dy }) => {
+ const box = node.getBoundingClientRect();
+ const span = horizontal ? box.width : box.height;
+ if (!span) return;
+ apply(start + ((horizontal ? dx : dy) / span) * 100);
+ },
+ });
+
+ // A splitter that only responds to a mouse is unusable without one.
+ bar.addEventListener("keydown", (e) => {
+ const step = e.shiftKey ? 10 : 2;
+ if (e.key === (horizontal ? "ArrowLeft" : "ArrowUp")) { e.preventDefault(); apply(current - step); }
+ if (e.key === (horizontal ? "ArrowRight" : "ArrowDown")) { e.preventDefault(); apply(current + step); }
+ });
+
+ node.append(first, bar, second);
+ return {
+ node,
+ get value() { return current; },
+ setValue: apply,
+ destroy() { dispose(); node.remove(); },
+ };
+ });
+}
+
+// The page: one region that fills whatever is left, and a bar that does not.
+//
+// Height has to be established SOMEWHERE, and the honest place is one element
+// rather than a percentage on each pane hoping its parent is definite. Without
+// it a vertical split resolves its basis against content, and the app renders
+// as a short strip at the top of an empty window -- which is exactly what the
+// first build of this frame did.
+define("frame", "app", ({ header, main, footer } = {}) => {
+ const head = header
+ ? el("div", { cls: "cx-frame-head", children: nodeOf(header, "frame.app") })
+ : null;
+ const body = el("div", { cls: "cx-frame-main",
+ children: main ? nodeOf(main, "frame.app") : null });
+ const foot = footer
+ ? el("div", { cls: "cx-frame-foot", children: nodeOf(footer, "frame.app") })
+ : null;
+ const node = el("div", { cls: "cx-frame", children: [head, body, foot].filter(Boolean) });
+ return { node, header: head, main: body, footer: foot, destroy: () => node.remove() };
+});
+
+// The app's own shape: a centre that shrinks, with panels docked either side.
+//
+// The toggles live in RAILS, outside the panels they open. v4 put the control
+// inside the region it controlled and then hid the region -- a collapsed column
+// was marked [hidden], which is display:none, and no transform slides a
+// display:none element into view, so the button did nothing at all and said
+// nothing. A control that can be hidden by the thing it operates is not a
+// control, so here the rail is a sibling of the panel and never moves.
+define("workspace", "docked", ({
+ id = "workspace", centre, left, right,
+ leftLabel = "Assets", rightLabel = "Properties",
+ leftOpen = true, rightOpen = true, onToggle, rails = true,
+} = {}) => {
+ const node = el("div", { cls: "cx-workspace" });
+
+ // window.localStorage, not the bare global: they are the same object in a
+ // browser, and only the explicit one exists under jsdom -- so persistence is
+ // testable where the tests are cheap instead of only in the browser pass.
+ const remember = (side, open) => {
+ try { window.localStorage.setItem(`cx.ws.${id}.${side}`, open ? "1" : "0"); } catch { /* private mode */ }
+ };
+ const recall = (side, fallback) => {
+ try {
+ const v = window.localStorage.getItem(`cx.ws.${id}.${side}`);
+ return v === null ? fallback : v === "1";
+ } catch { return fallback; }
+ };
+
+ const state = { left: recall("left", leftOpen), right: recall("right", rightOpen) };
+ // What the user last chose, kept apart from what is currently on screen.
+ //
+ // `state` is mutated by the one-at-a-time rule below, so it stops being a
+ // record of anyone's preference the moment a window is narrow. Snapshotting
+ // it there recorded a panel the RULE had just closed as a panel the user
+ // wanted closed -- so a page loaded at a narrow width came back from being
+ // widened with the right panel shut, and nothing the user did would explain
+ // why.
+ const wanted = { ...state };
+ const panels = {};
+ const toggles = {};
+
+ // Below this the panels overlay the centre instead of docking beside it, so
+ // only one may be open and neither starts open. The CSS decides how a panel
+ // is drawn; this decides what is open, and the two have to agree on the
+ // width, which is why it is written once here and matched there.
+ const NARROW = "(max-width: 760px)";
+ const narrow = typeof window.matchMedia === "function" ? window.matchMedia(NARROW) : null;
+ const isNarrow = () => Boolean(narrow && narrow.matches);
+ let wide = null; // what was open before the window shrank
+
+ const side = (which, label, content) => {
+ const panelId = uid(`ws-${which}`);
+ const panel = el("div", { cls: ["cx-workspace-side", `cx-workspace-${which}`],
+ attrs: { id: panelId, "aria-label": label },
+ children: content ? nodeOf(content, "workspace.docked") : null });
+
+ const button = el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"],
+ text: which === "left" ? "▎" : "▐",
+ attrs: { type: "button", "aria-controls": panelId, "aria-label": label,
+ "aria-expanded": String(state[which]) },
+ on: { click: () => set(which, !state[which]) } });
+
+ // The rail is one glyph on the outer edge of the window. It is the fallback,
+ // not the design: where the app has somewhere to put a NAMED toggle, it
+ // passes rails:false and drives open/close through this handle instead --
+ // "▎" on an edge is a control whose meaning has to be discovered.
+ const rail = rails
+ ? el("div", { cls: ["cx-workspace-rail", `cx-workspace-rail-${which}`], children: button })
+ : null;
+
+ panels[which] = panel;
+ toggles[which] = button;
+ return { panel, rail };
+ };
+
+ function set(which, open, { remember: keep = true } = {}) {
+ state[which] = Boolean(open);
+ // One at a time while overlaid: two panels over a narrow centre is the same
+ // problem the docking rule was avoiding, with an extra step.
+ if (state[which] && isNarrow()) {
+ const other = which === "left" ? "right" : "left";
+ if (state[other]) set(other, false, { remember: false });
+ }
+ panels[which].classList.toggle("cx-collapsed", !state[which]);
+ // Not [hidden] and not display:none: a panel with no width is out of the
+ // way and still animatable, and its toggle is in the rail either way.
+ panels[which].setAttribute("aria-hidden", String(!state[which]));
+ toggles[which].setAttribute("aria-expanded", String(state[which]));
+ // A window being small is not the user changing their mind, so an automatic
+ // close does not overwrite what they last chose -- in storage or here.
+ if (keep) {
+ wanted[which] = state[which];
+ remember(which, state[which]);
+ }
+ if (onToggle) onToggle(which, state[which]);
+ }
+
+ function fit() {
+ if (isNarrow()) {
+ if (wide === null) wide = { ...wanted };
+ set("left", false, { remember: false });
+ set("right", false, { remember: false });
+ } else if (wide) {
+ const was = wide;
+ wide = null;
+ set("left", was.left, { remember: false });
+ set("right", was.right, { remember: false });
+ }
+ }
+
+ const main = el("div", { cls: "cx-workspace-main",
+ children: centre ? nodeOf(centre, "workspace.docked") : null });
+
+ const l = side("left", leftLabel, left);
+ const r = side("right", rightLabel, right);
+ node.append(...[l.rail, l.panel, main, r.panel, r.rail].filter(Boolean));
+ set("left", state.left, { remember: false });
+ set("right", state.right, { remember: false });
+ fit();
+ if (narrow && narrow.addEventListener) narrow.addEventListener("change", fit);
+
+ return {
+ node,
+ // Hosts, so the shell mounts INTO the regions rather than rebuilding them.
+ get left() { return panels.left; },
+ get right() { return panels.right; },
+ get centre() { return main; },
+ isOpen: (which) => Boolean(state[which]),
+ open: (which) => set(which, true),
+ close: (which) => set(which, false),
+ toggle: (which) => set(which, !state[which]),
+ narrow: isNarrow,
+ destroy() {
+ if (narrow && narrow.removeEventListener) narrow.removeEventListener("change", fit);
+ node.remove();
+ },
+ };
+});
+
+define("collapsible", "default", ({ label, open = false, body, hint } = {}) => {
+ const node = el("details", { cls: "cx-collapsible", attrs: { open: open || undefined } });
+ const summary = el("summary", { cls: ["cx-collapsible-head", "cx-focusable"], children: [
+ el("span", { cls: "cx-collapsible-label", text: label }),
+ hint ? el("span", { cls: "cx-hint", text: hint }) : null,
+ ].filter(Boolean) });
+ node.append(summary, el("div", { cls: "cx-collapsible-body", children: body ? nodeOf(body, "collapsible.default") : null }));
+ return {
+ node,
+ get value() { return node.open; },
+ setValue(v) { node.open = Boolean(v); },
+ destroy: () => node.remove(),
+ };
+});
+
+define("sidebar", "rail", ({ items = [], value, onChange, label } = {}) => {
+ let current = value ?? items[0]?.value;
+ const node = el("nav", { cls: "cx-rail", attrs: { "aria-label": label } });
+ const buttons = items.map((item) => {
+ const b = el("button", { cls: ["cx-rail-item", "cx-focusable"], attrs: { type: "button" }, children: [
+ el("span", { cls: "cx-rail-icon", text: item.icon, attrs: { "aria-hidden": "true" } }),
+ el("span", { cls: "cx-rail-label", text: item.label }),
+ ] });
+ b.classList.toggle("cx-on", item.value === current);
+ b.addEventListener("click", () => {
+ current = item.value;
+ for (const { item: i, b: other } of buttons) other.classList.toggle("cx-on", i.value === current);
+ if (onChange) onChange(current);
+ });
+ node.append(b);
+ return { item, b };
+ });
+ return { node, get value() { return current; }, destroy: () => node.remove() };
+});
diff --git a/app/ui/composer/elements/modal.js b/app/ui/composer/elements/modal.js
new file mode 100644
index 00000000..08fd872f
--- /dev/null
+++ b/app/ui/composer/elements/modal.js
@@ -0,0 +1,215 @@
+// Modals.
+//
+// v4 built this shell by hand in eleven files, each repeating its own backdrop
+// click, close button and Escape handling -- and getting Escape wrong when one
+// opened inside another. Here the shell is one thing, and the stacking and
+// dismissal are the internals' problem.
+//
+// Every overlay handle carries isOverlay, because it mounts itself into the
+// portal instead of being appended by its caller.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { setText } from "../internals/text.js";
+import { uid } from "../internals/ids.js";
+import { mount, unmount } from "../internals/portal.js";
+import { claimFor } from "../internals/zlayer.js";
+import { push } from "../internals/dismiss.js";
+import { trap } from "../internals/focus.js";
+
+const SIZES = { sm: "360px", md: "520px", lg: "720px", xl: "960px" };
+
+function nodeOf(control, where) {
+ if (!control) return null;
+ if (control.node && control.node.nodeType === 1) return control.node;
+ throw new TypeError(`${where} takes a Composer element handle, not a ${typeof control}.`);
+}
+
+/**
+ * open({ title, subtitle, size, body, actions, onClose, closeOnOutside, stacked })
+ * Shared by every modal variant below.
+ */
+function open({
+ title, subtitle, size = "md", body, actions = [], onClose,
+ closeOnOutside = true, stacked = false, describedBy,
+} = {}) {
+ const titleId = uid("modal-title");
+
+ const card = el("div", {
+ cls: ["cx-modal", `cx-modal-${size}`, stacked ? "cx-modal-stacked" : null],
+ attrs: { role: "dialog", "aria-modal": "true", "aria-labelledby": titleId,
+ "aria-describedby": describedBy },
+ });
+ card.style.maxWidth = SIZES[size] || SIZES.md;
+
+ const close = el("button", {
+ cls: ["cx-icon-btn", "cx-icon-btn-md", "cx-focusable"],
+ text: "✕", attrs: { type: "button", "aria-label": "Close" },
+ on: { click: () => handle.close("close-button") },
+ });
+
+ card.append(el("header", { cls: "cx-modal-head", children: [
+ el("div", { cls: "cx-modal-titles", children: [
+ el("h2", { cls: "cx-modal-title", text: title, attrs: { id: titleId } }),
+ subtitle ? el("p", { cls: "cx-hint", text: subtitle }) : null,
+ ].filter(Boolean) }),
+ close,
+ ] }));
+
+ const content = el("div", { cls: "cx-modal-body", children: nodeOf(body, "modal") });
+ card.append(content);
+
+ // Always present, empty until there is something to put in it.
+ //
+ // A modal whose body changes needs its buttons to change with it, and the
+ // footer is the only row that sits OUTSIDE the scrolling body -- a bar built
+ // inside the body instead sticks to the bottom of the scroller, which is
+ // above the card's own edge by the body's padding, so the content scrolled
+ // through the gap underneath it.
+ const buttons = el("div", { cls: "cx-modal-foot-buttons" });
+ const noteText = el("p", { cls: "cx-hint" });
+ const foot = el("footer", { cls: "cx-modal-foot", children: [noteText, buttons] });
+
+ function setFooter({ actions: next = [], note = "" } = {}) {
+ buttons.replaceChildren(...next.map((a) => nodeOf(a, "modal actions")));
+ setText(noteText, note);
+ foot.toggleAttribute("hidden", !next.length && !note);
+ }
+ card.append(foot);
+ setFooter({ actions });
+
+ const backdrop = el("div", { cls: "cx-backdrop" });
+ const root = el("div", { cls: "cx-modal-layer", children: [backdrop, card] });
+ // A stacked modal shares the modal rung with the one beneath it: peers stack
+ // by claim order, so it lands above without needing a rung of its own.
+ const layer = claimFor("modal", root);
+ mount(root);
+
+ // Trap after mounting: focusables() must be able to see the card.
+ const release = trap(card);
+ const dismissal = push({
+ nodes: card,
+ closeOnOutside,
+ onDismiss: (reason) => handle.close(reason),
+ });
+
+ let closed = false;
+ const handle = {
+ node: card,
+ isOverlay: true,
+ setFooter,
+ close(reason = "manual") {
+ if (closed) return;
+ closed = true;
+ dismissal.release();
+ release();
+ layer.release();
+ unmount(root);
+ if (onClose) onClose(reason);
+ },
+ destroy() { handle.close("destroy"); },
+ };
+ return handle;
+}
+
+define("modal", "generic", (props) => open(props));
+
+// Opened above an existing modal without closing it. The z rung and the focus
+// handoff are the kit's problem; v4 made each caller compute a z-index from its
+// parent's.
+define("modal", "stacked", (props) => open({ ...props, stacked: true }));
+
+/**
+ * A yes/no question. `result` resolves true or false -- including on Escape and
+ * on a backdrop click, which both mean "no" rather than leaving the promise
+ * pending forever.
+ */
+define("modal", "dialogue", ({ title, message, tone = "neutral", confirmLabel = "Confirm", cancelLabel = "Cancel", onResult } = {}) => {
+ let settle;
+ const result = new Promise((resolve) => { settle = resolve; });
+ const finish = (value) => { settle(value); if (onResult) onResult(value); };
+
+ const bodyId = uid("modal-msg");
+ const handle = open({
+ title,
+ describedBy: bodyId,
+ body: { node: el("p", { cls: "cx-t cx-t-sm", text: message, attrs: { id: bodyId } }) },
+ onClose: (reason) => { if (reason !== "confirmed") finish(false); },
+ actions: [
+ { node: el("button", { cls: ["cx-btn", "cx-btn-lg", "cx-btn-neutral", "cx-focusable"],
+ text: cancelLabel, attrs: { type: "button" },
+ on: { click: () => handle.close("cancelled") } }) },
+ { node: el("button", { cls: ["cx-btn", "cx-btn-lg", tone === "danger" ? "cx-btn-danger" : "cx-btn-primary", "cx-focusable"],
+ text: confirmLabel, attrs: { type: "button" },
+ on: { click: () => { finish(true); handle.close("confirmed"); } } }) },
+ ],
+ });
+ handle.result = result;
+ return handle;
+});
+
+/** Ask for one value. Resolves the string, or null if dismissed. */
+define("modal", "prompt", ({ title, label, value = "", placeholder, confirmLabel = "Create", cancelLabel = "Cancel", validate, onResult } = {}) => {
+ let settle;
+ const result = new Promise((resolve) => { settle = resolve; });
+ const finish = (v) => { settle(v); if (onResult) onResult(v); };
+
+ const input = el("input", { cls: ["cx-input", "cx-input-md", "cx-focusable"],
+ attrs: { type: "text", placeholder, id: uid("prompt") } });
+ input.value = value;
+ const error = el("p", { cls: "cx-field-error" });
+
+ const submit = () => {
+ const problem = validate ? validate(input.value) : null;
+ if (problem) { error.textContent = problem; input.focus(); return; }
+ finish(input.value);
+ handle.close("confirmed");
+ };
+
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); submit(); } });
+
+ const handle = open({
+ title,
+ body: { node: el("div", { cls: "cx-field", children: [
+ label ? el("label", { cls: "cx-label", text: label, attrs: { for: input.id } }) : null,
+ input, error,
+ ].filter(Boolean) }) },
+ onClose: (reason) => { if (reason !== "confirmed") finish(null); },
+ actions: [
+ { node: el("button", { cls: ["cx-btn", "cx-btn-lg", "cx-btn-neutral", "cx-focusable"],
+ text: cancelLabel, attrs: { type: "button" }, on: { click: () => handle.close("cancelled") } }) },
+ { node: el("button", { cls: ["cx-btn", "cx-btn-lg", "cx-btn-primary", "cx-focusable"],
+ text: confirmLabel, attrs: { type: "button" }, on: { click: submit } }) },
+ ],
+ });
+ handle.result = result;
+ queueMicrotask(() => { input.focus(); input.select(); });
+ return handle;
+});
+
+/** Pick one of a list. Resolves the chosen id, or null. */
+define("modal", "choice", ({ title, subtitle, items = [], onPick } = {}) => {
+ let settle;
+ const result = new Promise((resolve) => { settle = resolve; });
+
+ const list = el("div", { cls: "cx-choice-list" });
+ for (const item of items) {
+ list.append(el("button", {
+ cls: ["cx-choice-row", "cx-focusable"],
+ attrs: { type: "button", disabled: item.disabled },
+ children: [
+ el("span", { cls: "cx-choice-label", text: item.label }),
+ item.hint ? el("span", { cls: "cx-hint", text: item.hint }) : null,
+ ].filter(Boolean),
+ on: { click: () => { settle(item.id); if (onPick) onPick(item.id); handle.close("picked"); } },
+ }));
+ }
+
+ const handle = open({
+ title, subtitle, size: "sm",
+ body: { node: list },
+ onClose: (reason) => { if (reason !== "picked") settle(null); },
+ });
+ handle.result = result;
+ return handle;
+});
diff --git a/app/ui/composer/elements/overlay.css b/app/ui/composer/elements/overlay.css
new file mode 100644
index 00000000..0833d281
--- /dev/null
+++ b/app/ui/composer/elements/overlay.css
@@ -0,0 +1,172 @@
+/* Every overlay lives on the portal root, which is fixed and inset:0 with
+ * pointer-events:none; each layer turns its own events back on. */
+.cx-modal-layer, .cx-slideover-layer, .cx-blocking {
+ position: fixed; inset: 0; display: flex; pointer-events: auto;
+}
+.cx-modal-layer { align-items: flex-start; justify-content: center; padding: 7vh var(--sp-6); }
+.cx-backdrop { position: absolute; inset: 0; background: var(--backdrop); }
+
+.cx-modal {
+ position: relative; width: 100%;
+ display: flex; flex-direction: column; max-height: 86vh;
+ background: var(--elev-3); color: var(--text);
+ border: var(--bw) solid var(--line); border-radius: var(--r-5);
+ box-shadow: var(--shadow-3);
+ animation: cx-pop var(--dur-2) var(--ease-standard);
+}
+/* A stacked modal gets a deeper shadow and nothing else.
+ *
+ * It used to paint its own backdrop as `0 0 0 100vmax` of shadow spread, which
+ * was redundant -- every modal, stacked or not, already mounts a real
+ * .cx-backdrop element that covers the layer -- so the area under a stacked
+ * modal was dimmed twice. It was also the most expensive declaration in this
+ * sheet by a distance: a shadow spread the size of the viewport diagonal, on an
+ * element inside a fixed layer, with an entry animation running. That is a
+ * standing invitation to a stale compositor layer, and one showed up as the
+ * whole app painted twice, side by side, until a window resize forced a
+ * repaint. */
+.cx-modal-stacked {
+ box-shadow: 0 20px 48px color-mix(in srgb, var(--shadow-ink) 90%, transparent);
+}
+@keyframes cx-pop { from { opacity: 0; transform: translateY(-6px) scale(.99); } }
+
+/* A modal that hosts SWITCHABLE content -- Settings, so far -- rather than
+ one fixed question: sizing to whichever section is showing means the frame
+ itself grows and shrinks on every click, which nothing in a real System
+ Settings window does. A real height, not a max, holds the frame still;
+ .cx-modal-body's own scroll takes the rest. */
+.cx-settings-modal { height: min(660px, 90vh); } /* v4's own window size, verbatim */
+
+.cx-modal-head {
+ display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-5);
+ padding: var(--sp-6); border-bottom: var(--bw) solid var(--line);
+}
+.cx-modal-titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
+.cx-modal-title { font-family: var(--font-display); font-size: var(--fs-2); font-weight: var(--fw-semibold); }
+/* A column, so a body that wants to fill the card can. Without it the body
+ sized to its content and the card scrolled as a whole -- which put the group
+ editor's Save bar below the fold instead of holding the bottom of the
+ window. */
+.cx-modal-body {
+ padding: var(--sp-6); overflow-y: auto;
+ display: flex; flex-direction: column; min-block-size: 0;
+ /* Latent until a modal had a real (not max-) height: a shrink-wrapped card
+ always summed head+body+foot exactly, so the foot landed at the bottom by
+ accident. .cx-settings-modal is a fixed height, and without this the body
+ stopped short of it, leaving the foot floating above true bottom with a
+ gap of bare card showing under it. */
+ flex: 1 1 auto;
+}
+.cx-modal-foot {
+ display: flex; align-items: center; justify-content: space-between; gap: var(--sp-6);
+ padding: var(--sp-5) var(--sp-6); border-top: var(--bw) solid var(--line);
+ background: var(--elev-2);
+}
+.cx-modal-foot-buttons { display: flex; gap: var(--sp-3); margin-left: auto; }
+
+.cx-choice-list { display: flex; flex-direction: column; gap: var(--sp-1); }
+.cx-choice-row {
+ display: flex; flex-direction: column; gap: 2px; text-align: left;
+ padding: var(--sp-4) var(--sp-5);
+ border: var(--bw) solid var(--line); border-radius: var(--r-3);
+ background: var(--elev-2); color: var(--text); cursor: pointer;
+}
+.cx-choice-row:hover { border-color: var(--accent); background: var(--accent-soft); }
+.cx-choice-label { font-size: var(--fs-4); }
+
+.cx-popover {
+ position: fixed; pointer-events: auto;
+ background: var(--elev-3); color: var(--text);
+ border: var(--bw) solid var(--line); border-radius: var(--r-4);
+ box-shadow: var(--shadow-3);
+ padding: var(--sp-2);
+ /* Both axes bounded, not just one. The height was capped from the start and
+ the width was not, so a long unbroken string -- a file path, a prompt, a
+ model filename -- made the popover wider than the screen and no amount of
+ repositioning could bring it back: anchor.js can move a box, it cannot
+ shrink one. Measured at 1436px inside a 1280px viewport. */
+ max-height: 60vh; overflow-y: auto;
+ max-inline-size: min(92vw, 480px);
+ overflow-wrap: anywhere;
+ animation: cx-pop var(--dur-1) var(--ease-standard);
+}
+.cx-popover-menu { min-width: 180px; }
+
+.cx-menu { display: flex; flex-direction: column; gap: 1px; }
+.cx-menu-item {
+ display: flex; align-items: center; gap: var(--sp-3); width: 100%;
+ padding: var(--sp-3) var(--sp-4);
+ border: 0; border-radius: var(--r-2); background: transparent;
+ color: var(--text); font-size: var(--fs-5); text-align: left; cursor: pointer;
+}
+.cx-menu-item:hover:not(:disabled), .cx-menu-item.cx-on { background: var(--hover); }
+.cx-menu-item:disabled { opacity: var(--disabled-opacity); cursor: default; }
+.cx-menu-item.cx-danger { color: var(--danger); }
+.cx-menu-label { flex: 1; }
+.cx-menu-hint { font-size: var(--fs-7); color: var(--faint); }
+.cx-menu-icon { width: 16px; text-align: center; color: var(--muted); }
+.cx-menu-sep { height: 1px; background: var(--line); margin: var(--sp-2) 0; }
+
+.cx-tooltip {
+ position: fixed; pointer-events: none;
+ background: var(--elev-3); color: var(--text);
+ border: var(--bw) solid var(--line); border-radius: var(--r-2);
+ padding: var(--sp-2) var(--sp-3);
+ font-size: var(--fs-6); box-shadow: var(--shadow-2);
+ max-width: 260px;
+}
+
+.cx-autocomplete { min-width: 220px; padding: var(--sp-1); }
+
+.cx-split-btn { display: inline-flex; }
+.cx-split-btn .cx-btn:first-child { border-radius: var(--ctl-r) 0 0 var(--ctl-r); }
+.cx-split-arrow { border-radius: 0 var(--ctl-r) var(--ctl-r) 0; margin-left: calc(var(--bw) * -1); padding: 0 var(--sp-3); }
+
+.cx-window {
+ position: fixed; pointer-events: auto;
+ display: flex; flex-direction: column;
+ background: var(--elev-3); color: var(--text);
+ border: var(--bw) solid var(--line); border-radius: var(--r-4);
+ box-shadow: var(--shadow-3); overflow: hidden;
+}
+.cx-window-bar {
+ display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
+ padding: var(--sp-2) var(--sp-3) var(--sp-2) var(--sp-5);
+ background: var(--elev-2); border-bottom: var(--bw) solid var(--line);
+ cursor: grab; user-select: none; flex: none;
+}
+.cx-window-bar:active { cursor: grabbing; }
+.cx-window-titles { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; }
+.cx-window-title { font-size: var(--fs-5); font-weight: var(--fw-semibold); }
+.cx-window-controls { display: flex; gap: var(--sp-1); }
+.cx-window-body { flex: 1; overflow: auto; padding: var(--sp-5); }
+.cx-rolled .cx-window-body, .cx-rolled .cx-window-grip { display: none; }
+.cx-window-grip {
+ position: absolute; right: 0; bottom: 0; width: 16px; height: 16px;
+ cursor: nwse-resize;
+ background: linear-gradient(135deg, transparent 50%, var(--line) 50%, var(--line) 60%, transparent 60%);
+}
+
+.cx-blocking { align-items: center; justify-content: center; background: var(--backdrop); }
+.cx-blocking-card {
+ display: flex; flex-direction: column; align-items: center; gap: var(--sp-5);
+ padding: var(--sp-9) var(--sp-10);
+ background: var(--elev-3); border: var(--bw) solid var(--line);
+ border-radius: var(--r-5); box-shadow: var(--shadow-3); text-align: center;
+}
+
+.cx-slideover-layer { align-items: stretch; }
+.cx-slideover {
+ position: relative; margin-left: auto;
+ width: min(420px, 92vw); display: flex; flex-direction: column;
+ background: var(--elev-1); border-left: var(--bw) solid var(--line);
+ box-shadow: var(--shadow-3);
+ animation: cx-slide-in var(--dur-2) var(--ease-standard);
+}
+.cx-slideover-left { margin-left: 0; margin-right: auto; border-left: 0; border-right: var(--bw) solid var(--line); }
+@keyframes cx-slide-in { from { transform: translateX(12px); opacity: 0; } }
+.cx-slideover-head {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: var(--sp-5) var(--sp-6); border-bottom: var(--bw) solid var(--line);
+}
+.cx-slideover-body { flex: 1; overflow-y: auto; padding: var(--sp-6); }
diff --git a/app/ui/composer/elements/popover.js b/app/ui/composer/elements/popover.js
new file mode 100644
index 00000000..c84442a4
--- /dev/null
+++ b/app/ui/composer/elements/popover.js
@@ -0,0 +1,237 @@
+// Anchored overlays: popovers, menus, tooltips, autocomplete.
+//
+// v4 had seven copies of measure-flip-clamp-and-dismiss, one per feature. This
+// is the one, and everything anchored is built on it.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { mount, unmount } from "../internals/portal.js";
+import { claimFor } from "../internals/zlayer.js";
+import { push } from "../internals/dismiss.js";
+import { anchorTo } from "../internals/anchor.js";
+
+function nodeOf(control, where) {
+ if (!control) return null;
+ if (control.node && control.node.nodeType === 1) return control.node;
+ throw new TypeError(`${where} takes a Composer element handle, not a ${typeof control}.`);
+}
+
+function floatOn(rung, cls, { anchor, side = "bottom", align = "start", gap = 6, onClose, closeOnOutside = true, content, role, label }) {
+ const anchorNode = anchor && anchor.node ? anchor.node : anchor;
+ const node = el("div", { cls, attrs: { role, "aria-label": label } });
+ const layer = claimFor(rung, node);
+ if (content) node.append(content);
+
+ // Mounted before measuring: a hidden element measures 0x0 and lands in the
+ // corner, which is the classic "my menu appears top-left" bug.
+ mount(node);
+ if (anchorNode) anchorTo(node, anchorNode, { side, align, gap });
+
+ const reposition = () => { if (anchorNode) anchorTo(node, anchorNode, { side, align, gap }); };
+ window.addEventListener("resize", reposition);
+ // Capture, because the scroller is usually an ancestor panel, not the window.
+ window.addEventListener("scroll", reposition, true);
+
+ const dismissal = push({
+ nodes: [node, anchorNode].filter(Boolean),
+ closeOnOutside,
+ onDismiss: (reason) => handle.close(reason),
+ });
+
+ let closed = false;
+ const handle = {
+ node,
+ isOverlay: true,
+ reposition,
+ close(reason = "manual") {
+ if (closed) return;
+ closed = true;
+ dismissal.release();
+ window.removeEventListener("resize", reposition);
+ window.removeEventListener("scroll", reposition, true);
+ layer.release();
+ unmount(node);
+ if (onClose) onClose(reason);
+ },
+ destroy() { handle.close("destroy"); },
+ };
+ return handle;
+}
+
+define("popover", "anchored", ({ body, ...rest } = {}) =>
+ floatOn("popover", "cx-popover", { ...rest, content: nodeOf(body, "popover.anchored"), role: "dialog" }));
+
+function menuList(items, onPick, close) {
+ const list = el("div", { cls: "cx-menu", attrs: { role: "menu" } });
+ for (const item of items) {
+ if (item.separator) { list.append(el("div", { cls: "cx-menu-sep", attrs: { role: "separator" } })); continue; }
+ list.append(el("button", {
+ cls: ["cx-menu-item", "cx-focusable", item.danger ? "cx-danger" : null],
+ attrs: { type: "button", role: "menuitem", disabled: item.disabled },
+ children: [
+ item.icon ? el("span", { cls: "cx-menu-icon", text: item.icon, attrs: { "aria-hidden": "true" } }) : null,
+ el("span", { cls: "cx-menu-label", text: item.label }),
+ item.hint ? el("span", { cls: "cx-menu-hint", text: item.hint }) : null,
+ ].filter(Boolean),
+ on: { click: () => { if (onPick) onPick(item.id); close(); } },
+ }));
+ }
+ return list;
+}
+
+define("menu", "dropdown", ({ items = [], onPick, ...rest } = {}) => {
+ const handle = floatOn("popover", "cx-popover cx-popover-menu", {
+ ...rest, role: "menu",
+ content: null,
+ });
+ handle.node.append(menuList(items, onPick, () => handle.close("picked")));
+ handle.reposition();
+ return handle;
+});
+
+/**
+ * Right-click menu: anchored to a POINT rather than an element.
+ *
+ * The anchor is a stand-in object with only getBoundingClientRect, because that
+ * is all anchorTo needs. It is not a node, so it never reaches the dismiss stack
+ * -- that filters to things that can answer "was the click inside me".
+ */
+define("menu", "context", ({ x = 0, y = 0, items = [], onPick, onClose } = {}) => {
+ const point = { getBoundingClientRect: () => ({ x, y, width: 0, height: 0, top: y, left: x, right: x, bottom: y }) };
+ const handle = floatOn("popover", "cx-popover cx-popover-menu", {
+ anchor: point, onClose, role: "menu",
+ });
+ handle.node.append(menuList(items, onPick, () => handle.close("picked")));
+ handle.reposition();
+ return handle;
+});
+
+/**
+ * A real tooltip. v4 used the native title attribute everywhere, which cannot be
+ * styled, cannot be triggered by keyboard focus, and waits about a second.
+ */
+define("tooltip", "default", ({ anchor, text, side = "top", trigger = true } = {}) => {
+ const anchorNode = anchor && anchor.node ? anchor.node : anchor;
+ let live = null;
+
+ const show = () => {
+ if (live) return;
+ live = floatOn("popover", "cx-tooltip", {
+ anchor: anchorNode, side, align: "center", gap: 6, closeOnOutside: false,
+ content: el("span", { text }), role: "tooltip",
+ });
+ };
+ const hide = () => { if (live) { live.close("hide"); live = null; } };
+
+ if (trigger && anchorNode) {
+ anchorNode.addEventListener("pointerenter", show);
+ anchorNode.addEventListener("pointerleave", hide);
+ anchorNode.addEventListener("focus", show);
+ anchorNode.addEventListener("blur", hide);
+ }
+
+ return {
+ node: anchorNode || el("span"),
+ isOverlay: true,
+ show, hide,
+ destroy() {
+ hide();
+ if (trigger && anchorNode) {
+ anchorNode.removeEventListener("pointerenter", show);
+ anchorNode.removeEventListener("pointerleave", hide);
+ anchorNode.removeEventListener("focus", show);
+ anchorNode.removeEventListener("blur", hide);
+ }
+ },
+ };
+});
+
+/**
+ * Completion under a field. This is the one overlay that must outrank modals --
+ * it opens from a field that is usually inside one.
+ */
+define("autocomplete", "default", ({ input, source, onPick, minChars = 1 } = {}) => {
+ const field = input && input.node ? input.node : input;
+ let live = null;
+ let index = -1;
+ let current = [];
+
+ const close = () => { if (live) { live.close("hide"); live = null; } index = -1; };
+
+ const draw = (items) => {
+ current = items;
+ if (!items.length) { close(); return; }
+ if (!live) {
+ live = floatOn("autocomplete", "cx-popover cx-autocomplete", {
+ anchor: field, side: "bottom", align: "start", gap: 2,
+ role: "listbox", onClose: () => { live = null; },
+ });
+ }
+ live.node.replaceChildren();
+ items.forEach((item, i) => {
+ live.node.append(el("button", {
+ cls: ["cx-menu-item", i === index ? "cx-on" : null],
+ attrs: { type: "button", role: "option", "aria-selected": String(i === index) },
+ children: [
+ el("span", { cls: "cx-menu-label", text: item.label }),
+ item.hint ? el("span", { cls: "cx-menu-hint", text: item.hint }) : null,
+ ].filter(Boolean),
+ on: { click: () => { if (onPick) onPick(item); close(); } },
+ }));
+ });
+ live.reposition();
+ };
+
+ const onInput = () => {
+ const q = field.value || "";
+ if (q.length < minChars) { close(); return; }
+ index = -1;
+ draw(source ? source(q) : []);
+ };
+
+ const onKeyDown = (e) => {
+ if (!live) return;
+ if (e.key === "ArrowDown") { e.preventDefault(); index = Math.min(index + 1, current.length - 1); draw(current); }
+ else if (e.key === "ArrowUp") { e.preventDefault(); index = Math.max(index - 1, 0); draw(current); }
+ else if (e.key === "Enter" && index >= 0) { e.preventDefault(); if (onPick) onPick(current[index]); close(); }
+ };
+
+ if (field) {
+ field.addEventListener("input", onInput);
+ field.addEventListener("keydown", onKeyDown);
+ }
+
+ return {
+ node: field || el("span"),
+ isOverlay: true,
+ close,
+ destroy() {
+ close();
+ if (field) {
+ field.removeEventListener("input", onInput);
+ field.removeEventListener("keydown", onKeyDown);
+ }
+ },
+ };
+});
+
+/** A button whose second half opens a menu. */
+define("splitButton", "md", ({ label, onClick, items = [], onPick, tone = "neutral" } = {}) => {
+ const main = el("button", { cls: ["cx-btn", "cx-btn-md", `cx-btn-${tone}`, "cx-focusable"],
+ text: label, attrs: { type: "button" }, on: { click: onClick } });
+ const arrow = el("button", { cls: ["cx-btn", "cx-btn-md", `cx-btn-${tone}`, "cx-split-arrow", "cx-focusable"],
+ text: "▾", attrs: { type: "button", "aria-label": `${label} options`, "aria-haspopup": "menu" } });
+
+ let menu = null;
+ arrow.addEventListener("click", () => {
+ if (menu) { menu.close("toggle"); menu = null; return; }
+ menu = floatOn("popover", "cx-popover cx-popover-menu", {
+ anchor: arrow, side: "bottom", align: "end", onClose: () => { menu = null; },
+ });
+ menu.node.append(menuList(items, onPick, () => { if (menu) menu.close("picked"); }));
+ menu.reposition();
+ });
+
+ const node = el("div", { cls: "cx-split-btn", children: [main, arrow] });
+ return { node, destroy() { if (menu) menu.close("destroy"); node.remove(); } };
+});
diff --git a/app/ui/composer/elements/slider.css b/app/ui/composer/elements/slider.css
new file mode 100644
index 00000000..767edc4c
--- /dev/null
+++ b/app/ui/composer/elements/slider.css
@@ -0,0 +1,47 @@
+.cx-slider { display: flex; align-items: center; gap: var(--sp-4); width: 100%; }
+
+.cx-range {
+ appearance: none; -webkit-appearance: none;
+ flex: 1; min-width: 80px; height: 18px; background: transparent; cursor: pointer;
+ /* --fill is written by the element as the value changes, so the filled part
+ * of the track follows the theme's accent without JS naming a colour. */
+ --fill: 0%;
+}
+.cx-range::-webkit-slider-runnable-track {
+ height: 4px; border-radius: var(--r-pill);
+ background: linear-gradient(to right, var(--accent) var(--fill), var(--line) var(--fill));
+}
+.cx-range::-moz-range-track {
+ height: 4px; border-radius: var(--r-pill);
+ background: linear-gradient(to right, var(--accent) var(--fill), var(--line) var(--fill));
+}
+.cx-range::-webkit-slider-thumb {
+ -webkit-appearance: none; appearance: none;
+ width: 14px; height: 14px; margin-top: -5px;
+ border-radius: var(--r-pill); background: var(--elev-1);
+ border: 2px solid var(--accent);
+ transition: transform var(--dur-1) var(--ease-standard);
+}
+.cx-range::-moz-range-thumb {
+ width: 14px; height: 14px; border-radius: var(--r-pill);
+ background: var(--elev-1); border: 2px solid var(--accent);
+}
+.cx-range:hover::-webkit-slider-thumb { transform: scale(1.12); }
+.cx-range:focus-visible { outline: none; }
+.cx-range:focus-visible::-webkit-slider-thumb { box-shadow: var(--focus-ring); }
+.cx-range:focus-visible::-moz-range-thumb { box-shadow: var(--focus-ring); }
+.cx-range:disabled { opacity: var(--disabled-opacity); cursor: default; }
+
+.cx-slider-sm .cx-range { height: 14px; }
+
+.cx-readout {
+ font-family: var(--font-mono); font-variant-numeric: tabular-nums;
+ font-size: var(--fs-6); color: var(--muted);
+ min-width: 4ch; text-align: right;
+}
+
+.cx-macro { display: flex; flex-direction: column; gap: var(--sp-3); width: 100%; }
+.cx-macro-presets { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
+.cx-macro-presets .cx-on { color: var(--accent); background: var(--accent-soft); }
+
+.cx-range-pair { display: flex; flex-direction: column; gap: var(--sp-1); width: 100%; }
diff --git a/app/ui/composer/elements/slider.js b/app/ui/composer/elements/slider.js
new file mode 100644
index 00000000..9195178f
--- /dev/null
+++ b/app/ui/composer/elements/slider.js
@@ -0,0 +1,134 @@
+// Sliders.
+//
+// The track and thumb are drawn. v4 left them fully native with only
+// accent-color, so a slider looked like the operating system rather than like
+// the app, and its hit target was whatever the browser decided.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { uid } from "../internals/ids.js";
+
+const pct = (v, min, max) => (max === min ? 0 : ((v - min) / (max - min)) * 100);
+
+function makeRange({ value, min, max, step, disabled, label, describedBy }) {
+ const input = el("input", {
+ cls: ["cx-range", "cx-focusable"],
+ attrs: { type: "range", min, max, step, disabled, "aria-label": label, "aria-describedby": describedBy },
+ });
+ input.value = String(value);
+ // The filled portion is drawn from a variable rather than a background image,
+ // so it follows the theme's accent without the JS knowing a colour.
+ const paint = () => input.style.setProperty("--fill", `${pct(Number(input.value), min, max)}%`);
+ paint();
+ input.addEventListener("input", paint);
+ return { input, paint };
+}
+
+function sliderBase(cls, { value = 0, min = 0, max = 1, step = 0.01, onChange, onCommit, disabled, label } = {}) {
+ const { input, paint } = makeRange({ value, min, max, step, disabled, label });
+ if (onChange) input.addEventListener("input", () => onChange(Number(input.value)));
+ if (onCommit) input.addEventListener("change", () => onCommit(Number(input.value)));
+ const node = el("div", { cls, children: input });
+ return {
+ node, input, paint,
+ get value() { return Number(input.value); },
+ setValue(v) { input.value = String(v); paint(); },
+ destroy: () => node.remove(),
+ };
+}
+
+for (const size of ["md", "sm"]) {
+ define("slider", size, (props = {}) => sliderBase(`cx-slider cx-slider-${size}`, props));
+}
+
+define("slider", "readout", ({ unit = "", precision = 2, ...props } = {}) => {
+ const base = sliderBase("cx-slider cx-slider-md cx-slider-readout", props);
+ const format = (v) => `${Number(v).toFixed(precision)}${unit}`;
+ const out = el("output", { cls: "cx-readout", text: format(base.value) });
+ base.input.addEventListener("input", () => { out.textContent = format(base.input.value); });
+ base.node.append(out);
+ const setValue = base.setValue;
+ return {
+ ...base,
+ setValue(v) { setValue(v); out.textContent = format(v); },
+ node: base.node,
+ };
+});
+
+/**
+ * A slider with named landing points. The presets are the point: most people
+ * want "Chill" or "Chaos", not 0.62, and the number is there for the ones who do.
+ */
+define("slider", "macro", ({ presets = [], unit = "", precision = 2, ...props } = {}) => {
+ const base = sliderBase("cx-slider cx-slider-md cx-slider-macro", props);
+ const format = (v) => `${Number(v).toFixed(precision)}${unit}`;
+ const out = el("output", { cls: "cx-readout", text: format(base.value) });
+ base.node.append(out);
+
+ const row = el("div", { cls: "cx-macro-presets" });
+ const buttons = presets.map((preset) => {
+ const b = el("button", {
+ cls: ["cx-btn", "cx-btn-sm", "cx-btn-ghost", "cx-focusable"],
+ text: preset.label,
+ attrs: { type: "button" },
+ on: { click: () => {
+ base.setValue(preset.value);
+ out.textContent = format(preset.value);
+ sync();
+ if (props.onChange) props.onChange(preset.value);
+ if (props.onCommit) props.onCommit(preset.value);
+ } },
+ });
+ row.append(b);
+ return { preset, b };
+ });
+
+ function sync() {
+ for (const { preset, b } of buttons) b.classList.toggle("cx-on", Number(base.value) === preset.value);
+ }
+ base.input.addEventListener("input", () => { out.textContent = format(base.input.value); sync(); });
+ sync();
+
+ const node = el("div", { cls: "cx-macro", children: [base.node, row] });
+ return {
+ node,
+ get value() { return base.value; },
+ setValue(v) { base.setValue(v); out.textContent = format(v); sync(); },
+ destroy: () => node.remove(),
+ };
+});
+
+/** Two thumbs. Trim handles and any "between x and y" setting are built on this. */
+define("range", "md", ({ from = 0, to = 1, min = 0, max = 1, step = 0.01, onChange, label } = {}) => {
+ const lo = makeRange({ value: from, min, max, step, label: label ? `${label} from` : "from" });
+ const hi = makeRange({ value: to, min, max, step, label: label ? `${label} to` : "to" });
+
+ // Thumbs must not pass each other: a range whose start is after its end is a
+ // state every consumer would then have to defend against.
+ const clampPair = (moved) => {
+ let a = Number(lo.input.value);
+ let b = Number(hi.input.value);
+ if (a > b) {
+ if (moved === "lo") a = b; else b = a;
+ lo.input.value = String(a);
+ hi.input.value = String(b);
+ lo.paint(); hi.paint();
+ }
+ if (onChange) onChange({ from: a, to: b });
+ };
+ lo.input.addEventListener("input", () => clampPair("lo"));
+ hi.input.addEventListener("input", () => clampPair("hi"));
+
+ const node = el("div", { cls: "cx-range-pair", attrs: { role: "group", "aria-label": label },
+ children: [lo.input, hi.input] });
+
+ return {
+ node,
+ get value() { return { from: Number(lo.input.value), to: Number(hi.input.value) }; },
+ setValue({ from: a, to: b }) {
+ lo.input.value = String(a); hi.input.value = String(b);
+ lo.paint(); hi.paint();
+ },
+ destroy: () => node.remove(),
+ };
+});
diff --git a/app/ui/composer/elements/status.css b/app/ui/composer/elements/status.css
new file mode 100644
index 00000000..0d729ec1
--- /dev/null
+++ b/app/ui/composer/elements/status.css
@@ -0,0 +1,104 @@
+.cx-chip {
+ display: inline-flex; align-items: center; gap: var(--sp-2);
+ height: var(--ctl-h-sm); padding: 0 var(--sp-3);
+ border: var(--bw) solid var(--line); border-radius: var(--r-pill);
+ font-size: var(--fs-7); color: var(--muted); white-space: nowrap;
+}
+.cx-chip-dot { width: 6px; height: 6px; border-radius: var(--r-pill); background: currentColor; }
+.cx-chip-good { color: var(--good); border-color: color-mix(in srgb, var(--good) 30%, var(--line)); }
+.cx-chip-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 30%, var(--line)); }
+.cx-chip-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--line)); }
+.cx-chip-info { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
+
+.cx-dot { width: 8px; height: 8px; border-radius: var(--r-pill); background: var(--muted); display: inline-block; flex: none; }
+.cx-dot-good { background: var(--good); }
+.cx-dot-warn { background: var(--warn); }
+.cx-dot-danger { background: var(--danger); }
+.cx-dot-info { background: var(--accent); }
+
+.cx-banner {
+ display: flex; align-items: center; gap: var(--sp-4);
+ padding: var(--sp-4) var(--sp-5);
+ border: var(--bw) solid var(--line); border-radius: var(--r-3);
+ background: var(--elev-2); font-size: var(--fs-5);
+}
+.cx-banner-info { border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
+.cx-banner-info .cx-dot { background: var(--accent); }
+.cx-banner-warn { border-color: color-mix(in srgb, var(--warn) 30%, var(--line)); }
+.cx-banner-warn .cx-dot { background: var(--warn); }
+.cx-banner-danger { border-color: color-mix(in srgb, var(--danger) 30%, var(--line)); }
+.cx-banner-danger .cx-dot { background: var(--danger); }
+.cx-banner-text { flex: 1; }
+
+.cx-inline-error { font-size: var(--fs-6); color: var(--danger); }
+
+.cx-progress {
+ width: 100%; height: 4px; border-radius: var(--r-pill);
+ background: var(--line); overflow: hidden;
+}
+.cx-progress-fill {
+ height: 100%; width: 0; background: var(--accent); border-radius: var(--r-pill);
+ transition: width var(--dur-2) var(--ease-standard);
+}
+.cx-progress-indeterminate .cx-progress-fill {
+ width: 35%; animation: cx-slide 1.2s var(--ease-standard) infinite;
+}
+@keyframes cx-slide { from { transform: translateX(-100%); } to { transform: translateX(320%); } }
+
+.cx-spinner {
+ display: inline-block; border-radius: var(--r-pill);
+ border: 2px solid color-mix(in srgb, var(--muted) 30%, transparent);
+ border-top-color: var(--accent);
+ animation: cx-spin 800ms linear infinite;
+}
+.cx-spinner-md { width: 16px; height: 16px; }
+.cx-spinner-sm { width: 12px; height: 12px; }
+
+.cx-empty {
+ display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
+ padding: var(--sp-10) var(--sp-6); text-align: center; color: var(--muted);
+}
+.cx-empty-icon { font-size: var(--fs-d3); color: var(--faint); line-height: 1; }
+.cx-empty-title { font-size: var(--fs-4); color: var(--text); }
+
+.cx-dropzone {
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ gap: var(--sp-2); padding: var(--sp-9) var(--sp-6); text-align: center;
+ border: var(--bw) dashed var(--line); border-radius: var(--r-4);
+ background: var(--elev-1); color: var(--muted); cursor: pointer;
+ transition: border-color var(--dur-1) var(--ease-standard), background var(--dur-1) var(--ease-standard);
+}
+.cx-dropzone:hover, .cx-dropzone.cx-over { border-color: var(--accent); background: var(--accent-soft); color: var(--text); }
+.cx-dropzone-label { font-size: var(--fs-4); }
+
+.cx-skeleton { display: flex; gap: var(--sp-3); }
+.cx-skeleton-line, .cx-skeleton-block { flex-direction: column; }
+.cx-skeleton-grid { display: grid; grid-template-columns: repeat(3, 1fr); }
+.cx-skeleton-cell {
+ border-radius: var(--r-2);
+ background: linear-gradient(90deg, var(--elev-2), var(--hover), var(--elev-2));
+ background-size: 200% 100%;
+ animation: cx-shimmer 1.4s linear infinite;
+}
+.cx-skeleton-line .cx-skeleton-cell { height: 10px; }
+.cx-skeleton-line .cx-skeleton-cell:last-child { width: 60%; }
+.cx-skeleton-block .cx-skeleton-cell { height: 44px; }
+.cx-skeleton-grid .cx-skeleton-cell { aspect-ratio: 16 / 10; }
+@keyframes cx-shimmer { to { background-position: -200% 0; } }
+
+.cx-toasts {
+ position: fixed; left: 50%; bottom: var(--sp-9); transform: translateX(-50%);
+ display: flex; flex-direction: column; gap: var(--sp-3); align-items: center;
+}
+.cx-toast {
+ display: flex; align-items: center; gap: var(--sp-4);
+ padding: var(--sp-4) var(--sp-6);
+ border: var(--bw) solid var(--line); border-radius: var(--r-pill);
+ background: var(--elev-3); color: var(--text);
+ box-shadow: var(--shadow-2); font-size: var(--fs-5);
+ animation: cx-rise var(--dur-2) var(--ease-standard);
+}
+.cx-toast-good { border-color: color-mix(in srgb, var(--good) 45%, var(--line)); }
+.cx-toast-warn { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
+.cx-toast-danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); }
+@keyframes cx-rise { from { opacity: 0; transform: translateY(8px); } }
diff --git a/app/ui/composer/elements/status.js b/app/ui/composer/elements/status.js
new file mode 100644
index 00000000..5d1bacd0
--- /dev/null
+++ b/app/ui/composer/elements/status.js
@@ -0,0 +1,174 @@
+// Telling the user what is going on.
+//
+// v4 had roughly fifteen badge classes, ten empty states and three spinners.
+// Here there are five tones and one shape of each, which is the whole point: a
+// status looks the same everywhere, so its colour actually means something.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { setText } from "../internals/text.js";
+import { portal } from "../internals/portal.js";
+import { claimFor } from "../internals/zlayer.js";
+
+const TONES = ["neutral", "good", "warn", "danger", "info"];
+
+for (const tone of TONES) {
+ define("chip", tone, ({ label, dot = false } = {}) =>
+ ({ node: el("span", { cls: ["cx-chip", `cx-chip-${tone}`], children: [
+ dot ? el("span", { cls: "cx-chip-dot", attrs: { "aria-hidden": "true" } }) : null,
+ el("span", { text: label }),
+ ].filter(Boolean) }),
+ destroy() { this.node.remove(); } }));
+}
+
+define("dot", "default", ({ tone = "neutral", label } = {}) =>
+ ({ node: el("span", { cls: ["cx-dot", `cx-dot-${tone}`],
+ attrs: { role: label ? "img" : undefined, "aria-label": label, title: label } }),
+ destroy() { this.node.remove(); } }));
+
+for (const tone of ["info", "warn", "danger"]) {
+ define("banner", tone, ({ text, action } = {}) => {
+ const words = el("span", { cls: "cx-banner-text", text });
+ const node = el("div", { cls: ["cx-banner", `cx-banner-${tone}`],
+ attrs: { role: tone === "danger" ? "alert" : "status" }, children: [
+ el("span", { cls: "cx-dot", attrs: { "aria-hidden": "true" } }),
+ words,
+ action && action.node ? action.node : null,
+ ].filter(Boolean) });
+ // Settable, because the things worth a banner come and go: a warning that
+ // can only be built is a warning that has to be destroyed and rebuilt to
+ // change, and whatever was holding it then holds a dead handle.
+ return { node, setText: (next) => setText(words, next), destroy: () => node.remove() };
+ });
+}
+
+define("inlineError", "default", ({ text } = {}) =>
+ ({ node: el("p", { cls: "cx-inline-error", text, attrs: { role: "alert" } }),
+ destroy() { this.node.remove(); } }));
+
+define("progress", "bar", ({ value = 0, max = 100, label } = {}) => {
+ const fill = el("div", { cls: "cx-progress-fill" });
+ const node = el("div", { cls: "cx-progress",
+ attrs: { role: "progressbar", "aria-valuemin": "0", "aria-valuemax": String(max),
+ "aria-valuenow": String(value), "aria-label": label },
+ children: fill });
+ const set = (v) => {
+ const pct = max ? Math.min(100, Math.max(0, (v / max) * 100)) : 0;
+ fill.style.width = `${pct}%`;
+ node.setAttribute("aria-valuenow", String(v));
+ };
+ set(value);
+ return { node, setValue: set, destroy: () => node.remove() };
+});
+
+// For work whose total is genuinely unknown. A bar that invents a percentage is
+// worse than one that admits it does not know.
+define("progress", "indeterminate", ({ label } = {}) =>
+ ({ node: el("div", { cls: ["cx-progress", "cx-progress-indeterminate"],
+ attrs: { role: "progressbar", "aria-label": label },
+ children: el("div", { cls: "cx-progress-fill" }) }),
+ destroy() { this.node.remove(); } }));
+
+for (const size of ["md", "sm"]) {
+ define("spinner", size, ({ label = "Working" } = {}) =>
+ ({ node: el("span", { cls: ["cx-spinner", `cx-spinner-${size}`],
+ attrs: { role: "status", "aria-label": label } }),
+ destroy() { this.node.remove(); } }));
+}
+
+define("emptyState", "default", ({ icon, title, hint, action } = {}) =>
+ ({ node: el("div", { cls: "cx-empty", children: [
+ icon ? el("div", { cls: "cx-empty-icon", text: icon, attrs: { "aria-hidden": "true" } }) : null,
+ el("p", { cls: "cx-empty-title", text: title }),
+ hint ? el("p", { cls: "cx-hint", text: hint }) : null,
+ action && action.node ? action.node : null,
+ ].filter(Boolean) }),
+ destroy() { this.node.remove(); } }));
+
+define("dropzone", "default", ({ label, hint, accept, onFiles, multiple = true } = {}) => {
+ const input = el("input", { cls: "cx-sr",
+ attrs: { type: "file", accept, multiple: multiple || undefined } });
+ const node = el("label", { cls: "cx-dropzone", children: [
+ input,
+ el("span", { cls: "cx-dropzone-label", text: label }),
+ hint ? el("span", { cls: "cx-hint", text: hint }) : null,
+ ].filter(Boolean) });
+
+ const emit = (files) => { if (onFiles && files && files.length) onFiles([...files]); };
+ input.addEventListener("change", () => emit(input.files));
+
+ // dragover must be prevented or the browser navigates to the dropped file,
+ // losing whatever was on screen.
+ for (const type of ["dragenter", "dragover"]) {
+ node.addEventListener(type, (e) => { e.preventDefault(); node.classList.add("cx-over"); });
+ }
+ for (const type of ["dragleave", "drop"]) {
+ node.addEventListener(type, (e) => { e.preventDefault(); node.classList.remove("cx-over"); });
+ }
+ node.addEventListener("drop", (e) => emit(e.dataTransfer && e.dataTransfer.files));
+
+ return { node, destroy: () => node.remove() };
+});
+
+for (const shape of ["line", "block", "grid"]) {
+ define("skeleton", shape, ({ count = shape === "grid" ? 6 : 3, label = "Loading" } = {}) => {
+ const node = el("div", { cls: ["cx-skeleton", `cx-skeleton-${shape}`],
+ attrs: { role: "status", "aria-label": label } });
+ for (let i = 0; i < count; i += 1) node.append(el("span", { cls: "cx-skeleton-cell" }));
+ return { node, destroy: () => node.remove() };
+ });
+}
+
+// One toast stack, owned by the kit. Several stacks fighting for the corner is
+// how v4's tour toast ended up as the only one anybody could use.
+let stack = null;
+let stackLayer = null;
+
+function toastStack() {
+ if (stack && stack.isConnected) return stack;
+ stack = el("div", { cls: "cx-toasts" });
+ stackLayer = claimFor("toast", stack);
+ stack.style.pointerEvents = "auto";
+ portal().appendChild(stack);
+ return stack;
+}
+
+for (const tone of ["info", "good", "warn", "danger"]) {
+ define("toast", tone, ({ text, action, duration = 3200 } = {}) => {
+ const node = el("div", { cls: ["cx-toast", `cx-toast-${tone}`],
+ attrs: { role: tone === "danger" ? "alert" : "status" }, children: [
+ el("span", { cls: "cx-toast-text", text }),
+ action && action.node ? action.node : null,
+ ].filter(Boolean) });
+
+ toastStack().appendChild(node);
+ let timer = duration ? setTimeout(() => handle.destroy(), duration) : null;
+
+ // Hovering pauses the countdown: a toast that vanishes while being read is
+ // a toast that failed to say anything.
+ node.addEventListener("pointerenter", () => { if (timer) { clearTimeout(timer); timer = null; } });
+ node.addEventListener("pointerleave", () => {
+ if (duration && !timer) timer = setTimeout(() => handle.destroy(), duration);
+ });
+
+ const handle = {
+ node,
+ setText: (value) => setText(node.querySelector(".cx-toast-text"), value),
+ destroy() {
+ if (timer) clearTimeout(timer);
+ node.remove();
+ if (stack && !stack.childElementCount) {
+ stackLayer.release();
+ stack.remove();
+ stack = null;
+ }
+ },
+ };
+ return handle;
+ });
+}
+
+export function _resetToasts() {
+ if (stack) { stack.remove(); stack = null; }
+ if (stackLayer) { stackLayer.release(); stackLayer = null; }
+}
diff --git a/app/ui/composer/elements/text.css b/app/ui/composer/elements/text.css
new file mode 100644
index 00000000..46b3c408
--- /dev/null
+++ b/app/ui/composer/elements/text.css
@@ -0,0 +1,44 @@
+.cx-h { font-family: var(--font-display); font-weight: var(--fw-semibold); color: var(--text); }
+.cx-h-display { font-size: var(--fs-d2); line-height: var(--lh-d2); font-weight: var(--fw-bold); letter-spacing: -0.02em; }
+.cx-h-xl { font-size: var(--fs-d3); line-height: var(--lh-d3); font-weight: var(--fw-bold); letter-spacing: -0.015em; }
+.cx-h-lg { font-size: var(--fs-1); line-height: var(--lh-1); }
+.cx-h-md { font-size: var(--fs-2); line-height: var(--lh-2); }
+.cx-h-sm { font-size: var(--fs-3); line-height: var(--lh-3); }
+
+.cx-t { color: var(--text); }
+.cx-t-lg { font-size: var(--fs-2); line-height: var(--lh-2); }
+.cx-t-md { font-size: var(--fs-3); line-height: var(--lh-3); }
+.cx-t-sm { font-size: var(--fs-4); line-height: var(--lh-4); }
+.cx-t-xs { font-size: var(--fs-6); line-height: var(--lh-6); }
+
+.cx-hint { font-size: var(--fs-6); line-height: var(--lh-6); color: var(--muted); }
+
+.cx-label { font-size: var(--fs-5); line-height: var(--lh-5); color: var(--text); }
+
+.cx-eyebrow {
+ font-size: var(--fs-7); line-height: var(--lh-7);
+ letter-spacing: .08em; text-transform: uppercase;
+ color: var(--faint);
+}
+
+.cx-code { font-family: var(--font-mono); font-size: 0.92em; color: var(--accent-hi); }
+.cx-code-block {
+ font-family: var(--font-mono); font-size: var(--fs-6); line-height: var(--lh-snug);
+ color: var(--text); background: var(--elev-2);
+ border: var(--bw) solid var(--line); border-radius: var(--r-3);
+ padding: var(--sp-4) var(--sp-5); margin: 0;
+ overflow-x: auto; /* wide code scrolls itself, never the page */
+}
+
+/* The one place the app names itself. The mark carries a little of its own
+ light, which is what stops a 5px dot reading as a bullet point. */
+.cx-brand { display: inline-flex; align-items: center; gap: var(--sp-3); }
+.cx-brand-mark {
+ inline-size: 9px; block-size: 9px; border-radius: var(--r-pill);
+ border: 2px solid var(--accent);
+ box-shadow: 0 0 6px var(--accent-soft);
+}
+.cx-brand-name {
+ font-family: var(--font-display); font-weight: var(--fw-bold);
+ font-size: var(--fs-3); letter-spacing: .2px; color: var(--text);
+}
diff --git a/app/ui/composer/elements/text.js b/app/ui/composer/elements/text.js
new file mode 100644
index 00000000..358b2704
--- /dev/null
+++ b/app/ui/composer/elements/text.js
@@ -0,0 +1,53 @@
+// Type. Nothing here carries a margin: spacing belongs to the container, so a
+// heading can be dropped anywhere without bringing its own gap.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { setText } from "../internals/text.js";
+import { uid } from "../internals/ids.js";
+
+const handle = (node) => ({
+ node,
+ setText: (value) => setText(node, value),
+ destroy: () => node.remove(),
+});
+
+const TAGS = { display: "h1", xl: "h1", lg: "h2", md: "h3", sm: "h4" };
+
+for (const size of Object.keys(TAGS)) {
+ define("header", size, ({ text, id } = {}) =>
+ handle(el(TAGS[size], { cls: `cx-h cx-h-${size}`, text, attrs: { id } })));
+}
+
+for (const size of ["lg", "md", "sm", "xs"]) {
+ define("text", size, ({ text } = {}) =>
+ handle(el("p", { cls: `cx-t cx-t-${size}`, text })));
+}
+
+// One hint, one look. v4 had ten class names for this exact thing.
+define("hint", "default", ({ text, id } = {}) =>
+ handle(el("p", { cls: "cx-hint", text, attrs: { id } })));
+
+define("label", "field", ({ text, for: htmlFor } = {}) =>
+ handle(el("label", { cls: "cx-label", text, attrs: { for: htmlFor } })));
+
+define("label", "section", ({ text } = {}) =>
+ handle(el("div", { cls: "cx-eyebrow", text })));
+
+/** Who this is. One mark and one word, in the display face, at the far left of
+ * the menu bar -- the only place in the app that names the app. */
+define("brand", "default", ({ name = "FunPack" } = {}) =>
+ ({ node: el("span", { cls: "cx-brand", children: [
+ el("span", { cls: "cx-brand-mark", attrs: { "aria-hidden": "true" } }),
+ el("span", { cls: "cx-brand-name", text: name }),
+ ] }),
+ destroy() { this.node.remove(); } }));
+
+define("code", "inline", ({ text } = {}) =>
+ handle(el("code", { cls: "cx-code", text })));
+
+define("code", "block", ({ text, label } = {}) => {
+ const id = uid("code");
+ const pre = el("pre", { cls: "cx-code-block", text, attrs: { id, "aria-label": label } });
+ return handle(pre);
+});
diff --git a/app/ui/composer/elements/track.css b/app/ui/composer/elements/track.css
new file mode 100644
index 00000000..e8f29e39
--- /dev/null
+++ b/app/ui/composer/elements/track.css
@@ -0,0 +1,53 @@
+/* A real timeline: a ruler in seconds, clips as wide as they play for, and a
+ * playhead over both. Pixel-positioned rather than percent/flex-grow -- what
+ * makes the ruler's ticks and the clips underneath actually line up. */
+.cx-track { overflow-x: auto; min-inline-size: 0; }
+.cx-track-inner { position: relative; min-inline-size: 100%; }
+
+.cx-track-ruler {
+ position: relative; block-size: var(--lh-8); min-inline-size: 100%;
+ border-block-end: var(--bw) solid var(--line-2);
+}
+.cx-track-tick {
+ position: absolute; inset-block-start: 0;
+ padding-inline-start: var(--sp-1);
+ border-inline-start: var(--bw) solid var(--line);
+ font-size: var(--fs-8); line-height: var(--lh-8); color: var(--faint);
+ font-variant-numeric: tabular-nums;
+}
+
+/* A fixed row height, not aspect-ratio off the clip's own width -- a clip's
+ width now tracks real duration and can run to hundreds of pixels, and
+ 16:10 off THAT would make a half-minute scene taller than the window.
+ Positioned (not flex-flow) for the same reason each clip sets its own
+ insetInlineStart in JS: a clip under the pixel floor must not push every
+ clip after it out of step with the ruler and the playhead, which both
+ stay in pure seconds*pxPerSecond regardless of any floor. */
+.cx-track-clips { position: relative; min-inline-size: 100%; block-size: 45px; }
+.cx-track-clip {
+ position: absolute; inset-block: 0; padding: 0;
+ border: var(--bw) solid var(--line); border-radius: var(--r-2);
+ background: var(--void); overflow: hidden; cursor: grab;
+ display: flex; align-items: center; justify-content: center;
+}
+.cx-track-face { position: relative; inline-size: 100%; block-size: 100%; display: flex; align-items: center; justify-content: center; }
+.cx-track-clip:hover { border-color: var(--accent); }
+.cx-track-clip.cx-on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
+.cx-track-clip.cx-dragging { opacity: 0.4; cursor: grabbing; }
+/* Still on the timeline, still there to bring back -- excluded from a batch
+ run reads as dimmed, not gone, the way a deleted scene would be. */
+.cx-track-clip.cx-excluded { opacity: 0.45; }
+/* A clip carries its rating as a colour on its edge -- the one thing about a
+ result you want to see without selecting it. */
+.cx-track-clip.cx-rated { border-color: var(--muted); }
+.cx-track-clip[data-rating="liked"] { border-color: var(--good); }
+.cx-track-clip[data-rating="disliked"] { border-color: var(--danger); }
+
+/* Spans both rows, so where the ruler reads and where the clip sits are
+ visibly the same point -- a plain marker on just one of them would not
+ read as "this is where you are" the way a line through both does. */
+.cx-track-playhead {
+ position: absolute; inset-block: 0; inline-size: 0;
+ border-inline-start: 2px solid var(--accent-hi);
+ pointer-events: none; z-index: 1;
+}
diff --git a/app/ui/composer/elements/track.js b/app/ui/composer/elements/track.js
new file mode 100644
index 00000000..558d3498
--- /dev/null
+++ b/app/ui/composer/elements/track.js
@@ -0,0 +1,179 @@
+// A single time-based track: clips laid out by real duration, a ruler in
+// seconds, and a playhead -- the shape of an actual video timeline, not a
+// filmstrip of equal-sized thumbnails with a tick per item.
+//
+// One track, on purpose: v5 generates one clip per scene, in order, with no
+// separate audio/overlay layers to line up against it. A second track is a
+// different, larger feature (waveforms, compositing, independent trims) --
+// this is only ever asked to place things that already play back to back.
+
+import { define } from "../internals/register.js";
+import { el } from "../internals/el.js";
+import { wireDragReorder } from "../internals/dragReorder.js";
+
+/** A clip's face: the result so far, or the glyph that stands in for one --
+ * the same visual language cx-cell-thumb uses elsewhere in the kit, but
+ * filling the clip's own fixed-height box rather than a 16:10 box of its
+ * own: a clip's width tracks real duration, and that box can run to
+ * hundreds of pixels wide. */
+function faceOf(item) {
+ const thumb = el("div", { cls: "cx-track-face" });
+ const glyph = () => el("span", { cls: "cx-cell-glyph", text: item.icon || "▦", attrs: { "aria-hidden": "true" } });
+ if (item.thumb) {
+ const img = el("img", { cls: "cx-cell-img", attrs: { src: item.thumb, alt: "", loading: "lazy" } });
+ // A thumbnail that fails to load falls back to the glyph, not the
+ // browser's broken-image icon -- which would read as a damaged file.
+ img.addEventListener("error", () => img.replaceWith(glyph()));
+ thumb.append(img);
+ } else {
+ thumb.append(glyph());
+ }
+ if (item.badge) thumb.append(el("span", { cls: "cx-cell-badge", text: item.badge }));
+ return thumb;
+}
+
+// A tick every 1/2/5/10... seconds, whichever is the smallest that still
+// leaves room to read the label beside it. Fixed intervals rather than a tick
+// per clip: what a person reads off a ruler is elapsed TIME, and a tick per
+// scene is the scene-index ruler this replaces.
+const NICE_INTERVALS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
+const MIN_TICK_PX = 56;
+
+function tickInterval(pxPerSecond) {
+ for (const s of NICE_INTERVALS) if (s * pxPerSecond >= MIN_TICK_PX) return s;
+ return NICE_INTERVALS[NICE_INTERVALS.length - 1];
+}
+
+function formatTime(seconds) {
+ const whole = Math.max(0, Math.round(seconds));
+ const m = Math.floor(whole / 60);
+ const s = whole % 60;
+ return `${m}:${String(s).padStart(2, "0")}`;
+}
+
+// A floor, not a cap: a scene under a second would otherwise render as a
+// sliver nothing could click or even see next to a normal one. Everything
+// else stays exactly proportional -- this only rescues the degenerate case.
+//
+// Known, accepted limit: several floored clips in a row still paint on top of
+// each other (later DOM order wins), so a run of very short adjacent scenes
+// can visually collapse into just the last one. Fixing that by shifting later
+// clips right to make room was tried and rejected -- the pushed offset never
+// resolves once introduced (nothing shrinks to give the room back), which
+// desyncs every clip after the run from the ruler and playhead for the rest
+// of the track. That failure is worse than an occasional occluded sliver, so
+// exact position over the seconds axis wins; seeking by click still resolves
+// correctly either way, since that reads time, not which clip is on top.
+const MIN_CLIP_PX = 24;
+
+/**
+ * track.default({ items, pxPerSecond, selection, playhead, label,
+ * onSeek, onReorder })
+ *
+ * `items`: [{ id, label, thumb, icon, badge, rating, excluded, start,
+ * duration }] -- start/duration in SECONDS, contiguous (each
+ * item's start is the previous one's start + duration), which is what lets
+ * one click handler double as both "seek" and "select": every point on the
+ * track belongs to exactly one clip. The track's own span is read off the
+ * items themselves (the last one's start + duration) -- nothing else here
+ * knows the project's length.
+ *
+ * `onSeek(seconds, item)` fires on any click on the track -- the ruler, the
+ * empty background, or a clip. `item` is whichever clip's span the point
+ * (or, for a clip itself, the clip clicked) falls in.
+ */
+define("track", "default", ({
+ items = [], pxPerSecond: initialPxPerSecond = 40, selection = [], playhead = 0,
+ label, onSeek, onReorder,
+} = {}) => {
+ let pxPerSecond = initialPxPerSecond;
+ const chosen = new Set(selection);
+ let current = items;
+ const spanOf = () => current.reduce((max, i) => Math.max(max, i.start + i.duration), 0);
+
+ const node = el("div", { cls: "cx-track", attrs: { role: "presentation", "aria-label": label } });
+ const inner = el("div", { cls: "cx-track-inner" });
+ const ruler = el("div", { cls: "cx-track-ruler", attrs: { role: "presentation" } });
+ const clips = el("div", { cls: "cx-track-clips", attrs: { role: "listbox", "aria-label": label } });
+ const head = el("div", { cls: "cx-track-playhead", attrs: { "aria-hidden": "true" } });
+ inner.append(ruler, clips, head);
+ node.append(inner);
+
+ function drawRuler(span) {
+ ruler.replaceChildren();
+ const interval = tickInterval(pxPerSecond);
+ for (let t = 0; t <= span; t += interval) {
+ const tick = el("span", { cls: "cx-track-tick", text: formatTime(t) });
+ tick.style.insetInlineStart = `${t * pxPerSecond}px`;
+ ruler.append(tick);
+ }
+ }
+
+ function drawClips() {
+ clips.replaceChildren();
+ for (const item of current) {
+ const on = chosen.has(item.id);
+ const cell = el("button", {
+ cls: ["cx-track-clip", on ? "cx-on" : null, item.rating ? "cx-rated" : null,
+ item.excluded ? "cx-excluded" : null, "cx-focusable"],
+ attrs: {
+ type: "button", role: "option", "aria-selected": String(on), title: item.label,
+ "aria-label": item.label, "data-rating": item.rating || undefined,
+ "data-id": item.id,
+ // Draggable whether or not `onReorder` is given, matching the
+ // strip: a drag with nowhere to report to is inert, not wired.
+ draggable: "true",
+ },
+ children: [faceOf(item)],
+ });
+ // Positioned from its own start, not stacked after the previous clip in
+ // normal flow -- a clip under the pixel floor would otherwise push
+ // every clip after it further right than the ruler and the playhead,
+ // which both stay in pure seconds*pxPerSecond, ever agree it is.
+ cell.style.insetInlineStart = `${item.start * pxPerSecond}px`;
+ cell.style.width = `${Math.max(MIN_CLIP_PX, item.duration * pxPerSecond)}px`;
+ clips.append(cell);
+ }
+ }
+
+ function setPlayhead(seconds) {
+ head.style.insetInlineStart = `${Math.max(0, seconds) * pxPerSecond}px`;
+ }
+
+ function draw() {
+ const span = spanOf();
+ inner.style.width = `${span * pxPerSecond}px`;
+ drawRuler(span);
+ drawClips();
+ setPlayhead(playhead);
+ }
+
+ wireDragReorder(clips, ".cx-track-clip", onReorder);
+
+ // One handler for the whole track: a click's OWN position is turned into a
+ // time, the same way a scrub bar would, whether it landed on the ruler, the
+ // empty background, or a clip -- a clip covers most of the track's area, so
+ // reading its id instead of where on it the click landed would make the
+ // ruler the only place a real, exact-second seek ever reaches. Keyboard
+ // activation (Enter/Space on a focused clip) still resolves correctly: the
+ // browser reports the click at that button's own on-screen position, which
+ // is well inside the right clip's span even if not at its exact start.
+ inner.addEventListener("click", (e) => {
+ if (!onSeek) return;
+ const rect = inner.getBoundingClientRect();
+ const seconds = Math.min(spanOf(), Math.max(0, (e.clientX - rect.left) / pxPerSecond));
+ const item = current.find((i) => seconds < i.start + i.duration) || current[current.length - 1] || null;
+ onSeek(seconds, item);
+ });
+
+ draw();
+
+ return {
+ node,
+ setItems(next = []) { current = next; draw(); },
+ setValue(next = []) { chosen.clear(); for (const id of next) chosen.add(id); drawClips(); },
+ setPlayhead(seconds) { playhead = seconds; setPlayhead(seconds); },
+ setZoom(next) { pxPerSecond = next; draw(); },
+ destroy: () => node.remove(),
+ };
+});
diff --git a/app/ui/composer/elements/wheel.css b/app/ui/composer/elements/wheel.css
new file mode 100644
index 00000000..f1f0849b
--- /dev/null
+++ b/app/ui/composer/elements/wheel.css
@@ -0,0 +1,62 @@
+/* The ring is an INSET shadow, not a border. A border pushes every absolutely
+ * positioned child inward by its own width, which put the hub a pixel past the
+ * screen edge on an edge panel -- the one place a pixel shows. */
+.cx-wheel {
+ position: fixed; pointer-events: auto; border-radius: var(--r-pill);
+ background: color-mix(in srgb, var(--elev-3) 92%, transparent);
+ box-shadow: var(--shadow-3), inset 0 0 0 var(--bw) var(--line);
+ backdrop-filter: blur(6px);
+ animation: cx-wheel-in var(--dur-1) var(--ease-standard);
+}
+@keyframes cx-wheel-in { from { opacity: 0; transform: scale(.9); } }
+
+/* The lit shape IS the region that picks the item, so the target is legible
+ * instead of implied by a box around a label. */
+.cx-wheel-canvas { position: absolute; inset: 0; pointer-events: none; }
+.cx-wheel-wedge {
+ fill: transparent;
+ stroke: color-mix(in srgb, var(--line) 70%, transparent);
+ stroke-width: 1;
+ transition: fill var(--dur-1) var(--ease-standard), stroke var(--dur-1) var(--ease-standard);
+}
+.cx-wheel-wedge.cx-on { fill: var(--accent-soft); stroke: var(--accent); }
+
+.cx-wheel-item {
+ position: absolute; transform: translate(-50%, -50%);
+ display: flex; flex-direction: column; align-items: center; gap: 2px;
+ min-width: 52px; padding: var(--sp-1) var(--sp-2);
+ background: transparent; border: 0; color: var(--muted);
+ font-size: var(--fs-7); cursor: pointer; text-align: center;
+ transition: color var(--dur-1) var(--ease-standard),
+ transform var(--dur-1) var(--ease-standard);
+}
+.cx-wheel-item .cx-wheel-icon { font-size: var(--fs-2); line-height: 1; }
+.cx-wheel-item.cx-on {
+ color: var(--accent-hi);
+ transform: translate(-50%, -50%) scale(1.06);
+}
+
+/* Sized to the dead zone exactly (radius 42), so the cancel target you can see
+ * is the cancel target you can hit. */
+/* left/top/size come from the geometry so the drawn hub and the cancel region
+ * are the same circle. */
+.cx-wheel-hub {
+ position: absolute; transform: translate(-50%, -50%);
+ border-radius: var(--r-pill);
+ border: var(--bw) dashed var(--line);
+ display: flex; align-items: center; justify-content: center;
+ color: var(--faint);
+ transition: border-color var(--dur-1) var(--ease-standard),
+ color var(--dur-1) var(--ease-standard),
+ background var(--dur-1) var(--ease-standard);
+}
+.cx-wheel-hub-glyph { font-size: var(--fs-2); line-height: 1; }
+.cx-wheel-hub.cx-on {
+ border-color: var(--danger); color: var(--danger);
+ background: color-mix(in srgb, var(--danger) 12%, transparent);
+}
+
+/* Half wheels sit ON the edge: the off-screen half is simply clipped by the
+ * viewport, which is what gives the edge-panel silhouette. The flat side gets a
+ * straight edge so it reads as pinned rather than cropped by accident. */
+.cx-wheel-half { backdrop-filter: blur(8px); }
diff --git a/app/ui/composer/elements/wheel.js b/app/ui/composer/elements/wheel.js
new file mode 100644
index 00000000..cac34e92
--- /dev/null
+++ b/app/ui/composer/elements/wheel.js
@@ -0,0 +1,330 @@
+// Radial pickers: the full wheel, and the half wheel pinned to a screen edge.
+//
+// Nothing in v4 resembles these. They are for the handful of choices made over
+// and over during editing, where a menu costs a read-and-aim every time: press,
+// flick toward the wedge you already know the position of, release. The muscle
+// memory is the feature -- the same choice always lives at the same angle.
+//
+// One GEOMETRY object per variant drives the drawn wedges, the label placement,
+// the hub and the hit test, so what you see and what you can hit cannot drift
+// apart.
+
+import { define } from "../internals/register.js";
+import { el, svg } from "../internals/el.js";
+import { mount, unmount } from "../internals/portal.js";
+import { claimFor } from "../internals/zlayer.js";
+import { push } from "../internals/dismiss.js";
+
+const MIN = 2;
+const MAX = 12;
+
+/** The full circle, opened wherever the pointer is. */
+export const FULL = {
+ size: 240,
+ rIn: 42, rOut: 117, rLabel: 84,
+ hub: { r: 42, x: 0, y: 0 },
+};
+
+/**
+ * Half a circle, centred ON an edge.
+ *
+ * The hub is pushed inward by its own radius so it sits fully on screen -- a hub
+ * centred on the edge is sliced in half by it, and half a cancel target is not
+ * a target. Everything else is sized around that: the wedges start beyond the
+ * hub so the two never overlap.
+ */
+export const HALF = {
+ size: 300,
+ rIn: 84, rOut: 147, rLabel: 115,
+ hub: { r: 40, x: 0, y: 0 }, // x/y filled in per edge, below
+};
+
+// Which half faces inward from each edge, where a panel pinned there sits, and
+// which way "inward" points. An edge panel opens AWAY from its edge, which is
+// the whole reason it works with a thumb: the arc lands where the thumb is.
+export const EDGES = {
+ right: { startDeg: 180, spanDeg: 180, inward: [-1, 0], anchor: (w, h, at) => [w, at ?? h / 2] },
+ left: { startDeg: 0, spanDeg: 180, inward: [1, 0], anchor: (w, h, at) => [0, at ?? h / 2] },
+ bottom: { startDeg: 270, spanDeg: 180, inward: [0, -1], anchor: (w, h, at) => [at ?? w / 2, h] },
+ top: { startDeg: 90, spanDeg: 180, inward: [0, 1], anchor: (w, h, at) => [at ?? w / 2, 0] },
+};
+
+const point = (cx, cy, radius, degrees) => {
+ const rad = (degrees - 90) * (Math.PI / 180);
+ return [cx + Math.cos(rad) * radius, cy + Math.sin(rad) * radius];
+};
+
+/**
+ * Which sector of an arc a point falls in; -1 for the hub or anywhere off the
+ * ring.
+ *
+ * Angles run clockwise from straight up, so 0 is 12 o'clock and the arrangement
+ * matches how people describe it ("the one at the top"). Everything radial goes
+ * through here, so a full wheel and an edge panel cannot disagree about which
+ * item a direction means.
+ *
+ * The bounds matter as much as the angle: the lit wedge IS the region that
+ * picks the item, so a pointer beyond the rim -- or over the hub -- is pointing
+ * at nothing, and highlighting an item there would promise a pick that release
+ * would not deliver.
+ */
+export function sectorInArc(dx, dy, count, {
+ startDeg = 0, spanDeg = 360,
+ rIn = FULL.rIn, rOut = FULL.rOut,
+ hubX = 0, hubY = 0, hubR = rIn,
+ bounded = true,
+} = {}) {
+ if (Math.hypot(dx - hubX, dy - hubY) < hubR) return -1; // over the hub: cancel
+ const distance = Math.hypot(dx, dy);
+ if (bounded && (distance < rIn || distance > rOut)) return -1;
+
+ const angle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
+ const rel = (angle - startDeg + 720) % 360;
+ if (rel > spanDeg) return -1; // outside a partial arc
+ return Math.min(Math.floor(rel / (spanDeg / count)), count - 1);
+}
+
+/** The full circle: item 0 centred at the top. */
+export function sectorAt(dx, dy, count, deadZone = FULL.hub.r) {
+ return sectorInArc(dx, dy, count, {
+ startDeg: -(360 / count) / 2, spanDeg: 360,
+ rIn: deadZone, rOut: FULL.rOut, hubR: deadZone,
+ });
+}
+
+/**
+ * An annular wedge: the actual area that picks this item.
+ *
+ * Highlighting the wedge rather than a box around the label is what makes the
+ * target legible -- the lit shape IS the region your pointer has to be in, so
+ * aiming stops being a guess about where one item's zone ends.
+ */
+export function wedgePath(index, count, geo = {}) {
+ const {
+ size = FULL.size, rIn = FULL.rIn, rOut = FULL.rOut,
+ startDeg = -(360 / count) / 2, spanDeg = 360,
+ } = geo;
+ const cx = size / 2;
+ const cy = size / 2;
+ const slice = spanDeg / count;
+ const start = startDeg + index * slice;
+ const end = start + slice;
+ const largeArc = slice > 180 ? 1 : 0;
+
+ const [x1, y1] = point(cx, cy, rOut, start);
+ const [x2, y2] = point(cx, cy, rOut, end);
+ const [x3, y3] = point(cx, cy, rIn, end);
+ const [x4, y4] = point(cx, cy, rIn, start);
+
+ const f = (n) => Number(n.toFixed(2));
+ return `M ${f(x1)} ${f(y1)} A ${rOut} ${rOut} 0 ${largeArc} 1 ${f(x2)} ${f(y2)} ` +
+ `L ${f(x3)} ${f(y3)} A ${rIn} ${rIn} 0 ${largeArc} 0 ${f(x4)} ${f(y4)} Z`;
+}
+
+function buildWheel({ items, onPick, onClose, geo, recentre, cancelOnCentre, cls, hubText }) {
+ if (items.length < MIN || items.length > MAX) {
+ throw new RangeError(`A picker takes ${MIN}-${MAX} items; got ${items.length}. More than that and nobody can aim.`);
+ }
+
+ const { size } = geo;
+ const centre = recentre();
+
+ const node = el("div", { cls, attrs: { role: "menu", "aria-label": "Quick pick" } });
+ const layer = claimFor("popover", node);
+ node.style.width = `${size}px`;
+ node.style.height = `${size}px`;
+
+ const place = () => {
+ node.style.left = `${centre.x - size / 2}px`;
+ node.style.top = `${centre.y - size / 2}px`;
+ };
+ place();
+
+ let active = -1;
+
+ // The wedges are drawn behind the labels, so the highlight is the pickable
+ // region itself rather than a box around some text.
+ const canvas = svg("svg", { class: "cx-wheel-canvas", viewBox: `0 0 ${size} ${size}`,
+ width: size, height: size, "aria-hidden": "true" });
+ const shapes = items.map((_, i) => {
+ const path = svg("path", { class: "cx-wheel-wedge", d: wedgePath(i, items.length, geo) });
+ canvas.append(path);
+ return path;
+ });
+ node.append(canvas);
+
+ const slice = geo.spanDeg / items.length;
+ const wedges = items.map((item, i) => {
+ const angle = geo.startDeg + slice * (i + 0.5);
+ const wedge = el("button", {
+ cls: ["cx-wheel-item", "cx-focusable"],
+ attrs: { type: "button", role: "menuitem", title: item.label },
+ children: [
+ item.icon ? el("span", { cls: "cx-wheel-icon", text: item.icon, attrs: { "aria-hidden": "true" } }) : null,
+ el("span", { cls: "cx-wheel-label", text: item.label }),
+ ].filter(Boolean),
+ });
+ // Placed on a circle rather than drawn as a pie slice: a round label that
+ // stays upright is far easier to read than text bent around a wedge.
+ const [lx, ly] = point(size / 2, size / 2, geo.rLabel, angle);
+ wedge.style.left = `${lx}px`;
+ wedge.style.top = `${ly}px`;
+ wedge.addEventListener("click", () => commit(i));
+ node.append(wedge);
+ return wedge;
+ });
+
+ // A glyph, not a sentence: the hub is small and round, and wrapped text in a
+ // circle reads badly at any size. The label survives as the accessible name.
+ const hub = el("div", {
+ cls: "cx-wheel-hub",
+ attrs: cancelOnCentre ? { role: "img", "aria-label": hubText, title: hubText } : { "aria-hidden": "true" },
+ children: cancelOnCentre ? el("span", { cls: "cx-wheel-hub-glyph", text: "✕" }) : null,
+ });
+ hub.style.width = `${geo.hub.r * 2}px`;
+ hub.style.height = `${geo.hub.r * 2}px`;
+ hub.style.left = `${size / 2 + geo.hub.x}px`;
+ hub.style.top = `${size / 2 + geo.hub.y}px`;
+ node.append(hub);
+
+ function highlight(index) {
+ active = index;
+ wedges.forEach((w, i) => w.classList.toggle("cx-on", i === index));
+ shapes.forEach((sh, i) => sh.classList.toggle("cx-on", i === index));
+ hub.classList.toggle("cx-on", index === -1);
+ }
+
+ function commit(index) {
+ if (index >= 0 && items[index] && onPick) onPick(items[index]);
+ handle.close(index >= 0 ? "picked" : "cancelled");
+ }
+
+ // Press-and-flick: the pointer never has to land on the item, only point at
+ // it, which is what makes this faster than a menu.
+ //
+ // `armed` exists because the click that OPENS the wheel also delivers a
+ // pointerup to this listener, at wherever the opening control happened to be
+ // -- which committed whatever wedge that direction pointed at, instantly.
+ // Requiring a pointermove first means a release only counts once the pointer
+ // has actually gone somewhere.
+ let armed = false;
+
+ const at = (event) => sectorInArc(
+ event.clientX - centre.x, event.clientY - centre.y, items.length,
+ { ...geo, hubX: geo.hub.x, hubY: geo.hub.y, hubR: cancelOnCentre ? geo.hub.r : 0 });
+
+ const onMove = (event) => { armed = true; highlight(at(event)); };
+ const onUp = (event) => {
+ if (!armed) return;
+ armed = false;
+ const index = at(event);
+ if (index >= 0) commit(index);
+ };
+
+ // An edge panel is pinned to an edge, so the edge moving is the one thing it
+ // must follow. Without this it keeps the pixel position it opened at and
+ // drifts into the middle of the window on a resize.
+ const onResize = () => {
+ Object.assign(centre, recentre());
+ place();
+ };
+ window.addEventListener("resize", onResize);
+ // visualViewport covers what window.resize misses: pinch-zoom, and mobile
+ // browser chrome sliding in and out, both of which move the edge without
+ // changing innerWidth.
+ window.visualViewport?.addEventListener("resize", onResize);
+
+ const onKeyDown = (event) => {
+ const n = items.length;
+ if (/^[1-9]$/.test(event.key)) {
+ const i = Number(event.key) - 1;
+ if (i < n) { event.preventDefault(); commit(i); }
+ } else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
+ event.preventDefault(); highlight(((active < 0 ? -1 : active) + 1 + n) % n);
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
+ event.preventDefault(); highlight(((active < 0 ? 1 : active) - 1 + n) % n);
+ } else if (event.key === "Enter" && active >= 0) {
+ event.preventDefault(); commit(active);
+ }
+ };
+
+ mount(node);
+ window.addEventListener("pointermove", onMove);
+ window.addEventListener("pointerup", onUp);
+ window.addEventListener("keydown", onKeyDown);
+
+ const dismissal = push({ nodes: node, onDismiss: (reason) => handle.close(reason) });
+
+ let closed = false;
+ const handle = {
+ node,
+ isOverlay: true,
+ get active() { return active; },
+ get centre() { return { ...centre }; },
+ highlight,
+ close(reason = "manual") {
+ if (closed) return;
+ closed = true;
+ window.removeEventListener("pointermove", onMove);
+ window.removeEventListener("pointerup", onUp);
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener("resize", onResize);
+ window.visualViewport?.removeEventListener("resize", onResize);
+ dismissal.release();
+ layer.release();
+ unmount(node);
+ if (onClose) onClose(reason);
+ },
+ destroy() { handle.close("destroy"); },
+ };
+ return handle;
+}
+
+define("wheel", "picker", ({ items = [], onPick, onClose, x, y, cancelOnCentre = true } = {}) =>
+ buildWheel({
+ items, onPick, onClose, cancelOnCentre,
+ cls: "cx-wheel",
+ hubText: "Release here to cancel",
+ geo: { ...FULL, startDeg: -(360 / Math.max(items.length, 1)) / 2, spanDeg: 360 },
+ recentre: () => ({
+ x: x ?? Math.round(window.innerWidth / 2),
+ y: y ?? Math.round(window.innerHeight / 2),
+ }),
+ }));
+
+/**
+ * Half a wheel, pinned to a screen edge -- the Samsung edge-panel shape.
+ *
+ * A control living at the edge has nowhere to open a full wheel: half of it
+ * would be off-screen, and the items that landed there would be unreachable.
+ * This puts the centre ON the edge and fans the items into the screen, so the
+ * arc lands exactly where the thumb already is and every item is in reach.
+ *
+ * `at` is the position along that edge (y for left/right, x for top/bottom);
+ * it defaults to the middle, and is re-derived whenever the window resizes.
+ */
+define("wheel", "half", ({ items = [], edge = "right", at, onPick, onClose, cancelOnCentre = true } = {}) => {
+ const spec = EDGES[edge];
+ if (!spec) throw new RangeError(`Unknown edge "${edge}". Known: ${Object.keys(EDGES).join(", ")}.`);
+
+ const [ix, iy] = spec.inward;
+ const geo = {
+ ...HALF,
+ startDeg: spec.startDeg,
+ spanDeg: spec.spanDeg,
+ // Pushed inward by its own radius, so the whole hub is on screen instead of
+ // being sliced down the middle by the edge it sits on.
+ hub: { r: HALF.hub.r, x: ix * HALF.hub.r, y: iy * HALF.hub.r },
+ };
+
+ return buildWheel({
+ items, onPick, onClose, cancelOnCentre,
+ cls: `cx-wheel cx-wheel-half cx-wheel-${edge}`,
+ hubText: "Release here to cancel",
+ geo,
+ recentre: () => {
+ const [cx, cy] = spec.anchor(window.innerWidth, window.innerHeight, at);
+ return { x: cx, y: cy };
+ },
+ });
+});
diff --git a/app/ui/composer/internals/anchor.js b/app/ui/composer/internals/anchor.js
new file mode 100644
index 00000000..31299d94
--- /dev/null
+++ b/app/ui/composer/internals/anchor.js
@@ -0,0 +1,113 @@
+// Measure, flip, clamp -- once.
+//
+// v4 had seven hand-rolled copies of this (autocomplete, suggestions, rating
+// picker, role menu, choice picker, timeline dropdown, composer add-menu), each
+// with its own idea of what to do near an edge. The geometry here is pure and
+// takes plain rectangles, so it can be tested exhaustively without a browser;
+// `anchorTo` is the thin part that touches the DOM.
+
+const OPPOSITE = { top: "bottom", bottom: "top", left: "right", right: "left" };
+
+/**
+ * place({ anchor, float, viewport, side, align, gap, flip, clamp, padding })
+ *
+ * anchor {x, y, width, height} the thing being pointed at, in viewport coords
+ * float {width, height} the thing being positioned
+ * viewport {width, height}
+ *
+ * Returns { x, y, side, align, flipped, clamped }. `side` is where the float
+ * ENDED UP, which is what a caller needs to point an arrow the right way.
+ */
+export function place({
+ anchor,
+ float,
+ viewport,
+ side = "bottom",
+ align = "start",
+ gap = 6,
+ flip = true,
+ clamp = true,
+ padding = 8,
+} = {}) {
+ if (!OPPOSITE[side]) throw new RangeError(`Unknown side "${side}".`);
+
+ let chosen = side;
+ let flipped = false;
+
+ if (flip && !fits(chosen, anchor, float, viewport, gap, padding)) {
+ const other = OPPOSITE[chosen];
+ // Only flip if the other side is genuinely better -- flipping into an even
+ // tighter gap just moves the clipping somewhere less expected.
+ if (room(other, anchor, viewport) > room(chosen, anchor, viewport)) {
+ chosen = other;
+ flipped = true;
+ }
+ }
+
+ let { x, y } = position(chosen, align, anchor, float, gap);
+ let clamped = false;
+
+ if (clamp) {
+ const cx = Math.min(Math.max(x, padding), Math.max(padding, viewport.width - float.width - padding));
+ const cy = Math.min(Math.max(y, padding), Math.max(padding, viewport.height - float.height - padding));
+ clamped = cx !== x || cy !== y;
+ x = cx;
+ y = cy;
+ }
+
+ return { x: Math.round(x), y: Math.round(y), side: chosen, align, flipped, clamped };
+}
+
+function room(side, anchor, viewport) {
+ switch (side) {
+ case "top": return anchor.y;
+ case "bottom": return viewport.height - (anchor.y + anchor.height);
+ case "left": return anchor.x;
+ case "right": return viewport.width - (anchor.x + anchor.width);
+ default: return 0;
+ }
+}
+
+function fits(side, anchor, float, viewport, gap, padding) {
+ const needed = (side === "top" || side === "bottom" ? float.height : float.width) + gap + padding;
+ return room(side, anchor, viewport) >= needed;
+}
+
+function position(side, align, anchor, float, gap) {
+ const vertical = side === "top" || side === "bottom";
+ const main = vertical
+ ? (side === "bottom" ? anchor.y + anchor.height + gap : anchor.y - float.height - gap)
+ : (side === "right" ? anchor.x + anchor.width + gap : anchor.x - float.width - gap);
+
+ const anchorStart = vertical ? anchor.x : anchor.y;
+ const anchorSize = vertical ? anchor.width : anchor.height;
+ const floatSize = vertical ? float.width : float.height;
+
+ let cross;
+ if (align === "center") cross = anchorStart + anchorSize / 2 - floatSize / 2;
+ else if (align === "end") cross = anchorStart + anchorSize - floatSize;
+ else cross = anchorStart;
+
+ return vertical ? { x: cross, y: main } : { x: main, y: cross };
+}
+
+/**
+ * Position `floatNode` against `anchorNode`. The float must already be in the
+ * document and visible to measure -- a hidden element measures as 0x0 and lands
+ * in the corner, which is how "my popover appears top-left" bugs happen.
+ */
+export function anchorTo(floatNode, anchorNode, opts = {}) {
+ const a = anchorNode.getBoundingClientRect();
+ const f = floatNode.getBoundingClientRect();
+ const result = place({
+ anchor: { x: a.x, y: a.y, width: a.width, height: a.height },
+ float: { width: f.width, height: f.height },
+ viewport: { width: window.innerWidth, height: window.innerHeight },
+ ...opts,
+ });
+ floatNode.style.position = "fixed";
+ floatNode.style.left = `${result.x}px`;
+ floatNode.style.top = `${result.y}px`;
+ floatNode.dataset.side = result.side;
+ return result;
+}
diff --git a/app/ui/composer/internals/density.js b/app/ui/composer/internals/density.js
new file mode 100644
index 00000000..e762e1e2
--- /dev/null
+++ b/app/ui/composer/internals/density.js
@@ -0,0 +1,56 @@
+// Adaptive sizing from the CONTAINER, not the viewport.
+//
+// v4's media bin got this right and nothing else used it: a panel that is 280px
+// wide should lay out like a 280px panel whether the window is 1200px or 3000px.
+// The column ratios there were hand-tuned (92/44/29/21 cqw) and drifted from the
+// gaps they were meant to account for; here they are derived.
+
+export const AUTO = 0;
+export const MAX_COLS = 4;
+
+/**
+ * Cell width for `cols` columns inside a container, as a cqw percentage,
+ * accounting for the gaps between them. cols = 0 means "as many as fit".
+ */
+export function cellWidth(cols, gapPercent = 2.5) {
+ if (!Number.isInteger(cols) || cols < 1) return null; // auto
+ const gaps = (cols - 1) * gapPercent;
+ return Math.max(1, Number(((100 - gaps) / cols).toFixed(2)));
+}
+
+/** The CSS value for `--cell` at a given column count. */
+export function cellExpression(cols) {
+ const pct = cellWidth(cols);
+ return pct === null
+ ? "clamp(var(--cell-thumb), 46cqw, var(--cell-panel))"
+ : `${pct}cqw`;
+}
+
+export function normaliseCols(value) {
+ const n = Number(value);
+ if (!Number.isFinite(n)) return AUTO;
+ return Math.min(MAX_COLS, Math.max(AUTO, Math.trunc(n)));
+}
+
+/** Apply a column choice to a grid host. */
+export function applyDensity(host, cols) {
+ const n = normaliseCols(cols);
+ host.dataset.cols = String(n);
+ host.style.setProperty("--cell", cellExpression(n === AUTO ? null : n));
+ return n;
+}
+
+// Per-viewer convenience, not state anything depends on: a browser that refuses
+// storage should still get a working grid.
+export function rememberCols(id, cols) {
+ try { localStorage.setItem(`cx.density.${id}`, String(normaliseCols(cols))); } catch { /* private mode */ }
+}
+
+export function recallCols(id, fallback = AUTO) {
+ try {
+ const raw = localStorage.getItem(`cx.density.${id}`);
+ return raw === null ? fallback : normaliseCols(raw);
+ } catch {
+ return fallback;
+ }
+}
diff --git a/app/ui/composer/internals/dismiss.js b/app/ui/composer/internals/dismiss.js
new file mode 100644
index 00000000..f168067d
--- /dev/null
+++ b/app/ui/composer/internals/dismiss.js
@@ -0,0 +1,96 @@
+// One dismissal stack for the whole app.
+//
+// Each overlay in v4 wired its own outside-click and Escape handler, so Escape
+// inside a modal with an open autocomplete closed the modal -- every listener
+// fired, and none of them knew about the others. Here there is a single pair of
+// document listeners over a stack, and Escape pops the TOP entry only.
+
+const stack = [];
+let listening = false;
+
+function onKeyDown(event) {
+ if (event.key !== "Escape" || !stack.length) return;
+ const top = stack[stack.length - 1];
+ if (!top.closeOnEsc) return;
+ // stopPropagation matters: without it a parent's own key handler also runs
+ // and closes a second layer on one press.
+ event.stopPropagation();
+ event.preventDefault();
+ top.dismiss("escape");
+}
+
+function onPointerDown(event) {
+ if (!stack.length) return;
+ const top = stack[stack.length - 1];
+ if (!top.closeOnOutside) return;
+ for (const node of top.nodes) {
+ // Guarded: an anchor can be a VIRTUAL point (a context menu is anchored to
+ // coordinates, not an element), and those have no .contains. Letting one
+ // through threw on the first click anywhere in the app.
+ if (node && typeof node.contains === "function" && node.contains(event.target)) return;
+ }
+ top.dismiss("outside");
+}
+
+function listen() {
+ if (listening) return;
+ // Capture phase, so an overlay that stops propagation on its own content
+ // cannot accidentally suppress dismissal of something above it.
+ document.addEventListener("keydown", onKeyDown, true);
+ document.addEventListener("pointerdown", onPointerDown, true);
+ listening = true;
+}
+
+function unlisten() {
+ if (!listening) return;
+ document.removeEventListener("keydown", onKeyDown, true);
+ document.removeEventListener("pointerdown", onPointerDown, true);
+ listening = false;
+}
+
+/**
+ * push({ nodes, onDismiss, closeOnEsc, closeOnOutside }) -> handle
+ *
+ * `nodes` are the regions that count as "inside" -- a popover plus the control
+ * that opened it, so clicking the trigger again does not dismiss-then-reopen.
+ */
+export function push({ nodes = [], onDismiss, closeOnEsc = true, closeOnOutside = true } = {}) {
+ const entry = {
+ // Only real nodes can answer "was the click inside me".
+ nodes: [].concat(nodes).filter((n) => n && typeof n.contains === "function"),
+ closeOnEsc,
+ closeOnOutside,
+ done: false,
+ dismiss(reason) {
+ if (entry.done) return;
+ entry.done = true;
+ remove(entry);
+ if (onDismiss) onDismiss(reason);
+ },
+ };
+ stack.push(entry);
+ listen();
+ return {
+ dismiss: (reason = "manual") => entry.dismiss(reason),
+ // Closing without running onDismiss: the caller is already tearing down.
+ release() {
+ entry.done = true;
+ remove(entry);
+ },
+ get depth() { return stack.indexOf(entry) + 1; },
+ get isTop() { return stack[stack.length - 1] === entry; },
+ };
+}
+
+function remove(entry) {
+ const i = stack.indexOf(entry);
+ if (i !== -1) stack.splice(i, 1);
+ if (!stack.length) unlisten();
+}
+
+export const depth = () => stack.length;
+
+export function _resetDismiss() {
+ stack.length = 0;
+ unlisten();
+}
diff --git a/app/ui/composer/internals/drag.js b/app/ui/composer/internals/drag.js
new file mode 100644
index 00000000..8f3ed7df
--- /dev/null
+++ b/app/ui/composer/internals/drag.js
@@ -0,0 +1,64 @@
+// Pointer drags for windows, sliders and splitters.
+//
+// Pointer capture rather than document-level listeners: capture follows the
+// pointer outside the window and guarantees a matching pointerup, so a drag
+// cannot be left running because the cursor left the viewport mid-gesture.
+
+/**
+ * drag(handle, { onStart, onMove, onEnd, button })
+ *
+ * onMove receives { dx, dy, x, y } relative to where the drag began.
+ * Returns a dispose function.
+ */
+export function drag(handle, { onStart, onMove, onEnd, button = 0 } = {}) {
+ let origin = null;
+ let pointerId = null;
+
+ const onPointerDown = (event) => {
+ if (event.button !== button || origin) return;
+ origin = { x: event.clientX, y: event.clientY };
+ pointerId = event.pointerId;
+ if (handle.setPointerCapture) handle.setPointerCapture(pointerId);
+ handle.addEventListener("pointermove", onPointerMove);
+ handle.addEventListener("pointerup", onPointerUp);
+ handle.addEventListener("pointercancel", onPointerUp);
+ event.preventDefault();
+ if (onStart) onStart({ x: origin.x, y: origin.y, event });
+ };
+
+ const delta = (event) => ({
+ dx: event.clientX - origin.x,
+ dy: event.clientY - origin.y,
+ x: event.clientX,
+ y: event.clientY,
+ });
+
+ const onPointerMove = (event) => {
+ if (!origin || event.pointerId !== pointerId) return;
+ if (onMove) onMove({ ...delta(event), event });
+ };
+
+ const onPointerUp = (event) => {
+ if (!origin || event.pointerId !== pointerId) return;
+ const final = delta(event);
+ stop();
+ if (onEnd) onEnd({ ...final, event, cancelled: event.type === "pointercancel" });
+ };
+
+ function stop() {
+ handle.removeEventListener("pointermove", onPointerMove);
+ handle.removeEventListener("pointerup", onPointerUp);
+ handle.removeEventListener("pointercancel", onPointerUp);
+ if (pointerId !== null && handle.releasePointerCapture && handle.hasPointerCapture?.(pointerId)) {
+ handle.releasePointerCapture(pointerId);
+ }
+ origin = null;
+ pointerId = null;
+ }
+
+ handle.addEventListener("pointerdown", onPointerDown);
+ return function dispose() {
+ stop();
+ handle.removeEventListener("pointerdown", onPointerDown);
+ };
+}
diff --git a/app/ui/composer/internals/dragReorder.js b/app/ui/composer/internals/dragReorder.js
new file mode 100644
index 00000000..e0e93af2
--- /dev/null
+++ b/app/ui/composer/internals/dragReorder.js
@@ -0,0 +1,46 @@
+// Reordering a row of id-tagged children by drag, shared by every element that
+// has one (the scene strip, the timeline track).
+//
+// Delegated on the container: cells are torn down and rebuilt on every redraw,
+// and listeners bound to them would have to be reattached every time. One set
+// here survives all of that.
+//
+// The dragged item's id lives on `dataTransfer`, not in a closure variable. A
+// closure var tracks by NOTHING the browser understands, so nothing keeps it in
+// step with the actual drag session: `dragend` fires on the ORIGINAL source
+// element, and a redraw mid-drag (an unrelated remove/undo firing on its own
+// input channel while the mouse is still held down) can detach that element
+// from the tree -- its `dragend` then never bubbles here, and a closure var set
+// at dragstart is never cleared, ready to be picked up by the next unrelated
+// drop that lands on this row from somewhere else entirely. `dataTransfer` has
+// no such gap: it belongs to the actual OS-level drag session, unaffected by
+// which DOM node currently receives the events, and a drop from any OTHER
+// session simply never had this MIME type set.
+const MIME = "text/x-funpack-reorder";
+
+/** wireDragReorder(node, ".cx-cell-selector", (draggedId, targetId) => {}) */
+export function wireDragReorder(node, cellSelector, onReorder) {
+ const cellOf = (e) => e.target.closest(cellSelector);
+ node.addEventListener("dragstart", (e) => {
+ const cell = cellOf(e);
+ if (!cell || !e.dataTransfer) return;
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData(MIME, cell.dataset.id || "");
+ cell.classList.add("cx-dragging");
+ });
+ node.addEventListener("dragend", (e) => { cellOf(e)?.classList.remove("cx-dragging"); });
+ node.addEventListener("dragover", (e) => {
+ // `getData` only returns the real value on "drop" (a browser security
+ // restriction during drag) -- `types` is what dragover can actually read.
+ if (e.dataTransfer && e.dataTransfer.types.includes(MIME)) e.preventDefault();
+ });
+ node.addEventListener("drop", (e) => {
+ if (!e.dataTransfer) return;
+ const draggedId = e.dataTransfer.getData(MIME);
+ if (!draggedId) return;
+ e.preventDefault();
+ const cell = cellOf(e);
+ const targetId = cell ? cell.dataset.id : null;
+ if (onReorder && targetId && targetId !== draggedId) onReorder(draggedId, targetId);
+ });
+}
diff --git a/app/ui/composer/internals/el.js b/app/ui/composer/internals/el.js
new file mode 100644
index 00000000..0e535865
--- /dev/null
+++ b/app/ui/composer/internals/el.js
@@ -0,0 +1,66 @@
+// The only document.createElement in the app.
+//
+// Everything the kit renders comes through here, which is what makes the
+// no-styling rule checkable: a module has no route to a raw element, and the
+// scanner only has one construction site to watch.
+
+import { asText } from "./text.js";
+
+const UNSAFE_ATTRS = new Set(["style", "class", "srcdoc", "onload", "onerror"]);
+
+/**
+ * el("div", { cls, text, attrs, on, children })
+ *
+ * `cls` takes kit class names only -- callers inside the kit. `text` goes
+ * through asText. `attrs` refuses style/class and any inline handler, so no
+ * amount of prop-forwarding can turn data into presentation or script.
+ */
+export function el(tag, opts = {}) {
+ const node = document.createElement(tag);
+ const { cls, text, attrs, on, children } = opts;
+
+ if (cls) node.className = Array.isArray(cls) ? cls.filter(Boolean).join(" ") : cls;
+ if (text !== undefined) node.appendChild(asText(text));
+
+ if (attrs) {
+ for (const [name, value] of Object.entries(attrs)) {
+ if (value == null || value === false) continue;
+ const lower = name.toLowerCase();
+ if (UNSAFE_ATTRS.has(lower) || lower.startsWith("on")) {
+ throw new TypeError(`Refusing to set "${name}": presentation and handlers are the kit's, not a caller's.`);
+ }
+ node.setAttribute(name, value === true ? "" : String(value));
+ }
+ }
+
+ if (on) for (const [event, handler] of Object.entries(on)) node.addEventListener(event, handler);
+ if (children) for (const child of [].concat(children)) if (child) node.appendChild(child);
+
+ return node;
+}
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+
+/**
+ * An SVG element. Separate because SVG needs createElementNS -- createElement
+ * would produce an HTMLUnknownElement that renders as nothing at all.
+ * Attributes only: SVG carries no module content, so there is no text path here.
+ */
+export function svg(tag, attrs = {}) {
+ const node = document.createElementNS(SVG_NS, tag);
+ for (const [name, value] of Object.entries(attrs)) {
+ if (value == null || value === false) continue;
+ node.setAttribute(name, String(value));
+ }
+ return node;
+}
+
+/** A detached fragment. Panels build into one so a half-built tree never lands. */
+export function frag() {
+ return document.createDocumentFragment();
+}
+
+export function clear(node) {
+ while (node.firstChild) node.removeChild(node.firstChild);
+ return node;
+}
diff --git a/app/ui/composer/internals/focus.js b/app/ui/composer/internals/focus.js
new file mode 100644
index 00000000..01b21d90
--- /dev/null
+++ b/app/ui/composer/internals/focus.js
@@ -0,0 +1,91 @@
+// Keyboard reachability: a trap for overlays, and roving tabindex for groups.
+
+const FOCUSABLE = [
+ "a[href]", "button:not([disabled])", "input:not([disabled])",
+ "select:not([disabled])", "textarea:not([disabled])",
+ '[tabindex]:not([tabindex="-1"])',
+].join(",");
+
+// Hiding is inherited: a control inside a hidden or aria-hidden subtree is
+// unreachable however focusable it looks on its own, and trapping focus onto one
+// strands the keyboard on something invisible.
+const HIDDEN = '[hidden], [aria-hidden="true"]';
+
+export function focusables(container) {
+ return [...container.querySelectorAll(FOCUSABLE)].filter((n) => {
+ const hidden = n.closest(HIDDEN);
+ return !hidden || !container.contains(hidden);
+ });
+}
+
+/**
+ * Keep Tab inside `container` until released, and put focus back where it was.
+ *
+ * Restoring matters as much as trapping: closing a dialog and dropping focus to
+ * silently ends keyboard navigation, and the only way back is the mouse.
+ */
+export function trap(container, { initial } = {}) {
+ const previous = document.activeElement;
+
+ const onKeyDown = (event) => {
+ if (event.key !== "Tab") return;
+ const items = focusables(container);
+ if (!items.length) { event.preventDefault(); return; }
+ const first = items[0];
+ const last = items[items.length - 1];
+ const active = document.activeElement;
+
+ // Wrapping is also the fix for focus having escaped the container entirely
+ // (a click outside, a removed node): either edge pulls it back in.
+ if (event.shiftKey && (active === first || !container.contains(active))) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && (active === last || !container.contains(active))) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+
+ container.addEventListener("keydown", onKeyDown);
+ const target = initial || focusables(container)[0] || container;
+ if (target && target.focus) target.focus();
+
+ return function release({ restore = true } = {}) {
+ container.removeEventListener("keydown", onKeyDown);
+ if (restore && previous && previous.isConnected && previous.focus) previous.focus();
+ };
+}
+
+/**
+ * One tab stop for a group, arrows to move within it -- what a segmented
+ * control, toggle group or menu needs so Tab does not walk every option.
+ */
+export function roving(container, { orientation = "horizontal", onSelect } = {}) {
+ const NEXT = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
+ const PREV = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
+
+ const items = () => focusables(container);
+
+ const setIndex = (i) => {
+ const list = items();
+ if (!list.length) return;
+ const clamped = (i + list.length) % list.length;
+ list.forEach((n, j) => n.setAttribute("tabindex", j === clamped ? "0" : "-1"));
+ list[clamped].focus();
+ if (onSelect) onSelect(list[clamped], clamped);
+ };
+
+ const onKeyDown = (event) => {
+ const list = items();
+ const current = list.indexOf(document.activeElement);
+ if (current === -1) return;
+ if (event.key === NEXT) { event.preventDefault(); setIndex(current + 1); }
+ else if (event.key === PREV) { event.preventDefault(); setIndex(current - 1); }
+ else if (event.key === "Home") { event.preventDefault(); setIndex(0); }
+ else if (event.key === "End") { event.preventDefault(); setIndex(list.length - 1); }
+ };
+
+ items().forEach((n, i) => n.setAttribute("tabindex", i === 0 ? "0" : "-1"));
+ container.addEventListener("keydown", onKeyDown);
+ return () => container.removeEventListener("keydown", onKeyDown);
+}
diff --git a/app/ui/composer/internals/ids.js b/app/ui/composer/internals/ids.js
new file mode 100644
index 00000000..3aec9daa
--- /dev/null
+++ b/app/ui/composer/internals/ids.js
@@ -0,0 +1,14 @@
+// Unique ids, so a label can point at its control without the caller inventing
+// a naming scheme (and colliding when the same panel is mounted twice).
+
+let n = 0;
+
+export function uid(prefix = "cx") {
+ n += 1;
+ return `${prefix}-${n}`;
+}
+
+// Tests only: ids are otherwise monotonic for the life of the page.
+export function _resetIds() {
+ n = 0;
+}
diff --git a/app/ui/composer/internals/motion.js b/app/ui/composer/internals/motion.js
new file mode 100644
index 00000000..a01af004
--- /dev/null
+++ b/app/ui/composer/internals/motion.js
@@ -0,0 +1,36 @@
+// Durations come from the tokens, so "how long" is answered in one place and
+// the reduced-motion guard in tokens.css zeroes every one of them at once.
+
+/** "120ms" | "0.12s" | "0" -> milliseconds. */
+export function parseDuration(value) {
+ const text = String(value ?? "").trim();
+ if (!text) return 0;
+ const match = /^(-?[\d.]+)(ms|s)?$/.exec(text);
+ if (!match) return 0;
+ const n = Number(match[1]);
+ if (!Number.isFinite(n)) return 0;
+ return Math.max(0, match[2] === "s" ? n * 1000 : n);
+}
+
+export function duration(step = 1) {
+ const value = getComputedStyle(document.documentElement).getPropertyValue(`--dur-${step}`);
+ return parseDuration(value);
+}
+
+export const prefersReducedMotion = () =>
+ Boolean(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
+
+/**
+ * Run `fn` after the element's transition, or immediately when motion is off.
+ * Always fires: a transitionend that never arrives (the property did not change,
+ * the node was hidden) would otherwise strand a teardown forever.
+ */
+export function afterTransition(node, step, fn) {
+ const ms = duration(step);
+ if (!ms) { fn(); return () => {}; }
+ let done = false;
+ const finish = () => { if (done) return; done = true; node.removeEventListener("transitionend", finish); clearTimeout(timer); fn(); };
+ const timer = setTimeout(finish, ms + 50);
+ node.addEventListener("transitionend", finish);
+ return finish;
+}
diff --git a/app/ui/composer/internals/portal.js b/app/ui/composer/internals/portal.js
new file mode 100644
index 00000000..ada91d3a
--- /dev/null
+++ b/app/ui/composer/internals/portal.js
@@ -0,0 +1,53 @@
+// One overlay root, appended to .
+//
+// Every floating thing lives here rather than beside whatever opened it. An
+// overlay nested inside app layout inherits that layout's stacking context, so
+// its z-index is measured against its siblings instead of the page -- which is
+// how v4 ended up with 99999 and 200000 and still had two things fighting.
+
+let root = null;
+
+export const ROOT_ID = "composer-overlays";
+
+export function portal() {
+ if (root && root.isConnected) return root;
+ root = document.getElementById(ROOT_ID);
+ if (!root) {
+ root = document.createElement("div");
+ root.id = ROOT_ID;
+ // NO POSITIONING, no transform, no filter, no contain, no z-index -- ever.
+ //
+ // Each of those makes this element a stacking context, and then the whole
+ // ladder is measured INSIDE it rather than against the page: every rung,
+ // from a tooltip at 300 to a modal at 800, collapses to whatever this one
+ // element resolves to. It said exactly that and then set `position: fixed`,
+ // which creates a stacking context all by itself -- so a menu at z 500 was
+ // painted under a button at z 1, and the only reason it was not obvious
+ // everywhere is that almost nothing in the app claims a z-index at all.
+ //
+ // Nothing is needed here anyway: every layer mounted into this root is
+ // itself `position: fixed`, so a static, zero-sized parent lays nothing out
+ // and intercepts nothing.
+ root.style.pointerEvents = "none";
+ }
+ if (!root.isConnected) document.body.appendChild(root);
+ return root;
+}
+
+/** Put `node` on the overlay root. Children re-enable their own pointer events. */
+export function mount(node) {
+ node.style.pointerEvents = "auto";
+ portal().appendChild(node);
+ return node;
+}
+
+export function unmount(node) {
+ // Both halves matter: with no root yet, an unparented node's parentNode is
+ // also null, and `null === null` would send removeChild to nothing.
+ if (root && node && node.parentNode === root) root.removeChild(node);
+}
+
+export function _resetPortal() {
+ if (root && root.isConnected) root.remove();
+ root = null;
+}
diff --git a/app/ui/composer/internals/register.js b/app/ui/composer/internals/register.js
new file mode 100644
index 00000000..5d339a01
--- /dev/null
+++ b/app/ui/composer/internals/register.js
@@ -0,0 +1,89 @@
+// Announcement, not enumeration -- the kit's own version of the rule the
+// backend uses.
+//
+// `composer.button.large` exists only because elements/button.js called
+// define("button", "large", ...). Nothing holds a list of elements, so adding
+// one is a single file, and asking for one that was never defined throws rather
+// than rendering something approximate. That throw is what makes a module with
+// a bespoke element hide itself instead of shipping a broken panel.
+
+export class UnknownElement extends Error {
+ constructor(group, variant) {
+ const known = variant === undefined
+ ? `Known groups: ${[...groups()].join(", ") || "(none yet)"}.`
+ : `Known variants of "${group}": ${[...variants(group)].join(", ") || "(none)"}.`;
+ super(
+ variant === undefined
+ ? `composer.${group} is not a Composer element group. ${known}`
+ : `composer.${group}.${variant} is not a Composer element. ${known}`
+ );
+ this.name = "UnknownElement";
+ this.group = group;
+ this.variant = variant;
+ }
+}
+
+const registry = new Map(); // group -> Map(variant -> factory)
+
+export function define(group, variant, factory) {
+ if (typeof factory !== "function") {
+ throw new TypeError(`define("${group}", "${variant}", ...) needs a factory function.`);
+ }
+ if (!registry.has(group)) registry.set(group, new Map());
+ const g = registry.get(group);
+ if (g.has(variant)) {
+ // Two files claiming one name means one of them silently loses, and which
+ // one depends on import order.
+ throw new Error(`composer.${group}.${variant} is already defined.`);
+ }
+ g.set(variant, factory);
+ return factory;
+}
+
+export const groups = () => registry.keys();
+export const variants = (group) => (registry.get(group) || new Map()).keys();
+export const has = (group, variant) => Boolean(registry.get(group)?.has(variant));
+export const lookup = (group, variant) => registry.get(group)?.get(variant);
+
+/** Every registered element, so a test can iterate what exists rather than a list. */
+export function* entries() {
+ for (const [group, gv] of registry) for (const [variant, factory] of gv) yield { group, variant, factory };
+}
+
+// Property access on a Proxy is also how JS probes an object (`then` when it is
+// awaited, Symbol.toStringTag when it is printed). Those must answer undefined
+// rather than throw, or a stray await turns into a confusing UnknownElement.
+const probe = (key) => typeof key === "symbol" || key === "then" || key === "toJSON";
+
+function groupProxy(group) {
+ return new Proxy(Object.create(null), {
+ get(_t, variant) {
+ if (probe(variant)) return undefined;
+ const factory = lookup(group, variant);
+ if (!factory) throw new UnknownElement(group, variant);
+ return factory;
+ },
+ has: (_t, variant) => has(group, variant),
+ ownKeys: () => [...variants(group)],
+ getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }),
+ });
+}
+
+export const composer = new Proxy(Object.create(null), {
+ get(_t, group) {
+ if (probe(group)) return undefined;
+ if (!registry.has(group)) throw new UnknownElement(group);
+ return groupProxy(group);
+ },
+ has: (_t, group) => registry.has(group),
+ ownKeys: () => [...groups()],
+ getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }),
+ set() {
+ throw new TypeError("composer is read-only; elements register themselves with define().");
+ },
+});
+
+// Tests only.
+export function _clearRegistry() {
+ registry.clear();
+}
diff --git a/app/ui/composer/internals/text.js b/app/ui/composer/internals/text.js
new file mode 100644
index 00000000..2dd8c77b
--- /dev/null
+++ b/app/ui/composer/internals/text.js
@@ -0,0 +1,38 @@
+// Module-supplied content is DATA, never markup.
+//
+// asText is the only way text reaches the DOM in this kit. It does not parse,
+// sanitise or escape -- it refuses anything that is not a scalar, so "markup"
+// is not a state the kit can be in. That is a stronger guarantee than escaping,
+// which is a thing you can forget to do in one place.
+
+const SCALAR = new Set(["string", "number", "boolean"]);
+
+export function asText(value) {
+ if (value == null) return document.createTextNode("");
+ if (!SCALAR.has(typeof value)) {
+ throw new TypeError(
+ `Composer content must be a string, number or boolean; got ${describe(value)}. ` +
+ "Elements take data, not nodes or markup."
+ );
+ }
+ if (typeof value === "number" && !Number.isFinite(value)) {
+ throw new TypeError(`Composer content must be a finite number; got ${value}.`);
+ }
+ return document.createTextNode(String(value));
+}
+
+/** Replace a node's content with `value`, rendered as text. */
+export function setText(node, value) {
+ while (node.firstChild) node.removeChild(node.firstChild);
+ node.appendChild(asText(value));
+ return node;
+}
+
+function describe(value) {
+ if (typeof value === "object" && value !== null) {
+ if (typeof value.nodeType === "number") return `a DOM node (<${value.nodeName?.toLowerCase()}>)`;
+ if (Array.isArray(value)) return "an array";
+ return "an object";
+ }
+ return `a ${typeof value}`;
+}
diff --git a/app/ui/composer/internals/zlayer.js b/app/ui/composer/internals/zlayer.js
new file mode 100644
index 00000000..1233e9a2
--- /dev/null
+++ b/app/ui/composer/internals/zlayer.js
@@ -0,0 +1,120 @@
+// The only place a z-index is written.
+//
+// v4 ended up with 9000, 9999, 10001, 12000, 99999, 200000 and two cross-file
+// collisions, because every overlay picked its own number in isolation. Here the
+// ladder is ordered once and each rung owns a block of slots, so peers stack
+// within a rung and can never cross into the next one.
+//
+// A rung is an ORDERED LIST and z is derived from position, rather than each
+// claim owning a fixed offset. That is what makes raise() and release() always
+// correct: with fixed offsets, a near-full rung has no free slot above the top,
+// and "move to the front" has nowhere to go.
+
+// Order is the contract; the numbers are derived. Autocomplete sits ABOVE modal
+// on purpose: it opens from a field that is often inside one.
+export const LADDER = [
+ "local",
+ "dropline",
+ "sticky",
+ "menubar",
+ "popover",
+ "backdrop",
+ "wizard",
+ "modal",
+ "floatingWindow",
+ "toast",
+ "autocomplete",
+ "tour",
+];
+
+export const SLOTS = 100; // peers per rung
+
+const stacks = new Map(); // rung -> entries, bottom to top
+
+export function baseOf(rung) {
+ const i = LADDER.indexOf(rung);
+ if (i === -1) {
+ throw new RangeError(`Unknown z rung "${rung}". Known: ${LADDER.join(", ")}.`);
+ }
+ return (i + 1) * SLOTS;
+}
+
+/**
+ * Take a z-index in `rung`. `onChange(z)` fires whenever this claim's z moves --
+ * which happens when a PEER raises or releases, not only when this one does.
+ * Without it a window that was above becomes stale in the DOM the moment
+ * another is brought to the front.
+ */
+export function claim(rung, onChange) {
+ const start = baseOf(rung);
+ if (!stacks.has(rung)) stacks.set(rung, []);
+ const list = stacks.get(rung);
+
+ if (list.length >= SLOTS) {
+ // Running a rung dry means something opens overlays and never releases
+ // them; quietly reusing a slot would hide the leak and stack two things at
+ // one z.
+ throw new RangeError(`z rung "${rung}" is full (${SLOTS} live claims). Something is not releasing.`);
+ }
+
+ const entry = { onChange };
+ list.push(entry);
+ let live = true;
+ let lastZ = start + list.indexOf(entry);
+
+ const handle = {
+ get z() {
+ if (!live) return lastZ;
+ lastZ = start + list.indexOf(entry);
+ return lastZ;
+ },
+ get rung() { return rung; },
+ get live() { return live; },
+ raise() {
+ if (!live) return handle;
+ const i = list.indexOf(entry);
+ if (i === -1 || i === list.length - 1) return handle;
+ list.splice(i, 1);
+ list.push(entry);
+ notify(list, start);
+ return handle;
+ },
+ release() {
+ if (!live) return;
+ lastZ = handle.z;
+ const i = list.indexOf(entry);
+ if (i !== -1) list.splice(i, 1);
+ live = false;
+ notify(list, start);
+ },
+ };
+ return handle;
+}
+
+function notify(list, start) {
+ list.forEach((entry, i) => { if (entry.onChange) entry.onChange(start + i); });
+}
+
+/**
+ * Claim a rung AND keep `node`'s z-index in sync with it -- the form every
+ * overlay should use.
+ *
+ * `claim()` alone hands back a number and trusts the caller to notice when it
+ * changes, and a caller that forgets gets a z-index that was right at open time
+ * and silently wrong afterwards: a peer releasing compacts the rung, and the
+ * next overlay opened lands on the number this one is still displaying. Six of
+ * the seven call sites forgot. Binding the node here means there is nothing
+ * left to forget.
+ */
+export function claimFor(rung, node) {
+ const handle = claim(rung, (z) => { node.style.zIndex = String(z); });
+ node.style.zIndex = String(handle.z);
+ return handle;
+}
+
+/** Live claims in a rung, bottom to top. Diagnostics and tests. */
+export const liveCount = (rung) => (stacks.get(rung) || []).length;
+
+export function _resetLayers() {
+ stacks.clear();
+}
diff --git a/app/ui/composer/panel.js b/app/ui/composer/panel.js
new file mode 100644
index 00000000..e2b354db
--- /dev/null
+++ b/app/ui/composer/panel.js
@@ -0,0 +1,191 @@
+// A module's settings declaration, rendered.
+//
+// This is the bridge the whole architecture turns on: one declaration renders
+// the panel, carries the defaults and names the values that come back. A module
+// writes no DOM and reads no DOM -- it says what it needs and gets values.
+//
+// The panel builds into a DETACHED fragment. If any part of it throws -- an
+// unknown renderer, a malformed option -- nothing has been appended, so the
+// module is absent rather than half-rendered. That is "hide, don't warn" at the
+// level where it actually has to hold.
+
+import { composer } from "./internals/register.js";
+import { el, frag } from "./internals/el.js";
+
+/** The renderer a type gets when the declaration does not ask for one. */
+export const DEFAULT_UI = {
+ bool: "checkboxRow",
+ int: "number",
+ float: "number",
+ enum: "select",
+ text: "input",
+ multiline: "textarea",
+ path: "filterList",
+ color: "swatch",
+};
+
+// Renderers that carry their own visible label, so wrapping them in a settings
+// row would print the label twice. Exported because anything else rendering a
+// control from a setting has to know the same thing, and a second copy of this
+// list is a second chance to forget one.
+export const SELF_LABELLING = new Set(["checkboxRow", "toggle"]);
+
+/** The renderer name a setting resolves to, whether or not it named one. */
+export const rendererNameFor = (setting) => setting.ui || DEFAULT_UI[setting.type];
+
+const RENDERERS = {
+ checkboxRow: (s, v, on) => composer.checkboxRow.default({ label: s.label, hint: s.hint, checked: v, onChange: on }),
+ toggle: (s, v, on) => composer.toggle.default({ label: s.label, hint: s.hint, checked: v, onChange: on }),
+
+ number: (s, v, on) => composer.number.md({
+ value: v, min: s.min, max: s.max, step: s.step, unit: s.unit,
+ precision: s.type === "int" ? 0 : s.precision, label: s.label, onChange: on }),
+ stepper: (s, v, on) => composer.stepper.md({
+ value: v, min: s.min, max: s.max, step: s.step, label: s.label, onChange: on }),
+ slider: (s, v, on) => composer.slider.readout({
+ value: v, min: s.min ?? 0, max: s.max ?? 1, step: s.step ?? 0.01,
+ unit: s.unit ?? "", precision: s.type === "int" ? 0 : 2, label: s.label, onCommit: on, onChange: on }),
+ macroSlider: (s, v, on) => composer.slider.macro({
+ value: v, min: s.min ?? 0, max: s.max ?? 1, step: s.step ?? 0.01,
+ unit: s.unit ?? "", presets: s.presets ?? [], label: s.label, onCommit: on, onChange: on }),
+
+ select: (s, v, on) => composer.select.md({ options: s.options, value: v, label: s.label, onChange: on }),
+ segmented: (s, v, on) => composer.segmented.md({ options: s.options, value: v, label: s.label, onChange: on }),
+ radioGroup: (s, v, on) => composer.radioGroup.default({ options: s.options, value: v, label: s.label, onChange: on }),
+ swatch: (s, v, on) => composer.color.swatch({ label: s.label, value: v, onChange: on }),
+
+ filterList: (s, v, on) => composer.filterList.md({
+ items: (s.options || []).map((o) => ({ id: o.value, label: o.label, hint: o.hint })),
+ value: v, placeholder: s.placeholder, onChange: on }),
+
+ // An overlay, so the row holds a button showing the current choice. Opening a
+ // wheel from a settings panel is unusual; being able to is what lets a module
+ // ask for one without writing any JavaScript.
+ wheel: (s, v, on) => {
+ const labelFor = (value) => (s.options.find((o) => o.value === value) || {}).label ?? String(value);
+ const button = composer.button.md({
+ label: labelFor(v),
+ onClick: () => composer.wheel.picker({
+ items: s.options.map((o) => ({ label: o.label, icon: o.icon, value: o.value })),
+ onPick: (item) => { button.setLabel(labelFor(item.value)); on(item.value); },
+ }),
+ });
+ return button;
+ },
+
+ input: (s, v, on) => composer.input.md({ value: v, placeholder: s.placeholder, label: s.label, onCommit: on }),
+ search: (s, v, on) => composer.search.md({ value: v, placeholder: s.placeholder, label: s.label, onCommit: on }),
+ textarea: (s, v, on) => composer.textarea.md({ value: v, placeholder: s.placeholder, label: s.label, onCommit: on }),
+ autoTextarea: (s, v, on) => composer.textarea.md({
+ value: v, placeholder: s.placeholder, label: s.label, autoGrow: true, onCommit: on }),
+};
+
+/** Is this setting's `when` satisfied by the current values? */
+export function visible(setting, values) {
+ const when = setting.when;
+ if (!when) return true;
+ return Object.entries(when).every(([key, expected]) =>
+ (Array.isArray(expected) ? expected : [expected]).includes(values[key]));
+}
+
+export function rendererFor(setting) {
+ // Core refuses a setting with no default long before the browser sees it, so
+ // this is belt and braces -- but the panel is the layer that cannot proceed
+ // without one: it would render a control holding `undefined` and report that
+ // as the value the moment anything read it.
+ if (!Object.prototype.hasOwnProperty.call(setting, "default")) {
+ throw new TypeError(`Setting has no default, so there is nothing to render it with.`);
+ }
+ const name = setting.ui || DEFAULT_UI[setting.type];
+ const render = RENDERERS[name];
+ if (!render) {
+ // Refusing here rather than falling back to something plausible: a panel
+ // that silently renders a different control is worse than one that is
+ // absent, because nobody notices it is wrong.
+ throw new TypeError(`No renderer for "${name}" (setting type "${setting.type}").`);
+ }
+ return render;
+}
+
+/**
+ * renderPanel(spec, { values, onChange }) -> { node, values, destroy }
+ *
+ * `values` starts from the module's declared defaults, so a panel and a
+ * headless run begin from the same numbers.
+ */
+export function renderPanel(spec, { values: initial, onChange } = {}) {
+ const settings = spec.settings || {};
+ const values = { ...defaultsOf(spec), ...(initial || {}) };
+
+ const rows = el("div", { cls: "cx-group-rows" });
+ let handles = new Map();
+
+ function build() {
+ // Into a fragment, and into a map of its OWN, so a row that throws leaves
+ // nothing half-made behind and touches nothing already on screen.
+ const built = frag();
+ const made = new Map();
+ for (const [key, setting] of Object.entries(settings)) {
+ if (!visible(setting, values)) continue;
+ const render = rendererFor(setting);
+ const control = render(setting, values[key], (next) => {
+ values[key] = next;
+ if (onChange) onChange(key, next, { ...values });
+ // Another setting's visibility may depend on this one.
+ if (dependents.has(key)) redraw();
+ });
+ made.set(key, control);
+ built.appendChild(
+ SELF_LABELLING.has(rendererNameFor(setting))
+ ? control.node
+ : composer.settingsRow.default({ label: setting.label, hint: setting.hint, control }).node
+ );
+ }
+ return { built, made };
+ }
+
+ // Which keys other rows watch, so a change only redraws when it can matter.
+ const dependents = new Set();
+ for (const setting of Object.values(settings)) {
+ for (const key of Object.keys(setting.when || {})) dependents.add(key);
+ }
+
+ function redraw() {
+ // Built BEFORE anything is torn down. Destroying first and building second
+ // meant a row that only appears once another value is set -- so a row the
+ // first build never rendered and never checked -- emptied the panel and
+ // then threw, leaving it permanently blank with no way back.
+ const { built, made } = build();
+ for (const handle of handles.values()) if (handle.destroy) handle.destroy();
+ handles = made;
+ rows.replaceChildren(built);
+ }
+
+ {
+ const first = build();
+ handles = first.made;
+ rows.appendChild(first.built);
+ }
+
+ const node = el("section", { cls: "cx-group", children: [
+ el("div", { cls: "cx-eyebrow", text: spec.title }),
+ rows,
+ ] });
+
+ return {
+ node,
+ get values() { return { ...values }; },
+ setValue(key, next) { values[key] = next; redraw(); },
+ destroy() {
+ for (const handle of handles.values()) if (handle.destroy) handle.destroy();
+ node.remove();
+ },
+ };
+}
+
+/** The same defaults core derives, computed in the browser from the same source. */
+export function defaultsOf(spec) {
+ const out = {};
+ for (const [key, setting] of Object.entries(spec.settings || {})) out[key] = setting.default;
+ return out;
+}
diff --git a/app/ui/composer/tests/_dom.js b/app/ui/composer/tests/_dom.js
new file mode 100644
index 00000000..6bcd4c76
--- /dev/null
+++ b/app/ui/composer/tests/_dom.js
@@ -0,0 +1,38 @@
+// A DOM for the internals that genuinely need one.
+//
+// jsdom is honest about structure and events and dishonest about layout -- it
+// has no real box model, so anything that depends on measured geometry belongs
+// in the browser (see the catalogue and the Playwright pass), not here.
+
+import { JSDOM } from "jsdom";
+
+const GLOBALS = [
+ "window", "document", "Node", "HTMLElement", "Event", "KeyboardEvent",
+ "CustomEvent", "getComputedStyle", "DocumentFragment", "Text",
+];
+
+export function setupDom(html = "") {
+ // A url, because without one jsdom gives the document an OPAQUE ORIGIN and
+ // `window.localStorage` throws a SecurityError on the property access itself
+ // -- not on get or set. Anything that remembers a preference then silently
+ // took its fallback path in every test, so "it is remembered" was untestable
+ // here and looked like it worked.
+ const dom = new JSDOM(html, { pretendToBeVisual: true, url: "http://localhost/funpack/" });
+ for (const name of GLOBALS) globalThis[name] = dom.window[name];
+ globalThis.window = dom.window;
+ globalThis.document = dom.window.document;
+ return dom;
+}
+
+export function teardownDom() {
+ for (const name of GLOBALS) delete globalThis[name];
+}
+
+/** Dispatch an event as it would arrive from a real interaction. */
+export const fire = (node, type, init = {}) =>
+ node.dispatchEvent(new globalThis.window.Event(type, { bubbles: true, cancelable: true, ...init }));
+
+export const key = (node, k) =>
+ node.dispatchEvent(new globalThis.window.KeyboardEvent("keydown", {
+ key: k, bubbles: true, cancelable: true,
+ }));
diff --git a/app/ui/composer/tests/anchor.test.js b/app/ui/composer/tests/anchor.test.js
new file mode 100644
index 00000000..3e23c6ef
--- /dev/null
+++ b/app/ui/composer/tests/anchor.test.js
@@ -0,0 +1,112 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { place } from "../internals/anchor.js";
+
+const VIEW = { width: 1000, height: 800 };
+const mid = { x: 400, y: 400, width: 100, height: 24 };
+const float = { width: 200, height: 120 };
+
+const at = (anchor, opts = {}) => place({ anchor, float, viewport: VIEW, ...opts });
+
+test("below and start-aligned by default", () => {
+ const r = at(mid);
+ assert.equal(r.side, "bottom");
+ assert.equal(r.y, mid.y + mid.height + 6);
+ assert.equal(r.x, mid.x);
+ assert.equal(r.flipped, false);
+});
+
+test("gap is respected on every side", () => {
+ assert.equal(at(mid, { side: "bottom", gap: 20 }).y, mid.y + mid.height + 20);
+ assert.equal(at(mid, { side: "top", gap: 20 }).y, mid.y - float.height - 20);
+ assert.equal(at(mid, { side: "right", gap: 20 }).x, mid.x + mid.width + 20);
+ assert.equal(at(mid, { side: "left", gap: 20 }).x, mid.x - float.width - 20);
+});
+
+test("alignment moves along the cross axis only", () => {
+ assert.equal(at(mid, { align: "start" }).x, 400);
+ assert.equal(at(mid, { align: "center" }).x, 400 + 50 - 100);
+ assert.equal(at(mid, { align: "end" }).x, 400 + 100 - 200);
+ // the main axis is untouched by alignment
+ const ys = ["start", "center", "end"].map((align) => at(mid, { align }).y);
+ assert.equal(new Set(ys).size, 1);
+});
+
+// --- flipping ---------------------------------------------------------------
+
+test("flips up when there is no room below", () => {
+ const low = { x: 400, y: 760, width: 100, height: 24 };
+ const r = at(low, { side: "bottom" });
+ assert.equal(r.side, "top");
+ assert.equal(r.flipped, true);
+});
+
+test("flips left when there is no room right", () => {
+ const right = { x: 940, y: 400, width: 40, height: 24 };
+ const r = at(right, { side: "right" });
+ assert.equal(r.side, "left");
+});
+
+test("does not flip into an even tighter gap", () => {
+ // Squeezed both ways: 106px below, 20px above, and the float needs 134. It
+ // fits neither, so it stays where it was asked to go and clamps -- flipping
+ // into LESS room just moves the clipping somewhere the caller did not expect,
+ // which is what several of v4's seven copies did.
+ const nearTop = { x: 400, y: 20, width: 100, height: 24 };
+ const shallow = { width: 1000, height: 150 };
+ const r = place({ anchor: nearTop, float, viewport: shallow, side: "bottom" });
+ assert.equal(r.side, "bottom");
+ assert.equal(r.flipped, false);
+ assert.equal(r.clamped, true);
+});
+
+test("flip can be switched off", () => {
+ const low = { x: 400, y: 760, width: 100, height: 24 };
+ assert.equal(at(low, { side: "bottom", flip: false }).side, "bottom");
+});
+
+// --- clamping ---------------------------------------------------------------
+
+test("clamps back inside the left edge", () => {
+ const r = at({ x: 2, y: 400, width: 40, height: 24 }, { align: "end" });
+ assert.ok(r.x >= 8);
+ assert.equal(r.clamped, true);
+});
+
+test("clamps back inside the right edge", () => {
+ const r = at({ x: 960, y: 400, width: 40, height: 24 });
+ assert.ok(r.x + float.width <= VIEW.width - 8);
+});
+
+test("clamping never pushes the float off the opposite edge", () => {
+ // A float wider than the viewport must sit at the padding, not at a negative x.
+ const huge = { width: 1200, height: 120 };
+ const r = place({ anchor: mid, float: huge, viewport: VIEW });
+ assert.equal(r.x, 8);
+});
+
+test("clamp can be switched off", () => {
+ const r = at({ x: 2, y: 400, width: 40, height: 24 }, { align: "end", clamp: false });
+ assert.ok(r.x < 0);
+ assert.equal(r.clamped, false);
+});
+
+// --- edges ------------------------------------------------------------------
+
+test("an unknown side is refused rather than guessed", () => {
+ assert.throws(() => at(mid, { side: "sideways" }), RangeError);
+});
+
+test("a zero-size anchor still places sensibly", () => {
+ // Context menus anchor to a point, not an element.
+ const r = at({ x: 300, y: 300, width: 0, height: 0 });
+ assert.equal(r.x, 300);
+ assert.equal(r.y, 306);
+});
+
+test("results are integers", () => {
+ const r = at(mid, { align: "center" });
+ assert.equal(Number.isInteger(r.x), true);
+ assert.equal(Number.isInteger(r.y), true);
+});
diff --git a/app/ui/composer/tests/density.test.js b/app/ui/composer/tests/density.test.js
new file mode 100644
index 00000000..28321318
--- /dev/null
+++ b/app/ui/composer/tests/density.test.js
@@ -0,0 +1,56 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom } from "./_dom.js";
+import { AUTO, MAX_COLS, applyDensity, cellExpression, cellWidth, normaliseCols, recallCols } from "../internals/density.js";
+
+test.beforeEach(() => setupDom());
+test.after(() => teardownDom());
+
+test("cell width accounts for the gaps between columns", () => {
+ // v4 hand-tuned these (92/44/29/21) and they drifted from the gap they were
+ // meant to allow for. Derived, they add up.
+ for (const cols of [1, 2, 3, 4]) {
+ const cell = cellWidth(cols);
+ const total = cell * cols + 2.5 * (cols - 1);
+ assert.ok(Math.abs(total - 100) < 0.05, `${cols} columns totalled ${total}`);
+ }
+});
+
+test("one column is the full container", () => {
+ assert.equal(cellWidth(1), 100);
+});
+
+test("auto falls back to the clamp expression", () => {
+ assert.match(cellExpression(null), /clamp\(/);
+ assert.match(cellExpression(2), /cqw$/);
+});
+
+test("column counts are clamped into range", () => {
+ assert.equal(normaliseCols(-3), AUTO);
+ assert.equal(normaliseCols(99), MAX_COLS);
+ assert.equal(normaliseCols("2"), 2);
+ assert.equal(normaliseCols(2.7), 2);
+ assert.equal(normaliseCols("nonsense"), AUTO);
+ assert.equal(normaliseCols(undefined), AUTO);
+});
+
+test("applying density sets both the attribute and the cell variable", () => {
+ const host = document.createElement("div");
+ assert.equal(applyDensity(host, 3), 3);
+ assert.equal(host.dataset.cols, "3");
+ assert.match(host.style.getPropertyValue("--cell"), /cqw/);
+});
+
+test("recall survives storage being unavailable", () => {
+ // Private windows and blocked site data throw on access; a grid must still
+ // render rather than taking the page down with it.
+ const original = Object.getOwnPropertyDescriptor(window, "localStorage");
+ Object.defineProperty(window, "localStorage", {
+ configurable: true,
+ get() { throw new Error("blocked"); },
+ });
+ globalThis.localStorage = undefined;
+ assert.equal(recallCols("bin", 2), 2);
+ if (original) Object.defineProperty(window, "localStorage", original);
+});
diff --git a/app/ui/composer/tests/dismiss.test.js b/app/ui/composer/tests/dismiss.test.js
new file mode 100644
index 00000000..ee0be29f
--- /dev/null
+++ b/app/ui/composer/tests/dismiss.test.js
@@ -0,0 +1,174 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom, fire, key } from "./_dom.js";
+import { push, depth, _resetDismiss } from "../internals/dismiss.js";
+
+test.beforeEach(() => { setupDom(); _resetDismiss(); });
+test.after(() => teardownDom());
+
+const panel = (id) => {
+ const n = document.createElement("div");
+ n.id = id;
+ document.body.appendChild(n);
+ return n;
+};
+
+test("Escape dismisses the top entry only", () => {
+ // v4's bug: a modal and an autocomplete inside it each had their own Escape
+ // handler, so one press closed both.
+ const closed = [];
+ const modal = panel("modal");
+ const menu = panel("menu");
+ push({ nodes: modal, onDismiss: () => closed.push("modal") });
+ push({ nodes: menu, onDismiss: () => closed.push("menu") });
+
+ key(document.body, "Escape");
+ assert.deepEqual(closed, ["menu"]);
+ assert.equal(depth(), 1);
+
+ key(document.body, "Escape");
+ assert.deepEqual(closed, ["menu", "modal"]);
+ assert.equal(depth(), 0);
+});
+
+test("a click outside dismisses the top entry", () => {
+ let closed = false;
+ const menu = panel("menu");
+ push({ nodes: menu, onDismiss: () => { closed = true; } });
+ fire(document.body, "pointerdown");
+ assert.equal(closed, true);
+});
+
+test("a click inside does not dismiss", () => {
+ let closed = false;
+ const menu = panel("menu");
+ const child = document.createElement("button");
+ menu.appendChild(child);
+ push({ nodes: menu, onDismiss: () => { closed = true; } });
+ fire(child, "pointerdown");
+ assert.equal(closed, false);
+});
+
+test("the trigger counts as inside, so clicking it again does not close-then-reopen", () => {
+ let closed = false;
+ const menu = panel("menu");
+ const trigger = panel("trigger");
+ push({ nodes: [menu, trigger], onDismiss: () => { closed = true; } });
+ fire(trigger, "pointerdown");
+ assert.equal(closed, false);
+});
+
+test("a click inside the top entry leaves the one below alone", () => {
+ const closed = [];
+ const modal = panel("modal");
+ const menu = panel("menu");
+ push({ nodes: modal, onDismiss: () => closed.push("modal") });
+ push({ nodes: menu, onDismiss: () => closed.push("menu") });
+ fire(menu, "pointerdown");
+ assert.deepEqual(closed, []);
+});
+
+test("closeOnEsc false ignores Escape but still takes outside clicks", () => {
+ let closed = false;
+ const modal = panel("modal");
+ push({ nodes: modal, closeOnEsc: false, onDismiss: () => { closed = true; } });
+ key(document.body, "Escape");
+ assert.equal(closed, false);
+ fire(document.body, "pointerdown");
+ assert.equal(closed, true);
+});
+
+test("closeOnOutside false ignores clicks but still takes Escape", () => {
+ let closed = false;
+ const modal = panel("modal");
+ push({ nodes: modal, closeOnOutside: false, onDismiss: () => { closed = true; } });
+ fire(document.body, "pointerdown");
+ assert.equal(closed, false);
+ key(document.body, "Escape");
+ assert.equal(closed, true);
+});
+
+test("a non-dismissable top entry blocks Escape reaching the one below", () => {
+ // Otherwise a modal that declined Escape would still close when a child
+ // overlay above it declined too.
+ const closed = [];
+ push({ nodes: panel("modal"), onDismiss: () => closed.push("modal") });
+ push({ nodes: panel("sticky"), closeOnEsc: false, onDismiss: () => closed.push("sticky") });
+ key(document.body, "Escape");
+ assert.deepEqual(closed, []);
+});
+
+test("dismissing twice runs onDismiss once", () => {
+ let n = 0;
+ const h = push({ nodes: panel("menu"), onDismiss: () => { n += 1; } });
+ h.dismiss();
+ h.dismiss();
+ assert.equal(n, 1);
+});
+
+test("release removes the entry without running onDismiss", () => {
+ let n = 0;
+ const h = push({ nodes: panel("menu"), onDismiss: () => { n += 1; } });
+ h.release();
+ assert.equal(n, 0);
+ assert.equal(depth(), 0);
+ key(document.body, "Escape");
+ assert.equal(n, 0);
+});
+
+test("dismissing out of order keeps the rest of the stack intact", () => {
+ const closed = [];
+ const a = push({ nodes: panel("a"), onDismiss: () => closed.push("a") });
+ push({ nodes: panel("b"), onDismiss: () => closed.push("b") });
+ a.dismiss();
+ assert.deepEqual(closed, ["a"]);
+ assert.equal(depth(), 1);
+ key(document.body, "Escape");
+ assert.deepEqual(closed, ["a", "b"]);
+});
+
+test("isTop and depth describe the stack", () => {
+ const a = push({ nodes: panel("a") });
+ const b = push({ nodes: panel("b") });
+ assert.equal(a.isTop, false);
+ assert.equal(b.isTop, true);
+ assert.equal(a.depth, 1);
+ assert.equal(b.depth, 2);
+});
+
+test("listeners are removed once the stack empties", () => {
+ // A stack that empties but keeps listening means every later key press walks
+ // dead handlers, and the leak grows with every overlay ever opened.
+ let added = 0;
+ const realAdd = document.addEventListener.bind(document);
+ const realRemove = document.removeEventListener.bind(document);
+ document.addEventListener = (...args) => { added += 1; return realAdd(...args); };
+ document.removeEventListener = (...args) => { added -= 1; return realRemove(...args); };
+
+ const h = push({ nodes: panel("a") });
+ assert.equal(added, 2);
+ h.dismiss();
+ assert.equal(added, 0);
+
+ push({ nodes: panel("b") });
+ assert.equal(added, 2, "listening resumes for a later overlay");
+});
+
+test("keys other than Escape are ignored", () => {
+ let closed = false;
+ push({ nodes: panel("a"), onDismiss: () => { closed = true; } });
+ key(document.body, "Enter");
+ key(document.body, "a");
+ assert.equal(closed, false);
+});
+
+test("a virtual anchor never reaches the outside-click check", () => {
+ // A context menu is anchored to coordinates, not an element. That stand-in
+ // has no .contains, and passing it through threw on the first click anywhere.
+ let closed = false;
+ const point = { getBoundingClientRect: () => ({ x: 10, y: 10, width: 0, height: 0 }) };
+ push({ nodes: [panel("menu"), point], onDismiss: () => { closed = true; } });
+ assert.doesNotThrow(() => fire(document.body, "pointerdown"));
+ assert.equal(closed, true);
+});
diff --git a/app/ui/composer/tests/drag.test.js b/app/ui/composer/tests/drag.test.js
new file mode 100644
index 00000000..371a9099
--- /dev/null
+++ b/app/ui/composer/tests/drag.test.js
@@ -0,0 +1,152 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom } from "./_dom.js";
+import { drag } from "../internals/drag.js";
+
+test.beforeEach(() => setupDom());
+test.after(() => teardownDom());
+
+function handle() {
+ const n = document.createElement("div");
+ document.body.appendChild(n);
+ // jsdom has PointerEvent but not the capture API; the kit guards for both, so
+ // record the calls to prove the guards do the right thing.
+ n.captured = [];
+ n.setPointerCapture = (id) => n.captured.push(id);
+ n.releasePointerCapture = (id) => { n.captured = n.captured.filter((c) => c !== id); };
+ n.hasPointerCapture = (id) => n.captured.includes(id);
+ return n;
+}
+
+const pointer = (node, type, { x = 0, y = 0, id = 1, button = 0 } = {}) =>
+ node.dispatchEvent(new window.PointerEvent(type, {
+ clientX: x, clientY: y, pointerId: id, button, bubbles: true, cancelable: true,
+ }));
+
+test("reports movement relative to where the drag began", () => {
+ const h = handle();
+ const moves = [];
+ drag(h, { onMove: (m) => moves.push([m.dx, m.dy]) });
+
+ pointer(h, "pointerdown", { x: 100, y: 100 });
+ pointer(h, "pointermove", { x: 130, y: 90 });
+ pointer(h, "pointermove", { x: 100, y: 100 });
+
+ assert.deepEqual(moves, [[30, -10], [0, 0]]);
+});
+
+test("start, move and end all fire, with the final delta on end", () => {
+ const h = handle();
+ const seen = [];
+ drag(h, {
+ onStart: () => seen.push("start"),
+ onMove: () => seen.push("move"),
+ onEnd: (e) => seen.push(`end:${e.dx},${e.dy}`),
+ });
+ pointer(h, "pointerdown", { x: 10, y: 10 });
+ pointer(h, "pointermove", { x: 15, y: 20 });
+ pointer(h, "pointerup", { x: 25, y: 30 });
+ assert.deepEqual(seen, ["start", "move", "end:15,20"]);
+});
+
+test("the pointer is captured for the drag and released after", () => {
+ // Capture is what guarantees a pointerup even when the cursor leaves the
+ // window; without it a drag can be left running forever.
+ const h = handle();
+ drag(h);
+ pointer(h, "pointerdown", { x: 0, y: 0, id: 7 });
+ assert.deepEqual(h.captured, [7]);
+ pointer(h, "pointerup", { x: 0, y: 0, id: 7 });
+ assert.deepEqual(h.captured, []);
+});
+
+test("moves before a pointerdown are ignored", () => {
+ const h = handle();
+ let moved = false;
+ drag(h, { onMove: () => { moved = true; } });
+ pointer(h, "pointermove", { x: 50, y: 50 });
+ assert.equal(moved, false);
+});
+
+test("moves after the drag ends are ignored", () => {
+ const h = handle();
+ let moves = 0;
+ drag(h, { onMove: () => { moves += 1; } });
+ pointer(h, "pointerdown", { x: 0, y: 0 });
+ pointer(h, "pointermove", { x: 5, y: 5 });
+ pointer(h, "pointerup", { x: 5, y: 5 });
+ pointer(h, "pointermove", { x: 99, y: 99 });
+ assert.equal(moves, 1);
+});
+
+test("a second pointer does not hijack a drag in progress", () => {
+ // Two fingers on a trim handle would otherwise interleave deltas from both.
+ const h = handle();
+ const moves = [];
+ drag(h, { onMove: (m) => moves.push(m.dx) });
+ pointer(h, "pointerdown", { x: 0, y: 0, id: 1 });
+ pointer(h, "pointermove", { x: 10, y: 0, id: 2 });
+ pointer(h, "pointermove", { x: 20, y: 0, id: 1 });
+ assert.deepEqual(moves, [20]);
+});
+
+test("a second pointerdown while dragging is ignored", () => {
+ const h = handle();
+ let starts = 0;
+ drag(h, { onStart: () => { starts += 1; } });
+ pointer(h, "pointerdown", { x: 0, y: 0, id: 1 });
+ pointer(h, "pointerdown", { x: 5, y: 5, id: 2 });
+ assert.equal(starts, 1);
+});
+
+test("only the configured button starts a drag", () => {
+ const h = handle();
+ let starts = 0;
+ drag(h, { onStart: () => { starts += 1; } });
+ pointer(h, "pointerdown", { x: 0, y: 0, button: 2 }); // right-click
+ assert.equal(starts, 0);
+ pointer(h, "pointerdown", { x: 0, y: 0, button: 0 });
+ assert.equal(starts, 1);
+});
+
+test("pointercancel ends the drag and says it was cancelled", () => {
+ const h = handle();
+ let end = null;
+ drag(h, { onEnd: (e) => { end = e; } });
+ pointer(h, "pointerdown", { x: 0, y: 0 });
+ pointer(h, "pointercancel", { x: 9, y: 9 });
+ assert.equal(end.cancelled, true);
+ assert.deepEqual(h.captured, [], "capture is released on cancel too");
+});
+
+test("pointerdown is prevented, so a drag does not also select text", () => {
+ const h = handle();
+ drag(h);
+ const event = new window.PointerEvent("pointerdown", {
+ clientX: 0, clientY: 0, pointerId: 1, button: 0, bubbles: true, cancelable: true,
+ });
+ h.dispatchEvent(event);
+ assert.equal(event.defaultPrevented, true);
+});
+
+test("dispose stops listening, mid-drag included", () => {
+ const h = handle();
+ let moves = 0;
+ const dispose = drag(h, { onMove: () => { moves += 1; } });
+ pointer(h, "pointerdown", { x: 0, y: 0 });
+ dispose();
+ pointer(h, "pointermove", { x: 10, y: 10 });
+ assert.equal(moves, 0);
+ assert.deepEqual(h.captured, [], "a disposed drag does not keep the pointer");
+});
+
+test("a drag with no callbacks does not throw", () => {
+ const h = handle();
+ drag(h);
+ assert.doesNotThrow(() => {
+ pointer(h, "pointerdown", { x: 0, y: 0 });
+ pointer(h, "pointermove", { x: 1, y: 1 });
+ pointer(h, "pointerup", { x: 1, y: 1 });
+ });
+});
diff --git a/app/ui/composer/tests/el.test.js b/app/ui/composer/tests/el.test.js
new file mode 100644
index 00000000..c6558404
--- /dev/null
+++ b/app/ui/composer/tests/el.test.js
@@ -0,0 +1,77 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom } from "./_dom.js";
+import { el, frag, clear } from "../internals/el.js";
+
+test.beforeEach(() => setupDom());
+test.after(() => teardownDom());
+
+test("builds an element with class and text", () => {
+ const n = el("button", { cls: "cx-btn", text: "Generate" });
+ assert.equal(n.tagName, "BUTTON");
+ assert.equal(n.className, "cx-btn");
+ assert.equal(n.textContent, "Generate");
+});
+
+test("class accepts an array and drops empty entries", () => {
+ assert.equal(el("div", { cls: ["a", null, "b", false] }).className, "a b");
+});
+
+test("text goes through asText, so markup stays text", () => {
+ const n = el("div", { text: "no " });
+ assert.equal(n.querySelector("b"), null);
+ assert.equal(n.textContent, "no ");
+});
+
+test("style and class attributes are refused", () => {
+ // Presentation is the kit's. Allowing either through attrs would reopen the
+ // exact hole the no-styling rule closes.
+ assert.throws(() => el("div", { attrs: { style: "color:red" } }), TypeError);
+ assert.throws(() => el("div", { attrs: { class: "sneaky" } }), TypeError);
+});
+
+test("inline event handler attributes are refused", () => {
+ for (const name of ["onclick", "onLoad", "onerror"]) {
+ assert.throws(() => el("div", { attrs: { [name]: "boom()" } }), TypeError);
+ }
+});
+
+test("ordinary attributes are set, booleans become empty strings", () => {
+ const n = el("input", { attrs: { type: "checkbox", disabled: true, "aria-label": "Pick" } });
+ assert.equal(n.getAttribute("type"), "checkbox");
+ assert.equal(n.getAttribute("disabled"), "");
+ assert.equal(n.getAttribute("aria-label"), "Pick");
+});
+
+test("false and null attributes are omitted entirely", () => {
+ const n = el("input", { attrs: { disabled: false, title: null } });
+ assert.equal(n.hasAttribute("disabled"), false);
+ assert.equal(n.hasAttribute("title"), false);
+});
+
+test("listeners and children are attached", () => {
+ let clicks = 0;
+ const child = el("span", { text: "x" });
+ const n = el("div", { on: { click: () => { clicks += 1; } }, children: child });
+ n.dispatchEvent(new window.Event("click"));
+ assert.equal(clicks, 1);
+ assert.equal(n.firstChild, child);
+});
+
+test("falsy children are skipped, so conditionals read naturally", () => {
+ const n = el("div", { children: [el("i"), null, false, undefined] });
+ assert.equal(n.childNodes.length, 1);
+});
+
+test("frag is detached, so a half-built tree never reaches the document", () => {
+ const f = frag();
+ f.appendChild(el("div"));
+ assert.equal(f.nodeType, 11);
+ assert.equal(document.body.childNodes.length, 0);
+});
+
+test("clear empties a node", () => {
+ const n = el("div", { children: [el("i"), el("b")] });
+ assert.equal(clear(n).childNodes.length, 0);
+});
diff --git a/app/ui/composer/tests/elements_behaviour.test.js b/app/ui/composer/tests/elements_behaviour.test.js
new file mode 100644
index 00000000..52cb493d
--- /dev/null
+++ b/app/ui/composer/tests/elements_behaviour.test.js
@@ -0,0 +1,634 @@
+// Behaviour the registry-wide rule tests cannot see: what each element does
+// when it is actually used.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom, fire } from "./_dom.js";
+
+let composer;
+test.before(async () => {
+ setupDom();
+ ({ composer } = await import("../composer.js"));
+});
+test.after(() => teardownDom());
+
+const mount = (handle) => { document.body.appendChild(handle.node); return handle; };
+
+// --- buttons ---------------------------------------------------------------
+
+test("a button reports clicks and can be made busy", () => {
+ let clicks = 0;
+ const b = mount(composer.button.md({ label: "Go", onClick: () => { clicks += 1; } }));
+ b.node.click();
+ assert.equal(clicks, 1);
+
+ b.setBusy(true);
+ assert.equal(b.node.disabled, true);
+ assert.equal(b.node.getAttribute("aria-busy"), "true");
+ b.node.click();
+ assert.equal(clicks, 1, "a busy button does not fire again");
+
+ b.setBusy(false);
+ assert.equal(b.node.disabled, false);
+});
+
+test("an unknown tone is refused rather than rendered plain", () => {
+ assert.throws(() => composer.button.md({ label: "x", tone: "sparkly" }), RangeError);
+});
+
+test("an icon button without a label is refused", () => {
+ // Otherwise a toolbar of glyphs is unusable by screen reader and unlabelled
+ // on hover, and nobody notices until someone needs it.
+ assert.throws(() => composer.iconButton.md({ icon: "x" }), TypeError);
+});
+
+test("buttonGroup picks one, or several when multi", () => {
+ const seen = [];
+ const g = mount(composer.buttonGroup.md({
+ value: "a", onChange: (v) => seen.push(v),
+ items: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
+ }));
+ g.node.querySelectorAll("button")[1].click();
+ assert.equal(g.value, "b");
+ assert.deepEqual(seen, ["b"]);
+ assert.equal(g.node.querySelectorAll("button")[1].getAttribute("aria-checked"), "true");
+
+ const m = mount(composer.buttonGroup.md({
+ multi: true, value: ["a"],
+ items: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
+ }));
+ m.node.querySelectorAll("button")[1].click();
+ assert.deepEqual(m.value.sort(), ["a", "b"]);
+ m.node.querySelectorAll("button")[0].click();
+ assert.deepEqual(m.value, ["b"], "clicking an active one turns it off");
+});
+
+// --- choice ----------------------------------------------------------------
+
+test("checkbox reads and writes its state", () => {
+ let last = null;
+ const c = mount(composer.checkbox.default({ label: "On", checked: false, onChange: (v) => { last = v; } }));
+ const input = c.node.querySelector("input");
+ input.click();
+ assert.equal(c.value, true);
+ assert.equal(last, true);
+ c.setValue(false);
+ assert.equal(input.checked, false);
+});
+
+test("a bare checkbox carries its label as the accessible name", () => {
+ const c = composer.checkbox.default({ label: "Enabled" });
+ assert.equal(c.node.querySelector("input").getAttribute("aria-label"), "Enabled");
+});
+
+test("a checkbox row is labelled by its visible text, not by aria-label", () => {
+ // Duplicating it would override what the user can see with a copy that can
+ // drift from it.
+ const c = composer.checkboxRow.default({ label: "Sync", hint: "Locks timing" });
+ const input = c.node.querySelector("input");
+ assert.equal(input.getAttribute("aria-label"), null);
+ const face = c.node.querySelector("label.cx-check-face");
+ assert.equal(face.getAttribute("for"), input.id,
+ "the label points at the input rather than wrapping it");
+ assert.equal(face.contains(input), false);
+ assert.ok(input.getAttribute("aria-describedby"), "the hint is wired as the description");
+});
+
+test("checklist collects several values", () => {
+ const c = mount(composer.checklist.default({
+ values: ["a"], items: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
+ }));
+ c.node.querySelectorAll("input")[1].click();
+ assert.deepEqual(c.value.sort(), ["a", "b"]);
+ c.setValue(["b"]);
+ assert.deepEqual(c.value, ["b"]);
+});
+
+test("radioGroup keeps exactly one", () => {
+ const r = mount(composer.radioGroup.default({
+ value: "a", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
+ }));
+ const inputs = r.node.querySelectorAll("input");
+ inputs[1].click();
+ assert.equal(r.value, "b");
+ assert.equal(inputs[0].checked, false);
+});
+
+test("segmented selects and reflects aria-checked", () => {
+ const s = mount(composer.segmented.md({
+ value: "t2v", options: [{ value: "t2v", label: "T" }, { value: "i2v", label: "I" }],
+ }));
+ const buttons = s.node.querySelectorAll("button");
+ buttons[1].click();
+ assert.equal(s.value, "i2v");
+ assert.equal(buttons[1].getAttribute("aria-checked"), "true");
+ assert.equal(buttons[0].getAttribute("aria-checked"), "false");
+});
+
+test("select round-trips its value", () => {
+ const s = mount(composer.select.md({
+ value: "b", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
+ }));
+ assert.equal(s.value, "b");
+ s.setValue("a");
+ assert.equal(s.value, "a");
+});
+
+test("toggle is a switch, not a checkbox, to assistive tech", () => {
+ const t = mount(composer.toggle.default({ label: "Second pass", checked: true }));
+ assert.equal(t.node.querySelector("input").getAttribute("role"), "switch");
+ assert.equal(t.value, true);
+});
+
+// --- input -----------------------------------------------------------------
+
+test("onCommit fires on Enter and blur, not on every keystroke", () => {
+ // A settings value that updates per keystroke writes nine intermediate values
+ // while you type "0.65".
+ const commits = [];
+ const inputs = [];
+ const i = mount(composer.input.md({
+ onInput: (v) => inputs.push(v),
+ onCommit: (v) => commits.push(v),
+ }));
+ i.node.value = "roof";
+ fire(i.node, "input");
+ assert.deepEqual(inputs, ["roof"]);
+ assert.deepEqual(commits, []);
+
+ i.node.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
+ assert.deepEqual(commits, ["roof"]);
+
+ fire(i.node, "blur");
+ assert.deepEqual(commits, ["roof"], "an unchanged value does not commit twice");
+});
+
+test("number clamps on commit, not while typing", () => {
+ // Clamping mid-keystroke makes a leading "-" or "0." impossible to type.
+ let last = null;
+ const n = mount(composer.number.md({ value: 3, min: 1, max: 10, onChange: (v) => { last = v; } }));
+ const input = n.node.querySelector("input") || n.node;
+ input.value = "99";
+ fire(input, "input");
+ assert.equal(input.value, "99", "not clamped yet");
+ fire(input, "blur");
+ assert.equal(last, 10);
+});
+
+test("number applies its precision", () => {
+ let last = null;
+ const n = mount(composer.number.md({ value: 1, min: 0, max: 10, precision: 1, onChange: (v) => { last = v; } }));
+ const input = n.node.querySelector("input") || n.node;
+ input.value = "3.14159";
+ fire(input, "blur");
+ assert.equal(last, 3.1);
+});
+
+test("stepper steps without float drift", () => {
+ const s = mount(composer.stepper.md({ value: 0.1, min: 0, max: 1, step: 0.2 }));
+ s.node.querySelectorAll("button")[1].click();
+ assert.equal(s.value, 0.3, "0.1 + 0.2 must not surface as 0.30000000000000004");
+});
+
+test("stepper respects its bounds", () => {
+ const s = mount(composer.stepper.md({ value: 1, min: 0, max: 1, step: 1 }));
+ s.node.querySelectorAll("button")[1].click();
+ assert.equal(s.value, 1);
+});
+
+test("filterList filters on label and hint, and reports a pick", () => {
+ let picked = null;
+ const f = mount(composer.filterList.md({
+ onChange: (id) => { picked = id; },
+ items: [
+ { id: "a", label: "bong_tangent", hint: "validated" },
+ { id: "b", label: "karras", hint: "2.3 era" },
+ ],
+ }));
+ const search = f.node.querySelector("input");
+ search.value = "valid";
+ fire(search, "input");
+ assert.equal(f.node.querySelectorAll(".cx-filter-row").length, 1, "matches on hint too");
+
+ f.node.querySelector(".cx-filter-row").click();
+ assert.equal(picked, "a");
+});
+
+test("filterList filters on keywords too, though it never draws them", () => {
+ const f = mount(composer.filterList.md({
+ items: [
+ { id: "a", label: "Models and pipeline", hint: "What the run is made of.", keywords: "loaders" },
+ { id: "b", label: "Updates" },
+ ],
+ }));
+ const search = f.node.querySelector("input");
+ search.value = "loaders";
+ fire(search, "input");
+ const rows = f.node.querySelectorAll(".cx-filter-row");
+ assert.equal(rows.length, 1, "a section findable only by a keyword must still be found");
+ assert.equal(f.node.textContent.includes("loaders"), false, "keywords are for search, not display");
+});
+
+test("filterList gives an icon a coloured chip only from a fixed tone, never from raw item data", () => {
+ const f = mount(composer.filterList.md({
+ items: [{ id: "a", label: "Node packs", icon: "▣", tone: "good" }],
+ }));
+ const icon = f.node.querySelector(".cx-filter-icon");
+ assert.ok(icon.classList.contains("cx-filter-icon-good"));
+});
+
+test("an unknown filterList icon tone is refused rather than rendered as a blank chip", () => {
+ assert.throws(() => mount(composer.filterList.md({
+ items: [{ id: "a", label: "Node packs", icon: "▣", tone: "sparkly-typo" }],
+ })), RangeError);
+});
+
+test("filterList says so when nothing matches", () => {
+ const f = mount(composer.filterList.md({ items: [{ id: "a", label: "one" }] }));
+ const search = f.node.querySelector("input");
+ search.value = "zzz";
+ fire(search, "input");
+ assert.equal(f.node.querySelectorAll(".cx-filter-row").length, 0);
+ assert.ok(f.node.querySelector(".cx-filter-empty"), "an empty list must say it is empty");
+});
+
+// --- slider ----------------------------------------------------------------
+
+test("slider reports as it moves and commits on release", () => {
+ const moves = [];
+ const commits = [];
+ const s = mount(composer.slider.md({
+ value: 0.5, min: 0, max: 1, step: 0.1,
+ onChange: (v) => moves.push(v), onCommit: (v) => commits.push(v),
+ }));
+ const input = s.node.querySelector("input");
+ input.value = "0.8";
+ fire(input, "input");
+ fire(input, "change");
+ assert.deepEqual(moves, [0.8]);
+ assert.deepEqual(commits, [0.8]);
+});
+
+test("the slider paints its fill from the value", () => {
+ const s = mount(composer.slider.md({ value: 0.25, min: 0, max: 1, step: 0.05 }));
+ assert.equal(s.node.querySelector("input").style.getPropertyValue("--fill"), "25%");
+});
+
+test("slider.readout shows the value and its unit", () => {
+ const s = mount(composer.slider.readout({ value: 0.65, min: 0, max: 1, step: 0.05, unit: "x", precision: 2 }));
+ assert.equal(s.node.querySelector("output").textContent, "0.65x");
+ s.setValue(0.3);
+ assert.equal(s.node.querySelector("output").textContent, "0.30x");
+});
+
+test("slider.macro jumps to a preset", () => {
+ const s = mount(composer.slider.macro({
+ value: 0.2, min: 0, max: 1, step: 0.05,
+ presets: [{ label: "Chill", value: 0.2 }, { label: "Chaos", value: 0.9 }],
+ }));
+ s.node.querySelectorAll(".cx-macro-presets button")[1].click();
+ assert.equal(s.value, 0.9);
+});
+
+test("range thumbs cannot pass each other", () => {
+ // A range whose start is after its end is a state every consumer would then
+ // have to defend against.
+ const r = mount(composer.range.md({ from: 0.3, to: 0.7, min: 0, max: 1, step: 0.05 }));
+ const [lo, hi] = r.node.querySelectorAll("input");
+ lo.value = "0.9";
+ fire(lo, "input");
+ const { from, to } = r.value;
+ assert.ok(from <= to, `from ${from} must not exceed to ${to}`);
+});
+
+// --- regressions found by the adversarial pass ------------------------------
+
+test("finishing a busy spell does not re-enable a button that was disabled", () => {
+ // busy and disabled are independent reasons a button cannot be clicked.
+ // Deriving the attribute from the constructor's value meant the later call
+ // silently lost to the earlier one.
+ const b = mount(composer.button.md({ label: "Save" }));
+ b.setDisabled(true);
+ b.setBusy(true);
+ b.setBusy(false);
+ assert.equal(b.node.disabled, true);
+ b.setDisabled(false);
+ assert.equal(b.node.disabled, false);
+});
+
+test("a busy button stays disabled even if setDisabled(false) is called", () => {
+ const b = mount(composer.button.md({ label: "Save" }));
+ b.setBusy(true);
+ b.setDisabled(false);
+ assert.equal(b.node.disabled, true);
+});
+
+test("clearing a number field keeps the last committed value", () => {
+ // A browser normalises invalid text in a number input to "", and Number("")
+ // is 0 -- finite, so it sails past an isFinite guard and lands on min,
+ // discarding what the user had actually entered.
+ const seen = [];
+ const n = mount(composer.number.md({ value: 3, min: 1, max: 10, onChange: (v) => seen.push(v) }));
+ const input = n.node.querySelector("input") || n.node;
+
+ input.value = "7";
+ fire(input, "blur");
+ assert.equal(n.value, 7);
+
+ input.value = "";
+ fire(input, "blur");
+ assert.equal(n.value, 7, "an emptied field is not a zero");
+ assert.equal(input.value, "7", "and the field shows what is actually held");
+ assert.deepEqual(seen, [7, 7]);
+});
+
+test("a number field ignores unparseable text on commit", () => {
+ const n = mount(composer.number.md({ value: 5, min: 0, max: 10 }));
+ const input = n.node.querySelector("input") || n.node;
+ input.value = "abc";
+ fire(input, "blur");
+ assert.equal(n.value, 5);
+});
+
+test("a list row carries a real control, separate from its reorder arrows", () => {
+ // A bare number printed beside up/down arrows reads as a stepper for that
+ // number. The weight is edited in a control; the arrows only move the row.
+ const weight = composer.number.md({ value: 0.8, min: -2, max: 2, step: 0.05, label: "Weight" });
+ const l = mount(composer.list.rows({
+ reorder: true,
+ items: [{ label: "detail_v3", control: weight }, { label: "motion_lift" }],
+ }));
+ assert.ok(l.node.querySelector(".cx-list-control input"), "the row has an editable control");
+
+ const moves = [...l.node.querySelectorAll(".cx-list-move")];
+ assert.ok(moves.length >= 2);
+ for (const m of moves) {
+ assert.match(m.getAttribute("aria-label"), /^Move .* (up|down)$/,
+ "reorder buttons say they reorder");
+ }
+});
+
+test("reordering a list reports the new order", () => {
+ let order = null;
+ const l = mount(composer.list.rows({
+ reorder: true, onReorder: (items) => { order = items.map((i) => i.label); },
+ items: [{ label: "a" }, { label: "b" }],
+ }));
+ // the second row's "up" button
+ l.node.querySelectorAll(".cx-list-row")[1].querySelector(".cx-list-move").click();
+ assert.deepEqual(order, ["b", "a"]);
+});
+
+// --- workspace -------------------------------------------------------------
+
+const workspace = (props = {}) => mount(composer.workspace.docked({
+ id: `ws-${Math.random().toString(36).slice(2)}`,
+ left: composer.hint.default({ text: "assets" }),
+ centre: composer.hint.default({ text: "preview" }),
+ right: composer.hint.default({ text: "properties" }),
+ ...props,
+}));
+
+test("a docked panel's toggle is not inside the panel it collapses", () => {
+ // The v4 fault, as a test. A collapsed column was marked [hidden], which is
+ // display:none, and the control that would have brought it back was inside
+ // it -- so the button did nothing at all and said nothing.
+ const ws = workspace();
+ for (const side of ["left", "right"]) {
+ const panel = side === "left" ? ws.left : ws.right;
+ const toggle = ws.node.querySelector(`.cx-workspace-rail-${side} button`);
+ assert.ok(toggle, `${side} has no toggle`);
+ assert.ok(!panel.contains(toggle), `the ${side} toggle is inside the panel it hides`);
+ }
+});
+
+test("collapsing keeps the panel in the document rather than removing it", () => {
+ const ws = workspace();
+ ws.close("left");
+ assert.equal(ws.isOpen("left"), false);
+ assert.ok(ws.node.contains(ws.left), "the panel was removed rather than collapsed");
+ assert.ok(!ws.left.hasAttribute("hidden"),
+ "[hidden] is display:none, which is what made this unrecoverable in v4");
+ assert.ok(ws.left.classList.contains("cx-collapsed"));
+});
+
+test("a collapsed panel can be reopened from its toggle", () => {
+ const ws = workspace();
+ const toggle = ws.node.querySelector(".cx-workspace-rail-right button");
+ toggle.click();
+ assert.equal(ws.isOpen("right"), false);
+ assert.equal(toggle.getAttribute("aria-expanded"), "false");
+ toggle.click();
+ assert.equal(ws.isOpen("right"), true);
+ assert.equal(toggle.getAttribute("aria-expanded"), "true");
+});
+
+test("the centre keeps its content and the sides keep theirs", () => {
+ const ws = workspace();
+ assert.match(ws.centre.textContent, /preview/);
+ assert.match(ws.left.textContent, /assets/);
+ assert.match(ws.right.textContent, /properties/);
+});
+
+test("which panels were open is remembered per workspace id", () => {
+ const first = workspace({ id: "remembered" });
+ first.close("left");
+ first.destroy();
+
+ const second = workspace({ id: "remembered" });
+ assert.equal(second.isOpen("left"), false, "the closed panel came back open");
+ assert.equal(second.isOpen("right"), true);
+});
+
+test("a workspace with no panels still renders its centre", () => {
+ const ws = mount(composer.workspace.docked({
+ id: "bare", centre: composer.hint.default({ text: "only the middle" }),
+ }));
+ assert.match(ws.centre.textContent, /only the middle/);
+ assert.ok(ws.left && ws.right, "the regions exist even when nothing was put in them");
+});
+
+test("a captionless tile still has a name", () => {
+ // The icons view drops the caption on purpose. Nothing else in the cell says
+ // what it is, so without an explicit name the whole bin reads to a screen
+ // reader as a row of unlabelled buttons.
+ const g = mount(composer.gallery.icons({ items: [{ id: "1", label: "rooftop_dusk_01.mp4" }] }));
+ const tile = g.node.querySelector(".cx-cell");
+ assert.equal(tile.getAttribute("aria-label"), "rooftop_dusk_01.mp4");
+ assert.equal(tile.getAttribute("title"), "rooftop_dusk_01.mp4");
+});
+
+test("the three views of a collection agree on what is selected", () => {
+ // Grid, list and icons are one bin in three shapes. A view that reported its
+ // selection differently would make switching views a way to lose it.
+ const items = [{ id: "a", label: "a.png" }, { id: "b", label: "b.png" }];
+ for (const view of ["adaptive", "list", "icons"]) {
+ const g = mount(composer.gallery[view]({ items, selection: ["b"] }));
+ assert.deepEqual(g.value, ["b"], `${view} reported the wrong selection`);
+ g.setValue(["a"]);
+ assert.deepEqual(g.value, ["a"], `${view} did not take a new selection`);
+ const on = [...g.node.querySelectorAll('[aria-selected="true"]')];
+ assert.equal(on.length, 1, `${view} drew ${on.length} selected entries`);
+ g.destroy();
+ }
+});
+
+test("a thumbnail that does not load falls back to the glyph", () => {
+ // The browser's broken-image icon reads as a damaged file. In a bin of fifty
+ // results one server hiccup would look like lost work.
+ const g = mount(composer.gallery.adaptive({ id: "broken", items: [{ id: "1", label: "a.png", thumb: "/gone.png" }] }));
+ const img = g.node.querySelector("img");
+ fire(img, "error");
+ assert.equal(g.node.querySelector("img"), null);
+ assert.ok(g.node.querySelector(".cx-cell-glyph"), "nothing stood in for the missing picture");
+ assert.match(g.node.textContent, /a\.png/, "the name went with it");
+});
+
+test("a bin's thumbnails are lazy", () => {
+ // A bin holds everything a session produced. Fetching all of it the moment
+ // the panel draws is the shape of v4's media-bin stall: the browser opens six
+ // connections per origin, and the API is behind the same six.
+ const items = [{ id: "1", label: "a.png", thumb: "/view?filename=a.png" }];
+ for (const view of ["adaptive", "list", "icons"]) {
+ const g = mount(composer.gallery[view]({ items }));
+ assert.equal(g.node.querySelector("img").getAttribute("loading"), "lazy",
+ `${view} fetches every thumbnail at once`);
+ g.destroy();
+ }
+});
+
+// --- track -------------------------------------------------------------
+
+const CLIPS = [
+ { id: "a", label: "Scene 1", start: 0, duration: 4 },
+ { id: "b", label: "Scene 2", start: 4, duration: 6 },
+ { id: "c", label: "Scene 3", start: 10, duration: 2 },
+];
+
+test("a clip's width is proportional to its duration, not equal to its neighbours'", () => {
+ // The third clip is 2s at 10px/s = 20px, under the 24px floor -- widened to
+ // stay clickable, which is why it is not exactly proportional to the other
+ // two the way the first two are to each other.
+ const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 10 }));
+ const widths = [...t.node.querySelectorAll(".cx-track-clip")].map((c) => parseFloat(c.style.width));
+ assert.deepEqual(widths, [40, 60, 24]);
+});
+
+test("a clip under the pixel floor is not squeezed to nothing", () => {
+ const t = mount(composer.track.default({
+ items: [{ id: "a", label: "Sliver", start: 0, duration: 0.1 }], pxPerSecond: 10,
+ }));
+ const width = parseFloat(t.node.querySelector(".cx-track-clip").style.width);
+ assert.ok(width >= 24, `a 0.1s clip at 10px/s rendered ${width}px wide`);
+});
+
+test("the ruler reads real elapsed time, not a tick per clip", () => {
+ // Three clips, but the ruler's ticks are seconds -- there is no reason their
+ // count should match the clip count in either direction.
+ const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 40 }));
+ const ticks = [...t.node.querySelectorAll(".cx-track-tick")].map((n) => n.textContent);
+ assert.deepEqual(ticks, ["0:00", "0:02", "0:04", "0:06", "0:08", "0:10", "0:12"]);
+});
+
+test("clicking partway into a clip seeks to that exact second, not to the clip's start", () => {
+ // A clip covers most of the track's own area -- if a click on one only
+ // ever resolved to its START, an exact-second seek would only ever be
+ // reachable from the thin ruler strip above it.
+ const seen = [];
+ const t = mount(composer.track.default({
+ items: CLIPS, pxPerSecond: 40, onSeek: (seconds, item) => seen.push([seconds, item && item.id]),
+ }));
+ // Clip "b" spans 4s-10s -> 160px-400px at 40px/s. 250px is 6.25s into it.
+ t.node.querySelector('[data-id="b"]')
+ .dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 250 }));
+ assert.deepEqual(seen, [[6.25, "b"]]);
+});
+
+test("clicking empty track (the ruler, the gap past the clips) seeks by position", () => {
+ const seen = [];
+ const t = mount(composer.track.default({
+ items: CLIPS, pxPerSecond: 10, onSeek: (seconds, item) => seen.push([seconds, item && item.id]),
+ }));
+ const inner = t.node.querySelector(".cx-track-inner");
+ inner.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 45 }));
+ // 45px at 10px/s = 4.5s, which falls inside clip "b" (4s..10s).
+ assert.deepEqual(seen, [[4.5, "b"]]);
+});
+
+test("a seek past the last clip clamps to the track's own end", () => {
+ const seen = [];
+ const t = mount(composer.track.default({
+ items: CLIPS, pxPerSecond: 10, onSeek: (seconds, item) => seen.push([seconds, item && item.id]),
+ }));
+ t.node.querySelector(".cx-track-inner")
+ .dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 9999 }));
+ assert.deepEqual(seen, [[12, "c"]]);
+});
+
+test("setPlayhead moves the marker without redrawing the clips", () => {
+ const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 10, playhead: 0 }));
+ const before = t.node.querySelector(".cx-track-clip");
+ t.setPlayhead(7);
+ const head = t.node.querySelector(".cx-track-playhead");
+ assert.equal(head.style.insetInlineStart, "70px");
+ assert.equal(t.node.querySelector(".cx-track-clip"), before, "the clip row was rebuilt for a playhead move");
+});
+
+test("setValue redraws the selected clip without a fresh setItems call", () => {
+ const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 10 }));
+ assert.equal(t.node.querySelectorAll('[aria-selected="true"]').length, 0);
+ t.setValue(["b"]);
+ const on = t.node.querySelector('[aria-selected="true"]');
+ assert.equal(on && on.dataset.id, "b");
+});
+
+test("dragging a clip onto another reports the reorder by id, not by position", () => {
+ const reordered = [];
+ const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 10, onReorder: (from, to) => reordered.push([from, to]) }));
+ const cells = t.node.querySelectorAll(".cx-track-clip");
+ const store = {};
+ const dt = {
+ effectAllowed: null,
+ setData: (type, v) => { store[type] = v; },
+ getData: (type) => store[type] || "",
+ get types() { return Object.keys(store); },
+ };
+ const fireDrag = (node, type) => {
+ const e = new window.Event(type, { bubbles: true, cancelable: true });
+ e.dataTransfer = dt;
+ node.dispatchEvent(e);
+ };
+ fireDrag(cells[0], "dragstart");
+ fireDrag(cells[2], "drop");
+ assert.deepEqual(reordered, [["a", "c"]]);
+});
+
+test("an excluded clip is dimmed, a rated one carries its rating", () => {
+ const t = mount(composer.track.default({
+ items: [
+ { id: "a", label: "A", start: 0, duration: 1, excluded: true },
+ { id: "b", label: "B", start: 1, duration: 1, rating: "liked" },
+ ], pxPerSecond: 10,
+ }));
+ const [a, b] = t.node.querySelectorAll(".cx-track-clip");
+ assert.equal(a.classList.contains("cx-excluded"), true);
+ assert.equal(b.getAttribute("data-rating"), "liked");
+});
+
+test("Enter is a newline in a textarea, not a commit", () => {
+ // A one-line field commits on Enter. A textarea holds the longest thing
+ // anyone types here -- the prompt -- and stealing Enter there means a
+ // multi-line value cannot be typed at all.
+ const committed = [];
+ const box = mount(composer.textarea.md({ label: "Prompt", onCommit: (v) => committed.push(v) }));
+ box.node.value = "first line";
+ const event = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
+ box.node.dispatchEvent(event);
+
+ assert.equal(event.defaultPrevented, false, "Enter was swallowed, so no second line can be typed");
+ assert.deepEqual(committed, []);
+
+ fire(box.node, "blur");
+ assert.deepEqual(committed, ["first line"], "blur is what commits a textarea");
+});
diff --git a/app/ui/composer/tests/focus.test.js b/app/ui/composer/tests/focus.test.js
new file mode 100644
index 00000000..33876cd9
--- /dev/null
+++ b/app/ui/composer/tests/focus.test.js
@@ -0,0 +1,129 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom } from "./_dom.js";
+import { focusables, roving, trap } from "../internals/focus.js";
+
+test.beforeEach(() => setupDom());
+test.after(() => teardownDom());
+
+function box(html) {
+ const n = document.createElement("div");
+ n.innerHTML = html; // test fixture, not kit code
+ document.body.appendChild(n);
+ return n;
+}
+
+const tab = (node, shift = false) =>
+ node.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Tab", shiftKey: shift, bubbles: true, cancelable: true }));
+
+const arrow = (node, key) =>
+ node.dispatchEvent(new window.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
+
+test("focusables skips disabled and hidden controls", () => {
+ const n = box(`a b
+ c d e
`);
+ assert.deepEqual(focusables(n).map((x) => x.textContent || x.tagName), ["a", "c"]);
+});
+
+test("focus moves into the container on trap", () => {
+ const n = box("a b ");
+ trap(n);
+ assert.equal(document.activeElement.textContent, "a");
+});
+
+test("Tab wraps from last back to first", () => {
+ const n = box("a b ");
+ trap(n);
+ n.querySelectorAll("button")[1].focus();
+ tab(n);
+ assert.equal(document.activeElement.textContent, "a");
+});
+
+test("Shift+Tab wraps from first back to last", () => {
+ const n = box("a b ");
+ trap(n);
+ tab(n, true);
+ assert.equal(document.activeElement.textContent, "b");
+});
+
+test("focus that escaped the container is pulled back", () => {
+ const outside = box("outside ");
+ const n = box("a b ");
+ trap(n);
+ outside.querySelector("button").focus();
+ tab(n);
+ assert.ok(n.contains(document.activeElement));
+});
+
+test("release puts focus back where it came from", () => {
+ // Dropping focus to on close silently ends keyboard navigation.
+ const opener = box("opener ").querySelector("button");
+ opener.focus();
+ const n = box("a ");
+ const release = trap(n);
+ assert.equal(document.activeElement.textContent, "a");
+ release();
+ assert.equal(document.activeElement, opener);
+});
+
+test("release can decline to restore", () => {
+ const opener = box("opener ").querySelector("button");
+ opener.focus();
+ const n = box("a ");
+ trap(n)({ restore: false });
+ assert.notEqual(document.activeElement, opener);
+});
+
+test("an empty container traps without throwing", () => {
+ const n = box("nothing focusable
");
+ assert.doesNotThrow(() => trap(n));
+ assert.doesNotThrow(() => tab(n));
+});
+
+test("roving gives the group one tab stop", () => {
+ const n = box("a b c ");
+ roving(n);
+ const tabindexes = [...n.querySelectorAll("button")].map((b) => b.getAttribute("tabindex"));
+ assert.deepEqual(tabindexes, ["0", "-1", "-1"]);
+});
+
+test("arrows move within the group and wrap", () => {
+ const n = box("a b c ");
+ roving(n);
+ n.querySelector("button").focus();
+ arrow(n, "ArrowRight");
+ assert.equal(document.activeElement.textContent, "b");
+ arrow(n, "ArrowRight");
+ arrow(n, "ArrowRight");
+ assert.equal(document.activeElement.textContent, "a", "wraps past the end");
+ arrow(n, "ArrowLeft");
+ assert.equal(document.activeElement.textContent, "c", "wraps before the start");
+});
+
+test("Home and End jump to the edges", () => {
+ const n = box("a b c ");
+ roving(n);
+ n.querySelector("button").focus();
+ arrow(n, "End");
+ assert.equal(document.activeElement.textContent, "c");
+ arrow(n, "Home");
+ assert.equal(document.activeElement.textContent, "a");
+});
+
+test("a vertical group uses up and down", () => {
+ const n = box("a b ");
+ roving(n, { orientation: "vertical" });
+ n.querySelector("button").focus();
+ arrow(n, "ArrowDown");
+ assert.equal(document.activeElement.textContent, "b");
+ arrow(n, "ArrowRight");
+ assert.equal(document.activeElement.textContent, "b", "the cross axis is ignored");
+});
+
+test("arrow keys outside the group are left alone", () => {
+ const n = box("a b ");
+ roving(n);
+ document.body.focus();
+ assert.doesNotThrow(() => arrow(n, "ArrowRight"));
+});
diff --git a/app/ui/composer/tests/ids.test.js b/app/ui/composer/tests/ids.test.js
new file mode 100644
index 00000000..56b9ec2b
--- /dev/null
+++ b/app/ui/composer/tests/ids.test.js
@@ -0,0 +1,14 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { uid, _resetIds } from "../internals/ids.js";
+
+test("ids are unique", () => {
+ _resetIds();
+ const seen = new Set(Array.from({ length: 500 }, () => uid()));
+ assert.equal(seen.size, 500);
+});
+
+test("the prefix is honoured, so ids say what they belong to", () => {
+ assert.match(uid("field"), /^field-\d+$/);
+});
diff --git a/app/ui/composer/tests/lint_fixtures/clean/ui.js b/app/ui/composer/tests/lint_fixtures/clean/ui.js
new file mode 100644
index 00000000..c59a0bff
--- /dev/null
+++ b/app/ui/composer/tests/lint_fixtures/clean/ui.js
@@ -0,0 +1,7 @@
+export function setup({ composer, values }) {
+ const button = composer.button.md({
+ label: "Re-key",
+ onClick: () => values.set("enabled", true),
+ });
+ return button;
+}
diff --git a/app/ui/composer/tests/lint_fixtures/offenders/ui.js b/app/ui/composer/tests/lint_fixtures/offenders/ui.js
new file mode 100644
index 00000000..e6ff8691
--- /dev/null
+++ b/app/ui/composer/tests/lint_fixtures/offenders/ui.js
@@ -0,0 +1,12 @@
+export function setup({ composer }) {
+ const b = composer.button.md({ label: "x" });
+ b.node.style.color = "#ff0000";
+ b.node.classList.add("mine");
+ b.node.innerHTML = "hi ";
+ const smuggled = 'still styling
';
+ const raw = document.createElement("div");
+ raw.setAttribute("style", "margin: 12px");
+ document.head.appendChild(raw);
+ void smuggled;
+ return new Function("return 1")();
+}
diff --git a/app/ui/composer/tests/motion.test.js b/app/ui/composer/tests/motion.test.js
new file mode 100644
index 00000000..819e12e6
--- /dev/null
+++ b/app/ui/composer/tests/motion.test.js
@@ -0,0 +1,32 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { parseDuration } from "../internals/motion.js";
+
+test("ms and s both parse to milliseconds", () => {
+ assert.equal(parseDuration("120ms"), 120);
+ assert.equal(parseDuration("0.12s"), 120);
+ assert.equal(parseDuration("1s"), 1000);
+});
+
+test("whitespace from getPropertyValue is tolerated", () => {
+ // getComputedStyle returns custom properties with their leading space intact.
+ assert.equal(parseDuration(" 180ms "), 180);
+});
+
+test("a bare number is treated as milliseconds", () => {
+ assert.equal(parseDuration("0"), 0);
+ assert.equal(parseDuration("250"), 250);
+});
+
+test("missing or unreadable values are zero, never NaN", () => {
+ // A NaN duration becomes a setTimeout that fires immediately or never, and
+ // the teardown it was guarding is stranded either way.
+ for (const bad of ["", null, undefined, "auto", "abc", {}]) {
+ assert.equal(parseDuration(bad), 0);
+ }
+});
+
+test("negative durations clamp to zero", () => {
+ assert.equal(parseDuration("-100ms"), 0);
+});
diff --git a/app/ui/composer/tests/overlays.test.js b/app/ui/composer/tests/overlays.test.js
new file mode 100644
index 00000000..3b723f70
--- /dev/null
+++ b/app/ui/composer/tests/overlays.test.js
@@ -0,0 +1,606 @@
+// Overlay behaviour: stacking, dismissal order, and the promises.
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const here = dirname(fileURLToPath(import.meta.url));
+
+import { setupDom, teardownDom, key, fire } from "./_dom.js";
+import { EDGES, sectorAt, sectorInArc } from "../elements/wheel.js";
+
+let composer, liveCount, LADDER;
+let entries;
+let DEMOS;
+let ROOT_ID;
+let baseOf;
+test.before(async () => {
+ setupDom();
+ ({ composer, entries } = await import("../composer.js"));
+ ({ DEMOS } = await import("../../catalogue/demos.js"));
+ ({ ROOT_ID } = await import("../internals/portal.js"));
+ ({ baseOf, liveCount, LADDER } = await import("../internals/zlayer.js"));
+});
+test.after(() => teardownDom());
+
+// Overlays live in the portal, not under the test's own node, so a test that
+// leaves one open would be counted by the next one.
+test.beforeEach(() => {
+ const root = document.getElementById(ROOT_ID);
+ if (root) root.replaceChildren();
+});
+
+const portalRoot = () => document.getElementById(ROOT_ID);
+const zOf = (node) => Number(node.closest("[style*='z-index']")?.style.zIndex || node.style.zIndex);
+
+// --- the bug v4 had ---------------------------------------------------------
+
+test("Escape inside a modal closes the autocomplete first, then the modal", () => {
+ // v4's modal and its autocomplete each owned an Escape handler, so one press
+ // closed both and you lost the dialog you were filling in.
+ const closed = [];
+ const modal = composer.modal.generic({ title: "Edit", onClose: () => closed.push("modal") });
+
+ const input = composer.input.md({});
+ modal.node.appendChild(input.node);
+ const ac = composer.autocomplete.default({
+ input, source: () => [{ label: "rooftop" }], minChars: 1,
+ });
+ input.node.value = "r";
+ fire(input.node, "input");
+ assert.ok(document.querySelector(".cx-autocomplete"), "the menu opened");
+
+ key(document.body, "Escape");
+ assert.deepEqual(closed, [], "the modal survived");
+ assert.equal(document.querySelector(".cx-autocomplete"), null, "the menu closed");
+
+ key(document.body, "Escape");
+ assert.deepEqual(closed, ["modal"]);
+ ac.destroy();
+});
+
+test("an autocomplete outranks a modal, so it is not hidden behind it", () => {
+ const modal = composer.modal.generic({ title: "Edit" });
+ const input = composer.input.md({});
+ modal.node.appendChild(input.node);
+ const ac = composer.autocomplete.default({ input, source: () => [{ label: "x" }], minChars: 1 });
+ input.node.value = "x";
+ fire(input.node, "input");
+
+ const menu = document.querySelector(".cx-autocomplete");
+ assert.ok(Number(menu.style.zIndex) >= baseOf("autocomplete"));
+ assert.ok(Number(menu.style.zIndex) > baseOf("modal"));
+
+ ac.destroy();
+ modal.destroy();
+});
+
+// --- modals -----------------------------------------------------------------
+
+test("a modal mounts into the portal and leaves nothing behind", () => {
+ const m = composer.modal.generic({ title: "Export" });
+ assert.ok(portalRoot().contains(m.node));
+ m.destroy();
+ assert.equal(portalRoot().childElementCount, 0);
+});
+
+test("a modal is announced as a dialog and labelled by its title", () => {
+ const m = composer.modal.generic({ title: "Export settings" });
+ assert.equal(m.node.getAttribute("role"), "dialog");
+ assert.equal(m.node.getAttribute("aria-modal"), "true");
+ const labelled = document.getElementById(m.node.getAttribute("aria-labelledby"));
+ assert.equal(labelled.textContent, "Export settings");
+ m.destroy();
+});
+
+test("dialogue resolves true on confirm and false on cancel", async () => {
+ const yes = composer.modal.dialogue({ title: "Delete?", message: "Gone for good.", confirmLabel: "Delete" });
+ yes.node.querySelectorAll(".cx-modal-foot button")[1].click();
+ assert.equal(await yes.result, true);
+
+ const no = composer.modal.dialogue({ title: "Delete?", message: "Gone for good." });
+ no.node.querySelectorAll(".cx-modal-foot button")[0].click();
+ assert.equal(await no.result, false);
+});
+
+test("dialogue resolves false when dismissed, rather than hanging", async () => {
+ // A promise nobody settles is a UI that quietly stops responding.
+ const d = composer.modal.dialogue({ title: "Delete?", message: "Gone for good." });
+ key(document.body, "Escape");
+ assert.equal(await d.result, false);
+});
+
+test("prompt resolves its value, and null when dismissed", async () => {
+ const p = composer.modal.prompt({ title: "New project", label: "Name", value: "Untitled" });
+ const input = p.node.querySelector("input");
+ input.value = "Rooftop";
+ p.node.querySelectorAll(".cx-modal-foot button")[1].click();
+ assert.equal(await p.result, "Rooftop");
+
+ const q = composer.modal.prompt({ title: "New project", label: "Name" });
+ key(document.body, "Escape");
+ assert.equal(await q.result, null);
+});
+
+test("prompt refuses to submit a value its validator rejects", async () => {
+ const p = composer.modal.prompt({
+ title: "New project", label: "Name", value: "",
+ validate: (v) => (v.trim() ? null : "Give it a name."),
+ });
+ p.node.querySelectorAll(".cx-modal-foot button")[1].click();
+ assert.equal(p.node.querySelector(".cx-field-error").textContent, "Give it a name.");
+ assert.ok(portalRoot().contains(p.node), "still open");
+ p.destroy();
+});
+
+test("choice resolves the picked id", async () => {
+ const c = composer.modal.choice({ title: "Start from", items: [
+ { id: "blank", label: "Empty" }, { id: "image", label: "From an image" },
+ ] });
+ c.node.querySelectorAll(".cx-choice-row")[1].click();
+ assert.equal(await c.result, "image");
+});
+
+test("a stacked modal sits above the one it opened from", () => {
+ const first = composer.modal.generic({ title: "One" });
+ const second = composer.modal.stacked({ title: "Two" });
+ assert.ok(zOf(second.node) > zOf(first.node));
+ key(document.body, "Escape");
+ assert.equal(portalRoot().childElementCount, 1, "only the top one closed");
+ first.destroy();
+});
+
+// --- popovers and menus -----------------------------------------------------
+
+test("a menu reports the item picked and closes", () => {
+ let picked = null;
+ const anchor = composer.button.md({ label: "Scene" });
+ document.body.appendChild(anchor.node);
+ const menu = composer.menu.dropdown({
+ anchor, onPick: (id) => { picked = id; },
+ items: [{ id: "split", label: "Split" }, { id: "dup", label: "Duplicate" }],
+ });
+ menu.node.querySelectorAll(".cx-menu-item")[1].click();
+ assert.equal(picked, "dup");
+ assert.equal(portalRoot().childElementCount, 0);
+});
+
+test("separators are not clickable items", () => {
+ const anchor = composer.button.md({ label: "Scene" });
+ const menu = composer.menu.dropdown({ anchor, items: [
+ { id: "a", label: "A" }, { separator: true }, { id: "b", label: "B" },
+ ] });
+ assert.equal(menu.node.querySelectorAll(".cx-menu-item").length, 2);
+ assert.equal(menu.node.querySelectorAll(".cx-menu-sep").length, 1);
+ menu.destroy();
+});
+
+test("clicking the trigger again does not close-then-reopen", () => {
+ const anchor = composer.button.md({ label: "Scene" });
+ document.body.appendChild(anchor.node);
+ const menu = composer.menu.dropdown({ anchor, items: [{ id: "a", label: "A" }] });
+ fire(anchor.node, "pointerdown");
+ assert.ok(portalRoot().contains(menu.node), "the anchor counts as inside");
+ menu.destroy();
+});
+
+test("a tooltip shows and hides on demand", () => {
+ const anchor = composer.iconButton.md({ icon: "✎", label: "Rename" });
+ document.body.appendChild(anchor.node);
+ const tip = composer.tooltip.default({ anchor, text: "Rename this scene", trigger: false });
+ tip.show();
+ assert.ok(document.querySelector(".cx-tooltip"));
+ tip.hide();
+ assert.equal(document.querySelector(".cx-tooltip"), null);
+ tip.destroy();
+});
+
+test("autocomplete needs minChars before it opens", () => {
+ const input = composer.input.md({});
+ document.body.appendChild(input.node);
+ const ac = composer.autocomplete.default({ input, source: () => [{ label: "x" }], minChars: 2 });
+ input.node.value = "a";
+ fire(input.node, "input");
+ assert.equal(document.querySelector(".cx-autocomplete"), null);
+ input.node.value = "ab";
+ fire(input.node, "input");
+ assert.ok(document.querySelector(".cx-autocomplete"));
+ ac.destroy();
+});
+
+// --- windows ----------------------------------------------------------------
+
+test("a window does not close on an outside click, but does on Escape", () => {
+ // It is a window, not a menu: clicking the app behind it is normal.
+ let closed = false;
+ const w = composer.floating.window({ id: "t1", title: "Composer", onClose: () => { closed = true; } });
+ fire(document.body, "pointerdown");
+ assert.equal(closed, false);
+ key(document.body, "Escape");
+ assert.equal(closed, true);
+});
+
+test("clicking a window raises it above its peers", () => {
+ const a = composer.floating.window({ id: "t2", title: "A" });
+ const b = composer.floating.window({ id: "t3", title: "B" });
+ assert.ok(zOf(b.node) > zOf(a.node));
+ a.toFront();
+ assert.ok(zOf(a.node) > zOf(b.node));
+ a.destroy(); b.destroy();
+});
+
+test("a blocking overlay traps focus and clears on close", () => {
+ const o = composer.overlay.blocking({ message: "Restarting…" });
+ assert.equal(o.node.getAttribute("aria-busy"), "true");
+ o.destroy();
+ assert.equal(portalRoot().childElementCount, 0);
+});
+
+// --- wheel ------------------------------------------------------------------
+
+test("the wheel's dead zone means cancel", () => {
+ assert.equal(sectorAt(0, 0, 6), -1);
+ assert.equal(sectorAt(10, 10, 6), -1);
+});
+
+test("sector 0 is straight up, and sectors run clockwise", () => {
+ // The muscle memory is the whole point: the same choice must always be at the
+ // same angle.
+ assert.equal(sectorAt(0, -100, 4), 0, "up");
+ assert.equal(sectorAt(100, 0, 4), 1, "right");
+ assert.equal(sectorAt(0, 100, 4), 2, "down");
+ assert.equal(sectorAt(-100, 0, 4), 3, "left");
+});
+
+test("every angle lands in a valid sector", () => {
+ for (const count of [2, 3, 5, 6, 8, 12]) {
+ for (let deg = 0; deg < 360; deg += 1) {
+ const rad = (deg - 90) * (Math.PI / 180);
+ const i = sectorAt(Math.cos(rad) * 100, Math.sin(rad) * 100, count);
+ assert.ok(i >= 0 && i < count, `${deg}deg with ${count} items gave ${i}`);
+ }
+ }
+});
+
+test("boundaries between sectors do not fall through", () => {
+ const count = 6;
+ const slice = 360 / count;
+ for (let i = 0; i < count; i += 1) {
+ const edge = (i * slice + slice / 2 - 0.01 - 90) * (Math.PI / 180);
+ const got = sectorAt(Math.cos(edge) * 100, Math.sin(edge) * 100, count);
+ assert.ok(got >= 0 && got < count);
+ }
+});
+
+test("the click that opens the wheel does not also pick", () => {
+ // The opening click delivers its own pointerup to the wheel, at wherever the
+ // button was -- which used to commit whatever wedge that direction pointed at.
+ let picked = null;
+ const w = composer.wheel.picker({
+ items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Remove" }],
+ onPick: (item) => { picked = item.label; },
+ x: 400, y: 300,
+ });
+ // 80px straight up from the centre: inside the ring, pointing at item 0.
+ window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 220, bubbles: true }));
+ assert.equal(picked, null, "a release with no movement is not a pick");
+ assert.ok(portalRoot().childElementCount > 0, "the wheel is still open");
+
+ window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 220, bubbles: true }));
+ window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 220, bubbles: true }));
+ assert.equal(picked, "Split", "after moving, a release picks");
+ w.destroy();
+});
+
+test("a pointer beyond the rim points at nothing", () => {
+ // The lit wedge IS the region that picks the item. Highlighting one from
+ // outside the ring promises a pick that release would not deliver.
+ const w = composer.wheel.picker({
+ items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Rate" }],
+ x: 400, y: 300,
+ });
+ window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 60, bubbles: true }));
+ assert.equal(w.active, -1, "240px out is off the wheel");
+ window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 220, bubbles: true }));
+ assert.equal(w.active, 0, "80px out is on it");
+ w.destroy();
+});
+
+test("releasing in the dead zone cancels without picking", () => {
+ let picked = null;
+ const w = composer.wheel.picker({
+ items: [{ label: "Split" }, { label: "Duplicate" }],
+ onPick: (item) => { picked = item.label; },
+ x: 400, y: 300,
+ });
+ window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 405, clientY: 305, bubbles: true }));
+ window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 405, clientY: 305, bubbles: true }));
+ assert.equal(picked, null);
+ w.destroy();
+});
+
+// --- half wheel -------------------------------------------------------------
+
+test("an edge panel's arc faces into the screen, not off it", () => {
+ // The point of pinning to an edge is that every item stays reachable; an arc
+ // pointing the wrong way would put half of them off-screen.
+ const count = 4;
+ for (const [edge, spec] of Object.entries(EDGES)) {
+ const inward = { right: 270, left: 90, bottom: 0, top: 180 }[edge];
+ const rad = (inward - 90) * (Math.PI / 180);
+ const i = sectorInArc(Math.cos(rad) * 100, Math.sin(rad) * 100, count, spec);
+ assert.ok(i >= 0, `${edge} panel does not cover its own inward direction`);
+ }
+});
+
+test("a direction outside a half wheel's arc selects nothing", () => {
+ // Off the edge is off the panel: it must not wrap round to the far item.
+ const spec = EDGES.right; // opens leftward
+ const right = sectorInArc(100, 0, 4, spec); // pointing off-screen
+ assert.equal(right, -1);
+});
+
+test("every inward angle of a half wheel lands in a sector", () => {
+ const spec = EDGES.right;
+ for (let deg = spec.startDeg + 1; deg < spec.startDeg + spec.spanDeg; deg += 1) {
+ const rad = (deg - 90) * (Math.PI / 180);
+ const i = sectorInArc(Math.cos(rad) * 100, Math.sin(rad) * 100, 4, spec);
+ assert.ok(i >= 0 && i < 4, `${deg}deg gave ${i}`);
+ }
+});
+
+test("a half wheel opens on its edge and picks by key", () => {
+ let picked = null;
+ const w = composer.wheel.half({
+ edge: "right", at: 300,
+ items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Rate" }],
+ onPick: (item) => { picked = item.label; },
+ });
+ assert.ok(w.node.classList.contains("cx-wheel-right"));
+ key(document.body, "3");
+ assert.equal(picked, "Rate");
+ w.destroy();
+});
+
+test("an edge panel's hub sits fully on screen, not sliced by the edge", () => {
+ // A hub centred on the edge is cut in half by it, and half a cancel target is
+ // not a target. It is pushed inward by its own radius.
+ const w = composer.wheel.half({ edge: "right", at: 300, items: [{ label: "a" }, { label: "b" }] });
+ const hub = w.node.querySelector(".cx-wheel-hub");
+ const size = Number(w.node.style.width.replace("px", ""));
+ assert.ok(parseFloat(hub.style.left) < size / 2, "the hub leans into the screen");
+ const radius = parseFloat(hub.style.width) / 2;
+ assert.ok(parseFloat(hub.style.left) + radius <= size / 2 + 0.01,
+ "and its far edge does not cross the screen edge");
+ w.destroy();
+});
+
+test("cancel is where the hub actually is, not where the centre is", () => {
+ const spec = EDGES.right;
+ const hubR = 40;
+ const arc = { ...spec, rIn: 84, rOut: 147, hubX: -hubR, hubY: 0, hubR };
+ // A point over the drawn hub cancels...
+ assert.equal(sectorInArc(-hubR, 0, 4, arc), -1);
+ // ...while the geometric centre, which is now outside the hub, is simply off
+ // the ring rather than a pick.
+ assert.equal(sectorInArc(0, 0, 4, arc), -1);
+});
+
+test("an edge panel follows its edge when the window resizes", () => {
+ // It is pinned to an edge; the edge moving is the one thing it must follow.
+ const w = composer.wheel.half({ edge: "right", items: [{ label: "a" }, { label: "b" }] });
+ const before = w.centre.x;
+
+ window.innerWidth = window.innerWidth + 400;
+ window.dispatchEvent(new window.Event("resize"));
+
+ assert.equal(w.centre.x, before + 400, "the centre moved with the edge");
+ const size = Number(w.node.style.width.replace("px", ""));
+ assert.equal(parseFloat(w.node.style.left), w.centre.x - size / 2);
+ window.innerWidth = window.innerWidth - 400;
+ w.destroy();
+});
+
+test("edge panels stay pinned at any viewport shape", () => {
+ for (const [w, h] of [[320, 640], [1920, 1080], [2560, 1080], [800, 2000]]) {
+ window.innerWidth = w;
+ window.innerHeight = h;
+ for (const edge of Object.keys(EDGES)) {
+ const p = composer.wheel.half({ edge, items: [{ label: "a" }, { label: "b" }, { label: "c" }] });
+ const [ex, ey] = EDGES[edge].anchor(w, h);
+ assert.deepEqual(p.centre, { x: ex, y: ey }, `${edge} at ${w}x${h}`);
+ p.destroy();
+ }
+ }
+});
+
+test("an unknown edge is refused rather than guessed", () => {
+ assert.throws(() => composer.wheel.half({ edge: "diagonal", items: [{ label: "a" }, { label: "b" }] }), RangeError);
+});
+
+test("the wheel refuses a count nobody could aim at", () => {
+ assert.throws(() => composer.wheel.picker({ items: [{ label: "only one" }] }), RangeError);
+ const many = Array.from({ length: 13 }, (_, i) => ({ label: String(i) }));
+ assert.throws(() => composer.wheel.picker({ items: many }), RangeError);
+});
+
+test("number keys pick directly", () => {
+ let picked = null;
+ const w = composer.wheel.picker({
+ items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Remove" }],
+ onPick: (item) => { picked = item.label; },
+ });
+ key(document.body, "2");
+ assert.equal(picked, "Duplicate");
+ assert.equal(portalRoot().childElementCount, 0, "picking closes it");
+ w.destroy();
+});
+
+// --- gallery ----------------------------------------------------------------
+
+test("the gallery sizes from its container, not the viewport", () => {
+ const g = composer.gallery.adaptive({ id: "g1", cols: 3, items: [{ id: "1", label: "a.mp4" }] });
+ const grid = g.node.querySelector(".cx-gallery");
+ assert.equal(grid.dataset.cols, "3");
+ assert.match(grid.style.getPropertyValue("--cell"), /cqw$/);
+ g.setCols(0);
+ assert.match(grid.style.getPropertyValue("--cell"), /clamp\(/, "auto falls back to the clamp");
+ g.destroy();
+});
+
+test("an empty gallery says it is empty", () => {
+ const g = composer.gallery.adaptive({ id: "g2", items: [], empty: "No media yet" });
+ assert.match(g.node.textContent, /No media yet/);
+ g.destroy();
+});
+
+test("gallery thumbnails are decorative, because the caption names them", () => {
+ const g = composer.gallery.adaptive({ id: "g3", items: [{ id: "1", label: "a.mp4", thumb: "/x.png" }] });
+ assert.equal(g.node.querySelector("img").getAttribute("alt"), "",
+ "an alt here would make a screen reader say the name twice");
+ g.destroy();
+});
+
+// --- z-index staleness ------------------------------------------------------
+
+test("a peer closing does not leave two overlays sharing a z-index", () => {
+ // A rung compacts when a claim is released, so every overlay's z can move
+ // without it doing anything. A caller that reads z once at open time keeps a
+ // number that is later handed to something else: two overlays at one level,
+ // where DOM order decides which one wins and which one eats the clicks.
+ const anchor = composer.button.md({ label: "x" });
+ document.body.appendChild(anchor.node);
+
+ const a = composer.menu.dropdown({ anchor, items: [{ id: "a", label: "A" }] });
+ const b = composer.menu.dropdown({ anchor, items: [{ id: "b", label: "B" }] });
+ assert.notEqual(a.node.style.zIndex, b.node.style.zIndex);
+
+ a.close();
+ const c = composer.menu.dropdown({ anchor, items: [{ id: "c", label: "C" }] });
+ assert.notEqual(b.node.style.zIndex, c.node.style.zIndex,
+ "the newest overlay must not land on a number still in use");
+ assert.ok(Number(c.node.style.zIndex) > Number(b.node.style.zIndex),
+ "and it must be above, not merely different");
+
+ b.close(); c.close();
+});
+
+test("every overlay keeps its z-index bound, not just windows", () => {
+ // The failure above was one call site remembering and six forgetting, so the
+ // test walks every overlay the registry has rather than naming them.
+ const anchor = composer.button.md({ label: "x" });
+ document.body.appendChild(anchor.node);
+
+ const opened = [];
+ for (const { group, variant, factory } of entries()) {
+ const demo = DEMOS[`${group}.${variant}`];
+ if (!demo) continue;
+ const handle = factory(typeof demo === "function" ? demo(composer) : demo);
+ if (!handle.isOverlay || !handle.node.style.zIndex) { handle.destroy(); continue; }
+ opened.push({ name: `${group}.${variant}`, handle });
+ }
+
+ // Closing the oldest compacts every rung it was in; nothing may go stale.
+ //
+ // `before` used to be computed and then discarded with `void before`, so this
+ // checked only that survivors do not collide -- which is trivially true when
+ // nothing moves at all. It would have passed with zlayer's notify() removed,
+ // and notify() is the whole mechanism this names.
+ const before = new Map(opened.map(({ name, handle }) => [name, handle.node.style.zIndex]));
+ const closed = opened.length > 1 ? opened.shift() : null;
+
+ // Some demos close themselves as they are built (a wheel that resolves on
+ // creation), so calling close() on one is a no-op and nothing SHOULD move.
+ // Counting live claims says whether a slot was actually freed; asserting
+ // compaction after a no-op close failed on correct code.
+ const rungOf = (z) => LADDER[Math.floor(Number(z) / 100) - 1];
+ const closedRung = closed ? rungOf(before.get(closed.name)) : null;
+ const liveBefore = closedRung ? liveCount(closedRung) : 0;
+
+ const seen = new Map();
+ for (const { name, handle } of opened) {
+ const z = handle.node.style.zIndex;
+ assert.ok(!seen.has(z), `${name} collides with ${seen.get(z)} at z ${z}`);
+ seen.set(z, name);
+ }
+
+ const freed = Boolean(closed && closedRung && liveCount(closedRung) < liveBefore);
+ if (freed) {
+ // What "nothing goes stale" means, stated exactly: everything that sat
+ // ABOVE the closed overlay in its rung moved down by one, and everything
+ // below did not move at all.
+ //
+ // Not "the rung is contiguous from its base" -- earlier tests in this file
+ // leave live claims, so this test does not own the whole rung and a gap at
+ // the base is somebody else's slot, not a fault. That version failed on
+ // correct code.
+ const closedZ = Number(before.get(closed.name));
+ const rung = (z) => Math.floor(z / 100);
+
+ for (const { name, handle } of opened) {
+ const was = Number(before.get(name));
+ const now = Number(handle.node.style.zIndex);
+ if (rung(was) !== rung(closedZ)) {
+ assert.equal(now, was, `${name} moved although it is in another rung`);
+ } else if (was > closedZ) {
+ assert.equal(now, was - 1,
+ `${name} was above ${closed.name} and did not compact down`);
+ } else {
+ assert.equal(now, was, `${name} was below ${closed.name} and moved anyway`);
+ }
+ }
+ }
+ for (const { handle } of opened) handle.destroy();
+
+ // The sweep above only checks compaction when a demo happened to leave a live
+ // claim, which is not something to rely on. Two overlays this test opens
+ // itself, so the invariant is exercised every run whatever the demos do.
+ const first = composer.popover.anchored({
+ anchor: anchor.node, body: composer.text.md({ text: "a" }) });
+ const second = composer.popover.anchored({
+ anchor: anchor.node, body: composer.text.md({ text: "b" }) });
+ const firstZ = Number(first.node.style.zIndex);
+ const secondZ = Number(second.node.style.zIndex);
+ assert.equal(secondZ, firstZ + 1, "two peers did not stack");
+
+ first.close();
+ assert.equal(Number(second.node.style.zIndex), firstZ,
+ "the survivor did not compact into the freed slot");
+ second.close();
+ anchor.destroy();
+});
+
+test("a stacked modal dims what is under it with a real backdrop, once", () => {
+ // The dimming used to come from TWO places at once: the layer's own backdrop
+ // element, and a box-shadow with a 100vmax spread on the card. So the area
+ // under a stacked modal was darkened twice, and a shadow the size of the
+ // viewport diagonal -- on an element in a fixed layer, with an entry
+ // animation running -- is how a stale compositor layer gets made. One showed
+ // up as the whole app painted twice until a resize forced a repaint.
+ const under = composer.modal.generic({ title: "Under", body: composer.text.sm({ text: "a" }) });
+ const over = composer.modal.stacked({ title: "Over", body: composer.text.sm({ text: "b" }) });
+
+ const layer = over.node.closest(".cx-modal-layer");
+ assert.ok(layer, "a stacked modal has no layer of its own");
+ assert.equal(layer.querySelectorAll(".cx-backdrop").length, 1,
+ "a stacked modal must dim through its own backdrop element, exactly once");
+
+ over.close();
+ under.close();
+});
+
+test("no overlay paints a backdrop with a viewport-sized shadow spread", () => {
+ // Read from the stylesheet, because jsdom applies no CSS: asserting on
+ // node.style.boxShadow here passes against any rule at all, since the value
+ // lives in the sheet and the inline style is always empty. The first version
+ // of this check did exactly that.
+ const css = readFileSync(join(here, "..", "elements", "overlay.css"), "utf8");
+ const offenders = css.split("\n")
+ .filter((line) => /box-shadow/.test(line) && /vmax|vmin|\b\d{3,}(px|vh|vw)\b/.test(line));
+ assert.deepEqual(offenders, [],
+ "a shadow spread that large is a stale-compositor-layer bug waiting to happen; " +
+ "dim with a .cx-backdrop element instead");
+});
diff --git a/app/ui/composer/tests/panel_schema.test.js b/app/ui/composer/tests/panel_schema.test.js
new file mode 100644
index 00000000..de68b415
--- /dev/null
+++ b/app/ui/composer/tests/panel_schema.test.js
@@ -0,0 +1,237 @@
+// The bridge: a declaration in, a panel out.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom, fire } from "./_dom.js";
+
+let renderPanel, defaultsOf, visible, rendererFor, DEFAULT_UI;
+test.before(async () => {
+ setupDom();
+ await import("../composer.js"); // elements register themselves
+ ({ renderPanel, defaultsOf, visible, rendererFor, DEFAULT_UI } = await import("../panel.js"));
+});
+test.after(() => teardownDom());
+
+// Panels are built detached and then mounted; jsdom only runs a control's
+// activation behaviour once it is in the document, so anything that clicks has
+// to mount first -- which is what the shell does anyway.
+const mount = (panel) => { document.body.appendChild(panel.node); return panel; };
+
+const SPEC = {
+ id: "audio_clock",
+ title: "Audio clock",
+ settings: {
+ enabled: { type: "bool", default: true, label: "Sync to audio clock", hint: "Locks frame timing." },
+ strength: { type: "float", default: 0.65, min: 0, max: 1, step: 0.05,
+ label: "Lock strength", unit: "x", ui: "slider", when: { enabled: true } },
+ mode: { type: "enum", default: "beat", label: "Alignment", ui: "segmented",
+ options: [{ value: "beat", label: "Beat grid" }, { value: "onset", label: "Onset" }],
+ when: { enabled: true } },
+ },
+};
+
+test("defaults come from the declaration, not from the controls", () => {
+ assert.deepEqual(defaultsOf(SPEC), { enabled: true, strength: 0.65, mode: "beat" });
+});
+
+test("a panel renders one row per visible setting", () => {
+ const panel = renderPanel(SPEC);
+ assert.equal(panel.node.querySelectorAll(".cx-check-row, .cx-settings-row").length, 3);
+ assert.match(panel.node.textContent, /Audio clock/);
+ panel.destroy();
+});
+
+test("the declared renderer is used, not the default for the type", () => {
+ const panel = renderPanel(SPEC);
+ assert.ok(panel.node.querySelector(".cx-slider"), "strength asked for a slider");
+ assert.ok(panel.node.querySelector(".cx-segmented"), "mode asked for segmented");
+ assert.equal(panel.node.querySelector("select"), null, "and not the enum default");
+ panel.destroy();
+});
+
+test("a type with no declared renderer gets its default", () => {
+ const panel = renderPanel({ title: "T", settings: {
+ n: { type: "float", default: 1, label: "N" },
+ t: { type: "text", default: "x", label: "T" },
+ } });
+ assert.equal(DEFAULT_UI.float, "number");
+ assert.ok(panel.node.querySelector("input[type=number]"));
+ assert.ok(panel.node.querySelector("input[type=text]"));
+ panel.destroy();
+});
+
+test("a self-labelling control is not wrapped in a second label", () => {
+ const panel = renderPanel({ title: "T", settings: {
+ on: { type: "bool", default: true, label: "Enabled" },
+ } });
+ const labels = [...panel.node.querySelectorAll("*")]
+ .filter((n) => n.textContent.trim() === "Enabled" && !n.children.length);
+ assert.equal(labels.length, 1, "the label appears once, not once per wrapper");
+ panel.destroy();
+});
+
+// --- conditional rows ------------------------------------------------------
+
+test("a row whose condition fails is absent, not disabled", () => {
+ // Hide, don't warn, one level down: a greyed control invites clicking on
+ // something that cannot do anything.
+ const panel = renderPanel(SPEC, { values: { enabled: false } });
+ assert.match(panel.node.textContent, /Sync to audio clock/);
+ assert.doesNotMatch(panel.node.textContent, /Lock strength/);
+ assert.equal(panel.node.querySelector("[disabled]"), null);
+ panel.destroy();
+});
+
+test("changing the key a row depends on brings it back", () => {
+ const panel = mount(renderPanel(SPEC, { values: { enabled: false } }));
+ assert.doesNotMatch(panel.node.textContent, /Lock strength/);
+ panel.node.querySelector("input[type=checkbox]").click();
+ assert.match(panel.node.textContent, /Lock strength/);
+ panel.destroy();
+});
+
+test("visible() takes a list as any-of", () => {
+ const setting = { when: { mode: ["beat", "onset"] } };
+ assert.equal(visible(setting, { mode: "onset" }), true);
+ assert.equal(visible(setting, { mode: "flat" }), false);
+});
+
+test("a setting with no condition is always visible", () => {
+ assert.equal(visible({}, {}), true);
+});
+
+// --- values ----------------------------------------------------------------
+
+test("edits are reported with the key and the whole value set", () => {
+ const seen = [];
+ const panel = mount(renderPanel(SPEC, { onChange: (key, value, all) => seen.push([key, value, all.mode]) }));
+ panel.node.querySelector("input[type=checkbox]").click();
+ assert.deepEqual(seen[0].slice(0, 2), ["enabled", false]);
+ assert.equal(seen[0][2], "beat", "the rest of the values come along");
+ panel.destroy();
+});
+
+test("the panel's values start from the defaults and track edits", () => {
+ const panel = mount(renderPanel(SPEC));
+ assert.deepEqual(panel.values, { enabled: true, strength: 0.65, mode: "beat" });
+
+ const slider = panel.node.querySelector(".cx-slider input");
+ slider.value = "0.3";
+ fire(slider, "input");
+ assert.equal(panel.values.strength, 0.3);
+ panel.destroy();
+});
+
+test("supplied values override the defaults", () => {
+ const panel = renderPanel(SPEC, { values: { strength: 0.2 } });
+ assert.equal(panel.values.strength, 0.2);
+ assert.equal(panel.values.mode, "beat", "and the rest still come from the declaration");
+ panel.destroy();
+});
+
+// --- refusing rather than approximating ------------------------------------
+
+test("a setting with no default is refused by the panel too", () => {
+ // Core catches this first; the panel is the layer that cannot proceed without
+ // one, since it would render a control holding undefined.
+ assert.throws(() => rendererFor({ type: "float", min: 0, max: 1 }), /no default/);
+});
+
+test("an unknown renderer throws instead of falling back", () => {
+ // A panel that silently renders a different control is worse than an absent
+ // one, because nobody notices it is wrong.
+ assert.throws(() => rendererFor({ type: "bool", ui: "hologram" }), TypeError);
+});
+
+test("a panel that cannot be built appends nothing", () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ try {
+ const panel = renderPanel({ title: "Broken", settings: {
+ ok: { type: "bool", default: true, label: "Fine" },
+ bad: { type: "bool", default: true, label: "Bad", ui: "hologram" },
+ } });
+ host.appendChild(panel.node);
+ assert.fail("should have thrown");
+ } catch (err) {
+ assert.ok(err instanceof TypeError);
+ assert.equal(host.childElementCount, 0, "nothing half-built reached the document");
+ }
+});
+
+test("every renderer named by the contract exists", async () => {
+ // The contract and the kit have to agree: a hint the schema accepts but the
+ // kit cannot render would make every module using it hide itself.
+ const HINTS = {
+ bool: ["checkboxRow", "toggle"],
+ int: ["number", "slider", "stepper", "macroSlider"],
+ float: ["number", "slider", "stepper", "macroSlider"],
+ enum: ["select", "segmented", "radioGroup", "filterList", "wheel"],
+ text: ["input", "search"],
+ multiline: ["textarea", "autoTextarea"],
+ path: ["filterList"],
+ };
+ for (const [type, hints] of Object.entries(HINTS)) {
+ for (const ui of hints) {
+ assert.doesNotThrow(() => rendererFor({ type, ui, default: null }), `${type} + ${ui}`);
+ }
+ assert.doesNotThrow(() => rendererFor({ type, default: null }), `${type} default`);
+ }
+});
+
+// --- a redraw is as safe as the first build --------------------------------
+
+test("a row that only appears later cannot empty the panel when it throws", () => {
+ // The initial build is protected by a detached fragment, and the redraw was
+ // not: it destroyed every control and then built, so a setting revealed by
+ // another value -- one the first build never rendered and so never checked --
+ // emptied the panel and threw on the way to refilling it. A blank panel with
+ // no way back, from a typo in a `ui` name.
+ const spec = {
+ id: "late", title: "Late",
+ settings: {
+ mode: { type: "enum", label: "Mode", default: "simple",
+ options: [{ value: "simple", label: "Simple" }, { value: "full", label: "Full" }] },
+ extra: { type: "int", label: "Extra", default: 1, ui: "noSuchRenderer",
+ when: { mode: "full" } },
+ },
+ };
+
+ const panel = renderPanel(spec, {});
+ document.body.replaceChildren(panel.node);
+ // The CONTROLS, not the rows. A control's destroy() takes the control out of
+ // its settings row and leaves the row behind, so counting rows sees a panel
+ // that is intact and is looking at labels with nothing under them -- the
+ // first version of this test did exactly that and passed against the bug.
+ const controls = () => panel.node.querySelectorAll("input, select, textarea").length;
+ const before = controls();
+ assert.ok(before >= 1, "nothing was rendered to begin with");
+
+ assert.throws(() => panel.setValue("mode", "full"), /noSuchRenderer/);
+ assert.equal(controls(), before,
+ "a redraw that could not finish stripped the controls out of the panel");
+});
+
+test("a redraw that succeeds destroys the controls it replaced", () => {
+ // The fix builds before it tears down, which is exactly where a leak would
+ // hide: keeping both maps and forgetting to destroy the old one.
+ const destroyed = [];
+ const spec = {
+ id: "swap", title: "Swap",
+ settings: {
+ mode: { type: "enum", label: "Mode", default: "a",
+ options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] },
+ only_a: { type: "int", label: "Only A", default: 1, when: { mode: "a" } },
+ },
+ };
+ const panel = renderPanel(spec, {});
+ document.body.replaceChildren(panel.node);
+ const was = panel.node.querySelector('[aria-label="Only A"]');
+ assert.ok(was, "the gated row was not rendered");
+
+ panel.setValue("mode", "b");
+ assert.equal(was.isConnected, false, "the replaced control stayed in the document");
+ assert.equal(panel.node.querySelector('[aria-label="Only A"]'), null);
+ void destroyed;
+});
diff --git a/app/ui/composer/tests/portal.test.js b/app/ui/composer/tests/portal.test.js
new file mode 100644
index 00000000..b64e1f14
--- /dev/null
+++ b/app/ui/composer/tests/portal.test.js
@@ -0,0 +1,77 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom } from "./_dom.js";
+import { portal, mount, unmount, ROOT_ID, _resetPortal } from "../internals/portal.js";
+
+test.beforeEach(() => { setupDom(); _resetPortal(); });
+test.after(() => teardownDom());
+
+test("there is exactly one overlay root, however often it is asked for", () => {
+ const a = portal();
+ const b = portal();
+ assert.equal(a, b);
+ assert.equal(document.querySelectorAll(`#${ROOT_ID}`).length, 1);
+});
+
+test("the root is a direct child of body", () => {
+ // Anywhere deeper and it inherits a stacking context from app layout, which
+ // is what makes z-index ladders stop working.
+ assert.equal(portal().parentNode, document.body);
+});
+
+test("the root creates no stacking context of its own", () => {
+ const root = portal();
+ // z-index belongs in this list: it is positioned and fixed, so ANY z-index
+ // (0 included) makes it a stacking context, and every overlay inside is then
+ // ranked by that single number rather than by the ladder.
+ for (const prop of ["transform", "filter", "backdropFilter", "contain", "willChange", "zIndex"]) {
+ assert.ok(!root.style[prop], `${prop} on the overlay root traps every overlay inside it`);
+ }
+});
+
+test("the root does not swallow clicks, but its children take them", () => {
+ const root = portal();
+ assert.equal(root.style.pointerEvents, "none");
+ const node = mount(document.createElement("div"));
+ assert.equal(node.style.pointerEvents, "auto");
+});
+
+test("mounted nodes land on the root and can be removed", () => {
+ const node = mount(document.createElement("div"));
+ assert.equal(node.parentNode, portal());
+ unmount(node);
+ assert.equal(node.parentNode, null);
+});
+
+test("unmounting something that was never mounted is harmless", () => {
+ assert.doesNotThrow(() => unmount(document.createElement("div")));
+ assert.doesNotThrow(() => unmount(null));
+});
+
+test("the root is recreated if something removes it", () => {
+ portal().remove();
+ assert.equal(portal().isConnected, true);
+});
+
+test("an existing root in the document is adopted, not duplicated", () => {
+ const existing = document.createElement("div");
+ existing.id = ROOT_ID;
+ document.body.appendChild(existing);
+ _resetPortal();
+ assert.equal(portal(), existing);
+ assert.equal(document.querySelectorAll(`#${ROOT_ID}`).length, 1);
+});
+
+test("the overlay root is not a stacking context", () => {
+ // If it is one, every rung of the ladder is measured inside it instead of
+ // against the page, and the whole ordering collapses to one number. It used
+ // to set `position: fixed`, which creates a stacking context on its own --
+ // and a menu at z 500 painted under a button at z 1.
+ const root = portal();
+ for (const property of ["position", "zIndex", "transform", "filter", "contain", "isolation", "opacity", "willChange"]) {
+ const value = root.style[property];
+ assert.ok(!value || value === "auto" || value === "none" || value === "static" || value === "1",
+ `the overlay root sets ${property}: ${value}, which can make it a stacking context`);
+ }
+});
diff --git a/app/ui/composer/tests/register.test.js b/app/ui/composer/tests/register.test.js
new file mode 100644
index 00000000..8691e76e
--- /dev/null
+++ b/app/ui/composer/tests/register.test.js
@@ -0,0 +1,72 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { composer, define, entries, has, UnknownElement, _clearRegistry } from "../internals/register.js";
+
+test.beforeEach(() => _clearRegistry());
+
+test("an element exists only because a file defined it", () => {
+ assert.equal(has("button", "large"), false);
+ define("button", "large", () => "made");
+ assert.equal(has("button", "large"), true);
+ assert.equal(composer.button.large(), "made");
+});
+
+test("asking for an undefined variant throws UnknownElement", () => {
+ define("button", "large", () => {});
+ assert.throws(() => composer.button.small, UnknownElement);
+});
+
+test("asking for an undefined group throws UnknownElement", () => {
+ assert.throws(() => composer.wobble, UnknownElement);
+});
+
+test("the error names what does exist, so a typo is obvious", () => {
+ define("button", "large", () => {});
+ define("button", "medium", () => {});
+ try {
+ composer.button.smal;
+ assert.fail("should have thrown");
+ } catch (err) {
+ assert.match(err.message, /composer\.button\.smal/);
+ assert.match(err.message, /large/);
+ assert.match(err.message, /medium/);
+ }
+});
+
+test("defining the same name twice is refused", () => {
+ define("button", "large", () => 1);
+ // Otherwise one of the two silently wins, decided by import order.
+ assert.throws(() => define("button", "large", () => 2), /already defined/);
+});
+
+test("define requires a factory", () => {
+ assert.throws(() => define("button", "large", "not a function"), TypeError);
+});
+
+test("composer cannot be written to", () => {
+ assert.throws(() => { composer.button = {}; }, TypeError);
+});
+
+test("probing keys answer undefined instead of throwing", () => {
+ // An accidental `await composer` must not surface as UnknownElement("then").
+ assert.equal(composer.then, undefined);
+ assert.equal(composer[Symbol.toStringTag], undefined);
+ define("button", "large", () => {});
+ assert.equal(composer.button.then, undefined);
+});
+
+test("entries() enumerates the registry, so tests need no hand-written list", () => {
+ define("button", "large", () => {});
+ define("button", "small", () => {});
+ define("hint", "default", () => {});
+ const found = [...entries()].map((e) => `${e.group}.${e.variant}`).sort();
+ assert.deepEqual(found, ["button.large", "button.small", "hint.default"]);
+});
+
+test("in/ownKeys reflect what is registered", () => {
+ define("button", "large", () => {});
+ assert.ok("button" in composer);
+ assert.equal("hint" in composer, false);
+ assert.deepEqual(Object.keys(composer), ["button"]);
+});
diff --git a/app/ui/composer/tests/rule_content_is_data.test.js b/app/ui/composer/tests/rule_content_is_data.test.js
new file mode 100644
index 00000000..1d56a27b
--- /dev/null
+++ b/app/ui/composer/tests/rule_content_is_data.test.js
@@ -0,0 +1,168 @@
+// Rule 4: module-supplied content is DATA, never markup.
+//
+// This test iterates THE REGISTRY, not a list, so an element added tomorrow is
+// covered the moment it calls define(). That is the same inversion the kit uses
+// everywhere else -- announcement, not enumeration -- applied to its own tests.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom } from "./_dom.js";
+import { DEMOS } from "../../catalogue/demos.js";
+import { ROOT_ID } from "../internals/portal.js";
+
+const PAYLOAD = ' ';
+
+let entries;
+let composer;
+test.before(async () => {
+ setupDom();
+ ({ composer, entries } = await import("../composer.js"));
+});
+
+// A container's demo is a function, because it has to build the handles it
+// composes. Poisoning then applies to that container's OWN content props -- the
+// elements inside it were attacked when their own demos ran.
+const resolve = (demo) => (typeof demo === "function" ? demo(composer) : demo);
+test.after(() => teardownDom());
+
+// Poison CONTENT only. A prop like `tone` or `side` is kit vocabulary, not
+// something a module writes prose into -- and those already refuse an unknown
+// value loudly, which is a different guarantee tested elsewhere. Whitelisting
+// content keeps this test honest as the kit grows: a new content prop has to be
+// named here, and until it is, `assertPoisoned` fails rather than passing
+// vacuously.
+const CONTENT_KEYS = new Set([
+ "text", "label", "hint", "title", "placeholder", "unit", "message",
+ "subtitle", "addLabel", "confirmLabel", "cancelLabel", "name", "caption",
+ // Three elements take an `empty`, and it is shown to a person like any other
+ // string here. It was not on this list, so none of them were ever asked to
+ // prove it comes back as text.
+ "empty",
+]);
+
+const isHandle = (v) => v && typeof v === "object" && v.node && v.node.nodeType === 1;
+
+function poison(value, key = null, counter = { n: 0 }) {
+ // A handle is a built element, not props. Copying it entry by entry would
+ // strip its getters and hand the container something that only looks like one.
+ if (isHandle(value)) return value;
+ if (Array.isArray(value)) return value.map((v) => poison(v, key, counter));
+ if (value && typeof value === "object") {
+ const out = {};
+ for (const [k, v] of Object.entries(value)) out[k] = poison(v, k, counter);
+ return out;
+ }
+ if (typeof value === "string" && CONTENT_KEYS.has(key)) {
+ counter.n += 1;
+ return PAYLOAD;
+ }
+ return value;
+}
+
+test("every registered element has a demo", () => {
+ const missing = [...entries()]
+ .map((e) => `${e.group}.${e.variant}`)
+ .filter((name) => !(name in DEMOS));
+ assert.deepEqual(missing, [],
+ "add these to catalogue/demos.js -- an element with no demo is neither shown nor attacked");
+});
+
+test("no element renders module content as markup", () => {
+ for (const { group, variant, factory } of entries()) {
+ const name = `${group}.${variant}`;
+ const demo = DEMOS[name];
+ if (!demo) continue;
+
+ const counter = { n: 0 };
+ const resolved = resolve(demo);
+ const props = poison(resolved, null, counter);
+
+ if (counter.n === 0) {
+ // A pure container takes handles and no content of its own -- there is
+ // nothing here to attack, and its children were attacked under their own
+ // demos. Anything else with no content prop is a demo that forgot one, or
+ // a prop name CONTENT_KEYS has not learned yet.
+ const composes = Object.values(resolved).some(
+ (v) => isHandle(v) || (Array.isArray(v) && v.some(isHandle)));
+ assert.ok(composes,
+ `${name}'s demo has no content prop this test recognises and composes nothing -- ` +
+ "either the demo is missing content, or its prop name belongs in CONTENT_KEYS");
+ continue;
+ }
+
+ const handle = factory(props);
+ // An overlay puts its content in the portal, not under the handle's node --
+ // and a tooltip only builds its content when shown. Search both.
+ if (handle.show) handle.show();
+ const portalRoot = document.getElementById(ROOT_ID);
+ const roots = [handle.node, portalRoot].filter(Boolean);
+ const node = handle.node;
+
+ for (const root of roots) {
+ assert.equal(root.querySelector("img"), null, `${name} rendered the payload as an element`);
+ assert.equal(root.querySelector("script"), null, `${name} rendered a script`);
+ }
+ // Some elements carry content in an attribute instead of text -- an
+ // icon-only button's label is its accessible name. Either is fine; the DOM
+ // escapes attribute values. What must never happen is it becoming markup.
+ const everywhere = roots.flatMap((root) => [root, ...root.querySelectorAll("*")]);
+ const inAttributes = everywhere.some((child) =>
+ child.getAttributeNames().some((attr) => child.getAttribute(attr).includes(PAYLOAD)));
+ const inText = roots.some((root) => root.textContent.includes(PAYLOAD));
+ assert.ok(inText || inAttributes,
+ `${name} dropped the payload entirely -- it renders neither as text nor as an attribute`);
+
+ // The kit may write inline styles -- a slider's fill percentage, a
+ // popover's position -- that is rule 1 permitting the KIT to style. What is
+ // forbidden is CONTENT reaching a style, or any inline handler at all.
+ for (const child of everywhere) {
+ const style = child.getAttribute("style");
+ assert.ok(!style || !style.includes(PAYLOAD), `${name} put content into an inline style`);
+ for (const attr of child.getAttributeNames()) {
+ assert.ok(!attr.startsWith("on"), `${name} set an inline handler (${attr})`);
+ }
+ }
+
+ handle.destroy();
+ }
+});
+
+test("every element returns the same shape of handle", () => {
+ // The panel renderer treats them alike; an element that returns a bare node
+ // would break it only at the point some module happens to use that element.
+ for (const { group, variant, factory } of entries()) {
+ const name = `${group}.${variant}`;
+ const demo = DEMOS[name];
+ if (!demo) continue;
+ const handle = factory(resolve(demo));
+ assert.ok(handle && handle.node, `${name} returned no node`);
+ assert.equal(typeof handle.destroy, "function", `${name} has no destroy()`);
+ assert.equal(handle.node.nodeType, 1, `${name} node is not an element`);
+ handle.destroy();
+ }
+});
+
+test("destroy() leaves nothing behind", () => {
+ // Overlays own their placement -- appending one here would drag it out of the
+ // portal and then destroy() would have nothing to remove. For those the test
+ // is that the portal comes back empty; for everything else, that the node
+ // detaches from wherever the caller put it.
+ for (const { group, variant, factory } of entries()) {
+ const name = `${group}.${variant}`;
+ const demo = DEMOS[name];
+ if (!demo) continue;
+
+ const handle = factory(resolve(demo));
+ if (handle.isOverlay) {
+ handle.destroy();
+ const portalRoot = document.getElementById(ROOT_ID);
+ assert.equal(portalRoot ? portalRoot.childElementCount : 0, 0,
+ `${name} left something in the overlay root`);
+ } else {
+ document.body.appendChild(handle.node);
+ handle.destroy();
+ assert.equal(handle.node.isConnected, false, `${name} stayed in the document`);
+ }
+ }
+});
diff --git a/app/ui/composer/tests/rule_no_module_styling.test.js b/app/ui/composer/tests/rule_no_module_styling.test.js
new file mode 100644
index 00000000..c64e3a60
--- /dev/null
+++ b/app/ui/composer/tests/rule_no_module_styling.test.js
@@ -0,0 +1,72 @@
+// Rule 1: a module never styles anything.
+//
+// The runtime half is stronger than this -- a module is handed { composer,
+// values, on } and nothing that could style or build -- but a scanner catches
+// the attempt in the file rather than at the moment it would have run.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { readFileSync } from "node:fs";
+
+import { scan, scanSource, code } from "../../../../tools/kit_lint/scan.js";
+import { MODULE_RULES, KIT_RULES } from "../../../../tools/kit_lint/rules.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repo = join(here, "..", "..", "..", "..");
+const FIXTURES = join(here, "lint_fixtures");
+
+test("every module in the tree is clean", () => {
+ // Empty today; the value is that it stays empty as modules arrive.
+ const violations = scan(join(repo, "modules"), MODULE_RULES);
+ assert.deepEqual(violations, [],
+ violations.map((v) => `${v.file}:${v.line} ${v.rule} -- ${v.why}`).join("\n"));
+});
+
+test("the kit never interprets content", () => {
+ // The kit may style and may build elements; that is its job. What it may not
+ // do is parse anything a module gave it.
+ const violations = scan(join(repo, "app", "ui", "composer"), KIT_RULES);
+ assert.deepEqual(violations, [],
+ violations.map((v) => `${v.file}:${v.line} ${v.rule}`).join("\n"));
+});
+
+test("the shell never interprets content either", () => {
+ assert.deepEqual(scan(join(repo, "app", "ui", "shell"), KIT_RULES), []);
+});
+
+// --- the scanner has to actually catch things ------------------------------
+
+test("a clean module passes", () => {
+ const source = readFileSync(join(FIXTURES, "clean", "ui.js"), "utf8");
+ assert.deepEqual(scanSource(source, MODULE_RULES), []);
+});
+
+test("the scanner catches every rule it claims to", () => {
+ // Without this, "zero violations" could mean "the scanner is broken" -- which
+ // is the failure mode of every lint nobody tests.
+ const source = readFileSync(join(FIXTURES, "offenders", "ui.js"), "utf8");
+ const caught = new Set(scanSource(source, MODULE_RULES).map((v) => v.rule));
+ for (const rule of ["inline-style", "style-attribute", "class-manipulation",
+ "raw-element", "markup", "stylesheet", "hardcoded-colour",
+ "hardcoded-size", "eval"]) {
+ assert.ok(caught.has(rule), `the offender fixture was not caught by "${rule}"`);
+ }
+});
+
+test("prose is not code", () => {
+ // Otherwise every rule becomes unmentionable in the comment explaining it.
+ const commented = `
+ // never write innerHTML here
+ /* and #ff0000 and 12px are fine to discuss */
+ export const ok = 1;
+ `;
+ assert.deepEqual(scanSource(commented, MODULE_RULES), []);
+ assert.ok(!code(commented).includes("innerHTML"));
+});
+
+test("a url containing a hash is not a colour", () => {
+ assert.deepEqual(scanSource('export const a = "#abc";', MODULE_RULES).map((v) => v.rule),
+ ["hardcoded-colour"], "a real colour is caught");
+});
diff --git a/app/ui/composer/tests/rule_unknown_element_hides.test.js b/app/ui/composer/tests/rule_unknown_element_hides.test.js
new file mode 100644
index 00000000..54ec28fc
--- /dev/null
+++ b/app/ui/composer/tests/rule_unknown_element_hides.test.js
@@ -0,0 +1,143 @@
+// Rule 3: an element the kit does not have makes its module hide.
+//
+// The alternative is a panel that renders something approximate, which is worse
+// than nothing: it looks like it works.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom } from "./_dom.js";
+
+let composer, UnknownElement, renderPanel, mountAll, mounts;
+test.before(async () => {
+ setupDom();
+ ({ composer, UnknownElement } = await import("../composer.js"));
+ ({ renderPanel } = await import("../panel.js"));
+ ({ mountAll } = await import("../../shell/panels.js"));
+ mounts = await import("../../shell/mounts.js");
+});
+test.after(() => teardownDom());
+
+test.beforeEach(() => {
+ mounts._reset();
+ document.body.replaceChildren();
+});
+
+const spec = (id, settings) => ({ id, title: id, mount: "test.region", settings });
+
+test("asking the kit for something it does not have throws", () => {
+ assert.throws(() => composer.hologram, UnknownElement);
+ assert.throws(() => composer.button.hologram, UnknownElement);
+});
+
+test("a panel naming an unknown renderer appends nothing", () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ assert.throws(() => {
+ const panel = renderPanel(spec("bad", {
+ good: { type: "bool", default: true, label: "Fine" },
+ bad: { type: "bool", default: true, label: "Bad", ui: "hologram" },
+ }));
+ host.appendChild(panel.node);
+ });
+ assert.equal(host.childElementCount, 0,
+ "not even the rows that built successfully reached the document");
+});
+
+test("a broken module is absent, and its neighbours still mount", async () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ mounts.offer("test.region", host);
+
+ const { mounted, hidden } = await mountAll({ modules: [
+ spec("first", { on: { type: "bool", default: true, label: "First" } }),
+ spec("broken", { on: { type: "bool", default: true, label: "Broken", ui: "hologram" } }),
+ spec("third", { on: { type: "bool", default: true, label: "Third" } }),
+ ] });
+
+ assert.deepEqual(mounted.map((m) => m.id), ["first", "third"]);
+ assert.deepEqual(hidden.map((h) => h.id), ["broken"]);
+ assert.match(host.textContent, /First/);
+ assert.match(host.textContent, /Third/);
+ assert.doesNotMatch(host.textContent, /Broken/);
+});
+
+test("nothing about a hidden module reaches the screen", async () => {
+ // No placeholder, no greyed row, no warning chip: absent is absent.
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ mounts.offer("test.region", host);
+
+ await mountAll({ modules: [spec("broken", {
+ on: { type: "bool", default: true, label: "Broken", ui: "hologram" },
+ })] });
+
+ assert.equal(host.childElementCount, 0);
+ assert.equal(host.textContent, "");
+});
+
+test("a module whose ui.js fails to load is hidden, not half-mounted", async () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ mounts.offer("test.region", host);
+
+ const { mounted, hidden } = await mountAll(
+ { modules: [{ ...spec("has_ui", { on: { type: "bool", default: true, label: "On" } }), ui: "/missing.js" }] },
+ { load: () => { throw new Error("404"); } },
+ );
+
+ assert.deepEqual(mounted, []);
+ assert.equal(hidden[0].id, "has_ui");
+ assert.equal(host.childElementCount, 0, "its panel did not stay behind");
+});
+
+test("a module naming a mount point nobody offers is absent", async () => {
+ // Hide, don't warn, one level up: a typo'd mount point makes a module vanish
+ // rather than landing somewhere arbitrary.
+ const { mounted, hidden } = await mountAll({ modules: [
+ { ...spec("lost", { on: { type: "bool", default: true, label: "On" } }), mount: "nowhere" },
+ ] });
+ assert.deepEqual(mounted, []);
+ assert.match(hidden[0].why, /no region offers/);
+});
+
+test("a module is handed no way to touch the document", async () => {
+ const host = document.createElement("div");
+ document.body.appendChild(host);
+ mounts.offer("test.region", host);
+
+ let context = null;
+ await mountAll(
+ { modules: [{ ...spec("m", { on: { type: "bool", default: true, label: "On" } }), ui: "/ui.js" }] },
+ { load: async () => ({ setup: (ctx) => { context = ctx; } }) },
+ );
+
+ assert.deepEqual(Object.keys(context).sort(), ["on", "shell", "values"]);
+
+ // Not "no value IS a node" -- that was the old assertion, and it passed while
+ // `composer` sat in this object handing out handles whose .node is a real
+ // element, reachable to the whole document through node.ownerDocument. The
+ // property that matters is that nothing here PRODUCES one either.
+ for (const [name, value] of Object.entries(context)) {
+ assert.equal(value instanceof globalThis.Node, false, `${name} is a DOM node`);
+ }
+ assert.equal("composer" in context, false,
+ "an element factory is a way to obtain a DOM node");
+
+ // `on` is callable, and must hand back an unsubscribe rather than anything
+ // that leads to the page.
+ const off = context.on(() => {});
+ assert.equal(typeof off, "function");
+ assert.equal(off instanceof globalThis.Node, false);
+
+ // Nothing one level down is a node either -- the services are the other place
+ // a handle could have been tucked away.
+ for (const [name, value] of Object.entries(context.shell)) {
+ for (const [inner, member] of Object.entries(value)) {
+ assert.equal(member instanceof globalThis.Node, false, `shell.${name}.${inner}`);
+ }
+ }
+
+ // The service surface is named one by one, so it can be reviewed.
+ assert.deepEqual(Object.keys(context.shell).sort(), ["density", "theme"]);
+});
diff --git a/app/ui/composer/tests/text.test.js b/app/ui/composer/tests/text.test.js
new file mode 100644
index 00000000..fd2c5ee6
--- /dev/null
+++ b/app/ui/composer/tests/text.test.js
@@ -0,0 +1,57 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { setupDom, teardownDom } from "./_dom.js";
+import { asText, setText } from "../internals/text.js";
+
+test.beforeEach(() => setupDom());
+test.after(() => teardownDom());
+
+test("scalars become text nodes", () => {
+ assert.equal(asText("hi").nodeType, 3);
+ assert.equal(asText(42).textContent, "42");
+ assert.equal(asText(true).textContent, "true");
+ assert.equal(asText(0).textContent, "0");
+ assert.equal(asText("").textContent, "");
+});
+
+test("null and undefined render as nothing, not as the word", () => {
+ assert.equal(asText(null).textContent, "");
+ assert.equal(asText(undefined).textContent, "");
+});
+
+test("markup arrives as literal text, never as elements", () => {
+ const host = document.createElement("div");
+ host.appendChild(asText(' '));
+ assert.equal(host.querySelector("img"), null);
+ assert.equal(host.textContent, ' ');
+});
+
+test("a DOM node is refused, and the message says why", () => {
+ const node = document.createElement("span");
+ assert.throws(() => asText(node), (err) => {
+ assert.ok(err instanceof TypeError);
+ assert.match(err.message, /DOM node/);
+ return true;
+ });
+});
+
+test("objects, arrays and functions are refused", () => {
+ // Refusing beats escaping: there is no path where markup exists and someone
+ // forgot to escape it.
+ for (const bad of [{}, [], () => {}, Symbol("x")]) {
+ assert.throws(() => asText(bad), TypeError);
+ }
+});
+
+test("non-finite numbers are refused rather than printed", () => {
+ for (const bad of [NaN, Infinity, -Infinity]) assert.throws(() => asText(bad), TypeError);
+});
+
+test("setText replaces content instead of appending", () => {
+ const host = document.createElement("div");
+ setText(host, "one");
+ setText(host, "two");
+ assert.equal(host.textContent, "two");
+ assert.equal(host.childNodes.length, 1);
+});
diff --git a/app/ui/composer/tests/zlayer.test.js b/app/ui/composer/tests/zlayer.test.js
new file mode 100644
index 00000000..ca2c0728
--- /dev/null
+++ b/app/ui/composer/tests/zlayer.test.js
@@ -0,0 +1,105 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { LADDER, SLOTS, baseOf, claim, liveCount, _resetLayers } from "../internals/zlayer.js";
+
+test.beforeEach(() => _resetLayers());
+
+test("the ladder is ordered, and every rung clears the one below", () => {
+ for (let i = 1; i < LADDER.length; i += 1) {
+ assert.ok(baseOf(LADDER[i]) > baseOf(LADDER[i - 1]) + SLOTS - 1,
+ `${LADDER[i]} must clear every peer of ${LADDER[i - 1]}`);
+ }
+});
+
+test("autocomplete outranks modal", () => {
+ // It opens from a field that is usually inside a modal; v4 got this wrong.
+ assert.ok(baseOf("autocomplete") > baseOf("modal"));
+});
+
+test("an unknown rung is refused rather than guessed", () => {
+ assert.throws(() => baseOf("somewhere"), RangeError);
+});
+
+test("peers stack within their rung and never reach the next one", () => {
+ const a = claim("modal");
+ const b = claim("modal");
+ assert.ok(b.z > a.z);
+ assert.ok(b.z < baseOf("floatingWindow"));
+});
+
+test("releasing frees the slot for reuse", () => {
+ const a = claim("modal");
+ const first = a.z;
+ a.release();
+ assert.equal(claim("modal").z, first);
+});
+
+test("releasing twice is harmless", () => {
+ const a = claim("modal");
+ a.release();
+ a.release();
+ assert.equal(a.live, false);
+});
+
+test("raise() puts a claim above its peers", () => {
+ const a = claim("floatingWindow");
+ const b = claim("floatingWindow");
+ assert.ok(b.z > a.z);
+ a.raise();
+ assert.ok(a.z > b.z, "click-to-front");
+ assert.ok(a.z < baseOf("toast"));
+});
+
+test("raise() on the top claim is a no-op", () => {
+ const a = claim("modal");
+ const b = claim("modal");
+ const before = b.z;
+ b.raise();
+ assert.equal(b.z, before);
+});
+
+test("raise() after release does nothing", () => {
+ const a = claim("modal");
+ a.release();
+ const z = a.z;
+ a.raise();
+ assert.equal(a.z, z);
+});
+
+test("raise() works when the rung is nearly full", () => {
+ // With fixed per-claim offsets this returned the LOWEST free slot, so raise()
+ // sent the window to the back -- exactly backwards, and only in the state a
+ // long session actually reaches.
+ const hs = Array.from({ length: SLOTS }, () => claim("popover"));
+ hs[0].release();
+ const mid = hs[50];
+ mid.raise();
+ const peers = hs.filter((h) => h.live && h !== mid).map((h) => h.z);
+ assert.ok(mid.z > Math.max(...peers), "raise must put it above every peer");
+ assert.ok(mid.z < baseOf("backdrop"), "and still inside its own rung");
+});
+
+test("releasing a middle claim keeps the order of the rest", () => {
+ const a = claim("modal");
+ const b = claim("modal");
+ const c = claim("modal");
+ b.release();
+ assert.ok(c.z > a.z);
+ assert.equal(liveCount("modal"), 2);
+});
+
+test("a released claim reports the z it last held", () => {
+ const a = claim("modal");
+ const b = claim("modal");
+ const z = b.z;
+ b.release();
+ assert.equal(b.z, z);
+});
+
+test("exhausting a rung is loud, not silently wrapped", () => {
+ // A rung running dry means overlays are being opened and never released.
+ // Reusing a slot would hide the leak and stack two things at one z.
+ for (let i = 0; i < SLOTS; i += 1) claim("popover");
+ assert.throws(() => claim("popover"), /is full/);
+});
diff --git a/app/ui/composer/tokens/base.css b/app/ui/composer/tokens/base.css
new file mode 100644
index 00000000..be353703
--- /dev/null
+++ b/app/ui/composer/tokens/base.css
@@ -0,0 +1,25 @@
+/* Shared bases for kit elements. No #ids, no feature selectors, no element
+ * appears here that does not exist in elements/. Every kit class is `cx-`. */
+
+.cx-focusable:focus-visible {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: var(--focus-ring);
+}
+
+.cx-disabled,
+.cx-focusable:disabled {
+ opacity: var(--disabled-opacity);
+ pointer-events: none;
+}
+
+/* Visually hidden but reachable by assistive tech — icon-only controls need it,
+ * which is why `label` is a required prop on iconButton. */
+.cx-sr {
+ position: absolute;
+ width: 1px; height: 1px;
+ padding: 0; margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
diff --git a/app/ui/composer/tokens/fonts.css b/app/ui/composer/tokens/fonts.css
new file mode 100644
index 00000000..cd9fad55
--- /dev/null
+++ b/app/ui/composer/tokens/fonts.css
@@ -0,0 +1,82 @@
+/* Bundled faces. Self-hosted so the editor is correct offline and behind
+ * firewalls that block Google's CDN -- v4 linked the CDN and silently rendered
+ * in a fallback for those users.
+ *
+ * Figtree and Archivo are variable, so one file covers every weight in the
+ * range. font-display: swap keeps text visible while they load; the fallback
+ * stacks in tokens.css are what shows in that moment.
+ *
+ * Licences and provenance: app/assets/fonts/README.md
+ */
+
+@font-face {
+ font-family: 'Figtree';
+ font-style: normal;
+ font-weight: 300 900;
+ font-display: swap;
+ src: url('/funpack/app/assets/fonts/figtree-300-900-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'Figtree';
+ font-style: normal;
+ font-weight: 300 900;
+ font-display: swap;
+ src: url('/funpack/app/assets/fonts/figtree-300-900-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Archivo';
+ font-style: normal;
+ font-weight: 400 900;
+ font-display: swap;
+ src: url('/funpack/app/assets/fonts/archivo-400-900-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'Archivo';
+ font-style: normal;
+ font-weight: 400 900;
+ font-display: swap;
+ src: url('/funpack/app/assets/fonts/archivo-400-900-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'IBM Plex Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/funpack/app/assets/fonts/ibm-plex-mono-400-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'IBM Plex Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('/funpack/app/assets/fonts/ibm-plex-mono-400-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'IBM Plex Mono';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('/funpack/app/assets/fonts/ibm-plex-mono-500-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'IBM Plex Mono';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('/funpack/app/assets/fonts/ibm-plex-mono-500-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
diff --git a/app/ui/composer/tokens/reset.css b/app/ui/composer/tokens/reset.css
new file mode 100644
index 00000000..4556a6f5
--- /dev/null
+++ b/app/ui/composer/tokens/reset.css
@@ -0,0 +1,50 @@
+/* The minimum normalisation the kit needs. Not a general-purpose reset. */
+*, *::before, *::after { box-sizing: border-box; }
+
+html, body { height: 100%; }
+
+body {
+ margin: 0;
+ /* A wash over the base, not the base alone: a flat fill across 1440px reads
+ as paper, and every surface above it then has nothing to sit against. */
+ background: var(--wash), var(--elev-0);
+ background-attachment: fixed;
+ color: var(--text);
+ font-family: var(--font-ui);
+ font-size: var(--fs-4);
+ line-height: var(--lh-4);
+ -webkit-font-smoothing: antialiased;
+}
+
+::selection { background: var(--accent-soft); }
+
+/* Scrollbars are chrome, and the platform's own are sized for a document.
+ Left alone, a 15px light-grey bar cut through every dark panel in the app. */
+* { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }
+*::-webkit-scrollbar { width: 10px; height: 10px; }
+*::-webkit-scrollbar-track { background: transparent; }
+*::-webkit-scrollbar-thumb {
+ background: var(--scroll-thumb); border-radius: var(--r-4);
+ /* Inset by its own border so the thumb reads as floating in the track
+ rather than as a second edge beside the panel's. */
+ border: 2px solid transparent; background-clip: padding-box;
+}
+*::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hi); background-clip: padding-box; }
+*::-webkit-scrollbar-corner { background: transparent; }
+
+/* Headings and paragraphs carry no margin of their own — spacing is the
+ * container's job, so an element can be dropped anywhere without surprises. */
+h1, h2, h3, h4, h5, h6, p, figure { margin: 0; font-size: inherit; font-weight: inherit; }
+
+button, input, select, textarea { font: inherit; color: inherit; }
+
+:focus:not(:focus-visible) { outline: none; }
+
+/* `hidden` means hidden, whatever the element is.
+ *
+ * The UA sheet's `[hidden] { display: none }` is an element-level rule, so any
+ * class that sets a display beats it -- and nearly every element here sets one.
+ * `.cx-btn { display: inline-flex }` alone made the attribute inert on every
+ * button in the kit: the Cancel button was marked hidden, stayed on screen, and
+ * nothing anywhere reported a problem. */
+[hidden] { display: none !important; }
diff --git a/app/ui/composer/tokens/tokens.css b/app/ui/composer/tokens/tokens.css
new file mode 100644
index 00000000..1cc852c6
--- /dev/null
+++ b/app/ui/composer/tokens/tokens.css
@@ -0,0 +1,245 @@
+/* Every value in the design system. Nothing outside this file invents one.
+ *
+ * Theme is an attribute on , stamped by shell/theme.js before first paint:
+ * data-theme = "dark" | "light" (what this file reads)
+ * data-theme-pref = "dark" | "light" | "auto"
+ * "auto" is resolved in JS on purpose — a @media block cannot be shared across
+ * both selectors, so the palette would be written twice and drift.
+ */
+
+:root {
+ /* ── type ───────────────────────────────────────────────────────────── */
+ /* Bundled in app/assets/fonts. The fallbacks matter: they are what shows
+ * while the woff2 loads, and what shows if a file ever goes missing. */
+ --font-ui: "Figtree", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ --font-display: "Archivo", "Figtree", system-ui, -apple-system, sans-serif;
+ --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+
+ /* display steps */
+ --fs-d1: 56px; --lh-d1: 1;
+ --fs-d2: 40px; --lh-d2: 1;
+ --fs-d3: 28px; --lh-d3: 1.15;
+ /* text steps (Apple HIG macOS scale — 76% of v4's sizes already sat here) */
+ --fs-1: 22px; --lh-1: 26px;
+ --fs-2: 17px; --lh-2: 22px;
+ --fs-3: 15px; --lh-3: 20px;
+ --fs-4: 13px; --lh-4: 16px;
+ --fs-5: 12px; --lh-5: 15px;
+ --fs-6: 11px; --lh-6: 14px;
+ --fs-7: 10px; --lh-7: 13px;
+ --fs-8: 9px; --lh-8: 12px; /* v4 used 9px 26 times without naming it */
+
+ --lh-tight: 1; --lh-snug: 1.35; --lh-loose: 1.5;
+
+ --fw-regular: 400; --fw-medium: 500; --fw-semibold: 600; --fw-bold: 700;
+
+ /* ── spacing (2px base; covers 82% of v4's real values) ─────────────── */
+ --sp-0: 0; --sp-1: 2px; --sp-2: 4px; --sp-3: 6px;
+ --sp-4: 8px; --sp-5: 10px; --sp-6: 12px; --sp-7: 16px;
+ --sp-8: 20px; --sp-9: 24px; --sp-10: 32px; --sp-11: 40px;
+
+ /* ── radius (v4 shipped 19 distinct values) ─────────────────────────── */
+ --r-1: 2px; --r-2: 4px; --r-3: 6px; --r-4: 8px; --r-5: 12px;
+ --r-pill: 9999px;
+
+ /* ── controls (v4 shipped 11 distinct heights) ──────────────────────── */
+ --ctl-h-sm: 20px; --ctl-h-md: 24px; --ctl-h-lg: 28px; --ctl-h-xl: 32px;
+ --ctl-h-icon: 18px; --ctl-h-icon-sm: 16px;
+ --ctl-pad-x-sm: 6px; --ctl-pad-x-md: 8px;
+ --ctl-pad-x-lg: 10px; --ctl-pad-x-xl: 12px;
+ --ctl-r: var(--r-3);
+ --ctl-gap: var(--sp-3);
+
+ /* ── borders & states ───────────────────────────────────────────────── */
+ --bw: 1px;
+ --disabled-opacity: 0.45;
+
+ /* ── motion ─────────────────────────────────────────────────────────── */
+ --dur-1: 120ms; --dur-2: 180ms; --dur-3: 260ms;
+ --ease-standard: cubic-bezier(.22, .61, .36, 1);
+
+ /* ── adaptive density (v4 had 8 different minmax floors) ────────────── */
+ --cell-thumb: 72px; --cell-card: 144px; --cell-panel: 240px;
+ --cell-icon: 48px; /* captionless tiles: the picture is the whole cell */
+
+ /* ── layout ─────────────────────────────────────────────────────────── */
+ --menubar-h: 34px;
+
+ /* One gutter. Every edge in the app lands on it: the space between the three
+ regions, between two panels stacked in a column, and between a band's
+ content and the window. Three different numbers for those three things is
+ what makes a layout read as accidental. */
+ --gutter: var(--sp-5);
+
+ /* Settings rows put their control in a column of this width, so every
+ control in a list of them has the same two edges. A is sized by
+ its widest OPTION, so one row holding a list of checkpoint filenames was
+ 264px wide among four that were 140. */
+ --control-col: 180px;
+}
+
+/* ── dark: the cutting-room palette (default) ─────────────────────────── */
+/* Scoped to any [data-theme] element, not just :root, so a pane can render the
+ * other theme in place — the catalogue shows both side by side that way. */
+:root,
+[data-theme="dark"] {
+ color-scheme: dark;
+
+ --elev-0: #0c0b09; /* page */
+ --elev-1: #131210; /* panel */
+ --elev-2: #1a1814; /* raised, head */
+ --elev-3: #1f1c16; /* overlay */
+
+ --hover: #221f19;
+ --active: #2a261e;
+
+ --line: #2a261e;
+ --line-2: #37322833;
+
+ --text: #ece7db;
+ --muted: #918a7a;
+ --faint: #645e51;
+
+ --accent: #f3a93c;
+ --accent-hi: #ffc36b;
+ --accent-lo: #e0972a;
+ --accent-soft: rgba(243, 169, 60, .14);
+ --on-accent: #1a1206;
+
+ --teal: #57d0c6; /* render / audio semantic */
+ --violet: #c084fc; /* overlay / text layer */
+ --danger: #e8607a;
+ --warn: #e8a040;
+ --good: #79c479;
+ --pink: rgb(255, 120, 180);
+
+ --void: #060606; /* media letterbox */
+ --shadow-ink: rgba(0, 0, 0, .85);
+ --backdrop: rgba(6, 5, 4, .66);
+
+ /* Film grain. `overlay` over a dark surface lifts it slightly and breaks up
+ the flatness of a large area of one colour, which is most of this app. */
+ --grain-opacity: .035;
+ --grain-blend: overlay;
+
+ /* A filled accent control, at rest and under the pointer.
+ Dark's accent is amber carrying near-black text: it can go as bright as it
+ likes, so the button sits at the accent and LIGHTENS when pressed towards.
+ Which way hover moves is the opposite of light's, below. */
+ --grad-accent: linear-gradient(180deg, var(--accent), var(--accent-lo));
+ --grad-accent-hover: linear-gradient(180deg, var(--accent-hi), var(--accent));
+ --accent-hover-edge: var(--accent-hi);
+}
+
+/* ── light: white, grey and daylight blue ─────────────────────────────── */
+[data-theme="light"] {
+ color-scheme: light;
+
+ --elev-0: #f5f7fa;
+ --elev-1: #ffffff;
+ --elev-2: #eef1f6;
+ --elev-3: #ffffff;
+
+ --hover: #e1e7ef;
+ --active: #d3dae3;
+
+ --line: #d3dae3;
+ --line-2: #8a97a833;
+
+ --text: #16202c;
+ --muted: #59677a;
+ --faint: #8d9aab;
+
+ --accent: #2f7fd4;
+ --accent-hi: #5aa4ec;
+ --accent-lo: #2668b0;
+ --accent-soft: rgba(47, 127, 212, .13);
+ --on-accent: #ffffff;
+
+ --teal: #12857c;
+ --violet: #7c3aed;
+ --danger: #c62a48;
+ --warn: #b26a00;
+ --good: #2f8f4e;
+ --pink: rgb(197, 45, 116);
+
+ --void: #0a0c0f;
+ --shadow-ink: rgba(24, 34, 48, .30);
+ --backdrop: rgba(24, 34, 48, .34);
+
+ /* The same grain over a light surface with `overlay` reads as dirt, so light
+ multiplies a much fainter one. v4 landed on the same two numbers. */
+ --grain-opacity: .015;
+ --grain-blend: multiply;
+
+ /* Light's accent carries WHITE text, which caps how bright the fill can be:
+ past the accent itself the label starts disappearing into it. So the button
+ sits AT the accent -- brighter than the accent-to-accent-lo fade it used to
+ wear, which made it look washed against a white page -- and DARKENS under
+ the pointer, where white only reads better. The opposite of dark's. */
+ --grad-accent: linear-gradient(180deg, var(--accent),
+ color-mix(in srgb, var(--accent) 85%, var(--accent-lo)));
+ --grad-accent-hover: linear-gradient(180deg, var(--accent-lo),
+ color-mix(in srgb, var(--accent-lo) 82%, black));
+ --accent-hover-edge: var(--accent-lo);
+}
+
+/* Derived values, written once so both themes get them for free. color-mix
+ * percentages come from a fixed set: 12 / 22 / 30 / 45 / 60 / 80 — v4 had float
+ * artefacts like 64.7%.
+ *
+ * The selector must include [data-theme], not just :root. A custom property's
+ * var()s are substituted where the property is DECLARED and inherited already
+ * resolved — so if these lived on :root alone, a scoped [data-theme="light"]
+ * subtree would inherit the root theme's accent and shadow. Declaring them here
+ * too makes each themed element re-derive them from its own palette. */
+:root,
+[data-theme] {
+ --line-hover: color-mix(in srgb, var(--accent) 30%, var(--line));
+ --line-active: color-mix(in srgb, var(--accent) 60%, var(--line));
+ --focus-ring: 0 0 0 3px var(--accent-soft);
+
+ --shadow-1: 0 1px 2px color-mix(in srgb, var(--shadow-ink) 45%, transparent);
+ --shadow-2: 0 4px 12px color-mix(in srgb, var(--shadow-ink) 60%, transparent);
+ --shadow-3: 0 12px 32px color-mix(in srgb, var(--shadow-ink) 80%, transparent);
+
+ /* ── surfaces ────────────────────────────────────────────────────────
+ Depth, derived from the palette rather than painted per component.
+ A flat fill everywhere is what made this read as a wireframe of itself:
+ every band, control and card was one colour with a 1px line around it,
+ so nothing sat above anything else.
+
+ Each of these is a vertical fall between two steps of the SAME theme's
+ elevation scale, so dark falls darker and light falls lighter, and
+ neither needs a rule of its own. */
+ --grad-band: linear-gradient(180deg, var(--elev-2),
+ color-mix(in srgb, var(--elev-2) 55%, var(--elev-0)));
+ --grad-raise: linear-gradient(180deg, color-mix(in srgb, var(--elev-2) 72%, var(--elev-3)),
+ var(--elev-2));
+
+
+ /* A filled accent control throws a little of its own colour. Negative
+ spread, so it is a bloom under the control and not a box around it. */
+ --glow-accent: 0 4px 14px -6px var(--accent);
+
+ /* The page is not one flat colour: a soft wash off the top-right corner,
+ which is what stops a 1440px-wide dark background looking like paper. */
+ --wash: radial-gradient(1200px 600px at 80% -10%,
+ color-mix(in srgb, var(--elev-2) 70%, transparent) 0%,
+ transparent 60%);
+
+ --scroll-thumb: color-mix(in srgb, var(--text) 16%, transparent);
+ --scroll-thumb-hi: color-mix(in srgb, var(--text) 28%, transparent);
+}
+
+/* One guard covers the whole app, because every duration is a token.
+ * v4 had 40 unguarded transitions — unguarded precisely because the durations
+ * were hardcoded at each site. */
+@media (prefers-reduced-motion: reduce) {
+ :root { --dur-1: 0ms; --dur-2: 0ms; --dur-3: 0ms; }
+ *, *::before, *::after {
+ animation-duration: .01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: .01ms !important;
+ }
+}
diff --git a/app/ui/features/log/index.js b/app/ui/features/log/index.js
new file mode 100644
index 00000000..b93a1768
--- /dev/null
+++ b/app/ui/features/log/index.js
@@ -0,0 +1,8 @@
+import { composer } from "../../composer/composer.js";
+import { open } from "./window.js";
+
+export default {
+ id: "log",
+ mount: "menubar",
+ setup: ({ host }) => host.append(composer.button.sm({ label: "Log", onClick: () => open() }).node),
+};
diff --git a/app/ui/features/log/window.js b/app/ui/features/log/window.js
new file mode 100644
index 00000000..bd3fe749
--- /dev/null
+++ b/app/ui/features/log/window.js
@@ -0,0 +1,93 @@
+// ComfyUI's log, in the app.
+//
+// Users do not open devtools. "It did not work" with nothing to read is where a
+// bug report dies, and the one place the reason is written down is a terminal
+// they may not even be able to see -- a rented box behind a tunnel has no
+// terminal on this desk at all.
+//
+// It polls only while open, and it stops polling while you are selecting text:
+// a panel that redraws under a drag makes copying a stack trace impossible,
+// which is the single thing anybody opens a log to do.
+
+import { composer } from "../../composer/composer.js";
+
+const EVERY = 2000;
+
+/** mount({ poll, setFooter, close }) -> { node, destroy }. See tempfiles.js's
+ * mount() for why setFooter/close are the only things asked for -- `destroy`
+ * additionally stops the poll, since this one runs a timer nothing else does. */
+export function mount({ poll = EVERY, setFooter = () => {}, close = () => {} } = {}) {
+ let timer = null;
+ let follow = true; // stick to the end until the user scrolls off it
+ let last = "";
+
+ const view = composer.code.block({ text: "Reading…" });
+ const body = composer.region.stack({ gap: "sm", fill: true, children: [view] });
+
+ const selecting = () => {
+ const sel = document.getSelection();
+ return sel && !sel.isCollapsed && view.node.contains(sel.anchorNode);
+ };
+
+ // Following the end is the default and stops being it the moment someone
+ // scrolls up -- otherwise reading anything older than two seconds is a fight.
+ view.node.addEventListener("scroll", () => {
+ const room = view.node.scrollHeight - view.node.scrollTop - view.node.clientHeight;
+ follow = room < 40;
+ });
+
+ async function refresh() {
+ if (selecting()) return; // never redraw under a drag
+ try {
+ const res = await fetch("/funpack/api/log?limit=600", { cache: "no-store" });
+ const payload = await res.json().catch(() => ({}));
+ const text = (payload.lines || []).join("\n")
+ || payload.detail
+ || "Nothing in the log yet.";
+ if (text !== last) {
+ last = text;
+ view.setText(text);
+ if (follow) view.node.scrollTop = view.node.scrollHeight;
+ }
+ setFooter({ note: payload.path || "", actions: footer() });
+ } catch (err) {
+ // The server being gone is the most interesting thing a log can say, so it
+ // is said -- and the lines already on screen are kept, because they are
+ // the ones from just before it went.
+ setFooter({ note: `Cannot reach ComfyUI: ${err.message}`, actions: footer() });
+ }
+ }
+
+ const footer = () => [
+ composer.button.md({
+ label: "Copy", onClick: () => {
+ // Selecting a thousand lines by hand is the other reason this is hard to
+ // get out of a log panel.
+ if (navigator.clipboard) navigator.clipboard.writeText(last).catch(() => {});
+ },
+ }),
+ composer.button.md({ label: "Close", tone: "primary", onClick: () => close() }),
+ ];
+
+ setFooter({ actions: footer() });
+ refresh();
+ timer = setInterval(refresh, poll);
+ return { node: body.node, destroy: () => { clearInterval(timer); timer = null; body.destroy(); } };
+}
+
+export function open({ poll = EVERY } = {}) {
+ let window_ = null;
+ const content = mount({
+ poll,
+ setFooter: (f) => window_ && window_.setFooter(f),
+ close: () => window_ && window_.close("done"),
+ });
+ window_ = composer.modal.generic({
+ title: "ComfyUI log",
+ subtitle: "What the server printed. Newest at the bottom.",
+ size: "xl",
+ body: content,
+ onClose: () => { content.destroy(); window_ = null; },
+ });
+ return window_;
+}
diff --git a/app/ui/index.html b/app/ui/index.html
new file mode 100644
index 00000000..b61d6292
--- /dev/null
+++ b/app/ui/index.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ FunPack
+
+
+
+
+
+
+
+
+
diff --git a/app/ui/modules.js b/app/ui/modules.js
new file mode 100644
index 00000000..49945661
--- /dev/null
+++ b/app/ui/modules.js
@@ -0,0 +1,4 @@
+// The one list of UI features. Adding a feature is a folder under ./features and a line here.
+export default [
+ "./features/log/index.js",
+].map((path) => new URL(path, import.meta.url).href);
diff --git a/app/ui/shell/features.js b/app/ui/shell/features.js
new file mode 100644
index 00000000..4428ada2
--- /dev/null
+++ b/app/ui/shell/features.js
@@ -0,0 +1,30 @@
+// Loads UI features. A feature is a folder with an index.js hub:
+// export default { id, mount, needs?, setup({ host }) -> teardown? }
+// `mount` names a region (see mounts.js). A feature that cannot load, names a region nobody offers,
+// needs a service the app lacks, or throws in setup is hidden -- nothing of it reaches the screen.
+import { hostFor, offered } from "./mounts.js";
+
+export async function loadFeatures(paths, { load = (path) => import(path), has = () => true } = {}) {
+ const mounted = [], hidden = [];
+ for (const path of paths) {
+ let hub;
+ try {
+ hub = (await load(path)).default;
+ const host = hostFor(hub.mount);
+ if (!host) throw new Error(`no region offers "${hub.mount}" (offered: ${offered().join(", ")})`);
+ const lacking = (hub.needs || []).filter((name) => !has(name));
+ if (lacking.length) throw new Error(`needs ${lacking.join(", ")}`);
+ const before = [...host.childNodes];
+ try {
+ mounted.push({ id: hub.id, teardown: hub.setup({ host }) });
+ } catch (err) { // whatever it put on the page goes with it
+ [...host.childNodes].filter((n) => !before.includes(n)).forEach((n) => n.remove());
+ throw err;
+ }
+ } catch (err) {
+ hidden.push({ id: hub?.id || path, why: err.message });
+ console.warn(`[FunPack] ${hub?.id || path} hidden: ${err.message}`);
+ }
+ }
+ return { mounted, hidden };
+}
diff --git a/app/ui/shell/features.test.js b/app/ui/shell/features.test.js
new file mode 100644
index 00000000..c3284376
--- /dev/null
+++ b/app/ui/shell/features.test.js
@@ -0,0 +1,27 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { offer, _reset } from "./mounts.js";
+import { loadFeatures } from "./features.js";
+
+const host = () => { const nodes = []; return { childNodes: nodes, append: (n) => nodes.push(n) }; };
+const hubs = {
+ ok: { id: "ok", mount: "a", setup: ({ host: h }) => h.append({ remove() {} }) },
+ throws: { id: "throws", mount: "a", setup: () => { throw new Error("boom"); } },
+ nowhere: { id: "nowhere", mount: "zzz", setup() {} },
+ needy: { id: "needy", mount: "a", needs: ["gpu"], setup() {} },
+};
+const run = (names) => loadFeatures(names, { load: async (n) => ({ default: hubs[n] }), has: () => false });
+
+test("a feature that loads is mounted; one that throws, names no region, or lacks a need is hidden, and the rest still load", async () => {
+ _reset(); offer("a", host());
+ const { mounted, hidden } = await run(["throws", "nowhere", "needy", "ok"]);
+ assert.deepEqual(mounted.map((m) => m.id), ["ok"]);
+ assert.deepEqual(hidden.map((h) => [h.id, h.why.split(" ")[0]]), [["throws", "boom"], ["nowhere", "no"], ["needy", "needs"]]);
+});
+
+test("what a feature put on the page before it threw is taken away", async () => {
+ _reset(); const h = host(); offer("a", h);
+ hubs.throws.setup = ({ host: x }) => { x.append({ remove() { h.childNodes.length = 0; } }); throw new Error("late"); };
+ await run(["throws"]);
+ assert.equal(h.childNodes.length, 0);
+});
diff --git a/app/ui/shell/frame.js b/app/ui/shell/frame.js
new file mode 100644
index 00000000..c7c99b0b
--- /dev/null
+++ b/app/ui/shell/frame.js
@@ -0,0 +1,20 @@
+// The app's regions, and what each offers to features. This is the whole of the layout:
+// Assets | Preview over Timeline | Properties, under a menubar. It names no feature.
+import { composer } from "../composer/composer.js";
+import { offer } from "./mounts.js";
+
+export function build(root) {
+ const zone = (title, mount) => {
+ const body = composer.region.stack({ gap: "sm", fill: true });
+ offer(mount, body.node);
+ return composer.panel.zone({ title, body });
+ };
+ const menubar = composer.region.stack({ gap: "sm" });
+ offer("menubar", menubar.node);
+
+ const centre = composer.splitPane.v({ panes: [zone("Preview", "preview"), zone("Timeline", "timeline")], size: 60 });
+ const main = composer.workspace.docked({ centre, left: zone("Assets", "assets"), right: zone("Properties", "inspector") });
+ const frame = composer.frame.app({ header: menubar, main });
+ root.append(frame.node);
+ return frame;
+}
diff --git a/app/ui/shell/manifest.js b/app/ui/shell/manifest.js
new file mode 100644
index 00000000..218c4198
--- /dev/null
+++ b/app/ui/shell/manifest.js
@@ -0,0 +1,10 @@
+// What the server says exists.
+
+const ENDPOINT = "/funpack/api/modules";
+
+export async function fetchManifest(traits) {
+ const url = traits ? `${ENDPOINT}?traits=${encodeURIComponent(traits.join(","))}` : ENDPOINT;
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`${ENDPOINT} answered ${response.status}`);
+ return response.json();
+}
diff --git a/app/ui/shell/mounts.js b/app/ui/shell/mounts.js
new file mode 100644
index 00000000..e0f058f7
--- /dev/null
+++ b/app/ui/shell/mounts.js
@@ -0,0 +1,51 @@
+// Mount points are ANNOUNCED by the regions that offer them.
+//
+// A fixed list in core would be a global list -- the one thing the architecture
+// avoids -- and it would have to be edited every time the shell grows a region.
+// Instead a region says what it can host, and a module naming something no
+// region offers is simply absent. That is "hide, don't warn" one level up.
+
+const regions = new Map(); // mount point -> { host, standIn }
+
+/**
+ * offer(mount, host, standIn?)
+ *
+ * `standIn` is what the region shows while nothing has mounted into it -- an
+ * empty state, not a label. It is taken down by settle() once something is
+ * there, because a region that says "modules appear here" ABOVE the modules
+ * that appeared is a region explaining itself to nobody.
+ */
+export function offer(mount, host, standIn = null) {
+ if (regions.has(mount)) {
+ throw new Error(`Mount point "${mount}" is already offered by another region.`);
+ }
+ regions.set(mount, { host, standIn });
+ return () => regions.delete(mount);
+}
+
+export const hostFor = (mount) => {
+ const region = regions.get(mount);
+ return region ? region.host : null;
+};
+export const offered = () => [...regions.keys()].sort();
+export const claimed = (mount) => regions.has(mount);
+
+/**
+ * Take down the stand-in of every region something mounted into.
+ *
+ * Called once, after everything that mounts has had its turn. Doing it as each
+ * one mounts would work too and would be wrong the moment two regions share a
+ * host: the one that mounted first would take down a stand-in belonging to
+ * both.
+ */
+export function settle() {
+ for (const { host, standIn } of regions.values()) {
+ if (!standIn || !standIn.parentNode) continue;
+ const others = [...host.children].filter((child) => child !== standIn);
+ if (others.length) standIn.remove();
+ }
+}
+
+export function _reset() {
+ regions.clear();
+}
diff --git a/app/ui/shell/panels.js b/app/ui/shell/panels.js
new file mode 100644
index 00000000..e3e1108c
--- /dev/null
+++ b/app/ui/shell/panels.js
@@ -0,0 +1,113 @@
+// Turning announcements into panels.
+//
+// Everything a module can reach is decided here, and it is deliberately little:
+// { values, on, shell }. No DOM node, no document, no element factory.
+//
+// `composer` used to be in that list, and it was the hole in the guarantee: a
+// factory returns a handle whose `.node` is a real element, so a module could
+// take one, style it, and reach the whole document through `node.ownerDocument`
+// -- no lint to evade, just the intended API. No module ever used it. Panels are
+// rendered from the DECLARATION, and a module that wants to draw something is a
+// module that should be declaring it instead.
+//
+// What is true, stated exactly: the kit hands a module nothing that touches the
+// DOM. What is NOT true, and must not be claimed: that a module CANNOT reach the
+// DOM. A ui.js is an ES module in the page realm and `document` is a global.
+// tools/kit_lint raises the cost of doing that accidentally; it is a discipline
+// check and not a boundary, and only real isolation (a worker, another realm)
+// would make it one. First-party modules in this repo are reviewed; a
+// third-party module would need that isolation before it could be trusted.
+
+import { composer } from "../composer/composer.js";
+import { renderPanel, defaultsOf } from "../composer/panel.js";
+import { hostFor, offered } from "./mounts.js";
+import * as values from "./values.js";
+import { services } from "./services.js";
+
+/**
+ * mountAll(manifest) -> { mounted, hidden }
+ *
+ * `hidden` is diagnostic only. Nothing about a hidden module reaches the screen:
+ * no placeholder, no greyed row, no warning chip. A module that failed is
+ * indistinguishable from one that was never installed, which is the point --
+ * the alternative is a UI full of things that cannot be used.
+ */
+export async function mountAll(manifest, { load = (path) => import(path) } = {}) {
+ const mounted = [];
+ const hidden = [];
+
+ for (const spec of manifest.modules || []) {
+ // Nothing to render. A module may exist purely to contribute ComfyUI nodes
+ // (a loader, a model's compatibility module), and those have no panel and
+ // no mount. Skipping is not hiding: there is no failure to report.
+ const renders = Object.keys(spec.settings || {}).length > 0 || spec.ui;
+ if (!renders) continue;
+
+ const host = hostFor(spec.mount);
+ if (!host) {
+ hidden.push({ id: spec.id, why: `no region offers "${spec.mount}" (offered: ${offered().join(", ") || "none"})` });
+ continue;
+ }
+
+ try {
+ // Values first: a module's ui.js may read them during setup, and a
+ // headless run has them without any of this having happened.
+ values.seed(spec.id, defaultsOf(spec));
+
+ let ui = null;
+ if (spec.ui) ui = await load(spec.ui);
+ let teardown = null;
+
+ if (ui && typeof ui.setup === "function") {
+ // Kept, not discarded: setup() returns the unsubscribe from on(), and
+ // dropping it means every re-mount leaves another live listener behind.
+ teardown = ui.setup({
+ values: {
+ get: () => values.valuesOf(spec.id),
+ set: (key, value) => values.set(spec.id, key, value),
+ },
+ // Scoped to this module: a module hearing about everyone else's
+ // edits would have to filter, and filtering needs an id it should
+ // not need to know.
+ on: (fn) => values.onChange((id, key, value) => {
+ if (id === spec.id) fn(key, value);
+ }),
+ // The app services a module may use, named explicitly. A short,
+ // reviewable list beats reaching for a global, which is how a module
+ // ends up depending on something nobody meant to expose.
+ shell: services,
+ });
+ }
+
+ // Built AFTER setup, so a control shows what setup settled on. The panel
+ // is drawn from a snapshot of the values, so building it first meant a
+ // module that adopted a live value in setup -- the theme, which the page
+ // applies before any module exists -- had a control showing something the
+ // app was not set to.
+ const panel = renderPanel(spec, {
+ values: values.valuesOf(spec.id),
+ onChange: (key, value) => values.set(spec.id, key, value),
+ });
+
+ // Only now, with a complete panel AND a setup() that returned, does
+ // anything reach the DOM. Appending before setup ran left a module whose
+ // setup threw reported as hidden with its panel still on screen -- which
+ // is precisely the guarantee this file states it keeps.
+ host.appendChild(panel.node);
+
+ mounted.push({
+ id: spec.id,
+ panel,
+ destroy() {
+ if (typeof teardown === "function") teardown();
+ panel.destroy();
+ },
+ });
+ } catch (err) {
+ hidden.push({ id: spec.id, why: `${err.name}: ${err.message}` });
+ console.warn(`[FunPack] ${spec.id} did not mount: ${err.message}`);
+ }
+ }
+
+ return { mounted, hidden };
+}
diff --git a/app/ui/shell/services.js b/app/ui/shell/services.js
new file mode 100644
index 00000000..ba5bb1d1
--- /dev/null
+++ b/app/ui/shell/services.js
@@ -0,0 +1,26 @@
+// What the app offers modules beyond the kit.
+//
+// Deliberately tiny and named one by one. The alternative -- letting a module
+// reach for a global -- means the surface is "whatever happens to be on window",
+// which nobody can review and nothing can keep stable.
+
+import { applyDensity, normaliseCols, rememberCols, recallCols } from "../composer/internals/density.js";
+
+export const services = {
+ theme: {
+ set(choice) { window.ComposerTheme.apply(choice); },
+ get: () => window.ComposerTheme.get(),
+ resolved: () => window.ComposerTheme.resolved(),
+ },
+ density: {
+ /** What the grids are set to, which the browser remembers between visits. */
+ get: () => recallCols("app"),
+ /** Column count for every adaptive grid on the page. */
+ set(cols) {
+ const n = normaliseCols(cols);
+ for (const grid of document.querySelectorAll(".cx-gallery")) applyDensity(grid, n);
+ rememberCols("app", n);
+ return n;
+ },
+ },
+};
diff --git a/app/ui/shell/theme.js b/app/ui/shell/theme.js
new file mode 100644
index 00000000..a7506104
--- /dev/null
+++ b/app/ui/shell/theme.js
@@ -0,0 +1,84 @@
+// Colour scheme: dark (the original cutting-room palette), light, or auto.
+//
+// "auto" is resolved HERE rather than in CSS: a @media (prefers-color-scheme)
+// block cannot be reused across the two selectors it would need, so the palette
+// would have to be written twice and would drift. The stylesheet only ever sees
+// data-theme="dark" or "light"; this file decides which.
+//
+// Loaded before every other script (and re-applied inline in ) so the
+// first paint is already in the right theme — a dark flash on a light setup is
+// the one bug a theme switcher cannot apologise for.
+(function () {
+ const LS_KEY = "funpack_theme";
+ const CHOICES = ["dark", "light", "auto"];
+ const listeners = new Set();
+ let mq = null;
+
+ function stored() {
+ try {
+ const v = localStorage.getItem(LS_KEY);
+ return CHOICES.includes(v) ? v : "dark";
+ } catch (_) {
+ return "dark";
+ }
+ }
+
+ function systemIsLight() {
+ return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches);
+ }
+
+ function resolve(choice) {
+ return choice === "auto" ? (systemIsLight() ? "light" : "dark") : choice;
+ }
+
+ // data-theme is what the stylesheet reads; data-theme-pref is the choice behind it
+ // ("auto" resolves to one of the other two). The theme changes on an explicit click
+ // and nothing else, so these two always move together.
+ function stamp(choice) {
+ const resolved = resolve(choice);
+ document.documentElement.setAttribute("data-theme", resolved);
+ document.documentElement.setAttribute("data-theme-pref", choice);
+ return resolved;
+ }
+
+ // Only while the preference is "auto" does the OS get a vote; a user who picked
+ // light explicitly should not flip at sunset.
+ function watchSystem(choice) {
+ if (!window.matchMedia) return;
+ if (!mq) {
+ mq = window.matchMedia("(prefers-color-scheme: light)");
+ const onChange = () => { if (get() === "auto") apply("auto"); };
+ if (mq.addEventListener) mq.addEventListener("change", onChange);
+ else if (mq.addListener) mq.addListener(onChange);
+ }
+ void choice;
+ }
+
+ function get() {
+ return document.documentElement.getAttribute("data-theme-pref") || stored();
+ }
+
+ function resolved() {
+ return document.documentElement.getAttribute("data-theme") || "dark";
+ }
+
+ function apply(choice, { persist = true } = {}) {
+ if (!CHOICES.includes(choice)) choice = "dark";
+ const res = stamp(choice);
+ if (persist) {
+ try { localStorage.setItem(LS_KEY, choice); } catch (_) {}
+ }
+ watchSystem(choice);
+ listeners.forEach((fn) => { try { fn(choice, res); } catch (_) {} });
+ return res;
+ }
+
+ function onChange(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ }
+
+ apply(stored(), { persist: false });
+
+ window.ComposerTheme = { get, resolved, apply, onChange, CHOICES };
+})();
diff --git a/app/ui/shell/values.js b/app/ui/shell/values.js
new file mode 100644
index 00000000..cc3fe00c
--- /dev/null
+++ b/app/ui/shell/values.js
@@ -0,0 +1,36 @@
+// The live value of every setting, keyed exactly as the declaration keys them.
+//
+// This is the only thing in the app that reads the UI. A module never does: it
+// declares what it needs and is handed values, so the same module works with a
+// panel on screen and with no browser at all.
+
+const store = new Map(); // moduleId -> { key: value }
+const listeners = new Set();
+
+export function seed(moduleId, defaults) {
+ store.set(moduleId, { ...defaults });
+}
+
+export function set(moduleId, key, value) {
+ const current = store.get(moduleId) || {};
+ current[key] = value;
+ store.set(moduleId, current);
+ for (const fn of listeners) {
+ try { fn(moduleId, key, value); } catch { /* a listener must not break the edit */ }
+ }
+}
+
+export const valuesOf = (moduleId) => ({ ...(store.get(moduleId) || {}) });
+
+/** Everything, as it would be sent with a generation request. */
+export const all = () => Object.fromEntries([...store].map(([id, v]) => [id, { ...v }]));
+
+export function onChange(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+}
+
+export function _reset() {
+ store.clear();
+ listeners.clear();
+}