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:
co-authored by
Claude Sonnet 5.5
parent
23208f5468
commit
580fa5f94a
@@ -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 };
|
||||
@@ -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); }
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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" } } },
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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 →</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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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); } }
|
||||
@@ -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(); },
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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); }
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
@@ -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); }
|
||||
@@ -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() };
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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); }
|
||||
@@ -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(); } };
|
||||
});
|
||||
@@ -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%; }
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
@@ -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); } }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
@@ -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); }
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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")();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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_;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user