Commit Graph
100 Commits
Author SHA1 Message Date
digital-garbage ecbd9e8519 app: two findings from the adversarial pass on the Settings rewrite
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.
2026-09-07 12:23:36 +03:00
digital-garbage 563b1c6f02 app: Settings looks designed instead of default -- icon chips, real sidebar, centred About
A bare glyph character had no framing at all; filterList's icon now gets a
coloured chip, drawn from a fixed tone (accent/danger/warn/good/neutral) --
never a raw colour from data, the same rule el()'s style refusal already
holds everywhere else. Each Settings section picks one.

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

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

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

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

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

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

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

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

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

Four adversarial rounds against this range, each fixed before landing.
2026-09-06 22:53:34 +03:00
digital-garbage 7ec055c7e8 app: drag to reorder scenes, and a zoom for the strip
Drag a clip onto another to reorder; an S/M/L control changes clip width
(--strip-w), remembered per browser. Trim already existed as the Scene
inspector's length field.

The dragged clip is tracked by its DataTransfer payload, not a closure
variable keyed on DOM position or id -- a redraw mid-drag (any project
change, not just a removal) tears down and rebuilds every cell, and
dragend fires on the original source node regardless of whether a
redraw detached it from the tree. Two adversarial rounds against this
file specifically, both fixed before landing.
2026-09-06 18:37:22 +03:00
digital-garbage 1185e2201f core, app: a decade of races closed -- log routes, restarts, results, defaults
Two routes silently shared /api/log; a pack's "updates available" badge
outlived the update; nothing server-side stopped a restart mid-generation,
and the fixes for that had their own dead end, a lock scoped to the wrong
event loop, an unlocked status fetch, and a check that only ran once
instead of at the moment it mattered. A run's result was lost outright if
the project it belonged to wasn't the one open when it finished -- on a
project switch or a page reload. A brand-new project inherited the
previous one's width, then inherited the wrong "default" for it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two faults of my own, both in the window:

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

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

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

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

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

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

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

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

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

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

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

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

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

Also serial-mode for the project spec: two tests making and switching projects
share one real store on the dev server, and the config runs a file in
parallel. A race over a JSON directory, not a bug in the app.
2026-09-06 13:07:08 +03:00
digital-garbage aceb06803d app, core: a rating is liked or it is not
dev went binary on H3 and said why: the steering reads the SIGN of a rating
and never its size, so a ten-point scale was the UI claiming a precision
nothing downstream used -- and at the sample sizes this actually runs at, a
mediocre middle rating only diluted the direction. v5 had invented a
four-way scale of its own; it now stores what dev stores.

Pressing the one that is on takes it back. Without a way out a mis-click is
a permanent opinion, and "no opinion" is a real answer nothing should have
to guess at.
2026-09-06 12:52:13 +03:00
digital-garbage faaa465d68 app, core: the inspector, the wheel, ratings, a ruler, and a way in
The surfaces v4 has that v5 did not. Each announces itself and each can be
absent without the rest noticing, which is the point of the exercise: the
frontend should read as v4, and nothing in it should be load-bearing for
anything else.

* SCENE AND PROJECT, in the column v4 keeps them in, which now says which
  scene it is about. Length on the timeline is the scene's own CROP and says
  outright that a regenerate reads the project's -- the rule that mixing the
  two is how a per-scene value quietly becomes everyone's.
* RATINGS on a scene, and a clip carries its rating as a colour on its edge.
  Stored, not yet read: nothing in v5 learns from them yet.
* A RULER, and clips as wide as they are long, so the strip reads as time
  rather than a row of equal boxes. Both ends bounded -- one long clip must
  not become the whole strip, and one short one must stay hittable.
* THE PICKER WHEEL, on the middle button anywhere. It shows whatever
  announced itself to actions.js, so a part of the app that grew a new action
  is in the wheel without the wheel being told, and a part that failed to
  load is simply not among them. v4's three pinned buttons had a fixed idea
  of what could go in them.
* THE WAY IN, and it closes -- on Escape, on the backdrop, on its own button.
  v4's could only be completed: a page refresh was the only way out, which
  makes it a trap rather than a wizard. Dismissing is a real answer.
* File > rename, and the listing moves with it rather than going stale until
  a reload.

