feat: app/ui — new frontend tree on the composer kit: frame offers regions, features load from one list and hide on failure; log is the first feature

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 17:58:48 +03:00
co-authored by Claude Sonnet 5.5
parent 23208f5468
commit 580fa5f94a
93 changed files with 10727 additions and 0 deletions
+8
View File
@@ -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 };
+84
View File
@@ -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); }
+255
View File
@@ -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);
});
+369
View File
@@ -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" },
],
};
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Composer elements</title>
<script src="/funpack/app/shell/theme.js"></script>
<link rel="stylesheet" href="/funpack/app/composer/composer.css">
<link rel="stylesheet" href="/funpack/app/catalogue/catalogue.css">
</head>
<body>
<header class="cat-bar">
<h1>Composer elements</h1>
<div class="cat-bar-spacer"></div>
<a class="cat-link" href="/funpack/app/catalogue/index.html">Tokens →</a>
<a class="cat-link" href="/funpack/app/catalogue/hide.html">Hide-don't-warn</a>
<button id="toggle-panes" aria-pressed="true">Both themes</button>
<button data-theme-set="dark">Dark</button>
<button data-theme-set="light">Light</button>
<button data-theme-set="auto">Auto</button>
</header>
<main class="cat-page" id="catalogue"></main>
<script type="module" src="/funpack/app/catalogue/elements.js"></script>
</body>
</html>
+120
View File
@@ -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);
+63
View File
@@ -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: "<b>no</b>" } } } },
},
{
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" } } },
},
];
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hide, don't warn</title>
<script src="/funpack/app/shell/theme.js"></script>
<link rel="stylesheet" href="/funpack/app/composer/composer.css">
<link rel="stylesheet" href="/funpack/app/catalogue/catalogue.css">
</head>
<body>
<header class="cat-bar">
<h1>Hide, don't warn</h1>
<div class="cat-bar-spacer"></div>
<a class="cat-link" href="/funpack/app/catalogue/index.html">Tokens</a>
<a class="cat-link" href="/funpack/app/catalogue/elements.html">Elements</a>
<button data-theme-set="dark">Dark</button>
<button data-theme-set="light">Light</button>
</header>
<main class="cat-page" id="catalogue"></main>
<script type="module" src="/funpack/app/catalogue/hide.js"></script>
</body>
</html>
+86
View File
@@ -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);
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Composer catalogue</title>
<script src="/funpack/app/shell/theme.js"></script>
<link rel="stylesheet" href="/funpack/app/composer/composer.css">
<link rel="stylesheet" href="/funpack/app/catalogue/catalogue.css">
</head>
<body>
<header class="cat-bar">
<h1>Composer catalogue</h1>
<div class="cat-bar-spacer"></div>
<a class="cat-link" href="/funpack/app/catalogue/elements.html">Elements &rarr;</a>
<a class="cat-link" href="/funpack/app/catalogue/hide.html">Hide-don't-warn</a>
<button id="toggle-panes" aria-pressed="true">Both themes</button>
<button data-theme-set="dark">Dark</button>
<button data-theme-set="light">Light</button>
<button data-theme-set="auto">Auto</button>
</header>
<main class="cat-page" id="catalogue"></main>
<script type="module" src="/funpack/app/catalogue/catalogue.js"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
<link rel="stylesheet" href="/funpack/app/composer/composer.css">
<link rel="stylesheet" href="/funpack/app/catalogue/catalogue.css">
<title>FunPack log</title>
<div id="app"></div>
<script src="/funpack/app/shell/theme.js"></script>
<script type="module" src="/funpack/app/catalogue/log.js"></script>
+70
View File
@@ -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();
+6
View File
@@ -0,0 +1,6 @@
<link rel="stylesheet" href="/funpack/app/composer/composer.css">
<link rel="stylesheet" href="/funpack/app/catalogue/catalogue.css">
<title>FunPack modules</title>
<div id="app"></div>
<script src="/funpack/app/shell/theme.js"></script>
<script type="module" src="/funpack/app/catalogue/modules.js"></script>
+85
View File
@@ -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();
+18
View File
@@ -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";
+16
View File
@@ -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";
+79
View File
@@ -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); } }
+142
View File
@@ -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(); },
};
});
+133
View File
@@ -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);
}
+271
View File
@@ -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(),
};
});
+270
View File
@@ -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;
});
+146
View File
@@ -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;
}
+330
View File
@@ -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 <img> 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(),
};
});
+20
View File
@@ -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";
+159
View File
@@ -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); }
+213
View File
@@ -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(),
};
});
+428
View File
@@ -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
<select> takes the width of its widest option, which is how one row in a
list of five ended up 124px wider than the rest. */
.cx-settings-control > * { max-inline-size: 100%; }
.cx-settings-control > .cx-select,
.cx-settings-control > .cx-input,
.cx-settings-control > .cx-textarea,
.cx-settings-control > .cx-slider { inline-size: 100%; }
.cx-settings-row.cx-danger .cx-settings-label { color: var(--danger); }
/* Below the width where a label and a control fit side by side, they stop
sitting side by side. The control column will not give up its width, so in a
docked panel the label was squeezed to one word per line and its hint to a
four-character column -- readable as damage, not as a layout. The container
is the ROWS, not the viewport: the same group is used in a 190px panel and in
a 600px window, and only its own width decides.
AFTER the rules it overrides, and it has to stay there: a container query
adds no specificity, so written above them it lost -- and `flex: 0 0 180px`
in a COLUMN is 180px of HEIGHT, which turned four settings into a panel two
screens long. */
/* 360, not 300: the control column is 180px and a label needs at least as much
again beside it. Written as 300 it depended on the padding around the rows --
trimming 4px off two nested boxes widened this container past the threshold
and the shredding came straight back, in a commit about something else. */
@container (max-width: 360px) {
.cx-settings-row { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
.cx-settings-control { flex: 0 0 auto; justify-content: flex-start; }
}
.cx-group { display: flex; flex-direction: column; gap: var(--sp-3); }
.cx-group-rows {
border: var(--bw) solid var(--line); border-radius: var(--r-4);
background: var(--elev-1);
/* Weighted to the left. A row's content -- a checkbox, a label, a stepper --
starts at the same edge every time, and sitting it hard against the border
made the whole card read as leaning left, because what is beside it on the
right is usually the shorter half of a wrapped line rather than a control.
A few pixels of extra lead-in settles it without costing the text a line. */
padding-inline: var(--sp-6) var(--sp-4);
container-type: inline-size;
}
.cx-panel {
border: var(--bw) solid var(--line); border-radius: var(--r-4);
background: var(--elev-1); overflow: hidden;
box-shadow: var(--shadow-1);
/* A zone is as wide as it is given, never as wide as its head wants to be. */
min-inline-size: 0;
/* A column, so a panel given a height passes it to its body. Without this a
viewer asking for 100% of its parent got 100% of `auto` and collapsed to
one line, inside a panel that was the right size around it. */
display: flex; flex-direction: column; min-block-size: 0;
}
/* Every panel head is the SAME height, whatever is in it. Padding-block plus a
min-height meant a head holding a control was 41px and a head holding only a
title was 34, so two panels side by side had their titles on different lines
-- the whole row read as if it had been assembled by hand. The height is the
band's; a control inside is centred in it. */
/* A zone is the app's own surface, not a card on it: no corners to round, no
shadow to cast, and no border of its own -- the hairline between it and the
next region belongs to the region, so two neighbours never draw two lines. */
.cx-zone { border: 0; border-radius: 0; box-shadow: none; }
.cx-panel-head {
display: flex; align-items: center; gap: var(--sp-2) var(--sp-5);
padding: var(--sp-1) var(--sp-6); border-bottom: var(--bw) solid var(--line);
background: var(--grad-band);
/* A MINIMUM height, and it wraps. Fixed, it could not shrink below the width
of everything in it -- and a head now holds a title, two or three controls
and a status. That min-content width became the whole centre column's, so a
768px window laid out 878px wide and scrolled sideways. v4 hit this and
landed in the same place: the right-hand block wraps, the head grows a
line, and nothing is ever clipped. */
min-block-size: var(--menubar-h); flex: none; flex-wrap: wrap;
min-inline-size: 0;
}
.cx-panel-title {
font-family: var(--font-display); font-size: var(--fs-3); font-weight: var(--fw-semibold);
display: flex; align-items: center; gap: var(--sp-3);
min-inline-size: 0; white-space: nowrap;
}
/* One accent mark per panel. Carried over from v4, where it is what makes a
head read as the top of something rather than as a strip of grey. */
.cx-panel-title::before {
content: ""; flex: none; inline-size: 5px; block-size: 5px;
border-radius: var(--r-pill); background: var(--accent);
}
/* Wraps, like the head around it. The head was made to wrap when a zone got
narrow, but the group of controls inside it was still one unbreakable row --
so the head's min-content was the whole row, and a 768px window laid out
860px wide and scrolled sideways. */
.cx-panel-actions { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; min-inline-size: 0; }
/* Whatever the zone has to say, at the far end of its own head: a count, a
duration, where a run has got to. Quiet by default -- it is read at a glance
and never pressed. */
.cx-panel-status {
margin-inline-start: auto; display: flex; align-items: center; gap: var(--sp-4);
min-inline-size: 0; font-size: var(--fs-6); color: var(--faint);
}
/* A column with a gap, not a block: two things put in a body by two different
parts of the app had whatever space their own line boxes happened to leave,
which is how a prompt box ended up 8px under one label and 3px under the
next. The body decides the rhythm; what goes in it brings nothing. */
.cx-panel-body {
padding: var(--sp-6); flex: 1 1 auto; min-block-size: 0; overflow: auto;
/* Both axes. A body is a scroller, and a scroller that cannot shrink below
its content is not one -- it hands the width up to the zone, the column and
the window, which then scrolls sideways as a page. */
min-inline-size: 0;
display: flex; flex-direction: column; gap: var(--sp-5);
}
/* One child fills the body -- a viewer, a bin. Several are each their own
height and the body scrolls, which is what a panel of controls does. */
.cx-panel-body > .cx-viewer, .cx-panel-body > .cx-stack-fill { flex: 1 1 auto; }
.cx-panel-flush > .cx-panel-body { padding: 0; }
.cx-toolbar { display: flex; align-items: center; gap: var(--sp-2); min-inline-size: 0; }
.cx-toolbar-trailing {
margin-inline-start: auto; display: flex; align-items: center; gap: var(--sp-3);
font-size: var(--fs-6); color: var(--muted);
}
.cx-action-bar {
display: flex; align-items: center; gap: var(--sp-6);
padding: var(--sp-5) var(--gutter);
border-top: var(--bw) solid var(--line); background: var(--grad-band);
position: sticky; bottom: 0;
}
/* What the app is SAYING sits at the start of the row and what is PRESSED at
the end. Everything shared one right-aligned group before, so "Ready" sat
against the Generate button with 1300px of empty bar to its left. */
.cx-action-bar-lead { display: flex; align-items: center; gap: var(--sp-5); min-inline-size: 0; }
.cx-action-bar-buttons { display: flex; align-items: center; gap: var(--sp-3); margin-inline-start: auto; }
.cx-list-wrap { display: flex; flex-direction: column; gap: var(--sp-3); align-items: flex-start; }
.cx-list {
width: 100%; border: var(--bw) solid var(--line); border-radius: var(--r-3);
background: var(--elev-1); overflow: hidden;
}
.cx-list-row {
display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-3) var(--sp-5); border-bottom: var(--bw) solid var(--line-2);
}
.cx-list-row:last-child { border-bottom: 0; }
.cx-list-label { font-size: var(--fs-4); flex: 1; min-width: 0; }
.cx-list-hint { font-size: var(--fs-7); color: var(--muted); }
.cx-list-control { flex: none; width: 92px; }
/* Separated from the row's own control by a rule, so the arrows read as
* "reorder this row" rather than "adjust that number". */
.cx-list-actions {
display: flex; gap: var(--sp-1); flex: none;
padding-left: var(--sp-4); margin-left: var(--sp-2);
border-left: var(--bw) solid var(--line-2);
}
.cx-list-move { color: var(--faint); }
.cx-list-move:hover:not(:disabled) { color: var(--accent); }
.cx-list-empty { padding: var(--sp-6); font-size: var(--fs-6); color: var(--faint); text-align: center; }
.cx-tabs { display: flex; gap: var(--sp-1); }
.cx-tab {
border: 0; background: transparent; color: var(--muted);
cursor: pointer; font-family: var(--font-ui); font-size: var(--fs-5);
transition: color var(--dur-1) var(--ease-standard);
}
.cx-tabs-underline { border-bottom: var(--bw) solid var(--line); gap: var(--sp-5); }
.cx-tabs-underline .cx-tab {
padding: var(--sp-3) 0; border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.cx-tabs-underline .cx-tab.cx-on { color: var(--text); border-bottom-color: var(--accent); }
.cx-tabs-dock .cx-tab {
height: var(--ctl-h-md); padding: 0 var(--ctl-pad-x-md);
border: var(--bw) solid var(--line); border-radius: var(--r-3);
font-size: var(--fs-6);
}
.cx-tabs-dock .cx-tab.cx-on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.cx-tab:hover { color: var(--text); }
.cx-split { display: flex; width: 100%; min-height: 120px; min-inline-size: 0; }
.cx-split-v { flex-direction: column; }
/* A column, so what is inside a pane inherits the pane's height instead of
sizing to its own content. A panel in a pane was the right size with a body
one line tall inside it, which looks like the panel is fine and the content
is empty. */
.cx-split-pane {
flex: 1 1 0; min-inline-size: 0; min-block-size: 0; overflow: auto;
display: flex; flex-direction: column;
}
.cx-split-pane > * { flex: 1 1 auto; min-block-size: 0; }
.cx-split > .cx-split-pane:first-child { flex: 0 0 auto; }
/* A horizontal split stacks rather than overflowing when there is not room for
two columns. The first pane carries an inline size, so on a narrow screen it
could not shrink and the whole page scrolled sideways -- 407px of content in
a 360px viewport. Wrapping rather than a media query because the kit sizes
itself from its container, not from the window. */
.cx-split-h { flex-wrap: wrap; }
.cx-split-h > .cx-split-pane { min-inline-size: min(100%, 260px); }
.cx-split-h > .cx-split-pane:first-child { max-inline-size: 100%; }
.cx-split-bar { flex: none; background: transparent; position: relative; }
.cx-split-h > .cx-split-bar { width: var(--gutter); cursor: col-resize; }
.cx-split-v > .cx-split-bar { height: var(--gutter); cursor: row-resize; }
.cx-split-bar::after {
content: ""; position: absolute; inset: 0; margin: auto;
background: var(--line); transition: background var(--dur-1) var(--ease-standard);
}
.cx-split-h > .cx-split-bar::after { width: 1px; }
.cx-split-v > .cx-split-bar::after { height: 1px; }
.cx-split-bar:hover::after, .cx-split-bar:focus-visible::after { background: var(--accent); }
.cx-split-bar:focus-visible { outline: none; }
/* The same radius as a panel: these stand side by side in the properties
column, and two different corner radii on two boxes in one column reads as
two different kinds of thing. */
.cx-collapsible { border: var(--bw) solid var(--line); border-radius: var(--r-4); background: var(--elev-1); }
.cx-collapsible-head {
display: flex; align-items: center; gap: var(--sp-3);
padding: 0 var(--sp-6); block-size: var(--menubar-h); cursor: pointer; list-style: none;
font-size: var(--fs-5); background: var(--grad-band);
border-radius: calc(var(--r-4) - var(--bw));
}
.cx-collapsible[open] > .cx-collapsible-head {
border-end-start-radius: 0; border-end-end-radius: 0;
border-block-end: var(--bw) solid var(--line);
}
.cx-collapsible-head::-webkit-details-marker { display: none; }
.cx-collapsible-head::before {
content: "›"; color: var(--faint); display: inline-block;
transition: transform var(--dur-1) var(--ease-standard);
}
.cx-collapsible[open] > .cx-collapsible-head::before { transform: rotate(90deg); }
/* Its own top padding, not the head's. With none, the first thing in the body
-- usually a section label -- sat directly on the head's dividing line, which
read as the label belonging to the divider rather than to what is under it.
Narrower at the sides than at the top: this is the third box a setting's text
is nested inside, and each level of padding comes out of the words. */
.cx-collapsible-body { padding: var(--sp-6) var(--sp-4) var(--sp-5); }
.cx-rail { display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-2); }
.cx-rail-item {
display: flex; align-items: center; gap: var(--sp-4);
height: var(--ctl-h-xl); padding: 0 var(--sp-4);
border: 0; border-radius: var(--r-3); background: transparent;
color: var(--muted); font-size: var(--fs-5); cursor: pointer; text-align: left;
}
.cx-rail-item:hover { background: var(--hover); color: var(--text); }
.cx-rail-item.cx-on { background: var(--accent-soft); color: var(--accent-hi); }
.cx-rail-icon { width: 18px; text-align: center; }
/* Workspace: a centre that shrinks, panels docked either side.
The rails hold the toggles and never collapse, so the control for a panel is
never inside the panel it controls. */
.cx-workspace {
display: flex; align-items: stretch; min-block-size: 0; block-size: 100%; inline-size: 100%;
}
/* Flex containers, not blocks with overflow. A region whose height comes from
its content gives a vertical split inside it a basis of `auto`, so the split's
second pane resolves to zero and simply is not there -- the prompt under the
preview vanished exactly that way, with no error and nothing to see. */
.cx-workspace-main {
flex: 1 1 auto; min-inline-size: 0; min-block-size: 0;
display: flex; flex-direction: column;
background: var(--elev-1);
}
.cx-workspace-main > *, .cx-workspace-side > * { flex: 1 1 auto; min-block-size: 0; }
.cx-workspace-side {
/* A width the CONTENT can live in, not a share of the window. Sizing these
as a fraction of the viewport gave a 900px-wide window a 198px column, in
which "Loosen the starting image" wrapped over three lines and a section
heading broke mid-word. A panel is as wide as a settings row needs; when
the window cannot afford two of them it stops docking them (below). */
flex: 0 0 auto;
inline-size: clamp(268px, 24vw, 360px);
min-block-size: 0;
overflow: auto;
display: flex; flex-direction: column;
background: var(--elev-1);
/* The divider between one region and the next. One hairline, the same one a
splitter draws, so the app reads as a single surface that has been cut up
rather than as three things put next to each other. */
border-inline: var(--bw) solid var(--line);
}
/* Width nil rather than display:none -- the panel stays in the flow, stays
measurable, and can come back without being rebuilt.
Deliberately not animated. A remembered-closed panel would animate shut on
every page load, which reads as the app doing something rather than as a
remembered preference, and an in-flight width is a state anything measuring
the layout has to wait out. */
.cx-workspace-side.cx-collapsed {
inline-size: 0;
overflow: hidden;
border-inline-width: 0;
}
/* The toggle sits in the head band's height, so it lines up with the panel
titles either side of it instead of floating a few pixels above them like a
mark left on the glass. */
/* The rails are the frame's own edge, so they carry the page colour rather than
a zone's -- the one strip of background the app shows. */
.cx-workspace-rail {
flex: 0 0 auto;
display: flex;
align-items: flex-start;
justify-content: center;
background: var(--elev-0);
padding-inline: var(--sp-1);
}
.cx-workspace-rail > * { block-size: var(--menubar-h); }
/* The page frame. The one place a definite height is established, so everything
below can fill instead of each pane guessing at a percentage of nothing. */
.cx-frame { display: flex; flex-direction: column; block-size: 100%; min-block-size: 0; }
/* Film grain over the app, and only over the app: no z-index, so anything that
opens above the frame -- a modal, a popover -- paints over it and stays
crisp. Fixed, so it does not scroll and cannot be captured by a scroller's
own stacking context. */
.cx-frame::after {
content: ""; position: fixed; inset: 0; pointer-events: none;
opacity: var(--grain-opacity); mix-blend-mode: var(--grain-blend);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
.cx-frame-main { flex: 1 1 auto; min-block-size: 0; min-inline-size: 0; display: flex; }
.cx-frame-main > * { flex: 1 1 auto; min-inline-size: 0; }
.cx-frame-head, .cx-frame-foot { flex: 0 0 auto; }
/* Bands: edge to edge, the same height as a panel head, with the gutter inside
them. So the top bar reads as part of the frame rather than as a stray
button floating above the first panel. */
.cx-frame-head {
display: flex; align-items: center;
block-size: var(--menubar-h); padding-inline: var(--gutter);
border-block-end: var(--bw) solid var(--line); background: var(--grad-band);
}
.cx-frame-head > * { flex: 1 1 auto; min-inline-size: 0; }
/* Not enough room to dock. Below this the two panels plus a usable centre do
not fit -- three columns in 375px gave clipped headings and a preview no
wider than a thumbnail -- so a panel stops taking space from the centre and
sits over it instead. The rails do not move, so the way in and out is the
same control in the same place at every size.
The element closes both panels when it enters this mode and opens only one
at a time within it, because two overlays over a narrow centre is the same
problem with extra steps. What was open before is restored on the way out,
and none of that touches the remembered preference -- a window being small
is not the user changing their mind.
This is also what makes a phone possible later rather than a rewrite: the
difference between docked and overlaid is where the panel is drawn, not what
it is or how it is opened. */
@media (max-width: 760px) {
.cx-workspace { position: relative; }
.cx-workspace-side {
position: absolute;
inset-block: 0;
z-index: 1;
inline-size: min(86vw, 360px);
background: var(--elev-1);
box-shadow: var(--shadow-3);
}
.cx-workspace-left { inset-inline-start: 0; }
.cx-workspace-right { inset-inline-end: 0; }
/* Above the overlay, or the panel covers the only control that closes it --
which is the whole fault this arrangement was built to avoid, arriving by a
different route the moment the panel stopped taking its own space. */
.cx-workspace-rail { position: relative; z-index: 2; }
/* A collapsed overlay must not keep catching pointers over the centre. */
.cx-workspace-side.cx-collapsed { inline-size: 0; box-shadow: none; }
}
/* region.stack -- the app's redrawable host. Spacing only: what goes in it
brings its own everything else. */
/* min-inline-size on the stack ITSELF, not only on what is in it. A flex item's
automatic minimum is its content, so a stack holding anything that scrolls --
a strip of scenes, a wide table -- handed its content's full width up to
whatever contains it, and the page scrolled sideways instead of the scroller
scrolling. The rule below covers a stack's children; this covers the stack.
No min-block-size:0 here, on purpose: that would let a plain grouping stack
-- rows of fields nested inside a scrollable panel -- get flex-shrunk below
its own content height the moment its container runs short on room, and
since nothing here clips it, the excess paints over the next row instead of
the panel scrolling. .cx-stack-fill below is the one stack meant to give up
height for that; a stack that hasn't opted into that must keep its natural
size so a tight column scrolls at the real scroll boundary instead of
silently overlapping. */
.cx-stack { display: flex; flex-direction: column; min-inline-size: 0; }
.cx-stack > * { min-inline-size: 0; }
.cx-stack-none { gap: 0; }
.cx-stack-sm { gap: var(--sp-4); }
.cx-stack-md { gap: var(--sp-6); }
.cx-stack-lg { gap: var(--sp-9); }
/* Filling the room it was given, rather than sizing to its content. Without a
definite height nothing inside can scroll: the whole page scrolls instead,
and a bar meant to hold the bottom scrolls away with it. */
.cx-stack-fill { flex: 1 1 auto; min-block-size: 0; }
/* A split inside a stack is what stretches. The rows around it -- a toolbar, a
banner, an action bar -- are each their own height. */
.cx-stack > .cx-split { flex: 1 1 auto; min-block-size: 0; }
/* A stack that is a small poster rather than a form -- an "About" pane's
mark, name and facts, centred instead of ranged left like every settings
list around it. */
.cx-about { align-items: center; text-align: center; padding-block: var(--sp-7) 0; }
/* v4's own mark size, from the scale (--fs-d1) rather than a literal --
the whole point of naming display steps at all. */
.cx-about > .cx-t-lg { font-size: var(--fs-d1); line-height: var(--lh-tight); color: var(--accent); }
.cx-about .cx-settings-row {
justify-content: center; gap: var(--sp-4); border-bottom: 0; padding-block: var(--sp-2);
inline-size: auto; align-self: center;
}
.cx-about .cx-settings-control { flex: 0 0 auto; justify-content: flex-start; }
/* The fact key ("Chip", "Memory", ...) reads as a label, not a value. */
.cx-about .cx-settings-label { font-weight: var(--fw-semibold); }
+495
View File
@@ -0,0 +1,495 @@
// Containers.
//
// These take other elements' HANDLES, never raw nodes: a handle is something the
// kit made, and insisting on one is what stops "just let me pass a div" becoming
// the hole through which modules build their own markup.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { setText } from "../internals/text.js";
import { uid } from "../internals/ids.js";
import { drag } from "../internals/drag.js";
import { roving } from "../internals/focus.js";
function nodeOf(control, where) {
if (!control) return null;
if (control.node && control.node.nodeType === 1) return control.node;
throw new TypeError(
`${where} takes a Composer element handle, not a ${control.nodeType === 1 ? "raw DOM node" : typeof control}. ` +
"Build the control with composer.<group>.<variant>() and pass what it returns."
);
}
/** Label above a control, with an optional hint and error beneath. */
define("field", "default", ({ label, hint, control, error } = {}) => {
const id = uid("field");
const hintId = hint ? uid("hint") : null;
const inner = nodeOf(control, "field.default");
if (inner && !inner.id) inner.id = id;
if (inner && hintId) inner.setAttribute("aria-describedby", hintId);
const node = el("div", { cls: "cx-field", children: [
label ? el("label", { cls: "cx-label", text: label, attrs: { for: inner ? inner.id : undefined } }) : null,
inner,
hint ? el("p", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null,
error ? el("p", { cls: "cx-field-error", text: error, attrs: { role: "alert" } }) : null,
].filter(Boolean) });
return { node, control, destroy: () => node.remove() };
});
define("field", "row", ({ fields = [] } = {}) =>
({ node: el("div", { cls: "cx-field-row", children: fields.map((f) => nodeOf(f, "field.row")) }),
destroy() { this.node.remove(); } }));
/** Title and hint on the left, control on the right. The settings-window row. */
define("settingsRow", "default", ({ label, hint, control, danger } = {}) => {
const inner = nodeOf(control, "settingsRow.default");
const hintId = hint ? uid("hint") : null;
if (inner && hintId) inner.setAttribute("aria-describedby", hintId);
const node = el("div", { cls: ["cx-settings-row", danger ? "cx-danger" : null], children: [
el("div", { cls: "cx-settings-text", children: [
el("div", { cls: "cx-settings-label", text: label }),
hint ? el("p", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null,
].filter(Boolean) }),
inner ? el("div", { cls: "cx-settings-control", children: inner }) : null,
].filter(Boolean) });
return { node, destroy: () => node.remove() };
});
define("group", "default", ({ label, rows = [], hint } = {}) => {
const node = el("section", { cls: "cx-group", children: [
label ? el("div", { cls: "cx-eyebrow", text: label }) : null,
hint ? el("p", { cls: "cx-hint", text: hint }) : null,
el("div", { cls: "cx-group-rows", children: rows.map((r) => nodeOf(r, "group.default")) }),
].filter(Boolean) });
return { node, destroy: () => node.remove() };
});
/**
* A titled box. Two of them, and the difference is only where they are used.
*
* `default` is a card: rounded, bordered, with a shadow, for a panel that
* floats on something -- inside a window, on a page.
*
* `zone` is a REGION OF THE APP: square, edge to edge, divided from its
* neighbours by the same hairline everything else uses. The app is one surface
* cut into areas, not a tray of cards with the desk showing between them --
* which is what an editing suite looks like, and what v4 was.
*/
const panel = (variant) => ({ title, actions = [], status = [], body, flush = false } = {}) => {
// Title, then what ACTS on this zone, then -- pushed to the far end -- what
// this zone has to SAY about itself. That order is the whole difference
// between an editing suite and a dashboard: the controls for a region live
// in that region's own head, next to the thing they change, instead of in one
// bar at the bottom that acts on whatever happens to be in front.
const head = (title || actions.length || status.length)
? el("header", { cls: "cx-panel-head", children: [
title ? el("h2", { cls: "cx-panel-title", text: title }) : null,
actions.length ? el("div", { cls: "cx-panel-actions", children: actions.map((a) => nodeOf(a, `panel.${variant}`)) }) : null,
status.length ? el("div", { cls: "cx-panel-status", children: status.map((a) => nodeOf(a, `panel.${variant} status`)) }) : null,
].filter(Boolean) })
: null;
const title_ = head && head.querySelector(".cx-panel-title");
const content = el("div", { cls: "cx-panel-body", children: body ? nodeOf(body, `panel.${variant}`) : null });
// `flush` is for a body that IS the content -- a picture, a video, a canvas.
// Padding around one of those is a frame nobody asked for, and the thing
// being looked at is the reason the zone exists.
const node = el("section", { cls: ["cx-panel", variant === "zone" ? "cx-zone" : null,
flush ? "cx-panel-flush" : null],
children: [head, content].filter(Boolean) });
return {
node, body: content,
/** A zone that is ABOUT something says which: "Scene · 2", not "Scene". */
setTitle(next) { if (title_) setText(title_, next); },
destroy: () => node.remove(),
};
};
define("panel", "default", panel("default"));
define("panel", "zone", panel("zone"));
// A region whose contents are replaced.
//
// Every part of the app that redraws needs one host it can empty and refill.
// Without it the shell reaches for the document to make a div of its own, and a
// div the shell made is a div the shell has to lay out -- which is the one thing
// that is supposed to live only here.
define("region", "stack", ({ children = [], gap = "md", label, fill = false } = {}) => {
const node = el("div", { cls: ["cx-stack", `cx-stack-${gap}`, fill ? "cx-stack-fill" : null],
attrs: { "aria-label": label } });
const set = (next = []) => {
// Handles, not nodes, like everywhere else -- so a caller cannot slip a
// document node in through the one element that takes children late.
node.replaceChildren(...next.filter(Boolean).map((c) => nodeOf(c, "region.stack")));
};
set(children);
return { node, set, destroy: () => node.remove() };
});
/** A bar has a start and an end: what you press, and what it tells you. */
define("toolbar", "default", ({ items = [], trailing = [], label } = {}) =>
({ node: el("div", { cls: "cx-toolbar", attrs: { role: "toolbar", "aria-label": label },
children: [
...items.map((i) => nodeOf(i, "toolbar.default")),
trailing.length
? el("div", { cls: "cx-toolbar-trailing",
children: trailing.map((i) => nodeOf(i, "toolbar.default trailing")) })
: null,
].filter(Boolean) }),
destroy() { this.node.remove(); } }));
/**
* The row a thing is started from: what is being SAID at the start of it, what
* is PRESSED at the end.
*
* `lead` exists because everything used to go in `actions`, which is
* right-aligned as a group -- so a status line sat against the button with the
* whole width of the bar empty to its left, and read as part of the button.
*/
define("actionBar", "sticky", ({ lead = [], actions = [], note } = {}) =>
({ node: el("div", { cls: "cx-action-bar", children: [
el("div", { cls: "cx-action-bar-lead", children: [
note ? el("p", { cls: "cx-hint", text: note }) : null,
...lead.map((a) => nodeOf(a, "actionBar.sticky lead")),
].filter(Boolean) }),
el("div", { cls: "cx-action-bar-buttons", children: actions.map((a) => nodeOf(a, "actionBar.sticky")) }),
] }),
destroy() { this.node.remove(); } }));
/** A reorderable list of rows: LoRA stacks, text encoders, anything repeatable. */
define("list", "rows", ({ items = [], reorder = false, onRemove, onAdd, onReorder, addLabel = "Add", empty = "Nothing here yet" } = {}) => {
const body = el("div", { cls: "cx-list" });
function draw() {
body.replaceChildren();
if (!items.length) {
body.append(el("p", { cls: "cx-list-empty", text: empty }));
}
items.forEach((item, index) => {
const controls = [];
if (reorder) {
// "Move up/down", not bare arrows: next to a numeric control, an
// unlabelled ▲▼ pair reads as increment/decrement.
controls.push(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-list-move", "cx-focusable"], text: "▲",
attrs: { type: "button", title: `Move ${item.label} up`,
"aria-label": `Move ${item.label} up`, disabled: index === 0 },
on: { click: () => move(index, index - 1) } }));
controls.push(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-list-move", "cx-focusable"], text: "▼",
attrs: { type: "button", title: `Move ${item.label} down`,
"aria-label": `Move ${item.label} down`, disabled: index === items.length - 1 },
on: { click: () => move(index, index + 1) } }));
}
if (onRemove) {
controls.push(el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"], text: "✕",
attrs: { type: "button", "aria-label": `Remove ${item.label}` },
on: { click: () => onRemove(item, index) } }));
}
// A row's control is a real element -- a LoRA's weight is edited here,
// not implied by a number printed beside the reorder arrows, which reads
// as a stepper for that number and is not one.
const control = item.control ? nodeOf(item.control, "list.rows item.control") : null;
body.append(el("div", { cls: "cx-list-row", children: [
el("span", { cls: "cx-list-label", text: item.label }),
item.hint ? el("span", { cls: "cx-list-hint", text: item.hint }) : null,
control ? el("div", { cls: "cx-list-control", children: control }) : null,
controls.length ? el("div", { cls: "cx-list-actions", children: controls }) : null,
].filter(Boolean) }));
});
}
function move(from, to) {
if (to < 0 || to >= items.length) return;
const [moved] = items.splice(from, 1);
items.splice(to, 0, moved);
draw();
if (onReorder) onReorder([...items]);
}
draw();
const foot = onAdd
? el("button", { cls: ["cx-btn", "cx-btn-sm", "cx-btn-ghost", "cx-focusable"], text: `+ ${addLabel}`,
attrs: { type: "button" }, on: { click: onAdd } })
: null;
const node = el("div", { cls: "cx-list-wrap", children: [body, foot].filter(Boolean) });
return { node, redraw: draw, destroy: () => node.remove() };
});
for (const kind of ["underline", "dock"]) {
define("tabs", kind, ({ tabs = [], value, onChange, label } = {}) => {
let current = value ?? tabs[0]?.value;
const node = el("div", { cls: ["cx-tabs", `cx-tabs-${kind}`], attrs: { role: "tablist", "aria-label": label } });
const buttons = tabs.map((tab) => {
const b = el("button", { cls: ["cx-tab", "cx-focusable"], text: tab.label,
attrs: { type: "button", role: "tab", "aria-selected": String(tab.value === current) } });
b.classList.toggle("cx-on", tab.value === current);
b.addEventListener("click", () => { current = tab.value; sync(); if (onChange) onChange(current); });
node.append(b);
return { tab, b };
});
function sync() {
for (const { tab, b } of buttons) {
const on = tab.value === current;
b.classList.toggle("cx-on", on);
b.setAttribute("aria-selected", String(on));
}
}
const stopRoving = roving(node);
return {
node,
get value() { return current; },
setValue(v) { current = v; sync(); },
destroy() { stopRoving(); node.remove(); },
};
});
}
for (const axis of ["h", "v"]) {
define("splitPane", axis, ({ panes = [], size = 50, min = 10, onResize, label } = {}) => {
const horizontal = axis === "h";
const node = el("div", { cls: ["cx-split", `cx-split-${axis}`] });
const first = el("div", { cls: "cx-split-pane", children: nodeOf(panes[0], "splitPane") });
const second = el("div", { cls: "cx-split-pane", children: nodeOf(panes[1], "splitPane") });
const bar = el("div", { cls: "cx-split-bar",
attrs: { role: "separator", tabindex: "0", "aria-label": label || "Resize",
"aria-orientation": horizontal ? "vertical" : "horizontal",
"aria-valuenow": String(size), "aria-valuemin": String(min), "aria-valuemax": String(100 - min) } });
let current = size;
const apply = (value) => {
current = Math.min(100 - min, Math.max(min, value));
first.style.flexBasis = `${current}%`;
bar.setAttribute("aria-valuenow", String(Math.round(current)));
if (onResize) onResize(current);
};
apply(size);
let start = 0;
const dispose = drag(bar, {
onStart: () => { start = current; },
onMove: ({ dx, dy }) => {
const box = node.getBoundingClientRect();
const span = horizontal ? box.width : box.height;
if (!span) return;
apply(start + ((horizontal ? dx : dy) / span) * 100);
},
});
// A splitter that only responds to a mouse is unusable without one.
bar.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 10 : 2;
if (e.key === (horizontal ? "ArrowLeft" : "ArrowUp")) { e.preventDefault(); apply(current - step); }
if (e.key === (horizontal ? "ArrowRight" : "ArrowDown")) { e.preventDefault(); apply(current + step); }
});
node.append(first, bar, second);
return {
node,
get value() { return current; },
setValue: apply,
destroy() { dispose(); node.remove(); },
};
});
}
// The page: one region that fills whatever is left, and a bar that does not.
//
// Height has to be established SOMEWHERE, and the honest place is one element
// rather than a percentage on each pane hoping its parent is definite. Without
// it a vertical split resolves its basis against content, and the app renders
// as a short strip at the top of an empty window -- which is exactly what the
// first build of this frame did.
define("frame", "app", ({ header, main, footer } = {}) => {
const head = header
? el("div", { cls: "cx-frame-head", children: nodeOf(header, "frame.app") })
: null;
const body = el("div", { cls: "cx-frame-main",
children: main ? nodeOf(main, "frame.app") : null });
const foot = footer
? el("div", { cls: "cx-frame-foot", children: nodeOf(footer, "frame.app") })
: null;
const node = el("div", { cls: "cx-frame", children: [head, body, foot].filter(Boolean) });
return { node, header: head, main: body, footer: foot, destroy: () => node.remove() };
});
// The app's own shape: a centre that shrinks, with panels docked either side.
//
// The toggles live in RAILS, outside the panels they open. v4 put the control
// inside the region it controlled and then hid the region -- a collapsed column
// was marked [hidden], which is display:none, and no transform slides a
// display:none element into view, so the button did nothing at all and said
// nothing. A control that can be hidden by the thing it operates is not a
// control, so here the rail is a sibling of the panel and never moves.
define("workspace", "docked", ({
id = "workspace", centre, left, right,
leftLabel = "Assets", rightLabel = "Properties",
leftOpen = true, rightOpen = true, onToggle, rails = true,
} = {}) => {
const node = el("div", { cls: "cx-workspace" });
// window.localStorage, not the bare global: they are the same object in a
// browser, and only the explicit one exists under jsdom -- so persistence is
// testable where the tests are cheap instead of only in the browser pass.
const remember = (side, open) => {
try { window.localStorage.setItem(`cx.ws.${id}.${side}`, open ? "1" : "0"); } catch { /* private mode */ }
};
const recall = (side, fallback) => {
try {
const v = window.localStorage.getItem(`cx.ws.${id}.${side}`);
return v === null ? fallback : v === "1";
} catch { return fallback; }
};
const state = { left: recall("left", leftOpen), right: recall("right", rightOpen) };
// What the user last chose, kept apart from what is currently on screen.
//
// `state` is mutated by the one-at-a-time rule below, so it stops being a
// record of anyone's preference the moment a window is narrow. Snapshotting
// it there recorded a panel the RULE had just closed as a panel the user
// wanted closed -- so a page loaded at a narrow width came back from being
// widened with the right panel shut, and nothing the user did would explain
// why.
const wanted = { ...state };
const panels = {};
const toggles = {};
// Below this the panels overlay the centre instead of docking beside it, so
// only one may be open and neither starts open. The CSS decides how a panel
// is drawn; this decides what is open, and the two have to agree on the
// width, which is why it is written once here and matched there.
const NARROW = "(max-width: 760px)";
const narrow = typeof window.matchMedia === "function" ? window.matchMedia(NARROW) : null;
const isNarrow = () => Boolean(narrow && narrow.matches);
let wide = null; // what was open before the window shrank
const side = (which, label, content) => {
const panelId = uid(`ws-${which}`);
const panel = el("div", { cls: ["cx-workspace-side", `cx-workspace-${which}`],
attrs: { id: panelId, "aria-label": label },
children: content ? nodeOf(content, "workspace.docked") : null });
const button = el("button", { cls: ["cx-icon-btn", "cx-icon-btn-sm", "cx-focusable"],
text: which === "left" ? "▎" : "▐",
attrs: { type: "button", "aria-controls": panelId, "aria-label": label,
"aria-expanded": String(state[which]) },
on: { click: () => set(which, !state[which]) } });
// The rail is one glyph on the outer edge of the window. It is the fallback,
// not the design: where the app has somewhere to put a NAMED toggle, it
// passes rails:false and drives open/close through this handle instead --
// "▎" on an edge is a control whose meaning has to be discovered.
const rail = rails
? el("div", { cls: ["cx-workspace-rail", `cx-workspace-rail-${which}`], children: button })
: null;
panels[which] = panel;
toggles[which] = button;
return { panel, rail };
};
function set(which, open, { remember: keep = true } = {}) {
state[which] = Boolean(open);
// One at a time while overlaid: two panels over a narrow centre is the same
// problem the docking rule was avoiding, with an extra step.
if (state[which] && isNarrow()) {
const other = which === "left" ? "right" : "left";
if (state[other]) set(other, false, { remember: false });
}
panels[which].classList.toggle("cx-collapsed", !state[which]);
// Not [hidden] and not display:none: a panel with no width is out of the
// way and still animatable, and its toggle is in the rail either way.
panels[which].setAttribute("aria-hidden", String(!state[which]));
toggles[which].setAttribute("aria-expanded", String(state[which]));
// A window being small is not the user changing their mind, so an automatic
// close does not overwrite what they last chose -- in storage or here.
if (keep) {
wanted[which] = state[which];
remember(which, state[which]);
}
if (onToggle) onToggle(which, state[which]);
}
function fit() {
if (isNarrow()) {
if (wide === null) wide = { ...wanted };
set("left", false, { remember: false });
set("right", false, { remember: false });
} else if (wide) {
const was = wide;
wide = null;
set("left", was.left, { remember: false });
set("right", was.right, { remember: false });
}
}
const main = el("div", { cls: "cx-workspace-main",
children: centre ? nodeOf(centre, "workspace.docked") : null });
const l = side("left", leftLabel, left);
const r = side("right", rightLabel, right);
node.append(...[l.rail, l.panel, main, r.panel, r.rail].filter(Boolean));
set("left", state.left, { remember: false });
set("right", state.right, { remember: false });
fit();
if (narrow && narrow.addEventListener) narrow.addEventListener("change", fit);
return {
node,
// Hosts, so the shell mounts INTO the regions rather than rebuilding them.
get left() { return panels.left; },
get right() { return panels.right; },
get centre() { return main; },
isOpen: (which) => Boolean(state[which]),
open: (which) => set(which, true),
close: (which) => set(which, false),
toggle: (which) => set(which, !state[which]),
narrow: isNarrow,
destroy() {
if (narrow && narrow.removeEventListener) narrow.removeEventListener("change", fit);
node.remove();
},
};
});
define("collapsible", "default", ({ label, open = false, body, hint } = {}) => {
const node = el("details", { cls: "cx-collapsible", attrs: { open: open || undefined } });
const summary = el("summary", { cls: ["cx-collapsible-head", "cx-focusable"], children: [
el("span", { cls: "cx-collapsible-label", text: label }),
hint ? el("span", { cls: "cx-hint", text: hint }) : null,
].filter(Boolean) });
node.append(summary, el("div", { cls: "cx-collapsible-body", children: body ? nodeOf(body, "collapsible.default") : null }));
return {
node,
get value() { return node.open; },
setValue(v) { node.open = Boolean(v); },
destroy: () => node.remove(),
};
});
define("sidebar", "rail", ({ items = [], value, onChange, label } = {}) => {
let current = value ?? items[0]?.value;
const node = el("nav", { cls: "cx-rail", attrs: { "aria-label": label } });
const buttons = items.map((item) => {
const b = el("button", { cls: ["cx-rail-item", "cx-focusable"], attrs: { type: "button" }, children: [
el("span", { cls: "cx-rail-icon", text: item.icon, attrs: { "aria-hidden": "true" } }),
el("span", { cls: "cx-rail-label", text: item.label }),
] });
b.classList.toggle("cx-on", item.value === current);
b.addEventListener("click", () => {
current = item.value;
for (const { item: i, b: other } of buttons) other.classList.toggle("cx-on", i.value === current);
if (onChange) onChange(current);
});
node.append(b);
return { item, b };
});
return { node, get value() { return current; }, destroy: () => node.remove() };
});
+215
View File
@@ -0,0 +1,215 @@
// Modals.
//
// v4 built this shell by hand in eleven files, each repeating its own backdrop
// click, close button and Escape handling -- and getting Escape wrong when one
// opened inside another. Here the shell is one thing, and the stacking and
// dismissal are the internals' problem.
//
// Every overlay handle carries isOverlay, because it mounts itself into the
// portal instead of being appended by its caller.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { setText } from "../internals/text.js";
import { uid } from "../internals/ids.js";
import { mount, unmount } from "../internals/portal.js";
import { claimFor } from "../internals/zlayer.js";
import { push } from "../internals/dismiss.js";
import { trap } from "../internals/focus.js";
const SIZES = { sm: "360px", md: "520px", lg: "720px", xl: "960px" };
function nodeOf(control, where) {
if (!control) return null;
if (control.node && control.node.nodeType === 1) return control.node;
throw new TypeError(`${where} takes a Composer element handle, not a ${typeof control}.`);
}
/**
* open({ title, subtitle, size, body, actions, onClose, closeOnOutside, stacked })
* Shared by every modal variant below.
*/
function open({
title, subtitle, size = "md", body, actions = [], onClose,
closeOnOutside = true, stacked = false, describedBy,
} = {}) {
const titleId = uid("modal-title");
const card = el("div", {
cls: ["cx-modal", `cx-modal-${size}`, stacked ? "cx-modal-stacked" : null],
attrs: { role: "dialog", "aria-modal": "true", "aria-labelledby": titleId,
"aria-describedby": describedBy },
});
card.style.maxWidth = SIZES[size] || SIZES.md;
const close = el("button", {
cls: ["cx-icon-btn", "cx-icon-btn-md", "cx-focusable"],
text: "✕", attrs: { type: "button", "aria-label": "Close" },
on: { click: () => handle.close("close-button") },
});
card.append(el("header", { cls: "cx-modal-head", children: [
el("div", { cls: "cx-modal-titles", children: [
el("h2", { cls: "cx-modal-title", text: title, attrs: { id: titleId } }),
subtitle ? el("p", { cls: "cx-hint", text: subtitle }) : null,
].filter(Boolean) }),
close,
] }));
const content = el("div", { cls: "cx-modal-body", children: nodeOf(body, "modal") });
card.append(content);
// Always present, empty until there is something to put in it.
//
// A modal whose body changes needs its buttons to change with it, and the
// footer is the only row that sits OUTSIDE the scrolling body -- a bar built
// inside the body instead sticks to the bottom of the scroller, which is
// above the card's own edge by the body's padding, so the content scrolled
// through the gap underneath it.
const buttons = el("div", { cls: "cx-modal-foot-buttons" });
const noteText = el("p", { cls: "cx-hint" });
const foot = el("footer", { cls: "cx-modal-foot", children: [noteText, buttons] });
function setFooter({ actions: next = [], note = "" } = {}) {
buttons.replaceChildren(...next.map((a) => nodeOf(a, "modal actions")));
setText(noteText, note);
foot.toggleAttribute("hidden", !next.length && !note);
}
card.append(foot);
setFooter({ actions });
const backdrop = el("div", { cls: "cx-backdrop" });
const root = el("div", { cls: "cx-modal-layer", children: [backdrop, card] });
// A stacked modal shares the modal rung with the one beneath it: peers stack
// by claim order, so it lands above without needing a rung of its own.
const layer = claimFor("modal", root);
mount(root);
// Trap after mounting: focusables() must be able to see the card.
const release = trap(card);
const dismissal = push({
nodes: card,
closeOnOutside,
onDismiss: (reason) => handle.close(reason),
});
let closed = false;
const handle = {
node: card,
isOverlay: true,
setFooter,
close(reason = "manual") {
if (closed) return;
closed = true;
dismissal.release();
release();
layer.release();
unmount(root);
if (onClose) onClose(reason);
},
destroy() { handle.close("destroy"); },
};
return handle;
}
define("modal", "generic", (props) => open(props));
// Opened above an existing modal without closing it. The z rung and the focus
// handoff are the kit's problem; v4 made each caller compute a z-index from its
// parent's.
define("modal", "stacked", (props) => open({ ...props, stacked: true }));
/**
* A yes/no question. `result` resolves true or false -- including on Escape and
* on a backdrop click, which both mean "no" rather than leaving the promise
* pending forever.
*/
define("modal", "dialogue", ({ title, message, tone = "neutral", confirmLabel = "Confirm", cancelLabel = "Cancel", onResult } = {}) => {
let settle;
const result = new Promise((resolve) => { settle = resolve; });
const finish = (value) => { settle(value); if (onResult) onResult(value); };
const bodyId = uid("modal-msg");
const handle = open({
title,
describedBy: bodyId,
body: { node: el("p", { cls: "cx-t cx-t-sm", text: message, attrs: { id: bodyId } }) },
onClose: (reason) => { if (reason !== "confirmed") finish(false); },
actions: [
{ node: el("button", { cls: ["cx-btn", "cx-btn-lg", "cx-btn-neutral", "cx-focusable"],
text: cancelLabel, attrs: { type: "button" },
on: { click: () => handle.close("cancelled") } }) },
{ node: el("button", { cls: ["cx-btn", "cx-btn-lg", tone === "danger" ? "cx-btn-danger" : "cx-btn-primary", "cx-focusable"],
text: confirmLabel, attrs: { type: "button" },
on: { click: () => { finish(true); handle.close("confirmed"); } } }) },
],
});
handle.result = result;
return handle;
});
/** Ask for one value. Resolves the string, or null if dismissed. */
define("modal", "prompt", ({ title, label, value = "", placeholder, confirmLabel = "Create", cancelLabel = "Cancel", validate, onResult } = {}) => {
let settle;
const result = new Promise((resolve) => { settle = resolve; });
const finish = (v) => { settle(v); if (onResult) onResult(v); };
const input = el("input", { cls: ["cx-input", "cx-input-md", "cx-focusable"],
attrs: { type: "text", placeholder, id: uid("prompt") } });
input.value = value;
const error = el("p", { cls: "cx-field-error" });
const submit = () => {
const problem = validate ? validate(input.value) : null;
if (problem) { error.textContent = problem; input.focus(); return; }
finish(input.value);
handle.close("confirmed");
};
input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); submit(); } });
const handle = open({
title,
body: { node: el("div", { cls: "cx-field", children: [
label ? el("label", { cls: "cx-label", text: label, attrs: { for: input.id } }) : null,
input, error,
].filter(Boolean) }) },
onClose: (reason) => { if (reason !== "confirmed") finish(null); },
actions: [
{ node: el("button", { cls: ["cx-btn", "cx-btn-lg", "cx-btn-neutral", "cx-focusable"],
text: cancelLabel, attrs: { type: "button" }, on: { click: () => handle.close("cancelled") } }) },
{ node: el("button", { cls: ["cx-btn", "cx-btn-lg", "cx-btn-primary", "cx-focusable"],
text: confirmLabel, attrs: { type: "button" }, on: { click: submit } }) },
],
});
handle.result = result;
queueMicrotask(() => { input.focus(); input.select(); });
return handle;
});
/** Pick one of a list. Resolves the chosen id, or null. */
define("modal", "choice", ({ title, subtitle, items = [], onPick } = {}) => {
let settle;
const result = new Promise((resolve) => { settle = resolve; });
const list = el("div", { cls: "cx-choice-list" });
for (const item of items) {
list.append(el("button", {
cls: ["cx-choice-row", "cx-focusable"],
attrs: { type: "button", disabled: item.disabled },
children: [
el("span", { cls: "cx-choice-label", text: item.label }),
item.hint ? el("span", { cls: "cx-hint", text: item.hint }) : null,
].filter(Boolean),
on: { click: () => { settle(item.id); if (onPick) onPick(item.id); handle.close("picked"); } },
}));
}
const handle = open({
title, subtitle, size: "sm",
body: { node: list },
onClose: (reason) => { if (reason !== "picked") settle(null); },
});
handle.result = result;
return handle;
});
+172
View File
@@ -0,0 +1,172 @@
/* Every overlay lives on the portal root, which is fixed and inset:0 with
* pointer-events:none; each layer turns its own events back on. */
.cx-modal-layer, .cx-slideover-layer, .cx-blocking {
position: fixed; inset: 0; display: flex; pointer-events: auto;
}
.cx-modal-layer { align-items: flex-start; justify-content: center; padding: 7vh var(--sp-6); }
.cx-backdrop { position: absolute; inset: 0; background: var(--backdrop); }
.cx-modal {
position: relative; width: 100%;
display: flex; flex-direction: column; max-height: 86vh;
background: var(--elev-3); color: var(--text);
border: var(--bw) solid var(--line); border-radius: var(--r-5);
box-shadow: var(--shadow-3);
animation: cx-pop var(--dur-2) var(--ease-standard);
}
/* A stacked modal gets a deeper shadow and nothing else.
*
* It used to paint its own backdrop as `0 0 0 100vmax` of shadow spread, which
* was redundant -- every modal, stacked or not, already mounts a real
* .cx-backdrop element that covers the layer -- so the area under a stacked
* modal was dimmed twice. It was also the most expensive declaration in this
* sheet by a distance: a shadow spread the size of the viewport diagonal, on an
* element inside a fixed layer, with an entry animation running. That is a
* standing invitation to a stale compositor layer, and one showed up as the
* whole app painted twice, side by side, until a window resize forced a
* repaint. */
.cx-modal-stacked {
box-shadow: 0 20px 48px color-mix(in srgb, var(--shadow-ink) 90%, transparent);
}
@keyframes cx-pop { from { opacity: 0; transform: translateY(-6px) scale(.99); } }
/* A modal that hosts SWITCHABLE content -- Settings, so far -- rather than
one fixed question: sizing to whichever section is showing means the frame
itself grows and shrinks on every click, which nothing in a real System
Settings window does. A real height, not a max, holds the frame still;
.cx-modal-body's own scroll takes the rest. */
.cx-settings-modal { height: min(660px, 90vh); } /* v4's own window size, verbatim */
.cx-modal-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-5);
padding: var(--sp-6); border-bottom: var(--bw) solid var(--line);
}
.cx-modal-titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.cx-modal-title { font-family: var(--font-display); font-size: var(--fs-2); font-weight: var(--fw-semibold); }
/* A column, so a body that wants to fill the card can. Without it the body
sized to its content and the card scrolled as a whole -- which put the group
editor's Save bar below the fold instead of holding the bottom of the
window. */
.cx-modal-body {
padding: var(--sp-6); overflow-y: auto;
display: flex; flex-direction: column; min-block-size: 0;
/* Latent until a modal had a real (not max-) height: a shrink-wrapped card
always summed head+body+foot exactly, so the foot landed at the bottom by
accident. .cx-settings-modal is a fixed height, and without this the body
stopped short of it, leaving the foot floating above true bottom with a
gap of bare card showing under it. */
flex: 1 1 auto;
}
.cx-modal-foot {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-6);
padding: var(--sp-5) var(--sp-6); border-top: var(--bw) solid var(--line);
background: var(--elev-2);
}
.cx-modal-foot-buttons { display: flex; gap: var(--sp-3); margin-left: auto; }
.cx-choice-list { display: flex; flex-direction: column; gap: var(--sp-1); }
.cx-choice-row {
display: flex; flex-direction: column; gap: 2px; text-align: left;
padding: var(--sp-4) var(--sp-5);
border: var(--bw) solid var(--line); border-radius: var(--r-3);
background: var(--elev-2); color: var(--text); cursor: pointer;
}
.cx-choice-row:hover { border-color: var(--accent); background: var(--accent-soft); }
.cx-choice-label { font-size: var(--fs-4); }
.cx-popover {
position: fixed; pointer-events: auto;
background: var(--elev-3); color: var(--text);
border: var(--bw) solid var(--line); border-radius: var(--r-4);
box-shadow: var(--shadow-3);
padding: var(--sp-2);
/* Both axes bounded, not just one. The height was capped from the start and
the width was not, so a long unbroken string -- a file path, a prompt, a
model filename -- made the popover wider than the screen and no amount of
repositioning could bring it back: anchor.js can move a box, it cannot
shrink one. Measured at 1436px inside a 1280px viewport. */
max-height: 60vh; overflow-y: auto;
max-inline-size: min(92vw, 480px);
overflow-wrap: anywhere;
animation: cx-pop var(--dur-1) var(--ease-standard);
}
.cx-popover-menu { min-width: 180px; }
.cx-menu { display: flex; flex-direction: column; gap: 1px; }
.cx-menu-item {
display: flex; align-items: center; gap: var(--sp-3); width: 100%;
padding: var(--sp-3) var(--sp-4);
border: 0; border-radius: var(--r-2); background: transparent;
color: var(--text); font-size: var(--fs-5); text-align: left; cursor: pointer;
}
.cx-menu-item:hover:not(:disabled), .cx-menu-item.cx-on { background: var(--hover); }
.cx-menu-item:disabled { opacity: var(--disabled-opacity); cursor: default; }
.cx-menu-item.cx-danger { color: var(--danger); }
.cx-menu-label { flex: 1; }
.cx-menu-hint { font-size: var(--fs-7); color: var(--faint); }
.cx-menu-icon { width: 16px; text-align: center; color: var(--muted); }
.cx-menu-sep { height: 1px; background: var(--line); margin: var(--sp-2) 0; }
.cx-tooltip {
position: fixed; pointer-events: none;
background: var(--elev-3); color: var(--text);
border: var(--bw) solid var(--line); border-radius: var(--r-2);
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-6); box-shadow: var(--shadow-2);
max-width: 260px;
}
.cx-autocomplete { min-width: 220px; padding: var(--sp-1); }
.cx-split-btn { display: inline-flex; }
.cx-split-btn .cx-btn:first-child { border-radius: var(--ctl-r) 0 0 var(--ctl-r); }
.cx-split-arrow { border-radius: 0 var(--ctl-r) var(--ctl-r) 0; margin-left: calc(var(--bw) * -1); padding: 0 var(--sp-3); }
.cx-window {
position: fixed; pointer-events: auto;
display: flex; flex-direction: column;
background: var(--elev-3); color: var(--text);
border: var(--bw) solid var(--line); border-radius: var(--r-4);
box-shadow: var(--shadow-3); overflow: hidden;
}
.cx-window-bar {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
padding: var(--sp-2) var(--sp-3) var(--sp-2) var(--sp-5);
background: var(--elev-2); border-bottom: var(--bw) solid var(--line);
cursor: grab; user-select: none; flex: none;
}
.cx-window-bar:active { cursor: grabbing; }
.cx-window-titles { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; }
.cx-window-title { font-size: var(--fs-5); font-weight: var(--fw-semibold); }
.cx-window-controls { display: flex; gap: var(--sp-1); }
.cx-window-body { flex: 1; overflow: auto; padding: var(--sp-5); }
.cx-rolled .cx-window-body, .cx-rolled .cx-window-grip { display: none; }
.cx-window-grip {
position: absolute; right: 0; bottom: 0; width: 16px; height: 16px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, var(--line) 50%, var(--line) 60%, transparent 60%);
}
.cx-blocking { align-items: center; justify-content: center; background: var(--backdrop); }
.cx-blocking-card {
display: flex; flex-direction: column; align-items: center; gap: var(--sp-5);
padding: var(--sp-9) var(--sp-10);
background: var(--elev-3); border: var(--bw) solid var(--line);
border-radius: var(--r-5); box-shadow: var(--shadow-3); text-align: center;
}
.cx-slideover-layer { align-items: stretch; }
.cx-slideover {
position: relative; margin-left: auto;
width: min(420px, 92vw); display: flex; flex-direction: column;
background: var(--elev-1); border-left: var(--bw) solid var(--line);
box-shadow: var(--shadow-3);
animation: cx-slide-in var(--dur-2) var(--ease-standard);
}
.cx-slideover-left { margin-left: 0; margin-right: auto; border-left: 0; border-right: var(--bw) solid var(--line); }
@keyframes cx-slide-in { from { transform: translateX(12px); opacity: 0; } }
.cx-slideover-head {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-5) var(--sp-6); border-bottom: var(--bw) solid var(--line);
}
.cx-slideover-body { flex: 1; overflow-y: auto; padding: var(--sp-6); }
+237
View File
@@ -0,0 +1,237 @@
// Anchored overlays: popovers, menus, tooltips, autocomplete.
//
// v4 had seven copies of measure-flip-clamp-and-dismiss, one per feature. This
// is the one, and everything anchored is built on it.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { mount, unmount } from "../internals/portal.js";
import { claimFor } from "../internals/zlayer.js";
import { push } from "../internals/dismiss.js";
import { anchorTo } from "../internals/anchor.js";
function nodeOf(control, where) {
if (!control) return null;
if (control.node && control.node.nodeType === 1) return control.node;
throw new TypeError(`${where} takes a Composer element handle, not a ${typeof control}.`);
}
function floatOn(rung, cls, { anchor, side = "bottom", align = "start", gap = 6, onClose, closeOnOutside = true, content, role, label }) {
const anchorNode = anchor && anchor.node ? anchor.node : anchor;
const node = el("div", { cls, attrs: { role, "aria-label": label } });
const layer = claimFor(rung, node);
if (content) node.append(content);
// Mounted before measuring: a hidden element measures 0x0 and lands in the
// corner, which is the classic "my menu appears top-left" bug.
mount(node);
if (anchorNode) anchorTo(node, anchorNode, { side, align, gap });
const reposition = () => { if (anchorNode) anchorTo(node, anchorNode, { side, align, gap }); };
window.addEventListener("resize", reposition);
// Capture, because the scroller is usually an ancestor panel, not the window.
window.addEventListener("scroll", reposition, true);
const dismissal = push({
nodes: [node, anchorNode].filter(Boolean),
closeOnOutside,
onDismiss: (reason) => handle.close(reason),
});
let closed = false;
const handle = {
node,
isOverlay: true,
reposition,
close(reason = "manual") {
if (closed) return;
closed = true;
dismissal.release();
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
layer.release();
unmount(node);
if (onClose) onClose(reason);
},
destroy() { handle.close("destroy"); },
};
return handle;
}
define("popover", "anchored", ({ body, ...rest } = {}) =>
floatOn("popover", "cx-popover", { ...rest, content: nodeOf(body, "popover.anchored"), role: "dialog" }));
function menuList(items, onPick, close) {
const list = el("div", { cls: "cx-menu", attrs: { role: "menu" } });
for (const item of items) {
if (item.separator) { list.append(el("div", { cls: "cx-menu-sep", attrs: { role: "separator" } })); continue; }
list.append(el("button", {
cls: ["cx-menu-item", "cx-focusable", item.danger ? "cx-danger" : null],
attrs: { type: "button", role: "menuitem", disabled: item.disabled },
children: [
item.icon ? el("span", { cls: "cx-menu-icon", text: item.icon, attrs: { "aria-hidden": "true" } }) : null,
el("span", { cls: "cx-menu-label", text: item.label }),
item.hint ? el("span", { cls: "cx-menu-hint", text: item.hint }) : null,
].filter(Boolean),
on: { click: () => { if (onPick) onPick(item.id); close(); } },
}));
}
return list;
}
define("menu", "dropdown", ({ items = [], onPick, ...rest } = {}) => {
const handle = floatOn("popover", "cx-popover cx-popover-menu", {
...rest, role: "menu",
content: null,
});
handle.node.append(menuList(items, onPick, () => handle.close("picked")));
handle.reposition();
return handle;
});
/**
* Right-click menu: anchored to a POINT rather than an element.
*
* The anchor is a stand-in object with only getBoundingClientRect, because that
* is all anchorTo needs. It is not a node, so it never reaches the dismiss stack
* -- that filters to things that can answer "was the click inside me".
*/
define("menu", "context", ({ x = 0, y = 0, items = [], onPick, onClose } = {}) => {
const point = { getBoundingClientRect: () => ({ x, y, width: 0, height: 0, top: y, left: x, right: x, bottom: y }) };
const handle = floatOn("popover", "cx-popover cx-popover-menu", {
anchor: point, onClose, role: "menu",
});
handle.node.append(menuList(items, onPick, () => handle.close("picked")));
handle.reposition();
return handle;
});
/**
* A real tooltip. v4 used the native title attribute everywhere, which cannot be
* styled, cannot be triggered by keyboard focus, and waits about a second.
*/
define("tooltip", "default", ({ anchor, text, side = "top", trigger = true } = {}) => {
const anchorNode = anchor && anchor.node ? anchor.node : anchor;
let live = null;
const show = () => {
if (live) return;
live = floatOn("popover", "cx-tooltip", {
anchor: anchorNode, side, align: "center", gap: 6, closeOnOutside: false,
content: el("span", { text }), role: "tooltip",
});
};
const hide = () => { if (live) { live.close("hide"); live = null; } };
if (trigger && anchorNode) {
anchorNode.addEventListener("pointerenter", show);
anchorNode.addEventListener("pointerleave", hide);
anchorNode.addEventListener("focus", show);
anchorNode.addEventListener("blur", hide);
}
return {
node: anchorNode || el("span"),
isOverlay: true,
show, hide,
destroy() {
hide();
if (trigger && anchorNode) {
anchorNode.removeEventListener("pointerenter", show);
anchorNode.removeEventListener("pointerleave", hide);
anchorNode.removeEventListener("focus", show);
anchorNode.removeEventListener("blur", hide);
}
},
};
});
/**
* Completion under a field. This is the one overlay that must outrank modals --
* it opens from a field that is usually inside one.
*/
define("autocomplete", "default", ({ input, source, onPick, minChars = 1 } = {}) => {
const field = input && input.node ? input.node : input;
let live = null;
let index = -1;
let current = [];
const close = () => { if (live) { live.close("hide"); live = null; } index = -1; };
const draw = (items) => {
current = items;
if (!items.length) { close(); return; }
if (!live) {
live = floatOn("autocomplete", "cx-popover cx-autocomplete", {
anchor: field, side: "bottom", align: "start", gap: 2,
role: "listbox", onClose: () => { live = null; },
});
}
live.node.replaceChildren();
items.forEach((item, i) => {
live.node.append(el("button", {
cls: ["cx-menu-item", i === index ? "cx-on" : null],
attrs: { type: "button", role: "option", "aria-selected": String(i === index) },
children: [
el("span", { cls: "cx-menu-label", text: item.label }),
item.hint ? el("span", { cls: "cx-menu-hint", text: item.hint }) : null,
].filter(Boolean),
on: { click: () => { if (onPick) onPick(item); close(); } },
}));
});
live.reposition();
};
const onInput = () => {
const q = field.value || "";
if (q.length < minChars) { close(); return; }
index = -1;
draw(source ? source(q) : []);
};
const onKeyDown = (e) => {
if (!live) return;
if (e.key === "ArrowDown") { e.preventDefault(); index = Math.min(index + 1, current.length - 1); draw(current); }
else if (e.key === "ArrowUp") { e.preventDefault(); index = Math.max(index - 1, 0); draw(current); }
else if (e.key === "Enter" && index >= 0) { e.preventDefault(); if (onPick) onPick(current[index]); close(); }
};
if (field) {
field.addEventListener("input", onInput);
field.addEventListener("keydown", onKeyDown);
}
return {
node: field || el("span"),
isOverlay: true,
close,
destroy() {
close();
if (field) {
field.removeEventListener("input", onInput);
field.removeEventListener("keydown", onKeyDown);
}
},
};
});
/** A button whose second half opens a menu. */
define("splitButton", "md", ({ label, onClick, items = [], onPick, tone = "neutral" } = {}) => {
const main = el("button", { cls: ["cx-btn", "cx-btn-md", `cx-btn-${tone}`, "cx-focusable"],
text: label, attrs: { type: "button" }, on: { click: onClick } });
const arrow = el("button", { cls: ["cx-btn", "cx-btn-md", `cx-btn-${tone}`, "cx-split-arrow", "cx-focusable"],
text: "▾", attrs: { type: "button", "aria-label": `${label} options`, "aria-haspopup": "menu" } });
let menu = null;
arrow.addEventListener("click", () => {
if (menu) { menu.close("toggle"); menu = null; return; }
menu = floatOn("popover", "cx-popover cx-popover-menu", {
anchor: arrow, side: "bottom", align: "end", onClose: () => { menu = null; },
});
menu.node.append(menuList(items, onPick, () => { if (menu) menu.close("picked"); }));
menu.reposition();
});
const node = el("div", { cls: "cx-split-btn", children: [main, arrow] });
return { node, destroy() { if (menu) menu.close("destroy"); node.remove(); } };
});
+47
View File
@@ -0,0 +1,47 @@
.cx-slider { display: flex; align-items: center; gap: var(--sp-4); width: 100%; }
.cx-range {
appearance: none; -webkit-appearance: none;
flex: 1; min-width: 80px; height: 18px; background: transparent; cursor: pointer;
/* --fill is written by the element as the value changes, so the filled part
* of the track follows the theme's accent without JS naming a colour. */
--fill: 0%;
}
.cx-range::-webkit-slider-runnable-track {
height: 4px; border-radius: var(--r-pill);
background: linear-gradient(to right, var(--accent) var(--fill), var(--line) var(--fill));
}
.cx-range::-moz-range-track {
height: 4px; border-radius: var(--r-pill);
background: linear-gradient(to right, var(--accent) var(--fill), var(--line) var(--fill));
}
.cx-range::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; margin-top: -5px;
border-radius: var(--r-pill); background: var(--elev-1);
border: 2px solid var(--accent);
transition: transform var(--dur-1) var(--ease-standard);
}
.cx-range::-moz-range-thumb {
width: 14px; height: 14px; border-radius: var(--r-pill);
background: var(--elev-1); border: 2px solid var(--accent);
}
.cx-range:hover::-webkit-slider-thumb { transform: scale(1.12); }
.cx-range:focus-visible { outline: none; }
.cx-range:focus-visible::-webkit-slider-thumb { box-shadow: var(--focus-ring); }
.cx-range:focus-visible::-moz-range-thumb { box-shadow: var(--focus-ring); }
.cx-range:disabled { opacity: var(--disabled-opacity); cursor: default; }
.cx-slider-sm .cx-range { height: 14px; }
.cx-readout {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: var(--fs-6); color: var(--muted);
min-width: 4ch; text-align: right;
}
.cx-macro { display: flex; flex-direction: column; gap: var(--sp-3); width: 100%; }
.cx-macro-presets { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.cx-macro-presets .cx-on { color: var(--accent); background: var(--accent-soft); }
.cx-range-pair { display: flex; flex-direction: column; gap: var(--sp-1); width: 100%; }
+134
View File
@@ -0,0 +1,134 @@
// Sliders.
//
// The track and thumb are drawn. v4 left them fully native with only
// accent-color, so a slider looked like the operating system rather than like
// the app, and its hit target was whatever the browser decided.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { uid } from "../internals/ids.js";
const pct = (v, min, max) => (max === min ? 0 : ((v - min) / (max - min)) * 100);
function makeRange({ value, min, max, step, disabled, label, describedBy }) {
const input = el("input", {
cls: ["cx-range", "cx-focusable"],
attrs: { type: "range", min, max, step, disabled, "aria-label": label, "aria-describedby": describedBy },
});
input.value = String(value);
// The filled portion is drawn from a variable rather than a background image,
// so it follows the theme's accent without the JS knowing a colour.
const paint = () => input.style.setProperty("--fill", `${pct(Number(input.value), min, max)}%`);
paint();
input.addEventListener("input", paint);
return { input, paint };
}
function sliderBase(cls, { value = 0, min = 0, max = 1, step = 0.01, onChange, onCommit, disabled, label } = {}) {
const { input, paint } = makeRange({ value, min, max, step, disabled, label });
if (onChange) input.addEventListener("input", () => onChange(Number(input.value)));
if (onCommit) input.addEventListener("change", () => onCommit(Number(input.value)));
const node = el("div", { cls, children: input });
return {
node, input, paint,
get value() { return Number(input.value); },
setValue(v) { input.value = String(v); paint(); },
destroy: () => node.remove(),
};
}
for (const size of ["md", "sm"]) {
define("slider", size, (props = {}) => sliderBase(`cx-slider cx-slider-${size}`, props));
}
define("slider", "readout", ({ unit = "", precision = 2, ...props } = {}) => {
const base = sliderBase("cx-slider cx-slider-md cx-slider-readout", props);
const format = (v) => `${Number(v).toFixed(precision)}${unit}`;
const out = el("output", { cls: "cx-readout", text: format(base.value) });
base.input.addEventListener("input", () => { out.textContent = format(base.input.value); });
base.node.append(out);
const setValue = base.setValue;
return {
...base,
setValue(v) { setValue(v); out.textContent = format(v); },
node: base.node,
};
});
/**
* A slider with named landing points. The presets are the point: most people
* want "Chill" or "Chaos", not 0.62, and the number is there for the ones who do.
*/
define("slider", "macro", ({ presets = [], unit = "", precision = 2, ...props } = {}) => {
const base = sliderBase("cx-slider cx-slider-md cx-slider-macro", props);
const format = (v) => `${Number(v).toFixed(precision)}${unit}`;
const out = el("output", { cls: "cx-readout", text: format(base.value) });
base.node.append(out);
const row = el("div", { cls: "cx-macro-presets" });
const buttons = presets.map((preset) => {
const b = el("button", {
cls: ["cx-btn", "cx-btn-sm", "cx-btn-ghost", "cx-focusable"],
text: preset.label,
attrs: { type: "button" },
on: { click: () => {
base.setValue(preset.value);
out.textContent = format(preset.value);
sync();
if (props.onChange) props.onChange(preset.value);
if (props.onCommit) props.onCommit(preset.value);
} },
});
row.append(b);
return { preset, b };
});
function sync() {
for (const { preset, b } of buttons) b.classList.toggle("cx-on", Number(base.value) === preset.value);
}
base.input.addEventListener("input", () => { out.textContent = format(base.input.value); sync(); });
sync();
const node = el("div", { cls: "cx-macro", children: [base.node, row] });
return {
node,
get value() { return base.value; },
setValue(v) { base.setValue(v); out.textContent = format(v); sync(); },
destroy: () => node.remove(),
};
});
/** Two thumbs. Trim handles and any "between x and y" setting are built on this. */
define("range", "md", ({ from = 0, to = 1, min = 0, max = 1, step = 0.01, onChange, label } = {}) => {
const lo = makeRange({ value: from, min, max, step, label: label ? `${label} from` : "from" });
const hi = makeRange({ value: to, min, max, step, label: label ? `${label} to` : "to" });
// Thumbs must not pass each other: a range whose start is after its end is a
// state every consumer would then have to defend against.
const clampPair = (moved) => {
let a = Number(lo.input.value);
let b = Number(hi.input.value);
if (a > b) {
if (moved === "lo") a = b; else b = a;
lo.input.value = String(a);
hi.input.value = String(b);
lo.paint(); hi.paint();
}
if (onChange) onChange({ from: a, to: b });
};
lo.input.addEventListener("input", () => clampPair("lo"));
hi.input.addEventListener("input", () => clampPair("hi"));
const node = el("div", { cls: "cx-range-pair", attrs: { role: "group", "aria-label": label },
children: [lo.input, hi.input] });
return {
node,
get value() { return { from: Number(lo.input.value), to: Number(hi.input.value) }; },
setValue({ from: a, to: b }) {
lo.input.value = String(a); hi.input.value = String(b);
lo.paint(); hi.paint();
},
destroy: () => node.remove(),
};
});
+104
View File
@@ -0,0 +1,104 @@
.cx-chip {
display: inline-flex; align-items: center; gap: var(--sp-2);
height: var(--ctl-h-sm); padding: 0 var(--sp-3);
border: var(--bw) solid var(--line); border-radius: var(--r-pill);
font-size: var(--fs-7); color: var(--muted); white-space: nowrap;
}
.cx-chip-dot { width: 6px; height: 6px; border-radius: var(--r-pill); background: currentColor; }
.cx-chip-good { color: var(--good); border-color: color-mix(in srgb, var(--good) 30%, var(--line)); }
.cx-chip-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 30%, var(--line)); }
.cx-chip-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--line)); }
.cx-chip-info { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
.cx-dot { width: 8px; height: 8px; border-radius: var(--r-pill); background: var(--muted); display: inline-block; flex: none; }
.cx-dot-good { background: var(--good); }
.cx-dot-warn { background: var(--warn); }
.cx-dot-danger { background: var(--danger); }
.cx-dot-info { background: var(--accent); }
.cx-banner {
display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5);
border: var(--bw) solid var(--line); border-radius: var(--r-3);
background: var(--elev-2); font-size: var(--fs-5);
}
.cx-banner-info { border-color: color-mix(in srgb, var(--accent) 30%, var(--line)); }
.cx-banner-info .cx-dot { background: var(--accent); }
.cx-banner-warn { border-color: color-mix(in srgb, var(--warn) 30%, var(--line)); }
.cx-banner-warn .cx-dot { background: var(--warn); }
.cx-banner-danger { border-color: color-mix(in srgb, var(--danger) 30%, var(--line)); }
.cx-banner-danger .cx-dot { background: var(--danger); }
.cx-banner-text { flex: 1; }
.cx-inline-error { font-size: var(--fs-6); color: var(--danger); }
.cx-progress {
width: 100%; height: 4px; border-radius: var(--r-pill);
background: var(--line); overflow: hidden;
}
.cx-progress-fill {
height: 100%; width: 0; background: var(--accent); border-radius: var(--r-pill);
transition: width var(--dur-2) var(--ease-standard);
}
.cx-progress-indeterminate .cx-progress-fill {
width: 35%; animation: cx-slide 1.2s var(--ease-standard) infinite;
}
@keyframes cx-slide { from { transform: translateX(-100%); } to { transform: translateX(320%); } }
.cx-spinner {
display: inline-block; border-radius: var(--r-pill);
border: 2px solid color-mix(in srgb, var(--muted) 30%, transparent);
border-top-color: var(--accent);
animation: cx-spin 800ms linear infinite;
}
.cx-spinner-md { width: 16px; height: 16px; }
.cx-spinner-sm { width: 12px; height: 12px; }
.cx-empty {
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
padding: var(--sp-10) var(--sp-6); text-align: center; color: var(--muted);
}
.cx-empty-icon { font-size: var(--fs-d3); color: var(--faint); line-height: 1; }
.cx-empty-title { font-size: var(--fs-4); color: var(--text); }
.cx-dropzone {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: var(--sp-2); padding: var(--sp-9) var(--sp-6); text-align: center;
border: var(--bw) dashed var(--line); border-radius: var(--r-4);
background: var(--elev-1); color: var(--muted); cursor: pointer;
transition: border-color var(--dur-1) var(--ease-standard), background var(--dur-1) var(--ease-standard);
}
.cx-dropzone:hover, .cx-dropzone.cx-over { border-color: var(--accent); background: var(--accent-soft); color: var(--text); }
.cx-dropzone-label { font-size: var(--fs-4); }
.cx-skeleton { display: flex; gap: var(--sp-3); }
.cx-skeleton-line, .cx-skeleton-block { flex-direction: column; }
.cx-skeleton-grid { display: grid; grid-template-columns: repeat(3, 1fr); }
.cx-skeleton-cell {
border-radius: var(--r-2);
background: linear-gradient(90deg, var(--elev-2), var(--hover), var(--elev-2));
background-size: 200% 100%;
animation: cx-shimmer 1.4s linear infinite;
}
.cx-skeleton-line .cx-skeleton-cell { height: 10px; }
.cx-skeleton-line .cx-skeleton-cell:last-child { width: 60%; }
.cx-skeleton-block .cx-skeleton-cell { height: 44px; }
.cx-skeleton-grid .cx-skeleton-cell { aspect-ratio: 16 / 10; }
@keyframes cx-shimmer { to { background-position: -200% 0; } }
.cx-toasts {
position: fixed; left: 50%; bottom: var(--sp-9); transform: translateX(-50%);
display: flex; flex-direction: column; gap: var(--sp-3); align-items: center;
}
.cx-toast {
display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-4) var(--sp-6);
border: var(--bw) solid var(--line); border-radius: var(--r-pill);
background: var(--elev-3); color: var(--text);
box-shadow: var(--shadow-2); font-size: var(--fs-5);
animation: cx-rise var(--dur-2) var(--ease-standard);
}
.cx-toast-good { border-color: color-mix(in srgb, var(--good) 45%, var(--line)); }
.cx-toast-warn { border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); }
.cx-toast-danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); }
@keyframes cx-rise { from { opacity: 0; transform: translateY(8px); } }
+174
View File
@@ -0,0 +1,174 @@
// Telling the user what is going on.
//
// v4 had roughly fifteen badge classes, ten empty states and three spinners.
// Here there are five tones and one shape of each, which is the whole point: a
// status looks the same everywhere, so its colour actually means something.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { setText } from "../internals/text.js";
import { portal } from "../internals/portal.js";
import { claimFor } from "../internals/zlayer.js";
const TONES = ["neutral", "good", "warn", "danger", "info"];
for (const tone of TONES) {
define("chip", tone, ({ label, dot = false } = {}) =>
({ node: el("span", { cls: ["cx-chip", `cx-chip-${tone}`], children: [
dot ? el("span", { cls: "cx-chip-dot", attrs: { "aria-hidden": "true" } }) : null,
el("span", { text: label }),
].filter(Boolean) }),
destroy() { this.node.remove(); } }));
}
define("dot", "default", ({ tone = "neutral", label } = {}) =>
({ node: el("span", { cls: ["cx-dot", `cx-dot-${tone}`],
attrs: { role: label ? "img" : undefined, "aria-label": label, title: label } }),
destroy() { this.node.remove(); } }));
for (const tone of ["info", "warn", "danger"]) {
define("banner", tone, ({ text, action } = {}) => {
const words = el("span", { cls: "cx-banner-text", text });
const node = el("div", { cls: ["cx-banner", `cx-banner-${tone}`],
attrs: { role: tone === "danger" ? "alert" : "status" }, children: [
el("span", { cls: "cx-dot", attrs: { "aria-hidden": "true" } }),
words,
action && action.node ? action.node : null,
].filter(Boolean) });
// Settable, because the things worth a banner come and go: a warning that
// can only be built is a warning that has to be destroyed and rebuilt to
// change, and whatever was holding it then holds a dead handle.
return { node, setText: (next) => setText(words, next), destroy: () => node.remove() };
});
}
define("inlineError", "default", ({ text } = {}) =>
({ node: el("p", { cls: "cx-inline-error", text, attrs: { role: "alert" } }),
destroy() { this.node.remove(); } }));
define("progress", "bar", ({ value = 0, max = 100, label } = {}) => {
const fill = el("div", { cls: "cx-progress-fill" });
const node = el("div", { cls: "cx-progress",
attrs: { role: "progressbar", "aria-valuemin": "0", "aria-valuemax": String(max),
"aria-valuenow": String(value), "aria-label": label },
children: fill });
const set = (v) => {
const pct = max ? Math.min(100, Math.max(0, (v / max) * 100)) : 0;
fill.style.width = `${pct}%`;
node.setAttribute("aria-valuenow", String(v));
};
set(value);
return { node, setValue: set, destroy: () => node.remove() };
});
// For work whose total is genuinely unknown. A bar that invents a percentage is
// worse than one that admits it does not know.
define("progress", "indeterminate", ({ label } = {}) =>
({ node: el("div", { cls: ["cx-progress", "cx-progress-indeterminate"],
attrs: { role: "progressbar", "aria-label": label },
children: el("div", { cls: "cx-progress-fill" }) }),
destroy() { this.node.remove(); } }));
for (const size of ["md", "sm"]) {
define("spinner", size, ({ label = "Working" } = {}) =>
({ node: el("span", { cls: ["cx-spinner", `cx-spinner-${size}`],
attrs: { role: "status", "aria-label": label } }),
destroy() { this.node.remove(); } }));
}
define("emptyState", "default", ({ icon, title, hint, action } = {}) =>
({ node: el("div", { cls: "cx-empty", children: [
icon ? el("div", { cls: "cx-empty-icon", text: icon, attrs: { "aria-hidden": "true" } }) : null,
el("p", { cls: "cx-empty-title", text: title }),
hint ? el("p", { cls: "cx-hint", text: hint }) : null,
action && action.node ? action.node : null,
].filter(Boolean) }),
destroy() { this.node.remove(); } }));
define("dropzone", "default", ({ label, hint, accept, onFiles, multiple = true } = {}) => {
const input = el("input", { cls: "cx-sr",
attrs: { type: "file", accept, multiple: multiple || undefined } });
const node = el("label", { cls: "cx-dropzone", children: [
input,
el("span", { cls: "cx-dropzone-label", text: label }),
hint ? el("span", { cls: "cx-hint", text: hint }) : null,
].filter(Boolean) });
const emit = (files) => { if (onFiles && files && files.length) onFiles([...files]); };
input.addEventListener("change", () => emit(input.files));
// dragover must be prevented or the browser navigates to the dropped file,
// losing whatever was on screen.
for (const type of ["dragenter", "dragover"]) {
node.addEventListener(type, (e) => { e.preventDefault(); node.classList.add("cx-over"); });
}
for (const type of ["dragleave", "drop"]) {
node.addEventListener(type, (e) => { e.preventDefault(); node.classList.remove("cx-over"); });
}
node.addEventListener("drop", (e) => emit(e.dataTransfer && e.dataTransfer.files));
return { node, destroy: () => node.remove() };
});
for (const shape of ["line", "block", "grid"]) {
define("skeleton", shape, ({ count = shape === "grid" ? 6 : 3, label = "Loading" } = {}) => {
const node = el("div", { cls: ["cx-skeleton", `cx-skeleton-${shape}`],
attrs: { role: "status", "aria-label": label } });
for (let i = 0; i < count; i += 1) node.append(el("span", { cls: "cx-skeleton-cell" }));
return { node, destroy: () => node.remove() };
});
}
// One toast stack, owned by the kit. Several stacks fighting for the corner is
// how v4's tour toast ended up as the only one anybody could use.
let stack = null;
let stackLayer = null;
function toastStack() {
if (stack && stack.isConnected) return stack;
stack = el("div", { cls: "cx-toasts" });
stackLayer = claimFor("toast", stack);
stack.style.pointerEvents = "auto";
portal().appendChild(stack);
return stack;
}
for (const tone of ["info", "good", "warn", "danger"]) {
define("toast", tone, ({ text, action, duration = 3200 } = {}) => {
const node = el("div", { cls: ["cx-toast", `cx-toast-${tone}`],
attrs: { role: tone === "danger" ? "alert" : "status" }, children: [
el("span", { cls: "cx-toast-text", text }),
action && action.node ? action.node : null,
].filter(Boolean) });
toastStack().appendChild(node);
let timer = duration ? setTimeout(() => handle.destroy(), duration) : null;
// Hovering pauses the countdown: a toast that vanishes while being read is
// a toast that failed to say anything.
node.addEventListener("pointerenter", () => { if (timer) { clearTimeout(timer); timer = null; } });
node.addEventListener("pointerleave", () => {
if (duration && !timer) timer = setTimeout(() => handle.destroy(), duration);
});
const handle = {
node,
setText: (value) => setText(node.querySelector(".cx-toast-text"), value),
destroy() {
if (timer) clearTimeout(timer);
node.remove();
if (stack && !stack.childElementCount) {
stackLayer.release();
stack.remove();
stack = null;
}
},
};
return handle;
});
}
export function _resetToasts() {
if (stack) { stack.remove(); stack = null; }
if (stackLayer) { stackLayer.release(); stackLayer = null; }
}
+44
View File
@@ -0,0 +1,44 @@
.cx-h { font-family: var(--font-display); font-weight: var(--fw-semibold); color: var(--text); }
.cx-h-display { font-size: var(--fs-d2); line-height: var(--lh-d2); font-weight: var(--fw-bold); letter-spacing: -0.02em; }
.cx-h-xl { font-size: var(--fs-d3); line-height: var(--lh-d3); font-weight: var(--fw-bold); letter-spacing: -0.015em; }
.cx-h-lg { font-size: var(--fs-1); line-height: var(--lh-1); }
.cx-h-md { font-size: var(--fs-2); line-height: var(--lh-2); }
.cx-h-sm { font-size: var(--fs-3); line-height: var(--lh-3); }
.cx-t { color: var(--text); }
.cx-t-lg { font-size: var(--fs-2); line-height: var(--lh-2); }
.cx-t-md { font-size: var(--fs-3); line-height: var(--lh-3); }
.cx-t-sm { font-size: var(--fs-4); line-height: var(--lh-4); }
.cx-t-xs { font-size: var(--fs-6); line-height: var(--lh-6); }
.cx-hint { font-size: var(--fs-6); line-height: var(--lh-6); color: var(--muted); }
.cx-label { font-size: var(--fs-5); line-height: var(--lh-5); color: var(--text); }
.cx-eyebrow {
font-size: var(--fs-7); line-height: var(--lh-7);
letter-spacing: .08em; text-transform: uppercase;
color: var(--faint);
}
.cx-code { font-family: var(--font-mono); font-size: 0.92em; color: var(--accent-hi); }
.cx-code-block {
font-family: var(--font-mono); font-size: var(--fs-6); line-height: var(--lh-snug);
color: var(--text); background: var(--elev-2);
border: var(--bw) solid var(--line); border-radius: var(--r-3);
padding: var(--sp-4) var(--sp-5); margin: 0;
overflow-x: auto; /* wide code scrolls itself, never the page */
}
/* The one place the app names itself. The mark carries a little of its own
light, which is what stops a 5px dot reading as a bullet point. */
.cx-brand { display: inline-flex; align-items: center; gap: var(--sp-3); }
.cx-brand-mark {
inline-size: 9px; block-size: 9px; border-radius: var(--r-pill);
border: 2px solid var(--accent);
box-shadow: 0 0 6px var(--accent-soft);
}
.cx-brand-name {
font-family: var(--font-display); font-weight: var(--fw-bold);
font-size: var(--fs-3); letter-spacing: .2px; color: var(--text);
}
+53
View File
@@ -0,0 +1,53 @@
// Type. Nothing here carries a margin: spacing belongs to the container, so a
// heading can be dropped anywhere without bringing its own gap.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { setText } from "../internals/text.js";
import { uid } from "../internals/ids.js";
const handle = (node) => ({
node,
setText: (value) => setText(node, value),
destroy: () => node.remove(),
});
const TAGS = { display: "h1", xl: "h1", lg: "h2", md: "h3", sm: "h4" };
for (const size of Object.keys(TAGS)) {
define("header", size, ({ text, id } = {}) =>
handle(el(TAGS[size], { cls: `cx-h cx-h-${size}`, text, attrs: { id } })));
}
for (const size of ["lg", "md", "sm", "xs"]) {
define("text", size, ({ text } = {}) =>
handle(el("p", { cls: `cx-t cx-t-${size}`, text })));
}
// One hint, one look. v4 had ten class names for this exact thing.
define("hint", "default", ({ text, id } = {}) =>
handle(el("p", { cls: "cx-hint", text, attrs: { id } })));
define("label", "field", ({ text, for: htmlFor } = {}) =>
handle(el("label", { cls: "cx-label", text, attrs: { for: htmlFor } })));
define("label", "section", ({ text } = {}) =>
handle(el("div", { cls: "cx-eyebrow", text })));
/** Who this is. One mark and one word, in the display face, at the far left of
* the menu bar -- the only place in the app that names the app. */
define("brand", "default", ({ name = "FunPack" } = {}) =>
({ node: el("span", { cls: "cx-brand", children: [
el("span", { cls: "cx-brand-mark", attrs: { "aria-hidden": "true" } }),
el("span", { cls: "cx-brand-name", text: name }),
] }),
destroy() { this.node.remove(); } }));
define("code", "inline", ({ text } = {}) =>
handle(el("code", { cls: "cx-code", text })));
define("code", "block", ({ text, label } = {}) => {
const id = uid("code");
const pre = el("pre", { cls: "cx-code-block", text, attrs: { id, "aria-label": label } });
return handle(pre);
});
+53
View File
@@ -0,0 +1,53 @@
/* A real timeline: a ruler in seconds, clips as wide as they play for, and a
* playhead over both. Pixel-positioned rather than percent/flex-grow -- what
* makes the ruler's ticks and the clips underneath actually line up. */
.cx-track { overflow-x: auto; min-inline-size: 0; }
.cx-track-inner { position: relative; min-inline-size: 100%; }
.cx-track-ruler {
position: relative; block-size: var(--lh-8); min-inline-size: 100%;
border-block-end: var(--bw) solid var(--line-2);
}
.cx-track-tick {
position: absolute; inset-block-start: 0;
padding-inline-start: var(--sp-1);
border-inline-start: var(--bw) solid var(--line);
font-size: var(--fs-8); line-height: var(--lh-8); color: var(--faint);
font-variant-numeric: tabular-nums;
}
/* A fixed row height, not aspect-ratio off the clip's own width -- a clip's
width now tracks real duration and can run to hundreds of pixels, and
16:10 off THAT would make a half-minute scene taller than the window.
Positioned (not flex-flow) for the same reason each clip sets its own
insetInlineStart in JS: a clip under the pixel floor must not push every
clip after it out of step with the ruler and the playhead, which both
stay in pure seconds*pxPerSecond regardless of any floor. */
.cx-track-clips { position: relative; min-inline-size: 100%; block-size: 45px; }
.cx-track-clip {
position: absolute; inset-block: 0; padding: 0;
border: var(--bw) solid var(--line); border-radius: var(--r-2);
background: var(--void); overflow: hidden; cursor: grab;
display: flex; align-items: center; justify-content: center;
}
.cx-track-face { position: relative; inline-size: 100%; block-size: 100%; display: flex; align-items: center; justify-content: center; }
.cx-track-clip:hover { border-color: var(--accent); }
.cx-track-clip.cx-on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
.cx-track-clip.cx-dragging { opacity: 0.4; cursor: grabbing; }
/* Still on the timeline, still there to bring back -- excluded from a batch
run reads as dimmed, not gone, the way a deleted scene would be. */
.cx-track-clip.cx-excluded { opacity: 0.45; }
/* A clip carries its rating as a colour on its edge -- the one thing about a
result you want to see without selecting it. */
.cx-track-clip.cx-rated { border-color: var(--muted); }
.cx-track-clip[data-rating="liked"] { border-color: var(--good); }
.cx-track-clip[data-rating="disliked"] { border-color: var(--danger); }
/* Spans both rows, so where the ruler reads and where the clip sits are
visibly the same point -- a plain marker on just one of them would not
read as "this is where you are" the way a line through both does. */
.cx-track-playhead {
position: absolute; inset-block: 0; inline-size: 0;
border-inline-start: 2px solid var(--accent-hi);
pointer-events: none; z-index: 1;
}
+179
View File
@@ -0,0 +1,179 @@
// A single time-based track: clips laid out by real duration, a ruler in
// seconds, and a playhead -- the shape of an actual video timeline, not a
// filmstrip of equal-sized thumbnails with a tick per item.
//
// One track, on purpose: v5 generates one clip per scene, in order, with no
// separate audio/overlay layers to line up against it. A second track is a
// different, larger feature (waveforms, compositing, independent trims) --
// this is only ever asked to place things that already play back to back.
import { define } from "../internals/register.js";
import { el } from "../internals/el.js";
import { wireDragReorder } from "../internals/dragReorder.js";
/** A clip's face: the result so far, or the glyph that stands in for one --
* the same visual language cx-cell-thumb uses elsewhere in the kit, but
* filling the clip's own fixed-height box rather than a 16:10 box of its
* own: a clip's width tracks real duration, and that box can run to
* hundreds of pixels wide. */
function faceOf(item) {
const thumb = el("div", { cls: "cx-track-face" });
const glyph = () => el("span", { cls: "cx-cell-glyph", text: item.icon || "▦", attrs: { "aria-hidden": "true" } });
if (item.thumb) {
const img = el("img", { cls: "cx-cell-img", attrs: { src: item.thumb, alt: "", loading: "lazy" } });
// A thumbnail that fails to load falls back to the glyph, not the
// browser's broken-image icon -- which would read as a damaged file.
img.addEventListener("error", () => img.replaceWith(glyph()));
thumb.append(img);
} else {
thumb.append(glyph());
}
if (item.badge) thumb.append(el("span", { cls: "cx-cell-badge", text: item.badge }));
return thumb;
}
// A tick every 1/2/5/10... seconds, whichever is the smallest that still
// leaves room to read the label beside it. Fixed intervals rather than a tick
// per clip: what a person reads off a ruler is elapsed TIME, and a tick per
// scene is the scene-index ruler this replaces.
const NICE_INTERVALS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600];
const MIN_TICK_PX = 56;
function tickInterval(pxPerSecond) {
for (const s of NICE_INTERVALS) if (s * pxPerSecond >= MIN_TICK_PX) return s;
return NICE_INTERVALS[NICE_INTERVALS.length - 1];
}
function formatTime(seconds) {
const whole = Math.max(0, Math.round(seconds));
const m = Math.floor(whole / 60);
const s = whole % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
// A floor, not a cap: a scene under a second would otherwise render as a
// sliver nothing could click or even see next to a normal one. Everything
// else stays exactly proportional -- this only rescues the degenerate case.
//
// Known, accepted limit: several floored clips in a row still paint on top of
// each other (later DOM order wins), so a run of very short adjacent scenes
// can visually collapse into just the last one. Fixing that by shifting later
// clips right to make room was tried and rejected -- the pushed offset never
// resolves once introduced (nothing shrinks to give the room back), which
// desyncs every clip after the run from the ruler and playhead for the rest
// of the track. That failure is worse than an occasional occluded sliver, so
// exact position over the seconds axis wins; seeking by click still resolves
// correctly either way, since that reads time, not which clip is on top.
const MIN_CLIP_PX = 24;
/**
* track.default({ items, pxPerSecond, selection, playhead, label,
* onSeek, onReorder })
*
* `items`: [{ id, label, thumb, icon, badge, rating, excluded, start,
* duration }] -- start/duration in SECONDS, contiguous (each
* item's start is the previous one's start + duration), which is what lets
* one click handler double as both "seek" and "select": every point on the
* track belongs to exactly one clip. The track's own span is read off the
* items themselves (the last one's start + duration) -- nothing else here
* knows the project's length.
*
* `onSeek(seconds, item)` fires on any click on the track -- the ruler, the
* empty background, or a clip. `item` is whichever clip's span the point
* (or, for a clip itself, the clip clicked) falls in.
*/
define("track", "default", ({
items = [], pxPerSecond: initialPxPerSecond = 40, selection = [], playhead = 0,
label, onSeek, onReorder,
} = {}) => {
let pxPerSecond = initialPxPerSecond;
const chosen = new Set(selection);
let current = items;
const spanOf = () => current.reduce((max, i) => Math.max(max, i.start + i.duration), 0);
const node = el("div", { cls: "cx-track", attrs: { role: "presentation", "aria-label": label } });
const inner = el("div", { cls: "cx-track-inner" });
const ruler = el("div", { cls: "cx-track-ruler", attrs: { role: "presentation" } });
const clips = el("div", { cls: "cx-track-clips", attrs: { role: "listbox", "aria-label": label } });
const head = el("div", { cls: "cx-track-playhead", attrs: { "aria-hidden": "true" } });
inner.append(ruler, clips, head);
node.append(inner);
function drawRuler(span) {
ruler.replaceChildren();
const interval = tickInterval(pxPerSecond);
for (let t = 0; t <= span; t += interval) {
const tick = el("span", { cls: "cx-track-tick", text: formatTime(t) });
tick.style.insetInlineStart = `${t * pxPerSecond}px`;
ruler.append(tick);
}
}
function drawClips() {
clips.replaceChildren();
for (const item of current) {
const on = chosen.has(item.id);
const cell = el("button", {
cls: ["cx-track-clip", on ? "cx-on" : null, item.rating ? "cx-rated" : null,
item.excluded ? "cx-excluded" : null, "cx-focusable"],
attrs: {
type: "button", role: "option", "aria-selected": String(on), title: item.label,
"aria-label": item.label, "data-rating": item.rating || undefined,
"data-id": item.id,
// Draggable whether or not `onReorder` is given, matching the
// strip: a drag with nowhere to report to is inert, not wired.
draggable: "true",
},
children: [faceOf(item)],
});
// Positioned from its own start, not stacked after the previous clip in
// normal flow -- a clip under the pixel floor would otherwise push
// every clip after it further right than the ruler and the playhead,
// which both stay in pure seconds*pxPerSecond, ever agree it is.
cell.style.insetInlineStart = `${item.start * pxPerSecond}px`;
cell.style.width = `${Math.max(MIN_CLIP_PX, item.duration * pxPerSecond)}px`;
clips.append(cell);
}
}
function setPlayhead(seconds) {
head.style.insetInlineStart = `${Math.max(0, seconds) * pxPerSecond}px`;
}
function draw() {
const span = spanOf();
inner.style.width = `${span * pxPerSecond}px`;
drawRuler(span);
drawClips();
setPlayhead(playhead);
}
wireDragReorder(clips, ".cx-track-clip", onReorder);
// One handler for the whole track: a click's OWN position is turned into a
// time, the same way a scrub bar would, whether it landed on the ruler, the
// empty background, or a clip -- a clip covers most of the track's area, so
// reading its id instead of where on it the click landed would make the
// ruler the only place a real, exact-second seek ever reaches. Keyboard
// activation (Enter/Space on a focused clip) still resolves correctly: the
// browser reports the click at that button's own on-screen position, which
// is well inside the right clip's span even if not at its exact start.
inner.addEventListener("click", (e) => {
if (!onSeek) return;
const rect = inner.getBoundingClientRect();
const seconds = Math.min(spanOf(), Math.max(0, (e.clientX - rect.left) / pxPerSecond));
const item = current.find((i) => seconds < i.start + i.duration) || current[current.length - 1] || null;
onSeek(seconds, item);
});
draw();
return {
node,
setItems(next = []) { current = next; draw(); },
setValue(next = []) { chosen.clear(); for (const id of next) chosen.add(id); drawClips(); },
setPlayhead(seconds) { playhead = seconds; setPlayhead(seconds); },
setZoom(next) { pxPerSecond = next; draw(); },
destroy: () => node.remove(),
};
});
+62
View File
@@ -0,0 +1,62 @@
/* The ring is an INSET shadow, not a border. A border pushes every absolutely
* positioned child inward by its own width, which put the hub a pixel past the
* screen edge on an edge panel -- the one place a pixel shows. */
.cx-wheel {
position: fixed; pointer-events: auto; border-radius: var(--r-pill);
background: color-mix(in srgb, var(--elev-3) 92%, transparent);
box-shadow: var(--shadow-3), inset 0 0 0 var(--bw) var(--line);
backdrop-filter: blur(6px);
animation: cx-wheel-in var(--dur-1) var(--ease-standard);
}
@keyframes cx-wheel-in { from { opacity: 0; transform: scale(.9); } }
/* The lit shape IS the region that picks the item, so the target is legible
* instead of implied by a box around a label. */
.cx-wheel-canvas { position: absolute; inset: 0; pointer-events: none; }
.cx-wheel-wedge {
fill: transparent;
stroke: color-mix(in srgb, var(--line) 70%, transparent);
stroke-width: 1;
transition: fill var(--dur-1) var(--ease-standard), stroke var(--dur-1) var(--ease-standard);
}
.cx-wheel-wedge.cx-on { fill: var(--accent-soft); stroke: var(--accent); }
.cx-wheel-item {
position: absolute; transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center; gap: 2px;
min-width: 52px; padding: var(--sp-1) var(--sp-2);
background: transparent; border: 0; color: var(--muted);
font-size: var(--fs-7); cursor: pointer; text-align: center;
transition: color var(--dur-1) var(--ease-standard),
transform var(--dur-1) var(--ease-standard);
}
.cx-wheel-item .cx-wheel-icon { font-size: var(--fs-2); line-height: 1; }
.cx-wheel-item.cx-on {
color: var(--accent-hi);
transform: translate(-50%, -50%) scale(1.06);
}
/* Sized to the dead zone exactly (radius 42), so the cancel target you can see
* is the cancel target you can hit. */
/* left/top/size come from the geometry so the drawn hub and the cancel region
* are the same circle. */
.cx-wheel-hub {
position: absolute; transform: translate(-50%, -50%);
border-radius: var(--r-pill);
border: var(--bw) dashed var(--line);
display: flex; align-items: center; justify-content: center;
color: var(--faint);
transition: border-color var(--dur-1) var(--ease-standard),
color var(--dur-1) var(--ease-standard),
background var(--dur-1) var(--ease-standard);
}
.cx-wheel-hub-glyph { font-size: var(--fs-2); line-height: 1; }
.cx-wheel-hub.cx-on {
border-color: var(--danger); color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
/* Half wheels sit ON the edge: the off-screen half is simply clipped by the
* viewport, which is what gives the edge-panel silhouette. The flat side gets a
* straight edge so it reads as pinned rather than cropped by accident. */
.cx-wheel-half { backdrop-filter: blur(8px); }
+330
View File
@@ -0,0 +1,330 @@
// Radial pickers: the full wheel, and the half wheel pinned to a screen edge.
//
// Nothing in v4 resembles these. They are for the handful of choices made over
// and over during editing, where a menu costs a read-and-aim every time: press,
// flick toward the wedge you already know the position of, release. The muscle
// memory is the feature -- the same choice always lives at the same angle.
//
// One GEOMETRY object per variant drives the drawn wedges, the label placement,
// the hub and the hit test, so what you see and what you can hit cannot drift
// apart.
import { define } from "../internals/register.js";
import { el, svg } from "../internals/el.js";
import { mount, unmount } from "../internals/portal.js";
import { claimFor } from "../internals/zlayer.js";
import { push } from "../internals/dismiss.js";
const MIN = 2;
const MAX = 12;
/** The full circle, opened wherever the pointer is. */
export const FULL = {
size: 240,
rIn: 42, rOut: 117, rLabel: 84,
hub: { r: 42, x: 0, y: 0 },
};
/**
* Half a circle, centred ON an edge.
*
* The hub is pushed inward by its own radius so it sits fully on screen -- a hub
* centred on the edge is sliced in half by it, and half a cancel target is not
* a target. Everything else is sized around that: the wedges start beyond the
* hub so the two never overlap.
*/
export const HALF = {
size: 300,
rIn: 84, rOut: 147, rLabel: 115,
hub: { r: 40, x: 0, y: 0 }, // x/y filled in per edge, below
};
// Which half faces inward from each edge, where a panel pinned there sits, and
// which way "inward" points. An edge panel opens AWAY from its edge, which is
// the whole reason it works with a thumb: the arc lands where the thumb is.
export const EDGES = {
right: { startDeg: 180, spanDeg: 180, inward: [-1, 0], anchor: (w, h, at) => [w, at ?? h / 2] },
left: { startDeg: 0, spanDeg: 180, inward: [1, 0], anchor: (w, h, at) => [0, at ?? h / 2] },
bottom: { startDeg: 270, spanDeg: 180, inward: [0, -1], anchor: (w, h, at) => [at ?? w / 2, h] },
top: { startDeg: 90, spanDeg: 180, inward: [0, 1], anchor: (w, h, at) => [at ?? w / 2, 0] },
};
const point = (cx, cy, radius, degrees) => {
const rad = (degrees - 90) * (Math.PI / 180);
return [cx + Math.cos(rad) * radius, cy + Math.sin(rad) * radius];
};
/**
* Which sector of an arc a point falls in; -1 for the hub or anywhere off the
* ring.
*
* Angles run clockwise from straight up, so 0 is 12 o'clock and the arrangement
* matches how people describe it ("the one at the top"). Everything radial goes
* through here, so a full wheel and an edge panel cannot disagree about which
* item a direction means.
*
* The bounds matter as much as the angle: the lit wedge IS the region that
* picks the item, so a pointer beyond the rim -- or over the hub -- is pointing
* at nothing, and highlighting an item there would promise a pick that release
* would not deliver.
*/
export function sectorInArc(dx, dy, count, {
startDeg = 0, spanDeg = 360,
rIn = FULL.rIn, rOut = FULL.rOut,
hubX = 0, hubY = 0, hubR = rIn,
bounded = true,
} = {}) {
if (Math.hypot(dx - hubX, dy - hubY) < hubR) return -1; // over the hub: cancel
const distance = Math.hypot(dx, dy);
if (bounded && (distance < rIn || distance > rOut)) return -1;
const angle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
const rel = (angle - startDeg + 720) % 360;
if (rel > spanDeg) return -1; // outside a partial arc
return Math.min(Math.floor(rel / (spanDeg / count)), count - 1);
}
/** The full circle: item 0 centred at the top. */
export function sectorAt(dx, dy, count, deadZone = FULL.hub.r) {
return sectorInArc(dx, dy, count, {
startDeg: -(360 / count) / 2, spanDeg: 360,
rIn: deadZone, rOut: FULL.rOut, hubR: deadZone,
});
}
/**
* An annular wedge: the actual area that picks this item.
*
* Highlighting the wedge rather than a box around the label is what makes the
* target legible -- the lit shape IS the region your pointer has to be in, so
* aiming stops being a guess about where one item's zone ends.
*/
export function wedgePath(index, count, geo = {}) {
const {
size = FULL.size, rIn = FULL.rIn, rOut = FULL.rOut,
startDeg = -(360 / count) / 2, spanDeg = 360,
} = geo;
const cx = size / 2;
const cy = size / 2;
const slice = spanDeg / count;
const start = startDeg + index * slice;
const end = start + slice;
const largeArc = slice > 180 ? 1 : 0;
const [x1, y1] = point(cx, cy, rOut, start);
const [x2, y2] = point(cx, cy, rOut, end);
const [x3, y3] = point(cx, cy, rIn, end);
const [x4, y4] = point(cx, cy, rIn, start);
const f = (n) => Number(n.toFixed(2));
return `M ${f(x1)} ${f(y1)} A ${rOut} ${rOut} 0 ${largeArc} 1 ${f(x2)} ${f(y2)} ` +
`L ${f(x3)} ${f(y3)} A ${rIn} ${rIn} 0 ${largeArc} 0 ${f(x4)} ${f(y4)} Z`;
}
function buildWheel({ items, onPick, onClose, geo, recentre, cancelOnCentre, cls, hubText }) {
if (items.length < MIN || items.length > MAX) {
throw new RangeError(`A picker takes ${MIN}-${MAX} items; got ${items.length}. More than that and nobody can aim.`);
}
const { size } = geo;
const centre = recentre();
const node = el("div", { cls, attrs: { role: "menu", "aria-label": "Quick pick" } });
const layer = claimFor("popover", node);
node.style.width = `${size}px`;
node.style.height = `${size}px`;
const place = () => {
node.style.left = `${centre.x - size / 2}px`;
node.style.top = `${centre.y - size / 2}px`;
};
place();
let active = -1;
// The wedges are drawn behind the labels, so the highlight is the pickable
// region itself rather than a box around some text.
const canvas = svg("svg", { class: "cx-wheel-canvas", viewBox: `0 0 ${size} ${size}`,
width: size, height: size, "aria-hidden": "true" });
const shapes = items.map((_, i) => {
const path = svg("path", { class: "cx-wheel-wedge", d: wedgePath(i, items.length, geo) });
canvas.append(path);
return path;
});
node.append(canvas);
const slice = geo.spanDeg / items.length;
const wedges = items.map((item, i) => {
const angle = geo.startDeg + slice * (i + 0.5);
const wedge = el("button", {
cls: ["cx-wheel-item", "cx-focusable"],
attrs: { type: "button", role: "menuitem", title: item.label },
children: [
item.icon ? el("span", { cls: "cx-wheel-icon", text: item.icon, attrs: { "aria-hidden": "true" } }) : null,
el("span", { cls: "cx-wheel-label", text: item.label }),
].filter(Boolean),
});
// Placed on a circle rather than drawn as a pie slice: a round label that
// stays upright is far easier to read than text bent around a wedge.
const [lx, ly] = point(size / 2, size / 2, geo.rLabel, angle);
wedge.style.left = `${lx}px`;
wedge.style.top = `${ly}px`;
wedge.addEventListener("click", () => commit(i));
node.append(wedge);
return wedge;
});
// A glyph, not a sentence: the hub is small and round, and wrapped text in a
// circle reads badly at any size. The label survives as the accessible name.
const hub = el("div", {
cls: "cx-wheel-hub",
attrs: cancelOnCentre ? { role: "img", "aria-label": hubText, title: hubText } : { "aria-hidden": "true" },
children: cancelOnCentre ? el("span", { cls: "cx-wheel-hub-glyph", text: "✕" }) : null,
});
hub.style.width = `${geo.hub.r * 2}px`;
hub.style.height = `${geo.hub.r * 2}px`;
hub.style.left = `${size / 2 + geo.hub.x}px`;
hub.style.top = `${size / 2 + geo.hub.y}px`;
node.append(hub);
function highlight(index) {
active = index;
wedges.forEach((w, i) => w.classList.toggle("cx-on", i === index));
shapes.forEach((sh, i) => sh.classList.toggle("cx-on", i === index));
hub.classList.toggle("cx-on", index === -1);
}
function commit(index) {
if (index >= 0 && items[index] && onPick) onPick(items[index]);
handle.close(index >= 0 ? "picked" : "cancelled");
}
// Press-and-flick: the pointer never has to land on the item, only point at
// it, which is what makes this faster than a menu.
//
// `armed` exists because the click that OPENS the wheel also delivers a
// pointerup to this listener, at wherever the opening control happened to be
// -- which committed whatever wedge that direction pointed at, instantly.
// Requiring a pointermove first means a release only counts once the pointer
// has actually gone somewhere.
let armed = false;
const at = (event) => sectorInArc(
event.clientX - centre.x, event.clientY - centre.y, items.length,
{ ...geo, hubX: geo.hub.x, hubY: geo.hub.y, hubR: cancelOnCentre ? geo.hub.r : 0 });
const onMove = (event) => { armed = true; highlight(at(event)); };
const onUp = (event) => {
if (!armed) return;
armed = false;
const index = at(event);
if (index >= 0) commit(index);
};
// An edge panel is pinned to an edge, so the edge moving is the one thing it
// must follow. Without this it keeps the pixel position it opened at and
// drifts into the middle of the window on a resize.
const onResize = () => {
Object.assign(centre, recentre());
place();
};
window.addEventListener("resize", onResize);
// visualViewport covers what window.resize misses: pinch-zoom, and mobile
// browser chrome sliding in and out, both of which move the edge without
// changing innerWidth.
window.visualViewport?.addEventListener("resize", onResize);
const onKeyDown = (event) => {
const n = items.length;
if (/^[1-9]$/.test(event.key)) {
const i = Number(event.key) - 1;
if (i < n) { event.preventDefault(); commit(i); }
} else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault(); highlight(((active < 0 ? -1 : active) + 1 + n) % n);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault(); highlight(((active < 0 ? 1 : active) - 1 + n) % n);
} else if (event.key === "Enter" && active >= 0) {
event.preventDefault(); commit(active);
}
};
mount(node);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("keydown", onKeyDown);
const dismissal = push({ nodes: node, onDismiss: (reason) => handle.close(reason) });
let closed = false;
const handle = {
node,
isOverlay: true,
get active() { return active; },
get centre() { return { ...centre }; },
highlight,
close(reason = "manual") {
if (closed) return;
closed = true;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
window.visualViewport?.removeEventListener("resize", onResize);
dismissal.release();
layer.release();
unmount(node);
if (onClose) onClose(reason);
},
destroy() { handle.close("destroy"); },
};
return handle;
}
define("wheel", "picker", ({ items = [], onPick, onClose, x, y, cancelOnCentre = true } = {}) =>
buildWheel({
items, onPick, onClose, cancelOnCentre,
cls: "cx-wheel",
hubText: "Release here to cancel",
geo: { ...FULL, startDeg: -(360 / Math.max(items.length, 1)) / 2, spanDeg: 360 },
recentre: () => ({
x: x ?? Math.round(window.innerWidth / 2),
y: y ?? Math.round(window.innerHeight / 2),
}),
}));
/**
* Half a wheel, pinned to a screen edge -- the Samsung edge-panel shape.
*
* A control living at the edge has nowhere to open a full wheel: half of it
* would be off-screen, and the items that landed there would be unreachable.
* This puts the centre ON the edge and fans the items into the screen, so the
* arc lands exactly where the thumb already is and every item is in reach.
*
* `at` is the position along that edge (y for left/right, x for top/bottom);
* it defaults to the middle, and is re-derived whenever the window resizes.
*/
define("wheel", "half", ({ items = [], edge = "right", at, onPick, onClose, cancelOnCentre = true } = {}) => {
const spec = EDGES[edge];
if (!spec) throw new RangeError(`Unknown edge "${edge}". Known: ${Object.keys(EDGES).join(", ")}.`);
const [ix, iy] = spec.inward;
const geo = {
...HALF,
startDeg: spec.startDeg,
spanDeg: spec.spanDeg,
// Pushed inward by its own radius, so the whole hub is on screen instead of
// being sliced down the middle by the edge it sits on.
hub: { r: HALF.hub.r, x: ix * HALF.hub.r, y: iy * HALF.hub.r },
};
return buildWheel({
items, onPick, onClose, cancelOnCentre,
cls: `cx-wheel cx-wheel-half cx-wheel-${edge}`,
hubText: "Release here to cancel",
geo,
recentre: () => {
const [cx, cy] = spec.anchor(window.innerWidth, window.innerHeight, at);
return { x: cx, y: cy };
},
});
});
+113
View File
@@ -0,0 +1,113 @@
// Measure, flip, clamp -- once.
//
// v4 had seven hand-rolled copies of this (autocomplete, suggestions, rating
// picker, role menu, choice picker, timeline dropdown, composer add-menu), each
// with its own idea of what to do near an edge. The geometry here is pure and
// takes plain rectangles, so it can be tested exhaustively without a browser;
// `anchorTo` is the thin part that touches the DOM.
const OPPOSITE = { top: "bottom", bottom: "top", left: "right", right: "left" };
/**
* place({ anchor, float, viewport, side, align, gap, flip, clamp, padding })
*
* anchor {x, y, width, height} the thing being pointed at, in viewport coords
* float {width, height} the thing being positioned
* viewport {width, height}
*
* Returns { x, y, side, align, flipped, clamped }. `side` is where the float
* ENDED UP, which is what a caller needs to point an arrow the right way.
*/
export function place({
anchor,
float,
viewport,
side = "bottom",
align = "start",
gap = 6,
flip = true,
clamp = true,
padding = 8,
} = {}) {
if (!OPPOSITE[side]) throw new RangeError(`Unknown side "${side}".`);
let chosen = side;
let flipped = false;
if (flip && !fits(chosen, anchor, float, viewport, gap, padding)) {
const other = OPPOSITE[chosen];
// Only flip if the other side is genuinely better -- flipping into an even
// tighter gap just moves the clipping somewhere less expected.
if (room(other, anchor, viewport) > room(chosen, anchor, viewport)) {
chosen = other;
flipped = true;
}
}
let { x, y } = position(chosen, align, anchor, float, gap);
let clamped = false;
if (clamp) {
const cx = Math.min(Math.max(x, padding), Math.max(padding, viewport.width - float.width - padding));
const cy = Math.min(Math.max(y, padding), Math.max(padding, viewport.height - float.height - padding));
clamped = cx !== x || cy !== y;
x = cx;
y = cy;
}
return { x: Math.round(x), y: Math.round(y), side: chosen, align, flipped, clamped };
}
function room(side, anchor, viewport) {
switch (side) {
case "top": return anchor.y;
case "bottom": return viewport.height - (anchor.y + anchor.height);
case "left": return anchor.x;
case "right": return viewport.width - (anchor.x + anchor.width);
default: return 0;
}
}
function fits(side, anchor, float, viewport, gap, padding) {
const needed = (side === "top" || side === "bottom" ? float.height : float.width) + gap + padding;
return room(side, anchor, viewport) >= needed;
}
function position(side, align, anchor, float, gap) {
const vertical = side === "top" || side === "bottom";
const main = vertical
? (side === "bottom" ? anchor.y + anchor.height + gap : anchor.y - float.height - gap)
: (side === "right" ? anchor.x + anchor.width + gap : anchor.x - float.width - gap);
const anchorStart = vertical ? anchor.x : anchor.y;
const anchorSize = vertical ? anchor.width : anchor.height;
const floatSize = vertical ? float.width : float.height;
let cross;
if (align === "center") cross = anchorStart + anchorSize / 2 - floatSize / 2;
else if (align === "end") cross = anchorStart + anchorSize - floatSize;
else cross = anchorStart;
return vertical ? { x: cross, y: main } : { x: main, y: cross };
}
/**
* Position `floatNode` against `anchorNode`. The float must already be in the
* document and visible to measure -- a hidden element measures as 0x0 and lands
* in the corner, which is how "my popover appears top-left" bugs happen.
*/
export function anchorTo(floatNode, anchorNode, opts = {}) {
const a = anchorNode.getBoundingClientRect();
const f = floatNode.getBoundingClientRect();
const result = place({
anchor: { x: a.x, y: a.y, width: a.width, height: a.height },
float: { width: f.width, height: f.height },
viewport: { width: window.innerWidth, height: window.innerHeight },
...opts,
});
floatNode.style.position = "fixed";
floatNode.style.left = `${result.x}px`;
floatNode.style.top = `${result.y}px`;
floatNode.dataset.side = result.side;
return result;
}
+56
View File
@@ -0,0 +1,56 @@
// Adaptive sizing from the CONTAINER, not the viewport.
//
// v4's media bin got this right and nothing else used it: a panel that is 280px
// wide should lay out like a 280px panel whether the window is 1200px or 3000px.
// The column ratios there were hand-tuned (92/44/29/21 cqw) and drifted from the
// gaps they were meant to account for; here they are derived.
export const AUTO = 0;
export const MAX_COLS = 4;
/**
* Cell width for `cols` columns inside a container, as a cqw percentage,
* accounting for the gaps between them. cols = 0 means "as many as fit".
*/
export function cellWidth(cols, gapPercent = 2.5) {
if (!Number.isInteger(cols) || cols < 1) return null; // auto
const gaps = (cols - 1) * gapPercent;
return Math.max(1, Number(((100 - gaps) / cols).toFixed(2)));
}
/** The CSS value for `--cell` at a given column count. */
export function cellExpression(cols) {
const pct = cellWidth(cols);
return pct === null
? "clamp(var(--cell-thumb), 46cqw, var(--cell-panel))"
: `${pct}cqw`;
}
export function normaliseCols(value) {
const n = Number(value);
if (!Number.isFinite(n)) return AUTO;
return Math.min(MAX_COLS, Math.max(AUTO, Math.trunc(n)));
}
/** Apply a column choice to a grid host. */
export function applyDensity(host, cols) {
const n = normaliseCols(cols);
host.dataset.cols = String(n);
host.style.setProperty("--cell", cellExpression(n === AUTO ? null : n));
return n;
}
// Per-viewer convenience, not state anything depends on: a browser that refuses
// storage should still get a working grid.
export function rememberCols(id, cols) {
try { localStorage.setItem(`cx.density.${id}`, String(normaliseCols(cols))); } catch { /* private mode */ }
}
export function recallCols(id, fallback = AUTO) {
try {
const raw = localStorage.getItem(`cx.density.${id}`);
return raw === null ? fallback : normaliseCols(raw);
} catch {
return fallback;
}
}
+96
View File
@@ -0,0 +1,96 @@
// One dismissal stack for the whole app.
//
// Each overlay in v4 wired its own outside-click and Escape handler, so Escape
// inside a modal with an open autocomplete closed the modal -- every listener
// fired, and none of them knew about the others. Here there is a single pair of
// document listeners over a stack, and Escape pops the TOP entry only.
const stack = [];
let listening = false;
function onKeyDown(event) {
if (event.key !== "Escape" || !stack.length) return;
const top = stack[stack.length - 1];
if (!top.closeOnEsc) return;
// stopPropagation matters: without it a parent's own key handler also runs
// and closes a second layer on one press.
event.stopPropagation();
event.preventDefault();
top.dismiss("escape");
}
function onPointerDown(event) {
if (!stack.length) return;
const top = stack[stack.length - 1];
if (!top.closeOnOutside) return;
for (const node of top.nodes) {
// Guarded: an anchor can be a VIRTUAL point (a context menu is anchored to
// coordinates, not an element), and those have no .contains. Letting one
// through threw on the first click anywhere in the app.
if (node && typeof node.contains === "function" && node.contains(event.target)) return;
}
top.dismiss("outside");
}
function listen() {
if (listening) return;
// Capture phase, so an overlay that stops propagation on its own content
// cannot accidentally suppress dismissal of something above it.
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("pointerdown", onPointerDown, true);
listening = true;
}
function unlisten() {
if (!listening) return;
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("pointerdown", onPointerDown, true);
listening = false;
}
/**
* push({ nodes, onDismiss, closeOnEsc, closeOnOutside }) -> handle
*
* `nodes` are the regions that count as "inside" -- a popover plus the control
* that opened it, so clicking the trigger again does not dismiss-then-reopen.
*/
export function push({ nodes = [], onDismiss, closeOnEsc = true, closeOnOutside = true } = {}) {
const entry = {
// Only real nodes can answer "was the click inside me".
nodes: [].concat(nodes).filter((n) => n && typeof n.contains === "function"),
closeOnEsc,
closeOnOutside,
done: false,
dismiss(reason) {
if (entry.done) return;
entry.done = true;
remove(entry);
if (onDismiss) onDismiss(reason);
},
};
stack.push(entry);
listen();
return {
dismiss: (reason = "manual") => entry.dismiss(reason),
// Closing without running onDismiss: the caller is already tearing down.
release() {
entry.done = true;
remove(entry);
},
get depth() { return stack.indexOf(entry) + 1; },
get isTop() { return stack[stack.length - 1] === entry; },
};
}
function remove(entry) {
const i = stack.indexOf(entry);
if (i !== -1) stack.splice(i, 1);
if (!stack.length) unlisten();
}
export const depth = () => stack.length;
export function _resetDismiss() {
stack.length = 0;
unlisten();
}
+64
View File
@@ -0,0 +1,64 @@
// Pointer drags for windows, sliders and splitters.
//
// Pointer capture rather than document-level listeners: capture follows the
// pointer outside the window and guarantees a matching pointerup, so a drag
// cannot be left running because the cursor left the viewport mid-gesture.
/**
* drag(handle, { onStart, onMove, onEnd, button })
*
* onMove receives { dx, dy, x, y } relative to where the drag began.
* Returns a dispose function.
*/
export function drag(handle, { onStart, onMove, onEnd, button = 0 } = {}) {
let origin = null;
let pointerId = null;
const onPointerDown = (event) => {
if (event.button !== button || origin) return;
origin = { x: event.clientX, y: event.clientY };
pointerId = event.pointerId;
if (handle.setPointerCapture) handle.setPointerCapture(pointerId);
handle.addEventListener("pointermove", onPointerMove);
handle.addEventListener("pointerup", onPointerUp);
handle.addEventListener("pointercancel", onPointerUp);
event.preventDefault();
if (onStart) onStart({ x: origin.x, y: origin.y, event });
};
const delta = (event) => ({
dx: event.clientX - origin.x,
dy: event.clientY - origin.y,
x: event.clientX,
y: event.clientY,
});
const onPointerMove = (event) => {
if (!origin || event.pointerId !== pointerId) return;
if (onMove) onMove({ ...delta(event), event });
};
const onPointerUp = (event) => {
if (!origin || event.pointerId !== pointerId) return;
const final = delta(event);
stop();
if (onEnd) onEnd({ ...final, event, cancelled: event.type === "pointercancel" });
};
function stop() {
handle.removeEventListener("pointermove", onPointerMove);
handle.removeEventListener("pointerup", onPointerUp);
handle.removeEventListener("pointercancel", onPointerUp);
if (pointerId !== null && handle.releasePointerCapture && handle.hasPointerCapture?.(pointerId)) {
handle.releasePointerCapture(pointerId);
}
origin = null;
pointerId = null;
}
handle.addEventListener("pointerdown", onPointerDown);
return function dispose() {
stop();
handle.removeEventListener("pointerdown", onPointerDown);
};
}
+46
View File
@@ -0,0 +1,46 @@
// Reordering a row of id-tagged children by drag, shared by every element that
// has one (the scene strip, the timeline track).
//
// Delegated on the container: cells are torn down and rebuilt on every redraw,
// and listeners bound to them would have to be reattached every time. One set
// here survives all of that.
//
// The dragged item's id lives on `dataTransfer`, not in a closure variable. A
// closure var tracks by NOTHING the browser understands, so nothing keeps it in
// step with the actual drag session: `dragend` fires on the ORIGINAL source
// element, and a redraw mid-drag (an unrelated remove/undo firing on its own
// input channel while the mouse is still held down) can detach that element
// from the tree -- its `dragend` then never bubbles here, and a closure var set
// at dragstart is never cleared, ready to be picked up by the next unrelated
// drop that lands on this row from somewhere else entirely. `dataTransfer` has
// no such gap: it belongs to the actual OS-level drag session, unaffected by
// which DOM node currently receives the events, and a drop from any OTHER
// session simply never had this MIME type set.
const MIME = "text/x-funpack-reorder";
/** wireDragReorder(node, ".cx-cell-selector", (draggedId, targetId) => {}) */
export function wireDragReorder(node, cellSelector, onReorder) {
const cellOf = (e) => e.target.closest(cellSelector);
node.addEventListener("dragstart", (e) => {
const cell = cellOf(e);
if (!cell || !e.dataTransfer) return;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData(MIME, cell.dataset.id || "");
cell.classList.add("cx-dragging");
});
node.addEventListener("dragend", (e) => { cellOf(e)?.classList.remove("cx-dragging"); });
node.addEventListener("dragover", (e) => {
// `getData` only returns the real value on "drop" (a browser security
// restriction during drag) -- `types` is what dragover can actually read.
if (e.dataTransfer && e.dataTransfer.types.includes(MIME)) e.preventDefault();
});
node.addEventListener("drop", (e) => {
if (!e.dataTransfer) return;
const draggedId = e.dataTransfer.getData(MIME);
if (!draggedId) return;
e.preventDefault();
const cell = cellOf(e);
const targetId = cell ? cell.dataset.id : null;
if (onReorder && targetId && targetId !== draggedId) onReorder(draggedId, targetId);
});
}
+66
View File
@@ -0,0 +1,66 @@
// The only document.createElement in the app.
//
// Everything the kit renders comes through here, which is what makes the
// no-styling rule checkable: a module has no route to a raw element, and the
// scanner only has one construction site to watch.
import { asText } from "./text.js";
const UNSAFE_ATTRS = new Set(["style", "class", "srcdoc", "onload", "onerror"]);
/**
* el("div", { cls, text, attrs, on, children })
*
* `cls` takes kit class names only -- callers inside the kit. `text` goes
* through asText. `attrs` refuses style/class and any inline handler, so no
* amount of prop-forwarding can turn data into presentation or script.
*/
export function el(tag, opts = {}) {
const node = document.createElement(tag);
const { cls, text, attrs, on, children } = opts;
if (cls) node.className = Array.isArray(cls) ? cls.filter(Boolean).join(" ") : cls;
if (text !== undefined) node.appendChild(asText(text));
if (attrs) {
for (const [name, value] of Object.entries(attrs)) {
if (value == null || value === false) continue;
const lower = name.toLowerCase();
if (UNSAFE_ATTRS.has(lower) || lower.startsWith("on")) {
throw new TypeError(`Refusing to set "${name}": presentation and handlers are the kit's, not a caller's.`);
}
node.setAttribute(name, value === true ? "" : String(value));
}
}
if (on) for (const [event, handler] of Object.entries(on)) node.addEventListener(event, handler);
if (children) for (const child of [].concat(children)) if (child) node.appendChild(child);
return node;
}
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* An SVG element. Separate because SVG needs createElementNS -- createElement
* would produce an HTMLUnknownElement that renders as nothing at all.
* Attributes only: SVG carries no module content, so there is no text path here.
*/
export function svg(tag, attrs = {}) {
const node = document.createElementNS(SVG_NS, tag);
for (const [name, value] of Object.entries(attrs)) {
if (value == null || value === false) continue;
node.setAttribute(name, String(value));
}
return node;
}
/** A detached fragment. Panels build into one so a half-built tree never lands. */
export function frag() {
return document.createDocumentFragment();
}
export function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
return node;
}
+91
View File
@@ -0,0 +1,91 @@
// Keyboard reachability: a trap for overlays, and roving tabindex for groups.
const FOCUSABLE = [
"a[href]", "button:not([disabled])", "input:not([disabled])",
"select:not([disabled])", "textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
// Hiding is inherited: a control inside a hidden or aria-hidden subtree is
// unreachable however focusable it looks on its own, and trapping focus onto one
// strands the keyboard on something invisible.
const HIDDEN = '[hidden], [aria-hidden="true"]';
export function focusables(container) {
return [...container.querySelectorAll(FOCUSABLE)].filter((n) => {
const hidden = n.closest(HIDDEN);
return !hidden || !container.contains(hidden);
});
}
/**
* Keep Tab inside `container` until released, and put focus back where it was.
*
* Restoring matters as much as trapping: closing a dialog and dropping focus to
* <body> silently ends keyboard navigation, and the only way back is the mouse.
*/
export function trap(container, { initial } = {}) {
const previous = document.activeElement;
const onKeyDown = (event) => {
if (event.key !== "Tab") return;
const items = focusables(container);
if (!items.length) { event.preventDefault(); return; }
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
// Wrapping is also the fix for focus having escaped the container entirely
// (a click outside, a removed node): either edge pulls it back in.
if (event.shiftKey && (active === first || !container.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (active === last || !container.contains(active))) {
event.preventDefault();
first.focus();
}
};
container.addEventListener("keydown", onKeyDown);
const target = initial || focusables(container)[0] || container;
if (target && target.focus) target.focus();
return function release({ restore = true } = {}) {
container.removeEventListener("keydown", onKeyDown);
if (restore && previous && previous.isConnected && previous.focus) previous.focus();
};
}
/**
* One tab stop for a group, arrows to move within it -- what a segmented
* control, toggle group or menu needs so Tab does not walk every option.
*/
export function roving(container, { orientation = "horizontal", onSelect } = {}) {
const NEXT = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const PREV = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
const items = () => focusables(container);
const setIndex = (i) => {
const list = items();
if (!list.length) return;
const clamped = (i + list.length) % list.length;
list.forEach((n, j) => n.setAttribute("tabindex", j === clamped ? "0" : "-1"));
list[clamped].focus();
if (onSelect) onSelect(list[clamped], clamped);
};
const onKeyDown = (event) => {
const list = items();
const current = list.indexOf(document.activeElement);
if (current === -1) return;
if (event.key === NEXT) { event.preventDefault(); setIndex(current + 1); }
else if (event.key === PREV) { event.preventDefault(); setIndex(current - 1); }
else if (event.key === "Home") { event.preventDefault(); setIndex(0); }
else if (event.key === "End") { event.preventDefault(); setIndex(list.length - 1); }
};
items().forEach((n, i) => n.setAttribute("tabindex", i === 0 ? "0" : "-1"));
container.addEventListener("keydown", onKeyDown);
return () => container.removeEventListener("keydown", onKeyDown);
}
+14
View File
@@ -0,0 +1,14 @@
// Unique ids, so a label can point at its control without the caller inventing
// a naming scheme (and colliding when the same panel is mounted twice).
let n = 0;
export function uid(prefix = "cx") {
n += 1;
return `${prefix}-${n}`;
}
// Tests only: ids are otherwise monotonic for the life of the page.
export function _resetIds() {
n = 0;
}
+36
View File
@@ -0,0 +1,36 @@
// Durations come from the tokens, so "how long" is answered in one place and
// the reduced-motion guard in tokens.css zeroes every one of them at once.
/** "120ms" | "0.12s" | "0" -> milliseconds. */
export function parseDuration(value) {
const text = String(value ?? "").trim();
if (!text) return 0;
const match = /^(-?[\d.]+)(ms|s)?$/.exec(text);
if (!match) return 0;
const n = Number(match[1]);
if (!Number.isFinite(n)) return 0;
return Math.max(0, match[2] === "s" ? n * 1000 : n);
}
export function duration(step = 1) {
const value = getComputedStyle(document.documentElement).getPropertyValue(`--dur-${step}`);
return parseDuration(value);
}
export const prefersReducedMotion = () =>
Boolean(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
/**
* Run `fn` after the element's transition, or immediately when motion is off.
* Always fires: a transitionend that never arrives (the property did not change,
* the node was hidden) would otherwise strand a teardown forever.
*/
export function afterTransition(node, step, fn) {
const ms = duration(step);
if (!ms) { fn(); return () => {}; }
let done = false;
const finish = () => { if (done) return; done = true; node.removeEventListener("transitionend", finish); clearTimeout(timer); fn(); };
const timer = setTimeout(finish, ms + 50);
node.addEventListener("transitionend", finish);
return finish;
}
+53
View File
@@ -0,0 +1,53 @@
// One overlay root, appended to <body>.
//
// Every floating thing lives here rather than beside whatever opened it. An
// overlay nested inside app layout inherits that layout's stacking context, so
// its z-index is measured against its siblings instead of the page -- which is
// how v4 ended up with 99999 and 200000 and still had two things fighting.
let root = null;
export const ROOT_ID = "composer-overlays";
export function portal() {
if (root && root.isConnected) return root;
root = document.getElementById(ROOT_ID);
if (!root) {
root = document.createElement("div");
root.id = ROOT_ID;
// NO POSITIONING, no transform, no filter, no contain, no z-index -- ever.
//
// Each of those makes this element a stacking context, and then the whole
// ladder is measured INSIDE it rather than against the page: every rung,
// from a tooltip at 300 to a modal at 800, collapses to whatever this one
// element resolves to. It said exactly that and then set `position: fixed`,
// which creates a stacking context all by itself -- so a menu at z 500 was
// painted under a button at z 1, and the only reason it was not obvious
// everywhere is that almost nothing in the app claims a z-index at all.
//
// Nothing is needed here anyway: every layer mounted into this root is
// itself `position: fixed`, so a static, zero-sized parent lays nothing out
// and intercepts nothing.
root.style.pointerEvents = "none";
}
if (!root.isConnected) document.body.appendChild(root);
return root;
}
/** Put `node` on the overlay root. Children re-enable their own pointer events. */
export function mount(node) {
node.style.pointerEvents = "auto";
portal().appendChild(node);
return node;
}
export function unmount(node) {
// Both halves matter: with no root yet, an unparented node's parentNode is
// also null, and `null === null` would send removeChild to nothing.
if (root && node && node.parentNode === root) root.removeChild(node);
}
export function _resetPortal() {
if (root && root.isConnected) root.remove();
root = null;
}
+89
View File
@@ -0,0 +1,89 @@
// Announcement, not enumeration -- the kit's own version of the rule the
// backend uses.
//
// `composer.button.large` exists only because elements/button.js called
// define("button", "large", ...). Nothing holds a list of elements, so adding
// one is a single file, and asking for one that was never defined throws rather
// than rendering something approximate. That throw is what makes a module with
// a bespoke element hide itself instead of shipping a broken panel.
export class UnknownElement extends Error {
constructor(group, variant) {
const known = variant === undefined
? `Known groups: ${[...groups()].join(", ") || "(none yet)"}.`
: `Known variants of "${group}": ${[...variants(group)].join(", ") || "(none)"}.`;
super(
variant === undefined
? `composer.${group} is not a Composer element group. ${known}`
: `composer.${group}.${variant} is not a Composer element. ${known}`
);
this.name = "UnknownElement";
this.group = group;
this.variant = variant;
}
}
const registry = new Map(); // group -> Map(variant -> factory)
export function define(group, variant, factory) {
if (typeof factory !== "function") {
throw new TypeError(`define("${group}", "${variant}", ...) needs a factory function.`);
}
if (!registry.has(group)) registry.set(group, new Map());
const g = registry.get(group);
if (g.has(variant)) {
// Two files claiming one name means one of them silently loses, and which
// one depends on import order.
throw new Error(`composer.${group}.${variant} is already defined.`);
}
g.set(variant, factory);
return factory;
}
export const groups = () => registry.keys();
export const variants = (group) => (registry.get(group) || new Map()).keys();
export const has = (group, variant) => Boolean(registry.get(group)?.has(variant));
export const lookup = (group, variant) => registry.get(group)?.get(variant);
/** Every registered element, so a test can iterate what exists rather than a list. */
export function* entries() {
for (const [group, gv] of registry) for (const [variant, factory] of gv) yield { group, variant, factory };
}
// Property access on a Proxy is also how JS probes an object (`then` when it is
// awaited, Symbol.toStringTag when it is printed). Those must answer undefined
// rather than throw, or a stray await turns into a confusing UnknownElement.
const probe = (key) => typeof key === "symbol" || key === "then" || key === "toJSON";
function groupProxy(group) {
return new Proxy(Object.create(null), {
get(_t, variant) {
if (probe(variant)) return undefined;
const factory = lookup(group, variant);
if (!factory) throw new UnknownElement(group, variant);
return factory;
},
has: (_t, variant) => has(group, variant),
ownKeys: () => [...variants(group)],
getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }),
});
}
export const composer = new Proxy(Object.create(null), {
get(_t, group) {
if (probe(group)) return undefined;
if (!registry.has(group)) throw new UnknownElement(group);
return groupProxy(group);
},
has: (_t, group) => registry.has(group),
ownKeys: () => [...groups()],
getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }),
set() {
throw new TypeError("composer is read-only; elements register themselves with define().");
},
});
// Tests only.
export function _clearRegistry() {
registry.clear();
}
+38
View File
@@ -0,0 +1,38 @@
// Module-supplied content is DATA, never markup.
//
// asText is the only way text reaches the DOM in this kit. It does not parse,
// sanitise or escape -- it refuses anything that is not a scalar, so "markup"
// is not a state the kit can be in. That is a stronger guarantee than escaping,
// which is a thing you can forget to do in one place.
const SCALAR = new Set(["string", "number", "boolean"]);
export function asText(value) {
if (value == null) return document.createTextNode("");
if (!SCALAR.has(typeof value)) {
throw new TypeError(
`Composer content must be a string, number or boolean; got ${describe(value)}. ` +
"Elements take data, not nodes or markup."
);
}
if (typeof value === "number" && !Number.isFinite(value)) {
throw new TypeError(`Composer content must be a finite number; got ${value}.`);
}
return document.createTextNode(String(value));
}
/** Replace a node's content with `value`, rendered as text. */
export function setText(node, value) {
while (node.firstChild) node.removeChild(node.firstChild);
node.appendChild(asText(value));
return node;
}
function describe(value) {
if (typeof value === "object" && value !== null) {
if (typeof value.nodeType === "number") return `a DOM node (<${value.nodeName?.toLowerCase()}>)`;
if (Array.isArray(value)) return "an array";
return "an object";
}
return `a ${typeof value}`;
}
+120
View File
@@ -0,0 +1,120 @@
// The only place a z-index is written.
//
// v4 ended up with 9000, 9999, 10001, 12000, 99999, 200000 and two cross-file
// collisions, because every overlay picked its own number in isolation. Here the
// ladder is ordered once and each rung owns a block of slots, so peers stack
// within a rung and can never cross into the next one.
//
// A rung is an ORDERED LIST and z is derived from position, rather than each
// claim owning a fixed offset. That is what makes raise() and release() always
// correct: with fixed offsets, a near-full rung has no free slot above the top,
// and "move to the front" has nowhere to go.
// Order is the contract; the numbers are derived. Autocomplete sits ABOVE modal
// on purpose: it opens from a field that is often inside one.
export const LADDER = [
"local",
"dropline",
"sticky",
"menubar",
"popover",
"backdrop",
"wizard",
"modal",
"floatingWindow",
"toast",
"autocomplete",
"tour",
];
export const SLOTS = 100; // peers per rung
const stacks = new Map(); // rung -> entries, bottom to top
export function baseOf(rung) {
const i = LADDER.indexOf(rung);
if (i === -1) {
throw new RangeError(`Unknown z rung "${rung}". Known: ${LADDER.join(", ")}.`);
}
return (i + 1) * SLOTS;
}
/**
* Take a z-index in `rung`. `onChange(z)` fires whenever this claim's z moves --
* which happens when a PEER raises or releases, not only when this one does.
* Without it a window that was above becomes stale in the DOM the moment
* another is brought to the front.
*/
export function claim(rung, onChange) {
const start = baseOf(rung);
if (!stacks.has(rung)) stacks.set(rung, []);
const list = stacks.get(rung);
if (list.length >= SLOTS) {
// Running a rung dry means something opens overlays and never releases
// them; quietly reusing a slot would hide the leak and stack two things at
// one z.
throw new RangeError(`z rung "${rung}" is full (${SLOTS} live claims). Something is not releasing.`);
}
const entry = { onChange };
list.push(entry);
let live = true;
let lastZ = start + list.indexOf(entry);
const handle = {
get z() {
if (!live) return lastZ;
lastZ = start + list.indexOf(entry);
return lastZ;
},
get rung() { return rung; },
get live() { return live; },
raise() {
if (!live) return handle;
const i = list.indexOf(entry);
if (i === -1 || i === list.length - 1) return handle;
list.splice(i, 1);
list.push(entry);
notify(list, start);
return handle;
},
release() {
if (!live) return;
lastZ = handle.z;
const i = list.indexOf(entry);
if (i !== -1) list.splice(i, 1);
live = false;
notify(list, start);
},
};
return handle;
}
function notify(list, start) {
list.forEach((entry, i) => { if (entry.onChange) entry.onChange(start + i); });
}
/**
* Claim a rung AND keep `node`'s z-index in sync with it -- the form every
* overlay should use.
*
* `claim()` alone hands back a number and trusts the caller to notice when it
* changes, and a caller that forgets gets a z-index that was right at open time
* and silently wrong afterwards: a peer releasing compacts the rung, and the
* next overlay opened lands on the number this one is still displaying. Six of
* the seven call sites forgot. Binding the node here means there is nothing
* left to forget.
*/
export function claimFor(rung, node) {
const handle = claim(rung, (z) => { node.style.zIndex = String(z); });
node.style.zIndex = String(handle.z);
return handle;
}
/** Live claims in a rung, bottom to top. Diagnostics and tests. */
export const liveCount = (rung) => (stacks.get(rung) || []).length;
export function _resetLayers() {
stacks.clear();
}
+191
View File
@@ -0,0 +1,191 @@
// A module's settings declaration, rendered.
//
// This is the bridge the whole architecture turns on: one declaration renders
// the panel, carries the defaults and names the values that come back. A module
// writes no DOM and reads no DOM -- it says what it needs and gets values.
//
// The panel builds into a DETACHED fragment. If any part of it throws -- an
// unknown renderer, a malformed option -- nothing has been appended, so the
// module is absent rather than half-rendered. That is "hide, don't warn" at the
// level where it actually has to hold.
import { composer } from "./internals/register.js";
import { el, frag } from "./internals/el.js";
/** The renderer a type gets when the declaration does not ask for one. */
export const DEFAULT_UI = {
bool: "checkboxRow",
int: "number",
float: "number",
enum: "select",
text: "input",
multiline: "textarea",
path: "filterList",
color: "swatch",
};
// Renderers that carry their own visible label, so wrapping them in a settings
// row would print the label twice. Exported because anything else rendering a
// control from a setting has to know the same thing, and a second copy of this
// list is a second chance to forget one.
export const SELF_LABELLING = new Set(["checkboxRow", "toggle"]);
/** The renderer name a setting resolves to, whether or not it named one. */
export const rendererNameFor = (setting) => setting.ui || DEFAULT_UI[setting.type];
const RENDERERS = {
checkboxRow: (s, v, on) => composer.checkboxRow.default({ label: s.label, hint: s.hint, checked: v, onChange: on }),
toggle: (s, v, on) => composer.toggle.default({ label: s.label, hint: s.hint, checked: v, onChange: on }),
number: (s, v, on) => composer.number.md({
value: v, min: s.min, max: s.max, step: s.step, unit: s.unit,
precision: s.type === "int" ? 0 : s.precision, label: s.label, onChange: on }),
stepper: (s, v, on) => composer.stepper.md({
value: v, min: s.min, max: s.max, step: s.step, label: s.label, onChange: on }),
slider: (s, v, on) => composer.slider.readout({
value: v, min: s.min ?? 0, max: s.max ?? 1, step: s.step ?? 0.01,
unit: s.unit ?? "", precision: s.type === "int" ? 0 : 2, label: s.label, onCommit: on, onChange: on }),
macroSlider: (s, v, on) => composer.slider.macro({
value: v, min: s.min ?? 0, max: s.max ?? 1, step: s.step ?? 0.01,
unit: s.unit ?? "", presets: s.presets ?? [], label: s.label, onCommit: on, onChange: on }),
select: (s, v, on) => composer.select.md({ options: s.options, value: v, label: s.label, onChange: on }),
segmented: (s, v, on) => composer.segmented.md({ options: s.options, value: v, label: s.label, onChange: on }),
radioGroup: (s, v, on) => composer.radioGroup.default({ options: s.options, value: v, label: s.label, onChange: on }),
swatch: (s, v, on) => composer.color.swatch({ label: s.label, value: v, onChange: on }),
filterList: (s, v, on) => composer.filterList.md({
items: (s.options || []).map((o) => ({ id: o.value, label: o.label, hint: o.hint })),
value: v, placeholder: s.placeholder, onChange: on }),
// An overlay, so the row holds a button showing the current choice. Opening a
// wheel from a settings panel is unusual; being able to is what lets a module
// ask for one without writing any JavaScript.
wheel: (s, v, on) => {
const labelFor = (value) => (s.options.find((o) => o.value === value) || {}).label ?? String(value);
const button = composer.button.md({
label: labelFor(v),
onClick: () => composer.wheel.picker({
items: s.options.map((o) => ({ label: o.label, icon: o.icon, value: o.value })),
onPick: (item) => { button.setLabel(labelFor(item.value)); on(item.value); },
}),
});
return button;
},
input: (s, v, on) => composer.input.md({ value: v, placeholder: s.placeholder, label: s.label, onCommit: on }),
search: (s, v, on) => composer.search.md({ value: v, placeholder: s.placeholder, label: s.label, onCommit: on }),
textarea: (s, v, on) => composer.textarea.md({ value: v, placeholder: s.placeholder, label: s.label, onCommit: on }),
autoTextarea: (s, v, on) => composer.textarea.md({
value: v, placeholder: s.placeholder, label: s.label, autoGrow: true, onCommit: on }),
};
/** Is this setting's `when` satisfied by the current values? */
export function visible(setting, values) {
const when = setting.when;
if (!when) return true;
return Object.entries(when).every(([key, expected]) =>
(Array.isArray(expected) ? expected : [expected]).includes(values[key]));
}
export function rendererFor(setting) {
// Core refuses a setting with no default long before the browser sees it, so
// this is belt and braces -- but the panel is the layer that cannot proceed
// without one: it would render a control holding `undefined` and report that
// as the value the moment anything read it.
if (!Object.prototype.hasOwnProperty.call(setting, "default")) {
throw new TypeError(`Setting has no default, so there is nothing to render it with.`);
}
const name = setting.ui || DEFAULT_UI[setting.type];
const render = RENDERERS[name];
if (!render) {
// Refusing here rather than falling back to something plausible: a panel
// that silently renders a different control is worse than one that is
// absent, because nobody notices it is wrong.
throw new TypeError(`No renderer for "${name}" (setting type "${setting.type}").`);
}
return render;
}
/**
* renderPanel(spec, { values, onChange }) -> { node, values, destroy }
*
* `values` starts from the module's declared defaults, so a panel and a
* headless run begin from the same numbers.
*/
export function renderPanel(spec, { values: initial, onChange } = {}) {
const settings = spec.settings || {};
const values = { ...defaultsOf(spec), ...(initial || {}) };
const rows = el("div", { cls: "cx-group-rows" });
let handles = new Map();
function build() {
// Into a fragment, and into a map of its OWN, so a row that throws leaves
// nothing half-made behind and touches nothing already on screen.
const built = frag();
const made = new Map();
for (const [key, setting] of Object.entries(settings)) {
if (!visible(setting, values)) continue;
const render = rendererFor(setting);
const control = render(setting, values[key], (next) => {
values[key] = next;
if (onChange) onChange(key, next, { ...values });
// Another setting's visibility may depend on this one.
if (dependents.has(key)) redraw();
});
made.set(key, control);
built.appendChild(
SELF_LABELLING.has(rendererNameFor(setting))
? control.node
: composer.settingsRow.default({ label: setting.label, hint: setting.hint, control }).node
);
}
return { built, made };
}
// Which keys other rows watch, so a change only redraws when it can matter.
const dependents = new Set();
for (const setting of Object.values(settings)) {
for (const key of Object.keys(setting.when || {})) dependents.add(key);
}
function redraw() {
// Built BEFORE anything is torn down. Destroying first and building second
// meant a row that only appears once another value is set -- so a row the
// first build never rendered and never checked -- emptied the panel and
// then threw, leaving it permanently blank with no way back.
const { built, made } = build();
for (const handle of handles.values()) if (handle.destroy) handle.destroy();
handles = made;
rows.replaceChildren(built);
}
{
const first = build();
handles = first.made;
rows.appendChild(first.built);
}
const node = el("section", { cls: "cx-group", children: [
el("div", { cls: "cx-eyebrow", text: spec.title }),
rows,
] });
return {
node,
get values() { return { ...values }; },
setValue(key, next) { values[key] = next; redraw(); },
destroy() {
for (const handle of handles.values()) if (handle.destroy) handle.destroy();
node.remove();
},
};
}
/** The same defaults core derives, computed in the browser from the same source. */
export function defaultsOf(spec) {
const out = {};
for (const [key, setting] of Object.entries(spec.settings || {})) out[key] = setting.default;
return out;
}
+38
View File
@@ -0,0 +1,38 @@
// A DOM for the internals that genuinely need one.
//
// jsdom is honest about structure and events and dishonest about layout -- it
// has no real box model, so anything that depends on measured geometry belongs
// in the browser (see the catalogue and the Playwright pass), not here.
import { JSDOM } from "jsdom";
const GLOBALS = [
"window", "document", "Node", "HTMLElement", "Event", "KeyboardEvent",
"CustomEvent", "getComputedStyle", "DocumentFragment", "Text",
];
export function setupDom(html = "<!doctype html><html><body></body></html>") {
// A url, because without one jsdom gives the document an OPAQUE ORIGIN and
// `window.localStorage` throws a SecurityError on the property access itself
// -- not on get or set. Anything that remembers a preference then silently
// took its fallback path in every test, so "it is remembered" was untestable
// here and looked like it worked.
const dom = new JSDOM(html, { pretendToBeVisual: true, url: "http://localhost/funpack/" });
for (const name of GLOBALS) globalThis[name] = dom.window[name];
globalThis.window = dom.window;
globalThis.document = dom.window.document;
return dom;
}
export function teardownDom() {
for (const name of GLOBALS) delete globalThis[name];
}
/** Dispatch an event as it would arrive from a real interaction. */
export const fire = (node, type, init = {}) =>
node.dispatchEvent(new globalThis.window.Event(type, { bubbles: true, cancelable: true, ...init }));
export const key = (node, k) =>
node.dispatchEvent(new globalThis.window.KeyboardEvent("keydown", {
key: k, bubbles: true, cancelable: true,
}));
+112
View File
@@ -0,0 +1,112 @@
import test from "node:test";
import assert from "node:assert/strict";
import { place } from "../internals/anchor.js";
const VIEW = { width: 1000, height: 800 };
const mid = { x: 400, y: 400, width: 100, height: 24 };
const float = { width: 200, height: 120 };
const at = (anchor, opts = {}) => place({ anchor, float, viewport: VIEW, ...opts });
test("below and start-aligned by default", () => {
const r = at(mid);
assert.equal(r.side, "bottom");
assert.equal(r.y, mid.y + mid.height + 6);
assert.equal(r.x, mid.x);
assert.equal(r.flipped, false);
});
test("gap is respected on every side", () => {
assert.equal(at(mid, { side: "bottom", gap: 20 }).y, mid.y + mid.height + 20);
assert.equal(at(mid, { side: "top", gap: 20 }).y, mid.y - float.height - 20);
assert.equal(at(mid, { side: "right", gap: 20 }).x, mid.x + mid.width + 20);
assert.equal(at(mid, { side: "left", gap: 20 }).x, mid.x - float.width - 20);
});
test("alignment moves along the cross axis only", () => {
assert.equal(at(mid, { align: "start" }).x, 400);
assert.equal(at(mid, { align: "center" }).x, 400 + 50 - 100);
assert.equal(at(mid, { align: "end" }).x, 400 + 100 - 200);
// the main axis is untouched by alignment
const ys = ["start", "center", "end"].map((align) => at(mid, { align }).y);
assert.equal(new Set(ys).size, 1);
});
// --- flipping ---------------------------------------------------------------
test("flips up when there is no room below", () => {
const low = { x: 400, y: 760, width: 100, height: 24 };
const r = at(low, { side: "bottom" });
assert.equal(r.side, "top");
assert.equal(r.flipped, true);
});
test("flips left when there is no room right", () => {
const right = { x: 940, y: 400, width: 40, height: 24 };
const r = at(right, { side: "right" });
assert.equal(r.side, "left");
});
test("does not flip into an even tighter gap", () => {
// Squeezed both ways: 106px below, 20px above, and the float needs 134. It
// fits neither, so it stays where it was asked to go and clamps -- flipping
// into LESS room just moves the clipping somewhere the caller did not expect,
// which is what several of v4's seven copies did.
const nearTop = { x: 400, y: 20, width: 100, height: 24 };
const shallow = { width: 1000, height: 150 };
const r = place({ anchor: nearTop, float, viewport: shallow, side: "bottom" });
assert.equal(r.side, "bottom");
assert.equal(r.flipped, false);
assert.equal(r.clamped, true);
});
test("flip can be switched off", () => {
const low = { x: 400, y: 760, width: 100, height: 24 };
assert.equal(at(low, { side: "bottom", flip: false }).side, "bottom");
});
// --- clamping ---------------------------------------------------------------
test("clamps back inside the left edge", () => {
const r = at({ x: 2, y: 400, width: 40, height: 24 }, { align: "end" });
assert.ok(r.x >= 8);
assert.equal(r.clamped, true);
});
test("clamps back inside the right edge", () => {
const r = at({ x: 960, y: 400, width: 40, height: 24 });
assert.ok(r.x + float.width <= VIEW.width - 8);
});
test("clamping never pushes the float off the opposite edge", () => {
// A float wider than the viewport must sit at the padding, not at a negative x.
const huge = { width: 1200, height: 120 };
const r = place({ anchor: mid, float: huge, viewport: VIEW });
assert.equal(r.x, 8);
});
test("clamp can be switched off", () => {
const r = at({ x: 2, y: 400, width: 40, height: 24 }, { align: "end", clamp: false });
assert.ok(r.x < 0);
assert.equal(r.clamped, false);
});
// --- edges ------------------------------------------------------------------
test("an unknown side is refused rather than guessed", () => {
assert.throws(() => at(mid, { side: "sideways" }), RangeError);
});
test("a zero-size anchor still places sensibly", () => {
// Context menus anchor to a point, not an element.
const r = at({ x: 300, y: 300, width: 0, height: 0 });
assert.equal(r.x, 300);
assert.equal(r.y, 306);
});
test("results are integers", () => {
const r = at(mid, { align: "center" });
assert.equal(Number.isInteger(r.x), true);
assert.equal(Number.isInteger(r.y), true);
});
+56
View File
@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "./_dom.js";
import { AUTO, MAX_COLS, applyDensity, cellExpression, cellWidth, normaliseCols, recallCols } from "../internals/density.js";
test.beforeEach(() => setupDom());
test.after(() => teardownDom());
test("cell width accounts for the gaps between columns", () => {
// v4 hand-tuned these (92/44/29/21) and they drifted from the gap they were
// meant to allow for. Derived, they add up.
for (const cols of [1, 2, 3, 4]) {
const cell = cellWidth(cols);
const total = cell * cols + 2.5 * (cols - 1);
assert.ok(Math.abs(total - 100) < 0.05, `${cols} columns totalled ${total}`);
}
});
test("one column is the full container", () => {
assert.equal(cellWidth(1), 100);
});
test("auto falls back to the clamp expression", () => {
assert.match(cellExpression(null), /clamp\(/);
assert.match(cellExpression(2), /cqw$/);
});
test("column counts are clamped into range", () => {
assert.equal(normaliseCols(-3), AUTO);
assert.equal(normaliseCols(99), MAX_COLS);
assert.equal(normaliseCols("2"), 2);
assert.equal(normaliseCols(2.7), 2);
assert.equal(normaliseCols("nonsense"), AUTO);
assert.equal(normaliseCols(undefined), AUTO);
});
test("applying density sets both the attribute and the cell variable", () => {
const host = document.createElement("div");
assert.equal(applyDensity(host, 3), 3);
assert.equal(host.dataset.cols, "3");
assert.match(host.style.getPropertyValue("--cell"), /cqw/);
});
test("recall survives storage being unavailable", () => {
// Private windows and blocked site data throw on access; a grid must still
// render rather than taking the page down with it.
const original = Object.getOwnPropertyDescriptor(window, "localStorage");
Object.defineProperty(window, "localStorage", {
configurable: true,
get() { throw new Error("blocked"); },
});
globalThis.localStorage = undefined;
assert.equal(recallCols("bin", 2), 2);
if (original) Object.defineProperty(window, "localStorage", original);
});
+174
View File
@@ -0,0 +1,174 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom, fire, key } from "./_dom.js";
import { push, depth, _resetDismiss } from "../internals/dismiss.js";
test.beforeEach(() => { setupDom(); _resetDismiss(); });
test.after(() => teardownDom());
const panel = (id) => {
const n = document.createElement("div");
n.id = id;
document.body.appendChild(n);
return n;
};
test("Escape dismisses the top entry only", () => {
// v4's bug: a modal and an autocomplete inside it each had their own Escape
// handler, so one press closed both.
const closed = [];
const modal = panel("modal");
const menu = panel("menu");
push({ nodes: modal, onDismiss: () => closed.push("modal") });
push({ nodes: menu, onDismiss: () => closed.push("menu") });
key(document.body, "Escape");
assert.deepEqual(closed, ["menu"]);
assert.equal(depth(), 1);
key(document.body, "Escape");
assert.deepEqual(closed, ["menu", "modal"]);
assert.equal(depth(), 0);
});
test("a click outside dismisses the top entry", () => {
let closed = false;
const menu = panel("menu");
push({ nodes: menu, onDismiss: () => { closed = true; } });
fire(document.body, "pointerdown");
assert.equal(closed, true);
});
test("a click inside does not dismiss", () => {
let closed = false;
const menu = panel("menu");
const child = document.createElement("button");
menu.appendChild(child);
push({ nodes: menu, onDismiss: () => { closed = true; } });
fire(child, "pointerdown");
assert.equal(closed, false);
});
test("the trigger counts as inside, so clicking it again does not close-then-reopen", () => {
let closed = false;
const menu = panel("menu");
const trigger = panel("trigger");
push({ nodes: [menu, trigger], onDismiss: () => { closed = true; } });
fire(trigger, "pointerdown");
assert.equal(closed, false);
});
test("a click inside the top entry leaves the one below alone", () => {
const closed = [];
const modal = panel("modal");
const menu = panel("menu");
push({ nodes: modal, onDismiss: () => closed.push("modal") });
push({ nodes: menu, onDismiss: () => closed.push("menu") });
fire(menu, "pointerdown");
assert.deepEqual(closed, []);
});
test("closeOnEsc false ignores Escape but still takes outside clicks", () => {
let closed = false;
const modal = panel("modal");
push({ nodes: modal, closeOnEsc: false, onDismiss: () => { closed = true; } });
key(document.body, "Escape");
assert.equal(closed, false);
fire(document.body, "pointerdown");
assert.equal(closed, true);
});
test("closeOnOutside false ignores clicks but still takes Escape", () => {
let closed = false;
const modal = panel("modal");
push({ nodes: modal, closeOnOutside: false, onDismiss: () => { closed = true; } });
fire(document.body, "pointerdown");
assert.equal(closed, false);
key(document.body, "Escape");
assert.equal(closed, true);
});
test("a non-dismissable top entry blocks Escape reaching the one below", () => {
// Otherwise a modal that declined Escape would still close when a child
// overlay above it declined too.
const closed = [];
push({ nodes: panel("modal"), onDismiss: () => closed.push("modal") });
push({ nodes: panel("sticky"), closeOnEsc: false, onDismiss: () => closed.push("sticky") });
key(document.body, "Escape");
assert.deepEqual(closed, []);
});
test("dismissing twice runs onDismiss once", () => {
let n = 0;
const h = push({ nodes: panel("menu"), onDismiss: () => { n += 1; } });
h.dismiss();
h.dismiss();
assert.equal(n, 1);
});
test("release removes the entry without running onDismiss", () => {
let n = 0;
const h = push({ nodes: panel("menu"), onDismiss: () => { n += 1; } });
h.release();
assert.equal(n, 0);
assert.equal(depth(), 0);
key(document.body, "Escape");
assert.equal(n, 0);
});
test("dismissing out of order keeps the rest of the stack intact", () => {
const closed = [];
const a = push({ nodes: panel("a"), onDismiss: () => closed.push("a") });
push({ nodes: panel("b"), onDismiss: () => closed.push("b") });
a.dismiss();
assert.deepEqual(closed, ["a"]);
assert.equal(depth(), 1);
key(document.body, "Escape");
assert.deepEqual(closed, ["a", "b"]);
});
test("isTop and depth describe the stack", () => {
const a = push({ nodes: panel("a") });
const b = push({ nodes: panel("b") });
assert.equal(a.isTop, false);
assert.equal(b.isTop, true);
assert.equal(a.depth, 1);
assert.equal(b.depth, 2);
});
test("listeners are removed once the stack empties", () => {
// A stack that empties but keeps listening means every later key press walks
// dead handlers, and the leak grows with every overlay ever opened.
let added = 0;
const realAdd = document.addEventListener.bind(document);
const realRemove = document.removeEventListener.bind(document);
document.addEventListener = (...args) => { added += 1; return realAdd(...args); };
document.removeEventListener = (...args) => { added -= 1; return realRemove(...args); };
const h = push({ nodes: panel("a") });
assert.equal(added, 2);
h.dismiss();
assert.equal(added, 0);
push({ nodes: panel("b") });
assert.equal(added, 2, "listening resumes for a later overlay");
});
test("keys other than Escape are ignored", () => {
let closed = false;
push({ nodes: panel("a"), onDismiss: () => { closed = true; } });
key(document.body, "Enter");
key(document.body, "a");
assert.equal(closed, false);
});
test("a virtual anchor never reaches the outside-click check", () => {
// A context menu is anchored to coordinates, not an element. That stand-in
// has no .contains, and passing it through threw on the first click anywhere.
let closed = false;
const point = { getBoundingClientRect: () => ({ x: 10, y: 10, width: 0, height: 0 }) };
push({ nodes: [panel("menu"), point], onDismiss: () => { closed = true; } });
assert.doesNotThrow(() => fire(document.body, "pointerdown"));
assert.equal(closed, true);
});
+152
View File
@@ -0,0 +1,152 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "./_dom.js";
import { drag } from "../internals/drag.js";
test.beforeEach(() => setupDom());
test.after(() => teardownDom());
function handle() {
const n = document.createElement("div");
document.body.appendChild(n);
// jsdom has PointerEvent but not the capture API; the kit guards for both, so
// record the calls to prove the guards do the right thing.
n.captured = [];
n.setPointerCapture = (id) => n.captured.push(id);
n.releasePointerCapture = (id) => { n.captured = n.captured.filter((c) => c !== id); };
n.hasPointerCapture = (id) => n.captured.includes(id);
return n;
}
const pointer = (node, type, { x = 0, y = 0, id = 1, button = 0 } = {}) =>
node.dispatchEvent(new window.PointerEvent(type, {
clientX: x, clientY: y, pointerId: id, button, bubbles: true, cancelable: true,
}));
test("reports movement relative to where the drag began", () => {
const h = handle();
const moves = [];
drag(h, { onMove: (m) => moves.push([m.dx, m.dy]) });
pointer(h, "pointerdown", { x: 100, y: 100 });
pointer(h, "pointermove", { x: 130, y: 90 });
pointer(h, "pointermove", { x: 100, y: 100 });
assert.deepEqual(moves, [[30, -10], [0, 0]]);
});
test("start, move and end all fire, with the final delta on end", () => {
const h = handle();
const seen = [];
drag(h, {
onStart: () => seen.push("start"),
onMove: () => seen.push("move"),
onEnd: (e) => seen.push(`end:${e.dx},${e.dy}`),
});
pointer(h, "pointerdown", { x: 10, y: 10 });
pointer(h, "pointermove", { x: 15, y: 20 });
pointer(h, "pointerup", { x: 25, y: 30 });
assert.deepEqual(seen, ["start", "move", "end:15,20"]);
});
test("the pointer is captured for the drag and released after", () => {
// Capture is what guarantees a pointerup even when the cursor leaves the
// window; without it a drag can be left running forever.
const h = handle();
drag(h);
pointer(h, "pointerdown", { x: 0, y: 0, id: 7 });
assert.deepEqual(h.captured, [7]);
pointer(h, "pointerup", { x: 0, y: 0, id: 7 });
assert.deepEqual(h.captured, []);
});
test("moves before a pointerdown are ignored", () => {
const h = handle();
let moved = false;
drag(h, { onMove: () => { moved = true; } });
pointer(h, "pointermove", { x: 50, y: 50 });
assert.equal(moved, false);
});
test("moves after the drag ends are ignored", () => {
const h = handle();
let moves = 0;
drag(h, { onMove: () => { moves += 1; } });
pointer(h, "pointerdown", { x: 0, y: 0 });
pointer(h, "pointermove", { x: 5, y: 5 });
pointer(h, "pointerup", { x: 5, y: 5 });
pointer(h, "pointermove", { x: 99, y: 99 });
assert.equal(moves, 1);
});
test("a second pointer does not hijack a drag in progress", () => {
// Two fingers on a trim handle would otherwise interleave deltas from both.
const h = handle();
const moves = [];
drag(h, { onMove: (m) => moves.push(m.dx) });
pointer(h, "pointerdown", { x: 0, y: 0, id: 1 });
pointer(h, "pointermove", { x: 10, y: 0, id: 2 });
pointer(h, "pointermove", { x: 20, y: 0, id: 1 });
assert.deepEqual(moves, [20]);
});
test("a second pointerdown while dragging is ignored", () => {
const h = handle();
let starts = 0;
drag(h, { onStart: () => { starts += 1; } });
pointer(h, "pointerdown", { x: 0, y: 0, id: 1 });
pointer(h, "pointerdown", { x: 5, y: 5, id: 2 });
assert.equal(starts, 1);
});
test("only the configured button starts a drag", () => {
const h = handle();
let starts = 0;
drag(h, { onStart: () => { starts += 1; } });
pointer(h, "pointerdown", { x: 0, y: 0, button: 2 }); // right-click
assert.equal(starts, 0);
pointer(h, "pointerdown", { x: 0, y: 0, button: 0 });
assert.equal(starts, 1);
});
test("pointercancel ends the drag and says it was cancelled", () => {
const h = handle();
let end = null;
drag(h, { onEnd: (e) => { end = e; } });
pointer(h, "pointerdown", { x: 0, y: 0 });
pointer(h, "pointercancel", { x: 9, y: 9 });
assert.equal(end.cancelled, true);
assert.deepEqual(h.captured, [], "capture is released on cancel too");
});
test("pointerdown is prevented, so a drag does not also select text", () => {
const h = handle();
drag(h);
const event = new window.PointerEvent("pointerdown", {
clientX: 0, clientY: 0, pointerId: 1, button: 0, bubbles: true, cancelable: true,
});
h.dispatchEvent(event);
assert.equal(event.defaultPrevented, true);
});
test("dispose stops listening, mid-drag included", () => {
const h = handle();
let moves = 0;
const dispose = drag(h, { onMove: () => { moves += 1; } });
pointer(h, "pointerdown", { x: 0, y: 0 });
dispose();
pointer(h, "pointermove", { x: 10, y: 10 });
assert.equal(moves, 0);
assert.deepEqual(h.captured, [], "a disposed drag does not keep the pointer");
});
test("a drag with no callbacks does not throw", () => {
const h = handle();
drag(h);
assert.doesNotThrow(() => {
pointer(h, "pointerdown", { x: 0, y: 0 });
pointer(h, "pointermove", { x: 1, y: 1 });
pointer(h, "pointerup", { x: 1, y: 1 });
});
});
+77
View File
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "./_dom.js";
import { el, frag, clear } from "../internals/el.js";
test.beforeEach(() => setupDom());
test.after(() => teardownDom());
test("builds an element with class and text", () => {
const n = el("button", { cls: "cx-btn", text: "Generate" });
assert.equal(n.tagName, "BUTTON");
assert.equal(n.className, "cx-btn");
assert.equal(n.textContent, "Generate");
});
test("class accepts an array and drops empty entries", () => {
assert.equal(el("div", { cls: ["a", null, "b", false] }).className, "a b");
});
test("text goes through asText, so markup stays text", () => {
const n = el("div", { text: "<b>no</b>" });
assert.equal(n.querySelector("b"), null);
assert.equal(n.textContent, "<b>no</b>");
});
test("style and class attributes are refused", () => {
// Presentation is the kit's. Allowing either through attrs would reopen the
// exact hole the no-styling rule closes.
assert.throws(() => el("div", { attrs: { style: "color:red" } }), TypeError);
assert.throws(() => el("div", { attrs: { class: "sneaky" } }), TypeError);
});
test("inline event handler attributes are refused", () => {
for (const name of ["onclick", "onLoad", "onerror"]) {
assert.throws(() => el("div", { attrs: { [name]: "boom()" } }), TypeError);
}
});
test("ordinary attributes are set, booleans become empty strings", () => {
const n = el("input", { attrs: { type: "checkbox", disabled: true, "aria-label": "Pick" } });
assert.equal(n.getAttribute("type"), "checkbox");
assert.equal(n.getAttribute("disabled"), "");
assert.equal(n.getAttribute("aria-label"), "Pick");
});
test("false and null attributes are omitted entirely", () => {
const n = el("input", { attrs: { disabled: false, title: null } });
assert.equal(n.hasAttribute("disabled"), false);
assert.equal(n.hasAttribute("title"), false);
});
test("listeners and children are attached", () => {
let clicks = 0;
const child = el("span", { text: "x" });
const n = el("div", { on: { click: () => { clicks += 1; } }, children: child });
n.dispatchEvent(new window.Event("click"));
assert.equal(clicks, 1);
assert.equal(n.firstChild, child);
});
test("falsy children are skipped, so conditionals read naturally", () => {
const n = el("div", { children: [el("i"), null, false, undefined] });
assert.equal(n.childNodes.length, 1);
});
test("frag is detached, so a half-built tree never reaches the document", () => {
const f = frag();
f.appendChild(el("div"));
assert.equal(f.nodeType, 11);
assert.equal(document.body.childNodes.length, 0);
});
test("clear empties a node", () => {
const n = el("div", { children: [el("i"), el("b")] });
assert.equal(clear(n).childNodes.length, 0);
});
@@ -0,0 +1,634 @@
// Behaviour the registry-wide rule tests cannot see: what each element does
// when it is actually used.
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom, fire } from "./_dom.js";
let composer;
test.before(async () => {
setupDom();
({ composer } = await import("../composer.js"));
});
test.after(() => teardownDom());
const mount = (handle) => { document.body.appendChild(handle.node); return handle; };
// --- buttons ---------------------------------------------------------------
test("a button reports clicks and can be made busy", () => {
let clicks = 0;
const b = mount(composer.button.md({ label: "Go", onClick: () => { clicks += 1; } }));
b.node.click();
assert.equal(clicks, 1);
b.setBusy(true);
assert.equal(b.node.disabled, true);
assert.equal(b.node.getAttribute("aria-busy"), "true");
b.node.click();
assert.equal(clicks, 1, "a busy button does not fire again");
b.setBusy(false);
assert.equal(b.node.disabled, false);
});
test("an unknown tone is refused rather than rendered plain", () => {
assert.throws(() => composer.button.md({ label: "x", tone: "sparkly" }), RangeError);
});
test("an icon button without a label is refused", () => {
// Otherwise a toolbar of glyphs is unusable by screen reader and unlabelled
// on hover, and nobody notices until someone needs it.
assert.throws(() => composer.iconButton.md({ icon: "x" }), TypeError);
});
test("buttonGroup picks one, or several when multi", () => {
const seen = [];
const g = mount(composer.buttonGroup.md({
value: "a", onChange: (v) => seen.push(v),
items: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
}));
g.node.querySelectorAll("button")[1].click();
assert.equal(g.value, "b");
assert.deepEqual(seen, ["b"]);
assert.equal(g.node.querySelectorAll("button")[1].getAttribute("aria-checked"), "true");
const m = mount(composer.buttonGroup.md({
multi: true, value: ["a"],
items: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
}));
m.node.querySelectorAll("button")[1].click();
assert.deepEqual(m.value.sort(), ["a", "b"]);
m.node.querySelectorAll("button")[0].click();
assert.deepEqual(m.value, ["b"], "clicking an active one turns it off");
});
// --- choice ----------------------------------------------------------------
test("checkbox reads and writes its state", () => {
let last = null;
const c = mount(composer.checkbox.default({ label: "On", checked: false, onChange: (v) => { last = v; } }));
const input = c.node.querySelector("input");
input.click();
assert.equal(c.value, true);
assert.equal(last, true);
c.setValue(false);
assert.equal(input.checked, false);
});
test("a bare checkbox carries its label as the accessible name", () => {
const c = composer.checkbox.default({ label: "Enabled" });
assert.equal(c.node.querySelector("input").getAttribute("aria-label"), "Enabled");
});
test("a checkbox row is labelled by its visible text, not by aria-label", () => {
// Duplicating it would override what the user can see with a copy that can
// drift from it.
const c = composer.checkboxRow.default({ label: "Sync", hint: "Locks timing" });
const input = c.node.querySelector("input");
assert.equal(input.getAttribute("aria-label"), null);
const face = c.node.querySelector("label.cx-check-face");
assert.equal(face.getAttribute("for"), input.id,
"the label points at the input rather than wrapping it");
assert.equal(face.contains(input), false);
assert.ok(input.getAttribute("aria-describedby"), "the hint is wired as the description");
});
test("checklist collects several values", () => {
const c = mount(composer.checklist.default({
values: ["a"], items: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
}));
c.node.querySelectorAll("input")[1].click();
assert.deepEqual(c.value.sort(), ["a", "b"]);
c.setValue(["b"]);
assert.deepEqual(c.value, ["b"]);
});
test("radioGroup keeps exactly one", () => {
const r = mount(composer.radioGroup.default({
value: "a", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
}));
const inputs = r.node.querySelectorAll("input");
inputs[1].click();
assert.equal(r.value, "b");
assert.equal(inputs[0].checked, false);
});
test("segmented selects and reflects aria-checked", () => {
const s = mount(composer.segmented.md({
value: "t2v", options: [{ value: "t2v", label: "T" }, { value: "i2v", label: "I" }],
}));
const buttons = s.node.querySelectorAll("button");
buttons[1].click();
assert.equal(s.value, "i2v");
assert.equal(buttons[1].getAttribute("aria-checked"), "true");
assert.equal(buttons[0].getAttribute("aria-checked"), "false");
});
test("select round-trips its value", () => {
const s = mount(composer.select.md({
value: "b", options: [{ value: "a", label: "A" }, { value: "b", label: "B" }],
}));
assert.equal(s.value, "b");
s.setValue("a");
assert.equal(s.value, "a");
});
test("toggle is a switch, not a checkbox, to assistive tech", () => {
const t = mount(composer.toggle.default({ label: "Second pass", checked: true }));
assert.equal(t.node.querySelector("input").getAttribute("role"), "switch");
assert.equal(t.value, true);
});
// --- input -----------------------------------------------------------------
test("onCommit fires on Enter and blur, not on every keystroke", () => {
// A settings value that updates per keystroke writes nine intermediate values
// while you type "0.65".
const commits = [];
const inputs = [];
const i = mount(composer.input.md({
onInput: (v) => inputs.push(v),
onCommit: (v) => commits.push(v),
}));
i.node.value = "roof";
fire(i.node, "input");
assert.deepEqual(inputs, ["roof"]);
assert.deepEqual(commits, []);
i.node.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
assert.deepEqual(commits, ["roof"]);
fire(i.node, "blur");
assert.deepEqual(commits, ["roof"], "an unchanged value does not commit twice");
});
test("number clamps on commit, not while typing", () => {
// Clamping mid-keystroke makes a leading "-" or "0." impossible to type.
let last = null;
const n = mount(composer.number.md({ value: 3, min: 1, max: 10, onChange: (v) => { last = v; } }));
const input = n.node.querySelector("input") || n.node;
input.value = "99";
fire(input, "input");
assert.equal(input.value, "99", "not clamped yet");
fire(input, "blur");
assert.equal(last, 10);
});
test("number applies its precision", () => {
let last = null;
const n = mount(composer.number.md({ value: 1, min: 0, max: 10, precision: 1, onChange: (v) => { last = v; } }));
const input = n.node.querySelector("input") || n.node;
input.value = "3.14159";
fire(input, "blur");
assert.equal(last, 3.1);
});
test("stepper steps without float drift", () => {
const s = mount(composer.stepper.md({ value: 0.1, min: 0, max: 1, step: 0.2 }));
s.node.querySelectorAll("button")[1].click();
assert.equal(s.value, 0.3, "0.1 + 0.2 must not surface as 0.30000000000000004");
});
test("stepper respects its bounds", () => {
const s = mount(composer.stepper.md({ value: 1, min: 0, max: 1, step: 1 }));
s.node.querySelectorAll("button")[1].click();
assert.equal(s.value, 1);
});
test("filterList filters on label and hint, and reports a pick", () => {
let picked = null;
const f = mount(composer.filterList.md({
onChange: (id) => { picked = id; },
items: [
{ id: "a", label: "bong_tangent", hint: "validated" },
{ id: "b", label: "karras", hint: "2.3 era" },
],
}));
const search = f.node.querySelector("input");
search.value = "valid";
fire(search, "input");
assert.equal(f.node.querySelectorAll(".cx-filter-row").length, 1, "matches on hint too");
f.node.querySelector(".cx-filter-row").click();
assert.equal(picked, "a");
});
test("filterList filters on keywords too, though it never draws them", () => {
const f = mount(composer.filterList.md({
items: [
{ id: "a", label: "Models and pipeline", hint: "What the run is made of.", keywords: "loaders" },
{ id: "b", label: "Updates" },
],
}));
const search = f.node.querySelector("input");
search.value = "loaders";
fire(search, "input");
const rows = f.node.querySelectorAll(".cx-filter-row");
assert.equal(rows.length, 1, "a section findable only by a keyword must still be found");
assert.equal(f.node.textContent.includes("loaders"), false, "keywords are for search, not display");
});
test("filterList gives an icon a coloured chip only from a fixed tone, never from raw item data", () => {
const f = mount(composer.filterList.md({
items: [{ id: "a", label: "Node packs", icon: "▣", tone: "good" }],
}));
const icon = f.node.querySelector(".cx-filter-icon");
assert.ok(icon.classList.contains("cx-filter-icon-good"));
});
test("an unknown filterList icon tone is refused rather than rendered as a blank chip", () => {
assert.throws(() => mount(composer.filterList.md({
items: [{ id: "a", label: "Node packs", icon: "▣", tone: "sparkly-typo" }],
})), RangeError);
});
test("filterList says so when nothing matches", () => {
const f = mount(composer.filterList.md({ items: [{ id: "a", label: "one" }] }));
const search = f.node.querySelector("input");
search.value = "zzz";
fire(search, "input");
assert.equal(f.node.querySelectorAll(".cx-filter-row").length, 0);
assert.ok(f.node.querySelector(".cx-filter-empty"), "an empty list must say it is empty");
});
// --- slider ----------------------------------------------------------------
test("slider reports as it moves and commits on release", () => {
const moves = [];
const commits = [];
const s = mount(composer.slider.md({
value: 0.5, min: 0, max: 1, step: 0.1,
onChange: (v) => moves.push(v), onCommit: (v) => commits.push(v),
}));
const input = s.node.querySelector("input");
input.value = "0.8";
fire(input, "input");
fire(input, "change");
assert.deepEqual(moves, [0.8]);
assert.deepEqual(commits, [0.8]);
});
test("the slider paints its fill from the value", () => {
const s = mount(composer.slider.md({ value: 0.25, min: 0, max: 1, step: 0.05 }));
assert.equal(s.node.querySelector("input").style.getPropertyValue("--fill"), "25%");
});
test("slider.readout shows the value and its unit", () => {
const s = mount(composer.slider.readout({ value: 0.65, min: 0, max: 1, step: 0.05, unit: "x", precision: 2 }));
assert.equal(s.node.querySelector("output").textContent, "0.65x");
s.setValue(0.3);
assert.equal(s.node.querySelector("output").textContent, "0.30x");
});
test("slider.macro jumps to a preset", () => {
const s = mount(composer.slider.macro({
value: 0.2, min: 0, max: 1, step: 0.05,
presets: [{ label: "Chill", value: 0.2 }, { label: "Chaos", value: 0.9 }],
}));
s.node.querySelectorAll(".cx-macro-presets button")[1].click();
assert.equal(s.value, 0.9);
});
test("range thumbs cannot pass each other", () => {
// A range whose start is after its end is a state every consumer would then
// have to defend against.
const r = mount(composer.range.md({ from: 0.3, to: 0.7, min: 0, max: 1, step: 0.05 }));
const [lo, hi] = r.node.querySelectorAll("input");
lo.value = "0.9";
fire(lo, "input");
const { from, to } = r.value;
assert.ok(from <= to, `from ${from} must not exceed to ${to}`);
});
// --- regressions found by the adversarial pass ------------------------------
test("finishing a busy spell does not re-enable a button that was disabled", () => {
// busy and disabled are independent reasons a button cannot be clicked.
// Deriving the attribute from the constructor's value meant the later call
// silently lost to the earlier one.
const b = mount(composer.button.md({ label: "Save" }));
b.setDisabled(true);
b.setBusy(true);
b.setBusy(false);
assert.equal(b.node.disabled, true);
b.setDisabled(false);
assert.equal(b.node.disabled, false);
});
test("a busy button stays disabled even if setDisabled(false) is called", () => {
const b = mount(composer.button.md({ label: "Save" }));
b.setBusy(true);
b.setDisabled(false);
assert.equal(b.node.disabled, true);
});
test("clearing a number field keeps the last committed value", () => {
// A browser normalises invalid text in a number input to "", and Number("")
// is 0 -- finite, so it sails past an isFinite guard and lands on min,
// discarding what the user had actually entered.
const seen = [];
const n = mount(composer.number.md({ value: 3, min: 1, max: 10, onChange: (v) => seen.push(v) }));
const input = n.node.querySelector("input") || n.node;
input.value = "7";
fire(input, "blur");
assert.equal(n.value, 7);
input.value = "";
fire(input, "blur");
assert.equal(n.value, 7, "an emptied field is not a zero");
assert.equal(input.value, "7", "and the field shows what is actually held");
assert.deepEqual(seen, [7, 7]);
});
test("a number field ignores unparseable text on commit", () => {
const n = mount(composer.number.md({ value: 5, min: 0, max: 10 }));
const input = n.node.querySelector("input") || n.node;
input.value = "abc";
fire(input, "blur");
assert.equal(n.value, 5);
});
test("a list row carries a real control, separate from its reorder arrows", () => {
// A bare number printed beside up/down arrows reads as a stepper for that
// number. The weight is edited in a control; the arrows only move the row.
const weight = composer.number.md({ value: 0.8, min: -2, max: 2, step: 0.05, label: "Weight" });
const l = mount(composer.list.rows({
reorder: true,
items: [{ label: "detail_v3", control: weight }, { label: "motion_lift" }],
}));
assert.ok(l.node.querySelector(".cx-list-control input"), "the row has an editable control");
const moves = [...l.node.querySelectorAll(".cx-list-move")];
assert.ok(moves.length >= 2);
for (const m of moves) {
assert.match(m.getAttribute("aria-label"), /^Move .* (up|down)$/,
"reorder buttons say they reorder");
}
});
test("reordering a list reports the new order", () => {
let order = null;
const l = mount(composer.list.rows({
reorder: true, onReorder: (items) => { order = items.map((i) => i.label); },
items: [{ label: "a" }, { label: "b" }],
}));
// the second row's "up" button
l.node.querySelectorAll(".cx-list-row")[1].querySelector(".cx-list-move").click();
assert.deepEqual(order, ["b", "a"]);
});
// --- workspace -------------------------------------------------------------
const workspace = (props = {}) => mount(composer.workspace.docked({
id: `ws-${Math.random().toString(36).slice(2)}`,
left: composer.hint.default({ text: "assets" }),
centre: composer.hint.default({ text: "preview" }),
right: composer.hint.default({ text: "properties" }),
...props,
}));
test("a docked panel's toggle is not inside the panel it collapses", () => {
// The v4 fault, as a test. A collapsed column was marked [hidden], which is
// display:none, and the control that would have brought it back was inside
// it -- so the button did nothing at all and said nothing.
const ws = workspace();
for (const side of ["left", "right"]) {
const panel = side === "left" ? ws.left : ws.right;
const toggle = ws.node.querySelector(`.cx-workspace-rail-${side} button`);
assert.ok(toggle, `${side} has no toggle`);
assert.ok(!panel.contains(toggle), `the ${side} toggle is inside the panel it hides`);
}
});
test("collapsing keeps the panel in the document rather than removing it", () => {
const ws = workspace();
ws.close("left");
assert.equal(ws.isOpen("left"), false);
assert.ok(ws.node.contains(ws.left), "the panel was removed rather than collapsed");
assert.ok(!ws.left.hasAttribute("hidden"),
"[hidden] is display:none, which is what made this unrecoverable in v4");
assert.ok(ws.left.classList.contains("cx-collapsed"));
});
test("a collapsed panel can be reopened from its toggle", () => {
const ws = workspace();
const toggle = ws.node.querySelector(".cx-workspace-rail-right button");
toggle.click();
assert.equal(ws.isOpen("right"), false);
assert.equal(toggle.getAttribute("aria-expanded"), "false");
toggle.click();
assert.equal(ws.isOpen("right"), true);
assert.equal(toggle.getAttribute("aria-expanded"), "true");
});
test("the centre keeps its content and the sides keep theirs", () => {
const ws = workspace();
assert.match(ws.centre.textContent, /preview/);
assert.match(ws.left.textContent, /assets/);
assert.match(ws.right.textContent, /properties/);
});
test("which panels were open is remembered per workspace id", () => {
const first = workspace({ id: "remembered" });
first.close("left");
first.destroy();
const second = workspace({ id: "remembered" });
assert.equal(second.isOpen("left"), false, "the closed panel came back open");
assert.equal(second.isOpen("right"), true);
});
test("a workspace with no panels still renders its centre", () => {
const ws = mount(composer.workspace.docked({
id: "bare", centre: composer.hint.default({ text: "only the middle" }),
}));
assert.match(ws.centre.textContent, /only the middle/);
assert.ok(ws.left && ws.right, "the regions exist even when nothing was put in them");
});
test("a captionless tile still has a name", () => {
// The icons view drops the caption on purpose. Nothing else in the cell says
// what it is, so without an explicit name the whole bin reads to a screen
// reader as a row of unlabelled buttons.
const g = mount(composer.gallery.icons({ items: [{ id: "1", label: "rooftop_dusk_01.mp4" }] }));
const tile = g.node.querySelector(".cx-cell");
assert.equal(tile.getAttribute("aria-label"), "rooftop_dusk_01.mp4");
assert.equal(tile.getAttribute("title"), "rooftop_dusk_01.mp4");
});
test("the three views of a collection agree on what is selected", () => {
// Grid, list and icons are one bin in three shapes. A view that reported its
// selection differently would make switching views a way to lose it.
const items = [{ id: "a", label: "a.png" }, { id: "b", label: "b.png" }];
for (const view of ["adaptive", "list", "icons"]) {
const g = mount(composer.gallery[view]({ items, selection: ["b"] }));
assert.deepEqual(g.value, ["b"], `${view} reported the wrong selection`);
g.setValue(["a"]);
assert.deepEqual(g.value, ["a"], `${view} did not take a new selection`);
const on = [...g.node.querySelectorAll('[aria-selected="true"]')];
assert.equal(on.length, 1, `${view} drew ${on.length} selected entries`);
g.destroy();
}
});
test("a thumbnail that does not load falls back to the glyph", () => {
// The browser's broken-image icon reads as a damaged file. In a bin of fifty
// results one server hiccup would look like lost work.
const g = mount(composer.gallery.adaptive({ id: "broken", items: [{ id: "1", label: "a.png", thumb: "/gone.png" }] }));
const img = g.node.querySelector("img");
fire(img, "error");
assert.equal(g.node.querySelector("img"), null);
assert.ok(g.node.querySelector(".cx-cell-glyph"), "nothing stood in for the missing picture");
assert.match(g.node.textContent, /a\.png/, "the name went with it");
});
test("a bin's thumbnails are lazy", () => {
// A bin holds everything a session produced. Fetching all of it the moment
// the panel draws is the shape of v4's media-bin stall: the browser opens six
// connections per origin, and the API is behind the same six.
const items = [{ id: "1", label: "a.png", thumb: "/view?filename=a.png" }];
for (const view of ["adaptive", "list", "icons"]) {
const g = mount(composer.gallery[view]({ items }));
assert.equal(g.node.querySelector("img").getAttribute("loading"), "lazy",
`${view} fetches every thumbnail at once`);
g.destroy();
}
});
// --- track -------------------------------------------------------------
const CLIPS = [
{ id: "a", label: "Scene 1", start: 0, duration: 4 },
{ id: "b", label: "Scene 2", start: 4, duration: 6 },
{ id: "c", label: "Scene 3", start: 10, duration: 2 },
];
test("a clip's width is proportional to its duration, not equal to its neighbours'", () => {
// The third clip is 2s at 10px/s = 20px, under the 24px floor -- widened to
// stay clickable, which is why it is not exactly proportional to the other
// two the way the first two are to each other.
const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 10 }));
const widths = [...t.node.querySelectorAll(".cx-track-clip")].map((c) => parseFloat(c.style.width));
assert.deepEqual(widths, [40, 60, 24]);
});
test("a clip under the pixel floor is not squeezed to nothing", () => {
const t = mount(composer.track.default({
items: [{ id: "a", label: "Sliver", start: 0, duration: 0.1 }], pxPerSecond: 10,
}));
const width = parseFloat(t.node.querySelector(".cx-track-clip").style.width);
assert.ok(width >= 24, `a 0.1s clip at 10px/s rendered ${width}px wide`);
});
test("the ruler reads real elapsed time, not a tick per clip", () => {
// Three clips, but the ruler's ticks are seconds -- there is no reason their
// count should match the clip count in either direction.
const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 40 }));
const ticks = [...t.node.querySelectorAll(".cx-track-tick")].map((n) => n.textContent);
assert.deepEqual(ticks, ["0:00", "0:02", "0:04", "0:06", "0:08", "0:10", "0:12"]);
});
test("clicking partway into a clip seeks to that exact second, not to the clip's start", () => {
// A clip covers most of the track's own area -- if a click on one only
// ever resolved to its START, an exact-second seek would only ever be
// reachable from the thin ruler strip above it.
const seen = [];
const t = mount(composer.track.default({
items: CLIPS, pxPerSecond: 40, onSeek: (seconds, item) => seen.push([seconds, item && item.id]),
}));
// Clip "b" spans 4s-10s -> 160px-400px at 40px/s. 250px is 6.25s into it.
t.node.querySelector('[data-id="b"]')
.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 250 }));
assert.deepEqual(seen, [[6.25, "b"]]);
});
test("clicking empty track (the ruler, the gap past the clips) seeks by position", () => {
const seen = [];
const t = mount(composer.track.default({
items: CLIPS, pxPerSecond: 10, onSeek: (seconds, item) => seen.push([seconds, item && item.id]),
}));
const inner = t.node.querySelector(".cx-track-inner");
inner.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 45 }));
// 45px at 10px/s = 4.5s, which falls inside clip "b" (4s..10s).
assert.deepEqual(seen, [[4.5, "b"]]);
});
test("a seek past the last clip clamps to the track's own end", () => {
const seen = [];
const t = mount(composer.track.default({
items: CLIPS, pxPerSecond: 10, onSeek: (seconds, item) => seen.push([seconds, item && item.id]),
}));
t.node.querySelector(".cx-track-inner")
.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 9999 }));
assert.deepEqual(seen, [[12, "c"]]);
});
test("setPlayhead moves the marker without redrawing the clips", () => {
const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 10, playhead: 0 }));
const before = t.node.querySelector(".cx-track-clip");
t.setPlayhead(7);
const head = t.node.querySelector(".cx-track-playhead");
assert.equal(head.style.insetInlineStart, "70px");
assert.equal(t.node.querySelector(".cx-track-clip"), before, "the clip row was rebuilt for a playhead move");
});
test("setValue redraws the selected clip without a fresh setItems call", () => {
const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 10 }));
assert.equal(t.node.querySelectorAll('[aria-selected="true"]').length, 0);
t.setValue(["b"]);
const on = t.node.querySelector('[aria-selected="true"]');
assert.equal(on && on.dataset.id, "b");
});
test("dragging a clip onto another reports the reorder by id, not by position", () => {
const reordered = [];
const t = mount(composer.track.default({ items: CLIPS, pxPerSecond: 10, onReorder: (from, to) => reordered.push([from, to]) }));
const cells = t.node.querySelectorAll(".cx-track-clip");
const store = {};
const dt = {
effectAllowed: null,
setData: (type, v) => { store[type] = v; },
getData: (type) => store[type] || "",
get types() { return Object.keys(store); },
};
const fireDrag = (node, type) => {
const e = new window.Event(type, { bubbles: true, cancelable: true });
e.dataTransfer = dt;
node.dispatchEvent(e);
};
fireDrag(cells[0], "dragstart");
fireDrag(cells[2], "drop");
assert.deepEqual(reordered, [["a", "c"]]);
});
test("an excluded clip is dimmed, a rated one carries its rating", () => {
const t = mount(composer.track.default({
items: [
{ id: "a", label: "A", start: 0, duration: 1, excluded: true },
{ id: "b", label: "B", start: 1, duration: 1, rating: "liked" },
], pxPerSecond: 10,
}));
const [a, b] = t.node.querySelectorAll(".cx-track-clip");
assert.equal(a.classList.contains("cx-excluded"), true);
assert.equal(b.getAttribute("data-rating"), "liked");
});
test("Enter is a newline in a textarea, not a commit", () => {
// A one-line field commits on Enter. A textarea holds the longest thing
// anyone types here -- the prompt -- and stealing Enter there means a
// multi-line value cannot be typed at all.
const committed = [];
const box = mount(composer.textarea.md({ label: "Prompt", onCommit: (v) => committed.push(v) }));
box.node.value = "first line";
const event = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
box.node.dispatchEvent(event);
assert.equal(event.defaultPrevented, false, "Enter was swallowed, so no second line can be typed");
assert.deepEqual(committed, []);
fire(box.node, "blur");
assert.deepEqual(committed, ["first line"], "blur is what commits a textarea");
});
+129
View File
@@ -0,0 +1,129 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "./_dom.js";
import { focusables, roving, trap } from "../internals/focus.js";
test.beforeEach(() => setupDom());
test.after(() => teardownDom());
function box(html) {
const n = document.createElement("div");
n.innerHTML = html; // test fixture, not kit code
document.body.appendChild(n);
return n;
}
const tab = (node, shift = false) =>
node.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Tab", shiftKey: shift, bubbles: true, cancelable: true }));
const arrow = (node, key) =>
node.dispatchEvent(new window.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
test("focusables skips disabled and hidden controls", () => {
const n = box(`<button>a</button><button disabled>b</button>
<input hidden><a href="#">c</a><span tabindex="-1">d</span><div aria-hidden="true"><button>e</button></div>`);
assert.deepEqual(focusables(n).map((x) => x.textContent || x.tagName), ["a", "c"]);
});
test("focus moves into the container on trap", () => {
const n = box("<button>a</button><button>b</button>");
trap(n);
assert.equal(document.activeElement.textContent, "a");
});
test("Tab wraps from last back to first", () => {
const n = box("<button>a</button><button>b</button>");
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("<button>a</button><button>b</button>");
trap(n);
tab(n, true);
assert.equal(document.activeElement.textContent, "b");
});
test("focus that escaped the container is pulled back", () => {
const outside = box("<button>outside</button>");
const n = box("<button>a</button><button>b</button>");
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 <body> on close silently ends keyboard navigation.
const opener = box("<button>opener</button>").querySelector("button");
opener.focus();
const n = box("<button>a</button>");
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("<button>opener</button>").querySelector("button");
opener.focus();
const n = box("<button>a</button>");
trap(n)({ restore: false });
assert.notEqual(document.activeElement, opener);
});
test("an empty container traps without throwing", () => {
const n = box("<p>nothing focusable</p>");
assert.doesNotThrow(() => trap(n));
assert.doesNotThrow(() => tab(n));
});
test("roving gives the group one tab stop", () => {
const n = box("<button>a</button><button>b</button><button>c</button>");
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("<button>a</button><button>b</button><button>c</button>");
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("<button>a</button><button>b</button><button>c</button>");
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("<button>a</button><button>b</button>");
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("<button>a</button><button>b</button>");
roving(n);
document.body.focus();
assert.doesNotThrow(() => arrow(n, "ArrowRight"));
});
+14
View File
@@ -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+$/);
});
@@ -0,0 +1,7 @@
export function setup({ composer, values }) {
const button = composer.button.md({
label: "Re-key",
onClick: () => values.set("enabled", true),
});
return button;
}
@@ -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 = "<b>hi</b>";
const smuggled = '<div style="color: red">still styling</div>';
const raw = document.createElement("div");
raw.setAttribute("style", "margin: 12px");
document.head.appendChild(raw);
void smuggled;
return new Function("return 1")();
}
+32
View File
@@ -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);
});
+606
View File
@@ -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");
});
+237
View File
@@ -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;
});
+77
View File
@@ -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`);
}
});
+72
View File
@@ -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"]);
});
@@ -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 = '<img src=x onerror="boom()">';
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`);
}
}
});
@@ -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");
});
@@ -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"]);
});
+57
View File
@@ -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('<img src=x onerror="boom()">'));
assert.equal(host.querySelector("img"), null);
assert.equal(host.textContent, '<img src=x onerror="boom()">');
});
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);
});
+105
View File
@@ -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/);
});
+25
View File
@@ -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;
}
+82
View File
@@ -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;
}
+50
View File
@@ -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; }
+245
View File
@@ -0,0 +1,245 @@
/* Every value in the design system. Nothing outside this file invents one.
*
* Theme is an attribute on <html>, 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 <select> is sized by
its widest OPTION, so one row holding a list of checkpoint filenames was
264px wide among four that were 140. */
--control-col: 180px;
}
/* ── dark: the cutting-room palette (default) ─────────────────────────── */
/* Scoped to any [data-theme] element, not just :root, so a pane can render the
* other theme in place — the catalogue shows both side by side that way. */
:root,
[data-theme="dark"] {
color-scheme: dark;
--elev-0: #0c0b09; /* page */
--elev-1: #131210; /* panel */
--elev-2: #1a1814; /* raised, head */
--elev-3: #1f1c16; /* overlay */
--hover: #221f19;
--active: #2a261e;
--line: #2a261e;
--line-2: #37322833;
--text: #ece7db;
--muted: #918a7a;
--faint: #645e51;
--accent: #f3a93c;
--accent-hi: #ffc36b;
--accent-lo: #e0972a;
--accent-soft: rgba(243, 169, 60, .14);
--on-accent: #1a1206;
--teal: #57d0c6; /* render / audio semantic */
--violet: #c084fc; /* overlay / text layer */
--danger: #e8607a;
--warn: #e8a040;
--good: #79c479;
--pink: rgb(255, 120, 180);
--void: #060606; /* media letterbox */
--shadow-ink: rgba(0, 0, 0, .85);
--backdrop: rgba(6, 5, 4, .66);
/* Film grain. `overlay` over a dark surface lifts it slightly and breaks up
the flatness of a large area of one colour, which is most of this app. */
--grain-opacity: .035;
--grain-blend: overlay;
/* A filled accent control, at rest and under the pointer.
Dark's accent is amber carrying near-black text: it can go as bright as it
likes, so the button sits at the accent and LIGHTENS when pressed towards.
Which way hover moves is the opposite of light's, below. */
--grad-accent: linear-gradient(180deg, var(--accent), var(--accent-lo));
--grad-accent-hover: linear-gradient(180deg, var(--accent-hi), var(--accent));
--accent-hover-edge: var(--accent-hi);
}
/* ── light: white, grey and daylight blue ─────────────────────────────── */
[data-theme="light"] {
color-scheme: light;
--elev-0: #f5f7fa;
--elev-1: #ffffff;
--elev-2: #eef1f6;
--elev-3: #ffffff;
--hover: #e1e7ef;
--active: #d3dae3;
--line: #d3dae3;
--line-2: #8a97a833;
--text: #16202c;
--muted: #59677a;
--faint: #8d9aab;
--accent: #2f7fd4;
--accent-hi: #5aa4ec;
--accent-lo: #2668b0;
--accent-soft: rgba(47, 127, 212, .13);
--on-accent: #ffffff;
--teal: #12857c;
--violet: #7c3aed;
--danger: #c62a48;
--warn: #b26a00;
--good: #2f8f4e;
--pink: rgb(197, 45, 116);
--void: #0a0c0f;
--shadow-ink: rgba(24, 34, 48, .30);
--backdrop: rgba(24, 34, 48, .34);
/* The same grain over a light surface with `overlay` reads as dirt, so light
multiplies a much fainter one. v4 landed on the same two numbers. */
--grain-opacity: .015;
--grain-blend: multiply;
/* Light's accent carries WHITE text, which caps how bright the fill can be:
past the accent itself the label starts disappearing into it. So the button
sits AT the accent -- brighter than the accent-to-accent-lo fade it used to
wear, which made it look washed against a white page -- and DARKENS under
the pointer, where white only reads better. The opposite of dark's. */
--grad-accent: linear-gradient(180deg, var(--accent),
color-mix(in srgb, var(--accent) 85%, var(--accent-lo)));
--grad-accent-hover: linear-gradient(180deg, var(--accent-lo),
color-mix(in srgb, var(--accent-lo) 82%, black));
--accent-hover-edge: var(--accent-lo);
}
/* Derived values, written once so both themes get them for free. color-mix
* percentages come from a fixed set: 12 / 22 / 30 / 45 / 60 / 80 — v4 had float
* artefacts like 64.7%.
*
* The selector must include [data-theme], not just :root. A custom property's
* var()s are substituted where the property is DECLARED and inherited already
* resolved — so if these lived on :root alone, a scoped [data-theme="light"]
* subtree would inherit the root theme's accent and shadow. Declaring them here
* too makes each themed element re-derive them from its own palette. */
:root,
[data-theme] {
--line-hover: color-mix(in srgb, var(--accent) 30%, var(--line));
--line-active: color-mix(in srgb, var(--accent) 60%, var(--line));
--focus-ring: 0 0 0 3px var(--accent-soft);
--shadow-1: 0 1px 2px color-mix(in srgb, var(--shadow-ink) 45%, transparent);
--shadow-2: 0 4px 12px color-mix(in srgb, var(--shadow-ink) 60%, transparent);
--shadow-3: 0 12px 32px color-mix(in srgb, var(--shadow-ink) 80%, transparent);
/* ── surfaces ────────────────────────────────────────────────────────
Depth, derived from the palette rather than painted per component.
A flat fill everywhere is what made this read as a wireframe of itself:
every band, control and card was one colour with a 1px line around it,
so nothing sat above anything else.
Each of these is a vertical fall between two steps of the SAME theme's
elevation scale, so dark falls darker and light falls lighter, and
neither needs a rule of its own. */
--grad-band: linear-gradient(180deg, var(--elev-2),
color-mix(in srgb, var(--elev-2) 55%, var(--elev-0)));
--grad-raise: linear-gradient(180deg, color-mix(in srgb, var(--elev-2) 72%, var(--elev-3)),
var(--elev-2));
/* A filled accent control throws a little of its own colour. Negative
spread, so it is a bloom under the control and not a box around it. */
--glow-accent: 0 4px 14px -6px var(--accent);
/* The page is not one flat colour: a soft wash off the top-right corner,
which is what stops a 1440px-wide dark background looking like paper. */
--wash: radial-gradient(1200px 600px at 80% -10%,
color-mix(in srgb, var(--elev-2) 70%, transparent) 0%,
transparent 60%);
--scroll-thumb: color-mix(in srgb, var(--text) 16%, transparent);
--scroll-thumb-hi: color-mix(in srgb, var(--text) 28%, transparent);
}
/* One guard covers the whole app, because every duration is a token.
* v4 had 40 unguarded transitions — unguarded precisely because the durations
* were hardcoded at each site. */
@media (prefers-reduced-motion: reduce) {
:root { --dur-1: 0ms; --dur-2: 0ms; --dur-3: 0ms; }
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { composer } from "../../composer/composer.js";
import { open } from "./window.js";
export default {
id: "log",
mount: "menubar",
setup: ({ host }) => host.append(composer.button.sm({ label: "Log", onClick: () => open() }).node),
};
+93
View File
@@ -0,0 +1,93 @@
// ComfyUI's log, in the app.
//
// Users do not open devtools. "It did not work" with nothing to read is where a
// bug report dies, and the one place the reason is written down is a terminal
// they may not even be able to see -- a rented box behind a tunnel has no
// terminal on this desk at all.
//
// It polls only while open, and it stops polling while you are selecting text:
// a panel that redraws under a drag makes copying a stack trace impossible,
// which is the single thing anybody opens a log to do.
import { composer } from "../../composer/composer.js";
const EVERY = 2000;
/** mount({ poll, setFooter, close }) -> { node, destroy }. See tempfiles.js's
* mount() for why setFooter/close are the only things asked for -- `destroy`
* additionally stops the poll, since this one runs a timer nothing else does. */
export function mount({ poll = EVERY, setFooter = () => {}, close = () => {} } = {}) {
let timer = null;
let follow = true; // stick to the end until the user scrolls off it
let last = "";
const view = composer.code.block({ text: "Reading…" });
const body = composer.region.stack({ gap: "sm", fill: true, children: [view] });
const selecting = () => {
const sel = document.getSelection();
return sel && !sel.isCollapsed && view.node.contains(sel.anchorNode);
};
// Following the end is the default and stops being it the moment someone
// scrolls up -- otherwise reading anything older than two seconds is a fight.
view.node.addEventListener("scroll", () => {
const room = view.node.scrollHeight - view.node.scrollTop - view.node.clientHeight;
follow = room < 40;
});
async function refresh() {
if (selecting()) return; // never redraw under a drag
try {
const res = await fetch("/funpack/api/log?limit=600", { cache: "no-store" });
const payload = await res.json().catch(() => ({}));
const text = (payload.lines || []).join("\n")
|| payload.detail
|| "Nothing in the log yet.";
if (text !== last) {
last = text;
view.setText(text);
if (follow) view.node.scrollTop = view.node.scrollHeight;
}
setFooter({ note: payload.path || "", actions: footer() });
} catch (err) {
// The server being gone is the most interesting thing a log can say, so it
// is said -- and the lines already on screen are kept, because they are
// the ones from just before it went.
setFooter({ note: `Cannot reach ComfyUI: ${err.message}`, actions: footer() });
}
}
const footer = () => [
composer.button.md({
label: "Copy", onClick: () => {
// Selecting a thousand lines by hand is the other reason this is hard to
// get out of a log panel.
if (navigator.clipboard) navigator.clipboard.writeText(last).catch(() => {});
},
}),
composer.button.md({ label: "Close", tone: "primary", onClick: () => close() }),
];
setFooter({ actions: footer() });
refresh();
timer = setInterval(refresh, poll);
return { node: body.node, destroy: () => { clearInterval(timer); timer = null; body.destroy(); } };
}
export function open({ poll = EVERY } = {}) {
let window_ = null;
const content = mount({
poll,
setFooter: (f) => window_ && window_.setFooter(f),
close: () => window_ && window_.close("done"),
});
window_ = composer.modal.generic({
title: "ComfyUI log",
subtitle: "What the server printed. Newest at the bottom.",
size: "xl",
body: content,
onClose: () => { content.destroy(); window_ = null; },
});
return window_;
}
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FunPack</title>
<script src="/funpack/app/ui/shell/theme.js"></script>
<link rel="stylesheet" href="/funpack/app/ui/composer/composer.css">
<style>
/* The app frame itself: the only rule outside the kit, and it sets no
colours, sizes or type -- just the shape of the page. A DEFINITE height,
because the kit's frame fills what it is given and "100% of auto" is
auto: with min-height here the whole app rendered as a strip at the top
of an empty window. */
html, body { block-size: 100%; }
body { display: flex; margin: 0; }
/* No padding: the frame's own bands run edge to edge, and the space
around the content is the gutter the kit applies inside them. */
#app { flex: 1; display: flex; min-block-size: 0; min-inline-size: 0; }
#app > * { flex: 1; min-inline-size: 0; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/funpack/app/ui/boot.js"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
// The one list of UI features. Adding a feature is a folder under ./features and a line here.
export default [
"./features/log/index.js",
].map((path) => new URL(path, import.meta.url).href);
+30
View File
@@ -0,0 +1,30 @@
// Loads UI features. A feature is a folder with an index.js hub:
// export default { id, mount, needs?, setup({ host }) -> teardown? }
// `mount` names a region (see mounts.js). A feature that cannot load, names a region nobody offers,
// needs a service the app lacks, or throws in setup is hidden -- nothing of it reaches the screen.
import { hostFor, offered } from "./mounts.js";
export async function loadFeatures(paths, { load = (path) => import(path), has = () => true } = {}) {
const mounted = [], hidden = [];
for (const path of paths) {
let hub;
try {
hub = (await load(path)).default;
const host = hostFor(hub.mount);
if (!host) throw new Error(`no region offers "${hub.mount}" (offered: ${offered().join(", ")})`);
const lacking = (hub.needs || []).filter((name) => !has(name));
if (lacking.length) throw new Error(`needs ${lacking.join(", ")}`);
const before = [...host.childNodes];
try {
mounted.push({ id: hub.id, teardown: hub.setup({ host }) });
} catch (err) { // whatever it put on the page goes with it
[...host.childNodes].filter((n) => !before.includes(n)).forEach((n) => n.remove());
throw err;
}
} catch (err) {
hidden.push({ id: hub?.id || path, why: err.message });
console.warn(`[FunPack] ${hub?.id || path} hidden: ${err.message}`);
}
}
return { mounted, hidden };
}
+27
View File
@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { offer, _reset } from "./mounts.js";
import { loadFeatures } from "./features.js";
const host = () => { const nodes = []; return { childNodes: nodes, append: (n) => nodes.push(n) }; };
const hubs = {
ok: { id: "ok", mount: "a", setup: ({ host: h }) => h.append({ remove() {} }) },
throws: { id: "throws", mount: "a", setup: () => { throw new Error("boom"); } },
nowhere: { id: "nowhere", mount: "zzz", setup() {} },
needy: { id: "needy", mount: "a", needs: ["gpu"], setup() {} },
};
const run = (names) => loadFeatures(names, { load: async (n) => ({ default: hubs[n] }), has: () => false });
test("a feature that loads is mounted; one that throws, names no region, or lacks a need is hidden, and the rest still load", async () => {
_reset(); offer("a", host());
const { mounted, hidden } = await run(["throws", "nowhere", "needy", "ok"]);
assert.deepEqual(mounted.map((m) => m.id), ["ok"]);
assert.deepEqual(hidden.map((h) => [h.id, h.why.split(" ")[0]]), [["throws", "boom"], ["nowhere", "no"], ["needy", "needs"]]);
});
test("what a feature put on the page before it threw is taken away", async () => {
_reset(); const h = host(); offer("a", h);
hubs.throws.setup = ({ host: x }) => { x.append({ remove() { h.childNodes.length = 0; } }); throw new Error("late"); };
await run(["throws"]);
assert.equal(h.childNodes.length, 0);
});
+20
View File
@@ -0,0 +1,20 @@
// The app's regions, and what each offers to features. This is the whole of the layout:
// Assets | Preview over Timeline | Properties, under a menubar. It names no feature.
import { composer } from "../composer/composer.js";
import { offer } from "./mounts.js";
export function build(root) {
const zone = (title, mount) => {
const body = composer.region.stack({ gap: "sm", fill: true });
offer(mount, body.node);
return composer.panel.zone({ title, body });
};
const menubar = composer.region.stack({ gap: "sm" });
offer("menubar", menubar.node);
const centre = composer.splitPane.v({ panes: [zone("Preview", "preview"), zone("Timeline", "timeline")], size: 60 });
const main = composer.workspace.docked({ centre, left: zone("Assets", "assets"), right: zone("Properties", "inspector") });
const frame = composer.frame.app({ header: menubar, main });
root.append(frame.node);
return frame;
}
+10
View File
@@ -0,0 +1,10 @@
// What the server says exists.
const ENDPOINT = "/funpack/api/modules";
export async function fetchManifest(traits) {
const url = traits ? `${ENDPOINT}?traits=${encodeURIComponent(traits.join(","))}` : ENDPOINT;
const response = await fetch(url);
if (!response.ok) throw new Error(`${ENDPOINT} answered ${response.status}`);
return response.json();
}
+51
View File
@@ -0,0 +1,51 @@
// Mount points are ANNOUNCED by the regions that offer them.
//
// A fixed list in core would be a global list -- the one thing the architecture
// avoids -- and it would have to be edited every time the shell grows a region.
// Instead a region says what it can host, and a module naming something no
// region offers is simply absent. That is "hide, don't warn" one level up.
const regions = new Map(); // mount point -> { host, standIn }
/**
* offer(mount, host, standIn?)
*
* `standIn` is what the region shows while nothing has mounted into it -- an
* empty state, not a label. It is taken down by settle() once something is
* there, because a region that says "modules appear here" ABOVE the modules
* that appeared is a region explaining itself to nobody.
*/
export function offer(mount, host, standIn = null) {
if (regions.has(mount)) {
throw new Error(`Mount point "${mount}" is already offered by another region.`);
}
regions.set(mount, { host, standIn });
return () => regions.delete(mount);
}
export const hostFor = (mount) => {
const region = regions.get(mount);
return region ? region.host : null;
};
export const offered = () => [...regions.keys()].sort();
export const claimed = (mount) => regions.has(mount);
/**
* Take down the stand-in of every region something mounted into.
*
* Called once, after everything that mounts has had its turn. Doing it as each
* one mounts would work too and would be wrong the moment two regions share a
* host: the one that mounted first would take down a stand-in belonging to
* both.
*/
export function settle() {
for (const { host, standIn } of regions.values()) {
if (!standIn || !standIn.parentNode) continue;
const others = [...host.children].filter((child) => child !== standIn);
if (others.length) standIn.remove();
}
}
export function _reset() {
regions.clear();
}
+113
View File
@@ -0,0 +1,113 @@
// Turning announcements into panels.
//
// Everything a module can reach is decided here, and it is deliberately little:
// { values, on, shell }. No DOM node, no document, no element factory.
//
// `composer` used to be in that list, and it was the hole in the guarantee: a
// factory returns a handle whose `.node` is a real element, so a module could
// take one, style it, and reach the whole document through `node.ownerDocument`
// -- no lint to evade, just the intended API. No module ever used it. Panels are
// rendered from the DECLARATION, and a module that wants to draw something is a
// module that should be declaring it instead.
//
// What is true, stated exactly: the kit hands a module nothing that touches the
// DOM. What is NOT true, and must not be claimed: that a module CANNOT reach the
// DOM. A ui.js is an ES module in the page realm and `document` is a global.
// tools/kit_lint raises the cost of doing that accidentally; it is a discipline
// check and not a boundary, and only real isolation (a worker, another realm)
// would make it one. First-party modules in this repo are reviewed; a
// third-party module would need that isolation before it could be trusted.
import { composer } from "../composer/composer.js";
import { renderPanel, defaultsOf } from "../composer/panel.js";
import { hostFor, offered } from "./mounts.js";
import * as values from "./values.js";
import { services } from "./services.js";
/**
* mountAll(manifest) -> { mounted, hidden }
*
* `hidden` is diagnostic only. Nothing about a hidden module reaches the screen:
* no placeholder, no greyed row, no warning chip. A module that failed is
* indistinguishable from one that was never installed, which is the point --
* the alternative is a UI full of things that cannot be used.
*/
export async function mountAll(manifest, { load = (path) => import(path) } = {}) {
const mounted = [];
const hidden = [];
for (const spec of manifest.modules || []) {
// Nothing to render. A module may exist purely to contribute ComfyUI nodes
// (a loader, a model's compatibility module), and those have no panel and
// no mount. Skipping is not hiding: there is no failure to report.
const renders = Object.keys(spec.settings || {}).length > 0 || spec.ui;
if (!renders) continue;
const host = hostFor(spec.mount);
if (!host) {
hidden.push({ id: spec.id, why: `no region offers "${spec.mount}" (offered: ${offered().join(", ") || "none"})` });
continue;
}
try {
// Values first: a module's ui.js may read them during setup, and a
// headless run has them without any of this having happened.
values.seed(spec.id, defaultsOf(spec));
let ui = null;
if (spec.ui) ui = await load(spec.ui);
let teardown = null;
if (ui && typeof ui.setup === "function") {
// Kept, not discarded: setup() returns the unsubscribe from on(), and
// dropping it means every re-mount leaves another live listener behind.
teardown = ui.setup({
values: {
get: () => values.valuesOf(spec.id),
set: (key, value) => values.set(spec.id, key, value),
},
// Scoped to this module: a module hearing about everyone else's
// edits would have to filter, and filtering needs an id it should
// not need to know.
on: (fn) => values.onChange((id, key, value) => {
if (id === spec.id) fn(key, value);
}),
// The app services a module may use, named explicitly. A short,
// reviewable list beats reaching for a global, which is how a module
// ends up depending on something nobody meant to expose.
shell: services,
});
}
// Built AFTER setup, so a control shows what setup settled on. The panel
// is drawn from a snapshot of the values, so building it first meant a
// module that adopted a live value in setup -- the theme, which the page
// applies before any module exists -- had a control showing something the
// app was not set to.
const panel = renderPanel(spec, {
values: values.valuesOf(spec.id),
onChange: (key, value) => values.set(spec.id, key, value),
});
// Only now, with a complete panel AND a setup() that returned, does
// anything reach the DOM. Appending before setup ran left a module whose
// setup threw reported as hidden with its panel still on screen -- which
// is precisely the guarantee this file states it keeps.
host.appendChild(panel.node);
mounted.push({
id: spec.id,
panel,
destroy() {
if (typeof teardown === "function") teardown();
panel.destroy();
},
});
} catch (err) {
hidden.push({ id: spec.id, why: `${err.name}: ${err.message}` });
console.warn(`[FunPack] ${spec.id} did not mount: ${err.message}`);
}
}
return { mounted, hidden };
}
+26
View File
@@ -0,0 +1,26 @@
// What the app offers modules beyond the kit.
//
// Deliberately tiny and named one by one. The alternative -- letting a module
// reach for a global -- means the surface is "whatever happens to be on window",
// which nobody can review and nothing can keep stable.
import { applyDensity, normaliseCols, rememberCols, recallCols } from "../composer/internals/density.js";
export const services = {
theme: {
set(choice) { window.ComposerTheme.apply(choice); },
get: () => window.ComposerTheme.get(),
resolved: () => window.ComposerTheme.resolved(),
},
density: {
/** What the grids are set to, which the browser remembers between visits. */
get: () => recallCols("app"),
/** Column count for every adaptive grid on the page. */
set(cols) {
const n = normaliseCols(cols);
for (const grid of document.querySelectorAll(".cx-gallery")) applyDensity(grid, n);
rememberCols("app", n);
return n;
},
},
};
+84
View File
@@ -0,0 +1,84 @@
// Colour scheme: dark (the original cutting-room palette), light, or auto.
//
// "auto" is resolved HERE rather than in CSS: a @media (prefers-color-scheme)
// block cannot be reused across the two selectors it would need, so the palette
// would have to be written twice and would drift. The stylesheet only ever sees
// data-theme="dark" or "light"; this file decides which.
//
// Loaded before every other script (and re-applied inline in <head>) so the
// first paint is already in the right theme — a dark flash on a light setup is
// the one bug a theme switcher cannot apologise for.
(function () {
const LS_KEY = "funpack_theme";
const CHOICES = ["dark", "light", "auto"];
const listeners = new Set();
let mq = null;
function stored() {
try {
const v = localStorage.getItem(LS_KEY);
return CHOICES.includes(v) ? v : "dark";
} catch (_) {
return "dark";
}
}
function systemIsLight() {
return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches);
}
function resolve(choice) {
return choice === "auto" ? (systemIsLight() ? "light" : "dark") : choice;
}
// data-theme is what the stylesheet reads; data-theme-pref is the choice behind it
// ("auto" resolves to one of the other two). The theme changes on an explicit click
// and nothing else, so these two always move together.
function stamp(choice) {
const resolved = resolve(choice);
document.documentElement.setAttribute("data-theme", resolved);
document.documentElement.setAttribute("data-theme-pref", choice);
return resolved;
}
// Only while the preference is "auto" does the OS get a vote; a user who picked
// light explicitly should not flip at sunset.
function watchSystem(choice) {
if (!window.matchMedia) return;
if (!mq) {
mq = window.matchMedia("(prefers-color-scheme: light)");
const onChange = () => { if (get() === "auto") apply("auto"); };
if (mq.addEventListener) mq.addEventListener("change", onChange);
else if (mq.addListener) mq.addListener(onChange);
}
void choice;
}
function get() {
return document.documentElement.getAttribute("data-theme-pref") || stored();
}
function resolved() {
return document.documentElement.getAttribute("data-theme") || "dark";
}
function apply(choice, { persist = true } = {}) {
if (!CHOICES.includes(choice)) choice = "dark";
const res = stamp(choice);
if (persist) {
try { localStorage.setItem(LS_KEY, choice); } catch (_) {}
}
watchSystem(choice);
listeners.forEach((fn) => { try { fn(choice, res); } catch (_) {} });
return res;
}
function onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
apply(stored(), { persist: false });
window.ComposerTheme = { get, resolved, apply, onChange, CHOICES };
})();
+36
View File
@@ -0,0 +1,36 @@
// The live value of every setting, keyed exactly as the declaration keys them.
//
// This is the only thing in the app that reads the UI. A module never does: it
// declares what it needs and is handed values, so the same module works with a
// panel on screen and with no browser at all.
const store = new Map(); // moduleId -> { key: value }
const listeners = new Set();
export function seed(moduleId, defaults) {
store.set(moduleId, { ...defaults });
}
export function set(moduleId, key, value) {
const current = store.get(moduleId) || {};
current[key] = value;
store.set(moduleId, current);
for (const fn of listeners) {
try { fn(moduleId, key, value); } catch { /* a listener must not break the edit */ }
}
}
export const valuesOf = (moduleId) => ({ ...(store.get(moduleId) || {}) });
/** Everything, as it would be sent with a generation request. */
export const all = () => Object.fromEntries([...store].map(([id, v]) => [id, { ...v }]));
export function onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
export function _reset() {
store.clear();
listeners.clear();
}