From 835fab7864f58dbb3408bba5b36498d132ed75dc Mon Sep 17 00:00:00 2001
From: digital-garbage <133010426+digital-garbage@users.noreply.github.com>
Date: Fri, 28 Aug 2026 19:58:06 +0300
Subject: [PATCH] core+shell: the bridge, and the first module through it
One declaration does three jobs: renders the panel, carries the defaults, names
the values that come back. A module writes no DOM and reads no DOM.
Validation refuses rather than repairs -- unknown type, a renderer its type does
not have, a label that is not text, a default outside its own bounds, a when
naming a key the module never declared. A module that is wrong does not get a
best-effort panel; it does not load, and says why once.
Ordering is by declared relation and stage, never priority numbers, and a cycle
drops only the modules in it. Compatibility is by trait, never model name.
The shell hands a module { composer, values, on, shell } and nothing else: no
node, no document, no kit internals. It cannot style anything because it is
never given anything that could.
catalogue/hide.html puts one good module and five broken ones through the real
shell and shows the result: one panel, five absent, no placeholders. The lint
fixtures make the scanner prove it catches each rule, so a clean scan means
something.
First slice: modules/system/appearance. Two settings, an optional ui.js, and
clicking Light actually turns the app light.
---
app/boot.js | 46 +++-
app/catalogue/catalogue.css | 7 +
app/catalogue/elements.html | 1 +
app/catalogue/fixtures.js | 63 ++++++
app/catalogue/hide.html | 23 ++
app/catalogue/hide.js | 86 ++++++++
app/catalogue/index.html | 1 +
app/composer/elements/choice.css | 32 +--
app/composer/elements/choice.js | 48 ++--
app/composer/panel.js | 173 +++++++++++++++
app/composer/tests/elements_behaviour.test.js | 5 +-
app/composer/tests/lint_fixtures/clean/ui.js | 7 +
.../tests/lint_fixtures/offenders/ui.js | 12 +
app/composer/tests/panel_schema.test.js | 181 +++++++++++++++
.../tests/rule_no_module_styling.test.js | 72 ++++++
.../tests/rule_unknown_element_hides.test.js | 121 ++++++++++
app/index.html | 11 +-
app/shell/layout.js | 35 +++
app/shell/manifest.js | 10 +
app/shell/mounts.js | 24 ++
app/shell/panels.js | 76 +++++++
app/shell/services.js | 24 ++
app/shell/values.js | 36 +++
core/contract.py | 76 +++++++
core/registry.py | 99 +++++++++
core/relations.py | 65 ++++++
core/routes.py | 52 ++++-
core/schema.py | 172 +++++++++++++++
.../fixtures/fixture_modules/__init__.py | 0
.../fixture_modules/broken/__init__.py | 0
.../broken/bad_label/__init__.py | 6 +
.../fixture_modules/broken/bad_ui/__init__.py | 6 +
.../broken/no_default/__init__.py | 6 +
.../fixture_modules/broken/raises/__init__.py | 7 +
.../fixture_modules/cycle/__init__.py | 0
.../fixture_modules/cycle/first/__init__.py | 5 +
.../fixture_modules/cycle/second/__init__.py | 5 +
.../fixture_modules/model/__init__.py | 0
.../model/needs_trait/__init__.py | 7 +
.../fixture_modules/timing/__init__.py | 0
.../timing/audio_clock/__init__.py | 30 +++
.../fixture_modules/timing/audio_clock/ui.js | 1 +
.../timing/momentum/__init__.py | 8 +
core/tests/test_registry.py | 160 ++++++++++++++
core/tests/test_routes_manifest.py | 70 ++++++
core/tests/test_schema.py | 208 ++++++++++++++++++
core/traits.py | 24 ++
modules/__init__.py | 5 +
modules/system/__init__.py | 0
modules/system/appearance/__init__.py | 39 ++++
modules/system/appearance/ui.js | 15 ++
tools/devserver.py | 17 +-
tools/kit_lint/rules.js | 36 +++
tools/kit_lint/scan.js | 51 +++++
54 files changed, 2219 insertions(+), 45 deletions(-)
create mode 100644 app/catalogue/fixtures.js
create mode 100644 app/catalogue/hide.html
create mode 100644 app/catalogue/hide.js
create mode 100644 app/composer/panel.js
create mode 100644 app/composer/tests/lint_fixtures/clean/ui.js
create mode 100644 app/composer/tests/lint_fixtures/offenders/ui.js
create mode 100644 app/composer/tests/panel_schema.test.js
create mode 100644 app/composer/tests/rule_no_module_styling.test.js
create mode 100644 app/composer/tests/rule_unknown_element_hides.test.js
create mode 100644 app/shell/layout.js
create mode 100644 app/shell/manifest.js
create mode 100644 app/shell/mounts.js
create mode 100644 app/shell/panels.js
create mode 100644 app/shell/services.js
create mode 100644 app/shell/values.js
create mode 100644 core/contract.py
create mode 100644 core/registry.py
create mode 100644 core/relations.py
create mode 100644 core/schema.py
create mode 100644 core/tests/fixtures/fixture_modules/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/broken/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/broken/bad_label/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/broken/bad_ui/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/broken/no_default/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/broken/raises/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/cycle/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/cycle/first/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/cycle/second/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/model/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/model/needs_trait/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/timing/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/timing/audio_clock/__init__.py
create mode 100644 core/tests/fixtures/fixture_modules/timing/audio_clock/ui.js
create mode 100644 core/tests/fixtures/fixture_modules/timing/momentum/__init__.py
create mode 100644 core/tests/test_registry.py
create mode 100644 core/tests/test_routes_manifest.py
create mode 100644 core/tests/test_schema.py
create mode 100644 core/traits.py
create mode 100644 modules/__init__.py
create mode 100644 modules/system/__init__.py
create mode 100644 modules/system/appearance/__init__.py
create mode 100644 modules/system/appearance/ui.js
create mode 100644 tools/kit_lint/rules.js
create mode 100644 tools/kit_lint/scan.js
diff --git a/app/boot.js b/app/boot.js
index 1092cf3c..908c5af8 100644
--- a/app/boot.js
+++ b/app/boot.js
@@ -1,10 +1,40 @@
-// Entry point. Increment 2: the kit's internals exist and the module graph
-// loads; no elements are defined yet, so there is nothing to render.
+// Entry point.
+//
+// The whole of what the app does at startup: build the regions, ask the server
+// what exists, and mount whatever announced itself. Nothing here names a module.
-import { entries } from "./composer/composer.js";
+import { build } from "./shell/layout.js";
+import { fetchManifest } from "./shell/manifest.js";
+import { mountAll } from "./shell/panels.js";
+import { all as allValues } from "./shell/values.js";
+import { composer } from "./composer/composer.js";
-const count = [...entries()].length;
-document.querySelector("#app").textContent =
- count === 0
- ? "FunPack v5 — Composer internals loaded, no elements defined yet"
- : `FunPack v5 — ${count} elements registered`;
+const root = document.querySelector("#app");
+
+async function start() {
+ build(root);
+
+ let manifest;
+ try {
+ manifest = await fetchManifest();
+ } catch (err) {
+ // The one failure the user must see: with no manifest there is no app, so
+ // silence here would be an empty window with no explanation.
+ root.replaceChildren(composer.emptyState.default({
+ icon: "▲",
+ title: "Could not reach FunPack",
+ hint: `${err.message}. Is ComfyUI running?`,
+ }).node);
+ return;
+ }
+
+ const { mounted, hidden } = await mountAll(manifest);
+ console.info(`[FunPack] ${mounted.length} module(s) mounted`,
+ hidden.length ? `· ${hidden.length} hidden` : "");
+ for (const { id, why } of hidden) console.warn(`[FunPack] ${id} is hidden: ${why}`);
+
+ // Handy while there is no generate button to send them anywhere.
+ window.FunPack = { manifest, values: allValues, mounted: mounted.map((m) => m.id), hidden };
+}
+
+start();
diff --git a/app/catalogue/catalogue.css b/app/catalogue/catalogue.css
index ee4f4b34..78f77a22 100644
--- a/app/catalogue/catalogue.css
+++ b/app/catalogue/catalogue.css
@@ -75,3 +75,10 @@
background: var(--accent-soft); color: var(--accent-hi);
font-family: var(--font-mono); font-size: var(--fs-6); cursor: pointer;
}
+
+/* hide.html */
+.cat-mount { display: flex; flex-direction: column; gap: var(--sp-5); }
+.cat-ledger { display: grid; grid-template-columns: 160px 1fr 1fr; gap: var(--sp-3) var(--sp-5);
+ font-size: var(--fs-6); align-items: baseline; }
+.cat-why { color: var(--muted); }
+.cat-caught { color: var(--faint); font-family: var(--font-mono); font-size: var(--fs-7); }
diff --git a/app/catalogue/elements.html b/app/catalogue/elements.html
index 6b43e31a..60645156 100644
--- a/app/catalogue/elements.html
+++ b/app/catalogue/elements.html
@@ -13,6 +13,7 @@
Composer elements
Tokens →
+ Hide-don't-warn
diff --git a/app/catalogue/fixtures.js b/app/catalogue/fixtures.js
new file mode 100644
index 00000000..95d5889d
--- /dev/null
+++ b/app/catalogue/fixtures.js
@@ -0,0 +1,63 @@
+// Hide, don't warn -- shown rather than asserted.
+//
+// One good module and five broken ones, put through the SAME shell code the app
+// uses. The good one's panel appears. The broken ones do not appear at all: no
+// placeholder, no greyed row, no warning chip. What you see on this page is the
+// whole of what a user would see.
+//
+// These mirror core/tests/fixtures/fixture_modules, so the browser and the
+// Python suite are looking at the same five ways of being wrong.
+
+export const GOOD = {
+ id: "audio_clock",
+ title: "Audio clock",
+ mount: "demo.panel",
+ stage: "conditioning",
+ status: "proven",
+ requires: ["audio_stream"],
+ settings: {
+ enabled: { type: "bool", default: true, label: "Sync to audio clock",
+ hint: "Locks frame timing to the audio stream." },
+ strength: { type: "float", default: 0.65, min: 0, max: 1, step: 0.05,
+ label: "Lock strength", unit: "x", ui: "slider", when: { enabled: true } },
+ mode: { type: "enum", default: "beat", label: "Alignment", ui: "segmented",
+ options: [{ value: "beat", label: "Beat grid" },
+ { value: "onset", label: "Onset" },
+ { value: "flat", label: "Flat" }],
+ when: { enabled: true } },
+ },
+};
+
+// Each of these is refused somewhere different, which is the point: they are
+// caught at the earliest layer that can catch them.
+export const BROKEN = [
+ {
+ why: "raises on import",
+ caughtBy: "core: the scan imports it in a try",
+ spec: { id: "explodes", title: "Explodes", mount: "demo.panel", settings: {}, ui: "/nope/missing.js" },
+ },
+ {
+ why: "asks for a renderer its type does not have",
+ caughtBy: "core: schema validation, before the manifest",
+ spec: { id: "bad_ui", title: "Bad renderer", mount: "demo.panel",
+ settings: { on: { type: "bool", default: true, label: "On", ui: "hologram" } } },
+ },
+ {
+ why: "a label that is not text",
+ caughtBy: "core: schema validation; the kit would refuse it too",
+ spec: { id: "bad_label", title: "Bad label", mount: "demo.panel",
+ settings: { on: { type: "bool", default: true, label: { markup: "no" } } } },
+ },
+ {
+ why: "no default, so a headless run has no value",
+ caughtBy: "core: schema validation",
+ spec: { id: "no_default", title: "No default", mount: "demo.panel",
+ settings: { strength: { type: "float", min: 0, max: 1, label: "Strength" } } },
+ },
+ {
+ why: "names a mount point no region offers",
+ caughtBy: "shell: mounts.js has no host for it",
+ spec: { id: "lost", title: "Lost module", mount: "somewhere.else",
+ settings: { on: { type: "bool", default: true, label: "On" } } },
+ },
+];
diff --git a/app/catalogue/hide.html b/app/catalogue/hide.html
new file mode 100644
index 00000000..67702060
--- /dev/null
+++ b/app/catalogue/hide.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Hide, don't warn
+
+
+
+
+
+
+
Hide, don't warn
+
+ Tokens
+ Elements
+
+
+
+
+
+
+
diff --git a/app/catalogue/hide.js b/app/catalogue/hide.js
new file mode 100644
index 00000000..a9f37fc8
--- /dev/null
+++ b/app/catalogue/hide.js
@@ -0,0 +1,86 @@
+// Renders the fixture modules through the real shell.
+
+import { mountAll } from "../shell/panels.js";
+import { offer, _reset as resetMounts } from "../shell/mounts.js";
+import { _reset as resetValues, all as allValues } from "../shell/values.js";
+import { GOOD, BROKEN } from "./fixtures.js";
+
+const el = (tag, cls, text) => {
+ const n = document.createElement(tag);
+ if (cls) n.className = cls;
+ if (text != null) n.textContent = text;
+ return n;
+};
+
+async function render(theme) {
+ resetMounts();
+ resetValues();
+
+ const page = document.querySelector("#catalogue");
+ page.textContent = "";
+
+ const section = el("section", "cat-section");
+ section.append(el("h2", null, "What a user sees"));
+ section.append(el("p", null,
+ "Six modules go in: one valid, five broken in different ways. Everything below " +
+ "is the whole of what reaches the screen."));
+
+ const pane = el("section", "cat-pane");
+ pane.setAttribute("data-theme", theme);
+ pane.append(el("h3", null, theme));
+ const host = el("div", "cat-mount");
+ pane.append(host);
+ section.append(pane);
+ page.append(section);
+
+ offer("demo.panel", host);
+
+ // The broken ones are handed to the shell exactly as the good one is. In the
+ // real app most never get this far -- core refuses them at import or at
+ // validation -- so this is the worst case: everything reaching the browser.
+ const manifest = { modules: [GOOD, ...BROKEN.map((b) => b.spec)] };
+ const { mounted, hidden } = await mountAll(manifest);
+
+ if (!host.childElementCount) host.append(el("p", "cat-broke", "nothing mounted at all"));
+
+ // Everything from here down is developer commentary, NOT part of what a user
+ // would see -- the panel above is.
+ const ledger = el("section", "cat-section");
+ ledger.append(el("h2", null, "Why each one is absent"));
+ ledger.append(el("p", null, "This table exists for you, not for the user. None of it appears in the app."));
+
+ const table = el("div", "cat-ledger");
+ for (const { id } of mounted) {
+ table.append(el("div", "cat-ledger-row cat-ok"), el("code", "cat-name", id));
+ table.append(el("span", null, "mounted"));
+ }
+ for (const { id, why } of hidden) {
+ const source = BROKEN.find((b) => b.spec.id === id);
+ table.append(el("code", "cat-name", id));
+ table.append(el("span", "cat-why", source ? source.why : "—"));
+ table.append(el("span", "cat-caught", why));
+ }
+ ledger.append(table);
+ page.append(ledger);
+
+ const values = el("section", "cat-section");
+ values.append(el("h2", null, "Values a headless run would get"));
+ values.append(el("p", null,
+ "Seeded from the same declaration the panel renders, so a generation with no " +
+ "browser open starts from the same numbers."));
+ const pre = el("pre", "cx-code-block");
+ pre.textContent = JSON.stringify(allValues(), null, 2);
+ values.append(pre);
+ page.append(values);
+}
+
+let theme = "dark";
+for (const btn of document.querySelectorAll("[data-theme-set]")) {
+ btn.addEventListener("click", () => {
+ theme = btn.getAttribute("data-theme-set");
+ window.ComposerTheme.apply(theme === "auto" ? "auto" : theme);
+ render(theme === "auto" ? window.ComposerTheme.resolved() : theme);
+ });
+}
+
+render(theme);
diff --git a/app/catalogue/index.html b/app/catalogue/index.html
index c5823b50..e6231ca1 100644
--- a/app/catalogue/index.html
+++ b/app/catalogue/index.html
@@ -13,6 +13,7 @@
Composer catalogue
Elements →
+ Hide-don't-warn
diff --git a/app/composer/elements/choice.css b/app/composer/elements/choice.css
index ab2a62c7..6e600cac 100644
--- a/app/composer/elements/choice.css
+++ b/app/composer/elements/choice.css
@@ -19,20 +19,24 @@
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-box { background: var(--accent); border-color: var(--accent); }
-.cx-check-input:checked + .cx-check-box::after { opacity: 1; transform: rotate(-45deg) translateY(-1px) scale(1); }
-.cx-check-input:indeterminate + .cx-check-box { background: var(--accent); border-color: var(--accent); }
-.cx-check-input:indeterminate + .cx-check-box::after {
+.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-box { border-color: var(--accent); box-shadow: var(--focus-ring); }
-.cx-check-input:disabled + .cx-check-box { opacity: var(--disabled-opacity); }
+.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); }
-.cx-check-row, .cx-radio, .cx-toggle-row {
+/* 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; }
+.cx-check-face {
display: flex; align-items: flex-start; gap: var(--sp-4);
cursor: pointer; padding: var(--sp-2) 0;
}
+.cx-toggle-face { align-items: center; justify-content: space-between; gap: var(--sp-6); }
.cx-check-text { display: flex; flex-direction: column; gap: 2px; }
.cx-check-label { font-size: var(--fs-4); line-height: var(--lh-4); color: var(--text); }
.cx-checklist {
@@ -54,9 +58,9 @@
background: var(--accent); transform: scale(0);
transition: transform var(--dur-1) var(--ease-standard);
}
-.cx-radio-input:checked + .cx-radio-dot { border-color: var(--accent); }
-.cx-radio-input:checked + .cx-radio-dot::after { transform: scale(1); }
-.cx-radio-input:focus-visible + .cx-radio-dot { border-color: var(--accent); box-shadow: var(--focus-ring); }
+.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); }
@@ -77,7 +81,7 @@
.cx-segment:hover { background: var(--hover); color: var(--text); }
.cx-segment.cx-on { background: var(--accent-soft); color: var(--accent-hi); }
-.cx-toggle-row { align-items: center; justify-content: space-between; gap: var(--sp-6); }
+
.cx-toggle-track {
width: 34px; height: 20px; flex: none; border-radius: var(--r-pill);
background: var(--line); position: relative;
@@ -88,6 +92,6 @@
border-radius: var(--r-pill); background: var(--elev-1);
transition: transform var(--dur-1) var(--ease-standard);
}
-.cx-toggle-input:checked + .cx-toggle-track { background: var(--accent); }
-.cx-toggle-input:checked + .cx-toggle-track .cx-toggle-knob { transform: translateX(14px); }
-.cx-toggle-input:focus-visible + .cx-toggle-track { box-shadow: var(--focus-ring); }
+.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); }
diff --git a/app/composer/elements/choice.js b/app/composer/elements/choice.js
index b0152a8e..e0742a76 100644
--- a/app/composer/elements/choice.js
+++ b/app/composer/elements/choice.js
@@ -50,7 +50,16 @@ define("checkboxRow", "default", ({ label, hint, checked, disabled, onChange } =
hint ? el("span", { cls: "cx-hint", text: hint, attrs: { id: hintId } }) : null,
].filter(Boolean) });
- const node = el("label", { cls: "cx-check-row", attrs: { for: id }, children: [input, box, text] });
+ // 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; },
@@ -69,8 +78,11 @@ define("checklist", "default", ({ items = [], values = [], onChange, label } = {
input.checked ? chosen.add(item.value) : chosen.delete(item.value);
if (onChange) onChange([...chosen]);
});
- node.append(el("label", { cls: "cx-check-row", attrs: { for: id }, children: [
- input, box, el("span", { cls: "cx-check-label", text: item.label }),
+ 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 {
@@ -105,13 +117,15 @@ define("radioGroup", "default", ({ options = [], value, onChange, orientation =
current = option.value;
if (onChange) onChange(current);
});
- node.append(el("label", { cls: "cx-radio", attrs: { for: id }, children: [
+ node.append(el("div", { cls: "cx-radio", children: [
input,
- 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) }),
+ 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 };
});
@@ -190,14 +204,16 @@ define("toggle", "default", ({ label, hint, checked, disabled, onChange } = {})
input.checked = Boolean(checked);
if (onChange) input.addEventListener("change", () => onChange(input.checked));
- const node = el("label", { cls: "cx-toggle-row", 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) }),
+ const node = el("div", { cls: "cx-toggle-row", children: [
input,
- el("span", { cls: "cx-toggle-track", attrs: { "aria-hidden": "true" },
- children: el("span", { cls: "cx-toggle-knob" }) }),
+ 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 {
diff --git a/app/composer/panel.js b/app/composer/panel.js
new file mode 100644
index 00000000..1dc5cc73
--- /dev/null
+++ b/app/composer/panel.js
@@ -0,0 +1,173 @@
+// 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",
+};
+
+// Renderers that carry their own visible label, so wrapping them in a settings
+// row would print the label twice.
+const SELF_LABELLING = new Set(["checkboxRow", "toggle"]);
+
+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 }),
+ 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" });
+ const handles = new Map();
+
+ function build() {
+ // Into a fragment first: a row that throws must not leave the previous
+ // half of the panel on screen.
+ const built = frag();
+ 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();
+ });
+ handles.set(key, control);
+ built.appendChild(
+ SELF_LABELLING.has(setting.ui || DEFAULT_UI[setting.type])
+ ? control.node
+ : composer.settingsRow.default({ label: setting.label, hint: setting.hint, control }).node
+ );
+ }
+ return built;
+ }
+
+ // 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() {
+ for (const handle of handles.values()) if (handle.destroy) handle.destroy();
+ handles.clear();
+ rows.replaceChildren(build());
+ }
+
+ rows.appendChild(build());
+
+ const node = el("section", { cls: "cx-group", children: [
+ el("div", { cls: "cx-eyebrow", text: spec.title }),
+ rows,
+ ] });
+
+ return {
+ node,
+ get values() { return { ...values }; },
+ setValue(key, next) { values[key] = next; redraw(); },
+ destroy() {
+ for (const handle of handles.values()) if (handle.destroy) handle.destroy();
+ node.remove();
+ },
+ };
+}
+
+/** The same defaults core derives, computed in the browser from the same source. */
+export function defaultsOf(spec) {
+ const out = {};
+ for (const [key, setting] of Object.entries(spec.settings || {})) out[key] = setting.default;
+ return out;
+}
diff --git a/app/composer/tests/elements_behaviour.test.js b/app/composer/tests/elements_behaviour.test.js
index 7ceb9fdc..1d2d94b8 100644
--- a/app/composer/tests/elements_behaviour.test.js
+++ b/app/composer/tests/elements_behaviour.test.js
@@ -88,7 +88,10 @@ test("a checkbox row is labelled by its visible text, not by aria-label", () =>
const c = composer.checkboxRow.default({ label: "Sync", hint: "Locks timing" });
const input = c.node.querySelector("input");
assert.equal(input.getAttribute("aria-label"), null);
- assert.equal(c.node.getAttribute("for"), input.id);
+ 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");
});
diff --git a/app/composer/tests/lint_fixtures/clean/ui.js b/app/composer/tests/lint_fixtures/clean/ui.js
new file mode 100644
index 00000000..c59a0bff
--- /dev/null
+++ b/app/composer/tests/lint_fixtures/clean/ui.js
@@ -0,0 +1,7 @@
+export function setup({ composer, values }) {
+ const button = composer.button.md({
+ label: "Re-key",
+ onClick: () => values.set("enabled", true),
+ });
+ return button;
+}
diff --git a/app/composer/tests/lint_fixtures/offenders/ui.js b/app/composer/tests/lint_fixtures/offenders/ui.js
new file mode 100644
index 00000000..e6ff8691
--- /dev/null
+++ b/app/composer/tests/lint_fixtures/offenders/ui.js
@@ -0,0 +1,12 @@
+export function setup({ composer }) {
+ const b = composer.button.md({ label: "x" });
+ b.node.style.color = "#ff0000";
+ b.node.classList.add("mine");
+ b.node.innerHTML = "hi";
+ const smuggled = '
still styling
';
+ const raw = document.createElement("div");
+ raw.setAttribute("style", "margin: 12px");
+ document.head.appendChild(raw);
+ void smuggled;
+ return new Function("return 1")();
+}
diff --git a/app/composer/tests/panel_schema.test.js b/app/composer/tests/panel_schema.test.js
new file mode 100644
index 00000000..0f0f5a6d
--- /dev/null
+++ b/app/composer/tests/panel_schema.test.js
@@ -0,0 +1,181 @@
+// 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`);
+ }
+});
diff --git a/app/composer/tests/rule_no_module_styling.test.js b/app/composer/tests/rule_no_module_styling.test.js
new file mode 100644
index 00000000..f0467bee
--- /dev/null
+++ b/app/composer/tests/rule_no_module_styling.test.js
@@ -0,0 +1,72 @@
+// Rule 1: a module never styles anything.
+//
+// The runtime half is stronger than this -- a module is handed { composer,
+// values, on } and nothing that could style or build -- but a scanner catches
+// the attempt in the file rather than at the moment it would have run.
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { readFileSync } from "node:fs";
+
+import { scan, scanSource, code } from "../../../tools/kit_lint/scan.js";
+import { MODULE_RULES, KIT_RULES } from "../../../tools/kit_lint/rules.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repo = join(here, "..", "..", "..");
+const FIXTURES = join(here, "lint_fixtures");
+
+test("every module in the tree is clean", () => {
+ // Empty today; the value is that it stays empty as modules arrive.
+ const violations = scan(join(repo, "modules"), MODULE_RULES);
+ assert.deepEqual(violations, [],
+ violations.map((v) => `${v.file}:${v.line} ${v.rule} -- ${v.why}`).join("\n"));
+});
+
+test("the kit never interprets content", () => {
+ // The kit may style and may build elements; that is its job. What it may not
+ // do is parse anything a module gave it.
+ const violations = scan(join(repo, "app", "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", "shell"), KIT_RULES), []);
+});
+
+// --- the scanner has to actually catch things ------------------------------
+
+test("a clean module passes", () => {
+ const source = readFileSync(join(FIXTURES, "clean", "ui.js"), "utf8");
+ assert.deepEqual(scanSource(source, MODULE_RULES), []);
+});
+
+test("the scanner catches every rule it claims to", () => {
+ // Without this, "zero violations" could mean "the scanner is broken" -- which
+ // is the failure mode of every lint nobody tests.
+ const source = readFileSync(join(FIXTURES, "offenders", "ui.js"), "utf8");
+ const caught = new Set(scanSource(source, MODULE_RULES).map((v) => v.rule));
+ for (const rule of ["inline-style", "style-attribute", "class-manipulation",
+ "raw-element", "markup", "stylesheet", "hardcoded-colour",
+ "hardcoded-size", "eval"]) {
+ assert.ok(caught.has(rule), `the offender fixture was not caught by "${rule}"`);
+ }
+});
+
+test("prose is not code", () => {
+ // Otherwise every rule becomes unmentionable in the comment explaining it.
+ const commented = `
+ // never write innerHTML here
+ /* and #ff0000 and 12px are fine to discuss */
+ export const ok = 1;
+ `;
+ assert.deepEqual(scanSource(commented, MODULE_RULES), []);
+ assert.ok(!code(commented).includes("innerHTML"));
+});
+
+test("a url containing a hash is not a colour", () => {
+ assert.deepEqual(scanSource('export const a = "#abc";', MODULE_RULES).map((v) => v.rule),
+ ["hardcoded-colour"], "a real colour is caught");
+});
diff --git a/app/composer/tests/rule_unknown_element_hides.test.js b/app/composer/tests/rule_unknown_element_hides.test.js
new file mode 100644
index 00000000..f56b1710
--- /dev/null
+++ b/app/composer/tests/rule_unknown_element_hides.test.js
@@ -0,0 +1,121 @@
+// 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(), ["composer", "on", "shell", "values"]);
+ for (const value of Object.values(context)) {
+ assert.equal(value instanceof globalThis.Node, false, "no DOM node is reachable from a module");
+ }
+ // The service surface is named one by one, so it can be reviewed.
+ assert.deepEqual(Object.keys(context.shell).sort(), ["density", "theme"]);
+});
diff --git a/app/index.html b/app/index.html
index 24291018..c1e161de 100644
--- a/app/index.html
+++ b/app/index.html
@@ -1,9 +1,18 @@
-
+
FunPack
+
+
+
diff --git a/app/shell/layout.js b/app/shell/layout.js
new file mode 100644
index 00000000..34dcf746
--- /dev/null
+++ b/app/shell/layout.js
@@ -0,0 +1,35 @@
+// The app's regions, and the mount points each one offers.
+//
+// A region declares what it can host. Core never learns these names, and no
+// list of them exists anywhere else -- a module asks for one by name, and gets
+// it or does not.
+
+import { composer } from "../composer/composer.js";
+import { offer } from "./mounts.js";
+
+export function build(root) {
+ const general = composer.panel.default({
+ title: "Settings",
+ body: composer.hint.default({ text: "Modules that announced a settings.general mount point." }),
+ });
+ const generation = composer.panel.default({
+ title: "Generation",
+ body: composer.hint.default({ text: "Modules that announced a generation.* mount point." }),
+ });
+
+ const page = composer.splitPane.h({
+ size: 46,
+ label: "Settings and generation",
+ panes: [general, generation],
+ });
+
+ root.replaceChildren(page.node);
+
+ // The mount point IS the contract with modules; the region behind it can be
+ // rearranged freely as long as the name survives.
+ offer("settings.general", general.body);
+ offer("generation.timing", generation.body);
+ offer("generation.model", generation.body);
+
+ return { general, generation, page };
+}
diff --git a/app/shell/manifest.js b/app/shell/manifest.js
new file mode 100644
index 00000000..218c4198
--- /dev/null
+++ b/app/shell/manifest.js
@@ -0,0 +1,10 @@
+// What the server says exists.
+
+const ENDPOINT = "/funpack/api/modules";
+
+export async function fetchManifest(traits) {
+ const url = traits ? `${ENDPOINT}?traits=${encodeURIComponent(traits.join(","))}` : ENDPOINT;
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`${ENDPOINT} answered ${response.status}`);
+ return response.json();
+}
diff --git a/app/shell/mounts.js b/app/shell/mounts.js
new file mode 100644
index 00000000..00a0ebfb
--- /dev/null
+++ b/app/shell/mounts.js
@@ -0,0 +1,24 @@
+// 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 element
+
+export function offer(mount, host) {
+ if (regions.has(mount)) {
+ throw new Error(`Mount point "${mount}" is already offered by another region.`);
+ }
+ regions.set(mount, host);
+ return () => regions.delete(mount);
+}
+
+export const hostFor = (mount) => regions.get(mount) || null;
+export const offered = () => [...regions.keys()].sort();
+export const claimed = (mount) => regions.has(mount);
+
+export function _reset() {
+ regions.clear();
+}
diff --git a/app/shell/panels.js b/app/shell/panels.js
new file mode 100644
index 00000000..cbe68596
--- /dev/null
+++ b/app/shell/panels.js
@@ -0,0 +1,76 @@
+// Turning announcements into panels.
+//
+// Everything a module can reach is decided here, and it is deliberately little:
+// { composer, values, on }. No DOM node, no document, no kit internals. A module
+// cannot style anything because it is never handed anything that could.
+
+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 || []) {
+ 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);
+
+ const panel = renderPanel(spec, {
+ values: values.valuesOf(spec.id),
+ onChange: (key, value) => values.set(spec.id, key, value),
+ });
+
+ // Only now, with a complete panel in hand, does anything reach the DOM.
+ host.appendChild(panel.node);
+
+ if (ui && typeof ui.setup === "function") {
+ ui.setup({
+ composer,
+ 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,
+ });
+ }
+
+ mounted.push({ id: spec.id, panel });
+ } catch (err) {
+ hidden.push({ id: spec.id, why: `${err.name}: ${err.message}` });
+ console.warn(`[FunPack] ${spec.id} did not mount: ${err.message}`);
+ }
+ }
+
+ return { mounted, hidden };
+}
diff --git a/app/shell/services.js b/app/shell/services.js
new file mode 100644
index 00000000..b50bc107
--- /dev/null
+++ b/app/shell/services.js
@@ -0,0 +1,24 @@
+// 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 } from "../composer/internals/density.js";
+
+export const services = {
+ theme: {
+ set(choice) { window.ComposerTheme.apply(choice); },
+ get: () => window.ComposerTheme.get(),
+ resolved: () => window.ComposerTheme.resolved(),
+ },
+ density: {
+ /** Column count for every adaptive grid on the page. */
+ set(cols) {
+ const n = normaliseCols(cols);
+ for (const grid of document.querySelectorAll(".cx-gallery")) applyDensity(grid, n);
+ rememberCols("app", n);
+ return n;
+ },
+ },
+};
diff --git a/app/shell/values.js b/app/shell/values.js
new file mode 100644
index 00000000..cc3fe00c
--- /dev/null
+++ b/app/shell/values.js
@@ -0,0 +1,36 @@
+// The live value of every setting, keyed exactly as the declaration keys them.
+//
+// This is the only thing in the app that reads the UI. A module never does: it
+// declares what it needs and is handed values, so the same module works with a
+// panel on screen and with no browser at all.
+
+const store = new Map(); // moduleId -> { key: value }
+const listeners = new Set();
+
+export function seed(moduleId, defaults) {
+ store.set(moduleId, { ...defaults });
+}
+
+export function set(moduleId, key, value) {
+ const current = store.get(moduleId) || {};
+ current[key] = value;
+ store.set(moduleId, current);
+ for (const fn of listeners) {
+ try { fn(moduleId, key, value); } catch { /* a listener must not break the edit */ }
+ }
+}
+
+export const valuesOf = (moduleId) => ({ ...(store.get(moduleId) || {}) });
+
+/** Everything, as it would be sent with a generation request. */
+export const all = () => Object.fromEntries([...store].map(([id, v]) => [id, { ...v }]));
+
+export function onChange(fn) {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+}
+
+export function _reset() {
+ store.clear();
+ listeners.clear();
+}
diff --git a/core/contract.py b/core/contract.py
new file mode 100644
index 00000000..57254363
--- /dev/null
+++ b/core/contract.py
@@ -0,0 +1,76 @@
+"""What a module declares, and the vocabulary it may use.
+
+Core holds no list of features. A module announces itself and this file says
+what a valid announcement looks like -- nothing here names an implementation.
+"""
+
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Optional
+
+# Bumped when the shape of an announcement changes. A module declaring an older
+# version is not quietly adapted: it is refused, because guessing at what an old
+# declaration meant is how silent behaviour changes happen.
+CONTRACT_VERSION = 1
+
+# Closed set. A type outside it has no renderer and no validation, so accepting
+# one would mean rendering something approximate.
+TYPES = frozenset({"bool", "int", "float", "enum", "text", "multiline", "path"})
+
+NUMERIC = frozenset({"int", "float"})
+
+# Which renderers each type allows, first being the default. A hint outside its
+# type's list is refused at import: the alternative is a panel that silently
+# renders something other than what was asked for.
+UI_HINTS: Dict[str, List[str]] = {
+ "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"],
+}
+
+# Ordering is coarse-grained by stage, then by declared relations within it.
+# Numbers are deliberately absent: a priority number is a global namespace, so
+# inserting one module means renumbering the others.
+STAGES: List[str] = ["load", "conditioning", "latent", "guidance", "sampling", "post"]
+
+
+@dataclass(frozen=True)
+class ModuleSpec:
+ """One module's announcement, after validation."""
+
+ id: str
+ title: str
+ mount: str
+ settings: Dict[str, dict] = field(default_factory=dict)
+ requires: List[str] = field(default_factory=list) # model traits
+ after: List[str] = field(default_factory=list) # module ids
+ before: List[str] = field(default_factory=list)
+ stage: str = "sampling"
+ ui: Optional[str] = None # served path to its ui.js
+ status: str = "experimental" # or "proven"
+ source: str = "" # dotted import path
+
+ def defaults(self) -> Dict[str, Any]:
+ """The values a headless run gets when no panel has been rendered.
+
+ Derived from the same declaration the panel renders, so the two cannot
+ disagree about what a setting means when nobody has touched it.
+ """
+ return {key: spec["default"] for key, spec in self.settings.items()}
+
+ def to_manifest(self) -> dict:
+ return {
+ "id": self.id,
+ "title": self.title,
+ "mount": self.mount,
+ "settings": self.settings,
+ "requires": list(self.requires),
+ "after": list(self.after),
+ "before": list(self.before),
+ "stage": self.stage,
+ "ui": self.ui,
+ "status": self.status,
+ }
diff --git a/core/registry.py b/core/registry.py
new file mode 100644
index 00000000..68e305e4
--- /dev/null
+++ b/core/registry.py
@@ -0,0 +1,99 @@
+"""Finding modules.
+
+Core scans a folder and imports what it finds. It holds no list, so adding a
+module is adding a folder; and each import is guarded, so a module that raises
+is absent and logged rather than taking the app down with it.
+"""
+
+import importlib
+import importlib.util
+import pkgutil
+from pathlib import Path
+from typing import Dict, List, Optional, Tuple
+
+from . import config, log
+from .contract import ModuleSpec
+from .schema import SchemaError, validate
+
+# Read off the module after import. Uppercase because they are declarations, not
+# behaviour -- a module's announcement should be legible without running it.
+FIELDS = {
+ "id": "ID", "title": "TITLE", "mount": "MOUNT", "settings": "SETTINGS",
+ "requires": "REQUIRES", "after": "AFTER", "before": "BEFORE",
+ "stage": "STAGE", "status": "STATUS",
+}
+
+
+class Registry:
+ def __init__(self) -> None:
+ self.specs: Dict[str, ModuleSpec] = {}
+ self.failed: List[Tuple[str, str]] = [] # (where, why)
+
+ @property
+ def ids(self) -> List[str]:
+ return sorted(self.specs)
+
+ def add(self, spec: ModuleSpec) -> None:
+ if spec.id in self.specs:
+ # Two modules answering to one id means one wins by import order,
+ # and which one is nobody's decision.
+ self.failed.append((spec.source, f"duplicate id {spec.id!r}, already declared by "
+ f"{self.specs[spec.id].source}"))
+ return
+ self.specs[spec.id] = spec
+
+
+def announcement_of(module, source: str) -> dict:
+ raw = {}
+ for key, const in FIELDS.items():
+ if hasattr(module, const):
+ raw[key] = getattr(module, const)
+ return raw
+
+
+def _feature_dirs(root: Path) -> List[Path]:
+ """modules/// -- two levels, each a package."""
+ if not root.is_dir():
+ return []
+ found = []
+ for domain in sorted(p for p in root.iterdir() if p.is_dir() and not p.name.startswith((".", "_"))):
+ for feature in sorted(p for p in domain.iterdir() if p.is_dir() and not p.name.startswith((".", "_"))):
+ if (feature / "__init__.py").is_file():
+ found.append(feature)
+ return found
+
+
+def scan(root: Optional[Path] = None, package: str = "modules") -> Registry:
+ """Import every module under `root` and collect the valid announcements."""
+ root = Path(root or config.MODULES_DIR)
+ registry = Registry()
+
+ for feature in _feature_dirs(root):
+ rel = feature.relative_to(root)
+ dotted = f"{package}." + ".".join(rel.parts)
+ try:
+ module = importlib.import_module(dotted)
+ except Exception as exc: # noqa: BLE001 -- any failure is absence
+ log.failed(dotted, exc)
+ registry.failed.append((dotted, f"{type(exc).__name__}: {exc}"))
+ continue
+
+ raw = announcement_of(module, dotted)
+ if not raw.get("id"):
+ # A folder that is not a module is not an error -- shared helpers
+ # live in packages too.
+ continue
+
+ # A module's ui.js is optional, and the default is none: most modules
+ # are a settings schema and no JavaScript at all.
+ ui_file = feature / "ui.js"
+ if ui_file.is_file():
+ raw["ui"] = f"{config.UI_PREFIX}/modules/{'/'.join(rel.parts)}/ui.js"
+
+ try:
+ registry.add(validate(raw, source=dotted))
+ except SchemaError as exc:
+ log.note(f"{dotted} declares an invalid module: {exc}")
+ registry.failed.append((dotted, str(exc)))
+
+ return registry
diff --git a/core/relations.py b/core/relations.py
new file mode 100644
index 00000000..f0e7ef8d
--- /dev/null
+++ b/core/relations.py
@@ -0,0 +1,65 @@
+"""Ordering by declared relations, never by priority numbers.
+
+A number is a global namespace: inserting one module between two others means
+renumbering everything after it, and the numbers live in files that know nothing
+about each other. A relation is local -- a module names only the neighbours it
+actually cares about, and everything else sorts itself.
+"""
+
+from typing import Dict, Iterable, List, Tuple
+
+from .contract import STAGES, ModuleSpec
+
+
+class CycleError(ValueError):
+ """Relations that cannot be satisfied in any order."""
+
+
+def order(specs: Iterable[ModuleSpec]) -> Tuple[List[ModuleSpec], List[Tuple[ModuleSpec, str]]]:
+ """(ordered, rejected) -- rejected carries the reason it could not be placed.
+
+ A module in a cycle is dropped rather than placed arbitrarily: an arbitrary
+ order is one that works until it does not, and by then nobody remembers the
+ order was never real.
+ """
+ specs = list(specs)
+ by_id = {spec.id: spec for spec in specs}
+ rejected: List[Tuple[ModuleSpec, str]] = []
+
+ # Edges point from "runs earlier" to "runs later". `before` is expressed as
+ # the same edge from the other end, so the two spellings cannot disagree.
+ edges: Dict[str, set] = {spec.id: set() for spec in specs}
+ for spec in specs:
+ for earlier in spec.after:
+ if earlier in by_id:
+ edges[spec.id].add(earlier)
+ for later in spec.before:
+ if later in by_id:
+ edges[later].add(spec.id)
+
+ # A relation naming an absent module is not an error: modules come and go,
+ # and "after audio_clock" simply has nothing to wait for when audio_clock
+ # failed to load or was never installed.
+
+ stage_of = {spec.id: STAGES.index(spec.stage) for spec in specs}
+ ordered: List[ModuleSpec] = []
+ placed: set = set()
+
+ # Stage first, then declaration order, so a run without any relations is
+ # stable and readable rather than dependent on filesystem order.
+ remaining = sorted(specs, key=lambda s: (stage_of[s.id], s.id))
+
+ while remaining:
+ ready = [s for s in remaining if edges[s.id] <= placed]
+ if not ready:
+ for spec in remaining:
+ unmet = sorted(edges[spec.id] - placed)
+ rejected.append((spec, f"ordering cycle with {', '.join(unmet)}"))
+ break
+ # Among the ready, respect stage so a later stage never jumps ahead.
+ nxt = min(ready, key=lambda s: (stage_of[s.id], s.id))
+ ordered.append(nxt)
+ placed.add(nxt.id)
+ remaining.remove(nxt)
+
+ return ordered, rejected
diff --git a/core/routes.py b/core/routes.py
index 18707093..a1b3fd81 100644
--- a/core/routes.py
+++ b/core/routes.py
@@ -5,7 +5,10 @@ so the real routes can be mounted on a throwaway app in a test. The handlers are
thin adapters over pure functions in `serve`.
"""
-from . import config, log, serve as static
+from . import config, log, registry as registry_mod, serve as static
+from .contract import CONTRACT_VERSION
+from .relations import order
+from .traits import split
# Two independent imports, two independent guards: aiohttp is installable on its
# own, and folding them together nulled `web` whenever ComfyUI's `server` was
@@ -40,6 +43,47 @@ def _serve_under(req, root, allowed):
return _respond(static.serve(root, req.match_info["tail"], allowed))
+# Scanned once at startup. A module list that changed under the UI mid-session
+# would mean a panel could refer to something that is no longer there.
+_registry = None
+
+
+def modules(rescan=False):
+ global _registry
+ if _registry is None or rescan:
+ _registry = registry_mod.scan()
+ return _registry
+
+
+def manifest(traits=None):
+ """What the browser is told: only modules that loaded and validated.
+
+ A module that failed is absent from `modules` entirely -- the UI renders
+ what announced itself, so absence here is what makes it absent on screen.
+ `failed` is carried alongside for the modules dump, never for rendering.
+ """
+ reg = modules()
+ specs = list(reg.specs.values())
+
+ incompatible = []
+ if traits is not None:
+ specs, incompatible = split(specs, traits)
+
+ ordered, rejected = order(specs)
+
+ return {
+ "contract": CONTRACT_VERSION,
+ "modules": [spec.to_manifest() for spec in ordered],
+ "failed": (
+ [{"where": where, "why": why} for where, why in reg.failed]
+ + [{"where": spec.source, "why": why} for spec, why in rejected]
+ ),
+ "incompatible": [
+ {"id": spec.id, "requires": spec.requires} for spec in incompatible
+ ],
+ }
+
+
def register(routes, prefix=None):
"""Attach FunPack's routes to an aiohttp route table."""
P = config.UI_PREFIX if prefix is None else prefix
@@ -48,6 +92,12 @@ def register(routes, prefix=None):
async def _health(_req):
return web.json_response({"ok": True})
+ @routes.get(P + "/api/modules")
+ async def _modules(req):
+ raw = req.query.get("traits")
+ traits = [t for t in raw.split(",") if t] if raw is not None else None
+ return web.json_response(manifest(traits))
+
@routes.get(P + "/app/{tail:.*}")
async def _app_asset(req):
return _serve_under(req, config.APP_DIR, config.APP_EXTS)
diff --git a/core/schema.py b/core/schema.py
new file mode 100644
index 00000000..3eb81b22
--- /dev/null
+++ b/core/schema.py
@@ -0,0 +1,172 @@
+"""Validating an announcement.
+
+Everything here refuses rather than repairs. A module whose declaration is wrong
+does not get a best-effort panel -- it does not load at all, and says why once.
+That is the same rule the UI follows: absent, not approximate.
+"""
+
+from typing import Any, Dict
+
+from .contract import NUMERIC, STAGES, TYPES, UI_HINTS, ModuleSpec
+
+
+class SchemaError(ValueError):
+ """A declaration core will not accept."""
+
+
+SCALARS = (str, int, float, bool)
+
+# Content the UI renders as text. Anything non-scalar here would have to be
+# interpreted to be displayed, and interpreting module-supplied content is the
+# one thing the kit will not do.
+TEXT_KEYS = ("label", "hint", "unit", "placeholder")
+
+
+def _require_text(value: Any, where: str) -> None:
+ if not isinstance(value, str):
+ raise SchemaError(f"{where} must be a string; got {type(value).__name__}.")
+
+
+def validate_setting(key: str, spec: Any, siblings: Dict[str, dict]) -> dict:
+ where = f"setting {key!r}"
+ if not isinstance(spec, dict):
+ raise SchemaError(f"{where} must be a dict; got {type(spec).__name__}.")
+
+ kind = spec.get("type")
+ if kind not in TYPES:
+ raise SchemaError(f"{where} has unknown type {kind!r}. Known: {', '.join(sorted(TYPES))}.")
+
+ if "default" not in spec:
+ # Without one there is no value for a headless run, and the panel would
+ # have to invent a starting point that apply() never agreed to.
+ raise SchemaError(f"{where} has no default.")
+
+ for text_key in TEXT_KEYS:
+ if text_key in spec:
+ _require_text(spec[text_key], f"{where}'s {text_key}")
+
+ hint = spec.get("ui")
+ if hint is not None and hint not in UI_HINTS[kind]:
+ raise SchemaError(
+ f"{where} asks for ui {hint!r}, which is not available for a {kind}. "
+ f"Allowed: {', '.join(UI_HINTS[kind])}."
+ )
+
+ if kind == "enum":
+ _validate_enum(spec, where)
+ elif kind in NUMERIC:
+ _validate_numeric(spec, where, kind)
+ elif kind == "bool":
+ if not isinstance(spec["default"], bool):
+ raise SchemaError(f"{where} is a bool but its default is {spec['default']!r}.")
+ else:
+ if not isinstance(spec["default"], str):
+ raise SchemaError(f"{where} is a {kind} but its default is not a string.")
+
+ if "when" in spec:
+ _validate_when(spec["when"], where, siblings, key)
+
+ return spec
+
+
+def _validate_enum(spec: dict, where: str) -> None:
+ options = spec.get("options")
+ if not isinstance(options, (list, tuple)) or not options:
+ raise SchemaError(f"{where} is an enum with no options.")
+
+ values = []
+ for i, option in enumerate(options):
+ if not isinstance(option, dict):
+ raise SchemaError(f"{where} option {i} must be a dict with value and label.")
+ if "value" not in option or "label" not in option:
+ raise SchemaError(f"{where} option {i} needs both a value and a label.")
+ if not isinstance(option["value"], SCALARS):
+ raise SchemaError(f"{where} option {i}'s value must be a scalar.")
+ _require_text(option["label"], f"{where} option {i}'s label")
+ values.append(option["value"])
+
+ if len(set(values)) != len(values):
+ raise SchemaError(f"{where} has duplicate option values.")
+ if spec["default"] not in values:
+ raise SchemaError(f"{where}'s default {spec['default']!r} is not one of its options.")
+
+
+def _validate_numeric(spec: dict, where: str, kind: str) -> None:
+ default = spec["default"]
+ if isinstance(default, bool) or not isinstance(default, (int, float)):
+ raise SchemaError(f"{where} is a {kind} but its default is {default!r}.")
+ if kind == "int" and not isinstance(default, int):
+ raise SchemaError(f"{where} is an int but its default is a float.")
+
+ low, high = spec.get("min"), spec.get("max")
+ for name, bound in (("min", low), ("max", high)):
+ if bound is not None and (isinstance(bound, bool) or not isinstance(bound, (int, float))):
+ raise SchemaError(f"{where}'s {name} must be a number.")
+ if low is not None and high is not None and low > high:
+ raise SchemaError(f"{where} has min {low} above max {high}.")
+ if low is not None and default < low:
+ raise SchemaError(f"{where}'s default {default} is below its min {low}.")
+ if high is not None and default > high:
+ raise SchemaError(f"{where}'s default {default} is above its max {high}.")
+
+ step = spec.get("step")
+ if step is not None and (isinstance(step, bool) or not isinstance(step, (int, float)) or step <= 0):
+ raise SchemaError(f"{where}'s step must be a positive number.")
+
+
+def _validate_when(when: Any, where: str, siblings: Dict[str, dict], key: str) -> None:
+ if not isinstance(when, dict) or not when:
+ raise SchemaError(f"{where}'s when must be a non-empty dict of sibling conditions.")
+ for other, expected in when.items():
+ if other == key:
+ raise SchemaError(f"{where}'s when refers to itself.")
+ if other not in siblings:
+ # A condition on a key that does not exist is always false, so the
+ # row would simply never appear -- a typo that reads as a decision.
+ raise SchemaError(f"{where}'s when refers to {other!r}, which this module does not declare.")
+ if not isinstance(expected, SCALARS) and not isinstance(expected, (list, tuple)):
+ raise SchemaError(f"{where}'s when value for {other!r} must be a scalar or a list.")
+
+
+def validate(announcement: Dict[str, Any], source: str = "") -> ModuleSpec:
+ """A validated ModuleSpec, or SchemaError naming what is wrong."""
+ for required in ("id", "title", "mount"):
+ value = announcement.get(required)
+ if not isinstance(value, str) or not value.strip():
+ raise SchemaError(f"module {source or '?'} has no {required}.")
+
+ stage = announcement.get("stage", "sampling")
+ if stage not in STAGES:
+ raise SchemaError(f"module {announcement['id']!r} has unknown stage {stage!r}. Known: {', '.join(STAGES)}.")
+
+ status = announcement.get("status", "experimental")
+ if status not in ("proven", "experimental"):
+ raise SchemaError(f"module {announcement['id']!r} has unknown status {status!r}.")
+
+ raw = announcement.get("settings") or {}
+ if not isinstance(raw, dict):
+ raise SchemaError(f"module {announcement['id']!r} has a non-dict settings block.")
+ settings = {key: validate_setting(key, spec, raw) for key, spec in raw.items()}
+
+ def _ids(name: str) -> list:
+ value = announcement.get(name) or []
+ if isinstance(value, str) or not isinstance(value, (list, tuple)):
+ raise SchemaError(f"module {announcement['id']!r}'s {name} must be a list.")
+ for item in value:
+ if not isinstance(item, str):
+ raise SchemaError(f"module {announcement['id']!r}'s {name} must contain strings.")
+ return list(value)
+
+ return ModuleSpec(
+ id=announcement["id"],
+ title=announcement["title"],
+ mount=announcement["mount"],
+ settings=settings,
+ requires=_ids("requires"),
+ after=_ids("after"),
+ before=_ids("before"),
+ stage=stage,
+ ui=announcement.get("ui"),
+ status=status,
+ source=source,
+ )
diff --git a/core/tests/fixtures/fixture_modules/__init__.py b/core/tests/fixtures/fixture_modules/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/core/tests/fixtures/fixture_modules/broken/__init__.py b/core/tests/fixtures/fixture_modules/broken/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/core/tests/fixtures/fixture_modules/broken/bad_label/__init__.py b/core/tests/fixtures/fixture_modules/broken/bad_label/__init__.py
new file mode 100644
index 00000000..75ed0aba
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/broken/bad_label/__init__.py
@@ -0,0 +1,6 @@
+"""A label that is not text. The UI would have to interpret it to show it."""
+
+ID = "bad_label"
+TITLE = "Bad label"
+MOUNT = "generation.timing"
+SETTINGS = {"enabled": {"type": "bool", "default": True, "label": {"markup": "no"}}}
diff --git a/core/tests/fixtures/fixture_modules/broken/bad_ui/__init__.py b/core/tests/fixtures/fixture_modules/broken/bad_ui/__init__.py
new file mode 100644
index 00000000..f61eb543
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/broken/bad_ui/__init__.py
@@ -0,0 +1,6 @@
+"""Asks for a renderer its type does not have."""
+
+ID = "bad_ui"
+TITLE = "Bad renderer"
+MOUNT = "generation.timing"
+SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On", "ui": "slider"}}
diff --git a/core/tests/fixtures/fixture_modules/broken/no_default/__init__.py b/core/tests/fixtures/fixture_modules/broken/no_default/__init__.py
new file mode 100644
index 00000000..3f353733
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/broken/no_default/__init__.py
@@ -0,0 +1,6 @@
+"""No default: a headless run would have no value to pass to apply()."""
+
+ID = "no_default"
+TITLE = "No default"
+MOUNT = "generation.timing"
+SETTINGS = {"strength": {"type": "float", "min": 0.0, "max": 1.0, "label": "Strength"}}
diff --git a/core/tests/fixtures/fixture_modules/broken/raises/__init__.py b/core/tests/fixtures/fixture_modules/broken/raises/__init__.py
new file mode 100644
index 00000000..ea59db92
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/broken/raises/__init__.py
@@ -0,0 +1,7 @@
+"""Fails on import. Must be absent, not fatal."""
+
+ID = "explodes"
+TITLE = "Explodes"
+MOUNT = "generation.timing"
+
+raise RuntimeError("this module cannot load")
diff --git a/core/tests/fixtures/fixture_modules/cycle/__init__.py b/core/tests/fixtures/fixture_modules/cycle/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/core/tests/fixtures/fixture_modules/cycle/first/__init__.py b/core/tests/fixtures/fixture_modules/cycle/first/__init__.py
new file mode 100644
index 00000000..ac3a6fd7
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/cycle/first/__init__.py
@@ -0,0 +1,5 @@
+ID = "cycle_first"
+TITLE = "First"
+MOUNT = "generation.timing"
+AFTER = ["cycle_second"]
+SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On"}}
diff --git a/core/tests/fixtures/fixture_modules/cycle/second/__init__.py b/core/tests/fixtures/fixture_modules/cycle/second/__init__.py
new file mode 100644
index 00000000..62aa4c29
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/cycle/second/__init__.py
@@ -0,0 +1,5 @@
+ID = "cycle_second"
+TITLE = "Second"
+MOUNT = "generation.timing"
+AFTER = ["cycle_first"]
+SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On"}}
diff --git a/core/tests/fixtures/fixture_modules/model/__init__.py b/core/tests/fixtures/fixture_modules/model/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/core/tests/fixtures/fixture_modules/model/needs_trait/__init__.py b/core/tests/fixtures/fixture_modules/model/needs_trait/__init__.py
new file mode 100644
index 00000000..c1c6abad
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/model/needs_trait/__init__.py
@@ -0,0 +1,7 @@
+"""Valid, but wants a trait the current model may not have."""
+
+ID = "needs_trait"
+TITLE = "Needs a trait"
+MOUNT = "generation.model"
+REQUIRES = ["cfg_free", "audio_stream"]
+SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On"}}
diff --git a/core/tests/fixtures/fixture_modules/timing/__init__.py b/core/tests/fixtures/fixture_modules/timing/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/core/tests/fixtures/fixture_modules/timing/audio_clock/__init__.py b/core/tests/fixtures/fixture_modules/timing/audio_clock/__init__.py
new file mode 100644
index 00000000..c5ecd7d1
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/timing/audio_clock/__init__.py
@@ -0,0 +1,30 @@
+"""A good module: the shape everything else is measured against."""
+
+ID = "audio_clock"
+TITLE = "Audio clock"
+MOUNT = "generation.timing"
+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.0, "max": 1.0, "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},
+ },
+}
diff --git a/core/tests/fixtures/fixture_modules/timing/audio_clock/ui.js b/core/tests/fixtures/fixture_modules/timing/audio_clock/ui.js
new file mode 100644
index 00000000..ff8b4c56
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/timing/audio_clock/ui.js
@@ -0,0 +1 @@
+export default {};
diff --git a/core/tests/fixtures/fixture_modules/timing/momentum/__init__.py b/core/tests/fixtures/fixture_modules/timing/momentum/__init__.py
new file mode 100644
index 00000000..bb523265
--- /dev/null
+++ b/core/tests/fixtures/fixture_modules/timing/momentum/__init__.py
@@ -0,0 +1,8 @@
+"""Declares a relation, so ordering has something to sort."""
+
+ID = "momentum"
+TITLE = "Momentum"
+MOUNT = "generation.timing"
+STAGE = "conditioning"
+AFTER = ["audio_clock"]
+SETTINGS = {"enabled": {"type": "bool", "default": False, "label": "Smooth motion"}}
diff --git a/core/tests/test_registry.py b/core/tests/test_registry.py
new file mode 100644
index 00000000..3629bc17
--- /dev/null
+++ b/core/tests/test_registry.py
@@ -0,0 +1,160 @@
+"""Scanning a real tree of modules, most of them deliberately broken.
+
+The fixtures under fixtures/fixture_modules are the point: one good module and
+five ways of being wrong, so "a broken module is absent, not fatal" is a thing
+the suite demonstrates rather than a claim in a comment.
+"""
+
+from pathlib import Path
+
+import pytest
+
+from core import registry as registry_mod
+from core.relations import order
+from core.traits import missing_for, split
+
+FIXTURES = Path(__file__).parent / "fixtures" / "fixture_modules"
+PACKAGE = "core.tests.fixtures.fixture_modules"
+
+
+@pytest.fixture(scope="module")
+def scanned():
+ return registry_mod.scan(FIXTURES, package=PACKAGE)
+
+
+def test_the_good_module_loads(scanned):
+ assert "audio_clock" in scanned.specs
+ spec = scanned.specs["audio_clock"]
+ assert spec.title == "Audio clock"
+ assert spec.stage == "conditioning"
+ assert spec.status == "proven"
+
+
+def test_a_module_that_raises_on_import_is_absent_not_fatal(scanned):
+ # The scan completed and returned other modules, which is the guarantee.
+ assert "explodes" not in scanned.specs
+ assert any("raises" in where for where, _ in scanned.failed)
+ assert scanned.specs, "one bad module did not take the rest down with it"
+
+
+@pytest.mark.parametrize("bad_id", ["bad_ui", "bad_label", "no_default"])
+def test_an_invalid_declaration_never_reaches_the_manifest(scanned, bad_id):
+ assert bad_id not in scanned.specs
+
+
+def test_every_failure_says_why(scanned):
+ # A module that is silently missing is indistinguishable from one that was
+ # never installed; the log line is what makes it debuggable.
+ assert scanned.failed
+ for where, why in scanned.failed:
+ assert where and why, f"failure recorded with no reason: {where!r} {why!r}"
+
+
+def test_a_module_with_a_ui_file_advertises_its_path(scanned):
+ assert scanned.specs["audio_clock"].ui.endswith("/modules/timing/audio_clock/ui.js")
+
+
+def test_a_module_without_a_ui_file_advertises_none(scanned):
+ # The default is no JavaScript at all: a settings schema is a whole module.
+ assert scanned.specs["momentum"].ui is None
+
+
+def test_defaults_are_available_without_any_ui(scanned):
+ assert scanned.specs["audio_clock"].defaults() == {
+ "enabled": True, "strength": 0.65, "mode": "beat",
+ }
+
+
+def test_a_duplicate_id_is_refused_rather_than_decided_by_import_order():
+ reg = registry_mod.Registry()
+ from core.schema import validate
+ a = validate({"id": "x", "title": "A", "mount": "m"}, source="one")
+ b = validate({"id": "x", "title": "B", "mount": "m"}, source="two")
+ reg.add(a)
+ reg.add(b)
+ assert reg.specs["x"].title == "A"
+ assert any("duplicate id" in why for _, why in reg.failed)
+
+
+# --- ordering --------------------------------------------------------------
+
+def test_relations_decide_the_order(scanned):
+ ordered, rejected = order([scanned.specs["audio_clock"], scanned.specs["momentum"]])
+ ids = [s.id for s in ordered]
+ assert ids.index("audio_clock") < ids.index("momentum")
+ assert rejected == []
+
+
+def test_before_is_the_same_edge_from_the_other_end():
+ from core.schema import validate
+ a = validate({"id": "a", "title": "A", "mount": "m", "before": ["b"]})
+ b = validate({"id": "b", "title": "B", "mount": "m"})
+ ordered, _ = order([b, a])
+ assert [s.id for s in ordered] == ["a", "b"]
+
+
+def test_a_relation_naming_an_absent_module_is_not_an_error():
+ # Modules come and go; "after audio_clock" has nothing to wait for when
+ # audio_clock was never installed.
+ from core.schema import validate
+ solo = validate({"id": "solo", "title": "S", "mount": "m", "after": ["not_installed"]})
+ ordered, rejected = order([solo])
+ assert [s.id for s in ordered] == ["solo"]
+ assert rejected == []
+
+
+def test_a_cycle_is_dropped_rather_than_ordered_arbitrarily(scanned):
+ # An arbitrary order is one that works until it does not, and by then nobody
+ # remembers it was never real.
+ pair = [scanned.specs["cycle_first"], scanned.specs["cycle_second"]]
+ ordered, rejected = order(pair)
+ assert ordered == []
+ assert {spec.id for spec, _ in rejected} == {"cycle_first", "cycle_second"}
+ assert all("cycle" in why for _, why in rejected)
+
+
+def test_a_cycle_does_not_take_the_healthy_modules_with_it(scanned):
+ everything = list(scanned.specs.values())
+ ordered, rejected = order(everything)
+ assert "audio_clock" in [s.id for s in ordered]
+ assert {s.id for s, _ in rejected} == {"cycle_first", "cycle_second"}
+
+
+def test_stages_run_in_declared_order():
+ from core.schema import validate
+ late = validate({"id": "late", "title": "L", "mount": "m", "stage": "post"})
+ early = validate({"id": "early", "title": "E", "mount": "m", "stage": "load"})
+ ordered, _ = order([late, early])
+ assert [s.id for s in ordered] == ["early", "late"]
+
+
+def test_ordering_is_stable_without_any_relations():
+ from core.schema import validate
+ specs = [validate({"id": i, "title": i, "mount": "m"}) for i in ("c", "a", "b")]
+ assert [s.id for s in order(specs)[0]] == ["a", "b", "c"]
+
+
+# --- traits ----------------------------------------------------------------
+
+def test_a_module_is_kept_only_when_the_model_has_what_it_needs(scanned):
+ specs = list(scanned.specs.values())
+ ok, no = split(specs, ["audio_stream", "flow_match"])
+ assert "audio_clock" in [s.id for s in ok]
+ assert "needs_trait" in [s.id for s in no], "cfg_free was not on offer"
+
+
+def test_a_module_with_no_requirements_fits_anything(scanned):
+ ok, _ = split([scanned.specs["momentum"]], [])
+ assert [s.id for s in ok] == ["momentum"]
+
+
+def test_the_missing_traits_are_nameable(scanned):
+ assert missing_for(scanned.specs["needs_trait"], ["audio_stream"]) == ["cfg_free"]
+
+
+def test_traits_are_names_the_model_offers_not_model_names(scanned):
+ # A module listing model names has to be edited whenever a model ships, and
+ # the one nobody edits is the one that silently stops appearing.
+ for spec in scanned.specs.values():
+ for trait in spec.requires:
+ assert trait.islower() and " " not in trait
diff --git a/core/tests/test_routes_manifest.py b/core/tests/test_routes_manifest.py
new file mode 100644
index 00000000..7790118b
--- /dev/null
+++ b/core/tests/test_routes_manifest.py
@@ -0,0 +1,70 @@
+"""The manifest: only what loaded and validated reaches the browser."""
+
+from pathlib import Path
+
+import pytest
+
+from core import routes as routes_mod
+from core import registry as registry_mod
+
+FIXTURES = Path(__file__).parent / "fixtures" / "fixture_modules"
+PACKAGE = "core.tests.fixtures.fixture_modules"
+
+
+@pytest.fixture()
+def manifest(monkeypatch):
+ reg = registry_mod.scan(FIXTURES, package=PACKAGE)
+ monkeypatch.setattr(routes_mod, "modules", lambda rescan=False: reg)
+ return routes_mod.manifest
+
+
+def test_only_valid_modules_are_listed(manifest):
+ ids = [m["id"] for m in manifest()["modules"]]
+ assert "audio_clock" in ids
+ for broken in ("explodes", "bad_ui", "bad_label", "no_default"):
+ assert broken not in ids
+
+
+def test_a_cycle_is_reported_but_not_listed(manifest):
+ result = manifest()
+ ids = [m["id"] for m in result["modules"]]
+ assert "cycle_first" not in ids and "cycle_second" not in ids
+ assert any("cycle" in entry["why"] for entry in result["failed"])
+
+
+def test_failures_are_carried_for_the_dump_not_for_rendering(manifest):
+ # The UI renders `modules`. `failed` exists so a person can find out why
+ # something is missing without reading the server log.
+ result = manifest()
+ assert result["failed"]
+ assert all({"where", "why"} == set(entry) for entry in result["failed"])
+
+
+def test_the_manifest_is_ordered_by_relation(manifest):
+ ids = [m["id"] for m in manifest()["modules"]]
+ assert ids.index("audio_clock") < ids.index("momentum")
+
+
+def test_traits_filter_what_is_offered(manifest):
+ with_audio = manifest(["audio_stream"])
+ assert "audio_clock" in [m["id"] for m in with_audio["modules"]]
+ assert "needs_trait" in [m["id"] for m in with_audio["incompatible"]]
+
+ without = manifest([])
+ assert "audio_clock" not in [m["id"] for m in without["modules"]]
+ assert "momentum" in [m["id"] for m in without["modules"]], "no requirements, fits anything"
+
+
+def test_no_traits_means_no_filtering(manifest):
+ assert manifest()["incompatible"] == []
+
+
+def test_the_contract_version_travels_with_it(manifest):
+ from core.contract import CONTRACT_VERSION
+ assert manifest()["contract"] == CONTRACT_VERSION
+
+
+def test_the_import_path_is_not_published(manifest):
+ # Where a module lives on disk is core's business.
+ for entry in manifest()["modules"]:
+ assert "source" not in entry
diff --git a/core/tests/test_schema.py b/core/tests/test_schema.py
new file mode 100644
index 00000000..a5acbd90
--- /dev/null
+++ b/core/tests/test_schema.py
@@ -0,0 +1,208 @@
+"""Validation refuses rather than repairs.
+
+Every case here is a module that would otherwise load and then behave in a way
+nobody declared -- a panel rendering the wrong control, a headless run with an
+invented default, a row that can never appear.
+"""
+
+import pytest
+
+from core.contract import CONTRACT_VERSION, ModuleSpec
+from core.schema import SchemaError, validate
+
+
+def announce(**overrides):
+ base = {
+ "id": "audio_clock", "title": "Audio clock", "mount": "generation.timing",
+ "settings": {"enabled": {"type": "bool", "default": True, "label": "On"}},
+ }
+ base.update(overrides)
+ return base
+
+
+def test_a_valid_declaration_becomes_a_spec():
+ spec = validate(announce())
+ assert isinstance(spec, ModuleSpec)
+ assert spec.id == "audio_clock"
+ assert spec.stage == "sampling"
+ assert spec.status == "experimental"
+
+
+def test_defaults_come_from_the_same_declaration_the_panel_renders():
+ # This is the whole point of one declaration doing three jobs: a headless
+ # run and a rendered panel cannot disagree about a starting value.
+ spec = validate(announce(settings={
+ "enabled": {"type": "bool", "default": True, "label": "On"},
+ "strength": {"type": "float", "default": 0.65, "min": 0, "max": 1, "label": "Strength"},
+ }))
+ assert spec.defaults() == {"enabled": True, "strength": 0.65}
+
+
+@pytest.mark.parametrize("missing", ["id", "title", "mount"])
+def test_the_essentials_are_required(missing):
+ raw = announce()
+ del raw[missing]
+ with pytest.raises(SchemaError, match=missing):
+ validate(raw)
+
+
+def test_a_blank_id_is_not_an_id():
+ with pytest.raises(SchemaError):
+ validate(announce(id=" "))
+
+
+# --- types and renderers ---------------------------------------------------
+
+def test_unknown_type_is_refused():
+ with pytest.raises(SchemaError, match="unknown type"):
+ validate(announce(settings={"x": {"type": "colour", "default": "#fff", "label": "C"}}))
+
+
+def test_unknown_ui_hint_is_refused():
+ # Accepting it would render something other than what was asked for.
+ with pytest.raises(SchemaError, match="not available for a bool"):
+ validate(announce(settings={"x": {"type": "bool", "default": True, "label": "X", "ui": "slider"}}))
+
+
+def test_a_valid_ui_hint_passes():
+ spec = validate(announce(settings={"x": {"type": "bool", "default": True, "label": "X", "ui": "toggle"}}))
+ assert spec.settings["x"]["ui"] == "toggle"
+
+
+def test_enum_may_ask_for_the_wheel():
+ # How a module gets a radial picker without writing any JavaScript.
+ spec = validate(announce(settings={"x": {
+ "type": "enum", "default": "a", "label": "X", "ui": "wheel",
+ "options": [{"value": "a", "label": "A"}, {"value": "b", "label": "B"}],
+ }}))
+ assert spec.settings["x"]["ui"] == "wheel"
+
+
+# --- defaults --------------------------------------------------------------
+
+def test_a_setting_without_a_default_is_refused():
+ with pytest.raises(SchemaError, match="no default"):
+ validate(announce(settings={"x": {"type": "float", "min": 0, "max": 1, "label": "X"}}))
+
+
+@pytest.mark.parametrize("spec", [
+ {"type": "bool", "default": "yes", "label": "X"},
+ {"type": "int", "default": 1.5, "label": "X"},
+ {"type": "float", "default": "0.5", "label": "X"},
+ {"type": "text", "default": 3, "label": "X"},
+ {"type": "bool", "default": 1, "label": "X"},
+])
+def test_a_default_of_the_wrong_type_is_refused(spec):
+ with pytest.raises(SchemaError):
+ validate(announce(settings={"x": spec}))
+
+
+def test_a_default_outside_its_own_bounds_is_refused():
+ with pytest.raises(SchemaError, match="below its min"):
+ validate(announce(settings={"x": {"type": "float", "default": -1, "min": 0, "max": 1, "label": "X"}}))
+ with pytest.raises(SchemaError, match="above its max"):
+ validate(announce(settings={"x": {"type": "float", "default": 9, "min": 0, "max": 1, "label": "X"}}))
+
+
+def test_inverted_bounds_are_refused():
+ with pytest.raises(SchemaError, match="above max"):
+ validate(announce(settings={"x": {"type": "int", "default": 0, "min": 5, "max": 1, "label": "X"}}))
+
+
+def test_a_non_positive_step_is_refused():
+ with pytest.raises(SchemaError, match="step"):
+ validate(announce(settings={"x": {"type": "float", "default": 0.5, "step": 0, "label": "X"}}))
+
+
+# --- content is text -------------------------------------------------------
+
+@pytest.mark.parametrize("key", ["label", "hint", "unit"])
+def test_content_must_be_text(key):
+ # The UI renders these as text and will not interpret anything else.
+ with pytest.raises(SchemaError, match=key):
+ validate(announce(settings={"x": {"type": "bool", "default": True, key: {"markup": ""}}}))
+
+
+def test_option_labels_must_be_text_and_values_scalar():
+ with pytest.raises(SchemaError, match="label"):
+ validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X",
+ "options": [{"value": "a", "label": ["a"]}]}}))
+ with pytest.raises(SchemaError, match="scalar"):
+ validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X",
+ "options": [{"value": {"a": 1}, "label": "A"}]}}))
+
+
+# --- enums -----------------------------------------------------------------
+
+def test_an_enum_needs_options():
+ with pytest.raises(SchemaError, match="no options"):
+ validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X", "options": []}}))
+
+
+def test_an_enum_default_must_be_one_of_its_options():
+ with pytest.raises(SchemaError, match="not one of its options"):
+ validate(announce(settings={"x": {"type": "enum", "default": "z", "label": "X",
+ "options": [{"value": "a", "label": "A"}]}}))
+
+
+def test_duplicate_option_values_are_refused():
+ with pytest.raises(SchemaError, match="duplicate"):
+ validate(announce(settings={"x": {"type": "enum", "default": "a", "label": "X",
+ "options": [{"value": "a", "label": "A"}, {"value": "a", "label": "B"}]}}))
+
+
+# --- conditional visibility ------------------------------------------------
+
+def test_when_may_only_name_siblings():
+ # A condition on a key that does not exist is always false, so the row would
+ # simply never appear -- a typo that reads as a deliberate decision.
+ with pytest.raises(SchemaError, match="does not declare"):
+ validate(announce(settings={
+ "a": {"type": "bool", "default": True, "label": "A"},
+ "b": {"type": "bool", "default": True, "label": "B", "when": {"nope": True}},
+ }))
+
+
+def test_when_may_not_name_itself():
+ with pytest.raises(SchemaError, match="itself"):
+ validate(announce(settings={"a": {"type": "bool", "default": True, "label": "A", "when": {"a": True}}}))
+
+
+def test_a_valid_when_survives_into_the_spec():
+ spec = validate(announce(settings={
+ "enabled": {"type": "bool", "default": True, "label": "On"},
+ "strength": {"type": "float", "default": 0.5, "label": "S", "when": {"enabled": True}},
+ }))
+ assert spec.settings["strength"]["when"] == {"enabled": True}
+
+
+# --- the rest of the announcement ------------------------------------------
+
+def test_unknown_stage_is_refused():
+ with pytest.raises(SchemaError, match="unknown stage"):
+ validate(announce(stage="whenever"))
+
+
+def test_unknown_status_is_refused():
+ with pytest.raises(SchemaError, match="unknown status"):
+ validate(announce(status="probably-fine"))
+
+
+def test_relations_must_be_lists_of_ids():
+ with pytest.raises(SchemaError, match="after"):
+ validate(announce(after="audio_clock")) # a bare string is a common slip
+ with pytest.raises(SchemaError, match="requires"):
+ validate(announce(requires=[1, 2]))
+
+
+def test_the_manifest_carries_what_the_ui_needs():
+ spec = validate(announce(requires=["audio_stream"], after=["x"], stage="latent"))
+ manifest = spec.to_manifest()
+ assert manifest["id"] == "audio_clock"
+ assert manifest["requires"] == ["audio_stream"]
+ assert manifest["stage"] == "latent"
+ assert "source" not in manifest, "the import path is core's business, not the browser's"
+
+
+def test_the_contract_has_a_version():
+ assert isinstance(CONTRACT_VERSION, int)
diff --git a/core/traits.py b/core/traits.py
new file mode 100644
index 00000000..348d9b10
--- /dev/null
+++ b/core/traits.py
@@ -0,0 +1,24 @@
+"""Compatibility by trait, never by model name.
+
+A module listing model names has to be edited every time a model ships, and the
+one nobody remembers to edit is the one that silently stops appearing. A model
+says what it IS; a module says what it NEEDS.
+"""
+
+from typing import Iterable, List, Sequence, Set, Tuple
+
+from .contract import ModuleSpec
+
+
+def split(specs: Iterable[ModuleSpec], available: Sequence[str]) -> Tuple[List[ModuleSpec], List[ModuleSpec]]:
+ """(compatible, incompatible) for the traits currently on offer."""
+ have: Set[str] = set(available or ())
+ compatible, incompatible = [], []
+ for spec in specs:
+ (compatible if set(spec.requires) <= have else incompatible).append(spec)
+ return compatible, incompatible
+
+
+def missing_for(spec: ModuleSpec, available: Sequence[str]) -> List[str]:
+ """Which traits a module wanted and did not get. For the modules dump."""
+ return sorted(set(spec.requires) - set(available or ()))
diff --git a/modules/__init__.py b/modules/__init__.py
new file mode 100644
index 00000000..f4337080
--- /dev/null
+++ b/modules/__init__.py
@@ -0,0 +1,5 @@
+"""Feature modules, grouped by domain.
+
+Nothing imports from here directly: core scans the folder. A module is a folder
+with an __init__.py that declares ID, TITLE, MOUNT and SETTINGS.
+"""
diff --git a/modules/system/__init__.py b/modules/system/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/system/appearance/__init__.py b/modules/system/appearance/__init__.py
new file mode 100644
index 00000000..1151105b
--- /dev/null
+++ b/modules/system/appearance/__init__.py
@@ -0,0 +1,39 @@
+"""Appearance: theme and grid density.
+
+The first real vertical slice, and deliberately the smallest one that exercises
+the whole path -- announcement, schema, panel, values -- with no model, no
+sampler and nothing that needs a GPU to be seen working.
+"""
+
+ID = "appearance"
+TITLE = "Appearance"
+MOUNT = "settings.general"
+STAGE = "load"
+STATUS = "proven"
+
+SETTINGS = {
+ "theme": {
+ "type": "enum", "default": "auto",
+ "label": "Colour scheme", "ui": "segmented",
+ "options": [
+ {"value": "dark", "label": "Dark"},
+ {"value": "light", "label": "Light"},
+ {"value": "auto", "label": "Auto"},
+ ],
+ },
+ "density": {
+ "type": "int", "default": 0, "min": 0, "max": 4, "step": 1,
+ "label": "Media columns",
+ "hint": "0 fits as many as the panel allows.",
+ "ui": "stepper",
+ },
+}
+
+
+def apply(values, context=None):
+ """Nothing to do at generation time: appearance is the browser's business.
+
+ Declared anyway so the module has the same shape as every other one -- a
+ module core has to special-case is a module that will be forgotten.
+ """
+ return context
diff --git a/modules/system/appearance/ui.js b/modules/system/appearance/ui.js
new file mode 100644
index 00000000..8630951a
--- /dev/null
+++ b/modules/system/appearance/ui.js
@@ -0,0 +1,15 @@
+// Appearance needs a little JavaScript, because its settings change the app
+// rather than the generation. Most modules have no ui.js at all.
+
+export function setup({ values, on, shell }) {
+ const apply = (key, value) => {
+ if (key === "theme") shell.theme.set(value);
+ if (key === "density") shell.density.set(value);
+ };
+
+ // Apply what is already stored, then follow every later edit.
+ const current = values.get();
+ for (const key of Object.keys(current)) apply(key, current[key]);
+
+ return on(apply);
+}
diff --git a/tools/devserver.py b/tools/devserver.py
index c978fda1..37e2b444 100644
--- a/tools/devserver.py
+++ b/tools/devserver.py
@@ -6,23 +6,32 @@ exercised every time you look at the catalogue.
python tools/devserver.py [port]
"""
+import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
-from urllib.parse import unquote, urlparse
+from urllib.parse import parse_qs, unquote, urlparse
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
-from core import config, serve as static # noqa: E402
+from core import config, routes, serve as static # noqa: E402
P = config.UI_PREFIX
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
- path = unquote(urlparse(self.path).path)
+ parsed = urlparse(self.path)
+ path = unquote(parsed.path)
- if path in (P, P + "/"):
+ # The same manifest the aiohttp route serves, so what you see in the
+ # browser here is what ComfyUI would send.
+ if path == P + "/api/modules":
+ raw = parse_qs(parsed.query).get("traits")
+ traits = [t for t in raw[0].split(",") if t] if raw else None
+ body = json.dumps(routes.manifest(traits)).encode()
+ served = static.Served(200, body, "application/json")
+ elif path in (P, P + "/"):
served = static.serve(config.APP_DIR, "index.html", config.APP_EXTS)
elif path.startswith(P + "/app/"):
served = static.serve(config.APP_DIR, path[len(P) + 5:], config.APP_EXTS)
diff --git a/tools/kit_lint/rules.js b/tools/kit_lint/rules.js
new file mode 100644
index 00000000..1e1981f7
--- /dev/null
+++ b/tools/kit_lint/rules.js
@@ -0,0 +1,36 @@
+// What a module may not do, and what the kit may not do.
+//
+// These are the static half of rules 1 and 4. The runtime half is stronger --
+// a module is never handed anything that could style or parse -- but a scanner
+// catches the attempt in the file rather than at the moment it would have run,
+// which is the difference between a failing test and a support ticket.
+
+export const MODULE_RULES = [
+ { name: "inline-style", why: "presentation belongs to the kit",
+ test: /\.style\s*[.[]|setAttribute\s*\(\s*['"`]style/ },
+ { name: "style-attribute", why: "presentation belongs to the kit",
+ test: /\bstyle\s*=\s*['"`]/ },
+ { name: "class-manipulation", why: "kit classes are the kit's; a module names elements, not classes",
+ test: /\bclassList\b|setAttribute\s*\(\s*['"`]class/ },
+ { name: "raw-element", why: "every element comes from composer..()",
+ test: /document\s*\.\s*createElement|createElementNS/ },
+ { name: "markup", why: "content is data; the kit renders it as text",
+ test: /\binnerHTML\b|\bouterHTML\b|insertAdjacentHTML|document\s*\.\s*write/ },
+ { name: "stylesheet", why: "a module cannot ship styling, and cannot inject it either",
+ test: /adoptedStyleSheets|document\s*\.\s*head|<\s*(link|style)\b/ },
+ { name: "hardcoded-colour", why: "colour comes from the theme tokens",
+ test: /#[0-9a-fA-F]{3,8}\b/ },
+ { name: "hardcoded-size", why: "sizes come from the spacing and control scales",
+ test: /\b\d+(\.\d+)?px\b/ },
+ { name: "eval", why: "nothing in a module is compiled at runtime",
+ test: /\beval\s*\(|new\s+Function\s*\(/ },
+];
+
+// The kit itself may style and may build elements -- that is its job -- but it
+// must never interpret content.
+export const KIT_RULES = [
+ { name: "markup", why: "the kit renders content as text and never parses it",
+ test: /\binnerHTML\b|\bouterHTML\b|insertAdjacentHTML|document\s*\.\s*write/ },
+ { name: "eval", why: "nothing in the kit is compiled at runtime",
+ test: /\beval\s*\(|new\s+Function\s*\(/ },
+];
diff --git a/tools/kit_lint/scan.js b/tools/kit_lint/scan.js
new file mode 100644
index 00000000..b8aa4bc8
--- /dev/null
+++ b/tools/kit_lint/scan.js
@@ -0,0 +1,51 @@
+// A source scanner for the rule tests.
+//
+// Deliberately dumb: it reads text and matches patterns. Something cleverer
+// would need a parser, and a parser is a thing that can be wrong in ways nobody
+// notices. A false positive here is a comment away from being fixed; a false
+// negative is a rule that quietly stopped being enforced.
+
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+const SKIP = new Set(["node_modules", ".git", "__pycache__", "tests"]);
+
+export function jsFilesIn(root) {
+ const out = [];
+ let entries;
+ try { entries = readdirSync(root); } catch { return out; }
+ for (const name of entries) {
+ if (SKIP.has(name) || name.startsWith(".")) continue;
+ const path = join(root, name);
+ if (statSync(path).isDirectory()) out.push(...jsFilesIn(path));
+ else if (name.endsWith(".js") && !name.endsWith(".test.js")) out.push(path);
+ }
+ return out;
+}
+
+/** Strip comments so a rule quoted in prose is not a violation. */
+export function code(source) {
+ return source
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
+}
+
+export function scanSource(source, rules) {
+ const body = code(source);
+ const found = [];
+ for (const rule of rules) {
+ const line = body.split("\n").findIndex((l) => rule.test.test(l));
+ if (line !== -1) found.push({ rule: rule.name, why: rule.why, line: line + 1 });
+ }
+ return found;
+}
+
+export function scan(root, rules) {
+ const violations = [];
+ for (const file of jsFilesIn(root)) {
+ for (const found of scanSource(readFileSync(file, "utf8"), rules)) {
+ violations.push({ file, ...found });
+ }
+ }
+ return violations;
+}