Two things the kit refused, correctly, and now nobody has to know: a wheel of
fewer than two items throws (there is nothing to aim between), so the shell
does not offer one; and a strip cell with no ceiling took the whole strip.
2026-09-06 12:30:59 +03:00
digital-garbage 33eeff08e3 app: the dials on the main window, and a way to have two projects
Steps, Sampler and Scheduler are on the main window -- the three v4 buried
several clicks deep in Engine Settings. No new UI: the sampler slot declares
roles the way the prompt and the size already do.

What made that nearly free is that the controls now come from the NODE's own
description, through the settingFor/rendererFor pair the pipeline window has
always used. So a combo is a dropdown of the real choices and a number obeys
the real bounds, and prompt.js lost both of its hand-rolled branches: it no
longer decides what a control looks like, it asks.

File: New project, and every project listed with the open one ticked. The
store has been able to do this since the day it arrived and there was no way
in -- 20 lines of menu.

Three faults found on the way, all in the shared input helper:

* ENTER COULD NOT BE TYPED IN A TEXTAREA. `wire()` bound Enter to commit for
  every text control, textareas included, so a multi-line value was
  impossible -- the prompt above all, which is the longest multi-line thing
  in the app. It has been that way in the pipeline window since it was
  written; nobody had typed a second line into one.
* A control committed on blur alone, so autofill, a password manager and any
  script setting a value went unheard. It listens to `change` as well now,
  and commit() only acts on a real difference so the pair costs nothing.
* A control commits on blur, which means text still being typed is not in
  the project yet -- and a tab closing is exactly when that is lost. boot
  blurs before it saves.
2026-09-06 12:18:33 +03:00
digital-garbage 77f72bbaee app: a run's result lands on the scene it was started for
setResult existed and nothing called it; the timeline already drew
scene.result. One wire short of a loop -- generate a scene, and what came
back is on the strip and still there after a reload.

The scene id is read when the run STARTS. A run takes minutes and the user
goes on clicking, so the selected scene when it finishes is not the one that
was generated.

A strip cell now falls back to its glyph when a thumbnail does not arrive,
the way the bin's cells already did -- the browser's broken-image icon reads
as a damaged file rather than a missing one.
2026-09-06 11:56:35 +03:00
digital-garbage 9a4fb4eca0 app, core: what the project is generated at
Width, height and length belong to the PROJECT, not to a scene: a scene
cropped on the timeline and regenerated comes back at the project's size,
because the crop was a timeline decision and a regenerate is a new scene.
They sit in the Constructor beside the prompt -- the two things settled in
one sitting -- and travel to the graph on the path the prompt already uses.

No new mechanism: the pipeline declares roles at `project.video` the same
way it declares the prompt, so core still names no node and no setting. A
role whose value is a number gets a number control instead of a box; that
is the whole of the UI change. What the pipeline does not offer is not
shown, so a graph with no length input simply has no Length.

Bounds are the NODE's and stay there -- it already refuses a value outside
them with a reason, beside Generate. A project file is the one thing here
that outlives the code that wrote it, so everything read back out of one is
checked: a whole positive number, or gone.

Also, four faults the browser suite was already red with before this:

* Six tests still clicked a rail toggle that moved into the timeline head.
* At 420px the panel OVERLAYS the centre -- and the named toggles are in
  the centre, so the panel covered the control that closes it. That is the
  v4 trap this project forbids. They are not offered at that width now; the
  View menu, which nothing can cover, is the way in and out.
* A 768px window laid out 860px wide and scrolled sideways. Root cause, and
  it was never about one component: a flex item's automatic minimum is its
  CONTENT, so every stack, split, panel body and frame region in the app
  handed its content's width upward. A scroller that cannot shrink below
  what it holds is not a scroller -- the strip of scenes made the WINDOW as
  wide as the strip. Floored at the five places every layout routes through.
* A bin test counted `.cx-cell-glyph` across the whole page, so the timeline
  drawing cells with the same glyph in them broke a test about the bin.
2026-09-05 22:16:01 +03:00
digital-garbageandClaude Opus 5 9dcd94c156 app, core: a project of scenes, and the timeline that draws it
The app could run a pipeline and had nowhere to keep what it was making. A
project is an ordered list of scenes, one JSON file each, and it is the only
part of the app that has to survive a reload.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:03:19 +03:00
digital-garbage d3b0ba0fa9 app: the accent button per theme, and room around a setting
* 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.
2026-08-30 22:32:18 +03:00
digital-garbage 62f92ecaf8 app: a menu bar, a timeline, and the prompt in a window of its own
v4's arrangement, which is the one that reads as an editor: a zone head
holds what acts on THAT zone, so the app grows a bar of its own for what
acts on the app.

