`SHIP_PATHS` omitted `locales/`, which ComfyUI reads from
`custom_nodes/<pack>/locales/<lang>/` for node display names,
descriptions and settings labels (app/custom_node_manager.py). We ship
main.json, nodeDefs.json and settings.json there.
The registry archive already included it, because `comfy node publish`
ships everything git-tracked that `.comfyignore` does not exclude. The
zip is a hand-maintained list, so the two artifacts silently disagreed
about what a complete install is. Nothing installs from the zip today,
which is the only reason this has not bitten anyone.
The omission is the symptom; two hand-maintained lists of the same thing
with nothing tying them together is the cause. `assertShipListIsTotal`
now fails the pack when a top-level tracked path is neither in
SHIP_PATHS nor explicitly listed as dev-only, so the next runtime
directory someone adds cannot go missing the same way.
Also names the missing-`zip` case: on Windows the packer reported
"exited with status null", which says nothing about `zip` not being on
PATH.
Data source for the optional value autocomplete (#27). Replaces the
community script this started from, which walked all 1000 allowed pages and
kept only `name,post_count`.
Three changes, each from something measured against the live API on
2026-08-06.
A post-count floor instead of a page count. `search[order]=count` sorts
strictly descending ACROSS pages:
page 1: first 8,244,950 last 55,918
page 100: first 68 last 67
page 800: first 1 last 1
so a popular tag cannot hide on a later page and stopping early provably
loses nothing. Walking to page 1000 spends ~900 requests and a quarter of an
hour collecting single-post tags. The default floor of 20 lands near what
other extensions ship.
Aliases, from the separate `tag_aliases.json` endpoint. `tags.json` carries
none, and aliases are what let someone type `hires` and land on `highres` --
demonstrably the most valuable field after the names.
Category is kept. The API returns it and the old script discarded it; it is
what lets the UI tell an artist tag from a character tag.
Also retries with backoff rather than aborting on the first non-200, which
over a 100-request run means one blip no longer discards everything.
The User-Agent is pinned to bare `product/version` and must stay that way --
Danbooru's bot filter 403s a bare token, a longer description, or anything
containing a URL. Adding a contact URL is the usual courtesy for a scraper and
is exactly what breaks it, so the reasoning is recorded next to the constant.
MANUAL ONLY. The list describes Danbooru, not this project, and goes stale
slowly: a two-year-old file still autocompletes correctly because staleness
moves post counts and therefore ranking, while tag names barely change. That
is not an argument for a timer against someone else's API. Output goes to a
fixed non-semver `tag-list` tag, replaced in place, so the download URL is
stable and the SPA can hardcode it -- and so semantic-release ignores it.
Investigating slow ComfyUI page loads on a real install turned up this
project as the single largest contributor, by a wide margin.
js/ 518 files 36.0 MB 179 stale chunk copies
web/ 1256 files 22.0 MB 1120 stale chunk copies
ComfyUI fetches EVERY *.js under WEB_DIRECTORY at page load, so that install
requested 237 of our chunks where 58 were live: 9.9 MB, 42% of all extension
bytes on the page and 27% of all extension requests. Twelve copies of `about`,
nine of `ContextWidget`.
Two independent causes, both ours.
Chunks are content-hashed, so every release emits new filenames, and Manager
installs an update by extracting over the installed folder without deleting
what the new version dropped. Each mechanism is reasonable alone. Together
they never remove anything. It stayed invisible for a dozen releases because
`emptyOutDir` wipes the output directory on every local build -- only installs
that UPDATE accumulate, and a maintainer never sees one.
And `sourcemap: "hidden"` still WRITES every .map, omitting only the
`sourceMappingURL` comment, so 22.5 MB of maps shipped that no browser had
ever requested. `pack-release.mjs` excluded them from the GitHub zip, but the
registry package comes from pyproject's `includes = ["js", "web"]`, which has
no exclusion -- so the fix has to be not emitting them.
Now: each build writes `.wp-assets.json` listing what it produced, and the
plugin prunes anything hashed and unlisted at import, before ComfyUI scans
WEB_DIRECTORY. Self-healing -- existing installs clean themselves on the next
start with no user action.
Deliberately conservative, because deleting a live file breaks an install
while missing a stale one only costs disk: no manifest, an empty manifest or a
corrupt one all mean do nothing; only bundler-shaped names are candidates
(hashed chunks, plus any .map -- the entry map is unhashed, which the hash
pattern alone walked straight past); every failure is swallowed.
Verified against a copy of the real install, updated the way Manager would:
1,881 files -> 302, 60 MB -> 10.9 MB, 1,579 removed. All 551 relative module
imports across the surviving chunks still resolve, and every asset
`web/index.html` references is intact.
Vite library mode ignores `build.assetsInlineLimit` and inlines every asset
unconditionally, so both woff2 files were base64'd into a chunk: 88,660 bytes
of font became a 118,896-byte JS file, awaited in main.ts's top-level
`Promise.all` and therefore parsed as JavaScript before ComfyUI could finish
registering the nodes.
fonts.ts believed the opposite -- its comment claimed `new URL(path,
import.meta.url)` kept the woff2 as separate assets. That transform is exactly
what Vite intercepts in lib mode, so the intent never held.
The fonts now leave the module graph entirely. `src/assets/fonts/wp-fonts.css`
is a real source file copied verbatim to `js/assets/fonts/` by copy-fonts.mjs;
fonts.ts <link>s it at a URL resolved against its own chunk, and the browser
resolves each woff2 relative to the stylesheet. No path arithmetic in JS, and
a renamed install directory still works.
The href is assembled from array parts on purpose: Vite's asset transform fires
on `new URL(<string literal>, import.meta.url)`, so a literal here would
silently re-inline the stylesheet with no build error and no failing test.
Measured with the gate's own method: 492,407 -> 403,055 bytes gzipped. Total
bytes on a cold cache are about unchanged -- gzip recovers base64's overhead --
so the win is latency and caching, not payload: registration no longer waits,
90 KB left the JS parse path, and the fonts stop being re-downloaded on every
release when the enclosing chunk's hash changes.
TOTAL_LIMIT tightened 490 -> 420 KB. Re-inlining costs ~90 KB, which fits under
490 KB and would have regressed unnoticed; it does not fit under 420 KB.
Verified against a running ComfyUI: the stylesheet and both woff2 serve 200 and
are fetched as fonts, and document.fonts.check() passes for JetBrains Mono,
which only this stylesheet provides.
Unset and empty were already safe. A MALFORMED id was not: any truthy
string went straight into `<@&…>`, so a stray space in the secret produced
a literal `<@& >` and a pasted mention produced `<@&<@&123>>` — visible
junk at the top of the most public message the project sends.
`normalizeRoleId` is now the single decision point, applied inside
`buildPayload` rather than at the call site so every caller gets the same
guarantee. It trims, unwraps a pasted `<@&123>`, and requires a 17-20
digit snowflake. Anything else is rejected rather than guessed at: a ping
that silently does not happen is a far smaller problem than a broken one
that does.
`allowed_mentions.roles` is derived from the same normalised value, so
the two can never disagree — a content string naming a role the payload
does not permit would be a mention that renders but never notifies.
The CLI warns when a non-empty id was supplied and rejected. Dropping it
silently would leave someone wondering why the ping never fired.
One webhook meant the choice was all-or-nothing: either every release
announced itself to everyone, or nothing was automated. Two channels,
deliberately asymmetric:
staff posts AUTOMATICALLY on every release
public ONLY from a deliberate manual dispatch
The staff copy carries a link back to this workflow with the inputs
spelled out. That is what makes it a review step rather than a duplicate
feed: the post being read IS the post that will go out, and the button to
send it is attached to it. Otherwise "now go publish it" is a separate
instruction living in someone's head.
The public copy does not get that field — an announcement should not tell
its readers how to re-send it. Returned as `undefined` rather than an
empty array, since an empty field block renders as dead space.
Guarded twice. The workflow only passes `--channel public` on a manual
dispatch, AND the script refuses public when `GITHUB_EVENT_NAME=release`.
One of those is enough; both are cheap, and the failure mode is
announcing to everyone with nobody having read it.
Only the public post pings the role. A ping in the staff channel on every
release is noise in a room that exists to watch releases.
`DISCORD_WEBHOOK_STAFF` / `DISCORD_WEBHOOK_PUBLIC`, with the existing
`DISCORD_WEBHOOK_URL` kept as a staff fallback so the current
single-webhook setup keeps working untouched.
v2.12.0 went out as "Captain Hook" with a placeholder avatar — a webhook
posts under whatever name and image it was created with, and Discord's
default is a generic pair.
`username` and `avatar_url` override that per message, so the identity
lives in the payload rather than in a webhook's settings. It then stays
right if the webhook is ever rotated or recreated, and it is the same in
every channel the automation posts to.
Avatar is the 512px manifest icon, fetched from raw.githubusercontent by
Discord server-side. It has to be raster — an SVG will not render, which
rules out the repo's favicon.svg, and that failure mode is silent. Pinned
to `main` rather than a tag so replacing the logo needs no code change.
`DISCORD_USERNAME` / `DISCORD_AVATAR_URL` override both. An empty value
falls through to the defaults rather than blanking the identity and
handing the post back to the webhook's name.
The dry run now prints both, since identity is invisible in the embed
body and is exactly what was wrong the first time.
Ten commits landed after `next.md` was written and none of them were in it.
Anything left out of that file simply does not ship in the notes.
No new highlights. The cap is 4-5 and it was already at 5 — per the file's
own rule, if a sixth feels essential something else was not a highlight.
Per-frame lock and bypass is a shortcut to something the seeds dialog
already did, so it goes in the tail rather than displacing anything.
Tail gains six entries in two new groups: the frame chips and collapsible
grid on the Loop and Seed List, the broken-reference fix, the what's-new
page, and the update check that was burning GitHub's hourly allowance.
Count corrected 20 → 26, which the update dialog computes independently
from the same bullets.
Also fixes a real bug in the announcement transform, caught by previewing
it against this file: `splitBody` stripped `<details>` BEFORE counting the
tail. The "Also in this release" bullets live inside that block, so it
counted nothing real — while the authoring guidance in the trailing HTML
comment contributed bullets of its own. A 26-change release announced
itself as 3. Comments are stripped, details are not.
README gets the Nodes 2.0 line. Renderer support is a compatibility fact
people check before installing, it was the single largest piece of work in
this release, and the README did not mention it anywhere. The "switching
between them with a workflow already open" clause is deliberate: that is
what repeatedly broke and is now specifically handled.
Announcements were written by hand, which is why they went out "only once
a few versions" -- the cost was never posting, it was composing a second
piece of prose about a release that had already been written up once.
Nothing new has to be written. `docs/release-notes/next.md` is already
curated by hand per release and already IS the GitHub release body, so
the announcement is a transform of it rather than a fresh document.
`scripts/lib/discord-notes.mjs` handles the gaps between GitHub markdown
and Discord, which are specific rather than general:
- masked links render ONLY inside an embed, so the notes go in an embed
and the message content stays a bare ping
- there are no tables; rows become bullets
- <details> shows as raw tags
- headings render, but an embed `##` outsizes the embed title above it,
so headings become bold
- the description caps at 4096, and truncation cuts at a bullet
boundary -- a hard slice can land inside a link and render as broken
markup, which is worse than saying less
It cuts at the same `<!-- /modal -->` marker the in-app update dialog
uses, for the same reason: an announcement is an interruption. The tail
is counted rather than dropped, so a three-bullet post over a forty-change
release does not read as a quiet week.
`allowed_mentions` is always explicit. Release notes are not written with
`@everyone` in mind and a webhook will happily resolve one that slipped
in; only a configured role id can ping.
Two entry points. `release: published` posts automatically. A manual
`workflow_dispatch` takes `--since <tag>` and rolls several releases into
one post -- the catch-up case that actually happens -- and defaults to a
dry run so a manual trigger cannot surprise anyone. Dry run is also the
CLI default: `pnpm announce --version v2.11.0` prints and posts nothing.
Kept as a separate workflow from release.yml on purpose: a Discord
outage, a rotated webhook or a rate limit must never fail a release that
has already shipped.
Also widens the vitest include to `scripts/**/*.test.mjs`. A broken
announcement transform is otherwise only noticed after it has been
posted.
The CSS this mirrors is `radial-gradient(120% 100% at 0% 0%, …)` — an
ellipse 120 percent of the WIDTH by 100 percent of the HEIGHT, anchored
top-left, so it falls off across the whole panel. I had applied a
`scale(1, h/w)` transform, which squashed it into a flat smear along the
top edge; on an 880x202 frame that reads as a tinted corner with three
quarters of the figure left flat.
Now `r=1` with `scale(1.2 1)`, which is the CSS geometry directly, and a
three-stop falloff so the tint carries further before it disappears.
Three things, all of them fidelity to the SPA figure rather than taste.
The frame lost its wash. PipelineDiagram paints
`radial-gradient(120% 100% at 0% 0%, node 6%, transparent 60%)` over
bg-1; dropping it left a flat slab. Reproduced as a radial gradient with
themed stops.
The connectors lost their fade. `.wp-doc-diagram__arrow-ln` is a
gradient that fades in and out at both ends, so a connector reads as
flow rather than a drawn border -- which is most of why these felt dead.
They are RECTS, not lines: a horizontal <line> has a zero-height
bounding box, so an objectBoundingBox gradient on it collapses and the
line vanishes, leaving an arrowhead pointing at nothing. Caught that in
the render, not in review.
And the loop diagram was topologically incomplete. WP_ContextLoop has a
`loop_config` side-output; WP_SeedList takes it as an optional input and
mirrors the loop's count, strategy and base seed from it. That link is
exactly WHY the two fan out in lockstep, and the old figure -- and my
first rebuild -- drew them as unrelated sources, leaving the caption's
claim unexplained.
The icon extractor rewrote `fill` attributes to `currentColor`. Almost
no PrimeIcon HAS a fill attribute -- of the ones these figures use, only
`sparkles` declares one -- so a rewrite-what-exists pass left every
other glyph at SVG's default black.
That is why the feeder pills looked right while every node icon came out
black: the feeders happened to use icons carrying fills.
Concrete fills are now stripped and the wrapping group carries
`fill="currentColor"`, so anything without its own fill inherits the
group colour. `fill="none"` is preserved -- on the icons that use it, it
marks the deliberately hollow part, and filling it would blob the glyph.
Verified rendered in both themes: WP nodes teal, external nodes muted,
feeder pills in their per-kind colours.
The two figures were hand-drawn SVG that had drifted from the app: flat
horizontal cards, approximated icon shapes, a violet accent the product
does not use, and — in the loop diagram — the assembled prompt running
straight into KSampler. It does not go there. Text is encoded first and
only conditioning reaches the sampler; seeds are the one thing that
arrives directly.
Both are now generated from the real component. PrimeIcons ships
`raw-svg/`, the same paths the webfont is built from, so the README
carries the actual glyphs rather than lookalikes, and every value is
lifted from PipelineDiagram.vue and theme.css instead of eyeballed:
teal `--wp-node` accent, `--wp-bg-2` cards at radius 10, vertical
icon/name/mono-subtitle layout, kind-coloured feeder pills, per-chip
accent borders, 9.5px uppercase connector labels.
A `prefers-color-scheme` block mirrors the SPA's light theme, so the
figures follow the reader's GitHub theme rather than sitting as a dark
slab on a light page. `pnpm docs:figures` regenerates them.
The SPA components are untouched — they already theme correctly.
Also: the release-notes tail moves into a <details>. On a release this
size it would otherwise double the page. The summary carries the count
so the reader knows what they are opening; the cost, noted in the
template, is that collapsed text is not reliably reachable by
find-in-page. The modal is unaffected — it already cuts above this.
Both popovers rendered one line of text per hit — a name, and for `@` a
trailing "12 opts · a1b2c3d4". That distinguishes entries whose names already
differ, and nothing at all when they don't, which is precisely the case that
sends someone to the popover in the first place. A library here has two
wildcards called `act_plan`; the old rows were byte-identical.
Rows are now built from what the thing actually is:
- `@` rows carry the wildcard's option count, its axes and its tag count,
with the uuid kept as the last-resort tiebreaker for entries that match on
every other fact. A funnel marks rows where Tab opens a filter, shown on
the active row only — on every row it is a column of noise, and the
affordance is unreachable for rows you are not on.
- `$` rows name the writer. The canvas walked a graph so it reads "written by
Style FX · ember-marten" and can honestly say a write "overrides 2"; the
library has no execution order, so the same count reads "2 others bind
this" instead. That distinction is resolved in one place, so the popover
and RefChip's hover card cannot drift into telling different stories about
the same variable.
The header now reports how many entries MATCHED, and says "· showing 50" when
the row cap hid the rest. It first reported `acRows.length`, which is the
post-cap figure — "50 matches" for a query that hit 89. A silent truncation is
how you conclude the wildcard you are looking for does not exist.
Row models live in `utils/suggestion-rows.ts`: pure data-to-data, out of the
2.8k-line editor that hosts them, and directly assertable.
Bundle budget 468 -> 490 KB. The previous bump left 179 bytes free, so every
change since has had to argue with the gate; this one is sized to also cover
the filter panel that follows.
The var-chip hover could only confirm a name existed. With near-identical
modules bound to the same var across several nodes, that is not actionable —
the question is always WHICH one wins.
Adds `collectUpstreamProducers`: the same chain walk and last-write-wins
ordering as `collectUpstreamKinds`, widened to keep identity instead of
discarding it. Covers all three producer sources — WP_Context modules,
WP_ContextInjector rows, and the WP_ContextLoop iteration vars (`$name` +
`$name_total`). Muted / bypassed nodes are skipped, matching runtime.
Node identity follows what each node actually has: WP_Context nodes report
their stable `adjective-noun` codename, the same one their header shows;
the Injector and Loop have no codename scheme, so they use a user-set title
and fall back to a plain kind label.
The card now reads "wildcard · Outfit · in dusk-marten" with the module id,
and adds two states worth surfacing: "overrides N earlier writes" (runtime is
last-write-wins, so it names the winner and says others exist) and "internal —
stripped from the final prompt", which is the most confusing state a var can
be in.
`graphAware` is an explicit prop rather than inferred: a graph-aware host that
finds no writer must say "no upstream producer", while the SPA — which has no
chain to walk — must not make that claim at all.
Raises the bundle budget 461 -> 468 KB. Approved standing: the maintainer
asked for the budget to move rather than features to be trimmed to fit.
The chip hover cards and the self-contained Select styles push the canvas
bundle past the previous 453 KB ceiling. Entry budget is untouched (main.js
stays ~4 KB of the 30 KB allowance); this only moves the total.
Raised deliberately rather than trimming further — the remaining headroom is
~8 KB, so new canvas CSS/JS still needs to stay lean.
Adds `bypass_frames: number[]` to ContextLoopConfig + its recovery parse
(sorted, deduped, non-negative ints, out-of-range kept — mirrors the Python
_parse_config). Bumps the js/ gzip budget 450 -> 453 KB: the config field
alone landed 34 bytes over 450, and the headroom covers the loop-bypass UI
added in the following tasks.
Right-click a wildcard / combine / fixed-values row and "Hold across
run" now flips instance.seed_scope without opening the editor (4 clicks
to 2). The item swaps to "Release hold" when held, mirrors the editor's
base-only rule (disabled with a hint while a loop edit-frame is active),
and is gated to HOLD_KINDS so derivation/constraint rows -- whose
handlers ignore seed_scope -- never see it.
Bumps the extension bundle ceiling 449 -> 450 KB (measured 133 B over).
The ComfyUI instance-modal matrix (MatrixSection) was still flat while the SPA
ConstraintMatrix had grown grouped axes. Bring it to parity:
- Extract the pure grouping logic (order-by-axis, contiguous groups, bucket
detection, labels, per-axis hue) into a shared components/shared/matrix-axis
module so the two grids stay identical; refactor the SPA matrix to import it.
- Port MatrixSection: column bands name each target axis, row header chips name
each multi-tag source axis, a solo axis folds its name into an eyebrow, and
the leftover run is labelled "uncategorized" (neutral, dashed) — not blank.
Headers + tag column + group chips + corner freeze while the grid scrolls on
both axes. All per-instance cell logic (overrides, popover, stranded) is
unchanged; cells stay keyed by tag name so the reorder is safe.
- ConstraintInstanceModal threads each wildcard's tag_groups into the matrix.
Cross-context refs resolved only via the preview-resolver cache (which has no
tag_groups) degrade to flat — the common sibling/chain case groups.
- Bump the extension bundle budget 432 -> 435 KB for the ~1.1 KB net growth.
Three pool/UI fixes. (B) The MOD dot now lights on filter / pick-range / exclude-null changes — the category_filter check ran nonEmptyArr() on a string and silently missed, so toggling the pool filter never marked the instance modified. (C/D) The wildcard card subtitle shows 'X of Y options' when a filter narrows the pool, and '· picks N[-M]' when the instance multi-picks. (F) A nested @{uuid} ref to a live-library wildcard not in the chain no longer shows a false BROKEN REF — the Context node DB-expands its catalog at run, so it resolves; only refs absent from BOTH chain and library still flag (and those already emit unknown_ref in the debug panel). Bumps the extension bundle budget 431->432 KB.
Every per-instance edit modal (wildcard, fixed-values, derivation, combine, constraint, bundle) now scrolls its body when content overflows, with the header pinned to the top and a sticky dock pinned to the bottom (runtime + footer, or target-reach + footer for constraints, which have no runtime) so Save is always reachable. The long inner lists — options, values, rules, exceptions — each scroll independently with sticky column headers. Constraint's target-reach moves to last. Compact + right-padded the exceptions delete button. Bumps the extension bundle budget 430->431 KB for the added modal CSS.
RichTextInput rebuilt an index-keyed v-for over a hand-mutated contenteditable when the nested-ref picker stole focus, throwing insertBefore/null and freezing the page. Scope a renderEpoch rebuild to the chip-insert path so typing/backspace is untouched. useEditorDraft now flushes the pending debounced draft on unmount and beforeunload, so a refresh after a freeze no longer loses work; ErrorBoundary tells the user their work was auto-saved. Bumps the extension bundle budget 430->431 KB for the added widget code.
Add the TypeScript mirror of engine/modules/_constraint_math.py with
identical semantics, validated against the SAME shared corpus
(tests/fixtures/constraint-corpus.json) so Py === TS parity holds. Vitest
covers 2 unit cases (multi-tag multiply, exclude-absorbing) plus the 4
corpus cases (multi-tag->1, multi-pick->6, exclude->EXCLUDE, exception->0.25).
Also bump bundle TOTAL_LIMIT 405 -> 430 KB for the upcoming SP3 constraint
reach UI; entry/per-file limits unchanged.
The inline `<span class="wp-check">` controls relied on the global `.wp-check`
rule from manager/styles/tokens.css, which is loaded in the SPA but NOT on the
ComfyUI canvas (settings.ts pulls a hand-picked CSS set; tokens.css is not in
it). So in canvas-mounted instance modals the converted checkboxes rendered as
unstyled, zero-size spans — invisible. (`.opt__check` survived because it is
component-scoped CSS, which ships into every bundle the component renders in.)
Fix: extract the wildcard-option box into `WpCheck.vue` — a shared component
whose `<style scoped>` mirrors `.opt__check` verbatim. Scoped CSS is compiled
into the component chunk, so it travels into the SPA *and* the canvas bundle
with no dependency on any global stylesheet. Route PoolSection, DisabledRules-
Section, SubcategoryFilterPicker, Push/PushBundleToLibraryModal, and
DisplayPlaygroundModal through it.
The SPA keeps its global `.wp-check` (ui/Checkbox.vue consumers) untouched.
Reverts the 405 -> 407 KB budget bump: one shared component is leaner than the
13 inline copies, so the total lands back under 405 KB.
Sweep the leftover browser-native `<input type="checkbox">` controls onto the
inline `wp-check` span so every checkmark box reads as the wildcard-option
style. Bumps the bundle budget 405 -> 407 KB for the added markup (~0.8 KB
gzip); the embed entry stays slim.
Converted (true checkboxes):
- DisabledRulesSection: per-rule disable toggles (onToggle now takes a bool).
- SubcategoryFilterPicker: exclude-null (manager twin of the pool toggle).
- PushToLibraryModal: "propagate to bundles".
- PushBundleToLibraryModal: cascade "restore missing refs".
- DisplayPlaygroundModal: 7 display-pref switches (drop the dead wp-pg__check
rule; rows become <div> so no orphaned <label> remains).
Intentionally left as-is — these are toggle SWITCHES / custom toggle buttons,
not checkmark boxes: the row-enable sliders (ModuleRow, InjectorRow,
MockModuleCard, BundleHeader), the internal-flag toggle, and seed-lock.
Tests updated to the aria-checked / click contract.
Chunk F (src/manager/components): SubcategoryFilterPicker reworked into a text-first boolean-expression editor (insert-at-cursor chips/operators, live reads-as + 'N of M match' count + validateExpression, exclude-null toggle; emits {expr, excludeNull}). RefChip shows a compact funnel indicator + hover tooltip instead of an inline subcat suffix. RichTextInput serializes ref atoms to @{uuid#name:expr!null}.
Chunk G (src/components/context/editors/wildcard): probability.ts isEnabled matches the boolean category_filter expression (parse+matches) + exclude_null. OptionRow renders per-tag CATEGORY chips colored by tag_groups axis. PoolSection: grouped quick pills (or within a group, and across) + an advanced boolean editor + exclude-null toggle.
Foundation/cascade: _shared.ts Instance.category_filter string[]->string + exclude_null; prune.ts drops stale tokens from the expression via subcatExprCascade; ContextWidget tooltip renders the expression string. Bundle-size budget 395->405 KB for the graph editor + shared parser (documented). Full vue-tsc + vitest green.
The community TS validators re-implement the engine's payload shapes by
hand, and drifted repeatedly (fixed_values entries-vs-values, wildcard
is_null omitted) -- each drift surfaced as a publish failure for a user.
This makes the engine the single source of truth.
- scripts/dump_engine_shapes.py: builds one sample payload per module
subtype, validates each against that subtype's own engine
Handler.validate_payload (raises if not engine-valid), and writes the
engine-row shape to src/validators/fixtures/engine-parity/*.json. Run
it whenever an engine module shape changes; commit the regenerated
fixtures.
- src/validators/__tests__/engine-parity.test.ts: asserts the STRICT
community validator accepts every engine-dumped fixture. 5/5 pass.
Now drift fails CI on either side: a sample that no longer matches its
handler fails the generator; a validator that rejects the real shape
fails the parity test. CLAUDE.md schema section documents the loop.
Five tightly-related changes to the registry-publishing path.
.comfyignore
Drops dev-only + repo-plumbing files from the registry archive
even though they stay in git: src/ (frontend source), tests/,
.github/, .husky/, public/images/docs/ (doc screenshots used at
SPA build time, baked into web/ anyway), vite + eslint + commitlint
+ semantic-release configs, scripts/, repo metadata (CHANGELOG,
CONTRIBUTING, CODEOWNERS, CODE_OF_CONDUCT, SECURITY). Syntax is
gitignore-style globs per the Registry spec.
[tool.comfy] in pyproject.toml
- Banner: 21:9 svg at public/images/comfy-banner.svg served via
raw.githubusercontent so the listing card on registry.comfy.org
renders the project banner instead of an empty placeholder.
- requires-comfyui = ">=0.3.36" — floor that introduces the V3
io.* schema (io.Schema, io.ComfyNode, @comfytype, ComfyTypeIO)
the entire wp_nodes/ tree uses. Older runtimes would fail at
import; the Manager warns instead.
- includes = ["js", "web"] — both build-output trees are
gitignored, so without this the registry archive would ship
without the canvas widgets (js/) or the manager SPA (web/).
public/images/comfy-banner.svg
Standalone 1400×600 svg (≈2.33:1, matches 21:9). Left half: title +
underline accent + tagline + module-kind badges (Wildcard / Fixed /
Combine / Derivation / Constraint, kind-colored). Right half: WP
Context → emits → resolved $vars → fills → Prompt Assembler flow.
Faint canvas-grid background so it reads as "this lives in
ComfyUI" at a glance.
scripts/pack-release.mjs
Fixes the manager outDir literal: ships web/ (the actual vite
outDir per vite.config.mts), not the stale web_dist/ name in the
CLAUDE.md comment. Without this the github-release zip would
ship without the SPA.
.releaserc.js — collapsed-changelog header / footer partials
release-notes-generator's writerOpts now wraps the per-commit
list in a <details> element with a fixed "Wildcard Pipeline
<version>" + doc/discord/install/issues links header. GitHub
renders <details> natively in release markdown — readers see the
TL;DR + 5 useful links above the fold (well under GitHub's
"Read more" character cutoff) and click to expand the full
feat/fix/perf log. The header text is also the part most likely
to stay useful weeks after the release.
Drops six pieces of repo plumbing in one go.
publish workflow (.github/workflows/publish.yml)
Fires on every published GitHub release + manual dispatch. Builds
the extension + manager SPA, then invokes Comfy-Org/publish-node-action
with REGISTRY_ACCESS_TOKEN so the ComfyUI Manager listing stays in
lockstep with semantic-release tags. Without this the README's
"search Wildcard Pipeline in the ComfyUI Manager" path would never
see new versions.
issue + PR forms (.github/ISSUE_TEMPLATE/, PULL_REQUEST_TEMPLATE.md)
- config.yml: blank issues off, three contact links (Discord first,
Discussions, Wiki).
- bug_report.yml: structured form with version chip, multi-select
surface dropdown covering all 10 nodes + 5 SPA areas + engine +
build, ComfyUI version, OS, Python version, repro, expected vs
actual, traceback (shell render), workflow JSON (json render),
WP Debug warnings paste-box.
- feature_request.yml: problem + proposal + alternatives + scope +
importance dropdown; nudges toward "describe the underlying
problem, not just the solution".
- PULL_REQUEST_TEMPLATE.md: summary, conventional-commit type
checkboxes mapped to release impact, related-issues line,
local-verification gate checklist, screenshots prompt for
visible changes, risk callout box.
release-zip + cleaner notes (.releaserc.js + scripts/pack-release.mjs)
- Switched commit-analyzer + release-notes-generator to the
conventionalcommits preset (already a transitive dep, no install).
- Marked chore/docs/style/test/build/ci as hidden:true so they
don't render in the GitHub release body. Only feat/fix/perf/
refactor/revert show. Past releases (v1.8.0) keep their long
notes; v1.9.0+ ships clean.
- New scripts/pack-release.mjs zips the deployable file set
(engine/, wp_nodes/, wp_api/, js/, web_dist/, public/, docs/help/,
__init__.py, pyproject.toml, README.md, LICENSE) into
dist/ComfyUI-Wildcard-Pipeline-v<version>.zip.
- @semantic-release/exec prepareCmd runs the version-stamping
AND the pack-release.mjs invocation so the zip carries the
right version in its name.
- @semantic-release/github assets config attaches the zip to the
GitHub release page so manual installers can grab a single
artifact instead of cloning at a specific tag.
Notes on what's NOT changed:
- ci.yml + release.yml stay as-is — local versions are a strict
superset of origin's (coverage gate, perf gate, fuzz job,
bundle-size budget). Release workflow already had the env +
permissions needed for semantic-release.
- bundle-size-pr.yml stays (local-only, useful).
Three coordinated changes to the WP_* node family.
1. New WP_SeedList node
Emits a list of N derived seeds for a downstream loop (is_output_list).
Three independent override toggles (override_seed / override_count /
override_strategy) each mirror the wired Loop's base_seed / count /
strategy. Connecting loop_config auto-flips override_count +
override_strategy ON so the natural intent of wiring in a loop gets
the defaults right; override_seed stays explicit opt-in.
All emitted seeds are masked to 50 bits via the new MAX_SAFE_SEED
constant in engine.seed_derive so values sit inside ComfyUI's
frontend randomize range and copy-paste reproduces identically in a
standalone sampler.
2. Context Loop type split + downstream Context seed widget lock
Split WP_CONTEXT_LOOP_CONFIG (wire payload) from a new
WP_CONTEXT_LOOP_WIDGET (DOM-widget-only TYPE). Loop's own widget
mounts on the widget type; the wire type has no factory so consumers
like WP_SeedList render their loop_config socket as a plain input
instead of being hidden by a fallback-undefined widget.
New graph walker hasUpstreamLoopOverridingSeed: WP_Context's widget
glue greys out the local seed stock widget when an upstream Loop has
override_seed ON, so users read "this value is dead while the Loop
drives it" without inspecting the Loop config separately.
3. wp_ prefix sweep + tooltips across every WP_* node input
Custom DOM widget input names now carry the wp_ prefix so canvas
socket labels read as branded WP widgets: modules -> wp_modules,
rows -> wp_rows, viewer -> wp_viewer, cleaner -> wp_cleaner,
context_loop_config -> wp_context_loop_config. Every stock input
across the node family gets a tooltip describing what it does and
when to wire it from another node. Tests + comfy_api stub updated.
CLAUDE.md notes the wp_ convention for future custom widgets.
Bundle size budget bumped 388 -> 395 KB to fit the SeedList Vue chunk
(~4.9 KB net gzip) + override lock UI; entry stays at ~4 KB gzipped.
Socket rows now scope their optional template to their OWN slot only
(top-to-bottom resolution; other $input_M stay literal). Adds a durable
general-template row (kind: "general") that survives socket churn, is
resolved after all socket rows, and composes from both raw sockets
($input_N) and socket-row bindings ($test). Engine processes socket rows
first then general rows in order; general rows honor binding validation +
the internal flag. Frontend: kind discriminator, "Add template row"
affordance, general-aware reconcile that keeps general rows, inline
binding+template editor with a refs hint, conflict-scanner + docs updates.
The bundle-size gate measures only the extension bundle (js/). The
Documentation tab lives in the manager SPA (web_dist/), so it does not
affect this budget; the earlier 395 KB bump was unnecessary and is
reverted to keep the extension gate meaningful.
Load now persists the picked template's {id,name} on node.properties
(workflow-serialized, survives reload), so Save can offer to update the
exact row the user loaded — the template-side analogue of the bundle
push-to-library identity. Clearing the template drops the ref.
Rebuilds both modals on ModalShell with the library look adapted from
ModulePickerModal / PushToLibraryModal:
- LoadTemplateModal: search + category/tags/favorites filter popover +
scrollable rows, single-click pick (no kind tabs / multi-select).
- SaveTemplateModal: name/description/category/tags + live preview, with
a dual footer — "Update existing" (the loaded or same-name row) and
"Save as new entry" (create, auto-suffixed). Saving rebinds the load
identity so the next Save defaults to updating that entry.
Bumps the total bundle budget 380 -> 385 KB for the two heavier lazy
modal chunks (~3 KB net gzip; entry untouched).
- Cross-node drag MOVE-becomes-COPY race: ContextWidget.onDrop sync-claims
consumedBy before any await so the source widget's dragend reads the
set value. dragend filters by _uid (idempotent) instead of stale
srcIdx — fixes the secondary issue where the backup pendingHandoffs
queue + dragend double-removed an adjacent target wildcard.
- pendingHandoffs queue in drag-store: belt-and-suspenders source-side
cleanup that runs even when dragend skips (constraint rows inside the
bundle frame were the trigger).
- Conflict scanner constraint_orphan_target slot allocator: per-target
sorted local positions + remaining downstream count. Each constraint
claims smallest local slot > own index, else a downstream slot. Fixes
duplicated constraints with target downstream cross-node + correctly
flags when only one local target sits BEFORE the constraint.
- collectDownstreamWildcardUuids: per-instance occurrences (not Set-
deduped) so duplicated downstream targets each count as a separate
claimable slot.
- RichTextPreview + ConstraintInstanceModal: cross-Context refs fall
back to preview-resolver cache (sub_categories, option values,
options_by_id, hasNullOption) so the canvas modal renders the actual
wildcard name, sub-cat list, and chip suffix instead of "source"/
"target" placeholders + stale matrix keys.
- Preview-resolver schema extended with subCategories / optionValues /
optionsById / hasNullOption populated from embed-bundle response.
- Constraint save hydrate: wp_api modules + bundles endpoints fill
exception source_value / target_value from source_id / target_id by
looking up the wildcard's option list before validation. Heals
library rows that stored only ids.
- TestRunner constraint table uses RichTextPreview for column headers
+ row values so nested @{uuid} refs render as chips.
- Inverted null filter UI: SubcategoryFilterPicker "Exclude null"
checkbox + RefChip "!null" suffix render.
- Bundle size budget bumped 330 KB to 340 KB.
- Drop stale CHECKPOINT-*.md docs.
User reported: dragging a bundle doesn't show an indicator and won't drop below its current position. Root cause: the resolver's "skip the dragged bundle" filter was too narrow — it excluded the dragged bundle's frame and rows whose bundle_origin === draggedUid, but kept the bundle's NESTED descendants (inner bundles + leaves whose bundle_origin matches an inner bundle of the dragged outer) as candidates.
When dragging the outer "hair_style" bundle, the inner "demo_bundle" and its leaves were still drop targets. Pointer over inner's body or its leaves resolved to zones targeting elements that move with the outer — applyDrop would no-op (self-related zone) or produce a misleading indicator at the source.
Fix:
- New helper `collectMovingBundleUids(draggedUid, bundles)` walks parent_uid descendants and returns the set of bundle uids that move with the dragged range.
- `classifyAgainst` (top-level walker) skips any bundle in that set AND any row whose bundle_origin is in that set.
- `classifyWithinBundle` body walk uses the same filter for its directRows iteration.
- Added a guard at the top of `classifyWithinBundle`: if the bundle the pointer is inside is part of the moving set (e.g. pointer in the inner bundle while dragging the outer), return null entirely. The whole region moves with the drag — no valid drop target.
Bundle-size budget bumped from 312 KB → 316 KB to accommodate the cumulative drag-redesign work (container-scoped resolver + pure applier + indicator SFC + zoom-aware offsetTop walker + moving-range filter). Net add ~1 KB; the +4 KB headroom is for follow-up polish in the same area.
Verified live via Chrome MCP at ComfyUI's 91% canvas zoom:
- Drag outer header → inner middle: returns null (no valid drop in self-region)
- Drag outer header → below outer (past everything): bar at end zone
- dragState clears on dragend ✓
The canvas combine TemplateSection used a plain textarea + a custom $var insert dropdown. The SPA's RichTextInput (atomic-chip editor) had drifted significantly ahead — chip rendering, $/\@ autocomplete with raw-text-space probe, chipify-on-boundary, click-to-edit, picker-anchored-near-chip — and the canvas was the only surface still showing raw $name text without chip visuals.
TemplateSection now mounts RichTextInput via defineAsyncComponent (chunk-split so the combine modal only pulls it in when opened) with surface="combine", varSuggestions=availableVars, and the existing template_override / library-default collapse semantics. The bespoke insert-var dropdown is gone — the chip editor's $-trigger autocomplete surfaces the same list. Detected pills, preview pane, and resolved-preview pane stay (they're separate read-only panes, not editing surfaces).
Bundle budget bumped 300 → 312 KB to fit the lazy-loaded RichTextInput chunk (~9 KB gzipped). Test rewrites use a small stub for RichTextInput so prop wiring and emit-driven updates can be asserted without booting the async resolver in jsdom.
Brings 25 legacy BEM-style class selectors into compliance with the
extension's wp-* CSS namespace convention (per CLAUDE.md "Extension
isolation"). Renames .cbm__* / .cnm__* / .dvm__* / .fvm__* / .wcm__*
in _modal-head.css and .tpl__* in _modal-template-ctrls.css, plus
every consumer reference across .vue / .ts / .css / .test files.
Also renames the root element classes .wcm and .fvm (referenced from
unscoped display-prefs.css) to .wp-wcm / .wp-fvm, and clears both
entries from the LEGACY_ALLOWLIST in check-css-isolation.mjs now that
they are properly prefixed.
pnpm check:css-isolation now exits 0; all gates clean.