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:
+38
-8
@@ -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();
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Hide, don't warn -- shown rather than asserted.
|
||||
//
|
||||
// One good module and five broken ones, put through the SAME shell code the app
|
||||
// uses. The good one's panel appears. The broken ones do not appear at all: no
|
||||
// placeholder, no greyed row, no warning chip. What you see on this page is the
|
||||
// whole of what a user would see.
|
||||
//
|
||||
// These mirror core/tests/fixtures/fixture_modules, so the browser and the
|
||||
// Python suite are looking at the same five ways of being wrong.
|
||||
|
||||
export const GOOD = {
|
||||
id: "audio_clock",
|
||||
title: "Audio clock",
|
||||
mount: "demo.panel",
|
||||
stage: "conditioning",
|
||||
status: "proven",
|
||||
requires: ["audio_stream"],
|
||||
settings: {
|
||||
enabled: { type: "bool", default: true, label: "Sync to audio clock",
|
||||
hint: "Locks frame timing to the audio stream." },
|
||||
strength: { type: "float", default: 0.65, min: 0, max: 1, step: 0.05,
|
||||
label: "Lock strength", unit: "x", ui: "slider", when: { enabled: true } },
|
||||
mode: { type: "enum", default: "beat", label: "Alignment", ui: "segmented",
|
||||
options: [{ value: "beat", label: "Beat grid" },
|
||||
{ value: "onset", label: "Onset" },
|
||||
{ value: "flat", label: "Flat" }],
|
||||
when: { enabled: true } },
|
||||
},
|
||||
};
|
||||
|
||||
// Each of these is refused somewhere different, which is the point: they are
|
||||
// caught at the earliest layer that can catch them.
|
||||
export const BROKEN = [
|
||||
{
|
||||
why: "raises on import",
|
||||
caughtBy: "core: the scan imports it in a try",
|
||||
spec: { id: "explodes", title: "Explodes", mount: "demo.panel", settings: {}, ui: "/nope/missing.js" },
|
||||
},
|
||||
{
|
||||
why: "asks for a renderer its type does not have",
|
||||
caughtBy: "core: schema validation, before the manifest",
|
||||
spec: { id: "bad_ui", title: "Bad renderer", mount: "demo.panel",
|
||||
settings: { on: { type: "bool", default: true, label: "On", ui: "hologram" } } },
|
||||
},
|
||||
{
|
||||
why: "a label that is not text",
|
||||
caughtBy: "core: schema validation; the kit would refuse it too",
|
||||
spec: { id: "bad_label", title: "Bad label", mount: "demo.panel",
|
||||
settings: { on: { type: "bool", default: true, label: { markup: "<b>no</b>" } } } },
|
||||
},
|
||||
{
|
||||
why: "no default, so a headless run has no value",
|
||||
caughtBy: "core: schema validation",
|
||||
spec: { id: "no_default", title: "No default", mount: "demo.panel",
|
||||
settings: { strength: { type: "float", min: 0, max: 1, label: "Strength" } } },
|
||||
},
|
||||
{
|
||||
why: "names a mount point no region offers",
|
||||
caughtBy: "shell: mounts.js has no host for it",
|
||||
spec: { id: "lost", title: "Lost module", mount: "somewhere.else",
|
||||
settings: { on: { type: "bool", default: true, label: "On" } } },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hide, don't warn</title>
|
||||
<script src="/funpack/app/shell/theme.js"></script>
|
||||
<link rel="stylesheet" href="/funpack/app/composer/composer.css">
|
||||
<link rel="stylesheet" href="/funpack/app/catalogue/catalogue.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="cat-bar">
|
||||
<h1>Hide, don't warn</h1>
|
||||
<div class="cat-bar-spacer"></div>
|
||||
<a class="cat-link" href="/funpack/app/catalogue/index.html">Tokens</a>
|
||||
<a class="cat-link" href="/funpack/app/catalogue/elements.html">Elements</a>
|
||||
<button data-theme-set="dark">Dark</button>
|
||||
<button data-theme-set="light">Light</button>
|
||||
</header>
|
||||
<main class="cat-page" id="catalogue"></main>
|
||||
<script type="module" src="/funpack/app/catalogue/hide.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
// Renders the fixture modules through the real shell.
|
||||
|
||||
import { mountAll } from "../shell/panels.js";
|
||||
import { offer, _reset as resetMounts } from "../shell/mounts.js";
|
||||
import { _reset as resetValues, all as allValues } from "../shell/values.js";
|
||||
import { GOOD, BROKEN } from "./fixtures.js";
|
||||
|
||||
const el = (tag, cls, text) => {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
};
|
||||
|
||||
async function render(theme) {
|
||||
resetMounts();
|
||||
resetValues();
|
||||
|
||||
const page = document.querySelector("#catalogue");
|
||||
page.textContent = "";
|
||||
|
||||
const section = el("section", "cat-section");
|
||||
section.append(el("h2", null, "What a user sees"));
|
||||
section.append(el("p", null,
|
||||
"Six modules go in: one valid, five broken in different ways. Everything below " +
|
||||
"is the whole of what reaches the screen."));
|
||||
|
||||
const pane = el("section", "cat-pane");
|
||||
pane.setAttribute("data-theme", theme);
|
||||
pane.append(el("h3", null, theme));
|
||||
const host = el("div", "cat-mount");
|
||||
pane.append(host);
|
||||
section.append(pane);
|
||||
page.append(section);
|
||||
|
||||
offer("demo.panel", host);
|
||||
|
||||
// The broken ones are handed to the shell exactly as the good one is. In the
|
||||
// real app most never get this far -- core refuses them at import or at
|
||||
// validation -- so this is the worst case: everything reaching the browser.
|
||||
const manifest = { modules: [GOOD, ...BROKEN.map((b) => b.spec)] };
|
||||
const { mounted, hidden } = await mountAll(manifest);
|
||||
|
||||
if (!host.childElementCount) host.append(el("p", "cat-broke", "nothing mounted at all"));
|
||||
|
||||
// Everything from here down is developer commentary, NOT part of what a user
|
||||
// would see -- the panel above is.
|
||||
const ledger = el("section", "cat-section");
|
||||
ledger.append(el("h2", null, "Why each one is absent"));
|
||||
ledger.append(el("p", null, "This table exists for you, not for the user. None of it appears in the app."));
|
||||
|
||||
const table = el("div", "cat-ledger");
|
||||
for (const { id } of mounted) {
|
||||
table.append(el("div", "cat-ledger-row cat-ok"), el("code", "cat-name", id));
|
||||
table.append(el("span", null, "mounted"));
|
||||
}
|
||||
for (const { id, why } of hidden) {
|
||||
const source = BROKEN.find((b) => b.spec.id === id);
|
||||
table.append(el("code", "cat-name", id));
|
||||
table.append(el("span", "cat-why", source ? source.why : "—"));
|
||||
table.append(el("span", "cat-caught", why));
|
||||
}
|
||||
ledger.append(table);
|
||||
page.append(ledger);
|
||||
|
||||
const values = el("section", "cat-section");
|
||||
values.append(el("h2", null, "Values a headless run would get"));
|
||||
values.append(el("p", null,
|
||||
"Seeded from the same declaration the panel renders, so a generation with no " +
|
||||
"browser open starts from the same numbers."));
|
||||
const pre = el("pre", "cx-code-block");
|
||||
pre.textContent = JSON.stringify(allValues(), null, 2);
|
||||
values.append(pre);
|
||||
page.append(values);
|
||||
}
|
||||
|
||||
let theme = "dark";
|
||||
for (const btn of document.querySelectorAll("[data-theme-set]")) {
|
||||
btn.addEventListener("click", () => {
|
||||
theme = btn.getAttribute("data-theme-set");
|
||||
window.ComposerTheme.apply(theme === "auto" ? "auto" : theme);
|
||||
render(theme === "auto" ? window.ComposerTheme.resolved() : theme);
|
||||
});
|
||||
}
|
||||
|
||||
render(theme);
|
||||
@@ -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 →</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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")();
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// What the server says exists.
|
||||
|
||||
const ENDPOINT = "/funpack/api/modules";
|
||||
|
||||
export async function fetchManifest(traits) {
|
||||
const url = traits ? `${ENDPOINT}?traits=${encodeURIComponent(traits.join(","))}` : ENDPOINT;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`${ENDPOINT} answered ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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"}}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 ()))
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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*\(/ },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user