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 + + + + + +
+

Composer elements

+
+ Tokens → + Hide-don't-warn + + + + +
+
+ + + 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 + + + + + +
+

Hide, don't warn

+
+ Tokens + Elements + + +
+
+ + + 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 + + + + + +
+

Composer catalogue

+
+ Elements → + Hide-don't-warn + + + + +
+
+ + + 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 + cd`); + assert.deepEqual(focusables(n).map((x) => x.textContent || x.tagName), ["a", "c"]); +}); + +test("focus moves into the container on trap", () => { + const n = box(""); + trap(n); + assert.equal(document.activeElement.textContent, "a"); +}); + +test("Tab wraps from last back to first", () => { + const n = box(""); + 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(""); + trap(n); + tab(n, true); + assert.equal(document.activeElement.textContent, "b"); +}); + +test("focus that escaped the container is pulled back", () => { + const outside = box(""); + const n = box(""); + 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("").querySelector("button"); + opener.focus(); + const n = box(""); + 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("").querySelector("button"); + opener.focus(); + const n = box(""); + 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(""); + 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(""); + 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(""); + 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(""); + 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(""); + 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