* The menu bar. Brand, View and Settings, and whether ComfyUI is still on
  the other end. Only menus with something real in them exist -- an empty
  File menu is a promise the app does not keep.
* The prompt is written in the CONSTRUCTOR, a window. v4 called this the
  Composer; that name is taken here by the element kit, and two things
  called Composer in one repo is one thing nobody can search for. It is
  the longest thing a person writes and the least often read, and a
  permanent third of the centre column is the room the timeline needs.
  The host is built at startup and outlives every opening, so a window
  that opens twice does not throw away what was typed.
* The timeline replaces it, and Generate moves into its head with what
  the run is doing at the far end of the same head. No bar across the
  bottom: one that spans the window belongs to no region.

Four faults found on the way, three of them mine and one much older:

* THE OVERLAY ROOT WAS A STACKING CONTEXT. It documents at length that it
  must never be one -- and then set `position: fixed`, which makes one on
  its own. So the whole z ladder was measured inside that single element:
  an open menu at z 500 painted UNDER a button at z 1. Every overlay in
  the app was one positive z-index away from being buried, and the only
  reason nobody had seen it is that almost nothing claims a z-index. The
  root is static and zero-sized now; every layer in it is fixed already.
* Generate's hover ran `filter: brightness()`, which lightens the LABEL
  along with the fill under it -- the one thing that had to stay readable
  lost contrast in both themes. Only the fill moves now, and which way it
  moves depends on the theme: dark's accent carries near-black text so it
  goes lighter, light's carries white so it goes darker. Measured: no
  gradient stop under 4.1:1, hovered or at rest.
* A fixed-height head could not shrink below the width of everything in
  it, and a head now holds a title, three controls and a status. That
  min-content became the centre column's, so a 768px window laid out
  878px wide and scrolled sideways. It wraps now, as v4's does.
* A CSS patch of mine silently did nothing because its anchor text had
  moved and I had left the assertion off that one replacement. Every
  patch asserts now.
2026-08-30 22:11:53 +03:00
digital-garbage 16eec81a03 app: the app is one surface, not a tray of cards
Ran v4 side by side to see what the difference actually was, and it was
not spacing. v4 fills the window edge to edge and divides its zones with
hairlines; v5 floated rounded, shadowed cards on a visible page
background. One reads as an editing suite, the other as a dashboard.

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

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

And the surfaces have depth, derived from each theme's own elevation
scale so neither needs a rule of its own: a vertical fall on every band
and every raised control, the accent gradient and a bloom under a primary
button, a wash off the top-right of the page, tokenised thin scrollbars,
an accent mark on every zone title, and v4's film grain -- overlay at
3.5% on dark, multiply at 1.5% on light, and painted with no z-index so
anything that opens above the frame stays crisp.
2026-08-30 21:17:39 +03:00
digital-garbage 549e430806 app: one gutter, one head height, one control column
The layout was assembled by hand and read like it. Measured at 1440x900:
the three columns started at three different heights, a panel head was 41px
when it held a control and 34 when it did not, the gap left of the centre
was 7px and right of it 19, the properties column sat 11px further in than
the others inside a border that meant nothing, and five controls in one
list had four different widths -- a <select> takes the width of its widest
option, so the row listing checkpoint filenames was 124px wider than its
neighbours.

Now: --gutter is the space between regions, between two panels stacked in
a column, and inside the bands; the head band height is the panel head
height is the collapsible head height; --control-col gives every settings
control the same two edges. The frame's bands run edge to edge and the
content is inset by the gutter, so the app has an outline instead of a
margin that changed per region.

Also here, all of it visible in the same screenshot:

* The status sat against the Generate button with 1300px of empty bar to
  its left -- everything was in one right-aligned group. What the app says
  is at the start of the row now, what you press is at the end.
* A panel body is a column with a gap: two things put in it by two parts
  of the app had whatever space their line boxes happened to leave.
* A container query adds no specificity, so the narrow-panel rule written
  above the rules it overrides lost -- and flex-basis in a column is
  HEIGHT, which turned four settings into a panel two screens long.
* The theme you picked was thrown away on every load: the appearance
  module applied its own declared default over the choice the page had
  already applied from storage. It adopts the live value now, and a panel
  is built after setup so its control shows what setup settled on.
2026-08-30 21:01:50 +03:00
digital-garbage 5b59043fc8 core, app: a prompt you can type, on the main window
The prompt was a slot input on a CLIPTextEncode, reachable only by opening
the pipeline window and finding the node. A slot can now say that one of
its inputs belongs on the app's surface -- a place and a label -- and the
shell offers a region with that name or does not, exactly as a module's
panel works. Core validates that a role is names and nothing else.

