Round 1 + round 2 of adversarial review on the single-track timeline
(2092c8b) found and fixed:
- Clip position now stays absolute (seconds*pxPerSecond) instead of the
MIN_CLIP_PX floor cascading into every later clip -- a floored short
clip no longer pushes everything after it out of sync with the ruler.
- pendingSeek is cleared unconditionally right after project.select(),
closing a gap where a no-op select() (clicking within the already
selected clip) left it dangling for a later, unrelated draw to
wrongly replay.
- draw() now always resyncs the playhead to the current scene's start
on any non-track-click selection change, rather than only when the
old value fell outside the new scene's span -- the old check let a
stale playhead survive a wholesale project switch whenever it
coincidentally still fit the new project's first scene.
- The timeline moved from the centre column's own inner split to an
outer split above/below the whole three-column workspace, matching
v4's actual layout (full-width timeline under resizable columns,
not squeezed inside one of them).
- That restructuring put the Properties column under real height
pressure for the first time, exposing a latent bug: .cx-stack's
base rule set min-block-size:0 on every stack, letting a plain
grouping stack (not meant to scroll) get flex-crushed below its own
content height, so its overflow painted over the next row ("Settings"
over "Loosen the starting image"). Removed from the base rule --
.cx-stack-fill already carries it for the one case that should
actually give up height to scroll.
- Documented, rather than "fixed" by reintroducing the rejected
cascading-position bug: several under-floor clips in a row still
visually occlude each other (later DOM paints over earlier). Click-
seek still resolves correctly since it reads time, not paint order.
- Guarded framesOf() against a negative/NaN scene.length the same way
fps() already guards its own input.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the scene-index-tick ruler with composer's new track.default
element: clips sized by real duration (frames/fps, not equal-width
boxes), a ruler in genuine elapsed seconds, and a playhead that follows
the selected scene but can be dragged to any exact second within a
clip via click-to-seek.
Also fixes a real crash found while live-testing this: addScene()
built a scene object missing source_image/references, which crashed
the Inspector the moment it tried to draw the new scene.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pipeline wiring: core/graph.py gains wire()/unwire(), and the "Models and
pipeline" editor can now actually connect one node's output to another's
input -- sockets and widgets alike, which is what makes "linked inputs"
(several widgets sharing one Primitive node's value) buildable at all,
without a second mechanism. Three adversarial review rounds found and fixed
real bugs along the way: check()'s request builder silently dropped the
wire-specific fields; the widgets loop had no wiring UI at all despite the
backend supporting it, and a widget already holding a link rendered as an
editable box with the wrong (default) value; wiring an input that already
had an unsaved drafted value let the stale value silently win on Save. Also
fixed, found live rather than by review: the Add-node picker stacked two
disagreeing search boxes (an outer server search and filterList's own
redundant client-side one).
Updates window: "Restart ComfyUI" is now a plain, always-available button,
not just something offered when an update already landed. Rolling back or
switching branch on a dirty tree now auto-stashes instead of refusing --
updating still refuses, unchanged, since pulling new commits onto changes
in progress is not something to do without asking. Three review rounds
here too: stashing before reading the rollback target shifted what HEAD@{1}
meant (a real reflog hazard, not just reasoned about); a stash's disclosure
was lost if the step right after it failed; that fix was too narrow and
missed pull()'s own failures inside checkout(pull_after=True) -- the actual
default path -- and rollback()'s own reset failing; and finally, the
broadened fix still only caught GitUpdateError, missing subprocess-level
failures like a hung fetch timing out, which is the realistic failure mode
on a rented GPU box's network.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Files the user brings IN (a reference image, an imported clip) now have a
real store: core/media.py, /api/media routes, and a "Media" tab beside the
bin's "Results" tab in the Assets zone. Upload, list, fetch, delete all
round-trip through the real server; nothing wires into a pipeline input yet,
since no model module or Studio conditioning takes a reference image today.
Two adversarial review rounds found and fixed three real issues along the
way: MAX_BYTES (512MB) was dead code because aiohttp's own client_max_size
capped every upload far lower first in both prod and dev, with a JSON-parse
error leaking to the user on rejection instead of a clean message; the
index.json read-modify-write had no guard against concurrent writers; and
the devserver's bare Application didn't match ComfyUI's own 100MB default,
which also broke an unrelated e2e assertion once the review polluted the
real, unisolated media/ directory testing the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three things the unified Settings window was still missing next to v4's
own, all found by looking at it next to v4 rather than in isolation:
- The sidebar is a 52px icon rail now, expanding to a full list on
hover/focus and overlaying the content instead of a splitPane's
permanently-docked two-pane shape -- v4's own sidebar, not an
approximation of it. Fixing this surfaced two real CSS bugs along the
way: filterList's own min-width:220px silently defeated the collapse
(clipping the search box to "Sear" instead of showing an icon), and
.cx-filter-list's unset overflow-x (computed to auto once overflow-y
was set) put a horizontal scrollbar under it. Centering the icon in
the collapsed row needed the label's flex-basis AND the row's own gap
to both go to zero -- an invisible label still claims its share of
both otherwise, and the icon renders off-centre.
- The modal now has a real height (v4's own 660px) rather than sizing to
whichever section is showing, so switching between a short section
(About) and a long one (Node packs) doesn't visibly resize the whole
window. That exposed a THIRD latent bug: .cx-modal-body never had
flex:1 1 auto, invisible until a modal had a real height to fill --
every prior modal shrink-wrapped its content, so head+body+foot always
summed to exactly the card's height by accident. Fixed at the shared
rule, not just for Settings.
- About now shows what v4's does: commit and dirty state, a codename
when the checkout has one, and the machine's real hardware/software
facts (chip, memory, GPU or MPS, disk, OS, ComfyUI/Python/Torch/
attention-backend versions, hostname) -- a new core/sysinfo.py
(ported from v4's own, individually guarded so a missing probe never
takes the panel down) behind a new GET /funpack/api/system, injected
into settings_window.js the same way every other network call here is.
Also: a nav row's tone (the coloured icon chip) is now validated against
its closed set the same way button.js already validates its own tone --
an unknown one used to render silently wrong instead of failing loudly.
An outside click on the Settings window closed it unconditionally --
pipeline_window.js's own standalone modal has always refused to close
that way (a group edit's draft is the only copy of it), and hosting it
inside Settings silently dropped that guarantee, with no way left for a
real user to reach the protected version (boot.js has no other caller
of pipeline_window.js any more). Settings now holds the door the same
way, for every section: an accidental backdrop click during any
half-finished edit no longer costs it.
filterList's icon `tone` claimed to be a closed set, the same rule
el()'s style refusal already holds -- but nothing checked it, so a
typo would have rendered a silent, unstyled chip instead of failing
loudly the way button.js's own tone check already does for the same
mistake. Now it throws, matching that sibling pattern.
A bare glyph character had no framing at all; filterList's icon now gets a
coloured chip, drawn from a fixed tone (accent/danger/warn/good/neutral) --
never a raw colour from data, the same rule el()'s style refusal already
holds everywhere else. Each Settings section picks one.
The sidebar was the filterList element's small dropdown-list shape (a
240px-capped box with its own border, floating in a large pane) rather
than a real full-height sidebar; .cx-filter-full opts a host into the
pane version without changing the compact one pipeline_window's own node
picker still uses.
Round one had a per-row hint line under the icon+label, which doesn't
exist in v4's own sidebar and broke alignment the moment a hint wrapped
(icon and label baseline-aligned against a hint floating as an independent
sibling rather than stacked under its own label). Fixed the real bug --
label+hint are a column now, not loose flex siblings -- then removed the
hint entirely to match v4: a section's subtitle belongs in its own head
once picked, not repeated under every row whether picked or not.
About is centred -- mark, name, version/branch -- rather than a plain
settings-row list, the one section built fresh rather than mounting an
existing window.
Picking Node packs, Updates, the log or Temp files used to close the
Settings window and open that feature's own separate modal, with no way
back except reopening Settings from the menu bar -- not what v4 does,
and not what "unified" is supposed to mean. Each of those five files
(plus pipeline_window.js) now exports mount({ setFooter, close }), the
same content its standalone open() already built, taking the modal
chrome's footer and close from whoever hosts it instead of owning a
modal directly. open() is unchanged for anyone opening one alone --
pipeline_window.test.js and every Playwright spec for these five prove
it, unedited, still passing.
Also fixes two findings from the adversarial pass on the earlier
deep-link version: filterList's search only matched a section's visible
label/hint, so a section findable only by a keyword (e.g. "loaders" for
"Models and pipeline") silently vanished from the filtered list; and
createSettingsWindow's "one at a time" state lived at module scope, so
a second independent instance would have redrawn the first one's modal
instead of opening its own. Both now have regression tests.
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.
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.
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.
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.
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.
The surfaces v4 has that v5 did not. Each announces itself and each can be
absent without the rest noticing, which is the point of the exercise: the
frontend should read as v4, and nothing in it should be load-bearing for
anything else.
* SCENE AND PROJECT, in the column v4 keeps them in, which now says which
scene it is about. Length on the timeline is the scene's own CROP and says
outright that a regenerate reads the project's -- the rule that mixing the
two is how a per-scene value quietly becomes everyone's.
* RATINGS on a scene, and a clip carries its rating as a colour on its edge.
Stored, not yet read: nothing in v5 learns from them yet.
* A RULER, and clips as wide as they are long, so the strip reads as time
rather than a row of equal boxes. Both ends bounded -- one long clip must
not become the whole strip, and one short one must stay hittable.
* THE PICKER WHEEL, on the middle button anywhere. It shows whatever
announced itself to actions.js, so a part of the app that grew a new action
is in the wheel without the wheel being told, and a part that failed to
load is simply not among them. v4's three pinned buttons had a fixed idea
of what could go in them.
* THE WAY IN, and it closes -- on Escape, on the backdrop, on its own button.
v4's could only be completed: a page refresh was the only way out, which
makes it a trap rather than a wizard. Dismissing is a real answer.
* File > rename, and the listing moves with it rather than going stale until
a reload.
Two things the kit refused, correctly, and now nobody has to know: a wheel of
fewer than two items throws (there is nothing to aim between), so the shell
does not offer one; and a strip cell with no ceiling took the whole strip.
Steps, Sampler and Scheduler are on the main window -- the three v4 buried
several clicks deep in Engine Settings. No new UI: the sampler slot declares
roles the way the prompt and the size already do.
What made that nearly free is that the controls now come from the NODE's own
description, through the settingFor/rendererFor pair the pipeline window has
always used. So a combo is a dropdown of the real choices and a number obeys
the real bounds, and prompt.js lost both of its hand-rolled branches: it no
longer decides what a control looks like, it asks.
File: New project, and every project listed with the open one ticked. The
store has been able to do this since the day it arrived and there was no way
in -- 20 lines of menu.
Three faults found on the way, all in the shared input helper:
* ENTER COULD NOT BE TYPED IN A TEXTAREA. `wire()` bound Enter to commit for
every text control, textareas included, so a multi-line value was
impossible -- the prompt above all, which is the longest multi-line thing
in the app. It has been that way in the pipeline window since it was
written; nobody had typed a second line into one.
* A control committed on blur alone, so autofill, a password manager and any
script setting a value went unheard. It listens to `change` as well now,
and commit() only acts on a real difference so the pair costs nothing.
* A control commits on blur, which means text still being typed is not in
the project yet -- and a tab closing is exactly when that is lost. boot
blurs before it saves.
setResult existed and nothing called it; the timeline already drew
scene.result. One wire short of a loop -- generate a scene, and what came
back is on the strip and still there after a reload.
The scene id is read when the run STARTS. A run takes minutes and the user
goes on clicking, so the selected scene when it finishes is not the one that
was generated.
A strip cell now falls back to its glyph when a thumbnail does not arrive,
the way the bin's cells already did -- the browser's broken-image icon reads
as a damaged file rather than a missing one.
Width, height and length belong to the PROJECT, not to a scene: a scene
cropped on the timeline and regenerated comes back at the project's size,
because the crop was a timeline decision and a regenerate is a new scene.
They sit in the Constructor beside the prompt -- the two things settled in
one sitting -- and travel to the graph on the path the prompt already uses.
No new mechanism: the pipeline declares roles at `project.video` the same
way it declares the prompt, so core still names no node and no setting. A
role whose value is a number gets a number control instead of a box; that
is the whole of the UI change. What the pipeline does not offer is not
shown, so a graph with no length input simply has no Length.
Bounds are the NODE's and stay there -- it already refuses a value outside
them with a reason, beside Generate. A project file is the one thing here
that outlives the code that wrote it, so everything read back out of one is
checked: a whole positive number, or gone.
Also, four faults the browser suite was already red with before this:
* Six tests still clicked a rail toggle that moved into the timeline head.
* At 420px the panel OVERLAYS the centre -- and the named toggles are in
the centre, so the panel covered the control that closes it. That is the
v4 trap this project forbids. They are not offered at that width now; the
View menu, which nothing can cover, is the way in and out.
* A 768px window laid out 860px wide and scrolled sideways. Root cause, and
it was never about one component: a flex item's automatic minimum is its
CONTENT, so every stack, split, panel body and frame region in the app
handed its content's width upward. A scroller that cannot shrink below
what it holds is not a scroller -- the strip of scenes made the WINDOW as
wide as the strip. Floored at the five places every layout routes through.
* A bin test counted `.cx-cell-glyph` across the whole page, so the timeline
drawing cells with the same glyph in them broke a test about the bin.
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>
* Generate was washed out on light and the fade downwards was why. Light's
accent carries WHITE text, which caps how bright the fill can go before
the label starts disappearing into it -- so the button sits AT the accent
now and DARKENS under the pointer, where white only reads better. Dark's
accent carries near-black text and has no such ceiling, so it does the
opposite: rest at the accent, lighter on hover. Measured in both, at rest
and hovered: no gradient stop under 4:1.
* A section label sat directly on the dividing line above it -- the
collapsible's body had no top padding of its own, so "APPEARANCE" read as
belonging to the divider rather than to the card under it.
* A checkbox in this app is a switch with a sentence under it, not a tick in
a list. At 4px of padding the label and its hint sat hard against the top
and bottom of their card, and a hint wrapping to two lines had nowhere to
wrap into.
* Row content is weighted to the left now: hard against the border, a card
read as leaning, because what sits opposite a control is usually the short
half of a wrapped line rather than anything at all.
And one regression caught in the screenshot: the settings row stacks below a
CONTAINER width, and that threshold was written as 300px -- which happened to
be just above what the properties column resolved to. Trimming 4px of padding
off two nested boxes widened it past the line, and every setting in that
column went back to being shredded, in a commit about something else. It is
360 now, which is what the numbers say it should always have been: a 180px
control column needs at least as much again beside it.
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.
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.
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.
Every module panel was decoration. The values lived in the browser and
nothing sent them, so a modifier switched on was one the run never heard
about. They now travel with the pipeline on every check, and core places
them wherever a module says they belong -- announced, not known, so the
node that carries them is not privileged.
Settings with nowhere to go are said beside Generate rather than
swallowed, and the warning survives the run it is about: the status line
is redrawn by every message, so a warning put there was gone the moment
the run said Queued.
Also: a settings row in a docked panel squeezed its label to one word per
line, because the control keeps a 140px floor and took it out of the
label. Below the width where they fit side by side, they stack.
A second run replaced the first on screen with no way back. Results now
accumulate in the Assets panel across runs, and the bin owns what the
viewer shows: the session subscription drove the viewer as well, so every
progress message walked a click on an older result back to the newest.
Grid, list and icons are one collection in three shapes, sharing selection
so switching view is not a way to lose it. No <video> thumbnails, ever --
that is v4's connection-pool stall. A thumbnail that does not arrive falls
back to the glyph rather than the browser's broken-image icon.
Every modal already mounts a .cx-backdrop element over its layer, so the
stacked variant painting another one as `0 0 0 100vmax` of shadow spread was
redundant, and the area beneath it was darkened twice.
It was also the most expensive declaration in the sheet: a spread the size of
the viewport diagonal, on an element inside a fixed layer, with an entry
animation running. Removed in favour of a bounded shadow, and a test reads the
stylesheet for the pattern -- asserting on node.style in jsdom passes against
any rule at all, since no CSS is applied there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A module whose setup() threw was reported as hidden with its panel already on
screen -- appended one line too early. The file states the opposite guarantee.
A redraw destroyed every control and then rebuilt, so a row revealed by another
value -- one the first build never rendered and so never checked -- stripped
the panel and threw on the way to refilling it. Building first makes a redraw
as safe as the first build already was.
A page loaded at a narrow width recorded the panel its own one-at-a-time rule
had just closed as one the user wanted closed, so widening brought back only
the left. What the user chose is now kept apart from what is on screen.
The redraw test counted rows and passed against the bug; a control removed from
a row leaves the row behind. It counts controls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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.
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.
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.
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.
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>
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>
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>
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>
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.
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.
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.
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.
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.
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.