app: a menu bar, a timeline, and the prompt in a window of its own

v4's arrangement, which is the one that reads as an editor: a zone head
holds what acts on THAT zone, so the app grows a bar of its own for what
acts on the app.

* The menu bar. Brand, View and Settings, and whether ComfyUI is still on
  the other end. Only menus with something real in them exist -- an empty
  File menu is a promise the app does not keep.
* The prompt is written in the CONSTRUCTOR, a window. v4 called this the
  Composer; that name is taken here by the element kit, and two things
  called Composer in one repo is one thing nobody can search for. It is
  the longest thing a person writes and the least often read, and a
  permanent third of the centre column is the room the timeline needs.
  The host is built at startup and outlives every opening, so a window
  that opens twice does not throw away what was typed.
* The timeline replaces it, and Generate moves into its head with what
  the run is doing at the far end of the same head. No bar across the
  bottom: one that spans the window belongs to no region.

Four faults found on the way, three of them mine and one much older:

* THE OVERLAY ROOT WAS A STACKING CONTEXT. It documents at length that it
  must never be one -- and then set `position: fixed`, which makes one on
  its own. So the whole z ladder was measured inside that single element:
  an open menu at z 500 painted UNDER a button at z 1. Every overlay in
  the app was one positive z-index away from being buried, and the only
  reason nobody had seen it is that almost nothing claims a z-index. The
  root is static and zero-sized now; every layer in it is fixed already.
* Generate's hover ran `filter: brightness()`, which lightens the LABEL
  along with the fill under it -- the one thing that had to stay readable
  lost contrast in both themes. Only the fill moves now, and which way it
  moves depends on the theme: dark's accent carries near-black text so it
  goes lighter, light's carries white so it goes darker. Measured: no
  gradient stop under 4.1:1, hovered or at rest.
* A fixed-height head could not shrink below the width of everything in
  it, and a head now holds a title, three controls and a status. That
  min-content became the centre column's, so a 768px window laid out
  878px wide and scrolled sideways. It wraps now, as v4's does.
* A CSS patch of mine silently did nothing because its anchor text had
  moved and I had left the assertion off that one replacement. Every
  patch asserts now.
