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>
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.
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.
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.
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>
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.
The sandbox was a claim the code could not make. `panels.js` said "no DOM
node, no document... a module cannot style anything because it is never
handed anything that could" -- while handing over `composer`, whose factories
return handles wrapping a real element. A module could take one, style it,
and reach the whole document through node.ownerDocument. No lint to evade,
just the intended API.
No module ever used it. Panels are rendered from the DECLARATION, and a
module that wants to draw something should be declaring it. So it is gone,
and the guarantee is now true as stated: the kit hands a module nothing that
touches the DOM.
What is NOT claimed any more: that a module CANNOT reach the DOM. A ui.js is
an ES module in the page realm and `document` is a global. kit_lint is a
discipline check, not a boundary -- globalThis["docu"+"ment"] defeats every
regex and always will. It now catches the bracket-access spellings, which
were the first thing tried and went straight through, and says plainly what
it is and is not.
The test that was supposed to protect this asserted "no value IS a node"
while a value that MAKES nodes sat in the same object. It checks what it
names now.
And the overlay compaction test computed a before-snapshot and discarded it
with `void before`, checking only that survivors do not collide -- true even
if nothing ever moves. Two attempts to fix it failed against CORRECT code
(earlier tests leave live claims, so the rung has a legitimate gap; and some
demos close themselves, so closing them again is a no-op). It now opens two
overlays of its own so the invariant runs every time, and removing notify()
from zlayer fails three tests instead of none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
raise() could send a claim to the BACK. Each claim owned a fixed offset, so on a
nearly-full rung there was no free slot above the top and the fallback took the
lowest free one instead: a floating window clicked to front went behind its
peers. Only reachable once a rung fills, which is a long session, not a test.
A rung is now an ordered list and z is derived from position, so raise() is a
move to the end and release() compacts.
drag.js shipped with no tests. 12 now, including the ones that matter: a second
pointer cannot hijack a drag, capture is released on cancel, and dispose stops a
drag already in progress.
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.