Commit Graph
35 Commits
Author SHA1 Message Date
digital-garbage 563b1c6f02 app: Settings looks designed instead of default -- icon chips, real sidebar, centred About
A bare glyph character had no framing at all; filterList's icon now gets a
coloured chip, drawn from a fixed tone (accent/danger/warn/good/neutral) --
never a raw colour from data, the same rule el()'s style refusal already
holds everywhere else. Each Settings section picks one.

The sidebar was the filterList element's small dropdown-list shape (a
240px-capped box with its own border, floating in a large pane) rather
than a real full-height sidebar; .cx-filter-full opts a host into the
pane version without changing the compact one pipeline_window's own node
picker still uses.

Round one had a per-row hint line under the icon+label, which doesn't
exist in v4's own sidebar and broke alignment the moment a hint wrapped
(icon and label baseline-aligned against a hint floating as an independent
sibling rather than stacked under its own label). Fixed the real bug --
label+hint are a column now, not loose flex siblings -- then removed the
hint entirely to match v4: a section's subtitle belongs in its own head
once picked, not repeated under every row whether picked or not.

About is centred -- mark, name, version/branch -- rather than a plain
settings-row list, the one section built fresh rather than mounting an
existing window.
2026-09-07 01:22:35 +03:00
digital-garbage e6e4dfa882 app: mount every Settings section in place instead of deep-linking out
Picking Node packs, Updates, the log or Temp files used to close the
Settings window and open that feature's own separate modal, with no way
back except reopening Settings from the menu bar -- not what v4 does,
and not what "unified" is supposed to mean. Each of those five files
(plus pipeline_window.js) now exports mount({ setFooter, close }), the
same content its standalone open() already built, taking the modal
chrome's footer and close from whoever hosts it instead of owning a
modal directly. open() is unchanged for anyone opening one alone --
pipeline_window.test.js and every Playwright spec for these five prove
it, unedited, still passing.

Also fixes two findings from the adversarial pass on the earlier
deep-link version: filterList's search only matched a section's visible
label/hint, so a section findable only by a keyword (e.g. "loaders" for
"Models and pipeline") silently vanished from the filtered list; and
createSettingsWindow's "one at a time" state lived at module scope, so
a second independent instance would have redrawn the first one's modal
instead of opening its own. Both now have regression tests.
2026-09-07 01:06:02 +03:00
digital-garbage 99ce537932 app: one Settings window instead of five menu items
Models and pipeline, Updates, Node packs, the log, and Temp files were
five separate dropdown entries with no way to search across them. One
button now opens a single searchable window; About is drawn in place,
everything else is a deep link -- each of those five already has real,
tested modal chrome of its own, so this reuses it rather than rebuilding
it inline.

Uses the existing composer.splitPane.h for the two-pane layout rather
than a new element -- an earlier pass added one, and its CSS collided
with splitPane's own .cx-split class, silently breaking the pipeline
window's own list/parameters split (caught by pipeline.spec.js before
this landed).
2026-09-07 00:40:45 +03:00
digital-garbage 75837df82e app: Generate All says so when every scene is excluded
Found by the adversarial pass on the exclude commit: every other
early-return in generateAll() says something (empty load, cancelled,
N failed) except this one -- excluding every scene made the button do
nothing with no feedback, indistinguishable from a hang.
2026-09-06 23:29:20 +03:00
digital-garbage 4ea6e9c9f6 app: Toggle Exclude -- drop a scene from Generate All without deleting it
The Edit menu gains Toggle Exclude, next to Add/Delete scene. An excluded
scene stays on the timeline, dimmed in the strip, and Generate All skips it
-- both in its count and its loop, so "2 of 2 did not generate" never counts
a scene that was never going to run.

