UI: feature lint rule, app.dom service, kit tiles/cards/grid/hero; move simple features onto them

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155S2Ye8BRUakV7GHAsmYWu
This commit is contained in:
Claude
2026-10-17 14:10:17 +00:00
parent 51c87d85c0
commit 326366c425
47 changed files with 252 additions and 162 deletions
+2 -1
View File
@@ -12,6 +12,7 @@ import { createPlayhead } from "./shell/playhead.js";
import { createMaintenance } from "./shell/maintenance.js";
import { syncSeparated } from "./shell/audio.js";
import { createKeys } from "./shell/keys.js";
import { createDom } from "./shell/dom.js";
import { createDock } from "./shell/dock.js";
import { hostFor } from "./shell/mounts.js";
import paths from "./modules.js";
@@ -29,7 +30,7 @@ const link = linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if
const frame = build(document.getElementById("app"));
const selection = createSelection({ project });
selection.on(() => say("select"));
const app = { editorSettings: [], timelineLanes: [], timelineView: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], beforeRun: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, pipelineWhy: link.why, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
const app = { editorSettings: [], timelineLanes: [], timelineView: {}, mediaMenu: [], mediaPeek: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], beforeRun: [], promptPrefix: [], lastRun: { typed: undefined, n: 0 }, runner: {}, project, pipeline, pipelineOwned: link.owns, pipelineWhy: link.why, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), dom: createDom(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
// Which backend modules the server has (loaded, even if this model cannot use them). Unknown (server down) = assume all, so nothing is hidden by a failed fetch.
const installed = await Promise.race([api.modules(), new Promise((_, no) => setTimeout(() => no(new Error("slow")), 4000))]) // a stalled server must not leave the page blank
.then((m) => (Array.isArray(m.modules) ? new Set([...m.modules, ...(m.incompatible || [])].map((x) => x.id)) : null)).catch(() => null);
+7
View File
@@ -141,6 +141,13 @@ export const DEMOS = {
],
}),
"brand.default": { name: "FunPack" },
"card.default": (c) => ({ children: [c.field.default({ label: "Prompt", control: c.input.md({ value: "A slow push in on the rooftop" }) }), c.hint.default({ text: "Related fields share one card." })] }),
"grid.pair": (c) => ({ children: [c.tile.action({ title: "Update", sub: "Pull the newest version" }), c.tile.action({ title: "Restart", sub: "Reload the server" })] }),
"grid.cards": (c) => ({ children: [c.tile.node({ title: "Load checkpoint", sub: "model.safetensors" }), c.tile.node({ title: "Sampler", sub: "Needs a choice", state: "needs" }), c.tile.add({ title: "+ Add a node", sub: "to Sampling" })] }),
"tile.action": { title: "Update", sub: "Pull the newest version from origin" },
"tile.node": { title: "Load checkpoint", sub: "model.safetensors", state: "ready" },
"tile.add": { title: "+ Add a node", sub: "to Loaders" },
"hero.default": { title: "FunPack 5", hint: "“Cunning Cranberry” · Cutting Room" },
"panel.default": (c) => ({
title: "Scene 3",
actions: [c.iconButton.sm({ icon: "✎", label: "Rename" }), c.iconButton.sm({ icon: "✕", label: "Remove" })],
+1
View File
@@ -11,6 +11,7 @@
@import "./elements/choice.css";
@import "./elements/slider.css";
@import "./elements/layout.css";
@import "./elements/tile.css";
@import "./elements/status.css";
@import "./elements/overlay.css";
@import "./elements/gallery.css";
+1
View File
@@ -11,6 +11,7 @@ import "./input.js";
import "./choice.js";
import "./slider.js";
import "./layout.js";
import "./tile.js";
import "./status.js";
import "./modal.js";
import "./popover.js";
+9 -1
View File
@@ -4,7 +4,7 @@
// kit made, and insisting on one is what stops "just let me pass a div" becoming
// the hole through which modules build their own markup.
import { define } from "../internals/register.js";
import { define, lookup } from "../internals/register.js";
import { el } from "../internals/el.js";
import { setText } from "../internals/text.js";
import { uid } from "../internals/ids.js";
@@ -57,6 +57,14 @@ define("settingsRow", "default", ({ label, hint, control, danger } = {}) => {
inner ? el("div", { cls: "cx-settings-control", children: inner }) : null,
].filter(Boolean) });
const hintNode = () => node.querySelector(".cx-hint");
return { node, setHint: (value) => { const h = hintNode(); if (h) setText(h, value); }, destroy: () => node.remove() };
});
/** The mark, a big title and one line under it. The top of an About page. */
define("hero", "default", ({ title, hint } = {}) => {
const node = el("div", { cls: ["cx-stack", "cx-stack-none", "cx-settings-hero"], children: [
lookup("brand", "default")({ name: "" }).node, lookup("header", "xl")({ text: title }).node, lookup("hint", "default")({ text: hint }).node] });
return { node, destroy: () => node.remove() };
});
+3 -3
View File
@@ -99,9 +99,9 @@ body { -webkit-font-smoothing: antialiased; letter-spacing: -.003em; }
.cx-viewer-media { border-radius: var(--r-3); }
/* inspector: related fields share one rounded card */
.fp-card { background: var(--elev-2); border-radius: var(--r-5); padding: var(--sp-6); gap: var(--sp-5); }
.fp-card .cx-input, .fp-card .cx-textarea, .fp-card .cx-select { background: var(--elev-3); }
.fp-card .cx-segmented { background: var(--elev-3); }
.cx-card { background: var(--elev-2); border-radius: var(--r-5); padding: var(--sp-6); gap: var(--sp-5); }
.cx-card .cx-input, .cx-card .cx-textarea, .cx-card .cx-select { background: var(--elev-3); }
.cx-card .cx-segmented { background: var(--elev-3); }
.cx-panel-body > .cx-eyebrow, .cx-stack > .cx-eyebrow { margin-block-start: var(--sp-4); }
/* list views read as a grouped card, like a settings list */
+3 -2
View File
@@ -25,8 +25,9 @@ for (const size of ["lg", "md", "sm", "xs"]) {
}
// One hint, one look. v4 had ten class names for this exact thing.
define("hint", "default", ({ text, id } = {}) =>
handle(el("p", { cls: "cx-hint", text, attrs: { id } })));
// `low`: the first thing to go when the row runs out of room.
define("hint", "default", ({ text, id, low = false } = {}) =>
handle(el("p", { cls: ["cx-hint", low ? "cx-hint-low" : null], text, attrs: { id } })));
define("label", "field", ({ text, for: htmlFor } = {}) =>
handle(el("label", { cls: "cx-label", text, attrs: { for: htmlFor } })));
+19
View File
@@ -0,0 +1,19 @@
.cx-grid { display: grid !important; gap: var(--sp-5); }
.cx-grid-pair { grid-template-columns: 1fr 1fr; gap: var(--sp-4); margin-block-start: var(--sp-5); }
.cx-grid-cards { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
.cx-tile { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-5) var(--sp-6); border: 0; border-radius: var(--r-5); background: var(--elev-2); color: var(--text); text-align: left; cursor: pointer; }
.cx-tile:hover { background: var(--hover); }
.cx-tile-title { font-size: var(--fs-4); font-weight: var(--fw-medium); }
.cx-tile-sub { font-size: var(--fs-6); color: var(--muted); }
.cx-tile-node { min-block-size: 84px; padding: var(--sp-6) var(--sp-6) var(--sp-5); }
.cx-tile-node .cx-tile-title { font-weight: var(--fw-semibold); padding-inline-end: var(--sp-6); }
.cx-tile-node .cx-tile-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-inline-size: 100%; }
.cx-tile-state { position: absolute; inset-block-start: var(--sp-6); inset-inline-end: var(--sp-6); inline-size: 8px; block-size: 8px; border-radius: var(--r-pill); background: var(--good); }
.cx-tile-state.cx-tile-needs { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 25%, transparent); }
.cx-tile-add { align-items: center; justify-content: center; min-block-size: 84px; background: transparent; border: var(--bw) dashed var(--line); color: var(--muted); }
.cx-tile-add .cx-tile-title { font-weight: var(--fw-medium); }
@media (max-width: 1150px) { .cx-hint-low { display: none; } }
+40
View File
@@ -0,0 +1,40 @@
// Cards and tiles: a rounded group of fields, a pressable tile, and the grid that lays tiles out.
import { define, lookup } from "../internals/register.js";
import { el } from "../internals/el.js";
import { setText } from "../internals/text.js";
/** Related fields on one rounded card. Takes handles, like a stack. */
define("card", "default", ({ children = [], gap = "md" } = {}) => {
const stack = lookup("region", "stack")({ children, gap });
stack.node.classList.add("cx-card");
return stack;
});
/** Tiles in rows. `pair` is two columns; `cards` fills the width with columns at least 190 wide. */
for (const kind of ["pair", "cards"]) {
define("grid", kind, ({ children = [] } = {}) => {
const stack = lookup("region", "stack")({ children, gap: "none" });
stack.node.classList.add("cx-grid", `cx-grid-${kind}`);
return stack;
});
}
const TONES = { ready: "Ready", needs: "Needs attention", skipped: "Skipped: not available" };
function tile(kind, { title, sub, onClick, state, label } = {}) {
if (state !== undefined && !TONES[state]) throw new RangeError(`Unknown tile state "${state}". Known: ${Object.keys(TONES).join(", ")}.`);
const head = el("span", { cls: "cx-tile-title", text: title });
const hint = el("span", { cls: "cx-tile-sub", text: sub });
const dot = state ? el("span", { cls: ["cx-tile-state", `cx-tile-${state}`], attrs: { title: TONES[state] } }) : null;
const node = el("button", { cls: ["cx-tile", `cx-tile-${kind}`, "cx-focusable"], attrs: { type: "button", "aria-label": label },
children: [dot, head, sub != null && sub !== "" ? hint : null].filter(Boolean) });
if (onClick) node.addEventListener("click", onClick);
return { node, setTitle: (v) => setText(head, v), setSub: (v) => setText(hint, v), destroy: () => node.remove() };
}
/** A whole-tile button: a title over one line of hint. */
define("tile", "action", (props = {}) => tile("action", props));
/** A node of the pipeline: title, one line of what is set, and a dot that says whether it is ready. */
define("tile", "node", (props = {}) => tile("node", { state: "ready", ...props }));
/** The empty slot at the end of a group: "+ Add". */
define("tile", "add", (props = {}) => tile("add", props));
@@ -11,7 +11,7 @@ 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";
import { MODULE_RULES, KIT_RULES, FEATURE_RULES } from "../../../../tools/kit_lint/rules.js";
const here = dirname(fileURLToPath(import.meta.url));
const repo = join(here, "..", "..", "..", "..");
@@ -36,6 +36,12 @@ test("the shell never interprets content either", () => {
assert.deepEqual(scan(join(repo, "app", "ui", "shell"), KIT_RULES), []);
});
test("every UI feature is built from the kit and touches the page only through app", () => {
const violations = scan(join(repo, "app", "ui", "features"), FEATURE_RULES);
assert.deepEqual(violations, [],
violations.map((v) => `${v.file.replace(repo, "")}:${v.line} ${v.rule}`).join("\n"));
});
// --- the scanner has to actually catch things ------------------------------
test("a clean module passes", () => {
+3 -3
View File
@@ -8,7 +8,7 @@ const BOXES = new Set(["Story", "Prompt"]); // aria-labels of the promp
export default {
id: "autocomplete",
mount: "menubar.menus",
needs: ["project", "api"],
needs: ["project", "api", "dom"],
setup({ app }) {
const p = app.project;
let library = [], fetched = 0, span = null;
@@ -39,11 +39,11 @@ export default {
const t = e.target;
if (t instanceof HTMLTextAreaElement && BOXES.has(t.getAttribute("aria-label"))) { refresh(); attach(t); }
};
document.addEventListener("focusin", onFocus);
const offFocus = app.dom.onPage("focusin", onFocus);
app.editorSettings.push(() => c.region.stack({ gap: "sm", children: [
c.toggle.default({ label: "Prompt autocomplete", hint: "Suggest matching shortcuts while you type in the Story and scene prompts. Shows the trigger and its prompt.",
checked: p.pref("autocomplete", true), onChange: (v) => p.setPref("autocomplete", v) })] }));
return () => document.removeEventListener("focusin", onFocus);
return offFocus;
},
};
+1 -1
View File
@@ -28,7 +28,7 @@ export function chat(app, own) {
if (!alive) return;
const here = ((app.pipeline && app.pipeline.slots()) || []).some((s) => s.node === NODE);
const list = rounds(), ok = last && last.ok, next = JSON.stringify([p.project && p.project.id, list, last && last.after, here]);
if (!force && (next === key || (page.node.contains(document.activeElement) && document.activeElement.tagName === "TEXTAREA"))) return; // not under the caret
if (!force && (next === key || app.dom.typingIn(page.node, "TEXTAREA"))) return; // not under the caret
key = next;
if (!p.project) return page.set([c.hint.default({ text: "Open a project first." })]);
// Comments would reach nothing: said here, the way Enhance says it (they stay, for a pipeline that has one).
+1 -1
View File
@@ -59,7 +59,7 @@ export function enhance(app, own) {
ps.ensureLoaded().then(draw);
app.api.enhancerEncoders().then((r) => { encoders = { files: r.files || [], families: r.families || [] }; draw(); }).catch(() => {});
// Another project's pipeline, or an edit made elsewhere: redraw, but never under a field being typed in.
const typing = () => page.node.contains(document.activeElement);
const typing = () => app.dom.typingIn(page.node);
own(ps.subscribe(() => { if (!typing()) draw(); }));
app.api.enhancerDefaults().then((d) => { defaults = d; draw(); }).catch(() => {});
const fetchRuns = () => app.api.enhancerRuns().then((r) => { last = (r.runs || []).slice(-1)[0] || null; if (!typing()) draw(); }).catch(() => {});
+6 -5
View File
@@ -1,6 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
import { createDom } from "../../shell/dom.js";
let enhance;
test.before(async () => { setupDom(); ({ enhance } = await import("./enhance.js")); });
@@ -10,7 +11,7 @@ test("the Enhance tab follows the open project's pipeline: no enhancer there, no
let live = [{ id: "e", node: "FunPackEnhancePrompt", inputs: { enabled: true } }], heard;
const ps = { slots: () => live, ensureLoaded: async () => {}, save: async () => {}, subscribe: (fn) => { heard = fn; return () => {}; } };
const api = { enhancerEncoders: async () => ({ files: [], families: [] }), enhancerDefaults: async () => ({}), enhancerRuns: async () => ({ runs: [] }) };
const page = enhance({ pipeline: ps, api }, () => {});
const page = enhance({ dom: createDom(), pipeline: ps, api }, () => {});
document.body.append(page.node);
assert.match(page.node.textContent, /Enhance prompt/);
live = [{ id: "s", node: "FunPackSampler", inputs: {} }]; // another project opened
@@ -23,7 +24,7 @@ test("a run that ends while Enhance is open shows its final prompt without reope
let runs = [], tell;
const api = { enhancerEncoders: async () => ({ files: [], families: [] }), enhancerDefaults: async () => ({}), enhancerRuns: async () => ({ runs }) };
const generate = { subscribe: (fn) => { tell = fn; fn({ phase: "idle" }); return () => {}; } };
const page = enhance({ pipeline: ps, api, generate }, () => {});
const page = enhance({ dom: createDom(), pipeline: ps, api, generate }, () => {});
await new Promise((r) => setTimeout(r, 0));
assert.match(page.node.textContent, /Nothing yet/);
runs = [{ prompt_id: "p", status: "off: the prompt ran as typed", before: "a red fox", after: "a red fox at dusk" }];
@@ -35,7 +36,7 @@ test("a run that ends while Enhance is open shows its final prompt without reope
test("Chat on a pipeline with no enhancer says so instead of taking comments that would reach nothing", async () => {
const { chat } = await import("./chat.js");
let slots = [{ id: "s", node: "Sampler" }], heard;
const app = { project: { project: { id: "P", scenes: [] }, pref: (_k, d) => d, setPref: () => {} }, selection: {}, lastRun: { n: 0 },
const app = { dom: createDom(), project: { project: { id: "P", scenes: [] }, pref: (_k, d) => d, setPref: () => {} }, selection: {}, lastRun: { n: 0 },
api: { enhancerRuns: async () => ({ runs: [] }) }, on: () => () => {}, pipeline: { slots: () => slots, subscribe: (fn) => { heard = fn; return () => {}; } } };
const page = chat(app, () => {});
assert.match(page.node.textContent, /No prompt enhancer here/);
@@ -49,7 +50,7 @@ test("the Encoder pick lists the encoder files, and its family shows only once a
let inputs = { enabled: true }, saved;
const ps = { slots: () => [{ id: "e", node: "FunPackEnhancePrompt", inputs }], ensureLoaded: async () => {}, save: async (b) => { saved = b; inputs = { ...inputs, ...b.inputs.e }; }, subscribe: () => () => {} };
const api = { enhancerEncoders: async () => ({ files: ["gemma4.safetensors"], families: ["stable_diffusion", "ltxv"] }), enhancerDefaults: async () => ({}), enhancerRuns: async () => ({ runs: [] }) };
const page = enhance({ pipeline: ps, api }, () => {});
const page = enhance({ dom: createDom(), pipeline: ps, api }, () => {});
document.body.append(page.node);
await new Promise((r) => setTimeout(r, 0));
assert.match(page.node.textContent, /Encoder/);
@@ -57,7 +58,7 @@ test("the Encoder pick lists the encoder files, and its family shows only once a
assert.doesNotMatch(page.node.textContent, /Encoder family/);
inputs = { enabled: true, encoder_file: "gemma4.safetensors" };
const ps2 = { ...ps, slots: () => [{ id: "e", node: "FunPackEnhancePrompt", inputs }] };
const again = enhance({ pipeline: ps2, api }, () => {});
const again = enhance({ dom: createDom(), pipeline: ps2, api }, () => {});
document.body.append(again.node);
await new Promise((r) => setTimeout(r, 0));
assert.match(again.node.textContent, /Encoder family/);
+2 -2
View File
@@ -30,7 +30,7 @@ const story = (app, own) => {
app.api.storyMarkers().then((r) => { words = r.markers || words; marker = words[0]; sync(); }).catch(() => {});
function sync() {
warn.setText(p.project && clash(p.project, words) ? `A scene's own text contains a cut word (${words.join(", ")}), so the story shows it as two scenes. Remove the word from that scene.` : "");
if (!p.project || document.activeElement === area || timer) return; // never rewrite the box while it is being typed in
if (!p.project || app.dom.focusOn(area) || timer) return; // never rewrite the box while it is being typed in
const next = joinStory(p.project, marker);
if (area.value !== next) box.setValue(next);
}
@@ -63,7 +63,7 @@ const sheets = { story, shortcuts, build, cuts,
export default {
id: "composer",
mount: "timeline.status",
needs: ["project", "api", "pipeline"],
needs: ["project", "api", "pipeline", "dom"],
setup({ host, app }) {
let win = null, owned = [];
const cleanup = () => { owned.forEach((f) => f()); owned = []; };
+4 -6
View File
@@ -32,13 +32,11 @@ export function shortcuts(app, own) {
/** Export / import / new category / delete all. */
function tools(anchor, lib) {
const did = (p) => p.then(draw).catch((err) => tell(err.message));
c.menu.dropdown({ anchor: anchor || document.body, items: [{ id: "export", label: "Export all…" }, { id: "import", label: "Import…" }, { id: "category", label: "New category…" }, { separator: true }, { id: "clear", label: "Delete all", danger: true, disabled: !(lib.shortcuts || []).length }],
c.menu.dropdown({ anchor: anchor || app.dom.body, items: [{ id: "export", label: "Export all…" }, { id: "import", label: "Import…" }, { id: "category", label: "New category…" }, { separator: true }, { id: "clear", label: "Delete all", danger: true, disabled: !(lib.shortcuts || []).length }],
onPick: async (id) => {
if (id === "export") return Object.assign(document.createElement("a"), { href: "/funpack/api/shortcuts/export", download: "funpack_shortcuts.json" }).click();
if (id === "export") return app.dom.download("/funpack/api/shortcuts/export", "funpack_shortcuts.json");
if (id === "import") {
const input = Object.assign(document.createElement("input"), { type: "file", accept: ".json,application/json" });
input.onchange = async () => {
const file = input.files && input.files[0];
const picked = async (file) => {
if (!file) return;
let data;
try { data = JSON.parse(await file.text()); } catch { return tell("That file is not valid JSON."); }
@@ -46,7 +44,7 @@ export function shortcuts(app, own) {
items: [{ id: "merge", label: "Merge into my library" }, { id: "replace", label: "Replace my library (deletes the shortcuts you have now)" }],
onPick: (mode) => did(app.api.importShortcuts(data, mode)) }); // closing the window imports nothing
};
return input.click();
return picked(await app.dom.pickFile(".json,application/json"));
}
if (id === "category") {
const category = await c.modal.prompt({ title: "New category", label: "Category", confirmLabel: "Next" }).result;
+1 -1
View File
@@ -76,7 +76,7 @@ export function variablesPanel(app, own) {
c.hint.default({ text: "Write $name in a prompt; it becomes the variable's text when you generate." }),
]); };
draw();
own(app.on(() => { const typing = page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName); if (!typing && shown !== JSON.stringify([p.project && p.project.id, rows()])) draw(); }));
own(app.on(() => { const typing = app.dom.typingIn(page.node); if (!typing && shown !== JSON.stringify([p.project && p.project.id, rows()])) draw(); }));
return page;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
import { createDom } from "../../shell/dom.js";
let hub;
test.before(async () => { setupDom(); hub = (await import("./index.js")).default; });
@@ -14,7 +15,7 @@ const rig = (phase, extra = {}) => {
waitForTerminal: () => Object.assign(Promise.resolve("done"), { cancel() {} }), ...extra };
const project = { project: doc, get scenes() { return doc.scenes; }, selected: null,
edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc), pref: (k, d) => ((doc.editor_settings || {})[k] ?? d) };
const app = { project, selection: { ids: [] }, lastRun: { n: 0 }, runner: {}, pipeline: { slots: () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: {} }] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} };
const app = { dom: createDom(), project, selection: { ids: [] }, lastRun: { n: 0 }, runner: {}, pipeline: { slots: () => [{ id: "s", roles: [{ at: "generation.seed", input: "seed" }], inputs: {} }] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} };
const host = document.createElement("div");
hub.setup({ host, app });
return { handlers, doc, host, app };
+2 -3
View File
@@ -17,7 +17,7 @@ export const renderFor = (sc, p, media, unitSec, promptId, text) => ({ media, ..
export default {
id: "generate",
mount: "timeline.actions",
needs: ["project", "pipeline", "generate", "api", "selection"],
needs: ["project", "pipeline", "generate", "api", "selection", "dom"],
setup({ host, app }) {
const p = app.project, g = app.generate;
let said = false;
@@ -57,8 +57,7 @@ export default {
let made = 0;
said = false;
try {
const typing = document.activeElement; // a field still being typed in commits first, however Generate was started
if (typing && typing !== document.body && typeof typing.blur === "function") typing.blur();
app.dom.blur(); // a field still being typed in commits first, however Generate was started
await Promise.all((app.beforeRun || []).map((f) => f().catch(() => {}))); // an edit still on its way (the Story box) lands first
// A project's pipeline still going in is the one this run uses, not the last one's.
// a typed value is already live and is never waited on; only a pipeline still going in, or a node change, is
+5 -14
View File
@@ -6,23 +6,14 @@ import { onMediaDrop } from "../../shell/dnd.js";
import { addScene } from "../../shell/edits.js";
import { isVideoClip, isSubclip } from "../../shell/scenes.js";
/** How long a bin file is, from the browser's own reading of it (null when it cannot say). */
const lengthOf = (asset) => new Promise((resolve) => {
const m = document.createElement(asset.kind === "audio" ? "audio" : "video");
const done = (v) => { m.removeAttribute("src"); m.load(); resolve(v); };
m.preload = "metadata";
m.onloadedmetadata = () => done(Number.isFinite(m.duration) ? m.duration : null);
m.onerror = () => done(null);
setTimeout(() => done(null), 8000);
m.src = `/funpack/api/media/${encodeURIComponent(asset.id)}/file`;
});
export default {
id: "imports",
mount: "timeline.toolbar",
needs: ["project", "api", "playhead"],
needs: ["project", "api", "playhead", "dom"],
setup({ host, app }) {
const p = app.project;
const lengthOf = (asset) => app.dom.length(asset.kind, `/funpack/api/media/${encodeURIComponent(asset.id)}/file`); // null when the browser cannot say
async function choose(kind) {
let bin;
@@ -50,10 +41,10 @@ export default {
const at = (e) => (app.timelineView && app.timelineView.timeAt ? app.timelineView.timeAt(e.clientX) : app.playhead.at);
const asset = async (item) => { let list = []; try { list = (await app.api.media()).media || []; } catch { /* the drop does nothing */ } return list.find((m) => m.id === item.id); };
const offs = [
onMediaDrop(".cx-nle-lane.cx-nle-lane-video", async (item, lane, e) => {
onMediaDrop("lane:video", async (item, lane, e, where) => {
const a = await asset(item);
if (!a || !p.project) return;
const clip = e.target.closest && e.target.closest(".cx-nle-clip[data-id]"), id = clip && clip.dataset.id;
const id = where.clipId;
if (a.kind === "video") return add(a);
if (a.kind !== "image") return c.toast.warn({ text: "A sound goes on an audio lane." });
const hit = id && p.scenes.find((s) => s.id === id);
@@ -65,7 +56,7 @@ export default {
const made = p.edit((pr) => { const sc = addScene(pr, "image"); sc.source_image = a.id; return sc; });
if (made) p.select(made.id);
}),
onMediaDrop(".cx-nle-lane.cx-nle-lane-audio", async (item, lane, e) => {
onMediaDrop("lane:audio", async (item, lane, e) => {
const a = await asset(item);
if (!a || a.kind !== "audio" || !p.project) return a && c.toast.warn({ text: "Only a sound can go on an audio lane." });
const seconds = await lengthOf(a);
+3 -2
View File
@@ -1,6 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
import { createDom } from "../../shell/dom.js";
let hub;
test.before(async () => { setupDom(); hub = (await import("./index.js")).default; });
@@ -13,7 +14,7 @@ test("a scene's text changed elsewhere (the Story box) shows in the Prompt box,
setText: () => {}, setScene: () => {}, edit: (fn) => fn(doc) };
const host = document.createElement("div");
document.body.append(host);
hub.setup({ host, app: { project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
hub.setup({ host, app: { dom: createDom(), project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
const box = () => [...host.querySelectorAll("textarea")][0];
assert.equal(box().value, "old");
doc.scenes[0].text = "from story";
@@ -35,7 +36,7 @@ test("typing in the Prompt box, then pressing a button in the panel, does not re
setText: () => {}, setScene: () => {}, edit: (fn) => fn(doc) };
const host = document.createElement("div");
document.body.append(host);
hub.setup({ host, app: { project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
hub.setup({ host, app: { dom: createDom(), project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
const box = host.querySelector("textarea");
box.focus();
doc.scenes[0].text = "typed here"; fire("change"); // the box's own edit, as setText would announce it
+2 -2
View File
@@ -9,7 +9,7 @@ import { gridOf, learnGrid } from "../../shell/frame_grid.js";
export default {
id: "inspector",
mount: "inspector",
needs: ["project"],
needs: ["project", "dom"],
setup({ host, app }) {
const p = app.project;
let tab = "scene", key = "";
@@ -28,7 +28,7 @@ export default {
// The scene's text is in the key, so an edit made elsewhere (the Story box) is shown -- but not while this panel's
// own box is being typed in: that would rebuild it under the caret. It catches up when the focus leaves.
let shownText;
const typing = () => body.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
const typing = () => app.dom.typingIn(body.node);
// Catch up once focus leaves the panel. Focus moving to a button or select inside it is a click under way: rebuilding
// between its mouse-down and mouse-up dropped the click; the change it makes redraws afterwards anyway.
body.node.addEventListener("focusout", (e) => { if (!(e.relatedTarget && body.node.contains(e.relatedTarget))) setTimeout(() => { if (!typing()) draw(); }); });
+1 -1
View File
@@ -45,7 +45,7 @@ function trimRows(p, sc, open) {
}
/** A group of fields on one rounded card. */
export const card = (...children) => { const n = c.region.stack({ gap: "md", children: children.flat().filter(Boolean) }); n.node.classList.add("fp-card"); return n; };
export const card = (...children) => c.card.default({ children: children.flat().filter(Boolean) });
export function sceneRows(p, app) {
const sc = p.selected, open = p.project;
+2 -1
View File
@@ -4,5 +4,6 @@ import { open } from "./window.js";
export default {
id: "log",
mount: "menubar.right",
setup: ({ host }) => host.append(composer.button.sm({ label: "Log", onClick: () => open() }).node),
needs: ["dom"],
setup: ({ host, app }) => host.append(composer.button.sm({ label: "Log", onClick: () => open({ dom: app.dom }) }).node),
};
+4 -7
View File
@@ -16,7 +16,7 @@ const EVERY = 2000;
/** mount({ poll, setFooter, close }) -> { node, destroy }. See tempfiles.js's
* mount() for why setFooter/close are the only things asked for -- `destroy`
* additionally stops the poll, since this one runs a timer nothing else does. */
export function mount({ poll = EVERY, setFooter = () => {}, close = () => {} } = {}) {
export function mount({ dom, poll = EVERY, setFooter = () => {}, close = () => {} }) {
let timer = null;
let follow = true; // stick to the end until the user scrolls off it
let last = "";
@@ -24,10 +24,7 @@ export function mount({ poll = EVERY, setFooter = () => {}, close = () => {} } =
const view = composer.code.block({ text: "Reading…" });
const body = composer.region.stack({ gap: "sm", fill: true, children: [view] });
const selecting = () => {
const sel = document.getSelection();
return sel && !sel.isCollapsed && view.node.contains(sel.anchorNode);
};
const selecting = () => dom.selectingIn(view.node);
// Following the end is the default and stops being it the moment someone
// scrolls up -- otherwise reading anything older than two seconds is a fight.
@@ -75,10 +72,10 @@ export function mount({ poll = EVERY, setFooter = () => {}, close = () => {} } =
return { node: body.node, destroy: () => { clearInterval(timer); timer = null; body.destroy(); } };
}
export function open({ poll = EVERY } = {}) {
export function open({ dom, poll = EVERY }) {
let window_ = null;
const content = mount({
poll,
dom, poll,
setFooter: (f) => window_ && window_.setFooter(f),
close: () => window_ && window_.close("done"),
});
+2 -2
View File
@@ -9,7 +9,7 @@ const base = (id) => `/funpack/api/media/${encodeURIComponent(id)}`;
export default {
id: "media",
mount: "assets.media",
needs: ["project", "api"],
needs: ["project", "api", "dom"],
setup({ host, app }) {
const p = app.project, api = app.api;
let items = [];
@@ -82,7 +82,7 @@ export default {
if (!name || !name.trim()) return;
try { await api.renameMedia(it.id, name.trim()); await refresh(); } catch (err) { tell(err.message); }
},
export: (it) => { const m = items.find((x) => x.id === it.id); Object.assign(document.createElement("a"), { href: `${base(it.id)}/file`, download: m ? m.name : it.id }).click(); },
export: (it) => { const m = items.find((x) => x.id === it.id); app.dom.download(`${base(it.id)}/file`, m ? m.name : it.id); },
delete: async (it) => {
const name = (items.find((m) => m.id === it.id) || {}).name || "this file";
if (!(await c.modal.dialogue({ title: "Delete media", message: `Delete “${name}” from the bin for good? Scenes in other projects that use it will lose it too.`, tone: "danger", confirmLabel: "Delete" }).result)) return;
+3 -4
View File
@@ -10,7 +10,7 @@ const sceneIds = (p) => segments(p).filter((s) => s.kind === "scene").map((s) =>
export default {
id: "menus",
mount: "menubar.menus",
needs: ["project", "selection"],
needs: ["project", "selection", "dom"],
setup({ host, app }) {
const p = app.project, sel = app.selection;
const ask = (title, value, confirmLabel) => c.modal.prompt({ title, label: "Name", value, confirmLabel }).result;
@@ -24,9 +24,8 @@ export default {
const actions = {
save: async () => {
await p.flush();
const open = p.project, link = Object.assign(document.createElement("a"), { download: `${open.name || "project"}.json`,
href: URL.createObjectURL(new Blob([JSON.stringify(open, null, 2)], { type: "application/json" })) });
link.click(); URL.revokeObjectURL(link.href);
const open = p.project;
app.dom.saveBlob(new Blob([JSON.stringify(open, null, 2)], { type: "application/json" }), `${open.name || "project"}.json`);
},
load: () => choose("Load project file", "a FunPack project .json", ".json,application/json", false, async ([file]) => {
let data; try { data = JSON.parse(await file.text()); } catch { throw new Error("That is not a project file."); }
+2 -2
View File
@@ -58,10 +58,10 @@ export default {
c.modal.dialogue({ title: "Remove lane", message: `Remove “${top.label}” and everything on it?`, tone: "danger", confirmLabel: "Remove" }).result.then((yes) => yes && edit((pr) => ov.removeLane(pr, top.id)));
} }) });
host.append(add.node);
const offDrop = onMediaDrop(".cx-nle-lane.cx-nle-lane-overlay", async (item, lane, e) => { // a picture dropped on an overlay lane becomes an overlay there
const offDrop = onMediaDrop("lane:overlay", async (item, lane, e, where) => { // a picture dropped on an overlay lane becomes an overlay there
if (item.kind !== "image" || !p.project) return c.toast.warn({ text: "Only a picture can be laid over the cut." });
let name = "Image"; try { name = ((await app.api.media()).media || []).find((m) => m.id === item.id).name; } catch { /* keep the generic label */ }
const row = [...document.querySelectorAll(".cx-nle-lane.cx-nle-lane-overlay")].indexOf(lane), lanes = ov.lanesOf(p.project), laneId = (lanes[lanes.length - 1 - row] || {}).id; // lanes draw top-first
const row = where.row, lanes = ov.lanesOf(p.project), laneId = (lanes[lanes.length - 1 - row] || {}).id; // lanes draw top-first
const sec = app.timelineView && app.timelineView.timeAt ? app.timelineView.timeAt(e.clientX) : app.playhead.at;
edit((pr) => ov.addImage(pr, item.id, name, sec, laneId));
});
+2 -3
View File
@@ -9,7 +9,7 @@ import { RENDER, runJob } from "./job.js";
export default {
id: "render-selected",
mount: "timeline.toolbar",
needs: ["project", "selection"],
needs: ["project", "selection", "dom"],
setup({ host, app }) {
const p = app.project, sel = app.selection;
const put = inPlace(host);
@@ -33,8 +33,7 @@ export default {
const exportIt = run(async () => {
const name = p.project.name, media = await join(); // named for the project it was started in
if (!media) return;
const link = Object.assign(document.createElement("a"), { href: viewUrl(media), download: `${name}.mp4` });
link.click();
app.dom.download(viewUrl(media), `${name}.mp4`);
});
const toBin = run(async () => {
const name = p.project.name, media = await join();
+2 -2
View File
@@ -9,7 +9,7 @@ import { widgetControl } from "../settings/models.js";
export default {
id: "sampler",
mount: "timeline.status",
needs: ["pipeline"],
needs: ["pipeline", "dom"],
setup({ host, app }) {
const ps = app.pipeline;
let pop = null, specs = {}, unreadable = false;
@@ -74,7 +74,7 @@ export default {
body.set([...unread, ...rows, ...mods.flatMap((m) => [mods.length > 1 ? c.label.section({ text: m.title || m.id }) : null,
...Object.entries(m.settings).filter(([, spec]) => whenSatisfied(values, m.id, spec.when)).map(([name, spec]) => settingRow(m.id, name, spec, values, change))])].filter(Boolean));
};
const typing = () => body.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
const typing = () => app.dom.typingIn(body.node);
const off = ps.subscribe(() => { if (pop && !typing()) describe().then(draw); });
const show = () => { button.node.hidden = !sampling().length && !marked().length && !seeds().length; if (button.node.hidden && pop) pop.close(); };
ps.ensureLoaded().then(show);
+1 -6
View File
@@ -3,11 +3,6 @@ import { composer as c } from "../../composer/composer.js";
const facts = (rows) => rows.filter(([, v]) => v).map(([k, v]) => c.settingsRow.default({ label: k, control: c.text.sm({ text: String(v) }) }));
const hero = (name, sub) => {
const node = c.region.stack({ gap: "none", children: [c.brand.default({ name: "" }), c.header.xl({ text: name }), c.hint.default({ text: sub })] });
node.node.classList.add("cx-settings-hero");
return node;
};
export const about = (api) => function mount() {
const body = c.region.stack({ gap: "sm", label: "About" });
@@ -16,7 +11,7 @@ export const about = (api) => function mount() {
const major = String(git.version || "").split(".")[0];
const mem = (sys && sys.memory) || {}, cpu = (sys && sys.cpu) || {}, disk = (sys && sys.disk) || {}, torch = (sys && sys.torch) || {}, gpu = ((sys && sys.gpus) || [])[0];
body.set([
hero(`FunPack${major ? ` ${major}` : ""}`, [git.codename && `“${git.codename}”`, "Cutting Room"].filter(Boolean).join(" · ")),
c.hero.default({ title: `FunPack${major ? ` ${major}` : ""}`, hint: [git.codename && `“${git.codename}”`, "Cutting Room"].filter(Boolean).join(" · ") }),
...facts([["Version", git.version], ["Commit", git.commit && String(git.commit).slice(0, 7)], ["Branch", git.branch]]),
...(sys ? [c.label.section({ text: "Hardware" }),
...facts([["Chip", cpu.name], ["Memory", mem.total_gb != null && `${mem.available_gb} GB free of ${mem.total_gb} GB`],
+1 -1
View File
@@ -28,7 +28,7 @@ export const engine = (app) => function mount() {
}
draw();
ps.ensureLoaded().then(draw);
const typing = () => page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
const typing = () => app.dom.typingIn(page.node);
page.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); }));
const off = ps.subscribe(() => { if (!typing()) draw(); });
return { node: page.node, destroy: () => { off(); page.node.remove(); } };
+1 -1
View File
@@ -19,7 +19,7 @@ export function settingRow(id, name, spec, values, onChange) {
control = slider ? c.slider.readout({ label, value, min: spec.min, max: spec.max, step, precision: spec.type === "int" ? 0 : 2, onCommit: set })
: c.number.md({ label, value, min: spec.min, max: spec.max, step, precision: spec.type === "int" ? 0 : undefined, onChange: set });
} else if (spec.type === "enum") control = c.select.md({ label, value, options: (spec.options || []).map((o) => ({ value: o.value, label: o.label || o.value })), onChange: set });
else if (spec.type === "color") control = c.color.swatch({ label, value: value || "#000000", onChange: set });
else if (spec.type === "color") control = c.color.swatch({ label, value: value || undefined, onChange: set });
else control = (spec.type === "multiline" ? c.textarea.md({ label, value: value || "", rows: 3, onCommit: set }) : c.input.md({ label, value: value || "", onCommit: set }));
return c.settingsRow.default({ label, hint, control });
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { shotMemory } from "./shotmemory.js";
export default {
id: "settings-sections",
mount: "settings",
needs: ["api", "theme", "pipeline", "maintenance"],
needs: ["api", "theme", "pipeline", "maintenance", "dom"],
setup({ host, app }) {
const add = (id, title, subtitle, icon, tone, mount, group = "", keywords = "") => host.add({ id, group, title, subtitle, icon, tone, mount, keywords: `${keywords} ${title}` });
add("about", "About FunPack", "", "◎", "accent", about(app.api), "", "version commit branch cpu memory gpu disk python torch");
+10 -25
View File
@@ -156,25 +156,13 @@ export const models = (app) => function mount() {
const mine = (t, id) => t.startsWith(`${id}:`) || t.startsWith(`${id}.`)
|| ((slotsNow().find((s) => s.id === id) || {}).roles || []).some((r) => r.label && t.startsWith(`${r.label} is `));
const nodeCard = (slot) => {
const node = document.createElement("button");
node.type = "button"; node.className = "fp-node-card cx-focusable";
const skipped = Boolean((ps.skipped && ps.skipped() || {})[slot.id]); // optional, and not available: the run goes without it
const needs = !skipped && (specs[slot.node] === null || ps.incomplete().some((t) => mine(t, slot.id)));
const head = Object.assign(document.createElement("span"), { className: "fp-node-title", textContent: label(slot) });
const dot = Object.assign(document.createElement("span"), { className: `fp-node-state${needs ? " fp-needs" : ""}`, title: skipped ? "Skipped: not available" : needs ? "Needs attention" : "Ready" });
const sub = Object.assign(document.createElement("span"), { className: "fp-node-sub", textContent: skipped ? "skipped: not available" : summary(slot) });
node.append(dot, head, sub);
node.addEventListener("click", () => { openId = slot.id; draw(); });
return { node };
};
const addCard = () => {
const node = document.createElement("button");
node.type = "button"; node.className = "fp-node-card fp-node-add cx-focusable";
node.append(Object.assign(document.createElement("span"), { className: "fp-node-title", textContent: "+ Add a node" }),
Object.assign(document.createElement("span"), { className: "fp-node-sub", textContent: `to ${group}` }));
node.addEventListener("click", async () => { const cls = await pickNode(api, `Add a node to ${group}`); if (cls) structural({ action: "add", node: cls, group }); });
return { node };
return c.tile.node({ title: label(slot), sub: skipped ? "skipped: not available" : summary(slot), state: skipped ? "skipped" : needs ? "needs" : "ready",
onClick: () => { openId = slot.id; draw(); } });
};
const addCard = () => c.tile.add({ title: "+ Add a node", sub: `to ${group}`,
onClick: async () => { const cls = await pickNode(api, `Add a node to ${group}`); if (cls) structural({ action: "add", node: cls, group }); } });
// A problem that names no slot first (a loop: "a feeds b feeds a") has no card of its own: its words are shown as they are,
// and every slot it names counts as broken, so the simple view cannot hide it.
@@ -203,7 +191,7 @@ export const models = (app) => function mount() {
specs = {}; await describe(); say(r.refused.length ? `Not changed: ${r.refused[0]}` : "");
}
async function importWorkflow() {
const file = await new Promise((resolve) => { const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,application/json" }); i.onchange = () => resolve(i.files[0]); i.oncancel = () => resolve(null); i.click(); });
const file = await app.dom.pickFile(".json,application/json");
if (!file) return;
let got;
try { got = await api.importWorkflow(JSON.parse(await file.text())); } catch (err) { return say(err instanceof SyntaxError ? "That file is not valid JSON." : err.message); }
@@ -216,9 +204,8 @@ export const models = (app) => function mount() {
async function card() {
let url;
try { url = URL.createObjectURL(await api.settingsCard(slotsNow(), app.project.project && app.project.project.name, app.theme && app.theme.get ? app.theme.get() : "dark")); } catch (err) { return say(`Could not render the card: ${err.message}`); }
const img = Object.assign(document.createElement("img"), { src: url, alt: "Pipeline settings", style: "max-width:100%" });
const win = c.modal.generic({ title: "Pipeline settings", size: "lg", body: { node: img }, onClose: () => URL.revokeObjectURL(url) });
win.setFooter({ actions: [c.button.sm({ label: "Save PNG", tone: "primary", onClick: () => Object.assign(document.createElement("a"), { href: url, download: "funpack-settings.png" }).click() })] });
const win = c.modal.generic({ title: "Pipeline settings", size: "lg", body: c.viewer.media({ src: url, kind: "image" }), onClose: () => URL.revokeObjectURL(url) });
win.setFooter({ actions: [c.button.sm({ label: "Save PNG", tone: "primary", onClick: () => app.dom.download(url, "funpack-settings.png") })] });
}
function draw() {
@@ -231,8 +218,7 @@ export const models = (app) => function mount() {
if (openId && !open) openId = null; // removed or swapped away
if (open) {
const back = c.button.sm({ label: `‹ ${open.group || "Other"}`, tone: "ghost", onClick: () => { openId = null; draw(); } });
const detail = c.region.stack({ gap: "md", children: slotBlock(open) });
detail.node.classList.add("fp-card");
const detail = c.card.default({ children: slotBlock(open) });
return page.set([back, note ? c.banner.warn({ text: note }) : null, ...status(open.id), detail].filter(Boolean));
}
let everything = false;
@@ -240,10 +226,9 @@ export const models = (app) => function mount() {
const show = (on) => { try { on ? localStorage.setItem(ALL_KEY, "1") : localStorage.removeItem(ALL_KEY); } catch { /* this visit only */ } draw(); };
const loose = unclaimed();
const chosen = slots.filter((x) => needsChoice(x, specs[x.node], specs[x.node] === null || ps.incomplete().some((t) => mine(t, x.id)) || namedIn(x.id, loose)));
const grid = c.region.stack({ gap: "none", children: everything
const grid = c.grid.cards({ children: everything
? [...slots.filter((x) => (x.group || "Other") === group).map(nodeCard), group ? addCard() : null].filter(Boolean)
: chosen.map(nodeCard) });
grid.node.classList.add("fp-node-grid");
const rest = slots.length - chosen.length;
page.set([tools(), note ? c.banner.warn({ text: note }) : null, ...status(),
!slots.length ? c.emptyState.default({ icon: "⬡", title: "No pipeline loaded", hint: "Is ComfyUI reachable? Or start from a model's starting point above." }) : null,
@@ -252,7 +237,7 @@ export const models = (app) => function mount() {
grid].filter(Boolean));
}
// A redraw while a box has focus would eat what is being typed: it waits for the focus to leave.
const typing = () => page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
const typing = () => app.dom.typingIn(page.node);
page.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); }));
draw();
ps.ensureLoaded().then(async () => { opened = ps.snapshot(); await describe(); draw(); });
+12 -11
View File
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createDom } from "../../shell/dom.js";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let models;
@@ -11,7 +12,7 @@ test("Revert goes back to the pipeline of the project now open, not the one open
const ps = { slots: () => [{ id: "model", node: "L", inputs: { file: live } }], snapshot: () => ({ slots: [{ id: "model", node: "L", inputs: { file: live } }] }),
ensureLoaded: async () => {}, subscribe: (fn) => { heard = fn; return () => {}; }, restore: async (snap) => { restored = snap.slots[0].inputs.file; return { refused: [] }; },
loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => [], refused: () => [], queueable: () => true, removedIds: () => [], offered: () => [] };
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes: { L: { widgets: [] } } }) } })();
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes: { L: { widgets: [] } } }) } })();
document.body.append(page.node);
await new Promise((r) => setTimeout(r, 0));
live = "B";
@@ -27,12 +28,12 @@ test("a node the server says needs something shows the orange dot, whichever way
const ps = { slots: () => slots, snapshot: () => ({ slots }), ensureLoaded: async () => {}, subscribe: () => () => {}, restore: async () => ({ refused: [] }),
loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => ["model.ckpt_name is '', which is not one of: a", "Length is 5000, above the largest 4096 it takes"],
refused: () => [], queueable: () => false, removedIds: () => [], offered: () => [] };
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes: { L: { widgets: [] }, S: { widgets: [] } } }) } })();
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes: { L: { widgets: [] }, S: { widgets: [] } } }) } })();
document.body.append(page.node);
await new Promise((r) => setTimeout(r, 0));
const dots = [...page.node.querySelectorAll(".fp-node-state")];
const dots = [...page.node.querySelectorAll(".cx-tile-state")];
assert.equal(dots.length, 2);
assert.ok(dots.every((d) => d.classList.contains("fp-needs")));
assert.ok(dots.every((d) => d.classList.contains("cx-tile-needs")));
});
test("a file input nothing has chosen shows “choose”, not the first file, and the one file can be picked", async () => {
@@ -55,11 +56,11 @@ test("the simple view shows only nodes that ask for a choice (files, sampler, br
loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => [], refused: () => [], queueable: () => true, removedIds: () => [], offered: () => [] };
const nodes = { FunPackDiffusionModelLoader: { widgets: [{ name: "model_name", type: "COMBO", choices: ["h3.safetensors"] }] },
FunPackSampler: { widgets: [{ name: "steps", type: "INT" }] }, FunPackPromptMarkup: { widgets: [{ name: "text", type: "STRING" }] } };
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes }) } })();
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes }) } })();
document.body.append(page.node);
await new Promise((r) => setTimeout(r, 0));
const cards = () => [...page.node.querySelectorAll(".fp-node-card .fp-node-title")].map((n) => n.textContent).join("|");
assert.equal(page.node.querySelectorAll(".fp-node-card").length, 2, cards());
const cards = () => [...page.node.querySelectorAll(".cx-tile-node .cx-tile-title")].map((n) => n.textContent).join("|");
assert.equal(page.node.querySelectorAll(".cx-tile-node").length, 2, cards());
assert.match(page.node.textContent, /1 more only connect features/);
[...page.node.querySelectorAll(".cx-toggle-row")].find((r) => /Show all nodes/.test(r.textContent)).querySelector("input").click();
await new Promise((r) => setTimeout(r, 0));
@@ -73,10 +74,10 @@ test("a problem that names no slot first (a loop) still shows its nodes in the s
loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => ["a feeds b feeds a: a slot cannot end up feeding itself"],
refused: () => [], queueable: () => false, removedIds: () => [], offered: () => [] };
const nodes = { PlumbA: { widgets: [] }, PlumbB: { widgets: [] }, PlumbC: { widgets: [] } };
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes }) } })();
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes }) } })();
document.body.append(page.node);
await new Promise((r) => setTimeout(r, 0));
assert.equal(page.node.querySelectorAll(".fp-node-card").length, 2, "a and b, not c");
assert.equal(page.node.querySelectorAll(".cx-tile-node").length, 2, "a and b, not c");
assert.match(page.node.textContent, /cannot end up feeding itself/);
page.node.remove();
});
@@ -88,10 +89,10 @@ test("a field still on screen for a node that was swapped away does not put its
save: async (b) => { saved.push(b); }, loading: () => false, loadError: () => null, saveNotes: () => [], incomplete: () => [], refused: () => [],
queueable: () => true, removedIds: () => [], offered: () => [] };
const nodes = { FunPackSampler: { widgets: [{ name: "steps", type: "INT", min: 1, max: 100 }] }, Other: { widgets: [] } };
const page = models({ pipeline: ps, api: { describeNodes: async () => ({ nodes }), packProviders: async () => ({}) } })();
const page = models({ dom: createDom(), pipeline: ps, api: { describeNodes: async () => ({ nodes }), packProviders: async () => ({}) } })();
document.body.append(page.node);
await new Promise((r) => setTimeout(r, 0));
page.node.querySelector(".fp-node-card").click(); // open it
page.node.querySelector(".cx-tile-node").click(); // open it
const field = page.node.querySelector("input[type=number]");
slots = [{ id: "k", node: "Other", inputs: {} }]; // the swap's answer lands; the field stays
field.value = "20";
+1 -1
View File
@@ -11,7 +11,7 @@ export const system = (app) => function mount() {
m.hints().then((h) => rows.forEach((r) => {
if (!r.hint) return;
r.button.setDisabled(!h.ok || (r.hint === "rollback" && !h.rollbackOk));
r.node.node.querySelector(".cx-hint").textContent = h[r.hint];
r.node.setHint(h[r.hint]);
}));
return c.region.stack({ gap: "sm", children: rows.map((r) => r.node) });
};
+2 -4
View File
@@ -20,8 +20,7 @@ export const taste = (app) => function mount() {
try {
const r = await fetch(app.api.tasteKeyUrl(name));
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).why || `The server refused (${r.status}).`);
const link = Object.assign(document.createElement("a"), { download: `${name}.zip`, href: URL.createObjectURL(await r.blob()) });
link.click(); URL.revokeObjectURL(link.href);
app.dom.saveBlob(await r.blob(), `${name}.zip`);
} catch (err) { tell(err.message); }
};
async function bring([file]) {
@@ -49,8 +48,7 @@ export const taste = (app) => function mount() {
try {
const r = await fetch(app.api.blockInfluenceExportUrl(key));
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).why || `The server refused (${r.status}).`);
const link = Object.assign(document.createElement("a"), { download: `${key}.block_influence.pt`, href: URL.createObjectURL(await r.blob()) });
link.click(); URL.revokeObjectURL(link.href);
app.dom.saveBlob(await r.blob(), `${key}.block_influence.pt`);
} catch (err) { tell(err.message); }
};
const clearInfluence = async (key) => {
+3 -2
View File
@@ -3,8 +3,9 @@ import { mount } from "./view.js";
export default {
id: "temp",
mount: "settings",
setup: ({ host }) => host.add({
needs: ["dom"],
setup: ({ host, app }) => host.add({
id: "temp", group: "System", title: "Temp Files", subtitle: "Where a file went when it did not land in the bin.",
keywords: "temp files output directory", icon: "▥", mount,
keywords: "temp files output directory", icon: "▥", mount: (ctx) => mount({ ...ctx, dom: app.dom }),
}),
};
+2 -2
View File
@@ -6,7 +6,7 @@ const ICON = { image: "▦", video: "▶", audio: "♪" };
const url = (f) => `/view?${new URLSearchParams({ filename: f.filename, subfolder: f.subfolder || "", type: "temp" })}`;
const size = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`);
export function mount({ setFooter, close }) {
export function mount({ dom, setFooter, close }) {
const body = composer.region.stack({ gap: "sm", fill: true, label: "Temp files" });
async function load() {
@@ -27,7 +27,7 @@ export function mount({ setFooter, close }) {
hint: `${f.kind} · ${size(Number(f.size) || 0)} · ${new Date((Number(f.mtime) || 0) * 1000).toLocaleTimeString()}`,
thumb: f.kind === "image" ? url(f) : null,
})),
onActivate: (cell) => window.open(url(files.find((f) => id(f) === cell.id)), "_blank", "noopener"),
onActivate: (cell) => dom.openTab(url(files.find((f) => id(f) === cell.id))),
})
: composer.emptyState.default({ icon: "▤", title: "Nothing in the temp directory", hint: payload.detail || "" })]);
setFooter({ note: payload.path || "", actions: [
+1 -2
View File
@@ -97,8 +97,7 @@ const zoom = {
needs: ["say"],
setup({ host, app }) {
const b = (label, what, title) => c.button.sm({ label, tone: "ghost", title, onClick: () => app.say(what) }).node;
const keys = c.hint.default({ text: "J back 1 s · K pause · L play · S split · I / O jump to clip start / end · + − zoom" }).node;
keys.classList.add("fp-keyhint"); // the first thing to go when the row runs out of room
const keys = c.hint.default({ text: "J back 1 s · K pause · L play · S split · I / O jump to clip start / end · + − zoom", low: true }).node;
host.append(keys,
b("−", "zoom.out", "Zoom out"), b("+", "zoom.in", "Zoom in"), b("fit", "zoom.fit", "Fit the cut to the window"));
},
+2 -11
View File
@@ -3,15 +3,7 @@ import { composer as c } from "../../composer/composer.js";
import { list } from "../../shell/project.js";
const SEEN = "funpack_welcomed";
// A tile: title over its one-line hint, the whole tile pressable.
const card = (title, hint, onClick) => {
const node = document.createElement("button");
node.type = "button"; node.className = "fp-tile cx-focusable";
const t = document.createElement("span"), h = document.createElement("span");
t.className = "fp-tile-title"; t.textContent = title; h.className = "fp-tile-hint"; h.textContent = hint;
node.append(t, h); node.addEventListener("click", onClick);
return { node };
};
const card = (title, sub, onClick) => c.tile.action({ title, sub, onClick });
export default {
id: "welcome",
@@ -37,10 +29,9 @@ export default {
if (win) return;
const recent = app.project.project;
const done = () => { markSeen(); if (win) win.close("done"); };
const tiles = c.region.stack({ gap: "sm", children: [
const tiles = c.grid.pair({ children: [
card("Update", "Pull the newest version from origin, install missing requirements and restart ComfyUI (after a running generation finishes)", () => app.maintenance.update()), card("Switch branch", "Pick another branch", () => app.maintenance.switchBranch()),
card("Restart ComfyUI", "Reload the server without updating", () => app.maintenance.restart()), card("Rollback update", "Undo the last update", () => app.maintenance.rollback())] });
tiles.node.classList.add("fp-tiles");
win = c.modal.generic({
title: "Welcome to FunPack", size: "lg",
body: c.region.stack({ gap: "md", children: [
-19
View File
@@ -47,22 +47,3 @@
:root.fp-peek .fp-peek-strip:hover { color: var(--text); }
:root.fp-peek.fp-peek-open .fp-peek-strip { display: none; }
:root.fp-peek .fp-split-bottom { display: none; }
/* Welcome: the maintenance actions as a 2x2 of tiles */
.fp-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); margin-block-start: var(--sp-5); }
.fp-tile { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: var(--sp-5) var(--sp-6); border: 0; border-radius: var(--r-5); background: var(--elev-2); color: var(--text); text-align: left; cursor: pointer; }
.fp-tile:hover { background: var(--hover); }
.fp-tile-title { font-size: var(--fs-4); font-weight: var(--fw-medium); }
.fp-tile-hint { font-size: var(--fs-6); color: var(--muted); }
@media (max-width: 1150px) { .fp-keyhint { display: none; } }
/* Models & Pipeline: every node is a card in its group */
.fp-node-grid { display: grid !important; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: var(--sp-5); }
.fp-node-card { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-2); min-block-size: 84px; padding: var(--sp-6) var(--sp-6) var(--sp-5); border: 0; border-radius: var(--r-5); background: var(--elev-2); color: var(--text); text-align: left; cursor: pointer; }
.fp-node-card:hover { background: var(--hover); }
.fp-node-title { font-size: var(--fs-4); font-weight: var(--fw-semibold); padding-inline-end: var(--sp-6); }
.fp-node-sub { font-size: var(--fs-6); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-inline-size: 100%; }
.fp-node-state { position: absolute; inset-block-start: var(--sp-6); inset-inline-end: var(--sp-6); inline-size: 8px; block-size: 8px; border-radius: var(--r-pill); background: var(--good); }
.fp-node-state.fp-needs { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 25%, transparent); }
.fp-node-add { align-items: center; justify-content: center; background: transparent; border: var(--bw) dashed var(--line); color: var(--muted); }
.fp-node-add .fp-node-title { padding: 0; font-weight: var(--fw-medium); }
+10 -3
View File
@@ -1,8 +1,14 @@
// Dragging a media-bin tile onto something. The bin says what it carries; whoever owns a drop target says what that means.
export const MEDIA_DRAG = "application/funpack-media";
/** When a bin tile is dropped on an element matching `selector`, call fn({id, kind}, element, event). -> remover. */
export function onMediaDrop(selector, fn, doc = document) {
// Drop targets by name, so a feature never writes a kit class. A raw selector still works (tests use one).
const TARGETS = { "lane:video": ".cx-nle-lane.cx-nle-lane-video", "lane:audio": ".cx-nle-lane.cx-nle-lane-audio", "lane:overlay": ".cx-nle-lane.cx-nle-lane-overlay" };
/** When a bin tile is dropped on a target (a name from TARGETS, or a selector), call
* fn({id, kind}, element, event, { clipId, row }). `clipId` is the clip under the drop, `row` the target's place among its kind
* (top first). -> remover. */
export function onMediaDrop(target_, fn, doc = document) {
const selector = TARGETS[target_] || target_;
const carries = (e) => e.dataTransfer && [...(e.dataTransfer.types || [])].includes(MEDIA_DRAG);
const target = (e) => (doc.querySelector('[role="dialog"]') ? null : e.target.closest && e.target.closest(selector));
const over = (e) => { if (carries(e) && target(e)) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } };
@@ -13,7 +19,8 @@ export function onMediaDrop(selector, fn, doc = document) {
let item;
try { item = JSON.parse(e.dataTransfer.getData(MEDIA_DRAG)); } catch { return; }
e.preventDefault();
fn(item, el, e);
const clip = e.target.closest(".cx-nle-clip[data-id]");
fn(item, el, e, { clipId: clip && clip.dataset ? clip.dataset.id : null, get row() { return [...doc.querySelectorAll(selector)].indexOf(el); } });
};
doc.addEventListener("dragover", over);
doc.addEventListener("drop", drop);
+48
View File
@@ -0,0 +1,48 @@
// The page, as a service. A feature never names `document` or `window`: it asks `app.dom`.
// Only this file and the kit reach the page, so what a feature may do there is this short list.
export function createDom(doc = document) {
const win = doc.defaultView;
const field = /^(input|textarea)$/i;
const on = (target, type, fn, opts) => { target.addEventListener(type, fn, opts); return () => target.removeEventListener(type, fn, opts); };
const link = (href, download) => Object.assign(doc.createElement("a"), { href, download }).click();
return {
/** The caret is in a text field inside `node`: a redraw there would eat keystrokes. `only` narrows it to one tag. */
typingIn(node, only) { const a = doc.activeElement; return !!a && node.contains(a) && (only ? a.tagName === only : field.test(a.tagName)); },
/** `node` itself has the focus. */
focusOn: (node) => doc.activeElement === node,
/** Let go of whatever has the focus, so a field being typed in commits first. */
blur() { const a = doc.activeElement; if (a && a !== doc.body && typeof a.blur === "function") a.blur(); },
/** Listen on the page / the window. Each returns the function that stops listening. */
onPage: (type, fn, opts) => on(doc, type, fn, opts),
onWindow: (type, fn, opts) => on(win, type, fn, opts),
/** A dialog is open, so nothing behind it should react. */
dialogOpen: () => !!doc.querySelector('[role="dialog"]'),
/** Text is being selected inside `node` (a redraw would cancel the drag). */
selectingIn(node) { const s = doc.getSelection(); return !!s && !s.isCollapsed && node.contains(s.anchorNode); },
/** Save a file from a URL the browser may fetch. */
download: (href, name) => link(href, name),
/** Save something built in the page (a Blob). */
saveBlob(blob, name) { const href = URL.createObjectURL(blob); link(href, name); URL.revokeObjectURL(href); },
/** Open a URL in a new tab. */
openTab: (url) => win.open(url, "_blank", "noopener"),
/** Ask for a file with the browser's own chooser -> the File, or null when it was cancelled. */
pickFile: (accept) => new Promise((resolve) => {
const input = Object.assign(doc.createElement("input"), { type: "file", accept });
input.onchange = () => resolve(input.files[0] || null);
input.oncancel = () => resolve(null);
input.click();
}),
/** How long a media file is, from the browser's own reading of it (null when it cannot say). */
length: (kind, src) => new Promise((resolve) => {
const m = doc.createElement(kind === "audio" ? "audio" : "video");
const done = (v) => { m.removeAttribute("src"); m.load(); resolve(v); };
m.preload = "metadata";
m.onloadedmetadata = () => done(Number.isFinite(m.duration) ? m.duration : null);
m.onerror = () => done(null);
setTimeout(() => done(null), 8000);
m.src = src;
}),
/** The page's body, as an anchor for things that float. */
body: doc.body,
};
}
+14
View File
@@ -45,3 +45,17 @@ export const KIT_RULES = [
{ name: "eval", why: "nothing in the kit is compiled at runtime",
test: /\beval\s*\(|new\s+Function\s*\(/ },
];
// A UI feature (app/ui/features) is app code, not a module, but it follows the
// same discipline: it builds with kit elements and reaches the page only through
// `app`. What it may not do is the module list, plus the frame's and the kit's
// private class names. localStorage is allowed (per-browser preferences).
export const FEATURE_RULES = [
...MODULE_RULES.filter((r) => !["reaching-for-globals", "raw-element"].includes(r.name)),
{ name: "document", why: "the page is reached through app.dom, never directly",
test: /(?<![\w./])document\s*[.[,;)]|createElementNS|\[\s*['"`]createElement['"`]\s*\]/ },
{ name: "reaching-for-globals", why: "the page is reached through app.dom, never directly",
test: /\bglobalThis\b|(?<![\w./])window\s*[.[]|\bself\s*\[/ },
{ name: "class-name", why: "classes belong to the kit (cx-) and the frame (fp-); a feature names elements",
test: /\bclassName\b|['"`.][^'"`\n]*\b(?:fp|cx)-[a-z]/ },
];
+1 -2
View File
@@ -34,8 +34,7 @@ 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 });
body.split("\n").forEach((l, at) => { if (rule.test.test(l)) found.push({ rule: rule.name, why: rule.why, line: at + 1 }); });
}
return found;
}