Commit Graph
45 Commits
Author SHA1 Message Date
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 7ec055c7e8 app: drag to reorder scenes, and a zoom for the strip
Drag a clip onto another to reorder; an S/M/L control changes clip width
(--strip-w), remembered per browser. Trim already existed as the Scene
inspector's length field.

The dragged clip is tracked by its DataTransfer payload, not a closure
variable keyed on DOM position or id -- a redraw mid-drag (any project
change, not just a removal) tears down and rebuilds every cell, and
dragend fires on the original source node regardless of whether a
redraw detached it from the tree. Two adversarial rounds against this
file specifically, both fixed before landing.
2026-09-06 18:37:22 +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 aceb06803d app, core: a rating is liked or it is not
dev went binary on H3 and said why: the steering reads the SIGN of a rating
and never its size, so a ten-point scale was the UI claiming a precision
nothing downstream used -- and at the sample sizes this actually runs at, a
mediocre middle rating only diluted the direction. v5 had invented a
four-way scale of its own; it now stores what dev stores.

Pressing the one that is on takes it back. Without a way out a mis-click is
a permanent opinion, and "no opinion" is a real answer nothing should have
to guess at.
2026-09-06 12:52:13 +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 16eec81a03 app: the app is one surface, not a tray of cards
Ran v4 side by side to see what the difference actually was, and it was
not spacing. v4 fills the window edge to edge and divides its zones with
hairlines; v5 floated rounded, shadowed cards on a visible page
background. One reads as an editing suite, the other as a dashboard.

So panel gains a second variant. "default" is still a card, for something
that floats -- inside a window, on a page. "zone" is a region of the app:
square, no shadow, no border of its own, divided from its neighbours by
the one hairline everything else uses. The workspace keeps no gutter
between regions; the preview zone is flush, because padding around a
picture is a frame nobody asked for, and its surround is letterbox black
rather than a panel colour.

The right column was two stacked panels with two heads for one thing.
It is one zone now.

And the surfaces have depth, derived from each theme's own elevation
scale so neither needs a rule of its own: a vertical fall on every band
and every raised control, the accent gradient and a bloom under a primary
button, a wash off the top-right of the page, tokenised thin scrollbars,
an accent mark on every zone title, and v4's film grain -- overlay at
3.5% on dark, multiply at 1.5% on light, and painted with no z-index so
anything that opens above the frame stays crisp.
2026-08-30 21:17:39 +03:00
digital-garbage 549e430806 app: one gutter, one head height, one control column
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.
2026-08-30 21:01:50 +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 3a1c76d64e app: two of the same window, and an edit undone by an older one
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>
2026-08-30 17:20:30 +03:00
digital-garbageandClaude Opus 5 66e4bce345 core, app: a value the queue would refuse, caught before it is queued
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>
2026-08-30 17:11:51 +03:00
digital-garbageandClaude Opus 5 5f0247405c app: three ways the UI kept what it said it had thrown away
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>
2026-08-30 17:03:55 +03:00
digital-garbageandClaude Opus 5 1295f1a365 app: an edit outlived the node it was for
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>
2026-08-30 16:41:54 +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-garbageandClaude Opus 5 eca4083837 core: a dropdown read as a wire, and a legal wire refused
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>
2026-08-30 16:35:52 +03:00
digital-garbage 78b623b2f7 app: panels sized by what they hold, and out of the way when they do not fit
The columns were a share of the viewport, which is not a measurement of
anything. A 900x900 window -- an ordinary shape -- gave the properties column
198px, in which "Loosen the starting image" wrapped over three lines and a
section heading broke mid-word; 375px put three columns side by side with
clipped headings and a preview no wider than a thumbnail. A panel is now as wide
as a settings row needs, and below 760px it stops taking space from the centre
and overlays it instead.

The element decides what is OPEN and the stylesheet decides how it is DRAWN, so
entering that mode closes both panels and only one opens at a time -- two
overlays over a narrow centre is the same problem with an extra step. What was
open is restored on the way out, and none of it touches the remembered
preference: a window being small is not the user changing their mind.

The rails paint above the overlay. Without that the panel covered the only
control that closes it, which is the exact fault this arrangement exists to
avoid, arriving by a different route the moment the panel stopped taking its own
space.

Also: the prompt was holding 38% of the height for two lines of text. The result
gets the room; the divider is still draggable.
2026-08-30 04:51:53 +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 6a588d3143 app: a cancel that did nothing, and said nothing
/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.
2026-08-30 04:17:29 +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-garbage 2bd604721c app: the base frame -- panels docked beside a preview that shrinks
v4's Simple mode is the Editor with about ten `display: none !important` rules
over it, which is not a smaller app but the same one wearing a mask. It broke in
the way that arrangement always breaks: a collapsed column was marked [hidden],
nothing can slide a display:none element into view, and the panel button did
nothing at all and said nothing. So the same shape is built the other way round
-- a region that does not exist yet is ABSENT, and the toggles live in rails
that are siblings of the panels, never inside what they hide.

