Commit Graph
386 Commits
Author SHA1 Message Date
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 30e136733a tests: a hung test now fails instead of hanging forever
`node --test` has no timeout by default. Two revert-checks left runs wedged on
session.test.js for forty minutes with no output and no way to tell from the
outside whether they were working or stuck.
2026-08-30 04:45:09 +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-garbage 29790cb8dc foundation: a graph correct at every link, and a window that fits but is placed off the edge
Round four, both findings confirmed against the code.

A cycle is the one wrong graph that passes a per-link check: every type
matches, every id exists, every required input is filled. build() returned it
as queueable, and ComfyUI caught it at execution instead -- a run accepted and
then killed from inside the executor, with the reason in a place the app has no
reason to be reading. Walked with an explicit stack: slots come from a request,
so a long chain must produce a refusal, not a RecursionError.

The window clamp measured size against the viewport and position against the
viewport minus a grabbable strip. Those are two budgets that do not add up, so
a 900px window remembered at x=1000 opened at x=720 of an 800px screen with its
close button and grip past the right edge -- while every "a strip is reachable"
assertion still passed. Dragging keeps the loose rule, because putting a window
half off the screen on purpose is ordinary; opening, restoring and a shrinking
viewport are not placements anybody made, so the window is placed whole when it
fits.
2026-08-29 22:27:43 +03:00
digital-garbageandClaude Opus 5 5b21ee7c92 foundation: both gaps round three found were gaps in round two's fixes
A malformed slots payload crashed the route with a TypeError and answered
500 in plain text. The round-two fix handled the ABSENCE of slots and not
their SHAPE, so reading slot["id"] off a string went straight through -- and
a refusal the app cannot parse is the failure this endpoint exists to avoid.
The shape check is its own function now, because a malformed REQUEST and an
unfinished PIPELINE are different things and get reported separately.

And a window could still be made unreachable, by resizing rather than
moving. Round two clamped position and said "a window can never be put
somewhere it cannot be recovered from"; size was never bounded, so a corner
drag grew it to 4937px in a 900px viewport and carried its own close and
maximise buttons off the screen, leaving Escape as the only way out.

Capping size to the viewport was not enough either: with x above zero the
right edge still left. Resizing is now capped to the room available from
where the window sits, which is a deliberately different rule from dragging
-- a window may be DRAGGED partly off, because a grabbable strip is
guaranteed and that is ordinary behaviour, but it may not be GROWN past the
edge it is anchored to.

Two rounds of fixes, two rounds of holes in those fixes. Both found only by
attacking the code as it stood after the previous fix rather than stopping
at the report that prompted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:18:41 +03:00
digital-garbageandClaude Opus 5 b62e88f44f composer: a window you cannot reach, and an empty pipeline that filled itself
A floating window remembers its position per id, and only the DRAG handler
clamped. So a window moved to the right of a wide screen and reopened on a
narrow one opened entirely off the viewport: no titlebar on screen to grab,
nothing to drag back, and the position remembered -- unrecoverable without
clearing localStorage by hand. Reproduced at x=1400 in an 800px viewport.

The clamp lives in apply() now, so every path goes through it: opening,
restoring a remembered box, coming back from maximised, and the viewport
shrinking under an open window. Also guarded for a viewport narrower than
the grabbable strip, where the old bound went negative and pushed the window
further off rather than back on.

jsdom cannot see any of this -- getBoundingClientRect is zeros there, so a
window off the right edge measures identically to one in the middle. The
three tests are Playwright, and removing the clamp fails two of them.

And POST /api/pipeline could not accept an empty pipeline: `slots or
default()` resurrects the built-ins, because an empty list is falsy. A client
that removed every slot got them handed back. Absent means "use the default";
empty means "there is nothing", and only one of those is a fallback.

One of the tests I first wrote for this asserted len(x) < len(y) + 1, which
is true of almost anything. It counts the pipeline at every step now and
fails if it ever grows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:10:43 +03:00
digital-garbageandClaude Opus 5 bd0c4be42e composer: Playwright, and the two layout faults it found in its first run
The plan asked for jsdom AND Playwright from the start and only jsdom was
ever built. jsdom has no layout engine: getBoundingClientRect answers zeros,
container queries never fire, a theme is an attribute rather than a computed
cascade. So every claim about POSITION or SIZE in this kit was unchecked, and
the unit suite could not have noticed either of these.