Covered by a jsdom render test, a real end-to-end menu-to-strip test, and a
Generate All exclude test.
2026-09-06 23:26:58 +03:00
digital-garbage b85c79a6a2 app, core: save/load a result, save/load a project, generate every scene
Player: Save to bin (whatever the viewer shows, if it has a real file
identity) and Save frame (canvas-capture the video on screen, upload
through ComfyUI's own /upload/image, no new route needed). Both add
without navigating the viewer -- an upload is a real round trip, and
the result is added to the bin, not shown as a stale finished pick.

Save/Load Project File: a real download route with Content-Disposition,
and an import route that never trusts an id in the file -- an import
always lands as its own project, never overwrites one already here.

Generate All: every scene in order, waiting for each to finish before
starting the next -- not a montage (v5 has no render/stitch stage),
N separate generations each landing on its own scene.

Both project-switching (open/newProject/importProject) and the earlier
run-reattach path shared a bug class this session kept finding: state
captured at one moment, applied at a later async moment with no check
that nothing had moved on in between. Closed it properly this time --
a generation counter every switch bumps and checks, including start()
(the page's own handlers are live before it resolves) and undo/redo
(also "a different project is open now"), and scheduleSave() captures
the object an edit actually touched instead of re-reading a variable
that can point somewhere else by the time its debounce timer fires.

Four adversarial rounds against this range, each fixed before landing.
2026-09-06 22:53:34 +03:00
digital-garbage 1185e2201f core, app: a decade of races closed -- log routes, restarts, results, defaults
Two routes silently shared /api/log; a pack's "updates available" badge
outlived the update; nothing server-side stopped a restart mid-generation,
and the fixes for that had their own dead end, a lock scoped to the wrong
event loop, an unlocked status fetch, and a check that only ran once
instead of at the moment it mattered. A run's result was lost outright if
the project it belonged to wasn't the one open when it finished -- on a
project switch or a page reload. A brand-new project inherited the
previous one's width, then inherited the wrong "default" for it.

Found and confirmed via an adversarial pass over 9dcd94c..HEAD, one round
per fix.
2026-09-06 18:08:48 +03:00
digital-garbage dae136b04c core, app: where a file went when it did not land in the bin
Fifth off the port list. ComfyUI writes previews and other transient outputs to
a temp directory and wipes it on restart, so everything in there is findable
for exactly as long as the server has been up -- which is the window in which
anyone asks where something went.

Read-only on purpose. This is a place to FIND a file, not to manage one;
deleting temp files is ComfyUI's job and it does it on restart.

Rows rather than tiles, which is the opposite of the bin and for the opposite
reason: a row shows the whole name, the size and when it was written, and the
grid draws none of those. Only media is listed -- a temp directory fills with
whatever any node felt like writing, and a browser listing 400 .pt files is not
a media bin. No <video> thumbnails, which wedged this app's API once already.

Three empty states, because they are three different facts: nothing was written
yet, the directory was wiped, and ComfyUI is not here to ask. An empty list
reads as the first one whichever is true.

One test taught me something about this environment again: opening a file puts
it in the Preview, the dev server has no /view, so the picture 404s and the kit
replaces it with its fallback -- correctly, and leaving nothing in the DOM to
assert on. What the viewer FETCHES is the evidence, so the test reads the
request rather than the picture.
2026-09-06 14:22:41 +03:00
digital-garbage 6371c4ad48 core, app: ComfyUI's log, where a user can reach it
Fourth off the port list. Users do not open devtools, and "it did not work"
with nothing to read is where a bug report dies -- especially on a rented box
behind a tunnel, where the terminal that has the answer is not on this desk.

Read from ComfyUI's own log FILE rather than from a buffer in this process.
v4 kept both and its own comment says why the file is the half that matters: a
crash and restart empties an in-memory buffer, and the lines from before the
crash are exactly the ones worth reading. So this is the smaller half and the
more useful one.

Three things the panel refuses to do, each because of what a log is FOR:

* It does not redraw while text is being selected. Copying a stack trace is the
  one thing anybody opens a log to do, and a panel that repaints under the drag
  makes it impossible.
* It keeps the last lines when the server stops answering. The most interesting
  thing a log can say is that ComfyUI just died, and replacing the lines from
  just before it went with the error that says it is gone throws away the only
  evidence of why.
* No log file is an ANSWER, with the reason and where to look instead. An empty
  list reads as a quiet log, and the difference matters precisely when somebody
  opened the panel because something else already broke.
2026-09-06 14:13:47 +03:00
digital-garbage ca913a3dec core, app: it restarted when nothing had changed, and could not see an update
Both findings from the adversarial pass, confirmed and fixed.

RESTARTING FOR NOTHING. Every successful git call scheduled a restart, whether
or not the checkout had moved. Pressing Update while already up to date is a
normal thing to do -- there is no way to know until it has been asked -- and it
cost a full boot. The route now restarts only when something actually moved: a
commit changed, or a branch did. Two branches at the same commit still count,
because they stop being the same the moment anything lands on either.

And the worse half, which the pass rated first: nothing checked whether a
generation was in flight. The restart takes the run with it, GPU time already
spent, with no warning anywhere. The window now says a run is going and turns
off the three buttons that would end it.

CHECK FOR UPDATES COULD NOT REPORT ONE. check_updates() answers under `checked`
and the app read `nodes`. The request succeeded, the JSON was valid, and every
pack reported nothing to update however far behind it was -- a fetch per pack
over the network for an answer that could never be shown. Neither side's tests
could see it: the route test asserted the server's shape, the window test never
pressed the button, and nothing compared the two.

So the tests now name each other. The Python one asserts the key the app reads;
the browser one stubs the server's real shape and reads the words off the row.
2026-09-06 14:05:03 +03:00
digital-garbage 8318b3da0b core, app: install, update and remove node packs
Third off the port list. Users will want to add the one pack a workflow needs
without being sent to another UI to do it, and a pack that ships experimental
work needs the same for itself.

custom_nodes.py comes across as core/nodes_manager.py, changed in two places:
where FunPack's root is, and one guard that the port made necessary.

THE GUARD. custom_nodes_root() asks ComfyUI first and falls back to "FunPack
lives inside custom_nodes" -- a reliable anchor in an install, and a wrong one
in a development checkout, where the parent is somebody's home directory. Every
rule protecting the delete measures against that answer, so with ComfyUI's
paths unavailable, "remove Foo" resolved against ~/Documents and all four
guards happily agreed. It now checks the assumption it is named after and
refuses with where it thinks it is. Found by running the function, not by
reading it.

Two faults of my own, both in the window:

* Update and Remove were a button GROUP -- radios -- for two actions that are
  not a choice between two states. It also left Remove looking selected after a
  click, which is an alarming thing to look at.
* A failure was drawn and then wiped by the redraw in the same finally block:
  the message appeared and vanished in one frame. What went wrong is state now,
  not an argument to one draw call.
2026-09-06 13:56:17 +03:00
digital-garbage e3793ee154 core, app: FunPack updates itself again
Second off the port list, and the one that gets reached for most: a pack whose
value is experimental work shipped continuously lives on switch-branch and
update.

git_update.py comes across from v4 as core/update.py, changed in three places
and no more: where the repo root is, a `remote=False` mode, and a 20-second
timeout on the one call that touches the network. It has been switching
branches on real rentals for months and every refusal in it -- no git on PATH,
not a checkout, a dirty tree, a branch that exists on neither side -- is a
lesson that cost somebody an afternoon. Rewriting it to look more like v5 would
trade proven plumbing for a fresh set of edge cases.

It is core rather than a module because it is about the INSTALL: no module can
announce the thing that updates every module.

Four routes, and every one of them ends by relaunching ComfyUI -- so the answer
is written first and the restart is a callback scheduled after it. `restart` is
looked up on the module rather than captured, or running the suite would
relaunch the test runner.

The window reads the checkout TWICE: once without the network, so there is
something to see instantly, then again with it for ahead and behind. A fetch
takes seconds on a good day and blocks until it times out on a machine that
cannot reach the remote, and neither is a reason to show an empty box. A dirty
tree is said before either button is pressed rather than as a refusal after.

Then it waits: an overlay that polls /api/health until the server answers, and
says so when it does not come back rather than spinning forever. That last part
needed the kit's blocking overlay to be able to change its message -- it could
not, so my give-up branch would have failed silently, which is the exact
pattern this project keeps finding.
2026-09-06 13:46:26 +03:00
digital-garbage 9ef2707a36 app: undo, an Edit menu, and a way back to a layout
First off the port list. Every edit in v5 was final -- v4 has had undo since
store.js, which is why it never showed up in a file-by-file inventory: there
is no undo.js to find.

A project is plain JSON and a few kilobytes, so undo is a snapshot before each
edit rather than an inverse per action. Capped at 40. The SELECTION travels
with the snapshot: undoing a removal and landing on a different scene than the
one that came back is not what anyone means by "take that back".

Two things it deliberately does not do. A new edit ends the redo line, or a
redo walks into a version that never followed from what is on screen. And a
project just opened has no past -- otherwise undo reaches back across a
project switch and replaces the open project with a version of a different one.

Edit menu: undo, redo, add and delete scene, move clip either way. Built on
each press so Undo is offered only when there is something behind it; a menu
that always offers it teaches people it does nothing. ⌘Z and ⇧⌘Z do the same,
except inside a text box, where the browser's own undo is the right one --
taking it over means one keystroke un-typing a paragraph instead of a word.

View gains Reset layout, for the person who closed both panels and cannot
remember what brings them back.
2026-09-06 13:32:08 +03:00
digital-garbage c338769431 app: the run used the project you left, not the one you opened
Found by the adversarial pass, reproduced twice.

Switching projects put the new project's numbers in the store and left the
OLD ones in the controls that produce what a run actually sends. The store's
own getter reported the new project correctly and the Inspector agreed, so
the only place the stale value showed was the Constructor -- a window nobody
has a reason to reopen after switching. Generate then ran at the size of the
project you left.

The attacker found it on the File-menu path. It is on three: opening from the
menu, opening programmatically, and applying a pipeline change, which rebuilds
every control from the PIPELINE's defaults and discards the project's. So the
fix is not in the menu handler -- the store now says when a DIFFERENT project
is open, which is not the same event as a field changing, and everything that
follows a project is wired to that instead of to the one route in that
happened to be reported.

Second finding, same pass: setVideo said "nothing on screen draws from this"
and did not report the change. True when written; the timeline started
computing its clip widths and its ruler from that value an hour later. A
comment is not a thing the next change has to keep true.

Also serial-mode for the project spec: two tests making and switching projects
share one real store on the dev server, and the config runs a file in
parallel. A race over a JSON directory, not a bug in the app.
2026-09-06 13:07:08 +03:00
digital-garbage faaa465d68 app, core: the inspector, the wheel, ratings, a ruler, and a way in
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.
2026-09-06 12:30:59 +03:00
digital-garbage 33eeff08e3 app: the dials on the main window, and a way to have two projects
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.
2026-09-06 12:18:33 +03:00
digital-garbage 77f72bbaee app: a run's result lands on the scene it was started for
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.
2026-09-06 11:56:35 +03:00
digital-garbage 9a4fb4eca0 app, core: what the project is generated at
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.
2026-09-05 22:16:01 +03:00
digital-garbageandClaude Opus 5 9dcd94c156 app, core: a project of scenes, and the timeline that draws it
The app could run a pipeline and had nowhere to keep what it was making. A
project is an ordered list of scenes, one JSON file each, and it is the only
part of the app that has to survive a reload.

A scene owns its prompt text and the result it produced, and nothing else. v4's
Scene grew three dozen fields; each belongs to the feature that needs it. Per
scene length and fps are deliberately absent — a regenerate uses the project's
values, so a scene cropped on the timeline comes back whole.

The prompt box edits the current scene rather than holding its own copy, so
what is on screen and what a run uses cannot disagree.

app: the region toggles are named buttons in the timeline bar

v4's arrangement. The kit's fallback is one glyph on a rail at each edge of the
window, which is right for a kit that cannot know what its panels are called and
wrong here, where they have names and a bar to put them in.

A pressed toggle was styled only inside a button group, so a standalone one
carried aria-pressed and looked identical either way.

gallery.strip is selectable like its siblings: a filmstrip is the one shape
whose whole job is saying which of these you are on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:03:19 +03:00
digital-garbage 62f92ecaf8 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.
2026-08-30 22:11:53 +03:00
digital-garbage 5b59043fc8 core, app: a prompt you can type, on the main window
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.
2026-08-30 20:27:44 +03:00
digital-garbage 1b0f489950 core, app: what the panels hold now reaches the run
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.
2026-08-30 20:16:46 +03:00
digital-garbage 7337721943 app: the bin — results a session produced, in three views
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.
2026-08-30 20:07:07 +03:00
digital-garbageandClaude Opus 5 8bfd28950d app: the models and pipeline window
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>
2026-08-30 16:36:01 +03:00
digital-garbage ea30989752 app: half the queue was never read
/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.
2026-08-30 04:39:00 +03:00
digital-garbage 28550e5742 app: a button that looked live and did nothing, one line after being disabled
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.
2026-08-30 04:33:40 +03:00
digital-garbage 0a7a2a1917 app: the load ordering, where every fault on this path has been
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.
2026-08-30 04:27:51 +03:00
digital-garbage 4bc6a1e400 app: one client id per TAB, one run per click, and a reason that survives
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.
2026-08-30 04:09:02 +03:00
digital-garbage 518e11cc1a app: a run that finishes while the page is loading was lost outright
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.
2026-08-30 03:55:46 +03:00
digital-garbage c4120cc178 app: the window between Generate and the run having a name
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.
2026-08-30 03:46:34 +03:00
digital-garbage f37f526268 app: Generate wired to a real run -- queued, progress, cancel, result
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.
2026-08-30 03:36:40 +03:00
digital-garbageandClaude Opus 5 37430137cb foundation: run it, and fix what running it found
First real generation through FunPack's nodes: SD1.5 on this Mac, real
weights, a real picture of an apple. Everything before this was static.

What it proved, on a real model rather than a fixture: the empty latent
takes its shape from SD1.5's own format with no family branch anywhere
(1, 4, 64, 64, an IMAGE latent from a pack built for video); the traits read
off the live model are predict_eps and spatial_latent; and ALG is correctly
absent, saying "needs temporal_latent", rather than running on a model with
no time axis.

What it found: the absolute-core-import bug again, this time INSIDE A
FUNCTION BODY. It imported fine, registered fine, passed 258 tests, and
raised at execute time -- during the generation. A suite that imports every
module cannot see an import that only happens when a node runs, so the check
for it is now static and walks the AST: no module may name `core` anywhere,
at any nesting, except through the shim.

The dev preview loaded one module in nine. It runs system python, which has
neither ComfyUI nor torch, so every module that imports either was absent
and the app looked almost empty while reporting nothing. It now re-execs
under ComfyUI's interpreter. Looking at the app is meant to be how the app
gets tested; it has to see what ComfyUI sees.

ALG asked for a mount point no region offered, so its panel vanished in
silence. The shell now offers one per stage, and a modules page lists what
loaded, what asked for a mount nothing offers, and what failed to import --
absence is correct behaviour and indistinguishable from "never installed",
so somewhere has to say which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:20:50 +03:00
digital-garbage 835fab7864 core+shell: the bridge, and the first module through it
One declaration does three jobs: renders the panel, carries the defaults, names
the values that come back. A module writes no DOM and reads no DOM.

Validation refuses rather than repairs -- unknown type, a renderer its type does
not have, a label that is not text, a default outside its own bounds, a when
naming a key the module never declared. A module that is wrong does not get a
best-effort panel; it does not load, and says why once.

Ordering is by declared relation and stage, never priority numbers, and a cycle
drops only the modules in it. Compatibility is by trait, never model name.

The shell hands a module { composer, values, on, shell } and nothing else: no
node, no document, no kit internals. It cannot style anything because it is
never given anything that could.

catalogue/hide.html puts one good module and five broken ones through the real
shell and shows the result: one panel, five absent, no placeholders. The lint
fixtures make the scanner prove it catches each rule, so a clean scan means
something.

First slice: modules/system/appearance. Two settings, an optional ui.js, and
clicking Light actually turns the app light.
2026-08-28 19:58:06 +03:00
digital-garbage 58193d3f21 composer: the internals, provable with no elements defined
register/zlayer/anchor/motion/density are pure cores over plain data, so most of
the suite runs with no DOM at all; jsdom covers only what is genuinely DOM.
100 tests, zero elements.

These five are why v4 had 11 hand-built modals and 7 hand-built popovers: there
was nothing shared to reuse. register makes an element exist only because a file
defined it; portal gives every overlay one stacking parent; zlayer owns the only
z-index in the app; dismiss keeps one stack so Escape pops the top and not the
modal underneath; anchor measures, flips and clamps once.

Three bugs the tests found: unmount() called removeChild on null when no portal
existed yet, focusables() checked aria-hidden on the element instead of its
ancestors so a hidden subtree stayed tab-reachable, and the density ratios v4
hand-tuned are now derived from the gap they were meant to allow for.
2026-08-28 15:03:19 +03:00
digital-garbage d5effb0378 core: serve the app, with the module allowlist as the wall
Static serving routes through a pure resolve(), so the extension allowlist and
the traversal guard are tested without aiohttp. modules/ serves .js only: a
module cannot ship a stylesheet even if one is written.
2026-08-28 14:18:55 +03:00