Two kit elements, because both are layout primitives rather than app code:
frame.app establishes the one definite height everything else fills, and
workspace.docked is the centre-shrinks-between-two-panels arrangement.

Three faults found by looking at it rather than by reasoning about it:

* The prompt was in the DOM with no pixels. A vertical split inside a parent
  whose height came from its content resolves its second pane to zero, and
  nothing anywhere reports that.
* The whole app rendered as a strip at the top of an empty window: `100%` of a
  `min-height` is `auto`.
* Two fixed-width panels plus their rails are wider than a 480px window, and the
  page scrolled sideways -- taking the transport bar with it.

The collapse is deliberately not animated: a remembered-closed panel would
animate shut on every load, which reads as the app doing something rather than
as a preference being restored.

Also: jsdom gives a document with no url an OPAQUE ORIGIN, where reading
window.localStorage throws on the property access itself. Everything that
remembers a preference was silently taking its fallback path in every jsdom
test, so "it is remembered" could not be tested at the cheap layer and looked
like it worked.
2026-08-30 03:20:33 +03:00
digital-garbageandClaude Opus 5 89e4b24b59 composer: a module is handed no way to reach the page, and one test now checks it
The sandbox was a claim the code could not make. `panels.js` said "no DOM
node, no document... a module cannot style anything because it is never
handed anything that could" -- while handing over `composer`, whose factories
return handles wrapping a real element. A module could take one, style it,
and reach the whole document through node.ownerDocument. No lint to evade,
just the intended API.

No module ever used it. Panels are rendered from the DECLARATION, and a
module that wants to draw something should be declaring it. So it is gone,
and the guarantee is now true as stated: the kit hands a module nothing that
touches the DOM.

What is NOT claimed any more: that a module CANNOT reach the DOM. A ui.js is
an ES module in the page realm and `document` is a global. kit_lint is a
discipline check, not a boundary -- globalThis["docu"+"ment"] defeats every
regex and always will. It now catches the bracket-access spellings, which
were the first thing tried and went straight through, and says plainly what
it is and is not.

The test that was supposed to protect this asserted "no value IS a node"
while a value that MAKES nodes sat in the same object. It checks what it
names now.

And the overlay compaction test computed a before-snapshot and discarded it
with `void before`, checking only that survivors do not collide -- true even
if nothing ever moves. Two attempts to fix it failed against CORRECT code
(earlier tests leave live claims, so the rung has a legitimate gap; and some
demos close themselves, so closing them again is a no-op). It now opens two
overlays of its own so the invariant runs every time, and removing notify()
from zlayer fails three tests instead of none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:51:03 +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-garbageandClaude Opus 5 7330ba0c18 core: nodes are announced by modules, like everything else
ComfyUI's V3 entrypoint replaces NODE_CLASS_MAPPINGS, which must not exist
at all: nodes.py checks it first and reaches comfy_entrypoint only in an
elif, so an empty dict registers nothing and reports no error.

A module declares NODES for graph structure and SETTINGS for preferences,
and the two never mix -- a saved workflow stores class_type and input
names, so letting a new setting add a socket would rot existing graphs.

Traits now come from two places: facts core can read off any model, and
providers announced by a model's own module. Core names no model, so
supporting a new one is a folder rather than an edit here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 03:03:19 +03:00
digital-garbage e2661a3965 core: a bad relation must not take healthy modules with it
A cycle rejected everything still waiting, not just the modules on it. One that
merely says 'after B' is not itself circular, and once B is dropped its relation
has nothing to wait for -- the same as naming a module that was never installed.
Now only the ids that can reach themselves are dropped, and a dropped id stops
being a dependency so the damage stops rather than spreading downstream.

A relation could also overturn the stage order: 'load after conditioning' was
honoured, putting a whole stage out of sequence to satisfy one module. Stage is
the coarse order; an impossible relation is dropped and logged, and the module
itself is kept.

panels.js now keeps what a module's setup() returns, which is its unsubscribe --
discarding it leaves a live listener behind on every re-mount.
2026-08-28 20:03: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 fd0a05d982 composer: the token layer, and a catalogue that reads it
Cleaned scales from v4's measured usage: 11 control heights to 4, 19 radii to 6,
9px named as a real type step, one reduced-motion guard because every duration
is a token now.

Derived tokens are declared on [data-theme] as well as :root -- a custom
property's var()s are substituted where it is declared, so on :root alone a
scoped light subtree inherits the dark accent.

The catalogue reads the tokens out of the stylesheet instead of listing them, so
it cannot drift; anything it has no renderer for shows up as Ungrouped.
2026-08-28 14:19:02 +03:00