A popover bounded its height and not its width. `max-height: 60vh` was there
from the start; there was no max-inline-size at all, so a long unbroken
string -- a file path, a model filename -- made the popover wider than the
screen and anchor.js could not save it: it can move a box, not shrink one.
Measured at 1436px inside a 1280px viewport, off the right edge in all four
corners.

A horizontal split could not fit a narrow screen. The first pane carries an
inline size and `flex: 0 0 auto`, so it would not shrink, and the page
scrolled sideways at 360px -- 407px of content in a 360px viewport. It wraps
now, which stacks the panes, rather than a media query: the kit sizes itself
from its container and not from the window.

Nine layout tests: every overlay corner, an overlay taller than the viewport,
both themes resolving every token they use, light actually being lighter than
dark, and no horizontal scrollbar at five widths. `npm run test:all` runs both
suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:57:52 +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 78c43acd67 contract: a colour type, and which run this is
Colour is a preference, so it is a setting: hex in, hex out, one spelling,
refused if it is not one. A module handed "red" in one place and "#f00" in
another guesses wrong somewhere.

Image and mask are NOT settings and deliberately have no type. They are
data -- tensors the app uploads and the graph wires -- and ComfyUI already
has socket types for both. Putting a drawn mask in a JSON settings payload
would be inventing a second way to move something that already moves.

Runs now have an id. Previews, progress, results and log lines all have to
be attributable to one generation, and ComfyUI gives a node no way to ask
which run it is in. So FunPack marks its own, every log record carries it,
and the history can be filtered to a single run. Readable rather than a
uuid, because it is going to be read: "run 7" is findable in a wall of
output and a hex string is not.

The colour element was caught by the registry-driven rule test before it was
finished -- an element with no catalogue demo is neither shown nor attacked,
so coverage keeps up with the registry instead of lagging behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:29:38 +03:00
digital-garbageandClaude Opus 5 74671329f9 log: a severity, a source, and a message
[Log] Info: FunPack Diffusion Model Loader | flux.safetensors loaded, attention sage
    [Log] Alert: MiniMaxH3ImageToVideo node | no "first_image" input is connected
    [Log] Warning: FunPack LoRA Loader | style.safetensors is empty and will not load
    [Log] Critical: torch | cannot allocate 14 GB (3 GB free); the run will not start

The difference between the middle two is the one that matters. Alert is a
choice you may not have made on purpose. Warning is something that will NOT
do what its name says -- the level v4 needed and did not have, which is how
560 handlers came to swallow a failure and carry on, so a feature could be
switched on, do nothing, and never say so.

Info is not decoration either: "loaded on GPU using sage attention" is how
an attention setting that did nothing gets noticed months before the
pictures look wrong enough to investigate.

Every call site moved -- there is no note() left to be vague through -- and
the loaders now say what they actually loaded rather than only what they
were asked for. Records are kept in a bounded ring as well as printed, so a
long rental session cannot turn the log into a memory leak, and served at
/funpack/api/log so a person can read it somewhere other than a terminal
full of ComfyUI's own output. There is a page for it.

Also dropped the preflight this was traded for: min and max are already
declared and already enforced at both doors, and a second protection layer
around a check that exists is ceremony.

Two mistakes worth keeping: a log line reached into the model's shape to
describe it and could have failed the load it was only narrating; and both
new pages passed `children` to an element that takes `rows`, which the kit
accepted and silently dropped. It throws on an unknown element and not on an
unknown prop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:09:33 +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 73e81b0263 composer: bind every overlay's z-index, not just windows
A rung compacts when a claim is released, so an overlay's z can move without it
doing anything. Six of seven call sites read z once at open time and kept a
number that was later handed to the next overlay: two of them at one level, DOM
order deciding which wins and which eats the clicks. Reproduced with two menus
and a third opened after closing the first -- both ended up at 501.

claimFor(rung, node) binds the node, so there is nothing left to forget, and the
raw claim() stays for the one case with no node. The test walks the registry
rather than naming overlays, because the bug was one site remembering and six
forgetting.