This commit is contained in:
digital-garbage
2026-08-30 22:11:53 +03:00
parent 16eec81a03
commit 62f92ecaf8
24 changed files with 588 additions and 115 deletions
+1
View File
@@ -38,6 +38,7 @@ async function start() {
const page = build(root, {
onGenerate: () => session.generate(),
onCancel: () => run.cancel(),
onConstructor: () => page.constructor.open(),
onPipeline: () => openPipeline({
load, describe, check, search,
onApply: (next) => {
+1
View File
@@ -139,6 +139,7 @@ export const DEMOS = {
control: c.button.md({ label: "Reset", tone: "danger" }) }),
],
}),
"brand.default": { name: "FunPack" },
"panel.default": (c) => ({
title: "Scene 3",
actions: [c.iconButton.sm({ icon: "✎", label: "Rename" }), c.iconButton.sm({ icon: "✕", label: "Remove" })],
+9 -3
View File
@@ -22,9 +22,15 @@
color: var(--on-accent); font-weight: var(--fw-bold);
box-shadow: var(--glow-accent);
}
/* Brightened rather than repainted: a second accent colour for hover is a
second thing to keep in step with the first, and this one cannot drift. */
.cx-btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
/* The SURFACE brightens, not the button.
`filter: brightness()` applies to the whole element, text included -- so the
label was lightened along with the fill under it and the one thing that had
to stay readable lost contrast in both themes. The label's colour is fixed
and only the fill moves, one step up the same accent ramp. */
.cx-btn-primary:hover:not(:disabled) {
background: var(--grad-accent-hover); border-color: var(--accent-hover-edge);
}
.cx-btn-primary:active:not(:disabled) { background: var(--accent-lo); }
.cx-btn-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--line)); }
.cx-btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: var(--danger); }
+26 -5
View File
@@ -50,6 +50,8 @@
border: var(--bw) solid var(--line); border-radius: var(--r-4);
background: var(--elev-1); overflow: hidden;
box-shadow: var(--shadow-1);
/* A zone is as wide as it is given, never as wide as its head wants to be. */
min-inline-size: 0;
/* A column, so a panel given a height passes it to its body. Without this a
viewer asking for 100% of its parent got 100% of `auto` and collapsed to
one line, inside a panel that was the right size around it. */
@@ -66,14 +68,22 @@
.cx-zone { border: 0; border-radius: 0; box-shadow: none; }
.cx-panel-head {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-5);
padding: 0 var(--sp-6); border-bottom: var(--bw) solid var(--line);
display: flex; align-items: center; gap: var(--sp-2) var(--sp-5);
padding: var(--sp-1) var(--sp-6); border-bottom: var(--bw) solid var(--line);
background: var(--grad-band);
block-size: var(--menubar-h); flex: none;
/* A MINIMUM height, and it wraps. Fixed, it could not shrink below the width
of everything in it -- and a head now holds a title, two or three controls
and a status. That min-content width became the whole centre column's, so a
768px window laid out 878px wide and scrolled sideways. v4 hit this and
landed in the same place: the right-hand block wraps, the head grows a
line, and nothing is ever clipped. */
min-block-size: var(--menubar-h); flex: none; flex-wrap: wrap;
min-inline-size: 0;
}
.cx-panel-title {
font-family: var(--font-display); font-size: var(--fs-3); font-weight: var(--fw-semibold);
display: flex; align-items: center; gap: var(--sp-3);
min-inline-size: 0; white-space: nowrap;
}
/* One accent mark per panel. Carried over from v4, where it is what makes a
head read as the top of something rather than as a strip of grey. */
@@ -81,7 +91,14 @@
content: ""; flex: none; inline-size: 5px; block-size: 5px;
border-radius: var(--r-pill); background: var(--accent);
}
.cx-panel-actions { display: flex; gap: var(--sp-2); }
.cx-panel-actions { display: flex; align-items: center; gap: var(--sp-2); }
/* Whatever the zone has to say, at the far end of its own head: a count, a
duration, where a run has got to. Quiet by default -- it is read at a glance
and never pressed. */
.cx-panel-status {
margin-inline-start: auto; display: flex; align-items: center; gap: var(--sp-4);
min-inline-size: 0; font-size: var(--fs-6); color: var(--faint);
}
/* A column with a gap, not a block: two things put in a body by two different
parts of the app had whatever space their own line boxes happened to leave,
which is how a prompt box ended up 8px under one label and 3px under the
@@ -95,7 +112,11 @@
.cx-panel-body > .cx-viewer, .cx-panel-body > .cx-stack-fill { flex: 1 1 auto; }
.cx-panel-flush > .cx-panel-body { padding: 0; }
.cx-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
.cx-toolbar { display: flex; align-items: center; gap: var(--sp-2); min-inline-size: 0; }
.cx-toolbar-trailing {
margin-inline-start: auto; display: flex; align-items: center; gap: var(--sp-3);
font-size: var(--fs-6); color: var(--muted);
}
.cx-action-bar {
display: flex; align-items: center; gap: var(--sp-6);
+17 -4
View File
@@ -78,11 +78,17 @@ define("group", "default", ({ label, rows = [], hint } = {}) => {
* cut into areas, not a tray of cards with the desk showing between them --
* which is what an editing suite looks like, and what v4 was.
*/
const panel = (variant) => ({ title, actions = [], body, flush = false } = {}) => {
const head = (title || actions.length)
const panel = (variant) => ({ title, actions = [], status = [], body, flush = false } = {}) => {
// Title, then what ACTS on this zone, then -- pushed to the far end -- what
// this zone has to SAY about itself. That order is the whole difference
// between an editing suite and a dashboard: the controls for a region live
// in that region's own head, next to the thing they change, instead of in one
// bar at the bottom that acts on whatever happens to be in front.
const head = (title || actions.length || status.length)
? el("header", { cls: "cx-panel-head", children: [
title ? el("h2", { cls: "cx-panel-title", text: title }) : null,
actions.length ? el("div", { cls: "cx-panel-actions", children: actions.map((a) => nodeOf(a, `panel.${variant}`)) }) : null,
status.length ? el("div", { cls: "cx-panel-status", children: status.map((a) => nodeOf(a, `panel.${variant} status`)) }) : null,
].filter(Boolean) })
: null;
const content = el("div", { cls: "cx-panel-body", children: body ? nodeOf(body, `panel.${variant}`) : null });
@@ -116,9 +122,16 @@ define("region", "stack", ({ children = [], gap = "md", label, fill = false } =
return { node, set, destroy: () => node.remove() };
});
define("toolbar", "default", ({ items = [], label } = {}) =>
/** A bar has a start and an end: what you press, and what it tells you. */
define("toolbar", "default", ({ items = [], trailing = [], label } = {}) =>
({ node: el("div", { cls: "cx-toolbar", attrs: { role: "toolbar", "aria-label": label },
children: items.map((i) => nodeOf(i, "toolbar.default")) }),
children: [
...items.map((i) => nodeOf(i, "toolbar.default")),
trailing.length
? el("div", { cls: "cx-toolbar-trailing",
children: trailing.map((i) => nodeOf(i, "toolbar.default trailing")) })
: null,
].filter(Boolean) }),
destroy() { this.node.remove(); } }));
/**
+13
View File
@@ -29,3 +29,16 @@
padding: var(--sp-4) var(--sp-5); margin: 0;
overflow-x: auto; /* wide code scrolls itself, never the page */
}
/* The one place the app names itself. The mark carries a little of its own
light, which is what stops a 5px dot reading as a bullet point. */
.cx-brand { display: inline-flex; align-items: center; gap: var(--sp-3); }
.cx-brand-mark {
inline-size: 9px; block-size: 9px; border-radius: var(--r-pill);
border: 2px solid var(--accent);
box-shadow: 0 0 6px var(--accent-soft);
}
.cx-brand-name {
font-family: var(--font-display); font-weight: var(--fw-bold);
font-size: var(--fs-3); letter-spacing: .2px; color: var(--text);
}
+9
View File
@@ -34,6 +34,15 @@ define("label", "field", ({ text, for: htmlFor } = {}) =>
define("label", "section", ({ text } = {}) =>
handle(el("div", { cls: "cx-eyebrow", text })));
/** Who this is. One mark and one word, in the display face, at the far left of
* the menu bar -- the only place in the app that names the app. */
define("brand", "default", ({ name = "FunPack" } = {}) =>
({ node: el("span", { cls: "cx-brand", children: [
el("span", { cls: "cx-brand-mark", attrs: { "aria-hidden": "true" } }),
el("span", { cls: "cx-brand-name", text: name }),
] }),
destroy() { this.node.remove(); } }));
define("code", "inline", ({ text } = {}) =>
handle(el("code", { cls: "cx-code", text })));
+13 -6
View File
@@ -15,12 +15,19 @@ export function portal() {
if (!root) {
root = document.createElement("div");
root.id = ROOT_ID;
// No transform, filter, backdrop-filter, contain -- OR z-index -- on this
// element, ever. Each creates a stacking context, and then the whole ladder
// is measured against this one element's number instead of against the page:
// a modal at 800 would still paint under a sticky header at 30.
root.style.position = "fixed";
root.style.inset = "0";
// NO POSITIONING, no transform, no filter, no contain, no z-index -- ever.
//
// Each of those makes this element a stacking context, and then the whole
// ladder is measured INSIDE it rather than against the page: every rung,
// from a tooltip at 300 to a modal at 800, collapses to whatever this one
// element resolves to. It said exactly that and then set `position: fixed`,
// which creates a stacking context all by itself -- so a menu at z 500 was
// painted under a button at z 1, and the only reason it was not obvious
// everywhere is that almost nothing in the app claims a z-index at all.
//
// Nothing is needed here anyway: every layer mounted into this root is
// itself `position: fixed`, so a static, zero-sized parent lays nothing out
// and intercepts nothing.
root.style.pointerEvents = "none";
}
if (!root.isConnected) document.body.appendChild(root);
+13
View File
@@ -62,3 +62,16 @@ test("an existing root in the document is adopted, not duplicated", () => {
assert.equal(portal(), existing);
assert.equal(document.querySelectorAll(`#${ROOT_ID}`).length, 1);
});
test("the overlay root is not a stacking context", () => {
// If it is one, every rung of the ladder is measured inside it instead of
// against the page, and the whole ordering collapses to one number. It used
// to set `position: fixed`, which creates a stacking context on its own --
// and a menu at z 500 painted under a button at z 1.
const root = portal();
for (const property of ["position", "zIndex", "transform", "filter", "contain", "isolation", "opacity", "willChange"]) {
const value = root.style[property];
assert.ok(!value || value === "auto" || value === "none" || value === "static" || value === "1",
`the overlay root sets ${property}: ${value}, which can make it a stacking context`);
}
});
+10
View File
@@ -121,6 +121,11 @@
the flatness of a large area of one colour, which is most of this app. */
--grain-opacity: .035;
--grain-blend: overlay;
/* Hover moves the fill AWAY from the label, and which way that is depends on
the theme: dark's accent carries near-black text, so it goes lighter. */
--grad-accent-hover: linear-gradient(180deg, var(--accent-hi), var(--accent));
--accent-hover-edge: var(--accent-hi);
}
/* ── light: white, grey and daylight blue ─────────────────────────────── */
@@ -163,6 +168,11 @@
multiplies a much fainter one. v4 landed on the same two numbers. */
--grain-opacity: .015;
--grain-blend: multiply;
/* Light's accent carries WHITE text, so brightening it on hover walks the
label towards its own background. It goes darker instead. */
--grad-accent-hover: linear-gradient(180deg, var(--accent), var(--accent-lo));
--accent-hover-edge: var(--accent-lo);
}
/* Derived values, written once so both themes get them for free. color-mix
+54
View File
@@ -0,0 +1,54 @@
// The Constructor: where a run is written, in a window of its own.
//
// v4 called this the Composer. The name is taken here -- `app/composer` is the
// element kit -- and two things called Composer in one codebase is one thing
// nobody can search for.
//
// The prompt is not a panel on the main window. It is the longest thing a
// person writes, it is edited in bursts and read rarely, and giving it a
// permanent third of the centre column cost the timeline the room it needs. So
// it opens, is written in, and closes.
//
// The HOST is built once, at startup, and outlives every opening. Two reasons,
// and both were faults before this file existed: a mount point has to exist
// when modules mount, which is long before anyone opens a window; and a window
// that rebuilt its contents on each open would throw away whatever was typed
// and not saved.
import { composer } from "../composer/composer.js";
export function createConstructor({ title = "Constructor" } = {}) {
const empty = composer.emptyState.default({
icon: "✎",
title: "Nothing to write yet",
hint: "The pipeline decides which of its inputs are written here.",
});
// The host is a plain region: it is handed to the modal as a body and handed
// back when the modal closes, which is why the modal never owns it.
const host = composer.region.stack({ gap: "sm", label: "Constructor", fill: true,
children: [empty] });
let window_ = null;
function open() {
if (window_) return window_; // one window onto one prompt
window_ = composer.modal.generic({
title,
subtitle: "What this run is made of.",
size: "lg",
body: host,
actions: [composer.button.md({ label: "Done", tone: "primary",
onClick: () => window_ && window_.close("done") })],
onClose: () => { window_ = null; },
});
return window_;
}
return {
host, empty, open,
/** Open, and mounted. Tests and the shell both ask. */
get isOpen() { return Boolean(window_); },
close() { if (window_) window_.close("close"); },
};
}
+61 -35
View File
@@ -18,10 +18,19 @@ import { composer } from "../composer/composer.js";
import { offer } from "./mounts.js";
import { createTransport } from "./transport.js";
import { createBin } from "./bin.js";
import { createMenubar } from "./menubar.js";
import { createConstructor } from "./constructor.js";
export function build(root, handlers = {}) {
// Centre: the result, and the prompt under it. The prompt is the one control
// that is always on screen, because it is the one always being edited.
// The transport: Generate, Cancel, and what the run is doing. Built before the
// zones because its controls are handed to the zone that holds them.
const transport = createTransport(handlers);
// Built here rather than on first open: a mount point has to exist when
// modules mount, which is long before anyone opens a window.
const constructor = createConstructor();
// Centre: the result, and the timeline under it.
const viewer = composer.viewer.media({ empty: "The result of the last run appears here." });
// Left: what a project is made of, which for now is what it has produced. The
@@ -33,25 +42,50 @@ export function build(root, handlers = {}) {
actions: [bin.control],
body: bin.host,
});
// v4's preview head carries nothing but the name of the zone: what is being
// looked at is not something you act on.
const preview = composer.panel.zone({ title: "Preview", body: viewer, flush: true });
// The timeline: what the project IS, and where a run is started from.
//
// Generate lives here rather than in a bar across the bottom, because a bar
// that spans the window belongs to no region and acts on whatever happens to
// be in front -- which is a dashboard. v4 puts Generate in the timeline head,
// beside the thing it fills, and so does this.
//
// The warning is the first thing INSIDE the zone rather than in its head: it
// is a sentence, not a chip, and it has to sit under the button it is about
// -- an inert setting is said where the run is started, not where it was
// switched on.
const timelineEmpty = composer.emptyState.default({
icon: "▭",
title: "Nothing on the timeline",
hint: "What you generate lands here, in the order it plays.",
});
const timeline = composer.panel.zone({
title: "Timeline",
actions: [
...transport.actions,
composer.button.sm({
label: "Constructor",
onClick: () => { if (handlers.onConstructor) handlers.onConstructor(); },
}),
],
status: transport.status,
body: composer.region.stack({ gap: "sm", fill: true,
children: [transport.warning, timelineEmpty] }),
});
// The stand-ins below are EMPTY STATES, not labels: they say a region is
// empty, and settle() takes them down once it is not. A line reading "modules
// appear here" left above the modules that appeared is a region explaining
// itself to nobody.
const promptEmpty = composer.emptyState.default({
icon: "✎",
title: "No prompt here",
hint: "The pipeline decides which of its inputs appear on the main window.",
});
const prompt = composer.panel.zone({ title: "Prompt", body: promptEmpty });
// The result gets the room. The prompt is a few lines and a button's worth of
// controls; at 38% of the height it was mostly empty space taken from the one
// thing on the page anybody is looking at. Draggable, so anyone writing a long
// prompt can take it back.
// The result gets the room. Draggable, so a long timeline can take it back.
const centre = composer.splitPane.v({
size: 74,
label: "Preview and prompt",
panes: [preview, prompt],
label: "Preview and timeline",
panes: [preview, timeline],
});
// Right: everything about the run. Generation is open, settings folded away
@@ -95,33 +129,24 @@ export function build(root, handlers = {}) {
right: properties,
});
// The transport. One row, always visible, and the place a run is started and
// reported -- an inert setting is said HERE, next to Generate, not in the
// panel that would have enabled it.
const transport = createTransport(handlers);
// The top bar: the way into everything that is a WINDOW rather than a panel.
// It holds one button today and will hold the rest as they land -- a place
// that exists is what stops the next one being wedged into the transport row,
// which is the one row that has to keep saying what the run is doing.
const bar = composer.toolbar.default({
label: "FunPack",
items: [
composer.button.sm({
label: "Models and pipeline",
onClick: () => { if (handlers.onPipeline) handlers.onPipeline(); },
}),
],
});
// The menu bar: who this is, what is not a zone, and whether ComfyUI is
// still on the other end. A zone head holds what acts on THAT zone, so
// anything that acts on the app has nowhere else to live.
const menubar = createMenubar({ workspace, onPipeline: handlers.onPipeline });
const bar = menubar;
const page = composer.frame.app({ header: bar, main: workspace, footer: transport });
const page = composer.frame.app({ header: bar, main: workspace });
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("assets.library", assets.body);
offer("generation.prompt", prompt.body, promptEmpty.node);
// The prompt is written in a WINDOW, not in a zone: it is the longest thing a
// person writes and the least often read, and a permanent third of the centre
// column is the room the timeline needs.
offer("generation.prompt", constructor.host.node, constructor.empty.node);
offer("settings.general", settings.node, settingsEmpty.node);
// One host, five names. Each carries the same stand-in, and settle() takes it
// down once anything at all has mounted into the panel they share.
@@ -129,6 +154,7 @@ export function build(root, handlers = {}) {
offer(`generation.${point}`, generation.node, generationEmpty.node);
}
return { workspace, assets, bin, preview, viewer, prompt, generation, settings,
properties, transport, bar, page };
return { workspace, assets, bin, preview, viewer, timeline, generation, settings,
properties, transport, constructor, menubar,
connection: menubar.connection, bar, page };
}
+101
View File
@@ -0,0 +1,101 @@
// The menu bar: who this is, what is not a zone, and whether the server is
// there.
//
// v4's arrangement, and the reason an app with only zones reads as unfinished:
// a zone head holds what acts on THAT zone, so anything that acts on the app --
// a window, a preference, the state of the connection -- has nowhere to live
// unless there is a bar that belongs to the app itself.
//
// Only menus with something real in them exist. An empty File menu is a promise
// the app does not keep, and this project's rule for an inapplicable feature is
// that it is absent rather than greyed.
import { composer } from "../composer/composer.js";
import { LIVE, LOST } from "./run.js";
import { services } from "./services.js";
const THEMES = [
{ id: "dark", label: "Dark" },
{ id: "light", label: "Light" },
{ id: "auto", label: "Auto", hint: "Follows the system" },
];
/** A dot and a word for whether ComfyUI is still on the other end. */
export function createConnection() {
const dot = composer.dot.default({ tone: "neutral", label: "ComfyUI" });
const text = composer.text.xs({ text: "Connecting" });
const SAID = {
[LIVE]: { tone: "good", words: "ComfyUI live" },
[LOST]: { tone: "danger", words: "Reconnecting" },
};
return {
/** Two handles, not a wrapper: the bar it goes in already lays out a row. */
items: [dot, text],
dot, text,
/** Reads the same run state everything else does. */
draw(state) {
const said = SAID[state && state.connection] || { tone: "neutral", words: "Connecting" };
dot.node.className = `cx-dot cx-dot-${said.tone}`;
dot.node.setAttribute("aria-label", said.words);
text.setText(said.words);
},
};
}
/**
* menu(label, items, onPick) -> a button that opens them.
*
* The menu is built on each press rather than kept: what is in it depends on
* what is true when it is opened -- which panel is showing, which theme is on
* -- and a menu built once is a menu that goes stale the first time anything
* changes underneath it.
*/
function menu(label, itemsOf, onPick) {
let open = null;
const button = composer.button.sm({
label, tone: "ghost",
onClick: () => {
if (open) { open.close("toggle"); open = null; return; }
open = composer.menu.dropdown({
anchor: button, side: "bottom", align: "start",
items: itemsOf(),
onPick: (id) => { open = null; onPick(id); },
onClose: () => { open = null; },
});
},
});
return button;
}
export function createMenubar({ workspace, onPipeline, theme = services.theme } = {}) {
const connection = createConnection();
const view = menu("View", () => [
{ id: "left", label: `${workspace && workspace.isOpen("left") ? "Hide" : "Show"} Assets` },
{ id: "right", label: `${workspace && workspace.isOpen("right") ? "Hide" : "Show"} Properties` },
{ separator: true },
...THEMES.map((t) => ({
...t,
// A tick rather than a disabled row: which one is on is a fact about the
// app, and a menu that hides it makes the user press one to find out.
icon: theme.get() === t.id ? "✓" : " ",
})),
], (id) => {
if (id === "left" || id === "right") workspace.toggle(id);
else theme.set(id);
});
const settings = menu("Settings", () => [
{ id: "pipeline", label: "Models and pipeline…" },
], (id) => { if (id === "pipeline" && onPipeline) onPipeline(); });
const bar = composer.toolbar.default({
label: "FunPack",
items: [composer.brand.default({ name: "FunPack" }), view, settings],
trailing: connection.items,
});
return { node: bar.node, bar, connection, view, settings };
}
+63 -9
View File
@@ -18,6 +18,14 @@ export const DONE = "done";
export const FAILED = "failed";
export const CANCELLED = "cancelled";
/** The socket, which is a different question from what a run is doing. */
export const OFFLINE = "offline"; // not opened yet
export const LIVE = "live";
export const LOST = "lost"; // dropped, and trying again
/** How long to wait before the next attempt: 1s, 2s, 4s ... capped at 30. */
export const backoff = (attempt) => Math.min(30_000, 1000 * 2 ** Math.max(0, attempt));
/** Where ComfyUI serves a produced file from. */
export function viewUrl(image, base = "") {
const q = new URLSearchParams({
@@ -38,6 +46,10 @@ export function createRun({
let state = {
phase: IDLE, promptId: null, progress: null, images: [], audio: [],
error: null, node: null,
// Whether this page can still hear ComfyUI at all. Part of the run state
// rather than a second thing to subscribe to: one state object is what
// stops two parts of the UI disagreeing about what is happening.
connection: OFFLINE,
};
const emit = (next) => {
@@ -202,20 +214,56 @@ export function createRun({
}
let socket = null;
let attempt = 0;
let waiting = null;
// A CLOSED socket is still an object. "Already listening" therefore cannot be
// "there is a socket": every drop -- a laptop sleeping, a tunnel to a rented
// box hiccuping, ComfyUI restarting -- was permanent for that tab, and
// silent. Progress and results simply stopped arriving while the GPU carried
// on, and only a manual reload brought them back.
//
// readyState is undefined on a stand-in socket, and a stand-in is a live one.
const alive = (s) => Boolean(s) && (s.readyState === undefined || s.readyState <= 1);
function listen() {
if (socket || typeof connect !== "function") return socket;
if (typeof connect !== "function") return null;
if (alive(socket)) return socket;
if (waiting) { clearTimeout(waiting); waiting = null; }
socket = connect(clientId);
if (socket) {
socket.addEventListener("message", (event) => {
// Binary frames are previews, which nothing here consumes yet. A parse
// failure must not take the socket down with it.
if (typeof event.data !== "string") return;
try { handle(JSON.parse(event.data)); } catch { /* not for us */ }
});
}
if (!socket) return null;
socket.addEventListener("open", () => { attempt = 0; emit({ connection: LIVE }); });
socket.addEventListener("message", (event) => {
// Binary frames are previews, which nothing here consumes yet. A parse
// failure must not take the socket down with it.
if (typeof event.data !== "string") return;
try { handle(JSON.parse(event.data)); } catch { /* not for us */ }
});
// close AND error: a refused connection fires error without ever closing on
// some browsers, and a dropped one closes without an error on others.
const dropped = (which) => () => {
if (which !== socket) return; // an older socket letting go
emit({ connection: LOST });
retry();
};
socket.addEventListener("close", dropped(socket));
socket.addEventListener("error", dropped(socket));
return socket;
}
function retry() {
if (waiting) return;
const wait = backoff(attempt);
attempt += 1;
waiting = setTimeout(() => { waiting = null; listen(); }, wait);
// Not a reason to keep a process alive: under a test runner a pending timer
// is the difference between a suite that finishes and one that hangs.
if (waiting && typeof waiting.unref === "function") waiting.unref();
}
return {
get state() { return { ...state }; },
@@ -234,6 +282,12 @@ export function createRun({
},
handle, // for tests and for a socket owned elsewhere
listen,
/** Stop trying. Only a page being torn down wants this. */
stopListening() {
if (waiting) { clearTimeout(waiting); waiting = null; }
if (socket && typeof socket.close === "function") socket.close();
socket = null;
},
/** Queue a prompt. Resolves with the prompt id, or throws with the reason. */
async start(prompt) {
+4
View File
@@ -108,6 +108,10 @@ export function wire({ run, page, check, id, queuedFor, finishedFor, slots, valu
// straight back -- live-looking, and doing nothing when pressed.
run.subscribe((state) => {
page.transport.draw(state);
// Whether ComfyUI is still there rides on the same state object, so it is
// drawn by the same subscription: two things watching the connection is two
// things that can disagree about whether the app is talking to anything.
if (page.connection) page.connection.draw(state);
// Results go to the BIN, and the bin decides what is on screen. Pointing
// the viewer at the newest image from here instead put two things in charge
// of it: every progress message re-showed the newest result, so clicking an
+68 -5
View File
@@ -56,12 +56,30 @@ test("picking an older result in the bin changes what the viewer shows", () => {
assert.match(p.viewer.node.querySelector("img").getAttribute("src"), /first\.png/);
});
test("the bin's view control sits in the Assets panel, not in the transport row", () => {
// Every control that is about a run belongs beside Generate; this one is not
// about a run, and putting it there is how that row stops being readable.
test("a control sits in the head of the zone it acts on", () => {
// The arrangement that makes this an editor rather than a dashboard: the bin's
// view control is in the Assets head, and Generate is in the head of the zone
// the run fills -- not in one bar at the bottom acting on whatever is in front.
const p = page();
assert.ok(p.assets.node.contains(p.bin.control.node));
assert.equal(p.transport.node.contains(p.bin.control.node), false);
assert.ok(p.assets.node.contains(p.bin.control.node), "the view control left the Assets head");
assert.ok(p.timeline.node.contains(p.transport.generate.node), "Generate is not in the Timeline head");
assert.equal(p.assets.node.contains(p.transport.generate.node), false);
assert.equal(p.page.footer, null, "there is still a bar across the bottom");
});
test("what a run is doing is said in the same head that starts it", () => {
const p = page();
const status = p.timeline.node.querySelector(".cx-panel-status");
assert.ok(status, "the Timeline head says nothing about the run");
assert.ok(status.contains(p.transport.statusText.node));
assert.ok(status.contains(p.transport.progress.node));
});
test("a warning about the next run sits inside the zone that starts it", () => {
// Under the button it is about, not in the head beside it: it is a sentence
// rather than a chip, and a 34px band is not where a sentence goes.
const p = page();
assert.ok(p.timeline.body.contains(p.transport.warning.node));
});
test("the assets mount point still exists for a module", () => {
@@ -70,3 +88,48 @@ test("the assets mount point still exists for a module", () => {
assert.ok(p.assets.body.contains(p.bin.host.node),
"the bin is not in the panel body a module also mounts into");
});
test("the prompt is written in a window, not in a zone", () => {
// A permanent third of the centre column for the thing that is written in
// bursts and read rarely, taken from the timeline, which is what the project
// IS. It opens, is written in, and closes.
const p = page();
assert.equal(p.constructor.isOpen, false, "the Constructor is open before anyone asked");
assert.equal(document.querySelector(".cx-modal"), null);
p.constructor.open();
assert.equal(p.constructor.isOpen, true);
const modal = document.querySelector(".cx-modal");
assert.ok(modal, "the Constructor did not open");
assert.ok(modal.contains(p.constructor.host.node), "the prompt is not in the window");
assert.match(modal.textContent, /Constructor/);
p.constructor.close();
assert.equal(document.querySelector(".cx-modal"), null);
});
test("what was typed survives the window being closed", () => {
// The host outlives every opening. A window that rebuilt its contents each
// time would throw away whatever was written and not saved -- which for a
// prompt is the whole of it.
const p = page();
const box = document.createElement("textarea");
p.constructor.host.node.appendChild(box);
box.value = "a cat on a rooftop";
p.constructor.open();
p.constructor.close();
p.constructor.open();
const found = document.querySelector(".cx-modal textarea");
assert.equal(found, box, "the window built itself a new prompt box");
assert.equal(found.value, "a cat on a rooftop");
p.constructor.close();
});
test("the timeline is where a run is started from", () => {
const p = page();
assert.match(p.timeline.node.textContent, /Nothing on the timeline/);
assert.ok(p.timeline.node.querySelector(".cx-panel-actions"));
assert.match(p.timeline.node.querySelector(".cx-panel-actions").textContent, /Constructor/);
});
+8 -2
View File
@@ -26,11 +26,17 @@ test.after(() => teardownDom());
// just before it was overwritten with "Ready" a moment after it appeared. The
// test passed and the button did nothing on screen.
function transport() {
// The real transport, mounted the way the shell mounts it: its controls go in
// a zone head and what it is saying goes at the far end of the same head.
// There is no bar -- an editor's controls belong to the region they act on.
const t = createTransport({});
document.body.appendChild(t.node);
const head = document.createElement("header");
for (const handle of [...t.actions, ...t.status, t.warning]) head.appendChild(handle.node);
document.body.replaceChildren(head);
return {
...t,
get text() { return t.status.node.textContent; },
head,
get text() { return t.statusText.node.textContent; },
get disabled() { return t.generate.node.disabled; },
};
}
+6 -2
View File
@@ -1,4 +1,4 @@
// The transport row, driven by run states.
// The transport, driven by run states.
//
// The reason this is one component reading one state object: a button here, a
// progress bar there and a status somewhere else each get their own idea of
@@ -24,8 +24,12 @@ const state = (over = {}) => ({
});
const built = (handlers) => {
// Mounted the way the shell mounts it: the controls go in a zone head, and
// there is no bar of its own to append.
const t = createTransport(handlers);
document.body.appendChild(t.node);
const head = document.createElement("header");
for (const handle of [...t.actions, ...t.status, t.warning]) head.appendChild(handle.node);
document.body.replaceChildren(head);
return t;
};
+19 -13
View File
@@ -1,11 +1,17 @@
// Generate, and everything that happens after it.
//
// One row that owns the whole lifecycle, because the alternative is a button
// One object owns the whole lifecycle, because the alternative is a button
// here and a progress bar there and a status somewhere else, each with its own
// idea of what is happening. This reads ONE state object and draws it -- so
// "queued" cannot appear next to a finished result, whatever order the messages
// arrive in.
//
// It owns the CONTROLS, not a row: they are handed out and the shell puts them
// in the head of the zone the run fills. A bar across the bottom of the window
// acts on "whatever is in front" and belongs to no region -- which is what made
// this read as a dashboard rather than an editor. v4 puts Generate in the head
// of the zone it produces, and so does this.
//
// Warnings live here too, beside Generate, rather than in the panel that would
// have caused them: a setting that will do nothing has to be said where the run
// is started, not where it was switched on.
@@ -16,24 +22,17 @@ import { IDLE, QUEUED, RUNNING, DONE, FAILED, CANCELLED } from "./run.js";
const WORKING = new Set([QUEUED, RUNNING]);
export function createTransport({ onGenerate, onCancel } = {}) {
const generate = composer.button.lg({
const generate = composer.button.md({
label: "Generate", tone: "primary",
onClick: () => { if (onGenerate) onGenerate(); },
});
const cancel = composer.button.md({
const cancel = composer.button.sm({
label: "Cancel", tone: "ghost",
onClick: () => { if (onCancel) onCancel(); },
});
const progress = composer.progress.bar({ value: 0, max: 100, label: "Generating" });
const status = composer.text.sm({ text: "Ready" });
const bar = composer.actionBar.sticky({
// What the run is doing at the start of the row, what starts and stops it
// at the end. The progress bar goes with the words, not with the buttons:
// it is the same statement in another form.
lead: [status, progress],
actions: [cancel, generate],
});
// Something true about the NEXT run, said before it starts and left up while
// it does: a setting that will not be applied is not a status, and putting it
@@ -42,7 +41,6 @@ export function createTransport({ onGenerate, onCancel } = {}) {
// the panel a person has already stopped looking at.
const warning = composer.banner.warn({ text: "" });
warning.node.setAttribute("hidden", "");
const row = composer.region.stack({ gap: "none", children: [warning, bar] });
// Something to say that is not about a run in progress: why the last attempt
// did not become one. It has to survive the next draw(), because a refused
@@ -113,8 +111,16 @@ export function createTransport({ onGenerate, onCancel } = {}) {
warning.node.toggleAttribute("hidden", !text);
}
return { node: row.node, draw, say, warn, hold, release,
generate, cancel, progress, status, warning };
return {
// What the shell puts in a zone head: the controls that start and stop a
// run, and -- at the far end of the same head -- what it is doing.
actions: [cancel, generate],
// The progress bar goes with the words rather than with the buttons: it is
// the same statement in another form.
status: [progress, status],
warning,
draw, say, warn, hold, release, generate, cancel, progress, statusText: status,
};
}
/**
+5
View File
@@ -0,0 +1,5 @@
/** The window moved into the Settings menu when the app grew a menu bar. */
export async function openPipelineWindow(page) {
await page.getByRole("button", { name: "Settings" }).click();
await page.getByRole("menuitem", { name: /Models and pipeline/ }).click();
}
+35 -11
View File
@@ -7,6 +7,7 @@
// controls in one list at four different widths.
import { test, expect } from "@playwright/test";
import { openPipelineWindow } from "./_menu.js";
const WIDE = { width: 1440, height: 900 };
@@ -81,7 +82,7 @@ test("every control in a list of settings shares its two edges", async ({ page }
// A <select> takes the width of its widest OPTION, so the row holding a list
// of checkpoint filenames was 124px wider than the four around it.
await app(page);
await page.getByRole("button", { name: "Models and pipeline" }).click();
await openPipelineWindow(page);
await page.locator(".cx-card", { hasText: "Loaders" }).click();
await expect(page.locator(".cx-modal .cx-settings-control").first()).toBeVisible();
@@ -93,18 +94,20 @@ test("every control in a list of settings shares its two edges", async ({ page }
.toHaveProperty("size", 1);
});
test("what the app says is at the start of the transport, what you press is at the end", async ({ page }) => {
// Everything used to be in one right-aligned group, so "Ready" sat against
// the Generate button with the whole width of the bar empty to its left.
test("a run is started from the head of the zone it fills, and reported there", async ({ page }) => {
// No bar across the bottom. A bar that spans the window belongs to no region
// and acts on whatever is in front, which is what made this a dashboard; v4
// puts Generate in the timeline head, beside the thing it fills.
await app(page);
const g = await gutter(page);
const [bar] = await boxes(page, ".cx-action-bar");
const [lead] = await boxes(page, ".cx-action-bar-lead");
const [buttons] = await boxes(page, ".cx-action-bar-buttons");
await expect(page.locator(".cx-action-bar")).toHaveCount(0);
expect(lead.x - bar.x, "the status is not at the start of the row").toBe(g);
expect(bar.r - buttons.r, "the buttons are not at the end of the row").toBe(g);
expect(buttons.x).toBeGreaterThan(lead.r);
const head = page.locator(".cx-panel", { hasText: "Timeline" }).first().locator(".cx-panel-head");
await expect(head.getByRole("button", { name: "Generate" })).toBeVisible();
const [bar] = await boxes(page, ".cx-panel-status");
const [generate] = await boxes(page, ".cx-panel-head .cx-btn-primary");
expect(bar.x, "what the run is doing is not at the far end of its own head")
.toBeGreaterThan(generate.r);
});
test("a closed panel leaves the rail and nothing else", async ({ page }) => {
@@ -158,3 +161,24 @@ test("a zone is square and a card is not", async ({ page }) => {
expect(zone.radius, "a region of the app has rounded corners").toBe("0px");
expect(zone.shadow, "a region of the app floats above itself").toBe("none");
});
test("an open menu is above what it covers", async ({ page }) => {
// The overlay root set `position: fixed`, which creates a stacking context on
// its own -- so the whole z ladder was measured inside that one element, and
// a menu at 500 painted UNDER a button at 1. Every overlay in the app was one
// positive z-index away from being buried; nothing had claimed one yet.
await app(page);
await page.getByRole("button", { name: "View" }).click();
const menu = page.locator(".cx-popover");
await expect(menu).toBeVisible();
const covered = await page.evaluate(() => {
const pop = document.querySelector(".cx-popover").getBoundingClientRect();
// A point well inside the menu, over the zone head it opened across.
const x = Math.round(pop.x + 25);
const y = Math.round(pop.y + 20);
const top = document.elementsFromPoint(x, y)[0];
return { inMenu: Boolean(top.closest(".cx-popover")), top: top.className };
});
expect(covered.inMenu, `${covered.top} is painted over the open menu`).toBe(true);
});
+19 -8
View File
@@ -5,11 +5,12 @@
// column of overlapping controls passes there and is unusable here.
import { test, expect } from "@playwright/test";
import { openPipelineWindow } from "./_menu.js";
async function openWindow(page) {
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
await page.getByRole("button", { name: "Models and pipeline" }).click();
await openPipelineWindow(page);
await expect(page.locator(".cx-modal")).toBeVisible();
}
@@ -97,22 +98,32 @@ test("Save and Cancel hold the bottom instead of scrolling with the settings", a
expect(after.y).toBeCloseTo(bar.y, 0);
});
test("the button opens one window, however many times it is pressed", async ({ page }) => {
test("the way in is closed while the window is open", async ({ page }) => {
// Reported from a real session: two of the same modal, one behind the other.
// Two windows over one pipeline are two drafts of it -- whichever is saved
// last wins, and the other was edited against a pipeline that had moved.
//
// There are two guards and this is the outer one: with the window up, its
// backdrop covers the menu it was opened from, so a second one cannot be
// asked for by hand at all. The inner guard -- open() called twice -- is in
// the jsdom suite, where a second call can actually be made.
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
const button = page.getByRole("button", { name: "Models and pipeline" });
await button.click();
await expect(page.locator(".cx-modal")).toHaveCount(1);
await button.click({ force: true });
await button.click({ force: true });
await openPipelineWindow(page);
await expect(page.locator(".cx-modal")).toHaveCount(1);
const settings = page.getByRole("button", { name: "Settings" });
await expect(settings).toBeVisible();
const blocked = await settings.evaluate((node) => {
const b = node.getBoundingClientRect();
const top = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
return !node.contains(top);
});
expect(blocked, "the opener is still reachable with the window open").toBe(true);
await page.locator(".cx-modal .cx-icon-btn").click();
await expect(page.locator(".cx-modal")).toHaveCount(0);
await button.click();
await openPipelineWindow(page);
await expect(page.locator(".cx-modal")).toHaveCount(1);
});
+28 -7
View File
@@ -5,13 +5,20 @@
import { test, expect } from "@playwright/test";
test("the prompt is on the main window, and typing in it is what runs", async ({ page }) => {
// The prompt is a slot input like any other. Until it was put here the only
// way to type one was to open the pipeline window and find the node.
const openConstructor = (page) =>
page.locator(".cx-panel-head").getByRole("button", { name: "Constructor" }).click();
test("the prompt is written in the Constructor, and typing in it is what runs", async ({ page }) => {
// The prompt is a slot input like any other. Until it was wired the only way
// to type one was to open the pipeline window and find the node; it now has a
// window of its own, opened from the timeline that a run fills.
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
const boxes = page.locator(".cx-panel textarea");
await expect(page.locator("textarea"), "the prompt is on the main window").toHaveCount(0);
await openConstructor(page);
const boxes = page.locator(".cx-modal textarea");
await expect(boxes).toHaveCount(2);
await boxes.first().fill("a cat on a rooftop");
@@ -27,11 +34,25 @@ test("the prompt is on the main window, and typing in it is what runs", async ({
expect(positive.inputs.clip, "the wiring was replaced by a value").toEqual(["clip", 0]);
});
test("what was typed is still there the next time the window opens", async ({ page }) => {
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
await openConstructor(page);
await page.locator(".cx-modal textarea").first().fill("a cat on a rooftop");
await page.locator(".cx-modal").getByRole("button", { name: "Done" }).click();
await expect(page.locator(".cx-modal")).toHaveCount(0);
await openConstructor(page);
await expect(page.locator(".cx-modal textarea").first()).toHaveValue("a cat on a rooftop");
});
test("a region that filled does not keep its stand-in", async ({ page }) => {
await page.goto("/funpack/");
await page.waitForFunction(() => window.FunPack !== undefined);
const prompt = page.locator(".cx-panel", { hasText: "Prompt" }).first();
await expect(prompt.locator("textarea")).toHaveCount(2);
await expect(prompt.getByText("No prompt here")).toHaveCount(0);
await openConstructor(page);
const modal = page.locator(".cx-modal");
await expect(modal.locator("textarea")).toHaveCount(2);
await expect(modal.getByText("Nothing to write yet")).toHaveCount(0);
});
+5 -5
View File
@@ -76,10 +76,10 @@ test("the prompt sits under the preview and both have height", async ({ page })
});
});
const preview = boxes.find((b) => b.title === "Preview");
const prompt = boxes.find((b) => b.title === "Prompt");
const timeline = boxes.find((b) => b.title === "Timeline");
expect(preview?.h, "the preview has no height").toBeGreaterThan(50);
expect(prompt?.h, "the prompt has no height").toBeGreaterThan(30);
expect(prompt.y).toBeGreaterThan(preview.y);
expect(timeline?.h, "the timeline has no height").toBeGreaterThan(30);
expect(timeline.y).toBeGreaterThan(preview.y);
});
// `hidden` has to actually hide.
@@ -117,8 +117,8 @@ test("the transport reports a refusal beside Generate", async ({ page }) => {
// The dev server has no ComfyUI behind it, so the pipeline genuinely cannot
// run -- and that is the case worth seeing: the reason appears where the run
// is started, not in a console nobody has open.
await page.locator('.cx-action-bar button:has-text("Generate")').click();
const bar = page.locator(".cx-action-bar");
await page.locator('.cx-panel-head button:has-text("Generate")').click();
const bar = page.locator(".cx-panel-status").first();
await expect(bar).not.toHaveText(/^Ready/);
// Whatever the reason is, it names the slot it is about. This used to read
// "there is no node called X installed", because the dev server had no node