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.
This commit is contained in:
digital-garbage
2026-08-28 19:58:06 +03:00
parent 73e81b0263
commit 835fab7864
54 changed files with 2219 additions and 45 deletions
+38 -8
View File
@@ -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();
+7
View File
@@ -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); }
+1
View File
@@ -13,6 +13,7 @@
<h1>Composer elements</h1>
<div class="cat-bar-spacer"></div>
<a class="cat-link" href="/funpack/app/catalogue/index.html">Tokens →</a>
<a class="cat-link" href="/funpack/app/catalogue/hide.html">Hide-don't-warn</a>
<button id="toggle-panes" aria-pressed="true">Both themes</button>
<button data-theme-set="dark">Dark</button>
<button data-theme-set="light">Light</button>
+63
View File
@@ -0,0 +1,63 @@
// Hide, don't warn -- shown rather than asserted.
//
// One good module and five broken ones, put through the SAME shell code the app
// uses. The good one's panel appears. The broken ones do not appear at all: no
// placeholder, no greyed row, no warning chip. What you see on this page is the
// whole of what a user would see.
//
// These mirror core/tests/fixtures/fixture_modules, so the browser and the
// Python suite are looking at the same five ways of being wrong.
export const GOOD = {
id: "audio_clock",
title: "Audio clock",
mount: "demo.panel",
stage: "conditioning",
status: "proven",
requires: ["audio_stream"],
settings: {
enabled: { type: "bool", default: true, label: "Sync to audio clock",
hint: "Locks frame timing to the audio stream." },
strength: { type: "float", default: 0.65, min: 0, max: 1, step: 0.05,
label: "Lock strength", unit: "x", ui: "slider", when: { enabled: true } },
mode: { type: "enum", default: "beat", label: "Alignment", ui: "segmented",
options: [{ value: "beat", label: "Beat grid" },
{ value: "onset", label: "Onset" },
{ value: "flat", label: "Flat" }],
when: { enabled: true } },
},
};
// Each of these is refused somewhere different, which is the point: they are
// caught at the earliest layer that can catch them.
export const BROKEN = [
{
why: "raises on import",
caughtBy: "core: the scan imports it in a try",
spec: { id: "explodes", title: "Explodes", mount: "demo.panel", settings: {}, ui: "/nope/missing.js" },
},
{
why: "asks for a renderer its type does not have",
caughtBy: "core: schema validation, before the manifest",
spec: { id: "bad_ui", title: "Bad renderer", mount: "demo.panel",
settings: { on: { type: "bool", default: true, label: "On", ui: "hologram" } } },
},
{
why: "a label that is not text",
caughtBy: "core: schema validation; the kit would refuse it too",
spec: { id: "bad_label", title: "Bad label", mount: "demo.panel",
settings: { on: { type: "bool", default: true, label: { markup: "<b>no</b>" } } } },
},
{
why: "no default, so a headless run has no value",
caughtBy: "core: schema validation",
spec: { id: "no_default", title: "No default", mount: "demo.panel",
settings: { strength: { type: "float", min: 0, max: 1, label: "Strength" } } },
},
{
why: "names a mount point no region offers",
caughtBy: "shell: mounts.js has no host for it",
spec: { id: "lost", title: "Lost module", mount: "somewhere.else",
settings: { on: { type: "bool", default: true, label: "On" } } },
},
];
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hide, don't warn</title>
<script src="/funpack/app/shell/theme.js"></script>
<link rel="stylesheet" href="/funpack/app/composer/composer.css">
<link rel="stylesheet" href="/funpack/app/catalogue/catalogue.css">
</head>
<body>
<header class="cat-bar">
<h1>Hide, don't warn</h1>
<div class="cat-bar-spacer"></div>
<a class="cat-link" href="/funpack/app/catalogue/index.html">Tokens</a>
<a class="cat-link" href="/funpack/app/catalogue/elements.html">Elements</a>
<button data-theme-set="dark">Dark</button>
<button data-theme-set="light">Light</button>
</header>
<main class="cat-page" id="catalogue"></main>
<script type="module" src="/funpack/app/catalogue/hide.js"></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
// Renders the fixture modules through the real shell.
import { mountAll } from "../shell/panels.js";
import { offer, _reset as resetMounts } from "../shell/mounts.js";
import { _reset as resetValues, all as allValues } from "../shell/values.js";
import { GOOD, BROKEN } from "./fixtures.js";
const el = (tag, cls, text) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
};
async function render(theme) {
resetMounts();
resetValues();
const page = document.querySelector("#catalogue");
page.textContent = "";
const section = el("section", "cat-section");
section.append(el("h2", null, "What a user sees"));
section.append(el("p", null,
"Six modules go in: one valid, five broken in different ways. Everything below " +
"is the whole of what reaches the screen."));
const pane = el("section", "cat-pane");
pane.setAttribute("data-theme", theme);
pane.append(el("h3", null, theme));
const host = el("div", "cat-mount");
pane.append(host);
section.append(pane);
page.append(section);
offer("demo.panel", host);
// The broken ones are handed to the shell exactly as the good one is. In the
// real app most never get this far -- core refuses them at import or at
// validation -- so this is the worst case: everything reaching the browser.
const manifest = { modules: [GOOD, ...BROKEN.map((b) => b.spec)] };
const { mounted, hidden } = await mountAll(manifest);
if (!host.childElementCount) host.append(el("p", "cat-broke", "nothing mounted at all"));
// Everything from here down is developer commentary, NOT part of what a user
// would see -- the panel above is.
const ledger = el("section", "cat-section");
ledger.append(el("h2", null, "Why each one is absent"));
ledger.append(el("p", null, "This table exists for you, not for the user. None of it appears in the app."));
const table = el("div", "cat-ledger");
for (const { id } of mounted) {
table.append(el("div", "cat-ledger-row cat-ok"), el("code", "cat-name", id));
table.append(el("span", null, "mounted"));
}
for (const { id, why } of hidden) {
const source = BROKEN.find((b) => b.spec.id === id);
table.append(el("code", "cat-name", id));
table.append(el("span", "cat-why", source ? source.why : "—"));
table.append(el("span", "cat-caught", why));
}
ledger.append(table);
page.append(ledger);
const values = el("section", "cat-section");
values.append(el("h2", null, "Values a headless run would get"));
values.append(el("p", null,
"Seeded from the same declaration the panel renders, so a generation with no " +
"browser open starts from the same numbers."));
const pre = el("pre", "cx-code-block");
pre.textContent = JSON.stringify(allValues(), null, 2);
values.append(pre);
page.append(values);
}
let theme = "dark";
for (const btn of document.querySelectorAll("[data-theme-set]")) {
btn.addEventListener("click", () => {
theme = btn.getAttribute("data-theme-set");
window.ComposerTheme.apply(theme === "auto" ? "auto" : theme);
render(theme === "auto" ? window.ComposerTheme.resolved() : theme);
});
}
render(theme);
+1
View File
@@ -13,6 +13,7 @@
<h1>Composer catalogue</h1>
<div class="cat-bar-spacer"></div>
<a class="cat-link" href="/funpack/app/catalogue/elements.html">Elements &rarr;</a>
<a class="cat-link" href="/funpack/app/catalogue/hide.html">Hide-don't-warn</a>
<button id="toggle-panes" aria-pressed="true">Both themes</button>
<button data-theme-set="dark">Dark</button>
<button data-theme-set="light">Light</button>
+18 -14
View File
@@ -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); }
+32 -16
View File
@@ -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 {
+173
View File
@@ -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;
}
@@ -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");
});
@@ -0,0 +1,7 @@
export function setup({ composer, values }) {
const button = composer.button.md({
label: "Re-key",
onClick: () => values.set("enabled", true),
});
return button;
}
@@ -0,0 +1,12 @@
export function setup({ composer }) {
const b = composer.button.md({ label: "x" });
b.node.style.color = "#ff0000";
b.node.classList.add("mine");
b.node.innerHTML = "<b>hi</b>";
const smuggled = '<div style="color: red">still styling</div>';
const raw = document.createElement("div");
raw.setAttribute("style", "margin: 12px");
document.head.appendChild(raw);
void smuggled;
return new Function("return 1")();
}
+181
View File
@@ -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`);
}
});
@@ -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");
});
@@ -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"]);
});
+10 -1
View File
@@ -1,9 +1,18 @@
<!doctype html>
<html lang="en" data-theme="dark">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FunPack</title>
<script src="/funpack/app/shell/theme.js"></script>
<link rel="stylesheet" href="/funpack/app/composer/composer.css">
<style>
/* The app frame itself: the only rule outside the kit, and it sets no
colours, sizes or type -- just the shape of the page. */
body { display: flex; }
#app { flex: 1; display: flex; min-height: 100vh; padding: var(--sp-6); }
#app > * { flex: 1; }
</style>
</head>
<body>
<div id="app"></div>
+35
View File
@@ -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 };
}
+10
View File
@@ -0,0 +1,10 @@
// What the server says exists.
const ENDPOINT = "/funpack/api/modules";
export async function fetchManifest(traits) {
const url = traits ? `${ENDPOINT}?traits=${encodeURIComponent(traits.join(","))}` : ENDPOINT;
const response = await fetch(url);
if (!response.ok) throw new Error(`${ENDPOINT} answered ${response.status}`);
return response.json();
}
+24
View File
@@ -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();
}
+76
View File
@@ -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 };
}
+24
View File
@@ -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;
},
},
};
+36
View File
@@ -0,0 +1,36 @@
// The live value of every setting, keyed exactly as the declaration keys them.
//
// This is the only thing in the app that reads the UI. A module never does: it
// declares what it needs and is handed values, so the same module works with a
// panel on screen and with no browser at all.
const store = new Map(); // moduleId -> { key: value }
const listeners = new Set();
export function seed(moduleId, defaults) {
store.set(moduleId, { ...defaults });
}
export function set(moduleId, key, value) {
const current = store.get(moduleId) || {};
current[key] = value;
store.set(moduleId, current);
for (const fn of listeners) {
try { fn(moduleId, key, value); } catch { /* a listener must not break the edit */ }
}
}
export const valuesOf = (moduleId) => ({ ...(store.get(moduleId) || {}) });
/** Everything, as it would be sent with a generation request. */
export const all = () => Object.fromEntries([...store].map(([id, v]) => [id, { ...v }]));
export function onChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
export function _reset() {
store.clear();
listeners.clear();
}
+76
View File
@@ -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,
}
+99
View File
@@ -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/<domain>/<feature>/ -- 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
+65
View File
@@ -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
+51 -1
View File
@@ -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)
+172
View File
@@ -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,
)
View File
@@ -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": "<b>no</b>"}}}
@@ -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"}}
@@ -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"}}
@@ -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")
@@ -0,0 +1,5 @@
ID = "cycle_first"
TITLE = "First"
MOUNT = "generation.timing"
AFTER = ["cycle_second"]
SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On"}}
@@ -0,0 +1,5 @@
ID = "cycle_second"
TITLE = "Second"
MOUNT = "generation.timing"
AFTER = ["cycle_first"]
SETTINGS = {"enabled": {"type": "bool", "default": True, "label": "On"}}
@@ -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"}}
@@ -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},
},
}
@@ -0,0 +1 @@
export default {};
@@ -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"}}
+160
View File
@@ -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
+70
View File
@@ -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
+208
View File
@@ -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": "<b>"}}}))
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)
+24
View File
@@ -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 ()))
+5
View File
@@ -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.
"""
View File
+39
View File
@@ -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
+15
View File
@@ -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);
}
+13 -4
View File
@@ -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)
+36
View File
@@ -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.<group>.<variant>()",
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*\(/ },
];
+51
View File
@@ -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;
}