The surfaces v4 has that v5 did not. Each announces itself and each can be
absent without the rest noticing, which is the point of the exercise: the
frontend should read as v4, and nothing in it should be load-bearing for
anything else.
* SCENE AND PROJECT, in the column v4 keeps them in, which now says which
scene it is about. Length on the timeline is the scene's own CROP and says
outright that a regenerate reads the project's -- the rule that mixing the
two is how a per-scene value quietly becomes everyone's.
* RATINGS on a scene, and a clip carries its rating as a colour on its edge.
Stored, not yet read: nothing in v5 learns from them yet.
* A RULER, and clips as wide as they are long, so the strip reads as time
rather than a row of equal boxes. Both ends bounded -- one long clip must
not become the whole strip, and one short one must stay hittable.
* THE PICKER WHEEL, on the middle button anywhere. It shows whatever
announced itself to actions.js, so a part of the app that grew a new action
is in the wheel without the wheel being told, and a part that failed to
load is simply not among them. v4's three pinned buttons had a fixed idea
of what could go in them.
* THE WAY IN, and it closes -- on Escape, on the backdrop, on its own button.
v4's could only be completed: a page refresh was the only way out, which
makes it a trap rather than a wizard. Dismissing is a real answer.
* File > rename, and the listing moves with it rather than going stale until
a reload.
Two things the kit refused, correctly, and now nobody has to know: a wheel of
fewer than two items throws (there is nothing to aim between), so the shell
does not offer one; and a strip cell with no ceiling took the whole strip.
Steps, Sampler and Scheduler are on the main window -- the three v4 buried
several clicks deep in Engine Settings. No new UI: the sampler slot declares
roles the way the prompt and the size already do.
What made that nearly free is that the controls now come from the NODE's own
description, through the settingFor/rendererFor pair the pipeline window has
always used. So a combo is a dropdown of the real choices and a number obeys
the real bounds, and prompt.js lost both of its hand-rolled branches: it no
longer decides what a control looks like, it asks.
File: New project, and every project listed with the open one ticked. The
store has been able to do this since the day it arrived and there was no way
in -- 20 lines of menu.
Three faults found on the way, all in the shared input helper:
* ENTER COULD NOT BE TYPED IN A TEXTAREA. `wire()` bound Enter to commit for
every text control, textareas included, so a multi-line value was
impossible -- the prompt above all, which is the longest multi-line thing
in the app. It has been that way in the pipeline window since it was
written; nobody had typed a second line into one.
* A control committed on blur alone, so autofill, a password manager and any
script setting a value went unheard. It listens to `change` as well now,
and commit() only acts on a real difference so the pair costs nothing.
* A control commits on blur, which means text still being typed is not in
the project yet -- and a tab closing is exactly when that is lost. boot
blurs before it saves.
setResult existed and nothing called it; the timeline already drew
scene.result. One wire short of a loop -- generate a scene, and what came
back is on the strip and still there after a reload.
The scene id is read when the run STARTS. A run takes minutes and the user
goes on clicking, so the selected scene when it finishes is not the one that
was generated.
A strip cell now falls back to its glyph when a thumbnail does not arrive,
the way the bin's cells already did -- the browser's broken-image icon reads
as a damaged file rather than a missing one.
Width, height and length belong to the PROJECT, not to a scene: a scene
cropped on the timeline and regenerated comes back at the project's size,
because the crop was a timeline decision and a regenerate is a new scene.
They sit in the Constructor beside the prompt -- the two things settled in
one sitting -- and travel to the graph on the path the prompt already uses.
No new mechanism: the pipeline declares roles at `project.video` the same
way it declares the prompt, so core still names no node and no setting. A
role whose value is a number gets a number control instead of a box; that
is the whole of the UI change. What the pipeline does not offer is not
shown, so a graph with no length input simply has no Length.
Bounds are the NODE's and stay there -- it already refuses a value outside
them with a reason, beside Generate. A project file is the one thing here
that outlives the code that wrote it, so everything read back out of one is
checked: a whole positive number, or gone.
Also, four faults the browser suite was already red with before this:
* Six tests still clicked a rail toggle that moved into the timeline head.
* At 420px the panel OVERLAYS the centre -- and the named toggles are in
the centre, so the panel covered the control that closes it. That is the
v4 trap this project forbids. They are not offered at that width now; the
View menu, which nothing can cover, is the way in and out.
* A 768px window laid out 860px wide and scrolled sideways. Root cause, and
it was never about one component: a flex item's automatic minimum is its
CONTENT, so every stack, split, panel body and frame region in the app
handed its content's width upward. A scroller that cannot shrink below
what it holds is not a scroller -- the strip of scenes made the WINDOW as
wide as the strip. Floored at the five places every layout routes through.
* A bin test counted `.cx-cell-glyph` across the whole page, so the timeline
drawing cells with the same glyph in them broke a test about the bin.
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.
The layout was assembled by hand and read like it. Measured at 1440x900:
the three columns started at three different heights, a panel head was 41px
when it held a control and 34 when it did not, the gap left of the centre
was 7px and right of it 19, the properties column sat 11px further in than
the others inside a border that meant nothing, and five controls in one
list had four different widths -- a <select> takes the width of its widest
option, so the row listing checkpoint filenames was 124px wider than its
neighbours.
Now: --gutter is the space between regions, between two panels stacked in
a column, and inside the bands; the head band height is the panel head
height is the collapsible head height; --control-col gives every settings
control the same two edges. The frame's bands run edge to edge and the
content is inset by the gutter, so the app has an outline instead of a
margin that changed per region.
Also here, all of it visible in the same screenshot:
* The status sat against the Generate button with 1300px of empty bar to
its left -- everything was in one right-aligned group. What the app says
is at the start of the row now, what you press is at the end.
* A panel body is a column with a gap: two things put in it by two parts
of the app had whatever space their line boxes happened to leave.
* A container query adds no specificity, so the narrow-panel rule written
above the rules it overrides lost -- and flex-basis in a column is
HEIGHT, which turned four settings into a panel two screens long.
* The theme you picked was thrown away on every load: the appearance
module applied its own declared default over the choice the page had
already applied from storage. It adopts the live value now, and a panel
is built after setup so its control shows what setup settled on.
The prompt was a slot input on a CLIPTextEncode, reachable only by opening
the pipeline window and finding the node. A slot can now say that one of
its inputs belongs on the app's surface -- a place and a label -- and the
shell offers a region with that name or does not, exactly as a module's
panel works. Core validates that a role is names and nothing else.
Values for those inputs travel addressed by slot rather than as a whole
pipeline, so the window that owns the structure and a box that owns one
value cannot disagree about the slots between them. An address that is
not there is refused: a prompt typed into a box for a removed slot has
gone nowhere, and a run that starts anyway is not the run that was asked
for.
Region stand-ins are empty states now, taken down once something mounts --
'modules appear here' left above the modules that appeared explained
nothing to nobody.
Every module panel was decoration. The values lived in the browser and
nothing sent them, so a modifier switched on was one the run never heard
about. They now travel with the pipeline on every check, and core places
them wherever a module says they belong -- announced, not known, so the
node that carries them is not privileged.
Settings with nowhere to go are said beside Generate rather than
swallowed, and the warning survives the run it is about: the status line
is redrawn by every message, so a warning put there was gone the moment
the run said Queued.
Also: a settings row in a docked panel squeezed its label to one word per
line, because the control keeps a 140px floor and took it out of the
label. Below the width where they fit side by side, they stack.
A second run replaced the first on screen with no way back. Results now
accumulate in the Assets panel across runs, and the bin owns what the
viewer shows: the session subscription drove the viewer as well, so every
progress message walked a click on an older result back to the newest.
Grid, list and icons are one collection in three shapes, sharing selection
so switching view is not a way to lose it. No <video> thumbnails, ever --
that is v4's connection-pool stall. A thumbnail that does not arrive falls
back to the glyph rather than the browser's broken-image icon.
The button opened another window on every press -- reported from a real
session as two of the same modal. 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 already moved. One window now, focused rather than stacked,
and a fresh one after it is closed.
commit() took whatever answer arrived. Moving a node carries the whole slot
list and removing one carries an id, so the second answers first and the first
then describes a pipeline that no longer exists -- putting a removed node back
with no refusal and nothing said. The picker guarded this already; the path
that decides what gets queued did not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ComfyUI refuses the WHOLE graph over one wrong value. v4 hit that with a LoRA
file that had been deleted: a feature that was switched off still stopped every
generation, and the message was "Prompt outputs failed validation" over a dozen
loaders. build() checked wiring and required-ness and never looked at a literal,
so check() called such a pipeline queueable.
A combo value that is not among the node choices, and a number outside the
bounds it declares, are now refused here -- naming the slot and the input.
No bounds means unknown, and unknown is not a refusal.
And when the queue does refuse one, its per-node reasons were captured by
run.js and never read: the status bar showed only the top-level string, because
describe() read `node` and the refusal path sets `nodes`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A module whose setup() threw was reported as hidden with its panel already on
screen -- appended one line too early. The file states the opposite guarantee.
A redraw destroyed every control and then rebuilt, so a row revealed by another
value -- one the first build never rendered and so never checked -- stripped
the panel and threw on the way to refilling it. Building first makes a redraw
as safe as the first build already was.
A page loaded at a narrow width recorded the panel its own one-at-a-time rule
had just closed as one the user wanted closed, so widening brought back only
the left. What the user chose is now kept apart from what is on screen.
The redraw test counted rows and passed against the bug; a control removed from
a row leaves the row behind. It counts controls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A draft is keyed by slot id, and a slot keeps its id when it is pointed at a
different node. An unsaved steps stayed attached across a swap -- and since
most samplers declare steps, and the server only refuses an input the new node
does NOT declare, it was written on the next Save with nothing saying it had
never been chosen for that node.
The fake server in the tests did not implement replace, which is why no test
could see it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One wide modal, a card per group, and a group that opens into a node list
beside that node/'s parameters, saved a group at a time. Groups travel on the
slot, so a pipeline keeps its own arrangement and a new group is a slot
claiming a name nobody used.
Values are drafted until Save; adding, removing, replacing and moving a node
go to the server as they happen, because only it can say whether the result
still wires up.
An unset dropdown reads "not set" rather than showing the first file as though
it had been chosen -- v4 recorded that trap, and picking the entry the box
already shows fires no change, so the one click that looks like the fix does
nothing.
New: region.stack, a frame header, a modal footer that can be refilled.
Not yet: wiring. A socket says so where a control would be.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ComfyUI spells a combo three ways -- a list where the type goes, the string
"COMBO" with the choices under options, and COMFY_DYNAMICCOMBO_V3. Only the
first was read, so every V3 node came back with no choices at all and the
window said "no files of this kind were found" over dropdowns holding seven
entries. A stock install here has 140 of the spelling that was missed.
A union type ("IMAGE,MASK") was compared as a whole string, so a MASK feeding
it was refused -- a legal wire refused, which is as bad as an illegal one
accepted. A MultiType wrapped around a number ("FLOAT,INT" with widgetType)
was read as a socket demanding a source for a field you type into.
graph.py and widgets.py had their own idea of what a type string means; both
now read comfy_types.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/queue answers with queue_running AND queue_pending, and only the first was
looked at. A job sits in the second from the moment /prompt returns until the
worker picks it up -- always briefly, and for as long as it takes whenever
something is running ahead of it, which on a shared box is the ordinary case. A
reload in that window found nothing, handed Generate back, and let the same job
be queued twice: the first copy then ran unnamed, its messages failing the id
check, burning GPU time while the UI narrated the second.
Every fixture in client.test.js was cleaner than the real payload -- none of
them carried a queue_pending key at all, though ComfyUI always sends both. That
is the third time a stand-in tidier than the real thing hid what it stood in
for, so the fixtures now carry both halves, including the malformed entries a
real answer can contain.
Also renamed, because the function no longer answers the question its name
asked: queuedFor returns which half it found the job in, and a job waiting its
turn is adopted as QUEUED. Saying "working" over a run that has not started is
the same small lie as saying "cancelled" over one that has not stopped.
subscribe() delivers the CURRENT state at once, and boot.js registered it right
after wire() had deliberately disabled Generate for the length of the reattach
lookup. That first delivery drew an idle run, and drawing an idle run hands the
button back -- so on every single page load Generate looked available while the
page still had no idea whether it already had a run. The queue stayed safe,
because the generator waits on the same lookup, but a live-looking button that
answers a click with nothing is its own fault.
Fixed twice over, because either alone would rot. The subscription moved into
wire() with the rest of the wiring, and the button being held is now a state the
transport keeps rather than something a caller does once: draw() runs on every
change, and each of those was free to undo it.
The fake run had no subscribe() at all -- which is exactly why the suite could
not see this. That is the second time a fake was tidier than the real object and
hid the fault it stood in for, so it now delivers state on subscribe like the
real one, and announces changes.
boot.js is down to assembling six things and mounting the modules.
reattach() was started and not waited on, and the Generate button is on screen
before it resolves -- so on a reload during a generation the phase still read
"idle" for the length of the queue lookup, and a click in that window queued a
SECOND job. The adopt that would have found the first one then refused, because
the phase was no longer idle, and from that moment the original run's messages
belonged to nobody: not shown, not cancellable, still burning GPU time.
Generate now waits on that lookup, and says so while it waits rather than
sitting dead with no explanation. A queue that never answers still gives the
button back.
The ordering moved into shell/session.js with the rest of it. session.js's own
docstring said the faults it was extracted to catch lived in boot.js "where
nothing could be driven by a test" -- and then the fix was wired up in boot.js,
where nothing could be driven by a test, and reopened the same class of fault.
The wiring is the thing under test, so it lives where tests can reach it.
Also: the fake run's gate now starts OPEN. Held shut by default, any regression
letting an extra start() through hung the suite instead of failing it, and a
hang says far less than an assertion.
/interrupt only consults the RUNNING half of ComfyUI's queue. A job waiting
behind another one is not there, so cancelling it returned 200, logged "not
currently running, skipping interrupt", and let the generation run in full when
its turn came -- the user asked for it to stop, the UI showed it stopping, and
the GPU spent the time anyway. Two tabs against one ComfyUI is all it takes, and
one tab per generation is now the normal arrangement.
ComfyUI has a route for exactly this: /api/jobs/<id>/cancel interrupts a running
job and dequeues a pending one, atomically, and answers {"cancelled": false} for
an id that already finished. An older ComfyUI without it falls back to the
interrupt, which is right for the case people hit most.
Reading that answer matters: a dequeued job never runs, so no
execution_interrupted is ever sent, and the run would have sat at "Queued"
forever after a cancel that worked. A run that IS executing is still left to the
socket to declare stopped -- an interrupt is a request, and claiming it early
shows "cancelled" over a model still finishing its step.
The one test touching cancel drove execution_start first, so it only ever
exercised the running case -- which is the half that already worked.
Two faults from the round, and a third I introduced fixing them.
ComfyUI keys its socket table by client id and pops the old entry when a new
socket arrives with the same one, so two tabs sharing an id meant the second
took the first's updates -- the first froze mid-progress while the GPU carried
on -- and the socket's `finally` pops that id again on close, so closing the
dead tab disconnected the live one. The id lives in sessionStorage now: it
survives a reload of this tab, which is what reattach needs, and does not reach
another tab. Duplicating a tab copies sessionStorage and is still a collision;
said in the comment rather than pretended away.
Generate only went dead when the RUN reached "queued", and reaching it takes a
round trip to ask what to queue. For the whole of that round trip the button was
live, so two clicks each posted their own /prompt: two jobs burning GPU time,
with the UI following one.
Both lived in boot.js, which runs on import and needs a document, so none of it
could be driven by a test. That wiring is now in shell/session.js and tested --
and the first version of those tests used a hand-rolled transport whose draw()
did nothing, which hid the fault the change introduced: the real draw() writes
the status from the run state, and a refused attempt leaves the run idle, so the
reason was overwritten with "Ready" in the same breath as it appeared. The tests
use the real transport, and a refusal now outlives the redraw that follows it.
The fix a commit ago buffered messages only while QUEUED, which is the window a
page creates for ITS OWN run. Reattach opens a second window nobody covered: the
socket is live, the phase is idle, and the queue has not yet said which run
belongs here. Anything arriving in that gap fell straight through and was
dropped -- and a run that ended inside it had already left `queue_running` by
the time it was asked about, so nothing found it either. The GPU produced a
picture, the app said Ready, and the result was gone.
Held whenever the id is unknown, not only while queueing. Then two questions on
load rather than one: what is still running, and -- if nothing is -- which of
the runs this page actually SAW on its own socket was ours, answered from
ComfyUI's history by the client id it recorded. Only ids seen during this page
load are asked about, so a result from an hour ago cannot be resurrected as
though it had just arrived.
Every reattach test called adopt() before sending anything, so none of them was
ever in the window that adopt() exists to close.
Three faults, all living in the length of the /prompt round trip -- a real
network delay during which the socket is already live and everything ComfyUI
broadcasts arrives.
* Cancel in that window POSTed `{"prompt_id": null}`, and ComfyUI reads a
missing prompt_id as a GLOBAL interrupt: it stops whatever any client is
running. On a shared box that is someone else's generation, and the clicking
user's own request may not even be queued yet. The cancel is remembered and
sent once the run has a name.
* `mine()` treated "no id yet" as "everything is mine", so a stranger's
execution_success and images flipped this run to Done -- showing another
client's picture, for a run that had not started. Messages arriving before the
id are now held and sorted out once it is known; dropping them instead would
lose a run short enough to finish inside the round trip. The buffer is bounded,
because on a shared server it fills with other people's traffic.
* The socket only opened at Generate, so the reattach the client id exists for
never happened: after a reload the app sat at Ready while its own generation
ran on, and pressing Generate queued a SECOND job beside it. It listens at
load and asks ComfyUI's own queue which run belongs to this client -- asked
rather than remembered, because a remembered id reattaches to a run that may
have finished while the page was gone.
Every existing test awaited start() before sending a message, so none of them
ever entered this window. The new ones hold the /prompt response open, which is
what a round trip actually is.
ComfyUI's own queue and its own socket, not a second one alongside them. A
FunPack queue would be a second thing tracking what is running, and the one the
user believes is whichever spoke last -- so this speaks to /prompt, /interrupt
and /ws as any ComfyUI client does and holds only a view of the server's state.
The graph is asked for, never assembled here, for the same reason.
The client id is remembered: ComfyUI addresses progress to the client that
queued the run, so a reload with a fresh id hears nothing about the generation
already in flight -- the run continues, the GPU time is spent, and the app says
Ready.
Faults found on the way, each in code that already had passing tests:
* `[hidden]` was inert across the whole kit. The UA rule is element-level, so
`.cx-btn { display: inline-flex }` beats it -- Cancel was marked hidden, sat
there, and nothing reported anything. jsdom cannot see it: the attribute was
always correct. One reset rule, and a browser test over four element kinds.
* A panel passed no height to its body and a split pane passed none to its
panes, so the viewer asked for 100% of `auto` and collapsed to a single line
inside a panel that was the right size around it.
* subscribe() delivered the first state outside the guard every later delivery
gets, so one bad listener took down whoever was wiring the UI up -- at
startup, before anything existed to report it.
* A whole tests directory was never run: the test script globbed
composer/tests only. `node --test` discovers them itself now, so there is no
list to forget.
* `empty` was not in the content rule's key list, so three elements were never
asked to prove their empty-state text comes back as text rather than markup.
The dev server now mounts the REAL routes -- register() already took a route
table -- instead of reimplementing them over http.server. It answered POST
/api/pipeline with 501, so the edit-and-queue path could not be looked at, which
is the one thing the dev server exists for.