Also drops a no-op ternary that picked the modal rung either way.
2026-08-28 19:38:07 +03:00
digital-garbage c3df8b2d45 composer: pin the edge panel properly
Three bugs in wheel.half, all found by looking at it:

The hub was centred on the edge, so the edge sliced it in half -- half a cancel
target is not a target. It is pushed inward by its own radius now, and the
wedges start beyond it. The ring is an inset shadow rather than a border,
because a border pushes every absolutely positioned child inward by its own
width, which put the hub a pixel past the screen edge.

Highlighting fired anywhere the pointer went, including well outside the wheel,
promising a pick that release would not deliver. The hit test is bounded by the
same rIn/rOut the wedges are drawn from, so the lit shape is the region.

An edge panel did not follow its edge: it kept the pixel position it opened at
and drifted inward when the window grew. It re-derives its anchor on resize,
and on visualViewport resize for pinch-zoom and mobile chrome.

One GEOMETRY object per variant now drives the wedges, the labels, the hub and
the hit test together, so they cannot disagree.
2026-08-28 19:29:57 +03:00
digital-garbage 563a3fdd80 composer: edge panel, list row controls, and three fixes
wheel.half pins the centre to a screen edge and fans the items inward, for a
control that lives at the edge and has nowhere to open a full wheel. The arc
geometry is now shared with the full wheel, so the two cannot disagree about
which item a direction means. The hub is a glyph sized to the dead zone: the
cancel target you see is the one you hit.

list.rows takes a real control per row. A bare number printed beside the reorder
arrows reads as a stepper for that number, so moving a LoRA looked like editing
its weight.

setBusy re-derived disabled from the constructor's value, so finishing a busy
spell re-enabled a button that had been disabled since; busy and disabled are
tracked separately now.

An emptied number field committed as 0 and snapped to min, discarding the last
good value -- a browser normalises invalid text to empty, and Number('') is
finite so it sailed past the isFinite guard.

A context menu anchors to a POINT, and that stand-in reached the dismiss stack,
which threw on the first click anywhere.
2026-08-28 19:17:54 +03:00
digital-garbage 094e056760 composer: the elements
55 element groups across text, buttons, choice, input, slider, layout, status,
overlays, gallery and the wheel picker. Each lands in a registry-driven
catalogue that shows every variant in both themes, so an element that fails to
register is visibly absent rather than quietly missing.

rule_content_is_data iterates the REGISTRY, not a list: a new element is
attacked with markup the moment it calls define(), and one without a demo fails
the suite. That is announcement-not-enumeration applied to the tests.

Fixes found while building: checkbox silently ignored its label (inverted
logic left it with aria-label=toggle), the overlay root set z-index 0 and so
became a stacking context that trapped every overlay it was built to free, and
raising a window left its peers' z stale because z is derived from position --
claim() now notifies a rung when a peer moves.

The wheel highlights the actual sector rather than a box around the label: the
lit shape is the region that picks the item, which is what makes it aimable.
2026-08-28 19:07:23 +03:00
digital-garbage 0e842d1666 composer: make a rung an ordered list, and cover drag
raise() could send a claim to the BACK. Each claim owned a fixed offset, so on a
nearly-full rung there was no free slot above the top and the fallback took the
lowest free one instead: a floating window clicked to front went behind its
peers. Only reachable once a rung fills, which is a long session, not a test.

A rung is now an ordered list and z is derived from position, so raise() is a
move to the end and release() compacts.

drag.js shipped with no tests. 12 now, including the ones that matter: a second
pointer cannot hijack a drag, capture is released on cancel, and dispose stops a
drag already in progress.
2026-08-28 15:06:41 +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 dd7c531fce composer: bundle Figtree, Archivo and IBM Plex Mono
Self-hosted, so the editor is correct offline and behind firewalls that block
Google's CDN -- v4 linked the CDN and silently rendered in a fallback there.
133 KB: Figtree and Archivo are variable, one file per subset; latin and
latin-ext only.

The OFL texts ship beside the fonts because redistribution requires it, and a
test fails if a bundled family arrives without one.

A missing face degrades silently into the fallback stack, so the faces declared
in fonts.css are checked against what is actually on disk, both directions.
2026-08-28 14:47:31 +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
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