Values for those inputs travel addressed by slot rather than as a whole
pipeline, so the window that owns the structure and a box that owns one
value cannot disagree about the slots between them. An address that is
not there is refused: a prompt typed into a box for a removed slot has
gone nowhere, and a run that starts anyway is not the run that was asked
for.

Region stand-ins are empty states now, taken down once something mounts --
'modules appear here' left above the modules that appeared explained
nothing to nobody.
2026-08-30 20:27:44 +03:00
digital-garbage 1b0f489950 core, app: what the panels hold now reaches the run
Every module panel was decoration. The values lived in the browser and
nothing sent them, so a modifier switched on was one the run never heard
about. They now travel with the pipeline on every check, and core places
them wherever a module says they belong -- announced, not known, so the
node that carries them is not privileged.

Settings with nowhere to go are said beside Generate rather than
swallowed, and the warning survives the run it is about: the status line
is redrawn by every message, so a warning put there was gone the moment
the run said Queued.

Also: a settings row in a docked panel squeezed its label to one word per
line, because the control keeps a 140px floor and took it out of the
label. Below the width where they fit side by side, they stack.
2026-08-30 20:16:46 +03:00
digital-garbage 7337721943 app: the bin — results a session produced, in three views
A second run replaced the first on screen with no way back. Results now
accumulate in the Assets panel across runs, and the bin owns what the
viewer shows: the session subscription drove the viewer as well, so every
progress message walked a click on an older result back to the newest.

Grid, list and icons are one collection in three shapes, sharing selection
so switching view is not a way to lose it. No <video> thumbnails, ever --
that is v4's connection-pool stall. A thumbnail that does not arrive falls
back to the glyph rather than the browser's broken-image icon.
2026-08-30 20:07:07 +03:00
digital-garbageandClaude Opus 5 34a55c4154 composer: a stacked modal dimmed twice, through a viewport-sized shadow
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>
2026-08-30 17:33:01 +03:00
digital-garbageandClaude Opus 5 3a1c76d64e app: two of the same window, and an edit undone by an older one
The button opened another window on every press -- reported from a real
session as two of the same modal. Two windows over one pipeline are two drafts
of it: whichever is saved last wins, and the other was edited against a
pipeline that had already moved. One window now, focused rather than stacked,
and a fresh one after it is closed.

commit() took whatever answer arrived. Moving a node carries the whole slot
list and removing one carries an id, so the second answers first and the first
then describes a pipeline that no longer exists -- putting a removed node back
with no refusal and nothing said. The picker guarded this already; the path
that decides what gets queued did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:20:30 +03:00
digital-garbageandClaude Opus 5 66e4bce345 core, app: a value the queue would refuse, caught before it is queued
ComfyUI refuses the WHOLE graph over one wrong value. v4 hit that with a LoRA
file that had been deleted: a feature that was switched off still stopped every
generation, and the message was "Prompt outputs failed validation" over a dozen
loaders. build() checked wiring and required-ness and never looked at a literal,
so check() called such a pipeline queueable.

A combo value that is not among the node choices, and a number outside the
bounds it declares, are now refused here -- naming the slot and the input.
No bounds means unknown, and unknown is not a refusal.

And when the queue does refuse one, its per-node reasons were captured by
run.js and never read: the status bar showed only the top-level string, because
describe() read `node` and the refusal path sets `nodes`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:11:51 +03:00
digital-garbageandClaude Opus 5 5f0247405c app: three ways the UI kept what it said it had thrown away
A module whose setup() threw was reported as hidden with its panel already on
screen -- appended one line too early. The file states the opposite guarantee.

A redraw destroyed every control and then rebuilt, so a row revealed by another
value -- one the first build never rendered and so never checked -- stripped
the panel and threw on the way to refilling it. Building first makes a redraw
as safe as the first build already was.

A page loaded at a narrow width recorded the panel its own one-at-a-time rule
had just closed as one the user wanted closed, so widening brought back only
the left. What the user chose is now kept apart from what is on screen.

The redraw test counted rows and passed against the bug; a control removed from
a row leaves the row behind. It counts controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:03:55 +03:00
digital-garbageandClaude Opus 5 1295f1a365 app: an edit outlived the node it was for
A draft is keyed by slot id, and a slot keeps its id when it is pointed at a
different node. An unsaved steps stayed attached across a swap -- and since
most samplers declare steps, and the server only refuses an input the new node
does NOT declare, it was written on the next Save with nothing saying it had
never been chosen for that node.

The fake server in the tests did not implement replace, which is why no test
could see it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:41:54 +03:00
digital-garbageandClaude Opus 5 8bfd28950d app: the models and pipeline window
One wide modal, a card per group, and a group that opens into a node list
beside that node/'s parameters, saved a group at a time. Groups travel on the
slot, so a pipeline keeps its own arrangement and a new group is a slot
claiming a name nobody used.

Values are drafted until Save; adding, removing, replacing and moving a node
go to the server as they happen, because only it can say whether the result
still wires up.

An unset dropdown reads "not set" rather than showing the first file as though
it had been chosen -- v4 recorded that trap, and picking the entry the box
already shows fires no change, so the one click that looks like the fix does
nothing.

New: region.stack, a frame header, a modal footer that can be refilled.

Not yet: wiring. A socket says so where a control would be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:36:01 +03:00
digital-garbageandClaude Opus 5 eca4083837 core: a dropdown read as a wire, and a legal wire refused
ComfyUI spells a combo three ways -- a list where the type goes, the string
"COMBO" with the choices under options, and COMFY_DYNAMICCOMBO_V3. Only the
first was read, so every V3 node came back with no choices at all and the
window said "no files of this kind were found" over dropdowns holding seven
entries. A stock install here has 140 of the spelling that was missed.

A union type ("IMAGE,MASK") was compared as a whole string, so a MASK feeding
it was refused -- a legal wire refused, which is as bad as an illegal one
accepted. A MultiType wrapped around a number ("FLOAT,INT" with widgetType)
was read as a socket demanding a source for a field you type into.

graph.py and widgets.py had their own idea of what a type string means; both
now read comfy_types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:35:52 +03:00
digital-garbage 52ae8e45a2 tests: a scan under a redirected modules dir poisoned the whole session
I committed the widget layer without running the full suite and broke
twenty-three tests in files that have nothing to do with it.

The registry caches its scan for the life of the process. A route test
redirects MODULES_DIR at a temp directory holding one fake module, and my new
test called install_into inside that -- which scans, and caches the temp
directory as the installed set. Every later test then ran against an empty
registry: the sampling tests found no modules to install, so a modifier that
should have been applied was not, and the failure surfaced as an assertion about
pre_cfg hooks three directories away.

The test needed a node, not FunPack's nodes, and KSampler is one. The fixture
that redirects the directory now also puts the cache back, because whoever moves
it owns restoring it, and there is a test for that -- the failure it prevents is
unrecognisable from where it lands.
2026-08-30 05:05:14 +03:00
digital-garbage 2349c550cb core: a node's inputs as a form, and a dev server that has nodes at all
graph.py reads an input's TYPE, which is all a wire needs. Editing a value needs
the rest -- the choices a combo offers, the bounds on a number, whether the box
is multiline -- so that is asked separately rather than widening the question
graph.py asks. Read from ComfyUI's own INPUT_TYPES, so a node written for
ComfyUI is editable here without knowing anything about FunPack.

Caught while looking at the real output rather than after committing: deciding
"a socket is upper case" is wrong, because STRING is upper case -- every prompt
box in the pipeline came back as something nobody could type in. The honest rule
is ComfyUI's own primitive set, plus forceInput, which is a short list core
holds on purpose.

/funpack/api/nodes answers by name. ComfyUI's /object_info returns every
installed node, which on a machine with a few packs is megabytes for a question
about a dozen; a node nobody installed comes back as null rather than missing,
so "this slot points at something absent" reads differently from "I did not ask".

The dev server now registers ComfyUI's nodes and FunPack's own, through the same
function the tests use. Without it the pipeline was fourteen "there is no node
called X installed" and nothing to edit -- so the one thing the dev server exists
for could not reach the half of the app that matters. It now reports the three
problems that are real: no model, no CLIP, no VAE chosen.
2026-08-30 05:00:29 +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 73c4536cab tests: the three call sites the wording fix changed and nothing checked
Reverting chain start-up, node schema reading and decode to log.failed left all
420 tests green -- so the fix was pinned in three of six places and free to rot
in the other three. Testing log.broke on its own never covers this: what recurs
is a call SITE reaching for the wrong function, and a unit test of the right one
passes either way. Each site is now exercised through the code that logs.

One finding, confirmed, and proven by reverting each site in turn.
2026-08-29 23:27:11 +03:00