Compare commits

...
550 Commits
Author SHA1 Message Date
semantic-release-bot 27cbf5fa26 chore(release): 2.13.2 [skip ci] 2026-08-08 23:12:52 +00:00
Dumitru Picalau fc920b4d3c Merge pull request #18 from DumiFlex/fix/editor-crashes-and-release-integrity
fix: Firefox editor crashes, library version guard, and release archive completeness
2026-08-09 02:12:08 +03:00
DumiFlex d604deb6a5 docs(release-notes): curated highlights for the editor and library fixes
`.releaserc.js` renders no commit sections (`transform: () => false`), so
this file IS the release body. Without it the release ships with only a
header and a compare link — the blank-changelog outcome that 2.13.0 hit.

Written for users rather than maintainers: what broke, what it looked
like, and which installs were affected. semantic-release clears the file
on release, so it leaves no net change on main.
2026-08-09 01:59:00 +03:00
DumiFlex 8cf40241cf fix(db): record migration checksums and refuse a foreign schema
`migrate()` skips everything `<= MAX(version)`, which made an integer
the entire identity of a schema. It is not, and two failures followed
from that, both silent:

1. A database migrated by a NEWER build. Every migration is already
   "applied", so the runner does nothing and the caller reads a schema
   it has never seen. Additive columns survive; a shape change does not.
2. A database at the SAME version but a different shape, because a
   migration file changed after it had already run somewhere — a
   revised migration, a hand-edited file, an abandoned branch. Version
   numbers match, so nothing compared anything.

Both corrupt cumulatively: every subsequent write goes through code
whose assumptions about the row shape are wrong, and the user has no
signal until something much later fails for an unrelated-looking reason.

Migration 017 adds a nullable `checksum` column; the runner records the
sha256 of the file it actually executed. `assert_schema_compatible`
checks both failure modes and runs at the top of `migrate()`, so no
caller can forget it.

NULL is treated as "unknown", never "mismatched". Rows applied before
017 existed cannot retroactively grow a checksum, and backfilling them
from the files on disk would assert exactly the compatibility the check
exists to verify. So the version-ahead half protects every database
immediately; the divergence half protects migrations from 018 onward.
2026-08-09 01:52:47 +03:00
DumiFlex 72e96ec73f fix(release): ship locales/ in the release zip, and make the ship list total
`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.
2026-08-09 01:52:46 +03:00
DumiFlex 5d593a1700 fix(editor): repair the host whenever the fragment anchors are gone
Second report, same wound, different door: Ctrl+A then Ctrl+X inside an
option value bricked the field on Firefox — typing, Backspace and even
Save and Cancel stopped responding — with `insertBefore: Child to insert
before is not a child of this node` and the `g is null` unmount cascade.

`onHostCut` already routes the deletion through `applyAtoms` so the
browser never removes the spans itself (2.13.1). But the atom list still
shrinks from N to 1, and shrinking a keyed list unmounts the surplus
`<template v-for>` Fragments. With the fragment anchors normalised away
by Firefox that teardown walks off the end of the child list, exactly as
the ref-picker path did — reached from a call site nobody had flagged.

Marking individual call sites `rebuild` was a whitelist, and the
whitelist kept missing entries: three separate crashes now, three
separate patches. Ask the DOM instead.

`hostAnchorsIntact()` counts the empty text nodes Vue uses to delimit its
Fragments — a healthy host holds exactly `2N + 2` of them, verified
against the live bundle. `isStructuralApply()` marks the applies that
add, remove or re-branch an atom, which are the only ones that touch an
anchor. When both hold, the host is replaced instead of patched, and all
thirteen `applyAtoms` call sites are covered at once.

Costs nothing on Chromium, where the anchors are never missing, and
nothing on the same-shape applies that carry ordinary typing — a test
pins that a chip settle still lands on the very same host element.

Save/Cancel and Backspace needed no separate fix; both were the dead
subtree left behind by the first throw.
2026-08-09 01:52:45 +03:00
DumiFlex 81495ea530 fix(editor): stop the ref picker killing the field on Firefox
Inserting a nested `@` reference took the whole editor down behind the
error boundary with `can't access property "nextSibling", e is null`,
followed by the unmount cascade (`g is null`). Firefox only.

`<template v-for>` mounts each atom inside a Fragment, and Vue delimits a
Fragment in the DOM with two EMPTY TEXT NODES. Those anchors sit inside
the contenteditable host, which the browser owns — and Firefox normalises
empty text nodes away as the user types. Chromium keeps them, which is
why this never reproduced here.

The picker-Apply path then re-keyed the atom v-for to force a clean
remount. Vue's `removeFragment` walks `nextSibling` from the start anchor
until it reaches the end anchor; with the end anchor gone the walk runs
off the last child and derefs null.

`hostDomIsStale()` could not catch it: it counts `[data-atom-index]`
ELEMENTS, and what the browser destroyed were anchors, so it reported a
healthy DOM and the re-key path was taken.

Both rebuild routes now go through the host ELEMENT swap that already
existed for the stale-DOM case. Unmounting an element vnode is a single
`hostRemove(el)` on a node that IS still attached, and its children come
down with `doRemove: false`, so no missing anchor and no detached child
el is ever touched; the new tree then mounts into an empty element with
nothing to insert before. The v-for key drops back to the plain index.

A host swap also replaces the element the ResizeObserver was watching, so
`useGrowableField.attach()` is now idempotent and RichTextInput re-attaches
on every swap — otherwise the overflow fade and grip-follow died silently
for the rest of the session on any field that had ever repaired itself.
2026-08-09 01:52:45 +03:00
semantic-release-bot 494b53192d chore(release): 2.13.1 [skip ci] 2026-08-04 19:11:29 +00:00
Dumitru Picalau 8c0f3e418e Merge pull request #17 from DumiFlex/fix/republish-2131
fix(release): re-cut 2.13.0 as 2.13.1 for the registry
2026-08-04 22:10:43 +03:00
DumiFlex d2f548d0e2 fix(release): re-cut 2.13.0 as 2.13.1 for the registry
The registry publish for 2.13.0 failed:

    400 {"message":"The node version already exists"}

The empty 2.13.0 cut earlier today was deleted from the registry, but
deleting frees the LISTING, not the number — version records are
immutable there, the same model as PyPI and npm, and the publisher
requires each new version to be greater than every existing one. So
2.13.0 can never be published again, whatever it now contains.

The consequence is worse than an untidy version history: 2.13.0 carries
the `pending_move` containment fix, it reached GitHub, and it did NOT
reach anyone installing through ComfyUI Manager. Those users are still on
2.12.0 with the unauthenticated file copy/move reachable.

Restores the 2.13.0 notes so this release ships with them rather than the
empty body an emptied `next.md` would produce, plus a line for people
already on 2.13.0 saying the two are the same build.
2026-08-04 22:04:40 +03:00
semantic-release-bot 7e36bb02c6 chore(release): 2.13.0 [skip ci] 2026-08-04 18:54:27 +00:00
Dumitru Picalau 3a5e46cd9d Merge pull request #16 from DumiFlex/fix/db-move-containment
fix(api): confine the deferred DB move to known database locations
2026-08-04 21:53:48 +03:00
DumiFlex 757116dc54 feat(spa): show skipped releases on the What's new page
The page rendered only the newest release, so updating across two
versions meant the middle one's notes were unreachable. That is not
hypothetical: 2.12.0 — the largest release in months — was current for
about an hour before 2.13.0 replaced it, so anyone updating from 2.11.0
would never have seen it.

The last three releases now appear beneath the current one, collapsed,
each with its own notes and a link to its release page. Three because the
gap is small but real; more and the page becomes a changelog, which the
compare link already does better.

Fetched through the same throttle and rate-limit lockout as the update
check — a second GitHub call on a shared 60/hr anonymous quota, on a page
someone may open repeatedly, is exactly what the caching work existed to
prevent. Cached under its own key with the same TTL, and skipped
entirely while a lockout is in force.

Also fixes the "All releases" button, which preferred the current
release's own URL and so went to a single release whenever the release
check had resolved — the common case. It now goes to the releases index,
which is what it says.
2026-08-04 21:47:37 +03:00
DumiFlex 05334d3e05 fix(api): confine the deferred DB move to known database locations
`PUT /wp/api/database/config` accepted `pending_move.from` and `.to` as
any absolute paths. `execute_pending_move` then runs `shutil.copy2` or
`shutil.move` on them at plugin load, as whoever runs ComfyUI. Every
ComfyUI route is unauthenticated by default, so anyone able to reach the
port could schedule:

  * an arbitrary file read — copy a target into `web/`, which this plugin
    serves at `/wp/<path>`, turning our own static server into the
    exfiltration channel;
  * an arbitrary overwrite or deletion — move any file anywhere.

Fixed with an allowlist rather than a sanitiser. The database can
legitimately live in exactly three server-computed locations under one
fixed filename, so there is no reason to accept a client-supplied path.
The live path is also allowed, since WP_DB_PATH / COMFYUI_USER_DIR can
place the file elsewhere and those users must still be able to migrate
off it.

Both paths are `resolve()`d before the membership test, so neither `..`
nor a symlink planted at an allowed location can smuggle a different
target past it, and the resolved values are what get stored — what
executes is what was validated.

`spa.py` already did this correctly (`resolve()` + `relative_to()` + 403);
this route simply missed the pattern.

Adds 7 containment tests covering arbitrary source, arbitrary
destination, the exfiltration-into-`web/` chain, traversal, and a no-op
move — plus one asserting a legitimate relocation still works, since a
guard that breaks the feature it protects is its own bug.
2026-08-04 21:36:59 +03:00
Dumitru Picalau 2d68c61f4a Merge pull request #15 from DumiFlex/ci/no-release-for-tooling
ci: stop tooling-only pushes from cutting a release
2026-08-04 20:24:58 +03:00
DumiFlex c6d8834a42 ci: stop tooling-only pushes from cutting a release
2.13.0 shipped as an empty release. Three commits touching only
`scripts/` and `.github/` were typed `feat(announce)` and
`fix(announce)`; both are release-triggering, so semantic-release did
exactly what it was told, and every user on 2.12.0 was offered an update
with a blank changelog.

The discipline fix — remember to type tooling commits `ci:` — had already
failed once within an hour of the rule being written down, so this is the
structural one. `paths-ignore` means those paths cannot cut a release
whatever the commit type says.

Neither path is part of what a user installs: `scripts/` is dev tooling,
`.github/` is CI. GitHub skips the run only when EVERY changed file
matches, so a push touching shipped code alongside a workflow still
releases normally.

`docs/**` is deliberately excluded from the list: `docs(readme)` is
configured to bump a patch, and `docs/release-notes/next.md` IS the
release body.

publish.yml needs no change — it fires on a published release, so no
release means no registry publish.
2026-08-04 20:19:00 +03:00
DumiFlex 99d3b21334 chore(release): revert the empty 2.13.0 [skip ci]
2.13.0 shipped nothing. It was cut because `feat(announce)` and
`fix(announce)` are release-triggering types, but those commits only
touched `scripts/` and `.github/` — dev tooling that is not part of what
users install. They should have been `ci:`, which is `release: false`.

The result was a version bump with an empty body: every user on 2.12.0
would have seen an update prompt with a blank changelog.

Release and tag are deleted; this restores package.json, pyproject.toml
and CHANGELOG.md to 2.12.0.

`[skip ci]` is load-bearing. Without it this push runs release.yml, which
would analyse the same commits against the now-latest tag v2.12.0 and cut
2.13.0 straight back.

Note that 2.13.0 will be used again by the next REAL release: the
`feat(announce)` commits stay in history and still count toward the bump.
That is the intended outcome — the number is reused for a release that
actually contains something, rather than being burned on an empty one.
2026-08-04 20:16:03 +03:00
semantic-release-bot e9a9d51ea3 chore(release): 2.13.0 [skip ci] 2026-08-04 17:12:12 +00:00
Dumitru Picalau 0572eec472 Merge pull request #14 from DumiFlex/feat/announce-identity
feat(announce): identity, and two channels — staff automatically, public by hand
2026-08-04 20:11:23 +03:00
DumiFlex c7f4d91a10 fix(announce): never emit a half-formed role mention
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.
2026-08-04 20:05:45 +03:00
DumiFlex 45a9092bbc feat(announce): two channels — staff automatically, public by hand
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.
2026-08-04 20:01:25 +03:00
DumiFlex 2dfed26c1e ci(announce): post as Wildcard Pipeline, not as the webhook's own name
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.
2026-08-04 19:52:51 +03:00
Dumitru Picalau 5585583765 Merge pull request #13 from DumiFlex/ci/discord-announce-node-setup
ci: stop the announce job probing for a pnpm cache it never uses
2026-08-04 19:45:41 +03:00
DumiFlex c4a761d071 ci: stop the announce job probing for a pnpm cache it never uses
The v2.12.0 announcement failed before it ran a line of script:

  Unable to locate executable file: pnpm

`actions/setup-node` sniffs the lockfile, sees pnpm, and tries to prime
its cache — but this job never installs pnpm. It does not need to: the
announce script has no dependencies at all, just plain ESM and the global
fetch built into Node 18+. Nothing is installed, so there is nothing to
cache.

Caught only in the real run because the failure is in the runner's
toolchain setup, not in anything a local invocation of the script
exercises.
2026-08-04 19:39:44 +03:00
semantic-release-bot 736e152167 chore(release): 2.12.0 [skip ci] 2026-08-04 16:37:01 +00:00
Dumitru Picalau a2756f6c0a Merge pull request #12 from DumiFlex/feat/qol-sweep-v2.12
feat: Nodes 2.0 support, value provenance, and an editable-at-scale library
2026-08-04 19:36:11 +03:00
DumiFlex 15c3474519 perf(seeds): stop FrameChips inlining a third copy of the theme
`FrameChips.vue` imported `theme.css` into its scoped style. The sheet
declares its tokens on `:root`, so one copy anywhere in the document
serves everything — and this component only ever renders inside
ContextLoopWidget or SeedListWidget, both of which already import it.

Being a shared component, its chunk then carried the whole 521-line sheet
a third time. Removed, with literal fallbacks added to the two
declarations that lacked them so the component still renders if it is
ever used somewhere unthemed.

The gzip win is small (~0.3 KB) because the chunk it lands in already
contained the sheet via SeedListModal, and gzip dedupes the repetition
well. Worth doing anyway: the duplication would start costing real bytes
the moment these modules split into different chunks.

Note for anyone reading the bundle report on this PR: `FrameChips.js` is
listed as a new 10.70 KB chunk and `SeedListModal.js` as removed at
8.33 KB. That is one chunk being RENAMED, not 10.70 KB of new code —
Rollup names a shared chunk after one of its modules, and adding
FrameChips to the chunk that already held SeedListModal and SeedLockRow
changed which name it picked. Real new weight is closer to 2 KB.
2026-08-04 19:29:14 +03:00
DumiFlex 87382e650e Merge origin/main into feat/qol-sweep-v2.12
Brings in the 2.11.0 release commit. Only conflict was
docs/release-notes/next.md: main cleared it when 2.11.0 shipped, this
branch holds the unreleased v2.12 notes. Kept ours — clearing it would
ship a release with an empty body.
2026-08-04 19:15:34 +03:00
DumiFlex f240aeaf05 docs(release): bring the notes up to date, and declare Nodes 2.0 in the README
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.
2026-08-04 19:13:59 +03:00
DumiFlex 911e9839a4 feat(release): derive the Discord announcement from the release notes
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.
2026-08-04 19:06:03 +03:00
DumiFlex cc6d76da3f fix(refs): only promise the in-place remap where a host can perform it
The broken-ref card told every surface to "click the chip to point it at
another module". Only `RichTextInput` listens for `remap`.
`RichTextPreview` renders the same chips read-only, and the canvas
derivation modal's summary column is a preview -- so on the surface where
the card was most likely to be read, the one instruction it gave did
nothing.

`remappable` is now an explicit prop, set by the editable input alone.
Read-only surfaces point at the SPA editor instead, which is where the
value can actually be repointed. It defaults to false because a caller
that forgets the prop is precisely the caller with no handler wired:
promising nothing is the safe direction.

Nothing shipped stale -- `web/` is a gitignored local build artifact --
but it is worth recording that a change under `src/manager/` is invisible
in the running SPA until `pnpm build:manager`, and the pre-commit hook
only builds the extension (for the size gate). Touch the SPA, rebuild it
before judging what you see.
2026-08-04 09:33:50 +03:00
DumiFlex 6bd6140ae9 fix(refs): stop a deleted module's refs from rendering as healthy
A ref to a module deleted from the library went on looking fine: right
name, right colour, resolved-ref glyph, and a hover card quoting the
option count the module had BEFORE it was deleted. The node badge said
BROKEN REF while the chip inside the modal disagreed -- the two read
different sources, and only one of them was right.

ROOT CAUSE. When the server confirms a uuid is gone -- a 404, or a 200
that omits it -- `fetchBundle` stamped a permanent tombstone but left the
cached snapshot in place, so `lookup()` kept answering from it and no
consumer ever learned the ref had broken. The rule it was following
("keep the stale value so a label never flickers back to a raw uuid") is
right for a TRANSIENT failure, where the old value is our best guess at
the current one. Once the server says the module is gone, the old value
is not a guess, it is a false statement. Confirmed-missing uuids now
evict; transient ones still keep their snapshot. `lookup` also refuses a
permanently-tombstoned uuid outright, so the fact beats any cache entry
whatever wrote it.

`atomIsResolved` already documented exactly this case -- "a ref pointing
at a deleted library entry should still render unresolved (red chip) even
when the cached label survives" -- and was correct; it just asked a
`lookup` that was lying to it.

THE CHIP. Two more places assumed a resolved ref. `optionStats` counted
from whatever snapshot was cached regardless, and outranks the broken
branch in the template, so a gone module reported "2 options" and hid the
explanation. `poolLabel` printed "pool: library" because that is
`resolvePoolFor`'s fallback for an unknown uuid -- a fallback, not a
finding -- which put "not in the library" and "pool: library" in the same
card. Both now return null for an unresolved ref.

The broken chip keeps the name rather than collapsing to a bare "?": the
`@{uuid#name}` syntax preserves what the target was called when the
reference was written, so it renders as a red `? test` and the hover
names the missing module, says it is not in the library, and says
clicking will remap it. Phrased as "was", since a name we can no longer
verify is exactly what the old card was wrong about.
2026-08-04 09:22:18 +03:00
DumiFlex 433930d005 feat(seeds): persist the frame-grid collapse across a workflow save
The toggle reset to open on every reload, which made it close to
pointless: someone who collapses a 100-frame grid to get their node back
wants it collapsed tomorrow too.

State lives on `node.properties.wp_frame_chips_collapsed`. That is the
workflow-JSON serialization root, and the only place per-node UI state
survives a save -- a WeakMap would be lost on reload, which is exactly
what we are fixing. Key is `wp_`-prefixed per the extension-isolation
convention.

`FrameChips` takes it as a `defineModel`, so it stays usable standalone
(local fallback) while the widget glue owns the value in the app. The
glue keeps a ref as the render source of truth and writes through to
properties, so a toggle repaints immediately instead of waiting on a
poll.

The re-sync in `onValueRestored` is load-bearing: `create()` runs BEFORE
ComfyUI applies a workflow's saved properties, so the first read always
sees nothing. Without it a collapsed grid would come back open and the
persistence would look broken in precisely the case it exists for.

The collapsed header keeps reporting `N locked · N bypassed`. That
mattered when collapse was per-session; it matters more now that the grid
can stay hidden indefinitely, because a forgotten lock is a confusing run
with no visible cause.
2026-08-04 09:02:13 +03:00
DumiFlex d381cc4b06 fix(seeds): put the Seed List chips under the button they belong to
The chips are the inline view of what the per-iteration seeds modal
lists, so sitting above its button made them read as an unrelated
section. Below it they read as its expansion, and the two nodes now
agree: the Context Loop already ordered them this way.
2026-08-04 08:53:51 +03:00
DumiFlex f52d4271dc feat(seeds): frame chips on the Seed List, and a collapsible bounded grid
Extracts the Loop's per-frame chips into a shared `FrameChips` component
and gives WP_SeedList the same surface, because the two nodes were
already showing the same information through different code.

CLICK SEMANTICS DIFFER ON PURPOSE. The Loop has an edit cursor, so a
plain click selects the frame to edit and the state toggles need
modifiers. The Seed List has no cursor and nothing else a chip could do,
so a plain click toggles the lock directly -- making someone hold Shift
for the only available action would be ceremony.

Bypass is asymmetric for the same kind of reason. The Loop OWNS its
bypass set, so Alt-click toggles it. The Seed List only MIRRORS the
bypass of the loop wired into its `loop_config`, so its chips show the
state and refuse to change it: editing there would mean writing to a
different node. Alt-click is inert rather than absent, and the tooltip
names where the setting actually lives, so a click that does nothing does
not read as a bug.

Both nodes now show locked frames too. That state existed and was only
visible inside the modal, which defeats the point of reading it off the
node.

COLLAPSE + BOUNDED HEIGHT. A 100-frame loop previously grew the node
until the chips owned the canvas. The grid now caps at roughly four rows
and scrolls; the wheel shield already installed on every widget host is
what keeps that scroll from zooming the canvas under Nodes 2.0.

The header keeps a `N locked · N bypassed` summary while collapsed --
hiding the grid must not also hide the fact that frames are set, since a
forgotten lock is a confusing run. Only in-range frames are counted: an
out-of-range lock has no chip here, and naming it would point at a frame
that is not on screen.

Collapse is deliberately NOT persisted. Surviving a workflow save would
mean a hidden grid staying hidden across sessions, which is the same
forgotten-lock trap. Re-opening costs one click.

Test ids for the chrome are pluralised (`loop-frames-grid`) so they do
not collide with the chips' own `loop-frame-` prefix, which suites count
with a prefix selector.
2026-08-04 08:51:33 +03:00
DumiFlex 6cf4990513 feat(loop): lock and bypass a frame straight from its chip
Both states were already VISIBLE on the frame chips -- a dashed border for
bypassed -- but only CHANGEABLE from the per-iteration seeds modal, which
is two clicks away and covers the node you were reading. Flipping one or
two frames is the common case, so it should not cost a round trip through
a dialog.

  Shift-click  toggle the frame's seed lock
  Alt-click    toggle bypass

Ctrl is deliberately unused: on macOS Ctrl-click IS a right-click, so the
combo would open a context menu instead of firing. Alt for bypass follows
the idiom already in the seed modal, where Alt-click on Copy takes the
locked-only variant.

A chip-lock writes the seed the frame WOULD have used, from the same
`deriveLoopSeeds` series the modal's "Lock all" writes, so the two agree
-- a lock that froze a different value than the one displayed would be
worse than no lock.

Bypassing the last running frame is refused, mirroring the modal's own
rule, and says why with a toast. A silent no-op would read as a broken
click, and nothing on the chip hints at the reason.

A modifier click never also moves the edit cursor. Jumping the editor to
another frame as a side effect of locking one is the kind of surprise
that makes people stop trusting a shortcut. `base` takes no modifiers at
all: it has no seed to lock and nothing to bypass.

Locked frames now get a corner dot -- the chips are ~20px tall so a
padlock glyph would be mud, and the tooltip carries the actual seed. The
dot survives bypass, or unlocking later would look like it did nothing.
Every chip is `position: relative` whether locked or not, so toggling one
cannot reflow the grid.

The combos are printed under the grid rather than left to tooltips. An
undiscoverable shortcut is the same as no shortcut, and that line is
where the reader is already looking.
2026-08-04 08:37:37 +03:00
DumiFlex ffd0a5e709 feat(spa): move what's new to its own page behind a topbar dot
As a dashboard card it held the top of the home view permanently to
deliver something read once, and competed with the hero for the first
thing a returning user sees. A topbar button with an unread dot costs a
few pixels, says "there is something here" just as clearly, and -- unlike
a dismissible card -- is still there next week when someone wants to
check what changed.

That last part fixes a gap the card could not. The update pill renders
only while a NEWER release exists, so once you were current there was no
way back to the notes for the version you are actually running. This page
is now the permanent home for them, which is what makes the unread dot
safe to clear on sight: nothing is lost by not reading it today.

The page shows the full notes, including the "Also in this release" tail
the update dialog cuts. The dialog cuts it because it is an interruption
and should stay short; a page the reader chose to open is not, and
withholding half the notes there would just send them to GitHub. The
per-commit dump stays stripped in both -- that is for people reading git.

Icon is a megaphone, with the dot moved to the BOTTOM-right rather than
the icon changed to suit the dot. Top-right is the conventional corner
but it is also where the horn is, so the dot merged into the glyph and
stopped reading as a marker; bottom-right is the one corner the shape
leaves empty. Tried and rejected: `pi-gift` (says reward), `pi-bell`
(promises actionable alerts this does not deliver) and `pi-sparkles`
(the wildcard kind icon everywhere else).

Also restores mocks between AppTopbar tests. The update-pill test spies
on `useReleaseCheck` and pins `current` to a fixed version; module spies
outlive the test that made them, so every later test in the file was
silently running against that stub. The new dot tests failed on it while
passing in isolation, which is the visible symptom of a hazard that was
already there.

Verified in the running SPA: 13 checks covering the dot lighting on a
version change, clearing on visit and staying clear across a reload, the
page rendering the tail the dialog omits, and the no-notes-cached state.
2026-08-04 08:29:27 +03:00
DumiFlex 845e03c877 fix(spa): stop burning the GitHub quota, and make the cards fit
Three defects, found by opening the page instead of reading the diff.

RATE LIMITING. The launch check was spending far more of the anonymous
60/hr-per-IP allowance than "once per session" implied, in two
independent ways.

The `sessionFetched` guard is a module-level binding, so it dies with the
page: the real cadence was one request per RELOAD, and a day of working
on the SPA is easily sixty. Freshness is now judged from the persisted
`checked_at` the cache was already writing and never reading, with a
6-hour TTL. `checkNow()` still ignores it, because the user asked.

Worse, the guard was set only once a response LANDED. Every consumer
mounting in the same tick -- topbar pill, Dashboard, Settings -- read it
as false and opened its own request, so a single load cost three units,
not one. It is now set on attempt, and concurrent callers share one
in-flight promise.

When GitHub does refuse us, the reset it hands back in X-RateLimit-Reset
is honoured rather than retried against, and persisted, so the reload
that follows a 403 does not immediately spend another request. The
previously cached release survives a refusal -- forgetting the version we
already knew because the quota ran out would be a second failure on top
of the first.

WHAT'S NEW WAS STRUCTURALLY DEAD. `hasUnseenRelease` needs a previously
recorded version, and the only writer was the card's own dismiss button,
which never renders until the card shows. A closed loop with no entry
point -- it could not have fired for anyone, ever. The running version is
now recorded on first sight, so the NEXT upgrade has a baseline. Tests
that seeded `seenVersion` by hand never exercised the path the app takes,
which is why they passed against a feature that could not run.

STYLING. Both cards styled themselves from scratch in `--wp-node` -- the
CANVAS node palette, teal -- which is the graph's colour language, not the
manager's. They now use the same Card/Button/Icon primitives and accent
purple as everything around them, and the what's-new list gets an
explicit `list-style` because the app reset clears it globally and the
entries were running together unmarked.

The star prompt also moves to the BOTTOM of the dashboard. Above the hero
it was the first thing on the page, louder than the product's own
welcome -- the layout of an ad rather than of an aside. The eligibility
rule already selects a returning user; it does not need the best seat.

Verified in the running SPA, not just in jsdom: 15 checks covering both
dismiss buttons persisting across reload, the snooze expiring, the
what's-new dismiss, four reloads making zero GitHub calls, and a stale
cache making exactly one.
2026-08-04 08:11:39 +03:00
DumiFlex de361d3fab feat(spa): a what's-new card, and one quiet request for a star
Two Dashboard cards, both inline and dismissible. Neither blocks
anything and neither is a modal — the extension already raises an update
dialog, and a second one on the next SPA open is the same news twice.

WHAT'S NEW reuses the release body the update check already fetches, cut
at the modal marker, so it shows the same highlights-only slice for the
same reason. Silent on a fresh install: someone who has never recorded a
version has no "new" to hear about. It also only renders when the fetched
release matches the INSTALLED version — while an update is pending the
body describes changes the user does not have yet, and presenting that
as "what's new in your version" would be a lie.

THE STAR PROMPT is a favour, so it behaves like one. The obvious
triggers were all first-touch — "created N modules", "ran the starter",
"first test run" — and every one fires on someone still poking at the
thing: ten modules can be ten empty rows, and the starter bundle is by
definition the moment before you understand anything. Those measure
curiosity, not value.

So it needs two signals, both: five distinct calendar days with the
manager open (not sessions -- a reload is not a return; not consecutive
-- life happens), AND a library carrying real content. Either alone is a
false positive.

Existing users are credited on first run: without it, tracking starts at
zero for everyone and a three-month user waits another five days, which
is backwards. The credit is deliberately short of the threshold, so they
still have to come back -- being asked the instant you update reads as a
reward for updating.

"Maybe later" hides it for a fortnight then lets it return, because a
button that visibly does nothing is a broken button and "later" is not
"no". "Don't ask again" is permanent and survives upgrades. The star
count is not shown: social proof cuts both ways, and at a low number it
argues against starring.

25 tests on the eligibility rule, which is the whole feature -- fire
early and it is adware, never fire and it is dead code.
2026-08-04 07:49:52 +03:00
DumiFlex 548b63edc9 feat(spa): tell the update dialog how much it is not showing
The dialog cuts at the modal marker, so it shows the headline and
highlights and then simply stops — a five-change release and an
eighty-change one look identical from inside it, which is the opposite
of what the cut was for.

`tailChangeCount` counts the bullets below the marker and the dialog
renders "+ N smaller changes" beneath the notes. Zero for releases
published before the marker existed, so nothing is claimed about them.

Verified against the real next.md: 20 counted, matching the 20 written
by hand in the <details> summary on the GitHub side. The template now
notes that the GitHub number is manual and the dialog's is computed, so
if they ever disagree the hand-written one is the wrong one.
2026-08-04 07:27:18 +03:00
DumiFlex 51286b3273 fix(docs): spread the figure wash across the panel, not the corner
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.
2026-08-04 07:23:11 +03:00
DumiFlex 157606b082 fix(docs): restore the figures' depth, and draw the loop_config link
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.
2026-08-04 07:21:28 +03:00
DumiFlex 2f40c921eb fix(docs): colour the flow-figure icons instead of leaving them black
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.
2026-08-04 07:17:28 +03:00
DumiFlex 4b69068ace docs: rebuild the README flow figures from the SPA's design system
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.
2026-08-04 07:09:56 +03:00
DumiFlex 0e1b36f6e4 build(release): rewrite the release-notes template
The old body was a flat curated list followed by the per-commit
changelog inside a <details>. Two problems, both visible on 2.11.0.

The commit list restated the curated notes. Hidden types were already
filtered out, so what remained WAS the user-facing changes -- the same
content at lower quality, growing linearly with the release. Dropped in
favour of a compare link, which is one line and strictly more useful:
diffable, searchable, and the real archive is git anyway.

The curated list had no hierarchy, so "long values wrap in a text box"
sat at the same weight as a correctness fix, and length scaled with the
release. It is now:

  Headline
  ### Highlights            4-5, capped
  <!-- /modal -->
  ### Also in this release  grouped one-liners
  ### Upgrade notes         only when the reader must act

The cap is the mechanism -- it is what keeps a 6-commit release and an
80-commit release roughly the same height. next.md carries the
selection rules as comments so the next writer does not have to
re-derive them.

The modal now stops at the marker. It has a 320px scroll box and one
job -- answer "is this worth clicking Update now" -- and was rendering
the entire body into it, slicing a paragraph mid-sentence. Bodies with
no marker still render whole, so every published release is unaffected.

Also replaces the stale next.md, which still held the already-published
2.11.0 text and would have shipped again verbatim. It now describes the
81 unreleased changes on this branch: 5 highlights, 20 grouped
one-liners, 291 words to the modal against 686 for the page.
2026-08-04 06:18:28 +03:00
DumiFlex dafb7d879c fix(cleaner): stop rewriting embedding and LoRA references (P3 #31)
The cleaner treats a prompt as prose or as a comma-separated tag list.
That is right for tags and wrong for the few tokens that are ADDRESSES
rather than words: `embedding:name` and `<lora:name:1.0>` resolve to a
file, so one edited character changes what the sampler loads, or loads
nothing.

Two rules were provably destructive:

  embedding:bad_prompt_                        -> embedding:bad_prompt
  embedding:badhandv4, embedding:badhandv5     -> embedding:badhandv4

`_` and `-` are in the stripped punctuation set, and `bad_prompt_` is a
real published shape. Versioned names sit far above the hardcoded 0.9
fuzzy threshold while naming different files. Both failures are silent:
ComfyUI resolves a missing embedding to nothing, and the dropped tag
only shows up in a generic "N removed" count.

engine/cleaner/atoms.py marks both forms -- LoRA included, since it has
the identical failure mode and excluding it would have been an
arbitrary line. Punctuation stripping skips atoms; fuzzy dedupe falls
back to exact comparison when either side is one.

This widens the cleaner's remit from "text" to "knows a little prompt
syntax", which the findings named as the cost. The alternative was a
rule that renames a file reference without saying so.

Narrow, and the narrowness is tested: ordinary tags still get trimmed,
ordinary near-duplicates still merge, exact repeats of an embedding
still collapse, and text mode still trims a leading edge -- it only
holds back the trailing trim when the prompt ENDS in an atom.
2026-08-04 05:59:08 +03:00
DumiFlex 6332ed1e4f perf(graph): memoise the widget parse the poll walkers share (P3 #29)
reactiveFromGraph installs a 400ms poll per call site, and a single
WP_Context node installs TEN of them. Every walk re-read the node's
wp_modules widget and re-parsed it from scratch -- on a real workflow
that string is ~294 KB, so the parse cost MORE than the traversal it
fed (JSON.parse 4.64 ms/call against 4.02 ms for the whole of
collectUpstreamProducers). Ten pollers parsed the identical string ten
times per cycle; five nodes made it ~21% of a core.

Cache keyed on the raw string, so it self-invalidates when the widget
is edited, and bounded at 24 entries because every keystroke in an
editor mints a new 294 KB key.

The findings flagged the hazard: callers now share one object instead
of each getting a private copy. That is why this lives in graph.ts as a
module-private helper rather than inside parseWidgetJson, whose other
callers (ContextWidget) legitimately mutate what they get back. The
read-only contract is enforced, not assumed -- graph.parse-cache.test.ts
puts the cache in freeze mode and runs every walker, so a write throws
instead of silently corrupting the next reader.

Measured on the same 5 x 18 x 133 graph as before:

  collectUpstreamProducers      3.67 ms -> 0.06 ms
  collectUpstreamVariables      9.39 ms -> 1.99 ms
  collectUpstreamWildcardUuids  3.69 ms -> 0.01 ms
  per cycle, 5 nodes           83.79 ms -> 10.33 ms
                               ~21% core -> ~2.6%

Collapsing the ten polls per node into one shared walk stays
un-attempted; this may make it unnecessary.
2026-08-04 05:55:04 +03:00
DumiFlex d9e4a7c262 build: scope the pre-commit gate to what changed
Every commit ran the whole CI suite -- lint, typecheck, the full 4000-test
vitest run, a build, the size gate, ruff and the full pytest suite --
regardless of what the commit touched. Measured on this machine that is
about 4.5 minutes, and since the habit was to run the same gates by hand
first, the real cost per commit was closer to nine.

The hook now looks at the staged paths and runs only the halves that can
be affected, with vitest scoped to the changed sources via `vitest
related` (30s against 150s; it still walks the import graph, so a shared
module pulls in its dependents). WP_FULL=1 forces everything.

This is only safe because CI now runs on every branch push rather than
just main -- a feature branch previously had no remote verification at
all until a PR existed.
2026-08-04 05:48:14 +03:00
DumiFlex 0765df3579 fix(chips): real hover cards on option chips, producers in derivations
Three corrections, all of them to my own earlier attempt.

Option chips: these were hand-rolled spans that only MIRRORED RefChip's
styling, so they were the one surface in the editor with no hover card.
The previous commit made that worse by bolting on plain `title`
tooltips instead of using the component we already have. Now they ARE
RefChips, so they get the same card as every other chip. Two test
expectations move to RefChip's conventions, which is the point of the
change: `@` is prefixed only once a ref resolves, and the filter
expression lives in the card rather than a native title.

Derivations: rule-summary chips said "binds at runtime" for variables
with an obvious producer. RuleValueChips rendered RichTextPreview with
only value/uuid-to-name/surface, and RichTextPreview had no producer
props at all, so every var chip below it fell back. Producers now flow
RulesSection -> RuleValueChips -> RichTextPreview -> RefChip, and the
same chain is wired for wildcard option chips
(ModuleEditModal -> WildcardInstanceModal -> PoolSection -> OptionRow).
Combine already worked, which is why the symptom looked
derivation-specific.

Type-to-filter hint: shipped behind a six-option threshold, which hid
it on exactly the menus being checked -- a three-option dropdown showed
nothing. Threshold is now two, so it appears wherever there is anything
to filter.
2026-08-04 05:35:56 +03:00
DumiFlex 2b00a5970c feat(ui): hover text for option chips, and telegraph type-to-filter
Two discoverability fixes.

Chips inside a wildcard option had no hover text in the ordinary case.
A nested-ref chip only carried a `title` when it was unresolved or
filtered, and `$var` chips carried none at all -- so a resolved ref,
which is the common case, gave no way to see which library entry it
points at. The chip label is abbreviated to `@name` with no uuid, so
there was nothing else to go on either. Ref chips now always describe
themselves (name, uuid, and any filter), and var chips say what they
are.

Every Select menu has always filtered as you type, but the only sign of
it was the "Filtering:" line, which appears AFTER the user has already
guessed the feature exists -- as a user put it in Discord, "perhaps it
should be telegraphed better". The menu now says "Type to filter" up
front, on lists of six or more where filtering is worth reaching for,
and gives way to the live filter line on the first keystroke. Dropdowns
that already carry their own search field are separate components and
are untouched.
2026-08-04 05:02:57 +03:00
DumiFlex 22b6597ec7 Revert "fix(nodes2): re-baseline every widget when the renderer flips live"
This reverts commit c421675102.
2026-08-04 04:51:03 +03:00
DumiFlex c421675102 fix(nodes2): re-baseline every widget when the renderer flips live
The Nodes 2.0 toggle does not reload the page, so every widget alive at
that moment was built against the other renderer. The damaging piece of
staleness is the autosize bookkeeping: pushSize compares the node's
current height against the last height WE set and treats a mismatch as
"the user dragged this, leave it alone". A renderer flip changes node
height without going through us, so from that instant every widget
decides the user owns its height and stops auto-collapsing.

That is the same trigger behind the Debug node's jump, and it explains
why WP_VarToInt/Float stay tall after their dropdown closes but only
following a LIVE switch -- after a page refresh they are fine.

One watcher, driven by the DOM churn the flip itself causes, tells every
widget to forget its baseline and re-fit. It is not specific to the two
nodes reported: any widget that autosizes was exposed.

Not reproduced end-to-end: the var picker only grows enough to show the
bug when it has upstream variables to list, and an unwired node's menu
is empty. The mechanism is covered by unit tests instead -- a flip
triggers a re-fit, a non-flip does not -- and the browser checks confirm
no regression in either renderer.
2026-08-04 03:42:19 +03:00
DumiFlex 021b411e3c fix(ui): platform-correct save chord, and a legible bundle summary
Two small ones.

The modal footers rendered a hardcoded `⌘↵`, which reads as a Mac-only
binding to the overwhelming majority of ComfyUI users. The handler has
always been `(ev.ctrlKey || ev.metaKey) && Enter`, so only the LABEL was
wrong -- it now says `Ctrl+↵` off Apple platforms. Detection prefers the
UA-CH platform hint and falls back to the user-agent string, avoiding
the deprecated navigator.platform.

The bundle header's child-count summary ("9 mods") was 10px in
--wp-text-dim, which resolves to #666 on the dark theme and sat right at
the edge of legibility against the bundle frame tint -- the one thing
telling you how big a collapsed bundle is. Now the muted token at 11px
with a faint pill, so it reads as a value rather than a watermark.
2026-08-04 03:30:16 +03:00
DumiFlex 312e1575ae fix(modals): shield keys that never traverse the modal
The root-level shield only sees keys TARGETED inside the overlay. Click
the button that opens a modal and focus stays on that button, which is
outside the overlay, so keystrokes go straight from it to window and the
shield never runs. Measured on the Blocklist modal: pressing a, a, r
with the modal open put ["a","a","r"] on window.

Two additions:

- focus the overlay when it opens, unless something inside already has
  focus (an autofocused field must keep it);
- guard at the document for keys targeted outside the open modal, since
  a modal owns the keyboard while it is up. Document bubble runs before
  window, where ComfyUI's keybindHandler lives.

Escape still passes through, so close handlers keep working.

Verified in a browser, Blocklist modal open:

  before   focus BUTTON (the trigger), keys to window ["a","a","r"]
  after    focus moves into the overlay, keys to window []
  typing   "abc" still lands in the textarea, nothing leaks
2026-08-04 03:14:34 +03:00
DumiFlex 9e8cd23fe5 fix(modals): stop keystrokes escaping to ComfyUI's global shortcuts
Pressing `r` inside an edit modal ran ComfyUI's Refresh Node Definitions
and froze the page.

ComfyUI binds `useEventListener(window, 'keydown', keybindHandler)`, and
the handler only bails for text-editing targets:

  const target = event.composedPath()[0]
  if (keyCombo.isReservedByTextInput &&
      (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT' ||
       target.contentEditable === 'true' || ...)) return

A plain letter pressed while focus sits on a button or a div inside a
modal is not reserved by text input, so it runs the global keybinding.
Its other guard -- skip global keybindings while a dialog is open -- keys
off ComfyUI's OWN dialog stack, which our modals never enter.

useModalKeyShield binds keydown/keyup to the modal ROOT in bubble phase.
Everything inside the modal has already had its turn by then, so inner
handlers, input defaults and IME are untouched; the event simply stops
before it can climb to window. stopPropagation only -- never
preventDefault -- so typing still works.

The corollary is that a modal's own key handling can no longer live on
window: a window listener sits ABOVE the shield and would never see keys
pressed inside its own modal. ModalShell now re-emits them, and the two
consumers that had window listeners are moved onto it:

- ModuleEditModal (Ctrl+Enter to save)
- BundlePickerModal (arrow/Enter/"/" navigation)

ModalShell keeps a window listener for Escape only, which covers the
case where focus is OUTSIDE an open modal.

ModulePickerModal already used a capture-phase window listener, which
runs before the shield, so it is unaffected.

Verified in a browser: keys pressed inside a ModalShell modal no longer
reach window, and Escape still closes it.
2026-08-04 02:52:03 +03:00
DumiFlex a769c0e496 fix(nodes2): survive a live renderer toggle with content on screen
Switching the renderer from the menu does not reload the page, so a
widget built under legacy never ran any construction-time isVueNodes()
branch, and the fill observer's bounded retry (~10s of frames, looking
for a node element that does not exist yet in legacy) had long since
given up. Toggling with content on screen therefore left the layout
unapplied: measured, the node jumped from 380 to 1200 and could no
longer be resized. Reproducing it needed a wait past the retry budget,
which is why the earlier reload-based test showed it passing.

Two changes:

- Apply the out-of-flow layout at construction, in BOTH renderers, and
  stop stripping it in the legacy branch. The push needs only one frame,
  so there must be no window at all. Legacy is indifferent -- it gives
  the host a definite box either way, and its numbers are unchanged
  (host 354u, scroller engaged, 500x240 floor, wheel scrolls 0 -> 300).
- Replace the bounded retry with a registry driven by one document-level
  observer, so a node element that appears minutes later is still
  picked up.

Verified: toggling live after the old budget would have expired now
keeps size at 380, the scroller engaged, and manual resize working.
2026-08-04 02:27:35 +03:00
DumiFlex 99125a63d7 fix(nodes2): fill the node instead of pinning it, and keep the wheel scrolling
Two follow-ups to 27685706, which fixed the Debug node inflating but by
pinning the host to a pixel height computed from node.size.

1. The pin was the wrong lever. The node element's height is
   authoritative, so the correction term sat at roughly zero: the host
   never grew with the node and drifted a couple of pixels smaller on
   every resize. The box stopped tracking the node and shrank each time
   it was dragged.

   The real cause is narrower than "the percentage height does not
   resolve". Every ancestor between us and the node DOES have a definite
   height tracking node.size -- measured 238 / 638 / 838 as the node
   went 330 / 730 / 930. What breaks is that we are a flex item, and a
   flex item defaults to min-height: auto, which refuses to shrink below
   its content. The payload grew our box, which pushed the chain, which
   the layout store wrote back into node.size.

   Three parts, each needed:
   - min-height on the host so it can shrink below content. Fixed, not
     content-derived: the host clamps a resize with
     measureMinContentHeight(), which MEASURES our content, and with the
     content out of flow that measured zero and took the floor with it
     (dragged to 62u where legacy stops at 240).
   - content out of flow. Our own min-height is not sufficient because
     EVERY ancestor is also min-height: auto, so the push propagates
     past us -- measured, the node still inflated 300 -> 870.
   - both applied at widget construction, not on a deferred sync: Vue
     paints content before any observer of ours runs, and one frame of
     in-flow content is enough for the store to record an inflated size
     that then sticks across a renderer switch.

2. The wheel zoomed the canvas instead of scrolling our widgets. The
   canvas yields a wheel only to a widget that declares
   data-capture-wheel="true" AND currently holds focus; a read-only
   viewer never qualifies. A bubble-phase stopPropagation on our own
   element does not reach that decision either. Measured over the Debug
   scroller:

     legacy      scrollTop 0 -> 300   scale 0.900 -> 0.900
     Nodes 2.0   scrollTop 0 ->   0   scale 0.900 -> 0.818

   Absorb it at window capture, which runs before every other listener,
   using stopPropagation alone so the browser still performs the native
   scroll. Ctrl+wheel is left to the canvas: that is pinch zoom. Applied
   to every widget host, not just the Debug viewer.

Verified in a real browser, both renderers:

  host tracks the node   238 / 638 / 838 for node 330 / 730 / 930
  payload does not push  size[1] holds at 400 for 3, 20 and 60 vars
  scroller engages       pre 289/1089
  wheel                  scrollTop 0 -> 300, scale unchanged
  renderer switch        size stays 380, no jump to content height
  floors                 Nodes 2.0 390x262, legacy 500x240

Legacy numbers are identical to before the change.
2026-08-03 23:29:14 +03:00
DumiFlex 2768570660 fix(nodes2): stop the Debug node inflating to fit its payload
fillHost means the user owns the node's height and the widget scrolls
whatever overflows. That rests on `height: 100%` resolving -- and a
percentage height against an INDEFINITE container computes to `auto`.
Legacy gives the host a definite box so it resolves. Nodes 2.0 lays
nodes out from content, so it does not: the flex column sizes to
content, .wp-dbg-pre (flex: 1 1 0%; min-height: 0) is handed its full
content height instead of being compressed, overflow: auto never
engages, and the node inflates to the whole payload -- which the layout
store then syncs back into node.size.

Measured with node.size[1] pinned at 400:

  payload    legacy host / pre          Nodes 2.0 host / pre
   3 vars    354u  305/305              338u  289/289
  20 vars    354u  305/429  scrolls     477u  429/429  never scrolls
  60 vars    354u  305/1089 scrolls    1138u 1089/1089 never scrolls

One mechanism, three reported symptoms: the dead scrollbar, the node
that cannot be dragged shorter, and the absurdly tall node.

Pinning a pixel height alone is NOT enough. The Nodes 2.0 parent is
.flex.flex-col.*:flex-1, and that `*:flex-1` hits every child including
us; with flex-basis 0% and flex-grow 1 the main-axis size comes from
flexing and an explicit height is ignored. Measured: host pinned to
308px still rendered 1024px. So opt out of flexing too.

Height comes from node.size[1], which is authoritative in both
renderers, via an error-correcting step rather than a modelled chrome
constant -- compared against size[1] + header, because the host's own
sync notes that layout size excludes the title.

Width: legacy gets its floor free from computeSize, which Nodes 2.0
never calls, so the node could be squeezed to MIN_NODE_WIDTH (225)
against a widget declaring 494. Its resize interaction reads the floor
from the node element's inline min-width, so publish ours there --
writing node.size[0] does not work, the layout store owns the rendered
size and syncs one-way.

After, Nodes 2.0 matches legacy: size[1] holds at 400 for every payload,
the pre scrolls (283/1089), and dragging shrinks to a 390x348 floor
instead of 225.

Only WP_Debug sets fillHost, so nothing else is affected.
2026-08-03 22:39:44 +03:00
DumiFlex 12d6f99104 fix(nodes2): keep the wire merge applied, and stop doubling the pin label
Four defects in the Vue-renderer wire merge, all from the same place:
the merge is DOM state, and it was bound to one element at one instant.

1. Merged rows printed their labels too. They are zero-height and
   stacked on the kept row, so "input_1"/"input_2"/"input_3" piled on
   top of the unified "inputs xN" and rendered as a smear. They keep the
   label override -- it has to be ready the moment they expand -- so
   suppress only their generated text.

2. No clearance under the merged pin. Zeroing the merged rows reclaimed
   all their vertical space, leaving the incoming wires running into the
   top edge of the widget body. Reserve some back on the last merged
   row: they share a y, so only its bottom margin moves what follows.

3. Switching renderers while collapsed came back expanded. At attach
   time the node's element usually does not exist yet -- the workflow is
   restored before Vue mounts nodes -- so the one-shot lookup bailed and
   nothing was left watching.

4. Focusing a collapsed node expanded its wires while the button still
   read "collapsed". Vue REPLACES the node element on re-render, so the
   per-element observer was watching a detached tree.

3 and 4 are both fixed by registering nodes and re-applying from a
single observer on a stable root. childList only: our own writes are
class and style attributes, which that never sees, so there is no
feedback loop.

Labels also now publish in BOTH states, not just while collapsed. Vue
renders slot names from its own snapshot and never sees a slot.label
write, which breaks the restore too: a legacy collapse serializes the
placeholders into the workflow, so reloading under Vue showed "inputs
xN" and " " forever, even expanded.

Verified in a real browser. Legacy collapse -> switch renderer -> reload:

  collapsed   4 pins at 1 distinct y, label "inputs x3", 12px clearance
  expanded    4 pins at 4 distinct y, labels input_0..input_3 restored
  focus       stays merged, 1 distinct y
2026-08-03 20:40:50 +03:00
DumiFlex 97a50ff452 fix(nodes2): merge injector wires under the Vue renderer
The wire merge was a canvas paint-time trick: override getInputPos and
every matched pin reports the first pin's position. Nodes 2.0 paints no
canvas for slots. It renders one .lg-slot--input row per node.inputs
entry and feeds those elements' measured rects to the layout store,
which is what the link renderer draws to, so the override moves nothing
there.

Measured with four wires merged: every pin reported the same
getInputPos while the DOM dots stayed 18px apart. Half the mechanism
did work -- mutating slot.label writes to the data model -- which is why
the node read "inputs xN" while the wires stayed fanned out.

The host has this feature internally: NodeSlots.vue takes a `unified`
prop that grid-stacks every dot into one cell. It is wired only to
whole-node collapse, with no per-node opt-in, so reproduce the effect on
the rows we own. Zero the merged rows' height so they share one centre
line, and pull the first up half a row to land on the kept pin. Height
rather than display: useSlotElementTracking discards any slot measuring
zero area, so hiding a row would drop its link endpoint instead of
moving it.

The label needs the same treatment for the same reason -- Vue renders
slot names from its own VueNodeData snapshot and never sees the
slot.label write (measured: model "inputs x4", rendered "input_0") --
so the collapsed label is published as a custom property and printed
from CSS.

Verified in a real browser, both renderers. Nodes 2.0, four wires:

  expanded   5 dynamic pins at 5 distinct y  [270,288,306,324,342]
  collapsed  5 dynamic pins at 1 distinct y  [270]   upstream stays 252
  expanded   5 dynamic pins at 5 distinct y  (round trip restores)

Also guard CSS.escape, which is absent in some environments.
2026-08-03 20:16:44 +03:00
DumiFlex b712394648 fix(nodes2): anchor the focus flash to the node's real box
The focus-flash border mapped graph coords to screen itself
(canvasRect + (pos + ds.offset) * ds.scale, plus a TITLE_PAD constant).
That derives the box from node.size, and under ComfyUI's Nodes 2.0
renderer node.size is a hint rather than a fact: the node is laid out
from its DOM content and never clamped back. A node declaring 400 wide
measured 490 graph units, so at 0.9 zoom the border came out 81px too
narrow and cut through the node.

Measure instead of recompute. Nodes 2.0 gives each node a
[data-node-id] element whose rect is the box, title bar included, which
also retires the TITLE_PAD estimate. The legacy renderer paints nodes on
the canvas and has no such element, so it keeps the transform maths --
which is correct there: both renderers agreed on position to the pixel,
only the width was wrong.

Verified through the real path (Assembler with an unresolved $var ->
warning toast -> Focus click) in both renderers:

  Nodes 2.0  overlay 584,320 432x291  node 584,320 432x291  (exact)
  legacy     overlay 584,325 432x247  (transform-maths fallback)
2026-08-03 18:53:29 +03:00
DumiFlex 72d9a17f89 test(p3): measure the poll cost and pin the embedding round-trip
Two reproducible probes backing the P3 investigations. Neither changes
behaviour; both exist so the findings can be re-run rather than re-argued.

`src/extension/__perf__/poll-cost.test.ts` times the graph walkers on a graph
shaped like the reported workflow — 5 Context nodes, 18 modules each, 133
options. It measures rather than asserts: the thresholds are loose so a busy
machine cannot turn it red, and the console output is the deliverable.

It shows the 400ms interval is not the expensive part. A Context node installs
TEN `reactiveFromGraph` polls, each re-reading the node's `wp_modules` widget
through `parseWidgetJson`, which is a bare `JSON.parse` with no memoisation.
That JSON is 294 KB per node, and parsing it costs slightly MORE than the
traversal it feeds — so the same string is parsed ten times per node per cycle
for nothing. Three of the ten polls across five nodes come to 84 ms every
400 ms, about a fifth of a core.

`tests/engine/cleaner/test_embedding_roundtrip.py` pins what the Prompt Cleaner
does to `embedding:` syntax. The engine itself never touches it — one unrelated
comment is the only hit in `engine/` — so the cleaner is the whole hazard, and
two of its rules damage embeddings:

- Punctuation stripping trims `_` and `-` from a tag's trailing edge, so
  `embedding:bad_prompt_` becomes `embedding:bad_prompt`. ComfyUI resolves a
  missing embedding to nothing, so the prompt quietly stops working.
- Fuzzy dedupe at its hardcoded 0.9 threshold treats `badhandv4` and
  `badhandv5` as the same tag and drops one. Different files, different
  training, no warning beyond the generic removed-count.

Those two tests assert the CURRENT behaviour and say so in their names, because
whether to teach the cleaner about prompt syntax is a design decision — and the
same argument would apply to LoRA tags, which have the same shape.

Written up in full, with the Nodes 2.0 and capability-survey findings, at
docs/investigations/p3-findings.md (untracked, per the docs allowlist).
2026-08-03 15:52:33 +03:00
DumiFlex 01de4f0f10 fix(tag-menu): bound the menu by the room it has, capped to a readable height
The previous attempt capped the menu's own height at `100vh - 24px`, which
cannot work: whether a menu fits depends entirely on where its top edge landed.
A 400px menu opened at y=700 overflows however small that cap is, because the
cap never knew about the 700. The height is now measured from the trigger to
the edge of the window.

That alone made it fill the whole page on a tall window, which is worse than
the overflow it fixed — one menu burying the page behind it, and a dozen tags
spread over 800px are harder to scan, not easier. So the viewport is the LIMIT
and 340px is the target: roughly a dozen rows plus the search box, shrinking
only when there is less room than that, with a 140px floor below which it
scrolls instead.

The filter menu also flips above its trigger when the room below is genuinely
worse, which the per-option picker already did. In practice the filter never
flips — its button lives in a sticky header near the top — but the row-level
picker does, and both now stay inside the window.

Measured at 1000 / 760 / 620 / 500px viewport heights, on both hosts: 340px
where there is room, 294px at 500px tall, and the row picker flipping to
12–312px at 620px tall. The inner list carries no cap of its own any more — it
is the part that gives, and a fixed 240px there fought the menu's own bound and
won.
2026-08-03 15:30:32 +03:00
DumiFlex 7f4b7b93f1 fix(editors): seven from testing — menu colour, dismissal, column order, blank rows
**The tag menu lost its axis colours** when the picker became a shared
component: I rewrote its stylesheet instead of porting it, and the rules
consuming `--chip-hue` went with it. The hue is what makes the menu read as the
same colour clusters as the pills and chips everywhere else; a flat grey list
loses the only cue saying which axis a tag belongs to. Ported properly this
time.

**It was cut off at the bottom** whenever the list behind it was short — an
empty filter result leaves almost no page to sit on. Capped to the viewport,
with `min-height: 0` on the inner list so it shrinks and scrolls instead of
pushing the menu past the edge.

**It closed the moment the filter emptied the list.** Any layout change fired a
scroll, and the handler that keeps the teleported menu glued to its trigger
treated that as a reason to dismiss. It re-measures now, and only closes once
the trigger has actually left the viewport.

**Select's menu could not be scrolled by its scrollbar** — reported on the test
runner, present on every Select in the app. The menu is teleported to <body>,
so it is not inside the trigger's wrapper, and the outside-click guard checked
only that wrapper: a mousedown on the menu's own scrollbar counted as an
outside click and dismissed it. The wheel worked, which is why it read as "the
scrollbar is dead" rather than "the menu closes". The guard now excludes the
menu, as its comment always claimed it did.

Audited the rest, since this class of bug is easy to repeat: every other
teleported surface with an outside-click dismissal — IconPicker, ContextMenu,
the sub-category picker — already excludes its own panel. Select was the only
one that did not.

**Fixed Values had the grip before the checkbox** in the body and after it in
the header, so the two disagreed. Body now matches.

**Select-all skipped the null option.** `selectableOptions` still filtered it
out from when it could not be selected at all; selection drives moves, weight
and delete, all of which it takes part in. Tag actions still skip it — the
engine refuses tags there.

**Bulk add appended past the starter blanks.** A new wildcard or fixed-values
module opens with empty rows so the table is not a void; a first bulk add left
them sitting above everything just added, and they fail validation on save (an
option's value must be a non-empty string). An untouched blank is scaffolding
and is consumed by the incoming batch. One the user tagged or re-weighted is
NOT untouched — they were working on it — so only a fully default row
qualifies.
2026-08-03 15:15:22 +03:00
DumiFlex a3a0e3f95b fix(wildcard): widen the filter, stop the tag menu closing on its own scroll, keep tags off null
Three from testing the merged toolbar.

**The search box was a quarter of the width it should be.** Two nested flex
competitions, both lost the same way. Card's header puts a `.wp-spacer`
(`flex: 1`) between the title and the actions slot, and the filter block had
`flex: 1` too, so the two split the free space evenly; a far larger grow factor
gives the block effectively all of it. Inside the block a second `.wp-spacer`
was right-aligning the count and taking half of what was left — it is gone, and
the count now sits beside the tag button it describes. Measured 267px → 523px
of an 1122px header.

**The tag menu closed the moment you scrolled it.** The scroll listener that
keeps the teleported menu glued to its trigger fired for scrolls INSIDE the
menu too, so reaching for the wheel or the scrollbar dismissed it before you
could read past the first axis. Scrolls originating within a teleported picker
are now ignored: that is the user reading its own list. A scroll of the page
underneath still dismisses it, since the trigger has moved.

**Bulk edit was giving the null option tags.** It has no chips of its own — the
row renders a dash — so the tags went in invisibly, and the engine would have
refused the save: "null option must have no sub_categories"
(wildcard_handler.py). Its participation is governed solely by `exclude_null`,
so a tag on it could not affect anything even if it were allowed. Apply and
Remove now skip it. Set weight and Delete still include it, because it has a
weight and can be deleted — and it stays selectable, since selection also
drives the moves it took part in as of the previous commit.

So: no, null should not carry tags. The engine has always said so; the UI was
quietly disagreeing.
2026-08-03 14:50:34 +03:00
DumiFlex 890d868e16 feat(editors): one tag menu, one toolbar row, and Fixed Values gets the same treatment
Five follow-ups from testing the options filter.

**The tag menu is now one component.** The filter had grown its own dropdown
while the per-option picker had another — same job, two implementations, and
they would have drifted within a release. `TagPickerMenu.vue` is the picker,
extracted: teleported past the `overflow: auto` scrollers that clipped it,
search pinned above a scrolling list, axis headings, checkboxes. Both callers
use it.

**It closes when you click away.** The filter menu was teleported to <body> but
never registered with `closeAllMenus`, so it stayed open until its own button
was clicked again. It joins the outside-click and Escape dismissal the
per-option picker already had, and closes on scroll rather than hanging in
space once its trigger has moved.

**One row, not two.** Bulk edit / Add null / Add option sat on one line and the
filter on another, which made the header taller than the first option in the
list. They all act on the same table; they belong together. The filter takes
the flexible width, the buttons keep their intrinsic size.

**The null option is a row like any other.** It could not be selected in bulk
edit and could not be reordered, because `hoistNullFirst` re-pinned it to index
0 on every save and any move would have silently undone itself. That pin was
never needed: the engine locates the null option by the `is_null` FLAG and
never by position — "the flag is the source of truth", wildcard_handler.py, and
`resolve` filters with `[o for o in options if not o.get("is_null")]`. The
function is gone, and the row now has a checkbox and a grip.

**Fixed Values gets the filter and the reorder.** No tag menu — those rows
carry no tags — so the text box searches BOTH halves, the binding and the
content, because you look for either and matching one would send you back to
scrolling half the time. Grip column, Alt+↑/↓, and Top / Bottom on the
selection toolbar, with drag disabled under a filter for the same reason as in
the options list.

The per-option picker's test hooks changed shape with the extraction —
`opt-tag-{id}-toggle-{tag}` rather than `opt-tag-toggle-{id}-{tag}` — since the
component namespaces every hook uniformly off one prefix.
2026-08-03 13:25:42 +03:00
DumiFlex 530714e62b feat(wildcard): filter the options list, and reorder it
A wildcard here carries 133 options. Until now the only way to reach one was
to scroll and read, and the only way to order them was to have added them in
the right sequence.

**Filter.** Text over the value plus a tag multi-select, pinned in the sticky
subheader so it survives the scroll. Text and tags AND together, and multiple
tags AND with each other — adding a second constraint should narrow the result,
and OR would widen it, which reads as the control being broken. The count sits
idle ("133 options") until a filter is on, because "133 of 133" reports a
success nobody asked for. The empty state says all 133 are still there: an
empty table in a list you can also delete rows from is an alarming thing to be
shown.

Rows keep their ORIGINAL index through the filter. Every row action addresses
its option by position — `removeOption(i)`, the `wc-opt-row-${i}` hooks — so
renumbering them under a filter would delete the wrong row.

**Folded tags get promoted.** A row shows four tags and folds the rest behind
`+N`. Filter by a tag sitting in that remainder and the row appeared with no
visible reason for being there, the evidence hidden behind the very pill saying
there was more. Matched tags are now promoted into the visible slots — tags are
a set, so their order carries no meaning and promoting one is free. Auto-
expanding instead would reflow every matching row at once, a lot of movement
for a small clarification. When matches remain folded even so, the pill itself
is tinted and says why.

**Reorder,** three ways, because no single one covers the range:

- A grip in its own column, hover-revealed. The only pointer affordance the
  value cell's contenteditable does not poison — a press there is a text
  selection, and the two gestures are indistinguishable at mousedown.
- Alt+↑/↓ on the focused grip. The only one that stays usable at 133 rows,
  where a drag becomes an auto-scroll fight.
- Bulk edit ▸ Top / Bottom / Move here. Select, then move as a block.

**Drag is disabled while filtered; the bulk moves are not.** Dropping row 3
above row 47 says nothing about the 43 hidden rows between them, so there is no
honest position to compute. The bulk moves have no such problem because their
destination is absolute — "top" means the top of the whole list, "here" means
beside one specific visible row. So search ▸ tick ▸ move is exactly the
workflow that stays well defined under a filter.

Two behaviours worth stating, both asserted:

- A scattered selection collapses into one contiguous block in the relative
  order it already had. There is no other coherent answer, but it surprises the
  first time, so the result is reported rather than discovered.
- The null option never moves and is never a landing point. `hoistNullFirst`
  re-pins it to index 0 on every save, so any move it joined would silently
  undo itself.

Ordering and set logic live in `utils/option-list-ops.ts`, out of the 2.4k-line
editor and directly assertable.
2026-08-02 20:54:43 +03:00
DumiFlex dd1d5d429f feat(editors): guard bulk-add against the page's own Save, keep empty tag groups
Two reported losses of work.

**Bulk add was easy to throw away.** Users reach for the PAGE's Save and Cancel
instead of the panel's own "Add options" / Cancel — the page pair is larger and
in the place muscle memory expects — and the typed batch went with it. Two
guards, and deliberately no new dialog for the common case:

- Save is greyed while the panel is open, so the only Save-shaped control that
  responds is the panel's own commit. The button carries a tooltip saying why;
  a greyed control with no stated reason reads as a broken one, and the reason
  is exactly what the user needs in order to clear it.
- Un-added text now counts as unsaved work, so the route guard that already
  covers Cancel, the back link and every other navigation picks it up for free.
  Only the wording changes: "Discard un-added options?" rather than the generic
  unsaved-changes copy.

Both editors that have a bulk-add panel — wildcard options and fixed values.
Constraint and derivation have bulk EDIT but no bulk add, so nothing to guard.

**Empty tag groups vanished on save.** A "+ Group" box created and not yet
filled was dropped, which reads as the editor deleting work the user did on
purpose. Dropping stays the default, because it is also what clears up boxes
made by accident, but the choice is now theirs: Settings › Editing › Keep empty
tag groups.

Stored as a preference rather than a payload field. An empty axis is already
representable as `{axis: []}`, a shape the engine's validator accepts — the
loop over members simply does not run — so keeping one needs no new field, no
validator, no migrator and no schema bump. What varies per user is the
DECISION; the RESULT is recorded in the payload like any other edit.

Note on the ref declaration: the guard's predicate runs during setup, so
`bulkPending` is declared above it rather than beside the panel state it
belongs with. Putting it in the obvious place threw "Cannot access
'bulkPending' before initialization" on every editor mount.
2026-08-02 19:53:28 +03:00
DumiFlex 1f07d6eb13 fix(chips): let a selection paint over a chip instead of skipping it
`.wp-refchip` carried `user-select: none`, which excludes an element from
selection RENDERING. Ctrl+A, or dragging across a value, highlighted the text
on either side and left the chip looking untouched — so the selection appeared
to stop at the chip even though it never did. Confirmed in Firefox 153:
`Selection.containsNode` reported the chip inside the selection the whole
time, and the selected string always contained its label. Only the paint was
missing, which made a correct selection look like a broken one.

Chips are atomic for EDITING because they are `contenteditable="false"`, not
because of `user-select`, so dropping it costs nothing there. Added an explicit
`::selection` colour as well: a chip carries its own tint, and the default
highlight washed it out.

Re-measured the whole interaction matrix afterwards, since making chips
selectable could plausibly change how edits fall across them — Ctrl+A followed
by Backspace, Delete, Shift+Delete, cut, paste and typing; undo, redo, bold,
italic, underline, Enter, double- and triple-click, Home/End deletes and
Ctrl+Backspace; each with and without a chip in the field. 40 cases, all
intact, no formatting elements injected, no page errors, and clicking a chip
still opens its filter picker.

The rule is asserted against the stylesheet source rather than a computed
style: it lives in a scoped <style> block, which jsdom does not apply, so a
computed-style check would have passed no matter what the rule said.
2026-08-02 19:01:12 +03:00
DumiFlex e5b3edba43 fix(editor): take cut and copy away from the browser
Reported: cutting an option's text and pasting it into another leaves the
field broken, after which Cancel does nothing and sometimes links stop working
too. Reproduced in Firefox 153 and measured.

A Ctrl+A selection is anchored on the HOST element, not inside a text span. Cut
was the one clipboard verb with no handler, so the browser performed the
deletion itself — and that deletion took the `wp-rt__text` spans with it, the
very elements Vue's vnodes point at. Measured directly after the cut: zero atom
spans left, one child node, the value a stray newline. The field still accepted
typing, but nothing chipped and nothing coloured, because there was no atom
span left to hold any of it, and the vdom now referenced detached nodes. That
is the state a later patch throws `insertBefore: Child to insert before is not
a child of this node` from — the DOMException reported separately.

Cut now runs through `applyAtoms` exactly as paste already did, so the
structure stays Vue's.

Copy was quietly lossy in the same way. A ref chip's DOM text is its display
label, so the browser's own copy turned `red @{955bb6fa} tail` into `red  tail`
and the ref vanished on paste — measured, every time. Copy now puts the SOURCE
text on the clipboard, and a chip survives the round trip.

Third, a general repair: any edit made against a host-anchored selection can
delete the atom spans, not merely their text — Ctrl+A then typing a character
did it too. `applyAtoms` already knows how to recover (it swaps the host
element when the DOM has gone stale) but nothing reached it from the typing
path, which deliberately avoids re-applying atoms to keep the caret steady.
`onHostInput` now re-applies when, and only when, the structure is actually
gone.

Measured across the full matrix — Ctrl+A followed by Backspace, Delete, cut,
typing, and paste, each with and without a chip in the field. Before: two of
ten cases left the field with no atom spans and one silently dropped a chip.
After: ten of ten intact, and the chip round-trips.

Not a bug, for the record: Cancel refusing to navigate while there are unsaved
edits is the route guard working — it raises "Discard unsaved changes?" and
waits. It only reads as a dead button when the render loop has already thrown,
which is what the cut corruption above led to.
2026-08-02 18:40:38 +03:00
DumiFlex 4b53aca434 fix(editor): stop the stranded-caret rescue from typing in the wrong place
Two user-reported Firefox bugs, both in the same rescue path. The host parks
the selection on ITSELF rather than inside a `wp-rt__text` span whenever the
caret is at the very start of the field — pressing Home, or clicking before
the first character. Firefox does this most readily, which is why both reports
came from there. `onHostBeforeInput` redirects such a caret into a span before
the browser inserts, and it was redirecting to the wrong place twice over.

**Typing at the start went to the end.** With no chip adjacent, the fallback
picked the LAST span and put the caret at the END of its text. Typing `{` in
front of `skirt` produced `skirt{`. On the host root the offset is a CHILD
INDEX, so it already says where the caret is; the fallback now scans forward
from it for a span and lands at that span's START, falling back to the end of
the preceding span only when there is nothing after.

**Typing over a selection did not replace it.** The rescue ran for ranges as
well as carets and unconditionally collapsed the selection, so the browser
never deleted what was selected — select-all then typing one letter over
`yellow` left `yellowy` instead of `y`. A range is exactly the case the
browser already handles correctly, so the rescue now returns early for one.
This is the mangling behind the `yyellow` in the first report's video.

Verified against Firefox 153.0 — the reported version — driving the real
browser: `skirt{` → `{skirt`, `yellowy` → `y`. Regression tests go through the
DOM Selection API rather than component internals, since the bugs live in the
interaction between the browser's own insertion and our redirect; both were
confirmed to fail against the previous code before being kept.

Not reproduced, and NOT fixed here: the report also mentions Ctrl+A + Backspace
failing to clear the field and a paste doing nothing. Both work in Firefox 153
before and after this change. An early test of mine appeared to show the paste
failing, but that was the synthetic ClipboardEvent delivering no data — a real
Ctrl+C/Ctrl+V round trip replaces the selection correctly. The `yyellow` state
in the video is explained by the selection bug above, so these may well be the
same defect seen from the other side; if it recurs after this lands, it needs a
fresh repro.
2026-08-02 10:42:23 +03:00
DumiFlex 6981314fb5 fix(producers): count writers, not write statements, and carry the count across nodes
`overrides N` was wrong in both directions.

**Inflated.** Several call sites fan out — a derivation writes once per branch,
a fixed_values once per row — because which branch fires is not knowable
statically and every reachable `target_var` has to appear in the var list.
Counting each of those as an override meant a rule with twelve branches all
targeting `$env_fx` reported "overrides 11", when at most ONE branch fires.
Worse, it reported it when nothing upstream wrote `$env_fx` at all: the module
that CREATES a variable was described as overriding eleven writes that never
existed.

`shadowed` now counts distinct PRODUCERS. Identity is passed in by the caller
rather than derived from the producer record, because `moduleId` is the library
uuid — the same module dropped into two Context nodes shares it while being two
genuinely competing writers. Callers key on the node plus the module's
per-instance `_uid`. Injector rows and loop vars have no module and fall back to
the node plus kind, so two rows of one injector binding the same name read as a
single writer; that is the honest summary, since the injector as a whole is what
binds the name.

**Lost.** The sibling producer map added for same-node attribution counted only
siblings and started from zero, and the merge was a plain object spread — which
replaced the upstream entry outright, taking its count with it. With `$outfit`
written by an upstream wildcard, rewritten by an upstream derivation, then
rewritten again by a module in this node, the row showed no badge at all. A
sibling that displaces an upstream writer has overridden that writer plus
everything it had already overridden, and the merge now says so.

Also: `WP_DEBUG_MAPS=1 pnpm build:extension` links the sourcemaps. They are
built either way, but `sourcemap: "hidden"` writes no `sourceMappingURL`
comment, so browsers never load them and every canvas stack trace arrives as
frame after frame of minified Vue runtime naming no component of ours. Off by
default — the comment ships to every install and the maps are large.
2026-08-02 08:36:32 +03:00
DumiFlex b3c4270f6c feat(autocomplete): mark node-supplied pools on the rows, give injector vars an icon
The node-vs-library marker landed on the ref chip and the filter header, but
not on the suggestion rows — which is the one place it is actually load-bearing.
By the time the chip carries it you have already chosen; the point of the mark
is to tell you WHICH of two same-named wildcards you are about to reference,
and whether it will resolve against this node's snapshot or the library row.
`@` rows carry it now, styled exactly like the filter header's so the fact
reads the same in all three places.

Injected `$var`s had no icon. `collectUpstreamProducers` reports WP_ContextInjector
rows as `kind: "injector"`, and `KIND_ICON_MAP` had no entry for it, so every
one of them fell through to the generic `pi-circle` and rendered as an empty
ring next to properly-marked siblings. Added `pi-sign-in` — the arrow-into-a-
door mark, matching "inject a value into the context". `--wp-kind-injector`
already existed in the canvas palette, so the tint was there waiting for a
glyph to put it on.

Two guards on the icon map itself, which had none:

- Every entry must name a glyph the installed PrimeIcons actually ships. A
  missing glyph renders an empty ring, which reads as "this kind has no icon"
  rather than as a mistake, so nobody reports it — exactly how `brush` shipped
  broken in the icon picker. The stylesheet is the authority, parsed rather
  than mirrored in a list that would go stale on the next upgrade.
- Every kind a canvas producer can emit must resolve to something other than
  the fallback circle. That is the assertion that would have caught `injector`.
2026-08-02 08:16:20 +03:00
DumiFlex 54b525cfa0 feat(canvas): attribute $vars written in the same node, and mark node pools
Three things the canvas could not say that the SPA could.

**$var suggestions were bare.** `collectUpstreamProducers` walks the node
chain and deliberately excludes the point-of-view node, so it can only
attribute vars produced further upstream. In the normal shape — one Context
node carrying most of the modules — nearly every name in the dropdown had no
writer at all, and the rows rendered as a bare list while the SPA showed a
full attribution line for the same names. It looked like the canvas was not
walking the graph; it was, and then discarding the half that mattered.

The node's own modules now produce a matching map, built in the same pass as
the sibling name list so the two cannot disagree about which module owns a
name. Siblings override upstream writers, which is what the engine does, so
the row names the module whose value actually survives. Their tail reads
"this node" rather than a node label, because every sibling would give the
same answer.

Separately, the combine template editor never received producers at all —
`ModuleEditModal` forwarded them only to the derivation modal — so that one
was bare regardless of where the vars came from. Threaded through.

**Nested refs never said whose pool they use.** A ref resolves against this
node's snapshot of a wildcard when it has one, and that snapshot can hold
different options from the library row of the same uuid. Until now the only
way to discover that was to open the hover card. The chip carries a small
marker when the node supplies the pool, and the filter panel's header carries
the matching one, so "this is the node's copy — these counts describe it"
reads the same in both places. The library case stays unmarked: it is the
default, and marking both turns a signal into decoration. Origin is decided by
presence in the node's pool map, which is exactly how `resolvePoolFor` already
picks the winner for the hover card.

**One more unbounded preview.** A wildcard option's value in the pool list had
no line cap, so a paragraph-length option grew taller than the rest of the
pool put together and pushed every other option off the panel. Clamped to
three lines with the full value on hover — with `overflow: hidden`, without
which `-webkit-line-clamp` silently does nothing.
2026-08-02 08:04:51 +03:00
DumiFlex 06bb1b39d7 fix(wildcard-editor): unclip the tag menu, contain its scroll, fold the axes
Four things, all in the way of editing a wildcard with a real tag vocabulary.

Escape threw away the edit. The editor pages run a window-level Escape
shortcut that cancels the edit and routes back to the list. It opts out when
focus sits in an `input, textarea, [contenteditable]` — which covers the filter
panel's expression field but NOT its tag buttons, so pressing Escape after
clicking a tag navigated off the page. That is why it only happened sometimes.
The picker now takes Escape in the capture phase and stops it there, so while
it is open the key cannot reach anything else.

The per-option tag menu was clipped. It was `position: absolute` inside
`.wp-editor__body`, an `overflow: auto` scroller, so rows past that container's
edge were simply cut — measured 34px lost near the foot of the page. It is
teleported to <body> now and positioned from the trigger's rect, which is what
every other popover here already does. It re-anchors on scroll and resize
rather than closing: closing was the first attempt and was wrong, because the
browser scrolls a partly-visible button into view as part of clicking it, so
opening a menu near either edge closed it in the same gesture. Once the trigger
leaves the viewport entirely there is nothing to anchor to, and it does close.

Scrolling it moved the page. The menu's list now carries
`overscroll-behavior: contain`, so reaching either end stops there instead of
handing the leftover delta to the editor behind it. Same fix applied to the
filter panel's tag list. The cap and the scrolling also moved off the menu and
onto the list inside it, so the new search box stays pinned while the tags
scroll under it.

Everything is searchable, and the axes fold. 30+ checkboxes in a menu is
not something you scan; the picker gains type-to-filter, and the sub-categories
editor gains a per-axis fold so you can shut the four axes you are not working
on. A folded axis still shows its tag count, so folding never hides the fact
that something is there. The caret is its own button rather than the whole
header, because the header also holds the rename input and making the row the
toggle would fold the group every time you clicked in to edit the name.

The option-tag tests now query the document: teleporting the menu puts it
outside the wrapper's element tree, so `w.get()` cannot reach it.
2026-08-02 07:38:37 +03:00
DumiFlex 77fc32d760 feat(filter): rebuild the sub-category panel to the v3 design
The panel had an expression field, a reads-as line and a flat chip palette.
Authoring anything real with it meant holding the wildcard's tag vocabulary in
your head, and the two states that matter most were invisible.

The expression is now syntax-coloured: terms in accent, operators in blue,
parens dim, and an undeclared word underlined in red AS you type rather than
after the validator finishes the sentence. A textarea cannot colour its own
text, so the paint is a mirror element beneath a textarea whose glyphs are
transparent; every metric that decides where a glyph lands is declared once
and inherited by both layers, because if they disagree the colours slide off
the characters. The field also wraps, grows and drags — a single line clipped
anything past ~40 characters, and a real expression is longer than that.

The palette is grouped by axis, each foldable, with a search over the tags.
A folded axis still reports "2 used" / "1 negated", so folding never hides
state. Chips take their state from the parsed AST — lit when included, struck
when negated, dashed when the wildcard declares a tag that no option carries —
so the palette stays a VIEW of the expression rather than a second source of
truth for it. Only the tag list scrolls; putting the scrollbar on the popover
would push the expression and Apply out of view exactly when a long list makes
them most useful.

Two states that were previously silent:

- Valid but matching nothing. `skin-tone and pastel` is well-formed over
  declared tags; it just happens no option carries both. That applied silently
  and you found out at run time with the ref resolving to nothing.
- An unknown term now offers the nearest declared tag, and applying it
  rewrites that word in place rather than re-serialising the expression, so a
  one-character typo fix does not reformat the line you are writing.

The count now includes the null option and drops it when Exclude null is
ticked. `optionTagSets` carries only non-null options, so the popover
advertised "132 options" while this panel counted against 131, and ticking the
flag changed nothing on screen.

Also: a header naming the wildcard being filtered — a breadcrumb back to the
suggestions on the insert flow, a plain title on chip-edit — with Escape
matching what that header promises. The whole Exclude null row is a click
target, not just the 13px box.

Popover rows gain the same treatment: the glyph sits in a tile tinted with its
OWN kind's colour, since a `$var` can be written by a fixed_values or a combine
as easily as by a wildcard; facts are discrete tiles; and the producer line
keeps the module name separate so it can be highlighted, because that name is
the identifying half of the sentence.
2026-08-02 07:12:21 +03:00
DumiFlex d221ea479f feat(autocomplete): give the @ and $ popover rows an identity
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.
2026-08-02 06:17:35 +03:00
DumiFlex b09400ecd6 feat(test-runner): make the module picker survive duplicate names
The picker built its rows as `{ value: id, label: name }`, so a library
holding two pools both called "Lighting" — which is what you get, because it
is the obvious name for the indoor one and the outdoor one — rendered two
identical lines with no way to tell which was which. The fields that would
have distinguished them were projected away one computed earlier, where
`filteredItems` narrowed whole rows down to `{ id, name }`.

Rows now stay whole, and each option carries its category, its payload
summary, and — only when the bare name is genuinely ambiguous — a short uuid,
appended last so it reads as a tiebreaker rather than as the row's identity.
The detail comes from `entitySubtitle`, the same function the Import and
Export pickers use, so a row reads the same wherever it is offered.

The builder lives in `utils/entity-select-options.ts` rather than inline in
the view: it is pure data-to-data, and when a uuid appears is worth asserting
directly instead of through a mounted component.

Two fixes in Select itself, both about the same failure:

- `meta` now repeats in the CLOSED trigger. Disambiguating rows only while
  the menu is open solves half the problem — pick one of five identical
  "Outfit" rows and the trigger collapses back to "Outfit", so the choice you
  just made carefully becomes unverifiable again.
- The icon/dot gutter is reserved for every row in a list where any row has
  one. Mixed lists rendered a ragged left edge, and that indent reads as a
  hierarchy that does not exist. Width is measured off the widest marker
  actually present, so a dots-only list does not pay for icon width.

Categories are fetched OUTSIDE the `Promise.all` that loads modules and
bundles. Joining them would let a failure fetching what is purely icon-and-
colour reject the whole batch and leave the runner with an empty module list.
2026-08-02 06:00:03 +03:00
DumiFlex 685d6593fd feat(test-runner): band the histogram rows so a row reads across its columns
The four histograms put a label, a bar and a count on one row, several rows
deep, with nothing between them. Rows are also variable height — a clamped
label is one line or three — so there is no steady rhythm to help the eye
carry a label across to its own count.

Banding, not highlighting. Two details decide which one it reads as:

- The container's `gap` is gone. With a gap, tinted rows cannot touch their
  neighbours, so each one floats as a discrete filled block and the fill
  reads as "this row is selected". The spacing now lives inside each row as
  padding, so the stripes are contiguous. This is the change that matters;
  contrast alone cannot fix a gapped stripe.
- No border-radius, and a negative inline margin matching the card body's
  14px so each stripe bleeds to the inner edge. A rounded, inset fill is a
  pill, and a pill on a row is the universal shape of selection.

The tint is `color-mix` against `--wp-text`, so it inverts with the theme
for free — verified rendered in both wp-theme-dark and wp-theme-light.
2026-08-02 05:41:21 +03:00
DumiFlex 4fc3cd84d0 fix(ui): bound the text that could run unchecked
Swept the project for user text rendered with no bound on its size, after the
Test Runner histogram turned a paragraph-length option value into a forty-line
label that pushed every other bar off the screen.

Static analysis flagged 168 interpolation sites, but most are fixed labels or
sit inside a parent that already clips — the useful signal was 54 genuinely
unbounded free-text renders, of which three are reachable today:

  * TestRunner `.wp-hist__template` — used by three of the four histogram
    types; only the fourth had ever been given `wp-truncate`.
  * Templates `.wp-tpl-full` — `max-width` but no height bound, so one long
    template pushed every row below it away.
  * InjectorRow `.wp-inj-summary__template` — a collapsed one-line summary
    that a long template silently stretched.

Each gets the treatment its ROLE calls for rather than one blanket rule:

  * histogram label — a preview beside a bar, so a two-line clamp (three for
    bundle rows, which show several modules at once) plus `title` for the rest.
    One line loses the difference between near-identical option values.
  * template detail pane — opened deliberately to READ it, so cap and scroll;
    clamping would defeat the point of expanding.
  * injector summary — one line is the whole contract, so a hard ellipsis.

Caught while verifying the BUILT css, not the source: a later rule reset
`.wp-hist--bundle .wp-hist__template` to `overflow: visible`, which silently
disables `-webkit-line-clamp` — the clamp would have shipped doing nothing.
That rule exists to let bundle labels wrap, which `white-space: normal` already
handles, so it keeps the wrapping and drops the overflow reset.
2026-08-02 05:05:58 +03:00
DumiFlex 88e75ac60c fix(inputs): draw the grip as a corner triangle, and stop fields collapsing to nothing
Two follow-ups to the grip.

**Shape.** The replacement grip was a filled square of hatching, which read as
a button. Clipping it to a bottom-right triangle puts the stripes parallel to
the hypotenuse — the shape the browser's own resizer draws, and the one users
already recognise as "drag me".

**Floor.** The canvas fixed-values rows never declared a `min-height`, so the
computed value was `auto`, which `parseFloat` turns into NaN and the old
`|| 0` turned into a floor of zero. With the native resizer that was masked by
the browser's intrinsic minimum; taking it over exposed it, and the rows could
be dragged away to a sliver with no way to see what you were editing.

Fixed at both levels, because they answer different questions. `.row__value`
now declares `min-height: 28px` — CSS stays authoritative about what a given
field's floor should be. `useGrowableField` additionally falls back to a hard
28px whenever CSS declares nothing usable, so a future caller that forgets can
not reach zero either.
2026-08-02 04:49:06 +03:00
DumiFlex 7bf39d9337 fix(inputs): replace the native resizer, which cannot be clamped
Your detail — "it gets stuck when the bottom edge touches the first character"
— was the box reaching one line, i.e. its `min-height`. Same fight as the
`max-height` cap fixed in the previous commit, at the other end, and the reason
three earlier attempts all missed it.

The native `resize` computes `startHeight + (pointerY - startY)` from an origin
it captures at pointerdown, and clamps to nothing. Drag past a limit and that
sum keeps running while the rendered box stays pinned, so dragging back moves
nothing until the invisible total walks all the way home.

Measured, collapsing past a 34px floor then expanding:

  native resize, floor 34px ........ 13 / 23 frames dead
  native resize, floor lifted to 0 . 10 / 23   (intrinsic minimum remains)
  rewriting style.height each frame  13 / 23   (browser ignores it)
  own grip, clamped ABSOLUTE math .. 13 / 23   (input already accumulated)
  own grip, incremental delta ......  0 / 23

Nothing outside the resizer can fix it: writes to `style.height` are discarded
because the browser recomputes from its own origin, and clamping the result
does not help when the accumulated input is what overshot. So the native
resizer is gone, replaced by a grip that applies each move's DELTA to the
current height and clamps to the CSS floor. No history, nothing to unwind — the
first pixel back off a limit moves the box.

Verified on the live SPA field with the reported sequence (scroll to the end,
over-collapse, expand): 0 / 19 dead frames.

The grip sits in the corner the overflow fade already leaves clear, and is
drawn like the native one it replaces. Also removes the `clampInlineHeight`
helper from the previous commit — disproven by the table above, and its
docblock claimed a fix it did not deliver.
2026-08-02 04:27:06 +03:00
DumiFlex ea188af729 fix(inputs): let a manual drag override the height cap
Measured this one instead of guessing at it. A real Playwright drag on a
capped field versus an uncapped one, sampling height against pointer position:

  capped    down: 60 70 … 190 192 192 192 192 192 192 192 192 192 192 192
            up:   192 192 192 192 192 192 192 192 192 192 192 190 180 …
            frozen while dragging up: 10 / 25 frames
  uncapped  frozen: 0 / 25

`max-height` and `resize` fight each other. Drag past the cap and the inline
height keeps climbing while the rendered box stays pinned; drag back and
nothing moves until the inline value falls under the cap again. That dead zone
is the reported "gets stuck, then starts working", and it bites exactly AT the
cap — which is also where the overflow fade appears, which is why the fade kept
looking like the culprit.

It was never the fade, the pointer events, or the ResizeObserver. My three
previous attempts all treated symptoms.

The cap exists to stop AUTO-grow eating the screen; a deliberate drag is the
user overriding that, so it now yields. On a pointerdown in the grip zone the
current rendered height is pinned and `max-height` is dropped — pinning first
because removing the cap from an `auto`-height box would otherwise let it leap
to full content height the moment the handle is touched. Grip-zone detection
keeps a plain click in the text from pinning the box and killing auto-grow.

Same harness re-run against the fix: 0 / 25 frozen frames, tracking identical
to the uncapped control.

Also restores live grip-follow. Deferring it to pointerup was part of the
previous wrong theory and just made the field slide off-screen for the whole
drag; scrolling by the overshoot moves the element up by exactly that much,
landing its bottom edge where the pointer already is.
2026-08-02 03:36:09 +03:00
DumiFlex 385e7b7778 fix(inputs): keep the resize observer out of the drag's way
The drag still seized the moment the fade appeared, on both targets. The fade
was the tell, not the cause: crossing the overflow threshold is simply when the
observer callback starts doing work, and the work was the problem.

Two things ran synchronously inside the ResizeObserver callback:

  * `updateOverflowHint` read `scrollHeight` / `clientHeight` and wrote a
    reactive ref, so every frame of a resize dirtied layout inside the very
    callback the browser uses to report layout. That is the ResizeObserver-loop
    case: the engine throttles and skips delivery for a frame, which the user
    feels as the drag locking up until they release and re-grab.
  * `followGrip` scrolled the container mid-drag, sliding the element out from
    under a pointer the browser anchors its resize tracking to. My own fix from
    the previous commit, adding a second stall on the way out.

The callback now only schedules; all measuring, state writes and scrolling
happen in the following animation frame. Grip-follow additionally waits for
`pointerup` — the field still ends up on screen after a drag past the fold,
without anything moving while the handle is held. Listeners sit on `window`,
since a resize drag routinely ends with the pointer outside the field.

The regression test asserts the invariant that actually matters rather than the
symptom: with rAF stubbed out, invoking the observer callback must read layout
zero times.
2026-08-02 03:20:34 +03:00
DumiFlex e5cc122341 fix(inputs): auto-grow must yield to the drag handle, and the grip must stay reachable
Three defects in the same field, two of which never reached the canvas because
I fixed them in the wrong component. `RichTextInput` and the fixed-values
`ValueRow` had each grown their own copy of "capped, resizable, auto-growing
text field", and the copies had diverged.

**Auto-grow stomped the manual resize.** `ValueRow.autosizeValue` wrote
`height = scrollHeight` on every input AND on every external value change, so a
box the user had dragged smaller sprang straight back open on the next
keystroke. That is the reported "hold it to collapse, it gets stuck, then
starts working" — the drag was working, the auto-fit was undoing it.
`useGrowableField` now records that the user has taken control and stops
auto-fitting from that point on, distinguishing its own writes from a drag with
an `autoDriven` guard so the first fit does not lock the field immediately.

**The fade hid the grip.** The bottom hint band is exactly as tall as the
native resize handle and sat right on top of it, so the handle was invisible:
aim, miss, re-grab. It is `pointer-events: none`, so it never blocked the drag
— it hid the target. The band now stops 16px short of the right edge. Fixed in
both components; previously only `RichTextInput` got it, which is why the
canvas fixed-values modal still looked wrong after a rebuild.

**Nothing followed the grip off-screen.** Dragging a field taller than the
space below it moves the box, not the pointer, so the handle slides out of view
and the drag goes blind. The shared helper scrolls by exactly the overshoot,
pinning the bottom edge to the viewport. Walks computed `overflow-y` for the
real scroller rather than assuming the page scrolls — on the canvas it does
not, and `window.scrollBy` would have been a no-op. Deliberately not
`scrollIntoView`, which this project bans for moving every ancestor.

Extracted rather than fixed twice: a third copy was about to be written for the
sub-category filter's expression box.
2026-08-02 03:05:55 +03:00
DumiFlex 09227b216c feat(filter): derive chip state from the parsed expression
Groundwork for the sub-category picker redesign. The palette's chips need to
show what the expression currently does with each tag — and that has to be read
from the parsed AST, not from what the user clicked, or the chips desync the
moment an expression is typed, pasted or hand-edited. The palette is a view of
the expression, never a second source of truth for it.

`collectTermPolarity` walks the AST tracking `not` parity, so `not (warm or
cool)` marks both negative and `not (not warm)` comes back positive. A tag
appearing both ways lets positive win: the chip reads as participating, and the
negation stays visible in the expression text.

`livingTags` separates DECLARED tags from tags any option actually carries.
A declared tag no option uses is offerable and guaranteed to match nothing —
the exact shape of the nested-ref bug — so it gets its own `dead` state rather
than looking selectable. It does NOT outrank `in`/`negated` though: once a tag
is in the expression the chip shows that, because the zero-match warning
already covers the consequence and two alarms for one mistake is noise.

`nearestTerm` backs the validator's did-you-mean with a length-scaled edit
budget — `colde`→`cold` yes, `magenta`→anything no. A wrong guess is worse
than no guess.
2026-08-02 02:28:57 +03:00
DumiFlex 8f388adc55 fix(inputs): stop a scrollable field handing its scroll to the page
Scrolling inside a capped, resizable field and reaching either end let the
remaining delta chain to the ancestor, so the modal — or the whole editor page
— lurched while the pointer was still over the field. Worst at the TOP edge,
where a small upward flick past the first line jumps the page.

`overscroll-behavior: contain` on every field that both caps its height and
scrolls: RichTextInput's `--wrap` and `--multi` hosts plus the nine other
resizable textareas across the assembler, cleaner, combine, fixed-values,
push-to-library, injector, bulk-add and import surfaces.

Applied per-rule rather than as one `textarea { … }` global: an unprefixed
element selector is exactly what `pnpm check:css-isolation` blocks, and on the
canvas it would reach into ComfyUI's own DOM. The pattern already exists in
five places (SelectionToolbar, UnmetDepsDialog, PickerSection…) — this brings
the input surfaces in line with it.
2026-08-02 02:22:10 +03:00
DumiFlex 2f08c138ac feat(refs): one pool for the picker, and a debug warning that explains itself
Two halves of the same gap, left over from the nested-ref fix.

**The sub-category picker offered the wrong pool's tags.** Its checkbox list
came from `buildWildcardRefData(catalog)` — a library fetch — while the engine
filters the ref against THIS node's snapshot whenever the uuid is a module
here. On a drifted snapshot the picker listed a tag no option in the running
pool carries: pick it, and the ref resolves to nothing, with the checkbox list
itself being the thing that misled you.

`overlayContextPools` applies the same precedence `resolvePoolFor` already
gives the hover card, so card, picker and engine now describe one pool.
`ContextPool` grew the declared vocabulary (`subCategories`, `tagGroups`,
`hasNull`) the picker needs — distinct from `tagSets`, which is what options
actually carry; a declared tag no option uses is offerable but matches
nothing. `uuidToName` is deliberately NOT overlaid: it holds the variable
binding a chip labels itself with, not a display name.

Only the derivation modal needed it. The constraint modal already builds from
node modules, and the SPA editors have no node, where the overlay is a no-op.

**"Matched no options" told you nothing.** The engine emitted
`ref_subcategory_empty_pool` all along, but `WARNING_LABELS` had no entry, so
Debug rendered the raw slug — and even read, the message could not distinguish
a wrong filter from a right filter aimed at a stale copy. That ambiguity is
exactly what made this bug a hunt.

`_stamp_pool_provenance` records, per catalog entry, whether the pool came
from this node or the library, plus the library's option count when a node
copy has drifted. The warning now reads:

    @{05c54db9:test} matched no options — searched this node's copy
    (1 option); the library now has 2

with `pool_origin`, `pool_total` and the pool's `available_sub_categories` in
`detail` — the tags it DOES offer being the whole explanation. Labels added
for the two nested-ref warnings and `ref_out_of_surface`.

Provenance is runtime-only: `__wp_catalog__` is stripped at the socket and the
workflow-embedded snapshot is built elsewhere, so neither key reaches saved
JSON. The extra reads are one per embedded wildcard on an already-open
connection, and the stamping never raises — diagnostic garnish must not take a
graph run down.
2026-08-02 01:25:28 +03:00
DumiFlex 6cbff32827 feat(refs): say when the pool lives in another node, not just "library"
Follow-up to the pool-source card. Naming the library as the source is honest
but leaves the obvious question unanswered: the pool is right there on the
canvas, in a node the user can see, so why isn't the ref using it?

Because a `@{}` ref only ever sees its OWN node's modules plus the library —
`__wp_catalog__` is rebuilt per node and is not among the cross-node internals,
so a catalog never crosses the socket. That is the exact opposite of how
`$vars` behave, which is what makes it a trap: move a pool one node upstream
and the ref silently switches to the library with nothing to explain it.

The card now says "also in dusk-marten — other nodes don't share pools" (or
"N other nodes" past one), in info blue rather than the drift amber, because
nothing is wrong — the ref is behaving correctly and the user simply cannot
see why from anywhere else.

`findWildcardHomesElsewhere` walks the whole graph, subgraphs included: a node
holding the uuid off to one side is exactly as irrelevant to the ref, and just
as confusing to look at. It is injected as a FUNCTION and called once per card
open rather than exposed as a reactive map — the walk is graph-wide and only an
open card ever reads the answer, so polling it on the 400ms cadence would burn
work nobody reads.

Wired through the same reader-prop path the other graph walkers use
(`wildcardHomesReader` in the mount glue), so the component stays mountable
headless with no graph.

The exclusion test — the asking node must not report itself, since its own
modules ARE the ref's pool — was checked to FAIL when the guard is removed.
2026-08-02 00:36:09 +03:00
DumiFlex 7e26211fa5 fix(chips): stop the container tooltip overlaying the ref hover card
Hovering a chip raised two popups at once: the chip's own hover card, and the
native tooltip belonging to whichever container it sat in — the derivation
rule/branch summaries put the full row text on a `title` so a clamped value
stays readable. They landed on top of each other.

The chip only rendered a `title` when it was filtered, so an unfiltered chip
had none and the browser walked up to the container's. An EMPTY title is not
the same as no title: per HTML it means "this element has no advisory
information" and stops that walk. So the chip now always carries `title=""`.

Nothing is lost — the filter's "reads as" and the excluded-null note already
render inside the hover card, so the native tooltip was duplicating them. The
seven tests that asserted the old title now assert the card instead, which is
where a user actually reads it.
2026-08-01 21:08:16 +03:00
DumiFlex 61288c5c2e feat(refs): the nested-ref hover names the pool it counted, and flags drift
The card read the live library unconditionally, but the engine resolves a
nested ref against the Context node's own frozen snapshot whenever the uuid is
also picked there, and only falls back to the library otherwise. So a node
holding a drifted snapshot reported the library's numbers while the graph ran
the snapshot's — "the SPA says 1 of 11 options match, the graph says 0 of 10".
Both counts were right about a different pool, and nothing said which, so
neither could be checked against what actually happened.

`extension/context-pools.ts` mirrors the engine's precedence: the node's pool
wins when present, the library answers otherwise. The card now counts against
whichever won, and names it — "pool: this node · Pose pool" vs "pool: library".
When the loser disagrees it adds "library has N — refresh to use it", so a
differing number is explained rather than hidden; silently showing only the
effective pool would have left the original confusion intact, just quieter.

Disabled modules are deliberately KEPT in the map. Disabling a module stops it
binding its own `$var`; it does not remove the pool the engine still resolves
nested refs against, and dropping it here would make the card disagree with the
engine in precisely the case where a user is already unsure where a value came
from.

Wired by provide/inject rather than a seventh threaded prop: RefChip sits six
components below the widget (row → edit modal → section → value chips →
preview → chip) and not one layer between has any business knowing about
option pools. Absent injection — the SPA library views — every ref reads the
library, which is correct there since there is no node.

The three tests that pin the precedence were checked to FAIL when the context
branch is disabled.
2026-08-01 20:34:24 +03:00
DumiFlex 22f7dedcf2 fix(autocomplete): don't reopen the picker when the caret sits behind a chip
Type a space after a `$style_fx` chip, delete it, and the caret settles flush
against the chip's trailing edge — which reopened the suggestion popover even
though nothing was being filtered and the chip already existed. Both surfaces.

A committed chip serialises back into raw text as exactly what the user would
have typed to create it, so `probeAutocomplete` — which works on raw text —
scans back from the caret, finds the chip's own `$`, and reports a live query.
From the string alone the two cases are identical; only the DOM distinguishes
them, because a chip is an atom element and in-progress text is not.

`chipRawSpans` carries that distinction into the raw coordinate space by
measuring each rendered chip with the SAME `childRawLen` the caret mapping
uses (hoisted rather than copied — two measurements of the same thing drift).
A trigger landing inside one of those spans suppresses the popover.

Surfaced by the earlier deletion re-probe fix: before it, deletion never
re-probed at all, so this path could not fire. The regression test asserts
zero suggestion rows after the exact reported sequence, and was checked to
FAIL (2 rows) with the guard disabled — otherwise it proves nothing.
2026-08-01 20:17:32 +03:00
DumiFlex 96c65a7d1b fix(derivation): cap the preview VALUE, restore the wrapping layout
Two previous attempts solved the wrong problem. The rule/branch summaries were
forced onto a single line — first with a gradient mask, then with
`text-overflow: ellipsis` — and both destroyed the thing that made the row
useful. The mask faded every pixel including the ref chips; the ellipsis
version was worse still, because the children were `inline-flex` and an
inline-flex box is ATOMIC: the browser cannot clip through it, so a value that
did not fit whole was dropped entirely and replaced by a bare `…`.

The layout was never the problem. Wrapping is what lets a four-chip branch
show all four chips, and that is the readable, wanted behaviour. The only
actual complaint was that one paragraph-length override ran to dozens of lines
and pushed the rest of the modal off screen.

So the layout goes back to exactly what it was (`display: flex; flex-wrap:
wrap`), byte for byte, and the cap moves to the VALUE: `clampPreviewValue`
cuts at 120 characters. Normal rows are untouched — every chip value in a real
derivation is well under the limit — and only the outlier gets cut. `title`
still carries the full text on hover.

The clamp never splits a `@{...}` token: half a token renders as literal text
rather than a chip, which reads as corruption rather than truncation, so a
token that would straddle the limit is dropped whole.
2026-08-01 20:00:35 +03:00
DumiFlex 1224d35010 fix(refs): discover @{uuid} refs written outside a wildcard payload
A nested ref in a derivation resolved to empty whenever the wildcard it points
at was not ALSO picked into the same Context node — no matter what the live
library held, and no matter how the ref was filtered.

Root cause is discovery, not resolution, which is why it looked like a
resolution bug. The catalog is synthesised from wildcard modules only, and
`walk_transitive_refs` was seeded from those uuids and followed refs found in
wildcard payloads only. A `@{uuid}` is legal on other surfaces too — most
importantly a derivation rule's action value, and the per-instance
`action_value_overrides` the edit modal writes when a user retargets a branch
on the canvas. Neither was ever scanned.

So such a ref's target reached the catalog only by coincidence: when the user
happened to also have that wildcard picked in the node. Removing it — a
reasonable thing to do once a derivation references it by uuid — dropped it
with nothing to put it back, and the ref became `unknown_ref` → empty string.

`ref_seed_uuids` (engine/modules/snapshot.py) now collects refs from every
module's `payload` AND `instance`, and both catalog builders seed the walk
with them. The instance half matters on its own: a canvas-side retarget lives
there and is not part of the library payload the walker would otherwise see.

Both builders are fixed, not just the graph one. `wp_api/preview.py` had the
same seeding, so the SPA preview resolved against a pool the engine would not
use — that is precisely how "the SPA says 1 of 11 options match, the graph
says 0 of 10" arose. Fixing one and not the other would have left the two
disagreeing in the opposite direction.

Embedded-first precedence is unchanged: a picked module still shadows the
library, so drift detection and workflow reproducibility hold. This only adds
targets that were previously absent entirely. An unresolvable uuid still warns
and resolves to empty rather than raising.
2026-08-01 19:42:27 +03:00
DumiFlex 78f831ad69 fix(derivation): truncate rule previews with an ellipsis, not a fade
The gradient mask was the wrong tool. `text-overflow: ellipsis` is ignored on
a flex container, so the previous fix reached for `mask-image` to signal "cut
off" — but a mask fades every pixel under it, chips included, so the tail of
each summary went unreadable rather than merely clipped. The user could no
longer see the ref chips that are the most informative part of the row.

Switching the summary to `display: block` puts it in an inline formatting
context, where the browser draws a real `…` at the clip point and leaves
everything before it at full opacity. Children become `inline-flex` so they
still sit on one line, with a margin standing in for the flex `gap`.
2026-08-01 19:37:36 +03:00
DumiFlex acb5848da3 fix(icons): a glyph that does not exist, and three that sat off-centre
Four things the user caught in one screenshot pair.

**`brush` is not a PrimeIcons glyph.** It rendered as an empty grid cell, which
reads as a layout bug rather than a typo — an unknown `pi-` class produces no
warning and no error, just nothing. Replaced with `sliders-h` ("tune the look";
the set has no brush/paint glyph at all, and `palette` already carries "art
style").

The durable part is `icon-catalog.ts` + its test: the curated list moved out of
the SFC so a spec can check every name against the installed `primeicons.css`,
which is the authority. Parsing the stylesheet beats hard-coding a name list
that would go stale on the next PrimeIcons bump. Verified live afterwards —
all 40 cells resolve a glyph.

**Category icons sat above their label in Select options.** The option row is
`align-items: flex-start` (deliberate — a 2-line label must keep the tick at
the top), so a 14px icon aligned to the TOP of an 18.75px label box rather than
to its first line. Measured 2.38px high before, 0 after. Fixed by centring the
icon and the colour dot against one line box, published as
`--wp-select-line-h` and used by both the label's `line-height` and the icon's
`height` — a `1.5em` on each would have meant two different heights, since the
icon runs at 12px and the label at 12.5px. Scoped to the option: the trigger
row is `align-items: center` and would be thrown off by the same rule.

**The icon-picker trigger jumped when a glyph was picked.** An em-dash and an
icon have different advance widths and baselines; both now share one fixed
16px slot. Grid cells got `line-height: 1` for the same reason — the icon
font's line box is taller than the mark and pushes it off centre inside an
otherwise centred flex cell.

**Import quick-select presets had no icons** where Export's do. Wiring them up
exposed that `category` had no row in `KIND_ICON_MAP` and was falling through
to the generic `pi-circle` on BOTH tabs; it now maps to `pi-bookmark`, matching
the Categories page's own empty state.
2026-08-01 19:03:02 +03:00
DumiFlex 2c6de11c8c feat(categories): icons everywhere a category appears, and one chip to render them
Categories have carried an `icon` column since the picker landed, but only the
Categories page ever showed it. Everywhere else a category still reduced to an
8px colour dot — which stops disambiguating anything once a library holds more
categories than there are hues a user can tell apart at that size.

Icons now render in the category dropdowns (filter + bulk-assign), in the row
chips on every list view, on the dashboard, and in both import/export pickers.
The icon replaces the dot rather than sitting beside it, tinted with the
category's colour so nothing is lost; a category with no icon still shows the
dot exactly as before.

Getting there meant collapsing three sets of copy-paste, because each would
otherwise have needed the same edit applied by hand N times — the failure mode
being that one gets missed and quietly renders the old way:

- `utils/category-options.ts` replaces thirteen identical
  `categoryStore.items.map(...)` expressions across nine files. The two
  variants differ only in the null choice ("All categories" vs "(none)"), so
  that is the only parameter.
- `components/CategoryChip.vue` replaces ten hand-written
  `<span class="wp-cat-chip" :style="catChipStyle(...)">` blocks.
- `ui/select-types.ts` holds `SelectOption`, which lived in `<script setup>`
  and so was invisible to plain `.ts` importers (they see the `*.vue` ambient
  shim, whose only export is the component). Select.vue re-exports it, so every
  existing `from "./Select.vue"` import keeps resolving. ModuleListView's
  hand-mirrored copy of that shape is gone with it — the copy had already gone
  stale against `icon`.

Also reorders the Categories page: icon and colour now come before the name, in
the form and in the table. They compose the chip the user is building, and
left-to-right now matches how the chip actually renders.
2026-08-01 09:53:47 +03:00
DumiFlex fe6f6ec451 feat(import): search + per-row detail in the import picker, mirroring export
The export picker grew a search box and subtitle lines when a 261-item
library made it a scroll-hunt. The import picker had the same problem the
moment anyone re-imported a whole-library export: duplicate display names,
and no way to jump to a known id.

Both sides now share the machinery instead of carrying two copies:

- `picker-subtitle.ts` — the per-kind detail line (`$outfit · 12 options`,
  `3x4 matrix`, `2 modules · 1 nested`). Extracted from ExportTab verbatim.
  Typed structurally rather than against `api/types`, because Export feeds
  it live library rows while Import feeds it migrated payload rows that are
  shape-validated but never narrowed to those interfaces; both satisfy the
  loose shape, so neither side needs a cast. Adds `entitySubtitle(kind, row)`
  for Import, which walks all 8 buckets in one v-for and so cannot pick the
  right function at the call site the way Export's per-bucket builders can.
- `PickerSearch.vue` — the box itself. Presentation only; each host owns its
  query and decides what "matches" means for its own rows.

Filter scope follows the same split Export settled on, because it is a
correctness question rather than a cosmetic one:
  - section select-all acts on VISIBLE rows, so deselecting a filtered
    section cannot silently drop picks the user can no longer see;
  - quick-select presets stay unfiltered — "select all wildcards" means the
    kind, not the current search result;
  - a section with matches force-opens, since a hit hidden behind a
    collapsed header reads as no hit at all.

Test fixtures for the new specs nest `variable_name` / `options` under
`payload`, matching what the server actually exports — `_row_to_module`
nests them and every migrator preserves that nesting. A first draft
invented a flat shape and produced empty subtitles.
2026-08-01 09:37:37 +03:00
DumiFlex 0f4ba28105 fix(derivation): actually truncate rule previews, and accent state recoveries
The truncation fix did not work. Setting `white-space: nowrap` on
`.rule-head__summary` was necessary but not sufficient: the value chips render
through RichTextPreview, which declares `white-space: pre-wrap` on `.wp-rtp`
and `.wp-rtp__text` THEMSELVES. A direct declaration beats what those elements
would inherit from an ancestor, so the paragraph kept wrapping to hundreds of
lines. `ui/Select.vue` already carried this exact workaround for its trigger
label; the derivation summaries needed the same `:deep(...) !important` push
down the tree.

Diagnosed from a live DOM dump rather than assumed: the `title` attribute
added by the previous commit was present in it, which ruled out the stale-
bundle theory I had reached for and pointed at cascade instead.

Also accents the context-menu action that matches the row's current state, so
the menu points at the fix for the badge the user just saw:
  missing  -> Push to library (re-link or add)
  drifted  -> Reset / Refresh from library (pull the newer version)
  modified -> Push to library (save local edits up)
States are ranked so exactly one library action accents — a row can be several
at once, and two competing accents say nothing. Each accented item also gains
a subtitle naming the reason.
2026-08-01 08:39:55 +03:00
DumiFlex 727bd089a4 fix(bundles): an unlinked bundle reads as missing, not modified
Reported: a bundle showed MODIFIED while the push modal said it wasn`t linked
to any library entry. Modified against what?

`isBundleMissingFromLibrary` early-returned false when the bundle had no
`library_id`, so MISSING only ever meant "had a link and it is gone" — an
unlinked bundle could not raise it. That left MODIFIED as the only badge, and
MODIFIED compares the children against `snapshot_fingerprint`, a baseline
captured from a library entry that is not there. It was diffing against a
ghost. Same class of gate as the re-link bug: a check that assumed the
library_id case was the only way to lose a link.

MISSING now covers both shapes — a `library_id` the poll no longer knows, and
no `library_id` at all. MODIFIED is suppressed for the second: with no insert,
there is nothing for the children to have diverged from. The deleted-entry case
keeps MOD, where the local snapshot IS a real baseline.

Badge tooltip and the context-menu subtitle stop asserting the entry was
DELETED — an absent id reads identically whether it was deleted, re-imported
under a new id, or arrived in a shared workflow. They now state the observable
fact and name Re-link before save-as-new, which is the recovery the old copy
steered users away from.
2026-08-01 02:46:04 +03:00
DumiFlex 470133f953 feat(relink): match bundles on child content, not just the stored hash
Bundles already had a re-link path — my earlier claim that they were scoped
out was wrong, twice: I read a comment about bundle FINGERPRINT VERIFICATION
during import as if it covered re-link. What they actually had was the same
gate modules had, requiring a stored `inserted_at_hash`, so a bundle that lost
its link had nothing to match on.

`computeBundleFingerprint` cannot serve here: it folds in per-instance `_uid`,
`meta` and `instance` to answer "have MY children been edited since insert", so
a fresh insert never matches the library row it came from. Adds
`bundleContentKey`, the ordered list of children`s `(type, payload_hash)` —
only what survives an insert, so a workflow bundle and its library row agree.
Child order is significant (a bundle IS an ordered range) and a hash-less child
still occupies a slot rather than vanishing from the shape.

Gate widened to `needsRelink` to mirror the module modal, and the "deleted
upstream" note replaced with the observable fact plus a pointer at Re-link,
in an informational rather than danger tone.
2026-08-01 02:03:33 +03:00
DumiFlex 388af28a2b feat(relink): offer re-link to hash-less modules, and stop guessing why
Re-link was gated on the row carrying a `payload_hash`. A row without one was
read as "never library-tracked, nothing to re-link to" — true for a module
authored in the widget, wrong for the cases that actually bite: a workflow
shared by someone else, or a row whose stored hash was lost. Those still
correspond to a library entry, and the user was pushed to "Save as new entry",
which is how duplicates got made.

`moduleFingerprint` cannot help here — it takes `payload_hash` as an INPUT, so
it says nothing about a row missing one. Adds `localPayloadFingerprint`, which
hashes the payload CONTENT instead. It deliberately does not mirror the
engine`s hashing: both sides are computed locally from live payloads, so
self-consistency is the only requirement and cross-language canonicalisation
never enters the picture. Object keys are sorted recursively (JSON key order
is not meaningful and payloads round-trip through APIs that reorder them);
array order is preserved, since option and rule order IS meaningful.

Matching now runs strongest-signal-first: server `payload_hash`, then the
local content key (hash-less drafts only), then name. A present-but-different
hash still reads as "content differs" — a stale content key must never upgrade
a real difference to identical. The cross-kind gate is unchanged. Matches made
without a server hash are tagged `matchedByContent` and labelled
"identical (content)" so the UI never implies the authoritative hash agreed.

The note also stops asserting a cause it cannot know. "has been deleted
upstream" was one explanation among several that look identical from here; it
now states the observable fact — the id isn`t in the library — and points at
Re-link when a match exists, in an informational rather than danger tone. Red
plus a "Save as new entry" recommendation was actively steering users into the
duplicate.

Bundles still have no re-link path; that is the remaining half of this issue.
2026-08-01 01:51:35 +03:00
DumiFlex 7c18fcc208 feat(chips): name the library module that binds a $var in the SPA
Canvas var chips gained producer attribution; the SPA still said only
"binds at runtime". It has no chain to walk, but it does have the catalog —
and with several near-identical modules binding the same name, "which module
is this?" is exactly the question the library editors raise.

Adds `buildLibraryVarProducers`: the same per-kind extraction
`collectLibraryVarHints` already does, but keeping module identity instead of
deduping it away. A module that binds one var from several places — a
derivation with three branches writing `$pose`, a fixed_values sheet with a
repeated row name — counts ONCE, otherwise the card claims three modules bind
a name only one does.

Deliberately no ranking. The library has no execution order, so which producer
wins depends entirely on how they are later arranged in a Context node. The
card names the first and reports how many others share the name, via a
`siblingLabel` that swaps the canvas`s "overrides N earlier writes" for
"N other library modules bind this name". Same reason `nodeLabel` is now
optional: with no graph there is no node to name, so the clause is omitted
rather than filled with something invented.

Wired into the Derivation and Combine editors, the two SPA surfaces with
`$var` chips.
2026-07-30 20:15:15 +03:00
DumiFlex 4cf497b507 feat(chips): name the module and node that produce a $var
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.
2026-07-30 16:40:25 +03:00
DumiFlex 51eabadc36 fix(ui): truncate rule previews instead of clamping, unbury hover cards
Rule head + branch summary: the two-line max-height clamp stopped the modal
blowing out but sliced the last line through the middle, which read as broken,
and there was no way to see the rest. Now a clean single line with a fading
trailing edge (flex containers ignore `text-overflow`) plus a `title` carrying
the full plain-text rendering — `branchSummaryText` / `ruleSummaryText`.

Hover cards moved to their own tier (10030) above the popover tier (10020).
Raising popovers earlier put dropdown menus ABOVE these cards, so an `@ref`
chip inside a constraint-exception Select option had its hover card drawn
underneath the very menu it belongs to. A chip can live inside a popover, so
its card has to clear it. Applies to RefChip and PairBadge.

Select option rows now wrap to two lines instead of clipping at one. An
unscoped `white-space: nowrap !important` was forcing single-line rich content
in the menu, which cut exception values — a phrase plus a nested `@ref` —
exactly where they stop being distinguishable. The trigger keeps its
single-line clip: it has a fixed height and wrapping there blows up the row.

Fixed values: Enter inserts a newline again. Swallowing it was parity with the
single-line `<input>` this replaced, for its own sake — the engine stores the
value verbatim, multi-line round-trips, and the modal saves on Cmd/Ctrl+Enter,
so plain Enter was free all along.
2026-07-30 16:06:21 +03:00
DumiFlex 96b4141aed fix(ui): cut the changelog dump, clamp rule previews, fix the overflow hint
Update dialog: release bodies end with a `<details>` block holding the raw
per-commit list. GitHub collapses it behind a click; `normalizeReleaseHtml`
unwrapped it inline and dumped every commit message into the preview, burying
the human-written "What`s new" the dialog exists to show. It is now stripped
before render, along with the `---` that introduced it. Matched on the summary
text rather than stripping every `<details>`, so a future collapsible still
unwraps as before. The dialog already links out to the full changelog.

Derivation rule previews: a rule head and a branch summary are PREVIEWS, but
neither was clamped, so one paragraph-length action value wrapped to hundreds
of lines and pushed every other rule, the runtime row and the footer buttons
off screen. Capped at two / three lines; the expanded body and the override
inputs still hold the full value.

Overflow fade: it only appeared once a field was typed into — never on a box
the user can scroll but not edit. A mount-time measure runs before the
max-height cap applies, reads `scrollHeight === clientHeight`, and concludes
there is nothing below. Measurement now happens after layout via rAF, and a
ResizeObserver keeps it honest across manual resizes and reflows. Applied to
both RichTextInput and the ComfyUI fixed-values row, and the fixed-values row
gained the fade it was missing entirely.

Dead CSS: drops the `.wp-tpl__menu*` selectors — Combine`s TemplateSection has
no insert menu, so they matched nothing (`.wp-tpl__reset` IS still live and
stays). Removing them exposed that their `.ibm__menu*` partners were only
passing `check:css-isolation` because a wp-prefixed selector shared the rule,
so those are now `.wp-ibm__menu*` and the check passes on its own merits.
2026-07-30 15:16:06 +03:00
DumiFlex 963c36fc18 fix(ui): rework the overflow fade, and make branch headers fully clickable
The "more below" fade read wrong on both counts it was meant to get right.
Its 1px side insets left a visible seam down the left edge where the band
stopped short of the border, and blending toward the field background painted
an opaque strip that washed out the line underneath instead of hinting at it.

Now full-bleed (`inset: auto 0 0 0`), clipped by the wrapper`s own overflow +
radius so there is no seam, and the gradient runs to a TRANSPARENT accent so
text keeps showing through. A 1px inset accent line along the bottom does the
actual attention-getting, which lets the wash stay light.

Derivation branch headers (SPA) now toggle from anywhere on the bar. The
chevron was a ~14px target on a full-width header that already looked
pressable. Clicks originating inside a control are ignored, so the chevron
does not double-toggle and the enable checkbox / delete action keep their own
behaviour.
2026-07-30 03:41:45 +03:00
DumiFlex 59e51d34c0 feat(spa): quick-fix pass — nav, export detail, input scaling, tag collapse
Templates: drops the Template column. A template string is a whole prompt
skeleton, so a table cell only ever showed a truncated fragment while stealing
the width the name needed; the row expansion already shows it in full. The
column now carries length + distinct `$var` count.

Nav: Wiki loses its sidebar entry — it duplicates Documentation`s content on
GitHub and sat directly above View Source. It now lives at the foot of the
Documentation view`s own nav. "Report an issue" becomes "Feedback & support",
opening a modal that offers Discord (noted as faster) and GitHub, since most
feedback is a feature request rather than a bug. Discord also gets a permanent
topbar icon.

Export: sections with search matches auto-expand — a filtered but collapsed
section gave no clue whether the query had found anything. Every entity kind
now carries row detail, not just wildcards: values / rules / matrix size +
exceptions / children + nested / template length. Combine reads `output_var`
and `input_vars` (an earlier guess of `output_variable` matched nothing, so
combine rows showed nothing at all). Detail is right-aligned in the name
column so it forms one scannable run. Kind presets carry their kind glyph.

Constraints: source/target dropdown meta gains tag + axis counts — that IS the
matrix about to be built, so the grid size is worth knowing before picking.

Inputs: max auto-grow drops from 40vh to 12rem (wrap) / 14rem (multiline) —
40vh let one field eat half the viewport before it scrolled. ComfyUI
fixed-values gains the manual resize handle it was missing, and the SPA
derivation value fields gain `wrap`, so both match combine/derivation
behaviour. Adds a restrained accent-tinted bottom fade when a capped box is
hiding content below the fold.

Wildcards (SPA): per-option tag chips cap at 4 behind a `+N`, mirroring the
canvas OptionRow, and the Sub-Categories section collapses via a `+`/`x` in
its header with a tag/axis count while shut. A fully tagged wildcard pushed
the options table off screen.

Select: hover titles on the trigger and option rows, so a value clipped by a
fixed-width trigger is still readable — constraint exception sources run long.
2026-07-30 03:32:30 +03:00
DumiFlex 4e746ce2aa fix(ui): let dropdown scrollbars work, and put popovers above the modal tier
Two separate defects, both making a visible dropdown unusable.

Scrolling closed the list. `onWindowScroll` was a capture-phase listener on
window that closed the popover on ANY scroll — including scrolls originating
INSIDE the popover. So wheeling over the list closed it, and dragging its
scrollbar closed it mid-drag, leaving a half-typed reference behind. It now
ignores scrolls inside the popover, and a genuine outside scroll re-anchors
via `positionPopup` instead of closing: losing an in-progress token because
the page moved is a worse outcome than following the input. Matches what the
injector menu and VarAutocompleteInput already do.

Popovers rendered behind modals. Every teleported popup sat at z-index 9999
while modal overlays run from 9999 (ModalShell) to 10010
(InjectorBindingModal). At equal z-index the popover won only on DOM order,
so inside any 10000+ modal it was buried — the reported "injector ref
dropdown appears behind the modal". Moves the whole popover tier to 10020:
the autocomplete popover, the shared `.ibm__menu` / `.wp-tpl__menu` insert
menu, the subcategory filter picker, `ui/Select`, and IconPicker.

Also: the Pool collapse chevron was a 9px glyph with no hit area, so nobody
could tell it was pressable — now an 18px target with a hover box. And the
seed-list modal goes 486px to 560px, since its toolbar and per-row toggles
were being squeezed into near-square boxes to fit.
2026-07-30 02:45:48 +03:00
DumiFlex 8d6d4e4b10 fix(autocomplete): re-filter the suggestion list when characters are deleted
Reported repro: type `@act` (4 matches), narrow to `@act_` (2), delete the
`_`, and `@act` still shows 2. The list never followed a deletion, on either
surface.

Backspace and Delete both `preventDefault()` and perform the deletion
themselves via `deleteRawRange`. Suppressing the native delete means no
`input` event fires — and `onHostInput` is the only other caller of
`refreshAutocompleteFromHost`. So `acQuery` kept its pre-deletion value and
`acItems`, computed off it, stayed frozen at whatever it had narrowed to.
That is also the real cause of the earlier "type something unmatched and you
must clear the whole field" report: the field was fine, the list was stale.

Both delete branches now re-probe. The call sits in a SECOND `nextTick`
because `restoreCursorAtChar` ends with `host.focus()` and `onHostFocus`
schedules its own tick that can reposition the caret — probing in the first
tick reads the pre-settled caret at offset 0, the probe returns null, and the
popover closes instead of re-filtering. Verified: probing one tick earlier
left the list empty where an equivalent input event produced 3 rows.

Also reverts the earlier partial-brace / space handling in
`probeAutocomplete`. That was built on a misreading: `@{uuid#name}` is an
internal serialisation the user never types, wildcards are addressed by
variable binding rather than display name, and a space is the settle
delimiter that commits a chip — so neither is a valid query shape and the
bare-identifier scan was already correct.
2026-07-30 02:41:09 +03:00
DumiFlex 7f07f99951 perf(resolver): refresh wildcard snapshots on focus instead of waiting out a timer
The 60s freshness window was the only thing invalidating cached snapshots, so
after editing a wildcard in the SPA you sat and waited for the canvas to
agree. The window was arbitrary — it existed because the canvas and the SPA
are separate pages with no shared invalidation event to subscribe to.

There IS an observable signal: the user coming back. They edit in the SPA
tab, switch to the ComfyUI tab, and expect the canvas to be current.
`markAllStale` now runs on `focus` (clicking between windows) and on
`visibilitychange` (tab switch), so the refresh happens exactly when they
look at it.

The mark is cheap and the refetch stays lazy — driven by whatever `ensure()`
the next reactive tick makes — so a focus event on a graph with no `@{}` refs
costs nothing. Only the freshness stamps are dropped, never the cached
values, so nothing flickers back to a raw uuid mid-refresh. Transient failure
tombstones clear too, since an explicit "check again" should retry rather
than wait out RETRY_TTL_MS; permanent 404s stay.

The 60s timer stays as a backstop for the case where focus never changes —
two visible windows on one desktop.
2026-07-28 17:27:04 +03:00
DumiFlex e35ebb25a7 feat(export): make Copy payload emit an importable envelope
Copy exists so a module can be shared WITHOUT walking the Export tab, which
means what lands on the clipboard has to be paste-able into Import. It was a
bare engine row, and `parsePayload` rejects that outright — it requires
`{schema_version, bundles, wildcards, fixed_values, combines, derivations,
constraints, categories}` and never gets as far as looking at the entity.

`buildCopyableRow` now emits a complete export envelope carrying the one
entity in its own bucket, with the siblings empty. Bundles (which have
`children` and no `type`) go to `bundles`. `schema_version` still comes from
the same `schemaVersionForPayload` the publish paths use, so a copied entity
and a published one can never disagree about their shape.

An unrecognised entity kind is now refused rather than copied: an envelope
with every bucket empty reads as "copy worked" and then imports nothing,
which is worse than a visible failure.

Scope: this carries the one entity, not its dependency closure. A wildcard
nesting `@{uuid}` refs still imports with those dangling, and the Import
tab`s missing-dependency UI reports them; shipping the closure stays the
Export tab`s job since it has the UI to show what is being dragged along.

Tests round-trip the copied JSON through the REAL `parsePayload` rather than
asserting a hand-written shape.
2026-07-28 17:22:46 +03:00
DumiFlex 693f7191d0 fix(theme): read ComfyUI dark marker from <html> as well as <body>
The extension rendered in light mode on top of a dark ComfyUI. Not a broken
light theme — it was STUCK in light.

ComfyUI moved the `dark-theme` class between frontend releases. Verified
against two live builds on this machine:

  1.45.21 -> <body class="litegraph grid dark-theme ...">, <html> bare
  1.47.10 -> <html class="dark-theme">, body no longer carries it

`hasDarkClass` only read `document.body`, so on any current build the check
returned false and `detectInitialTheme` reported "light" — the extension then
painted its light tokens onto ComfyUI`s dark chrome. Evaluated against the
live 1.47.10 DOM, the old predicate returns false where the new one returns
true.

Now checks EITHER element and observes both, so a theme switch is caught
whichever element the running frontend mutates and older builds keep working
unchanged. Light is still the genuine signal when neither carries the class.

Tests pin both eras plus the html-side switch.
2026-07-28 17:00:18 +03:00
DumiFlex 2896e11d26 fix(resolver): expire cached wildcard snapshots so the canvas stops drifting
The canvas and the SPA disagreed about the same derivation: the SPA reported
"1 of 11 options match" while the ComfyUI instance reported "0 of 10".

`ensure()` short-circuited on `cache.has(u)` and nothing but the test seam
ever cleared the map, so a SUCCESSFUL snapshot lived for the lifetime of the
page. Failures already had a 30s retry TTL; successes had none. The canvas
therefore kept whatever a wildcard looked like the first time it was
referenced — add an option in the SPA and the canvas reported the old count
until a full reload. The two surfaces are separate pages with no shared
invalidation event, so freshness has to be time-based.

Adds a 60s freshness window, tracked in a sibling `cachedAt` map so the
public `PreviewLookup` shape stays free of bookkeeping. The stale entry is
deliberately KEPT while the refresh is in flight, and kept even if the
refresh fails, so a resolved label never flickers back to a raw uuid. The
permanent-failure check moved ahead of the staleness check so a stale entry
whose refresh 404s cannot re-enter the queue on every 400ms reactive tick.

Also switches the SPA wildcard option value to `wrap`. It was a fixed 34px
single-line field with a horizontal scroll, so a long option value — the
paragraph-length ones users actually write — was effectively invisible.
2026-07-28 04:31:38 +03:00
DumiFlex dd793c536e fix(ui): three layout defects — seed rows, picker chips, playground banner
Seed-list modal: a locked row carries three fixed-width toggles plus a hard
178px seed input and a roll button. On the 486px modal that overran the panel
— the input clipped at the right edge and the roll button was pushed out of
view entirely. The value column now shrinks (`min-width: 0`, 178px as a max
rather than a fixed width), and a long derived seed ellipsis-clips instead of
shoving the DERIVED tag off the edge.

Module picker: the selected-module chip rendered its name as a bare text
node, so a long name like "Accent piece (color-neutral)" wrapped onto a
second line inside a chip fixed at 22px tall and spilled out of its own pill.
Wrapped in a span that ellipsis-clips, with the full name on hover.

Display playground banner: the header scrim was hardcoded to a near-black
rgba over the brand gradient. Under the light theme the band stayed dark
while `--wp-text` flipped to a dark ink — dark text on a dark banner. The
scrim now follows the theme, keeping the same 0.85 knock-back either way.
2026-07-28 04:20:00 +03:00
DumiFlex 6c4589315a fix(wildcard): tame oversized tag chips + match SPA probability precision
Two problems in the canvas wildcard modal, both worst on exactly the
well-authored wildcards the extension is for.

Option rows: a fully tagged option carries 8+ sub-categories (hue,
temperature, tone, saturation, several suitability flags). Wrapped inside the
96px CATEGORY column they stacked into eight lines, making ONE row taller
than the option list was worth. Shows the first three behind a `+N` pill that
expands per row — the tags stay reachable, they just stop dominating.

Tag pool: 40-odd pills pushed the option list, the thing the user came to
edit, most of a screen down. The section now collapses from the header, and
reports how many tags are ticked while collapsed. Starts open so the pool
stays discoverable.

Probability: the modal rendered `Math.round(p * 100)`, so with 30+ options
every entry below 0.5% read as a flat "0%" — a legitimately weighted option
looked impossible. Now delegates to the SPA`s `formatProbability`, which
already existed for exactly this reason, so both editors agree digit for
digit.
2026-07-28 04:09:58 +03:00
DumiFlex 3f6b856eec feat(categories): add an icon picker
The `icon` column has existed end-to-end — engine column, `_UPDATABLE_FIELDS`
in wp_api/categories.py, `CategoryRow.icon` in the TS types — with no UI to
set or display it, so every category rendered under the same generic bookmark.

Adds `IconPicker`, a teleported grid of curated PrimeIcons grouped by the
kinds of things people actually categorise prompt modules by (subject, style,
scene, structure, signals), plus a "No icon" reset. PrimeIcons ships ~300
glyphs; offering all of them would need its own search UI and most of the set
is chrome (chevrons, spinners, media transport) that means nothing as a
library label.

Glyph names are stored unprefixed ("palette"), matching what the column
already held. Wired into both the new-category form and the row editor, with
an Icon column in the table.
2026-07-28 03:58:52 +03:00
DumiFlex bbc7556af2 feat(inputs): let fixed-value and combine fields grow with their content
Users routinely paste an entire sentence into a value, and the fields showed
a sliver of it with no way to see the rest.

- Fixed values, ComfyUI modal: the value was a single-line `<input>`. Now an
  auto-growing `<textarea>` that wraps, sized to content and capped at 30vh
  before it scrolls. Enter is swallowed — a fixed value is one string and the
  engine has no meaning for an embedded newline — so it still behaves like the
  input it replaced.
- Fixed values, SPA: swaps the fixed 2-row `multiline` box for `wrap`, the
  existing grow-to-fit mode (one row minimum, 40vh cap, manual resize handle).
  A fixed height either wasted space or hid most of a long value.
- RichTextInput `--multi`: adds the 40vh cap + scroll that `--wrap` already
  had. Uncapped, pasting a paragraph grew the box to thousands of pixels and
  pushed every control below it off-screen.
- Combine editor: the output variable moved out of its 220px column beside the
  template to its own full-width row ABOVE it. That column squeezed the field
  that actually needs room to grow, and read as a footnote to the template
  rather than as the thing the combine produces.
2026-07-28 03:49:02 +03:00
DumiFlex 5064f084a3 feat(export): add cross-bucket search + $variable/option-count row detail
A 261-item library made the export picker a scroll-hunt, and because many
entities share a display name (five wildcards all called "Outfit"), scrolling
was the only way to find a specific one — and it still could not tell you
which was which.

Adds a search box matching name, short id, and the new row subtitle, plus a
`subtitle` on PickerRow rendering "$outfit · 31 options" — the two facts that
actually distinguish same-named modules. Mirrors what the canvas
ModulePickerModal rows already show.

Section counts and select-all read the VISIBLE rows so the header checkbox
describes what the user is looking at, but select-all leaves rows hidden by
the search untouched: silently dropping a filtered-out selection on a
"deselect all" click would lose work the user cannot see. The footer counter
still reports the true export size.
2026-07-28 03:37:51 +03:00
DumiFlex 67761fbba8 feat(var-picker): add type-to-filter to the Var-to-Int/Bool/Float dropdown
A long Context chain produces dozens of upstream vars and the picker menu was
an unfiltered scroll — the only dropdown in the extension without search.

Adds a sticky filter field that focuses on open, matching with or without the
leading `$` so typing `see` finds `$seed`. Enter picks the sole remaining
match (the common case after a few characters) and Escape closes without
changing the selection. An empty result now says so rather than rendering a
blank menu.
2026-07-28 03:27:46 +03:00
DumiFlex 630129bc32 feat(select): disambiguate same-labelled options with a meta column
A library routinely holds several wildcards under one display name — five
"Outfit" entries authored at different times — and the constraint source /
target dropdowns offered five identical rows with no way to tell which was
which.

Adds an optional `meta` to `SelectOption`, rendered dim and right-aligned,
and wires the constraint editor to pass "N opts · <short uuid>". The
type-to-filter now matches `meta` as well as `label`, so pasting a uuid jumps
straight to its row — without that the duplicates stay unreachable by typing,
since their labels are identical.

Also gives the option label `min-width: 0` + ellipsis so a long module name
clips inside the row instead of forcing the menu past its max-width and
pushing the meta and tick off the edge.
2026-07-28 03:23:42 +03:00
DumiFlex ba170939e8 feat(bundles): cap the expanded child list and summarise its kinds
A 66-child scene-composer bundle dumped every child inline on expand, so one
row buried the rest of the list.

Caps the rendered children at 8 behind a "Show all N" / "Show fewer" toggle,
and leads with a kind breakdown ("15 wildcards · 5 constraints", dominant
kind first) so a collapsed list still says what the bundle is MADE of rather
than only how many rows it has.

Expanded-in-full state is keyed by bundle id rather than a per-row flag, so
it survives re-sorting and re-filtering the list.
2026-07-28 03:13:47 +03:00
DumiFlex fd3c3805b6 feat(spa): add Report an issue + Discord to the sidebar
The Discord invite existed ONLY inside the community embed`s offline
fallback, so it was reachable exactly when the community failed to load and
invisible the rest of the time. There was no route to the issue tracker at
all.

Adds both to the Get Started section, with `GITHUB_NEW_ISSUE` joining the
link registry so a repo rename keeps touching one line. The issue link lands
on the chooser rather than a prefilled template — picking bug vs feature is
the reporter`s call, and a wrong prefill is more friction than none.

Also stamps `data-nav-id` on the external-anchor branch, which the button and
parent branches already carried.
2026-07-28 03:05:06 +03:00
DumiFlex 7cb2da2468 fix(chips): stop claiming every $var is "produced upstream"
The var-chip hover read `resolved ? "produced upstream" : "binds at
runtime"`, but for a var `resolved` is merely `name.length > 0` — deliberately
permissive in `atomIsResolved` so a legitimate runtime-bound var never renders
as a broken red chip. The upshot: EVERY var claimed an upstream producer.
Meaningless in the SPA, which has no graph at all, and unverified on the
canvas.

Adds an explicit `inScope` prop bound from the host`s suggestion pool
(upstream + siblings on the canvas, the library catalog in the SPA) and
reworded to "in scope" / "binds at runtime" — surface-neutral, and it claims
membership rather than production, which is the only thing the chip can
actually know.

`resolved` keeps its permissive meaning so chip STYLING is untouched.
`inScope` defaults to false, so a caller that passes no pool makes no claim
and falls back to "binds at runtime", which is always true.
2026-07-28 02:59:11 +03:00
DumiFlex aa1ae66821 fix(export): stamp schema_version on the copied engine row
Copy Payload wrote the bare engine row, which says nothing about which
payload shape it is in — and the shape is what decides whether a consumer
can read it. Publish already stamped the version (buildPublishBody, and the
publishToCommunity hash), so a copied row and the same row published to the
community carried different information about themselves.

Adds `buildCopyableRow`, leading the JSON with `schema_version` derived from
the SAME `schemaVersionForPayload` the publish paths use, so the two can
never disagree — including the feature bumps (SP2b range syntax, SP3
non-default constraint reach).

Note this is a single engine ROW, not an import envelope: `parsePayload`
wants `{schema_version, wildcards: [], bundles: [], categories: [], …}`, so
the clipboard JSON remains for inspection, sharing, and the community
publish form rather than for pasting into the Import tab.
2026-07-28 02:55:05 +03:00
DumiFlex f922a872cc feat(ui): make picker and reach-pick rows clickable, not just their checkboxes
Selecting a dozen entities out of a 261-item library meant a dozen precise
clicks on an 18px checkbox. Worse, the constraint reach pick rows already
carried `cursor: pointer`, so they LOOKED clickable and silently were not.

Both surfaces now toggle on a click anywhere in the row. Clicks originating
inside an interactive descendant are ignored, so the control and the row
never double-toggle and cancel each other out — the picker excludes buttons,
inputs, the checkbox wrapper and `.wp-id` (whose `cursor: copy` advertises a
different action), and the reach list excludes `.wp-check`.

Keyboard behaviour is deliberately unchanged on both: the checkbox stays the
single focusable control per row, so this adds no second tab stop for
keyboard or screen-reader users.

Replaces the TargetReachSection comment that documented the old
checkbox-only behaviour as intentional.
2026-07-28 02:44:40 +03:00
DumiFlex 23486ff1ef fix(injector): teleport the insert-ref menu and dismiss it on outside click
The menu was `position: absolute` inside the binding modal, so the modal`s
scroll container CLIPPED it — open it from a row near the bottom and the list
vanished below the fold. It also had no outside-click dismissal at all: once
open, the only exits were picking an item or closing the whole modal.

Teleports the menu to <body> with `position: fixed` viewport coords measured
off the trigger button, flipping above when there is no room below, matching
the strategy RichTextInput`s autocomplete popover already uses. Adds a
capture-phase mousedown listener that closes on any click outside both the
menu and its button, keeps the menu positioned across scroll/resize, and
tears every listener down on close and unmount.

Escape now dismisses the menu before falling through to cancel, so a stray
open dropdown can no longer cost the user their whole draft. The teleported
node mirrors the host`s resolved theme class, since it escapes the
`.wp-theme-*` scope the tokens hang off.

Tests query the teleported node through the document and unmount their
wrappers between cases — clearing document.body under a live component
detaches Vue`s teleport anchor and the next patch throws on a null parent.
2026-07-28 02:37:18 +03:00
DumiFlex 2fa4618ef9 fix(autocomplete): make the suggestion list actually scrollable
The popover capped matches at 8 rows. With 100+ wildcards sharing near
identical display names, a query like `pose` silently truncated to the first
8 and the rest were simply unreachable — and because 8 rows never overflowed
the popover`s 240px max-height, no scrollbar ever appeared. That reads to the
user as "the scrollbar does not work".

Raises the cap to 50 in both RichTextInput and VarAutocompleteInput so the
list genuinely overflows and `overflow-y: auto` earns its keep, and scrolls
the keyboard-selected row into view on every `acActive` change. Without the
latter, ArrowDown past the last visible row moved the selection but left the
popover scrolled at the top, so the highlight vanished and the list looked
frozen.
2026-07-28 02:33:13 +03:00
DumiFlex 50211e510b fix(autocomplete): keep the $/@ popover alive on partial brace refs
Typing the `{` that the canonical `@{uuid#name}` syntax calls for killed the
suggestion popover outright, and it only came back after deleting the entire
run — the reported "dropdowns break if you type something that cannot be
found and you need to delete the whole text and start typing again".

`probeAutocomplete` only scanned back through `[a-zA-Z0-9_]`, so `{`, and
every space or dash inside a display name ("Pose pool — presenting"), ended
the scan on a non-trigger char and returned null. The INSERT half of the same
feature already understood the form: `insertChipAtCursor`s cut regex allows
`\{[0-9a-fA-F:,_ -]*\}?`. The two halves simply disagreed about what a
half-typed ref looks like.

Probes the partial brace form first, before the bare-identifier scan. A `{`
not preceded by `@` stays an inline brace block (`{a|b|c}`), and a `}` already
present before the caret means the ref is complete so the popover stays shut.
2026-07-28 02:29:06 +03:00
DumiFlex d7a72d7434 fix(rich-text): remount contenteditable host when browser destroys its children
Alt-tabbing mid-token in the ComfyUI edit modal froze the page with
`DOMException: Node.insertBefore: Child to insert before is not a child of
this node`, followed by a cascade of null derefs (`emitsOptions of null`,
`parentNode of null`) that left the editor dead until a page refresh.

Root cause: the host is `contenteditable`, so the BROWSER owns its child
nodes and may replace them wholesale — select-all + retype, IME commit,
undo, some paste paths. Vue then holds detached `el` pointers in its child
vnodes. Blur fires a full re-parse via `onHostBlur`, and patching the new
atom structure over the wreckage derefs a dead node.

The existing `renderEpoch` rebuild does NOT cover this — verified against a
repro probe: bumping the v-for key still walks every stale `el` during
teardown and throws on `nextSibling` of null. The only safe repair is
discarding the host ELEMENT vnode, which Vue unmounts with a single
`hostRemove(el)` on a node that is still attached, tearing children down
with `doRemove: false` so no stale child is ever touched.

Adds `hostDomIsStale()` (live `[data-atom-index]` count below
`atoms.value.length`) plus a `hostEpoch` key on the host element. The check
is deliberately `<` and not `!==`: EXTRA top-level nodes are the normal
typing case and must not remount, which would eat the caret mid-keystroke.

Covers both directions with regression tests — the destroyed-children crash
and the orphan-text-node non-regression.
2026-07-28 02:12:07 +03:00
DumiFlex 7d443ba00b fix(context): guard bundle-snapshot reset on unlinked bundles
`resetChildToBundleSnapshot` fetched `api.bundles.get(bundle.library_id)`
without checking the bundle is actually linked. An unlinked or broken-link
BundleInstance carries an empty `library_id`, so the request built
`/wp/api/bundles/`, whose trailing slash normalizes to the collection
route — surfacing as `Reset failed: No such API route: /wp/api/bundles`,
which reads like a missing endpoint rather than a missing link.

Adds the same up-front guard its sibling `resetBundleToLibrary` already
had, and routes both catch blocks through `bundleResetErrorMessage` so a
404 (library row deleted or re-imported under a new uuid) names re-linking
as the recovery instead of leaking raw API text.
2026-07-28 02:07:57 +03:00
semantic-release-bot 1787b6041c chore(release): 2.11.0 [skip ci] 2026-07-24 02:07:07 +00:00
Dumitru Picalau fd4c501871 Merge pull request #11 from DumiFlex/fix/held-constraint-registration
fix(engine): hold + constraint correctness, and library re-link tooling
2026-07-24 04:06:31 +02:00
DumiFlex 9fb653f7ee docs(release-notes): summarise held-module fix + library re-link tooling 2026-07-24 04:56:13 +03:00
DumiFlex 3f45b26396 chore(size): raise total bundle budget to 461 KB
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.
2026-07-24 04:48:49 +03:00
DumiFlex 44d0ca2261 fix(search): match modules, bundles and templates by id as well as name
Every search box was name-only, so pasting an 8-hex uuid — the one identifier
that is unambiguous when two entries share a display name — returned nothing.

Server side: the module, bundle and template `_build_where` clauses now match
`(name LIKE ? OR id LIKE ?)`. Client side: the remap-ref, bundle-reattach and
constraint-reattach pickers filter on uuid as well as name, which is the
reliable way to pick the right target among same-named candidates.
2026-07-24 04:43:59 +03:00
DumiFlex 699dedade0 feat(chips): hover card on nested-ref and variable chips
Hovering a `@{uuid}` chip now shows what it actually resolves to: the uuid,
the sub-category filter read back in plain language, and "N of M options
match" computed LIVE against the library via preview-resolver (extended with
per-option tag sets) rather than from a stale embedded copy — so the count
moves when the library changes. Variable chips show their binding state.

The popover teleports from INSIDE the chip span so the component keeps a
single root (a second root silently broke `wrapper.classes()` in every
existing RefChip test). "Not in library" is gated on the resolver actually
missing the uuid — constraints have no option tag sets, and keying off that
made valid entries claim they were missing.
2026-07-24 04:39:13 +03:00
DumiFlex a64e56d083 fix(chips): render nested refs + vars in derivation and exception surfaces
Nested `@{uuid}` refs and `$vars` inside `{a|b}` braces rendered as raw text
on the derivation rule-value and constraint-exception surfaces. Root cause was
hand-rolled `tokenizeRich` renderers dumping `dp-brace` verbatim — the shared
`atomicEditorModel.parse` already decomposes those, so both surfaces now
delegate to RichTextPreview instead of re-tokenising.

Exception "extras" also regain a real picker: a Select of concrete option
values with chipped #label/#option slots, rather than free text.

Select.vue ships self-contained styles for this: its `.wp-select*` rules lived
only in the manager-global tokens.css, which is NOT in the canvas bundle, so
on canvas the control rendered unstyled with its menu behind the modal. Any
manager UI component reused on canvas needs its own styles. Also pins rich
content to a single line inside the trigger/options, gives the SPA exception
table a fixed layout so nowrap cells can't overflow, and isolates the matrix
grid's stacking context so its sticky headers stop painting over the dock.
2026-07-24 04:34:24 +03:00
DumiFlex 5a83e61fd1 feat(editors): show module identity in edit-modal headers + wrap long values
Two debugging aids for the instance edit modals:

- InstanceIdChip: a click-to-copy monospace chip in every per-kind modal
  header showing the library uuid (the id that @{} refs, constraint
  source/target and library rows all match on), with the per-instance _uid
  in the tooltip. Makes drift/linkage problems traceable at a glance instead
  of requiring a workflow-JSON dig.
- RichTextInput gains a `wrap` prop so single-line surfaces wrap + auto-grow
  instead of clipping. Applied to the derivation rule override boxes, whose
  values were unreadable once they exceeded the field width.
2026-07-24 04:29:40 +03:00
DumiFlex c268e75c20 feat(import): surface content duplicates as a resolvable conflict row (D3b)
Completes D3b. The mechanism landed in 5dbe7c3c but nothing could produce a
`link` decision, because a content duplicate raises NO uuid collision and so
never reached ConflictModal.

- findLinkableDuplicates: the subset worth offering a link for — content
  matches an existing row under a DIFFERENT uuid AND the incoming id isn't
  itself a collision (that's the batch-conflict path's question).
- New per-item issue kind `content-duplicate` (+ ContentDuplicateDetail),
  built in ImportExport.buildPerItemIssues from the live library rows.
- ConflictModal renders it with a dim IN LIBRARY badge, names the existing
  entry, and offers "Link to existing" / "Import anyway". Never auto-applied:
  the row counts as unresolved until the user chooses.
- PerItemDecision gains kind "link" + target_id. partitionSelection drops a
  linked row like skip; runCommit folds old->target into the same remap table
  the renames use, so every @{} ref + constraint source/target follows to the
  existing entry instead of dangling on a uuid that never lands.
2026-07-24 04:19:47 +03:00
DumiFlex e63be54081 feat(import): link-to-existing decision for content duplicates (D3b core)
An incoming entity whose uuid does NOT collide still installs as a brand-new
row even when byte-identical content already exists under a DIFFERENT uuid.
Re-importing an iterated pack that way multiplies duplicate rows and splits
references across two identical entries.

Adds the mechanism:
- findContentDuplicates (pure): maps incoming id -> existing library id when
  EXACTLY ONE same-kind row shares the moduleFingerprint under a different
  uuid. Zero matches = genuinely new; 2+ = ambiguous, left to the user. A
  match at the SAME id is an ordinary collision classifyOne already handles.
- CollisionDecision gains { kind: "link", target_id }. applyCollisionDecisions
  drops the incoming entity and records old->target in the SAME renameMap the
  rename path uses, so the existing walkRemap pass re-points every @{oldId}
  ref + constraint source/target at the row that's already there.

Never automatic and never overwrites — import still defaults to create-as-new
(spec decision #2). Additive: with no producer of a `link` decision yet, every
existing path is unchanged. Surfacing it in the ImportPicker (content
duplicates raise no uuid collision, so they never reach ConflictModal) is the
remaining last-mile step.
2026-07-24 04:03:23 +03:00
DumiFlex ecebd55654 feat(relink): content-aware library re-link for detached bundle instances
Bundle-side sibling of the module re-link. A workflow BundleInstance is
detached when its library_id is absent from the polled bundleHashes map (a
re-import minted a fresh uuid). Its `inserted_at_hash` IS the library bundle's
payload_hash at insert time, so a content-identical library bundle under a
DIFFERENT uuid matches on exactly that — letting the bundle side reuse the
same pure findRelinkCandidates/autoRelinkTarget helpers as modules.

PushBundleToLibraryModal gains the re-link section (auto-picks a sole
identical match, else a ranked picker searchable by name or id, with
identical vs "content differs" badges) and emits mode:"relink".
ContextWidget.onBundlePushSaved folds "relink" into the existing rebind
branch, swapping library_id + inserted_at_hash + name and re-snapping the
fingerprint. No write happens — it points at an entry that already exists.
2026-07-24 03:38:10 +03:00
DumiFlex 78297c4179 fix(bundle): never let "Reset to library snapshot" silently empty a bundle
resetBundleToLibrary fetched the library snapshot and replaced the live
children unconditionally. When the instance's library_id was empty (an
unlinked/detached bundle) or the fetch resolved to an empty/mis-shaped
children array, it committed an EMPTY replacement — wiping the bundle's
modules with no warning (reported as "reset gave me an empty wildcard").

Two invariant guards, before the destructive commit:
- refuse when the instance has no library_id (not linked) — tell the user to
  re-link first, instead of fetching an empty snapshot;
- refuse when the fetched snapshot has no children (broken link / wrong shape)
  — abort rather than wipe the live children.

Both surface an error toast and leave the bundle untouched. Valid reset (a
linked bundle with a populated snapshot) is unchanged.
2026-07-24 00:17:36 +03:00
DumiFlex bb5f6b8658 fix(linkage): surface drift for a library-uuid module with an empty local hash
matchStateOf bailed on any falsy payload_hash, so a workflow module whose uuid
IS in the live library but whose local payload_hash is "" (a malformed/legacy
copy, or one a builder emitted with payload_hash: "") was verdict-less — no
drift dot, no "Edit library entry in SPA", and re-link couldn't fire either
(isLibraryMissing needs a truthy hash). The library link was never lost, only
the local hash was blank.

Now: when the uuid is present in libraryHashes, an empty local hash reads as
drift (conflict) so the existing bulk "refresh drifted" button relinks it in
one click. A genuinely inline module (fresh uuid, not in the library) stays
verdict-less exactly as before. All truthy-hash paths (missing/drift/silent-
skip/type-conflict) are unchanged, so D3a re-link's detached detection is intact.
2026-07-23 23:52:52 +03:00
DumiFlex a059b48635 feat(relink): content-aware library re-link for detached module rows
Surfaces a re-link path in PushToLibraryModal when a workflow module is
detached (library-tracked but its uuid is absent from the live library — a
re-import minted a fresh id) AND a content-identical library row exists under
a different uuid. Picks the sole identical row automatically, else shows a
ranked picker (identical vs "content differs", searchable by name or id).

Confirming emits mode:"relink"; both save paths apply it:
- right-click -> ContextWidget.onPushSaved: applyRelink swaps the row id +
  payload_hash and walks every module so sibling @{} refs + constraint
  source/target follow the library uuid.
- edit-modal -> ModuleEditModal stamps _relinkFrom (old id) before the swap;
  ContextWidget.saveEditedModule runs the same ref remap once committed.

Import stays create-as-new (no install.ts change) — re-link is the reconnect
path, never a silent overwrite. Bundle re-link is a separate follow-on.
2026-07-23 23:05:01 +03:00
DumiFlex 9ef1fb698a feat(relink): content-aware re-link match + apply helpers (pure)
Two pure helpers for D3a module library re-linking:

- relink-match: findRelinkCandidates ranks live-library rows against a
  detached draft by (type, payload_hash) content identity, then (type, name);
  autoRelinkTarget yields the sole content-identical row or null (ambiguous /
  none). Never auto-links on name alone.
- relink-apply: applyRelink swaps the detached instance's own id + payload_hash
  and walkRemaps every module's payload + instance so nested @{uuid} refs and
  constraint source/target follow to the new uuid. Reuses walkRemap verbatim.
2026-07-23 22:44:28 +03:00
DumiFlex 1b32a8a110 fix(engine): carry held modules' frame-0 constraint state to later frames
A held wildcard copies its frozen value from the pipeline hold base pass and
early-returns before _record_pick, so its frame-0 pick never landed in the real
ctx. Constraints keyed on a held source then mis-fired (spurious source_missing
/ never_applied warnings), and a cross-node constraint whose source is held left
its held target UNCONSTRAINED across frames (held outfit drifted: kimono at
frame 0, unconstrained pick on frames 2+).

Fix: after the per-node hold base pass, merge the base pass's __wp_picks__ (for
held modules) + __wp_constraint_hits__ into the real ctx, before the module
loop. The held source is then registered for this node's constraints and the
registration flows downstream into the next node's base pass, keeping cross-node
held targets constrained. Value-freeze path untouched.
2026-07-23 18:11:53 +03:00
semantic-release-bot 31b011b8fb chore(release): 2.10.2 [skip ci] 2026-07-21 22:16:47 +00:00
Dumitru Picalau a2e93fe7e4 Merge pull request #10 from DumiFlex/fix/updater-downgrade
fix: updater downgrade + pill reactivity + readable release notes
2026-07-22 00:16:09 +02:00
DumiFlex 1e42f9b5aa fix(manager): prevent updater downgrade, fix pill reactivity + release notes
Four fixes to the in-app updater shipped in 2.10.0:

- DOWNGRADE (critical): Update Now sent ComfyUI Manager selected_version:'latest' + mode:'cache', so 'latest' resolved against Manager's STALE cached catalog and could install an OLDER version over a newer one. Now pins the exact target version the check found (e.g. 2.10.1), uses mode:'remote' for a fresh catalog, and hard-guards against installing anything not strictly newer than what's running.
- Check Now now lights the topbar pill immediately. useReleaseCheck kept per-consumer refs, so a manual check in Settings never reached the topbar until reload; the reactive state is now a module-level singleton shared by every consumer.
- Release notes render properly: the renderer unwraps <details>/<summary>/<b> into markdown instead of showing literal tags (raw HTML still escaped).
- Changelog is human-readable: .releaserc.js now prepends a curated, plain-language highlights section (docs/release-notes/next.md) above the collapsed per-commit list, cleared automatically on each release.
2026-07-22 01:11:29 +03:00
semantic-release-bot 67199b5dba chore(release): 2.10.1 [skip ci] 2026-07-21 18:38:03 +00:00
Dumitru Picalau 74c7331b7e Merge pull request #9 from DumiFlex/fix/subcat-rename-validation
fix: validate sub-category & variable names on rename (#7)
2026-07-21 20:37:20 +02:00
DumiFlex 00b735d32f fix(manager): validate names on rename via shared validators (#7)
Sub-category rename hand-rolled a weaker check than the add path, so a name with a space (or other invalid char) slipped through the cascade rename dialog until save. Combine output-var renames weren't validated at all. Root cause: each name-entry surface reached for its own validator/regex and they drifted.

Add a single canonical validator module (src/manager/validation/names.ts) exposing validateSubcatName (re-exported from the engine-parity subcatFilter), validateRefGrammarName, and validateVariableName/isValidVariableName. Route every name-entry site through it: subcategory add+rename, wildcard/module display-name ref-grammar check (was triplicated inline), combine output_var save+rename, fixed-value var names, var binding, and validateModule. Add + rename now share the exact same function and cannot diverge again.
2026-07-21 20:56:48 +03:00
semantic-release-bot a1fe6956d1 chore(release): 2.10.0 [skip ci] 2026-07-21 17:31:59 +00:00
Dumitru Picalau fe2423f5b4 Merge pull request #8 from DumiFlex/feat/manager-qol
Manager QOL backlog + loop bypass frames + in-app update panel
2026-07-21 19:31:16 +02:00
DumiFlex 6c20f53cc6 feat(manager): add Updates settings card with check-on-launch + check now
Harden applyLatest against malformed/legacy cache blobs and a missing __APP_VERSION__ so a bad cache degrades to no-update instead of throwing; mock useReleaseCheck in the pre-existing Settings suite.
2026-07-21 20:19:31 +03:00
DumiFlex 936a13913e feat(manager): open UpdateDialog from the topbar update pill 2026-07-21 20:03:49 +03:00
DumiFlex 290e7a81f5 feat(manager): add UpdateDialog with changelog + Manager-driven update 2026-07-21 19:57:37 +03:00
DumiFlex 327e7cc762 feat(manager): add ComfyUI Manager update composable 2026-07-21 17:21:59 +03:00
DumiFlex 830776efd1 feat(manager): add safe markdown renderer for release notes 2026-07-21 17:17:05 +03:00
DumiFlex bdcf85ccd4 feat(manager): rework release check to launch-once + manual, expose release notes 2026-07-21 17:12:23 +03:00
DumiFlex d42b7ca1d8 feat(manager): add checkOnLaunch update preference to uiStore 2026-07-21 17:05:52 +03:00
DumiFlex fcde3b31d6 feat(manager): per-rule branch collapse + collapse rules/branches by default
- Each rule card gets inline collapse-all / expand-all buttons for its own
  conditions (IF/ELIF/ELSE branches), shown while the card is open.
- Branches now start COLLAPSED on open (newly-added branches still open
  expanded for immediate editing), and rules are collapsed by default
  regardless of count — so opening a derivation shows a compact overview.
2026-07-21 15:29:38 +03:00
DumiFlex ea4ed059c3 feat(loop): purple wash + dashed bar on bypassed seed rows 2026-07-21 15:13:52 +03:00
DumiFlex f670c3db76 feat(loop): mirror the loop's bypassed frames in the seed list UI
The WP_SeedList already emits only kept frames; now its widget shows WHICH.
The host glue reads the wired loop's bypass_frames (when Override count from
loop is on) and passes them to the widget: a "N bypassed" badge on the seeds
button + read-only dimmed/struck rows in the per-iteration modal (no toggle —
bypass is owned by the loop). Adds a `bypassReadonly` mode to the shared
SeedListModal so the same modal is interactive for the loop, read-only here.
2026-07-21 15:07:21 +03:00
DumiFlex af95f08ef4 fix(loop): seed list mirrors the loop's bypassed frames
A WP_SeedList wired to a bypassing loop still emitted the full `count`
seeds, so the sampler ran 18 times against the loop's 3 kept prompts.
WP_ContextLoop now publishes `kept_indices` (the 0-based frames it actually
emits) in its loop_config side output; WP_SeedList, when taking its count
from the loop (override_count), emits one seed per kept frame — drawn from
the full series so per-frame locks still land. Backward compatible: a
loop_config without kept_indices (or a full/empty list) leaves the emit
unchanged.
2026-07-21 14:49:26 +03:00
DumiFlex 70283ce4d3 feat(loop): dashed border on bypassed edit-frame chips 2026-07-21 14:40:32 +03:00
DumiFlex bab87cd3c2 fix(loop): parse parity + edge coverage from final review
Addresses the minor findings from the whole-branch review:
- TS bypass_frames parse now type-guards before accepting (no Number()
  coercion), so it drops true/"3"/null identically to the Python parser
  (the spec requires the two to agree on corrupt input).
- Add execute tests for bypassing frame 0 and for an out-of-range index
  re-applying once count grows past it (both spec-listed behaviors).
- Document seedAt's i>=1 precondition; pass :previous to inactive bypass
  rows so their "lock previous" button matches the active rows.
2026-07-21 03:06:20 +03:00
DumiFlex 7545ade87d feat(loop): wire per-frame bypass through the loop widget 2026-07-21 02:55:40 +03:00
DumiFlex 651ca65228 feat(loop): opt-in bypass column in the per-iteration modal 2026-07-21 02:48:52 +03:00
DumiFlex d524d142b5 feat(loop): add optional bypass toggle to seed-lock row 2026-07-21 02:43:13 +03:00
DumiFlex da2239dca5 feat(loop): mirror bypass_frames in the ts config type
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.
2026-07-21 02:38:07 +03:00
DumiFlex 9d372b1d8f feat(loop): emit only non-bypassed frames with pinned identity 2026-07-21 02:28:24 +03:00
DumiFlex 7aab09d6bc feat(loop): parse bypass_frames in the loop config 2026-07-21 02:21:09 +03:00
DumiFlex 2295ce0f81 fix(manager): count templates in the import loaded-bar total
`payloadEntityCount` summed seven buckets but omitted templates, so the
Import tab's "Loaded · N entities" bar under-reported any payload containing
templates (a template-only import read "0 entities"). Add the templates
bucket to the sum.
2026-07-21 01:42:08 +03:00
DumiFlex 8ea15719bd feat(manager): per-branch collapse + collapse-all for derivation rules (#9)
Building on the existing rule-card collapse (rules default-collapsed when
>1):

- Each IF / ELIF / ELSE branch now has its own collapse chevron. Collapsing a
  branch hides its condition/action editors and shows a one-line
  `$cond → $target` peek in the branch head, so a rule with many branches
  stays scannable.
- The Rules card gains Collapse-all / Expand-all buttons (shown when >1
  rule). They broadcast a `collapseCommand` (nonce + state) that every rule
  card adopts; individual cards can still be toggled afterwards.

Verified live: per-branch chevron hides the body + shows the peek;
Collapse-all / Expand-all toggle every rule card.
2026-07-21 00:21:50 +03:00
DumiFlex d03936478c feat(manager): move Target reach above the constraint rule matrix (#10a)
Section order is now Identity → Wildcards → Target reach → Rule matrix →
Exceptions. The "how many downstream targets does this constraint cover"
decision now reads before the per-pair rule grid, matching the top-down
authoring flow (pick wildcards → decide reach → fill the matrix).
2026-07-20 18:48:53 +03:00
DumiFlex 8dcef0f3d9 fix(manager): resolve inline multi-pick {N-M$$sep$$…} in the test runner
The JS resolver's `expandInlineChoices` only understood single-pick
`{a|b|c}`. A multi-pick block like `{1-2$$, $$smirk|smug|half-closed eyes}`
matched the brace regex but got split naively on `|`, so the `1-2$$, $$`
prefix stuck to the first branch and leaked into renders as raw
`1-2$$, $$smirk` (the Test Runner samples + histogram).

Mirror the engine's `_resolve_multi_pick` (engine/syntax/resolve.py):
detect the `N$$sep$$` / `N-M~$$sep$$` prefix, parse the count range, the
`~` independent flag (draw WITH replacement vs unique), and the separator;
then pick k weighted branches (via a `pickKUnique` port of the engine's
`pick_k_unique`) and join by sep. A fixed count skips the RNG draw, matching
the engine's seed behaviour. Single-pick `{a|b|c}` is unchanged.

Tests cover fixed/range counts, empty separator, independent-with-repeats,
and the exact user repro string.
2026-07-20 18:41:36 +03:00
DumiFlex 8912937a9a feat(manager): per-kind quick-select presets + import-tab mirror (#1)
Export quick-select bar now has a preset per kind (Bundles, Wildcards, Fixed
values, Combines, Derivations, Constraints, Categories, Templates) alongside
Full library + Favorites, via a generic `presetKindOnly` (replaces the lone
"Wildcards only"). Each per-kind button is disabled when the library has none
of that kind.

The Import picker now mirrors the bar after a payload loads from file/paste:
"All" + the same per-kind presets, operating on the loaded payload's
entities (disabled per empty bucket). The `.wp-preset-btn` atom moved to
tokens.css so both bars share it; each bar keeps its own layout wrapper.

Verified live: Export "Wildcards" → 56 of 132; Import "Wildcards" → 3 of 5,
"All" → 5 of 5, with empty-kind buttons correctly disabled on both.
2026-07-20 18:14:19 +03:00
DumiFlex d4e6d0c112 feat(manager): drag-and-drop file import (#5)
The Import tab now accepts a dropped .json export file, not just a file-pick
or paste. The pick/paste buttons sit inside a dashed dropzone that advertises
"or drag & drop a .json export file here"; dragging a file over it lights the
frame accent and switches the hint to "Drop to import"; dropping reads the
file and runs it through the same parse pipeline as the file picker (a shared
`readAndParse` helper). Non-JSON drops fall through to the existing inline
"Invalid payload" error, so there's no silent failure.

Verified live: the dropzone highlights on drag-over; unit tests cover the
drop→payload-ready path and the highlight toggle.
2026-07-20 17:57:48 +03:00
DumiFlex 696711dca1 fix(manager): fresh dates for imported adds (#12) + live counts after import (#7)
#12 — imported modules kept the exporter's original creation date (shown
"1mo old" instead of just-imported). The engine importer's insert paths use
`entity.get("created_at", now)`, so a client-supplied date is copied
verbatim. The rename path already stripped lifecycle fields for this reason;
extract that into a shared `stripLifecycle` helper and apply it to the `add`
path too, so the server stamps a fresh created_at (identity/`id` preserved).

#7 — the sidebar template + category count badges went stale after an
import, because the post-commit / post-undo refresh in ImportExport only
reloaded the module + bundle stores. Refresh `templateStore.fetchCatalog()`
and `categoryStore.fetchAll()` alongside them so the counts update live.
2026-07-20 17:31:03 +03:00
DumiFlex b54302d546 fix(manager): detect bundle collisions on import instead of blind-adding (#6)
Bundles were excluded from import collision detection on the premise that a
`bundle-fingerprint.ts` MOD-detection flow handled them downstream — but that
file was never built. So an imported bundle whose id already existed:
  - never appeared in the conflict modal (missing from the conflict count),
  - fell through to a blind `add` decision,
  - was rejected by the server with 400 "id collision on add",
  - which failed the whole commit and the bundles never imported.

Bundles live in the same id→snapshot_fingerprint library map as modules, so
route them through the SAME `detectCollisions`: identical → silent-skip,
present-but-changed / no library fingerprint → conflict / exists-unknown,
both of which surface in the conflict modal for Replace / Rename / Skip —
exactly like a colliding module. Fixed in `buildSelectedEntities`
(the commit orchestrator) and mirrored in `ImportPicker`'s inline badges so
an existing bundle reads MODIFIED / EXISTING / DUPLICATE / NEW consistently.
Removed the stale `bundle-fingerprint.ts` references from the comments.

Verified live: re-importing an existing bundle now opens the conflict modal
("1 existing" → Skip / Replace / Import as new) instead of 400-ing.
2026-07-20 17:15:42 +03:00
DumiFlex 0bd4382cf0 fix(manager): sticky-header subpixel bleed + confusing weight validation
Two polish fixes flagged before starting Batch C:

- Sticky header bleed: the editor scroll container's top can land on a
  fractional device pixel (measured 152.39 at dpr 1), so pinning the sticky
  region exactly at top:0 left a sub-pixel sliver above the opaque header
  through which the scrolled content behind showed as a faint ghost line.
  Overlap the scroll edge by 1px (top:-1px, clipped by the container overflow
  and absorbed by the header's 12px top padding) so the bg covers the seam at
  any DPR; the sticky <thead> tracks the same -1px to stay flush.

- Confusing weight tooltip: the option-weight input carried `min="0.01"
  step="0.1"`, so a default weight of 1 is off the native step grid and the
  browser popped up "Please select a valid value. The two nearest valid
  values are 0.91 and 1.01." The native step is validation-only here — arrow
  keys, the wheel, and the custom chevrons all route through bump(), which
  reads props.step itself. Input now emits native step="any" for number
  inputs (no grid validation), while bump() keeps stepping by props.step.
2026-07-20 16:43:32 +03:00
DumiFlex ed10b881b7 feat(manager): bulk select + delete for exceptions + derivation rules (#11)
Extends the shared useBulkSelection + BulkDeleteToolbar pattern to the last
two editors:

- ConstraintEditor exceptions (a table): a "Bulk edit" toggle adds a
  checkbox column + select-all to the editable exceptions table and a delete
  toolbar in the sticky subheader. Exceptions carry no persisted id, so
  selection keys off a WeakMap-backed transient uid (stable across edits and
  single-row removals, never written to the payload).

- DerivationEditor rules (a card list): the checkbox rides to the left of
  each rule card; select-all + the delete toolbar sit in the sticky
  subheader. Rules already have a stable id, so selection keys off it.

The bulk-edit toggle stays reachable while bulk mode is active even after
every row is deleted, so the user can always leave bulk mode.
2026-07-20 10:51:23 +03:00
DumiFlex cbf102b8a0 feat(manager): bulk select + delete for fixed-values (#11)
Adds a shared `useBulkSelection` composable + lean `BulkDeleteToolbar`
(count + Delete + Clear) for editors whose rows have nothing to bulk-set,
then wires them into FixedEditor: a "Bulk edit" toggle reveals a per-row
checkbox column + select-all, and the sticky subheader shows the delete
toolbar once ≥1 row is picked. Deletion is in-memory (marks the editor
dirty; committed on Save), and the existing bulk-ADD panel is untouched.
2026-07-20 10:36:34 +03:00
DumiFlex be70a5f149 feat(manager): relevant + colour-coded bulk sub-category menus
Bulk-edit Apply/Remove sub-category menus (#3, #4):
- Apply now lists only tags that would change something — every tag EXCEPT
  those already on all selected rows (commonTags).
- Remove now lists only tags the selection actually carries (presentTags,
  the union across selected rows); empty selection-tags → empty menu.
- Menu items show an axis-hue swatch via the shared `tagHue`, so a
  sub-category reads with the same colour here as in the pills / row chips.

Dropdown scroll-trap (#3):
- `overscroll-behavior: contain` on the menu list stops wheel scroll at its
  top/bottom edge from chaining to the editor body ("nudged the page").
- Scrolling the page behind an open menu now closes it (capture-phase scroll
  listener that ignores scrolls originating inside the menu's own list).

WildcardEditor computes commonSelectedTags / presentSelectedTags /
selectedTagHues and passes them to SelectionToolbar. New props are optional
and fall back to the old all-tags behaviour, so nothing else breaks.
2026-07-20 10:23:35 +03:00
DumiFlex 7d2bd63912 test(engine): un-skip pipeline trace tests after handler migration
The four tests skipped pending the tasks 15-17 handler migration now assert
the current dispatcher-based trace shape:
- writes always carry an `overwrite` flag (first write = False)
- disabled modules emit a `skipped_disabled` trace row (writing nothing)
- unknown module types emit a `skipped_unknown_type` row + a warning log
- rng is threaded via `ctx["__wp_rng__"]` (HANDLERS dict removed), seeded
  deterministically from the `seed` arg
2026-07-20 10:09:50 +03:00
DumiFlex 6f69304796 fix(manager): keep bulk-edit toolbar stuck with sticky card header
Batch A stuck only the card header; the bulk-edit toolbar (BulkAddPanel +
SelectionToolbar) lived below it in the card body as a separate flow
element, so scrolling a long option list left it behind. Wrap the header
and a new `subheader` slot in one `.wp-card__sticky` region and stick that
region as a unit. Card.vue now measures the whole region for
`--wp-card-header-h`, so the sticky <thead> auto-offsets below it as the
toolbar appears/disappears. Move the `.wpc-bulk-controls` block into the
subheader slot in WildcardEditor + FixedEditor.
2026-07-20 10:00:40 +03:00
DumiFlex 6802f11497 feat(manager): sticky list headers across editors
Long option / value / rule / exception lists scrolled their toolbar and
column header out of view, so "Add option" (and friends) were unreachable at
the bottom -- exactly where new rows land. Add a reusable `sticky-header`
option to Card: it pins the card header to the top of the editor scroll
region and publishes the measured header height (`--wp-card-header-h`, via
ResizeObserver) so a sticky <thead> can offset right below it. Chromium wont
scroll-anchor a table cell here, so the <thead> ELEMENT is stuck instead.

Applied to Wildcard options, Fixed-values, Derivation rules, and Constraint
exceptions. Verified live: header + column header pin, Add option reachable.
2026-07-20 00:10:16 +03:00
semantic-release-bot f888acdcd3 chore(release): 2.9.0 [skip ci] 2026-07-01 00:18:37 +00:00
DumiFlex 41fae1e41b fix(injector): keep custom pin labels with the socket on drag-reorder
reorderInjectorRows reassigned slot_name by position, but the object spread
carried the OLD slot_label along with the moved row -- so swapping two pins
left both rows showing the same stale label (the row that landed on a socket
did not pick up that socket's label). A custom label belongs to the physical
socket, which stays put on reorder (only the binding/template moves), so
snapshot socket->label before moving and assign each row the label of the
socket it lands on.
2026-07-01 03:17:58 +03:00
DumiFlex bf61f649fa fix(injector): right-align the type pill on label-less ref/slot rows
In the binding modal $ref / $slot dropdown, the type pill (e.g. "string")
sat next to the name on rows WITHOUT a label instead of right-aligned like
labelled rows -- the margin-left:auto lived on the label, which is v-if'd
out when absent. Give the type pill margin-left:auto too, and reset it to 0
when a label precedes it (the flex gap spaces them), so the pill lines up on
every row whether or not it has a label.
2026-07-01 03:17:58 +03:00
DumiFlex fc9d8a85a2 fix(derivation-modal): pin rule cards so the rule list actually scrolls
THE root cause of "rules clipped, no scrollbar": the rule cards are flex
children of the flex-column .rules__list, so they SHRANK to fit its
max-height and `.rule-card overflow:hidden` then clipped their branches. The
list never overflowed -> never scrolled -> no scrollbar ever appeared (each
rule showed only ~2 branches no matter how many it had). Add flex-shrink:0
to .rule-card so the cards keep full height; the list then truly overflows
calc(80vh - 20rem) and scrolls with its scrollbar, while header / identity /
runtime / footer stay pinned.

Verified live in ComfyUI via devtools: cards render ALL branches (clipped:
false), list scrollHeight 804 in a 336 cap (SCROLLS: true), modal does not
scroll, dock fully visible.
2026-07-01 03:17:58 +03:00
DumiFlex 6f664ce9e9 fix(derivation-modal): scroll the rule LIST, not the whole modal
After the last fix the MODAL scrolled (overflow-y:auto) while the rule list
did not -- and the sticky dock then covered the bottom rules on scroll, so
you still could not see the full rules. Force the scroll onto the rule list:
.dvm overflow:hidden (the modal cannot scroll, so header + identity stay
pinned at the top and the dock at the bottom) + the rule list capped at
calc(80vh - 20rem) (the modal budget minus the fixed chrome, padded so the
dock never clips) with overflow-y:auto. The rule list is now the only
scroller. Measured the chrome (~15rem) vs the budget in a devtools mock:
modal does not scroll, dock fully visible, list caps + scrolls.
2026-07-01 03:17:58 +03:00
DumiFlex 5d715fba99 fix(derivation-modal): absolute max-height for the rules scroll (revert flex)
My previous attempt used .dvm overflow:hidden + rules flex:1 1 auto, which
CLIPPED the rules with no scrollbar in the real app: the modal overlay
parents carry only max-height:100% (no definite height), so the flex-fill
child collapses instead of scrolling. A mock with a fixed-height container
masked it.

Match the wildcard modal's proven approach: .dvm overflow-y:auto + the rule
list capped with an ABSOLUTE max-height that scrolls in place. Cap is
calc(80vh - 16rem) -- the modal budget minus the fixed chrome (header +
identity + rules head + runtime/footer dock) -- so list + chrome never
exceeds 80vh: ONE scrollbar (the rule list), no outer modal bar, dock always
visible. Prominent scrollbar (12px, brighter than the subtle app default) so
it's unmistakably there.
2026-07-01 03:17:58 +03:00
DumiFlex e98aca30b3 fix(derivation-modal): scroll the rules region, pin header + footer
The derivation instance modal double-scrolled: the modal body (.dvm,
overflow-y:auto, max-height 80vh) AND the inner rule list (max-height 44vh)
both scrolled, so long rule lists clipped between the identity row and the
runtime/footer dock with no clear scrollbar -- "cannot see the full rules".

Make .dvm overflow:hidden and let the RULES region (flex:1 1 auto, content
basis so short lists still size naturally) fill the space and OWN the
scroll. Header + identity stay pinned at the top, the runtime/footer dock at
the bottom, and the rules scroll in between with a visible themed scrollbar
(matches the ModulePickerModal --wp-scrollbar-thumb convention).
2026-07-01 03:17:58 +03:00
DumiFlex df76abdbb0 fix(engine): derivation hold freezes each branch roll, not the branch choice
Hold was replaying the derivation's frame-0 OUTPUT, which froze WHICH branch
fired -- wrong. A derivation is a transform: the condition should still pick
the branch from the current inputs each iteration; only the random roll
inside a branch should freeze. Now hold resolves with the constant hold seed
keyed PER BRANCH (rule:branch), so each branch's inline {a|b} / @{} value is
frozen to its frame-0 roll independent of which other branches fired -- but a
varying input still switches IF->ELIF and uses THAT branch's frozen value
(== what that branch would have rolled at frame 0).

Drops the now-unused __wp_module_outputs__ capture from pipeline.run (the
freeze-output replay was its only consumer). wildcard / combine /
fixed_values still freeze the whole resolved VALUE via __wp_hold_base_ctx__,
because a constraint can reshape their pool so a frozen seed isn't enough --
a derivation branch's pool is fixed, so per-branch seed freeze suffices.
2026-07-01 03:17:58 +03:00
DumiFlex 8568aa2c1e fix(injector): persist custom pin labels durably across collapse + reload
The last fix reverted renamed pins to input_N on expand: the only copy of
the custom label lived in litegraph slot.label, which collapse overwrites
with a placeholder ("inputs xN" / " ") and serializes. So the label was
lost on reload, and the collapsed row tag even showed "inputs xN".

Persist it durably instead (new InjectorRow.slot_label). Capture the live
pin labels into the row on collapse -- the last tick they're readable --
and have the collapse module's expandedLabel reconstruct from it on expand
(replacing the () => undefined name-fallback). computeSlotLabels no longer
surfaces the placeholder while collapsed, and the row tag prefers
row.slot_label over the live slotLabels, so a collapsed / reloaded node
shows the real label, not the placeholder.

Existing already-corrupted saves can't be recovered (the label was
overwritten before this code ran) -- rename once + collapse to re-capture.
2026-07-01 03:17:58 +03:00
DumiFlex aba4e8c8ac docs(spa): note derivation supports Hold across run
Add derivation to the Hold-capable kinds in the Seeds & loops page and
describe its output freezing even as inputs vary.
2026-07-01 03:17:58 +03:00
DumiFlex ff6825cedd feat(engine): derivation honors seed_scope=hold (freeze frame-0 output)
A derivation is a transform that writes a DYNAMIC set of vars (whichever
rules fire), so it can't reuse the single-binding hold path. The pipeline
now captures each module's frame-0 output dict keyed by per-instance uid
during the hold base pass (__wp_module_outputs__); the derivation handler
replays its captured dict verbatim on hold. The captured values are
post-resolve, so inline {a|b} picks AND nested @{} refs freeze too -- and
the output stays frozen even when the derivation's INPUT varies (the same
value-freeze trade-off a constrained wildcard makes).

Adds derivation to HOLD_KINDS + a "Hold across run" toggle on the
derivation RuntimeSection. The row right-click Hold item picks it up for
free via HOLD_KINDS.
2026-07-01 03:17:58 +03:00
DumiFlex 0ede205530 fix(injector): reconstruct collapsed-wire labels from data, not the dead stash
The collapse label stash is an in-memory WeakMap that does not survive a
reload. A node saved collapsed serialized the placeholder ("inputs xN" /
" ") into slot.label, so on reload the stash was empty and expand restored
the placeholder onto input_0/1/2 instead of the names. The earlier
pre-attach reset ran BEFORE litegraph configure() restored the
placeholders, so it was a no-op -- which is why the bug persisted.

Add an opt-in expandedLabel resolver to attachCollapsableConnections: in
that mode the module skips the stash and reconstructs each matched slot's
expanded label from durable data on expand -- for EVERY matched slot,
stashed or not, which is what heals a reloaded node. The injector passes
() => undefined since its pins are name-derived (input_N). Existing broken
saves self-heal on the next expand.

Tradeoff: a custom pin label set via the slot context menu no longer
survives a collapse cycle (reverts to input_N) -- it was never persisted
durably anyway, so it was already lost on reload.
2026-07-01 03:17:58 +03:00
DumiFlex a738618dd3 docs(spa): mention the right-click Hold toggle shortcut
The Hold sections described only the Runtime-settings path; add the
two-click row context-menu shortcut alongside it on the WP Context node
page and the Seeds & loops concept page.
2026-07-01 03:17:58 +03:00
DumiFlex 8dcebdcc55 feat(context): quick Hold toggle in the module row context menu
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).
2026-07-01 03:17:58 +03:00
DumiFlex 1b3e4aa61f docs(spa): describe hold as a value freeze (constrained picks + nested refs) 2026-07-01 03:17:58 +03:00
DumiFlex 0281b8e321 fix(injector): repair collapse-wire labels corrupted across a reload
An older build serialized the collapse PLACEHOLDER labels ('inputs xN' on the
first input pin, ' ' on the rest) into the workflow JSON. On reload the slots
are fresh objects, so applyCollapsedLabels stashed those placeholders as the
'original' label — and expanding then restored the placeholder instead of the
real name (input_0 -> 'inputs xN', siblings blank, input_3 dangling).

The injector's input pins are always name-derived (input_N), so clear any
persisted label before attachCollapsableConnections runs, making the stash
capture the correct name-fallback original. Self-healing on every load, so
existing corrupted saves recover too. Regression tests in
collapse-connections.test.ts cover the repair contract + the bug it guards.
2026-07-01 03:17:58 +03:00
DumiFlex 58c5b27922 fix(engine): hold the resolved VALUE across iterations, not just the seed
seed_scope=hold previously swapped in a constant seed and re-picked each
iteration. That failed two ways: (1) a held wildcard whose pool is reshaped by
a constraint (varying source) picks a different option from the same seed; and
(2) under Override seed, the 'hold' seed equalled the per-iteration derived[k]
(effective_chain_seed returns an override verbatim, ignoring loop_index=0), so
hold was a no-op. Nested @{} refs in a held value also re-rolled.

Now hold freezes the frame-0 RESOLVED value. context_node computes a constant
hold seed from the loop's derived-series head; pipeline runs a throwaway base
pass at loop_index=0 when a loop drives + any module holds, and the
wildcard/combine/fixed-values handlers copy their resolved value from that base
context verbatim — skipping the pick AND resolve_text, so a constrained or
@{}-bearing held wildcard stays frozen (green jeans stays green jeans).

Tests: constrained held wildcard + nested-ref held wildcard each stay constant
across iterations while their vary counterparts move.
2026-07-01 03:17:58 +03:00
DumiFlex 4a34c9bbdd docs(spa): document per-iteration overrides, seed scope + lock-previous
Adds a 'Per-iteration overrides' concept page (edit-frame, per-frame
override/enable/seed-lock) and expands seeds-and-loops + the WP Context Loop /
WP Seed List / WP Context node pages with seed scope (vary/hold), the
per-iteration seed modal, and the lock-previous button. Mirrors the wiki
update. Held off main so it doesn't cut a standalone release.
2026-07-01 03:17:58 +03:00
semantic-release-bot 06c380fd01 chore(release): 2.8.0 [skip ci] 2026-06-26 03:44:39 +00:00
DumiFlex c48aafb143 fix(context): scope the wrapping grid to the frame chips only
The earlier frame-chip wrap fix put the grid + ellipsis on the shared
.wp-loop__chips/.wp-loop__chip rules, which also style the 3 strategy chips —
truncating 'sequential' to 'seq...'. Move the grid to a .wp-loop__chips--frames
modifier on the edit-frame row only and drop the ellipsis, so the strategy
chips keep full labels on one row while the frame chips still wrap.
2026-06-26 06:43:58 +03:00
DumiFlex ff4d8faa1f feat(manager): snapshot source/target tag_groups into published constraints
A standalone constraint references its source/target wildcards by id but
carries no axis grouping of its own — so the community preview rendered its
matrix flat + uncoloured when the wildcards weren't also published. Backfill
each wildcard's tag_groups into the published constraint as source_groups /
target_groups (sibling of the existing axis-name backfill), making the
grouping self-contained. Preview-only inner-payload metadata; the constraint
validator's inner z.object strips it on a consumer's install.
2026-06-26 06:43:58 +03:00
DumiFlex c7c867e531 feat(context): add lock-previous-seed to the iteration-seed modals
The Context Loop + Seed List seed modals show the UPCOMING derived series
(deriveLoopSeeds of the CURRENT base); under control_after_generate=randomize
that won't reproduce the last run. Add a per-frame Prev button that fills +
locks the seed the frame ACTUALLY used last run, greyed out when there's no
captured previous (no prior run, or the Loop wasn't driving seeds).

The nodes emit their executed per-iteration series as loop_seeds in the node
UI; a shared _seed-capture helper captures it on the executed event (mirroring
WP_Context) and the glue threads it through the widgets -> SeedListModal ->
SeedLockRow.
2026-06-26 06:43:57 +03:00
DumiFlex 2fb853fa81 fix(context): name the disabled toggles in the per-frame runtime hint + wrap loop frame chips
The runtime frame-active hint read as if it scoped the Lock pick (the one
control that IS per-frame editable); reword it to name Hide-from-prompt and
Hold-across-run — the toggles it actually disables. And switch the Context
Loop edit-frame chips from a non-wrapping flex row to an auto-fill grid so 15+
frames wrap instead of overflowing the node.
2026-06-26 06:43:56 +03:00
DumiFlex e1885a3115 fix(context): sweep redundant frame lock-null when base unlocks via the modal 2026-06-26 06:43:55 +03:00
DumiFlex f38a316ccb fix(context): clear redundant frame lock-null override when the base unlocks 2026-06-26 06:43:54 +03:00
DumiFlex e56fd22bc9 fix(context): modal lock button pins the rolled seed via injected resolver 2026-06-26 06:43:53 +03:00
DumiFlex b33afd8797 fix(context): capture the loop's per-frame seeds so the lock pins the previous roll 2026-06-26 06:43:52 +03:00
DumiFlex 67a09df6d6 fix(extension): read loop seed and count as numbers for per-frame seed lock 2026-06-26 06:43:51 +03:00
DumiFlex 1f164df0c5 fix(context): per-frame seed lock computes the frame's real seed from the loop 2026-06-26 06:43:50 +03:00
DumiFlex 91b5f7581a revert(context): drop per-frame seed capture, superseded by double-shift fix 2026-06-26 06:43:50 +03:00
DumiFlex 3527ada397 fix(engine): stop double-shifting the per-iteration override seed 2026-06-26 06:43:49 +03:00
DumiFlex 25388b4650 fix(context): per-frame seed lock captures that frame's own rolled seed 2026-06-26 06:43:48 +03:00
DumiFlex 6eb4b730cb fix(context): per-frame enable overrides base enabled (frame_enabled) 2026-06-26 06:43:47 +03:00
DumiFlex 942834d16a fix(engine): apply iteration_overrides for generic-object modules 2026-06-26 06:43:46 +03:00
DumiFlex 03796c56ba fix(engine): include disabled_frames in the generic-object snapshot keys 2026-06-26 06:43:45 +03:00
DumiFlex 65683aed7c fix(context): coerce disabled_frames to int set and guard frame toggle 2026-06-26 06:43:44 +03:00
DumiFlex 8a9dc9efbd feat(context): per-frame enable/disable via disabled_frames
- add `disabled_frames?: number[]` to `ModuleEntry` (_shared.ts)
- engine gate reads the field (dict + object branches) and skips the
  module at the listed loop indices with status `skipped_frame`
- `toggleEnabled` becomes frame-aware: when a frame is active it
  adds/removes the frame index from `disabled_frames` instead of
  flipping the base `enabled` flag; empties the array to remove the key
- add `isDisabledOnFrame` helper to `ContextWidget` + `ModuleRowCtx`
- checkbox reflects effective enabled at the frame; adds an `off #k` badge
- TDD: 3 engine tests + 4 ModuleRow.test.ts cases
2026-06-26 06:43:44 +03:00
DumiFlex 8f696d6eec refactor(context): unify frame-hint markup and add false-case coverage 2026-06-26 06:43:43 +03:00
DumiFlex 1822297a9a feat(context): disable run-level controls while editing a frame 2026-06-26 06:43:42 +03:00
DumiFlex 21f285033d feat(context): make the row quick-lock act on the active frame 2026-06-26 06:43:41 +03:00
DumiFlex bb5e84d3b2 fix(context): restrict per-frame overrides to an allowlist of meaningful fields 2026-06-26 06:43:40 +03:00
DumiFlex b292699487 fix(context): add hover states to the per-kind edit modal footer buttons 2026-06-26 06:43:39 +03:00
DumiFlex 39fdb6f187 fix(context): resync editor draft on frame revert + restyle the banner for readability 2026-06-26 06:43:38 +03:00
DumiFlex 3c492f1cb9 fix(context): stack the frame banner above the editor card instead of beside it 2026-06-26 06:43:37 +03:00
DumiFlex 79cf3857ad feat(context): frame banner with revert-to-base in the module editor 2026-06-26 06:43:36 +03:00
DumiFlex e93f7ba06e feat(context): held and per-frame override row badges 2026-06-26 06:43:35 +03:00
DumiFlex 4e71e043e7 feat(context): redirect edits into iteration_overrides when a frame is active 2026-06-26 06:43:34 +03:00
DumiFlex 7b416ad0ed feat(context): add pure frame-override helpers 2026-06-26 06:43:34 +03:00
DumiFlex ece6d49929 feat(context-loop): add edit-frame selector chip row 2026-06-26 06:43:33 +03:00
DumiFlex 6dc033d57e feat(context-loop): add session frame-cursor singleton 2026-06-26 06:43:32 +03:00
DumiFlex 9663636d10 feat(context): add hold-across-run toggle to the runtime sections 2026-06-26 06:43:31 +03:00
DumiFlex 8910b98a86 feat(types): add seed_scope and iteration_overrides to module config 2026-06-26 06:43:30 +03:00
DumiFlex 10dcac4100 feat(engine): apply per-frame iteration_overrides onto module instance 2026-06-26 06:43:29 +03:00
DumiFlex 15c1ade3ef feat(engine): fixed_values honors hold seed_scope 2026-06-26 06:43:28 +03:00
DumiFlex 8871a2806d feat(engine): combine honors hold seed_scope 2026-06-26 06:43:28 +03:00
DumiFlex 072e4e7836 feat(engine): wildcard honors hold seed_scope, locked_seed still wins 2026-06-26 06:43:27 +03:00
DumiFlex 0f7aaabe3c feat(engine): thread a hold seed and loop index into the pipeline run 2026-06-26 06:43:26 +03:00
semantic-release-bot f1451baf40 chore(release): 2.7.0 [skip ci] 2026-06-26 03:00:36 +00:00
DumiFlex 589f45eb74 feat(seedlist): alt-click copy to grab only the locked seeds 2026-06-26 05:56:55 +03:00
DumiFlex fa73750ca0 feat(seedlist): add paste-to-replace locks from copied text 2026-06-26 05:56:54 +03:00
DumiFlex b49a579b33 feat(seedlist): show out-of-range locks dimmed but still editable 2026-06-26 05:56:53 +03:00
DumiFlex 34ec18feff feat(seedlist): flash a copied indicator on the modal copy button 2026-06-26 05:56:52 +03:00
DumiFlex 44d8ab9575 feat(widgets): seed-list override hint + capitalize loop bypass label 2026-06-26 05:56:51 +03:00
DumiFlex 68910bfc18 feat(seedlist): preview effective base/count/strategy from the wired loop 2026-06-26 05:56:50 +03:00
DumiFlex b5262c22d5 test(widgets): restore parse-recovery coverage dropped alongside seed_locks tests 2026-06-26 05:56:49 +03:00
DumiFlex fb19e3cef8 feat(widgets): feed stock base-seed + count into the seed modal 2026-06-26 05:56:48 +03:00
DumiFlex 43d775ec8f feat(seedlist): per-iteration seeds button + modal 2026-06-26 05:56:47 +03:00
DumiFlex 86eb15cfa3 feat(loop): per-iteration seeds button + modal; shrink iteration-var buttons 2026-06-26 05:56:46 +03:00
DumiFlex 0f0cd47018 feat(shared): view/set/lock per-iteration seeds modal 2026-06-26 05:56:45 +03:00
DumiFlex 1ad7e8e415 feat(shared): add per-iteration view/set/lock row (SeedLockRow) 2026-06-26 05:56:44 +03:00
DumiFlex 5a3e834c42 feat(widgets): add seed_locks field to loop + seed-list configs 2026-06-26 05:56:43 +03:00
DumiFlex cc64ca3ab8 feat(shared): ts mirror of loop seed derivation with py-ts parity corpus 2026-06-26 05:56:43 +03:00
DumiFlex fd2cdcbf08 feat(seedlist): apply per-iteration seed locks to the output series 2026-06-26 05:56:42 +03:00
DumiFlex 48845981a1 feat(loop): apply per-iteration seed locks in the context loop node 2026-06-26 05:56:40 +03:00
DumiFlex 5da64f8d78 feat(engine): add apply_seed_locks for per-iteration seed overrides 2026-06-26 05:56:39 +03:00
semantic-release-bot fe0d702e75 chore(release): 2.6.0 [skip ci] 2026-06-22 15:49:55 +00:00
DumiFlex d3e25b72ad fix(constraint): drop matrix scroll-container padding that leaked scrolled cells
The real bleed source (the border-collapse fix was necessary but not
sufficient): the scroll frame had `padding: 6px`. position:sticky pins the
frozen headers at the padding edge, but overflow still renders the scrolling
cells INSIDE that padding — so a 6px strip of scrolled cells stayed visible on
the top/left scroll-start sides, just outside the sticky row/column headers.

Live-diagnosed on the SPA editor: exaggerating the padding to 30px grew the
bleed strip to 30px; setting it to 0 removed it entirely. Set padding:0 on both
scroll containers (SPA ConstraintMatrix + canvas MatrixSection); the frame's
1px border + border-radius give the visual edge, content sits flush, and the
4px inter-cell gap (th/td padding) is unaffected.
2026-06-22 17:51:49 +03:00
DumiFlex 40980093e8 fix(constraint): stop scrolled cells bleeding through the frozen matrix headers
position:sticky header cells under border-collapse:collapse hit a Chromium
paint bug: scrolled data cells show through the frozen row/column headers
instead of being covered. Switch both matrices (SPA ConstraintMatrix + canvas
MatrixSection) to border-collapse:separate with border-spacing:0; the 4px
inter-cell gap still comes from the th/td padding, so the layout is unchanged
while the sticky backings now paint opaquely.
2026-06-22 17:10:38 +03:00
DumiFlex 538db06efc feat(constraint): group the canvas matrix axes like the SPA
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.
2026-06-22 15:27:08 +03:00
DumiFlex eac852fbda feat(manager): label the matrix ungrouped axis run as "uncategorized"
The leftover run of sub-categories with no axis (or an empty axis name)
rendered as a blank band over its columns and no chip above its rows — it
read as a layout gap. It now gets the same treatment as a real axis: a
band ("uncategorized") over the columns and a header chip over the rows,
tinted neutral grey (distinct from the per-axis palette) with a dashed
edge so it still reads as the catch-all bucket rather than a named axis.

Gated on the matrix having at least one named axis: a fully-flat matrix
(no tag_groups at all) keeps the legacy source/target-tinted flat headers
and grows no "uncategorized" label. Ordering is unchanged — only the
bucket's label + colour change.
2026-06-22 14:49:19 +03:00
DumiFlex 0bbf150ca3 feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart
Same-named library entries were indistinguishable in the broken-reference
pickers (two "mood", two "color" with nothing to choose between). Each
candidate now surfaces the disambiguating data:

- Constraint reattach: 8-hex uuid + option count + declared sub-categories.
- @{} remap popup: 8-hex uuid alongside the existing option count.
- Bundle child reattach: 8-hex id beside the name.

The uuid is the definitive tiebreaker; the option/sub-category line gives a
human-readable one when the hex alone is opaque.
2026-06-22 14:14:05 +03:00
DumiFlex c0f354cb8f feat(manager): group constraint matrix axes with header chips and column bands
Redesign the constraint matrix headers so each sub-category axis reads as
one coloured group on both axes:

- Columns grow a band row naming each axis, spanning its tags; flat
  (ungrouped) columns keep the single header row.
- Multi-tag row groups get a pinned header chip (left-frozen, full axis
  name always readable however long); solo grouped axes fold the name in
  as an eyebrow over the value instead of wasting a header row.
- Per-axis hues come from the shared axisHueAt palette; ungrouped tags
  keep the legacy source/target tint.
- Column band, tag column, group chips and corner stay frozen while cells
  scroll on both axes; the tag row sticks at the measured band height.

ConstraintEditor passes the source/target wildcard tag_groups through so
the matrix can order and colour by axis.
2026-06-22 13:47:17 +03:00
DumiFlex b3537ad6c5 feat(manager): type-to-filter all dropdowns
While a Select menu is open, printable keystrokes build a query that filters options (case-insensitive substring); Backspace trims, a "Filtering:" row shows the query, and an empty result shows "No matches". Arrow/Enter navigate the filtered set. Small dropdowns look unchanged until you type, so every Select gains autocomplete with no per-call-site change.
2026-06-22 02:17:27 +03:00
DumiFlex a77e60b402 feat(manager): author and save bundles directly in the manager
The bundle editor hard-disabled Save unless editing an existing bundle, so a new bundle could be opened but never saved ("create from Context" only). Save is now gated on validation (a name) for both modes; in create-mode it POSTs a new bundle and routes into its edit page. The add-child picker already builds save-valid children, so authoring here is safe.
2026-06-22 02:12:56 +03:00
DumiFlex c1f45a3fd2 fix(api): keep duplicate children when creating or updating bundles
Bundle children were deduped by id on create + update, silently dropping the second+ copy — but multi-instance bundles repeat a module id on purpose (one wildcard used as garment/hair/eyes). Removed _dedupe_children from both paths; per-instance disambiguation happens at Context-insert time via _uid, not at bundle-store time. Inverts the former QA-hardening dedup tests to assert preservation.
2026-06-22 02:08:26 +03:00
DumiFlex 11d205bf61 fix(manager): decimal option probabilities and distinct sub-category group colors
Option probabilities rounded to "0%" for anything below 0.5% — misleading when a wildcard has many options. New utils/percent.ts formatProbability keeps large values as integers and grows decimals for small ones (0.01%) so a nonzero weight never reads as zero.

Sub-category group colors repeated: the per-component AXIS_HUES palette held two near-identical purples and wrapped with % length. Extracted one shared components/shared/axis-color.ts (axisHueAt) with a de-duplicated, order-separated palette plus a golden-angle fallback so every group is distinct; OptionRow, PoolSection, and WildcardEditor now share it.
2026-06-22 02:04:04 +03:00
semantic-release-bot d628a0687d chore(release): 2.5.0 [skip ci] 2026-06-18 04:36:29 +00:00
DumiFlex 02cc9f766f feat(manager): one-time library repair for non-hex module/bundle ids
Pre-existing ids that aren't 8-hex (e.g. an imported 'coloruni') can't be nested-referenced. A version-gated SPA-boot repair (runLibraryRepair, fired fire-and-forget from AppLayout) rekeys them. create() server-mints ids, so it creates copies -> builds the old->new map -> remaps every referrer (nested @{} via a literal pass since walkRemap's REF_RE is 8-hex only, plus whole-string constraint axes) -> updates -> deletes the old rows, ordered create->update->delete so a crash never leaves a dangling ref. Pure planning (findInvalidIds / planRemapUpdates / remapAll) unit-tested; orchestrator tested against a mock store.
2026-06-18 07:31:07 +03:00
DumiFlex a1d051172d fix(import): normalize non-hex module/bundle ids on import + download
A module/bundle id that isn't a canonical 8-hex uuid (e.g. a hand-authored 'coloruni' that round-tripped through import -> community -> download) silently can't be nested-referenced: @{coloruni} matches neither the editor chip tokenizer nor the engine resolver (both require [0-9a-f]{8}), so the ref renders as plain text and never resolves. install.ts now mints a fresh valid id for any invalid module/bundle id (new isValidId in utils/ids.ts) and folds it into the reattach rename map, so walkRemap rewrites every @{oldid} ref + constraint source/target axis to the new id. Covers both import and community download (same install path).
2026-06-18 07:00:09 +03:00
semantic-release-bot 5b5c635be6 chore(release): 2.4.0 [skip ci] 2026-06-18 02:53:08 +00:00
Dumitru Picalau ccb8392219 Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug
fix: inline branch weights + wildcard pool UI + debug warnings
2026-06-18 05:52:28 +03:00
DumiFlex d8c20728cb fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs
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.
2026-06-18 05:42:34 +03:00
DumiFlex 0e9e5db486 feat(engine): emit a constraint source-missing debug warning
A constraint with both ends absent from the chain emitted only the target-focused constraint_never_applied warning, so WP Debug showed one issue while the editor row flagged both source AND target missing. The pipeline now also emits constraint_source_missing (with a matching DebugViewer label) when the constraint's source wildcard isn't in the chain, so both ends surface in the debug panel.
2026-06-18 05:37:55 +03:00
DumiFlex a9516996b4 fix(engine): apply N:: branch weights in simple inline {a|b|c} choices
Simple inline picks resolved a branch uniformly and never stripped the N:: weight prefix, so {2::a|b} could emit the literal 2::a and {2::|, long shadows} leaked 2::. _resolve_inline_pick now strips the prefix and weights the pick when any branch carries one, staying byte-for-byte seed-stable for unweighted choices. Mirrored in the TS preview resolver (expandInlineChoices); multi-pick blocks already handled weights.
2026-06-18 05:33:24 +03:00
semantic-release-bot 02a046321f chore(release): 2.3.1 [skip ci] 2026-06-18 01:06:25 +00:00
Dumitru Picalau 6a5a8d318e Merge pull request #3 from DumiFlex/fix/instance-modal-scroll
fix(extension): scroll instance edit modals with pinned header + footer
2026-06-18 04:05:45 +03:00
DumiFlex a19e5f4500 Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll
# Conflicts:
#	scripts/check-bundle-size.mjs
2026-06-18 03:44:54 +03:00
DumiFlex c43bff0488 fix(extension): scroll instance edit modals with pinned header + footer
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.
2026-06-18 03:23:24 +03:00
semantic-release-bot a3a3dbe2df chore(release): 2.3.0 [skip ci] 2026-06-17 22:58:00 +00:00
Dumitru Picalau 6e8696fa85 Merge pull request #2 from DumiFlex/feat/manager-bulk-editor
Feat/manager bulk editor
2026-06-18 01:57:18 +03:00
DumiFlex 959cfbe179 feat(manager): add bulk editor for wildcard options and fixed values
Adds an in-editor bulk mode to the wildcard + fixed-value editors. Bulk add pastes options (value [#tag] [*N] with trailing order-free modifiers; new sub-categories auto-create in Ungrouped) or fixed values (name = value, existing names update in place). In bulk mode a checkbox column + selection toolbar apply/remove a sub-category, set the weight, or delete across every checked row at once; constraint-referenced options are kept and reported rather than silently dropped. Pure bulkParse.ts util plus two presentational components (BulkAddPanel, SelectionToolbar) reusing the shared Checkbox/Input. 27 new tests.
2026-06-18 01:34:00 +03:00
DumiFlex 325c90818f feat(widgets): shield clipboard shortcuts on editable widget inputs
ComfyUI's canvas captured Ctrl+A/C/V/X/Z/Y on widget text inputs, so copy/paste pasted whole graph nodes instead of editing the field. clipboard-shield.ts stops those shortcuts (and copy/cut/paste events) from bubbling past an editable target without ever calling preventDefault, so native editing still works. Wired into the widget inner via _shared.ts plus a body-level catch-all in main.ts for body-teleported modals like the Context Injector variable-binding field.
2026-06-18 01:29:40 +03:00
DumiFlex 0f5166e7d1 fix(manager): recover from rich-text editor crash and persist drafts on unload
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.
2026-06-18 01:25:24 +03:00
semantic-release-bot 9c1bb35a78 chore(release): 2.2.0 [skip ci] 2026-06-16 00:03:54 +00:00
Dumitru Picalau a53d5266de Merge pull request #1 from DumiFlex/feat/community-embed
feat: Wildcard Pipeline Community integration (embed, schema versioning, SP1-SP3, reattachment)
2026-06-16 03:03:13 +03:00
DumiFlex 3065e99c43 test(syntax): move subcat_filter tests into tests/syntax for the coverage gate 2026-06-15 21:53:04 +03:00
DumiFlex 844d16e677 docs(constraint): document target reach selectors and axis reattach 2026-06-15 18:38:49 +03:00
DumiFlex 402fdcc453 docs(derivation): document nested refs in action values 2026-06-15 18:29:23 +03:00
DumiFlex 8b99795dae docs(wildcard): document multi-pick, sub-categories, and nested refs 2026-06-15 18:21:28 +03:00
DumiFlex 7297b2ff7c docs(syntax): document multi-pick, nested-in-inline, $var.K, and the surface gate 2026-06-15 18:10:50 +03:00
DumiFlex feb760e2a0 feat(publish): verify published deps + reclassify stale ones to publish-first 2026-06-15 15:36:35 +03:00
DumiFlex 33316aa494 feat(publish): surface bundle children external refs as dependencies 2026-06-15 13:19:00 +03:00
DumiFlex 7b2d057f34 fix(import-export): refresh also reloads the export tab listing 2026-06-15 12:53:08 +03:00
DumiFlex c66c1f52fa fix(library): validate all-items bundles against loaded catalog not empty store 2026-06-15 12:43:02 +03:00
DumiFlex e458e8d75a fix(bundles): reconcile restored inner-bundle fingerprint to clear false drift 2026-06-15 12:21:07 +03:00
DumiFlex f8edcfedd5 feat(publish): wire inner-bundle dependency publish button to navigate 2026-06-15 12:05:16 +03:00
DumiFlex f58d849500 feat(bundles): manual per-child reattach with community download 2026-06-15 00:02:42 +03:00
DumiFlex b4f2d6bf5a feat(install): auto-reattach inner-bundle references on bundle install 2026-06-14 23:51:04 +03:00
DumiFlex e7e844b7e8 feat(publish): surface inner-bundle dependencies in guided publish 2026-06-14 23:41:10 +03:00
DumiFlex f1ab33c025 feat(bundles): move extract action to children header, used-by beside title 2026-06-14 23:10:58 +03:00
DumiFlex b1fa0ce411 feat(bundles): relink bundle to extracted modules + gate extract on dangling children 2026-06-14 23:01:41 +03:00
DumiFlex 0984a7f450 fix(constraint): origin-stamp downloaded deps so they get the community badge 2026-06-14 22:25:11 +03:00
DumiFlex 02ad0ebcc7 feat(constraint): download missing deps from community + reattach 2026-06-14 21:33:56 +03:00
DumiFlex 7dc9817e71 feat(community): orchestrate transitive dep download for dangling refs 2026-06-14 21:19:43 +03:00
DumiFlex c858bef9c3 feat(community): add public post detail + download fetch helpers 2026-06-14 21:12:53 +03:00
DumiFlex 1ee8fe28ec feat(community): add transitive dependency closure walker 2026-06-14 21:06:16 +03:00
DumiFlex 59af070e7d feat(bundles): extract bundle children to library with ref remap 2026-06-14 20:51:58 +03:00
DumiFlex 223770e18c feat(bundles): add extractBundleChildren fresh-id materialization helper 2026-06-14 20:38:57 +03:00
DumiFlex a9bde14434 feat(install): host bridge forwards post deps + library slug 2026-06-14 20:02:37 +03:00
DumiFlex 0494e3c1a5 feat(install): auto-reattach references via slug-bridge on install 2026-06-14 19:51:07 +03:00
DumiFlex e138868abc feat(install): add reattach type surface (deps, slug, count) 2026-06-14 19:40:34 +03:00
DumiFlex a5f95cf172 refactor(install): applyCollisionDecisions returns rename map 2026-06-14 19:30:30 +03:00
DumiFlex 7845d91bc5 feat(install): add buildReattachRemap slug-bridge helper 2026-06-14 19:21:33 +03:00
DumiFlex 50612dcc39 fix(publish): stamp constraint source/target wildcard names so shares self-describe 2026-06-14 18:13:12 +03:00
DumiFlex 9a3b5fafb4 fix(publish): forward schema_version stamp into the community publish hash 2026-06-14 17:33:35 +03:00
DumiFlex 292a0a547c feat(publish): guided unmet-dependency checklist before publish 2026-06-14 13:53:35 +03:00
DumiFlex 107d5f4432 feat(publish): auto-detect + inject module dependencies into publish hash 2026-06-14 13:04:32 +03:00
DumiFlex e709d538af feat(publish): add listReferencedUuids dependency detector 2026-06-14 12:50:39 +03:00
DumiFlex 9f7ee12218 fix(schema): accept natively-supported future versions on install (max-known, not chain head) 2026-06-14 12:14:34 +03:00
DumiFlex 0cb57064b1 fix(schema): advertise max-known schema version to publish gate, not chain head 2026-06-14 11:55:27 +03:00
DumiFlex 5ae0ff083f feat(constraint): canvas parity for stranded read-only matrix + colored exception modes 2026-06-14 02:01:17 +03:00
DumiFlex 4598f68d28 feat(constraint): lock stranded wildcard fields + color-code exception modes 2026-06-14 01:23:45 +03:00
DumiFlex d188ead525 feat(constraint): backfill cached wildcard name from live catalog on save 2026-06-13 18:43:10 +03:00
DumiFlex fa68a364c7 feat(constraint): show stranded constraint matrix + exceptions read-only in spa editor 2026-06-13 18:31:11 +03:00
DumiFlex 97d3036e6a fix(cascade): stamp deleted wildcard name onto dependent constraints for broken-ref banner 2026-06-13 18:12:56 +03:00
DumiFlex eff7a6a8ed fix(bundles): stamp payload_hash on cascade-restored bundle children so re-insert shows no drift 2026-06-13 17:12:40 +03:00
DumiFlex 37ca8bdd52 fix(bundles): sync restored module payload_hash on cascade so canvas shows no drift 2026-06-13 16:53:05 +03:00
DumiFlex fd254a6aa2 feat(conflicts): surface broken nested @{} refs at module row 2026-06-13 12:46:15 +03:00
DumiFlex c10236108c test(constraint): double-cast findComponent to VueWrapper in cached-names test 2026-06-12 22:02:48 +03:00
DumiFlex 70da7e0faf feat(constraint): cache source/target wildcard names for broken-ref banner
Stamp the source/target wildcard display names onto the constraint payload
(source_wildcard_name / target_wildcard_name) when the source/target is set,
so the broken-reference banner can show "Source wildcard 'Starter subject'
(54693e08)" instead of just the uuid after the wildcard is deleted.

Display-only and additive: the engine resolves by uuid and never reads these
names (diagnostic, like producer_engine_version), so there is no engine
resolution change, no Py=TS parity, and no community schema bump. Legacy
constraints lack the fields and gracefully fall back to uuid-only.

SPA ConstraintEditor stamps on pick + reattach + load and persists when
known; the canvas ConstraintInstanceModal mirrors the reattach stamp and
falls back to the cached name in the banner when the live lookup misses.

Spec: docs/superpowers/specs/2026-06-12-constraint-cached-ref-names-design.md
2026-06-12 21:56:54 +03:00
DumiFlex fa0f2ceca1 fix(bundles): repoint cascade-restored standalone library refs (phase 1 pass 2)
phase 1 minted a fresh library entry per missing module by POSTing its
payload raw, but never rewrote internal refs. a restored constraint (or
any module referencing a sibling restored module) shipped a standalone
library entry with dangling source_wildcard_id / target_wildcard_id /
@{} refs pointing at the old, now-deleted uuids. forward-ref order made
a mid-loop rewrite impossible: the constraint is POSTed before its target
wildcard mints a new id, so moduleMap is incomplete at that moment.

split phase 1 into two passes. pass 1 still POSTs raw to MINT ids. pass 2
runs after the loop (moduleMap complete), walkRemaps each restored
module payload against the full map, and corrects the freshly-created
entry via api.modules.update only when a ref actually moved. ref-free
modules are skipped. this is the same rewrite phase 3 (pushed children)
and phase 4 (workflow rebind) already apply; the standalone library
entries were the third, unfixed output.
2026-06-12 21:36:54 +03:00
DumiFlex 6fe52c065f fix(bundles): repoint workflow module refs on cascade restore, not just ids 2026-06-12 21:08:28 +03:00
DumiFlex a4d19134b7 refactor(bundles): delete dead remapBundleUuids, keep walkRemap 2026-06-12 20:31:04 +03:00
DumiFlex 9071dfba6d fix(engine): bind constraint to its own bundle copy's source pick 2026-06-12 20:23:36 +03:00
DumiFlex 690169bda3 test(engine): lock constraint source-pick fallback semantics 2026-06-12 20:16:13 +03:00
DumiFlex febb9f3e70 feat(engine): capture constraint's own bundle_origin on registered meta 2026-06-12 20:09:40 +03:00
DumiFlex d6094ed393 feat(engine): record per-origin source pick view in __wp_picks__ 2026-06-12 20:03:02 +03:00
DumiFlex 7bcfcac9ad feat(engine): thread bundle_origin into resolve ctx per module 2026-06-12 19:56:18 +03:00
DumiFlex 3884ded916 fix(manager): re-anchor remap popup on resize + cap height to viewport 2026-06-12 18:46:28 +03:00
DumiFlex 9a976137f0 feat(cascade): per-entity wildcard-delete cleanup via cleanup_ids
Wildcard-delete now lets the user opt in per affected entity which dead
@{uuid} references the engine strips, instead of a single all-or-nothing
cascade_refs flag.

- CascadeConfirmDialog: wildcard-delete branch renders per-row controls —
  constraints are informational ("reattach to heal", never cleaned),
  nested-ref wildcards/derivations get a default-unchecked "strip dead
  reference" checkbox. Checked ids form cleanup_ids on confirm. Other
  delete kinds keep the single cascade_refs checkbox unchanged.
- useCascadeApply: add cleanup_ids?: string[] to CascadeApplyRequest;
  apply() forwards it when present (dryRun unchanged).
- wp_api/cascade.py: document cleanup_ids in the body shape (already
  forwarded verbatim to apply_cascade; no per-field plumbing needed).
- Tests: dialog cases for the mixed affected set + cascade_refs
  regression-lock; server test proving cleanup_ids reaches the engine
  end-to-end (strips opted-in ref, leaves omitted ref broken).
2026-06-12 17:52:10 +03:00
DumiFlex e288d1b31d fix(cascade): wildcard-delete leaves constraints, opt-in nested-ref strip
Constraints are now healable via the reattach UI, so the cascade no
longer deletes them on wildcard delete — they are left broken for the
user to re-point later. Remove the constraint-delete loop entirely.

Nested @{wildcard_id...} refs (the deleted wildcard inserted inside
another wildcard option value or a derivation action string) are
stripped only for the entity ids passed in a new cleanup_ids list;
everything else is left untouched. apply_cascade reads
req["cleanup_ids"] and forwards it. Empty/absent cleanup_ids strips
nothing — the new default, which also fixes the old "untick ignored"
bug for wildcard delete.

Add _strip_whole_ref_in_string, reusing the shared _ref_expr_pattern
to remove the whole @{...} token (vs the subcat helper that only drops
the :expr filter), collapsing seam whitespace.
2026-06-12 17:35:56 +03:00
DumiFlex 498a4ba935 fix(manager): anchor remap popup to the chip using its real height 2026-06-12 16:15:42 +03:00
DumiFlex 236c39ac49 feat(import): apply walkremap follow-through over committed entities on rename 2026-06-12 15:53:09 +03:00
DumiFlex 4dd6d6c48e feat(import): walkremap follow-through helper for renamed friend ids 2026-06-12 15:41:42 +03:00
DumiFlex 1bf1e42b49 feat(constraint): spa editor reattach control mirrors the canvas modal 2026-06-12 15:34:07 +03:00
DumiFlex 47af97ee11 fix(constraint): derive real dropped-cell count for reattach preview
The modal never bound :dropped-cell-count, so it stayed 0 and the
spec-mandatory pre-confirm reconcile preview ("N cells dropped") never
rendered — the user reattached blind to how many matrix cells a
different-axes wildcard would discard. Confirm is irreversible (no v1
undo), so this count must show BEFORE confirm.

ConstraintReattachSection now emits its live pre-confirm pick (and a
pickcleared signal on open/cancel/confirm); ConstraintInstanceModal
tracks it and derives a real PER-CELL drop count against the candidate's
sub_categories — a vanished source row drops all its cells, a vanished
target key drops one cell per row that carries it — then binds it to the
section. Reattach emit shape is unchanged.
2026-06-12 15:21:45 +03:00
DumiFlex 77f00ae975 feat(constraint): canvas modal detects dangling source/target and reattaches via walkremap 2026-06-12 15:09:29 +03:00
DumiFlex 07dcf313df fix(constraint): float cached-name match in reattach dropdown (auto-suggest)
The reattach dropdown seeded its candidate search empty and sorted
candidates alphabetically only, discarding the spec's "Auto-suggest
matching" behaviour. The broken ref's cached #name must be the primary
match signal so an import-from-friend case (friend's `colour` -> local
`colour`) surfaces the name match at the top.

Mirror sibling #3 (RemapRefPopup): keep the filter input empty so all
candidates stay visible, but use the open side's cached name as a SORT
KEY that floats an exact (case-insensitive) name match to the top; the
typed query stays a secondary substring filter; remaining candidates
sort alphabetically. Dangling source/target uuids stay excluded.
2026-06-12 15:00:31 +03:00
DumiFlex f7369ff40e feat(constraint): reattach section for dangling source/target with blast-radius warning 2026-06-12 14:49:07 +03:00
DumiFlex 770909dec7 feat(manager): thread module-id so remap clears broken-ref warnings in all #3 hosts 2026-06-12 14:40:37 +03:00
DumiFlex a75c7371f5 feat(manager): wire @remap in richtextinput to heal the open field 2026-06-12 14:32:38 +03:00
DumiFlex e897bd3eba feat(manager): remaprefpopup for healing broken nested chips 2026-06-12 14:20:13 +03:00
DumiFlex 43de217109 feat(refchip): distinct remap emit for unresolved broken chips 2026-06-12 14:08:58 +03:00
DumiFlex fd25a63bf0 feat(cascade): reconcileSubcatExpr token reconciler for remap preview 2026-06-12 14:01:54 +03:00
DumiFlex 1d59df9f73 feat(cascade): rewriteBrokenRef pure rewriter for broken-ref remap 2026-06-12 13:46:36 +03:00
DumiFlex 96fc26a1ae docs: flag pick-bucket collision + deprecate dead remapBundleUuids 2026-06-12 12:59:19 +03:00
DumiFlex 67de90c37b test: lock cascade-restore end-to-end constraint repoint (#2) 2026-06-12 12:49:37 +03:00
DumiFlex e748064969 fix(cascade): keep bundles frozen on wildcard rename (no auto-mutate)
fix_wildcard_rename_name (called from wp_api/modules.py on a wildcard name update) walked every bundle and rewrote its frozen child snapshots — the cached #name on the renamed wildcard plus @{uuid#name} refs in sibling children. So editing/renaming a wildcard silently mutated every bundle holding a snapshot of it, defeating the snapshot contract.

Removed the bundle-walk block so a rename leaves bundle snapshots untouched (mirrors fix_wildcard_delete, which already preserves them). Bundles drift instead; only the explicit refresh-drifted-and-update-bundle flow re-snapshots. Live module @{} ref labels are still rewritten — only frozen bundle copies are left alone.
2026-06-12 11:39:50 +03:00
DumiFlex bb9ec6dfd6 fix(bundle): remap constraint source/target to restored uuids on cascade push
cascade-restore rewriteChildId only rewrote each child entry top-level id, never its payload. So when the cascade push re-created a missing wildcard under a fresh uuid, a sibling constraint kept pointing source_wildcard_id/target_wildcard_id at the dead uuid (and @{} refs likewise) — the pushed bundle shipped broken source/target.

Deep-walk payload + instance via walkRemap (now exported from uuid-remap) with the combined module+inner-bundle restore table, so restored uuids flow into constraint source/target + @{} refs. Refs to non-restored (external) uuids stay untouched.
2026-06-12 11:26:15 +03:00
DumiFlex e69ceaeb9a fix(spa): show rich-text placeholder ghost (zwsp pad defeated :empty)
The placeholder ghost was gated on .wp-rt__host:empty, but the host always carries a ZWSP pad span (padAtoms), so :empty never matched and the placeholder never painted for ANY RichTextInput — including the derivation override fields the user expects to show the library default.

Gate the ghost on an explicit wp-rt__host--empty class that tracks live content (updated on every input + programmatic apply, so it clears on the first keystroke like a native input). The ::before now overlays the text area (host position:relative + absolute ::before with padding:inherit) so the caret stays at the start. Revives placeholders across all RichTextInput hosts (combine, SPA derivation, canvas overrides).
2026-06-12 10:49:44 +03:00
DumiFlex ec3508a0bf fix(canvas): picker z-index + teleport theme + inline ↪#N badge in derivation summary
Three polish fixes for the canvas derivation instance modal's @{} feature:

- Fix 1 (z-index): raise the SubcategoryFilterPicker teleport backdrop/anchor
  from 1000/1001 to 10000/10001 so the step-2 sub-category picker sits ABOVE
  the canvas instance modal (matching the @-autocomplete popover's 9999 tier),
  instead of rendering behind it. CSS-only; SPA modals live well below 9999.

- Fix 2 (theme): propagate the host's resolved wp-theme-light/wp-theme-dark
  class onto the body-teleported autocomplete popover + picker anchor/backdrop
  so their --wp-* tokens resolve to the host palette rather than the base
  :root default. Detected live from the host's nearest themed ancestor, so it
  is correct in both the SPA and the canvas.

- Fix 3 (badge): render the inline ↪#N constraint-pair PairBadge after the
  value chips in the derivation rule summary (IF/ELIF/ELSE rows + collapsed
  rule head), mirroring OptionRow's per-option badge. Threads the existing
  via-option-pairs map (keyed by engine branch key) through ModuleEditModal
  -> DerivationInstanceModal -> RulesSection; reuses PairBadge variant=option.
2026-06-12 10:25:55 +03:00
DumiFlex ab813bbca4 feat(canvas): @{} chips + autocomplete in derivation instance modal
The CANVAS derivation instance modal now reaches full parity with the
SPA derivation editor: the read-only rule summary renders nested
`@{uuid}` refs + `$var` references as chips (was raw
`@{ad00acd5#mood:warm}` text), and the per-branch override fields are
RichTextInputs with `@`/`$` autocomplete + the sub-category filter
pop-up — replacing the plain `<input>`s.

Reuses the existing machinery rather than rebuilding it:
- RulesSection summary chips delegate to a new RuleValueChips
  presentational component that copies the wildcard OptionRow's
  tokenizeRich-based chip markup + palette verbatim.
- The action + ELSE-action override fields mount the lazily-imported
  RichTextInput (`surface=derivation`, `allow-nested-refs`, the six
  buildWildcardRefData maps) — engine resolves `@{}` there as a carrier.
- The condition override field is RichTextInput too but WITHOUT
  allow-nested-refs / ref-data (engine compares condition.value RAW);
  it still gets `$var` suggestions, mirroring the SPA condition field.
- DerivationInstanceModal fetches /wp/api/modules once on mount and
  builds the ref-data via the shared buildWildcardRefData +
  collectLibraryWildcardRefs (self id excluded). The `@{}` source is
  the LIBRARY by identity, not the chain.
- ModuleEditModal forwards upstream/sibling vars (the `$var` source);
  sibling/chain MODULES are intentionally not forwarded.

RichTextInput stays a single shared lazy chunk (Vite dedupes the
import combine's TemplateSection already pulls), so the extension
bundle stays within budget.
2026-06-12 07:44:25 +03:00
DumiFlex 2a1123f549 fix(spa): block native delete eating the ZWSP pad at field boundaries
Real-browser bug (jsdom could not reproduce): pressing Backspace with the caret at the absolute start of a chip field hit a bare early-return BEFORE preventDefault, so Chromium ran its native deleteContentBackward and ate the ZWSP pad char in the leading text span. Losing the pad stranded the caret on the host root; the contenteditable DOM then permanently diverged from the atoms model, producing the stuck "cant delete / $#" state and a downstream Vue insertBefore-null render crash.

Fix: preventDefault before returning at both empty boundaries (Backspace at range.start===0, forward-Delete at range.start>=totalLen) so the browser never natively deletes the pad. Diagnosed via a live in-browser console trace (jsdom cannot perform native contenteditable deletion); regression test asserts the guard fires.
2026-06-12 07:13:30 +03:00
DumiFlex 3564938b72 feat(spa): author @{} nested refs in derivation action values
The engine resolves @{} nested-wildcard refs on the derivation surface (Layer A), but the SPA derivation editor never got the matching authoring support — no @ autocomplete, refs stayed literal text. Reuse the wildcard editor machinery rather than build new.

Extract buildWildcardRefData(catalog) into utils/library-suggestions (one pass → the six per-wildcard maps RichTextInput consumes); WildcardEditor refactors onto it (no behaviour change). Add an allowNestedRefs prop to RichTextInput that opens the existing @-autocomplete gate + ref chipify on a non-wildcard surface. DerivationRuleCard forwards the maps + allow-nested-refs to ACTION + ELSE value inputs ONLY — condition values are compared raw by the engine, so they stay literal. Hint updated.
2026-06-11 22:46:39 +03:00
DumiFlex 0e9b5cfbbf refactor(constraint): centralise derivation branch-key format + py/ts corpus parity
Extract branch_carrier_key() (engine) and branchKey() (TS) so the derivation branch-key format (rule_id:bi / rule_id:else) has a single source per language; route all call sites through them.

Add branch_key_cases to tests/fixtures/constraint-corpus.json; the Py runner (test_constraint_corpus.py) and TS runner (constraint-pairs.test.ts) each assert their production helper against the shared fixture key, so the two formats cannot drift independently.

Resolved-output parity is engine-only (TS never resolves derivation refs); A1 already locks it.
2026-06-11 18:03:29 +03:00
DumiFlex 098c069d53 feat(constraint): list derivation-nested occurrences in reach pick-list + orphan scan
B4: add "derivation" to TargetReachSection local CARRIER_TYPES so
carrierOptionIdsFor finds derivation branch-key option ids in the
pick checklist; extend lookup builder to collect derivation
action.value strings for transitive @{uuid} reach.

B5: extend collectDownstreamNestedReachUuids to scan derivation
payload.rules[].branches[].action.value and else.action.value
with matchAll (same REF_RE as wildcard options), so orphan-scan
counts derivation-hosted @{uuid} refs as valid reach.
2026-06-11 17:45:18 +03:00
DumiFlex b27b9f8d7c fix(constraint): keep @{} carrier regex strict-hex (engine/dep-graph parity)
B2 relaxed REF_REGEX_VIA to [0-9a-z] to fit a non-hex test stub, diverging
constraint-pairs.ts from the canonical [0-9a-f]{8} uuid class used by the
engine tokenizer (tokenize.py / cascade/scan.py / pipeline.py) and the TS
dep-graph.ts twin. Revert to [0-9a-f] and switch the derivation tests to a
real hex stub (aabbccdd) instead. Carrier ref detection now matches the
engine exactly again.
2026-06-11 17:36:47 +03:00
DumiFlex 8247d739ce feat(constraint): recognise derivations as @{} carriers (branch-key option ids)
B2: add refBearingEntries helper + expand CARRIER_TYPES to include derivation;
rewrite carrierOptionIdsFor to iterate refBearingEntries so derivation rules
contribute keyed entries (rule_id:bi / rule_id:else) matching the engine's
derivation_handler branch-key contract. B3: refValuesByUuid build loop in
computePairingsFull now calls refBearingEntries so derivation action values
participate in transitive @{uuid} reach. Also relaxes REF_REGEX_VIA uuid
character class from [0-9a-f] to [0-9a-z] so test stubs with non-hex letters
round-trip correctly (production uuids remain lowercase-hex, a strict subset).
2026-06-11 17:28:51 +03:00
DumiFlex 599c3b17f6 feat(engine): stamp derivation carrier (uid, branch_key) for nested reach
_apply_action now accepts carrier_key and stamps (module_uid, branch_key)
on the resolve context before calling resolve_text, enabling SP3 pick-reach
constraints to target wildcards nested inside a specific derivation branch.
Branch call site passes f"{rule_id}:{bi}"; else call site passes
f"{rule_id}:else".
2026-06-11 17:19:28 +03:00
DumiFlex 059517f740 feat(engine): resolve @{} refs on the derivation surface
open the surface gate in _resolve_ref for surface="derivation" so
action.value strings can embed @{uuid} wildcard refs. conditions
stay raw (they compare literal text, never roll). constraint bucket
threads through build_resolve_ctx unchanged, so chain constraints
re-weight nested targets for free.

update parametrized ref_out_of_surface tests to remove derivation
from the gated surface list — it now resolves like wildcard.
2026-06-11 17:09:46 +03:00
DumiFlex 73a00a3760 fix(context): constraint-reach QA, node codenames, bundle-aware toolbar
QA + polish on the unmerged SP3 constraint-reach work, plus the node-id
redesign and bundle-aware bulk toolbar.

- reach badge reactivity: pairingBadgeEqual compares reach + pairingMapEqual
  compares contributors (context.ts) so a target_select edit re-renders.
- TargetReachSection: neutral section highlight + custom up/down count stepper.
- nested carrier renders ONE badge (constraint-pairs.ts: nested coverage goes
  to viaInbound only, not also contributors).
- instance target_select override reaches the badge: cross-node-pairings.ts
  folds the instance value over payload; the modal collapses to null only when
  it equals the library default (preserves an explicit "all"). Engine
  precedence (instance over payload) locked by tests.
- graph.ts writeBindings honors instance.variable_binding, fixing the duplicate
  shadow ("override") badge + missing-var scan + preview for renamed bindings.
- node identity: fixed, unique, POV-independent adjective-noun codename from the
  litegraph node id (node-codename.ts); read-only neutral chip; dropped the
  editable node_label + A/B/C letters.
- nested-pick label reads "@<target> in <carrier>" (target by display name).
- bulk toolbar: toggle-all flips bundle masters + top-level modules (not bundle
  children); collapse/expand-all flips bundle frames; buttons resized to the
  row-icon size.
2026-06-11 16:38:56 +03:00
DumiFlex d826c6ba61 fix(ts): pick reach stores bare _uid; partial_reach severity info + stale-comment cleanup 2026-06-11 12:49:23 +03:00
DumiFlex 83149630ab docs: update constraint conventions to the SP3 reach model 2026-06-11 10:32:10 +03:00
DumiFlex 826d86f70d feat(ts): stamp schema_version 4 when constraint target_select is non-default 2026-06-11 10:22:32 +03:00
DumiFlex 764c82ee73 feat(ts): debug rows for constraint never_applied + partial_reach 2026-06-11 10:07:23 +03:00
DumiFlex a0f9651c6b feat(ts): library-default target_select in the manager constraint editor 2026-06-11 10:02:22 +03:00
DumiFlex c80605ac4e feat(ts): constraint reach selector — segmented modes + cross-node pick checklist 2026-06-11 09:45:38 +03:00
DumiFlex 3f14a6ff35 feat(ts): per-selector constraint orphan scan (drop one-shot claim-order) 2026-06-11 09:19:46 +03:00
DumiFlex 02ed686129 feat(ts): badge reframe — reach suffix, contributor cluster + solid collapse chip, before name 2026-06-11 09:01:23 +03:00
DumiFlex 39022106c3 feat(ts): pairings mark-all reach + contributors (drop exclusive claim) 2026-06-11 08:37:30 +03:00
DumiFlex a94cd543d0 feat(ts): per-WP_Context node_label, tagged through the cross-node walker + editable header chip 2026-06-11 08:10:20 +03:00
DumiFlex 71e05b1956 fix(ts): thread cross-node chain to the constraint modal (chain-modules prop) 2026-06-11 07:49:06 +03:00
DumiFlex 1ddbe4548f feat(ts): transitive nested-target carrier walk (cap + cycle) with route chain 2026-06-11 07:25:27 +03:00
DumiFlex b20faa0ca8 feat(ts): preview resolver applies combine + reach (engine parity) 2026-06-11 07:06:14 +03:00
DumiFlex 2eb0e365b7 feat(ts): combineConstraintFactor mirror + shared corpus parity
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.
2026-06-11 06:49:59 +03:00
DumiFlex 4165b2e9f4 feat(engine): thread carrier uid + option id for nested-pick constraint reach 2026-06-11 06:42:16 +03:00
DumiFlex be1f765243 feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot
Replace the constraint one-shot/consumed-set model with a reach selector:
each constraint's target_select (default {mode:"all"}) decides which firing
target instances it covers, and every covering constraint is applied +
combined sequentially. A per-constraint hit counter (__wp_constraint_hits__)
replaces the consumed-set; reach is downstream-relative (a constraint is only
in ctx after its module ran).

- apply_constraints_for_target rewritten: hits/firing_uid/carrier_ctx params,
  _occurrence_matches matcher (carrier_ctx None from callers — nested-pick
  threading is a later task). consumed= kept accepted+ignored for back-compat.
- claim_carrier_constraints deleted (the positional spill failsafe is obsolete
  without consumption); its import + both wildcard_handler call sites + the
  _claim_carried helper removed.
- resolve.py nested path shares the ctx hit counter (get_constraint_hits) so
  first/next coverage spans direct + nested encounters; carrier_ctx=None.
- pipeline.py finalisation now hits-based: constraint_never_applied when a
  selector covered zero, new constraint_partial_reach when next N / pick
  matched fewer than asked.
- cross-node: __wp_constraint_hits__ replaces __wp_consumed_constraints__ in
  _CROSS_NODE_INTERNAL_KEYS; build_payload dedup also collapses partial_reach.

Existing one-shot tests adapted to the new semantics:
- test_constraint_first_instance: the implicit "fires once" tests re-expressed
  via explicit target_select={mode:"first"}; added an all-mode-default test and
  a partial_reach finalisation test. The pre-seeded-consumed no-op test became a
  first-mode "counter already past 1" test (consumed-set concept gone).
- test_user_chain_simulation: consumed assertions → hit-count assertions; the
  carrier-claim tests (no-spill, null-pick claim, source-not-picked, sibling
  one-shots) rewritten to the new model — a carrier that doesn't resolve the ref
  no longer "claims" anything, so the constraint applies on the next actual
  resolution (or legitimately never fires → never_applied).
- test_module_interactions: "only first fires" composition test replaced with a
  two-all-constraints-compose-on-one-target test (both excludes zero the pool →
  excludes-all fallback), plus a single-all-constraint survivor test.
2026-06-11 06:21:57 +03:00
DumiFlex 8c3c9aedc2 feat(engine): validate + register constraint target_select; instance override field 2026-06-11 05:53:44 +03:00
DumiFlex f68d2ece81 feat(engine): per-pick recording + apply constraints via combine_constraint_factor 2026-06-11 05:41:40 +03:00
DumiFlex dd785c5ca7 feat(engine): pure combine_constraint_factor + shared constraint corpus 2026-06-11 05:29:40 +03:00
DumiFlex 8ad2d0cec6 fix(ui): stack the pool allow-repeats + exclude-null toggles
Both were `display: inline-flex`, so when multi-pick + a null option are both
present they flowed onto one line and aligned by baseline — the two boxes read
as vertically misaligned (only visible now that the boxes actually render).
Make each a block-level `flex` row with `width: fit-content` so they stack with
their boxes left-aligned in a column.
2026-06-10 02:13:48 +03:00
DumiFlex 8000e1b75c fix(ui): render embed/canvas checkboxes via a scoped WpCheck component
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.
2026-06-10 01:59:33 +03:00
DumiFlex c6baeb8136 refactor(ui): convert remaining native checkboxes to the shared wp-check look
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.
2026-06-10 01:34:05 +03:00
DumiFlex 62d3768428 refactor(ui): make .wp-check the one checkbox style + restyle to wildcard-option look
The wildcard pool's per-option box (`.opt__check`) is the look we want for
every checkbox. Restyle the global `.wp-check` to match it and make the rule
markup-agnostic so a bare `<span role="checkbox">` gets the same style as the
ui/Checkbox.vue `<button>` — no component import needed in the size-sensitive
embed tree.

- tokens.css: `.wp-check` geometry now matches `.opt__check` (14px, 1.5px soft
  border, 3px radius, sunk into --wp-bg, 9px tick). On/disabled selectors gain
  `[aria-checked="true"]` / `[aria-disabled="true"]` alongside the existing
  `[data-checked]` / `:disabled` so spans and buttons share one definition.
  On-state fill stays accent-600 for the white-tick contrast audit.
- PoolSection: drop the duplicate shared/Checkbox.vue I had added; the
  exclude-null + allow-repeats toggles are now inline `wp-check` spans,
  matching the OptionRow / ValueRow / ExceptionsSection siblings and keeping
  the embed bundle within budget.
- tests: assert the aria-checked / aria-disabled span contract.

Custom toggle BUTTONS (internal flag, seed-lock) are intentionally untouched.
2026-06-10 00:55:37 +03:00
DumiFlex e78592839f feat(spa): "Allow repeats" toggle for the wildcard pool multi-pick
Exposes the pick_independent instance field (engine resolution landed in 33b3600): instance schema + snapshot round-trip, TS instance type + _shell INSTANCE_FIELDS_PER_KIND registry (so reset-overrides + modified-state + the TS/Py parity gate all include it) + preview cache-key, and the canvas PoolSection 'Allow repeats' toggle (shown only while multi-pick is active; off = unique). Instance-only (per-node, canvas runtime), additive + tolerant — no community catalog change.
2026-06-10 00:09:10 +03:00
DumiFlex 33b3600bf8 feat(engine): independent (allow-repeats) multi-pick for the wildcard pool
The pool multi-pick (PICK min-max) now honors instance.pick_independent: when true it draws WITH replacement (repeats allowed) and the count is NOT clamped to the pool size, mirroring the inline ~ flag. Default (unique, pick_k_unique) path is byte-unchanged, so existing locked seeds reproduce. Foundation for the 'Allow repeats' pool toggle; schema (instance + library default) + TS parity + UI follow.
2026-06-09 23:44:25 +03:00
DumiFlex ba455fb098 fix(spa): colour rich-text syntax on the ComfyUI canvas
A RichTextInput/RichTextPreview mounted inside a Context node (combine template field, constraint exception preview) rendered brace-block scaffolding + inline tokens uncoloured: the colour rules live in manager/styles/rich-text.css, loaded only by the SPA (manager/main.ts), never on the canvas (which gets theme.css = :root vars only). Ship a tiny rich-text-canvas.css with just the .wp-rt-* colour rules and import it once at the extension entry. Values mirror rich-text.css (colour only, no background).
2026-06-09 23:27:34 +03:00
DumiFlex 55d9ef19e0 fix(spa): show the !null ban mark on canvas inner-block ref chips
An exclude-null-only ref @{uuid#name!null} has no ':expr', so the lexer's name capture swallows the '!null'; OptionRow.refFilter only inspected sub_categories and missed it, so a decomposed inner ref (e.g. {3$$,$$@{uuid#mood!null}}) showed no filter/ban indicator. refFilter now peels a trailing '!null' off the name (mirrors the SPA RefChip 4-segment lift) and refDisplay strips it from the label so it reads @mood, not @mood!null.
2026-06-09 23:20:38 +03:00
DumiFlex 0962aad413 feat(spa): canvas wildcard editor decomposes blocks into inner chips (sp2b)
OptionRow rendered a whole {a|b}/{N$$sep$$…} block as raw text, so a nested @{uuid} showed the literal uuid instead of a chip. It now decomposes blocks the way the SPA editor does (mirrors atomicEditorModel.parse): scaffolding stays block-coloured, inner @{uuid}/$var arms become the same chips top-level ones use (with resolved names — inner ref uuids are now fed to ensure()). Also: preview-tokens DP_MULTI_RE accepts the SP2b range/~ prefix so the combine/fixed-values preview panes colour {2-4~$$…} too.
2026-06-09 22:00:13 +03:00
DumiFlex 62f2e4aafb feat(spa): resolve inline blocks in the combine preview (deterministic)
The combine RESOLVED preview left {a|b}/{N$$sep$$…} literal. It now expands them deterministically (seedless preview): an alternation -> its first branch, a multi-pick -> its first N branches joined by the separator, with nested $vars resolved via the upstream map. Runtime still rolls the real RNG; this is the static preview's concrete example. The preview pane now also shows when a template has only inline blocks (no vars).
2026-06-09 21:49:40 +03:00
DumiFlex 51a526eb06 fix(spa): chip whitespace-padded block arms + recolour blocks on edit
#4b: decompose each brace-block arm token-by-token instead of only when the arm is a SINGLE token — a leading/trailing space (e.g. {2$$, $$ $variable1|$variable2}) made the arm two tokens so the var never chipped. Now ref/var tokens chip and surrounding whitespace/literals stay scaffolding.

#3 correction: replace the blanket blockColor-preservation in readHostAsAtoms/deleteRawRange with recolorBlocks — re-derive scaffolding colour from the live serialised text after an atom-direct edit (split text atoms at block-token boundaries, never chipify). A still-valid block keeps its colour; breaking a block by deleting its {/}/| or $$ delimiter now correctly de-colours the scaffolding (previously it stayed coloured).
2026-06-09 21:18:02 +03:00
DumiFlex 619cd5ab1e fix(spa): keep brace-block scaffolding colour through atom-direct edits
The Wave-5 atom-direct edit path (backspace/delete) rebuilt text atoms via readHostAsAtoms WITHOUT blockColor, and deleteRawRange dropped it on slice/merge — so editing inside a {a|b}/{N$$…} block stripped the amber/green scaffolding colour until the next full settle re-parse (#3, confirmed via live repro: deleting } dropped scaf-multi spans 2->0). Both now read/carry blockColor (readHostAsAtoms maps the .wp-rt-block-scaf--* class back to the atom; deleteRawRange preserves it through slice + merge).
2026-06-09 20:23:20 +03:00
DumiFlex 3a9a09aeb9 fix(spa): $var autocomplete fires after the $$ multi-pick delimiter
probeAutocomplete bailed whenever the char before a $ trigger was also $, treating the $$sep$$ multi-pick delimiter (e.g. {3$$,$$$style}) as a $$ escape and suppressing the var popover. Extracted the pure probe to utils/autocompleteProbe.ts and replaced the naive check with $-run parity: an even run is an escape/delimiter pair, an odd run's unpaired tail is a real $name/@{ token start. Unit-tested.
2026-06-09 20:08:33 +03:00
DumiFlex 6097590854 fix(spa): detect @{uuid} refs nested inside brace/multi blocks
refsInValue only scanned top-level tokens, so a @{uuid} living inside a {a|b} / {N$$sep$$…} block (which tokenises to ONE dp-brace/dp-multi token carrying its arms as raw strings in meta.branches) was never seen. The Wildcards list 'Uses nested refs' + 'Referenced by others' facets and the dependency graph all read 0 for such refs. Now recurse into each block's branches so a nested ref counts and produces a graph edge.
2026-06-09 19:50:46 +03:00
DumiFlex b9ae37a5f5 fix(spa): drop leftover background box behind multi-pick blocks
Two duplicate token-style sources still painted --wp-rt-token-good-bg behind {N$$…} multi-pick blocks after the SP2b V2 redesign: the canvas wildcard editor's scoped .opt__tok--multi (the visible regression) and tokens.css's unscoped .wp-rt-dp-multi (now dead — RichTextInput/Preview decompose before tokenising, so nothing emits that span — but kept in sync to prevent a future mirror/embed regression). Both are now colour-only, matching the SPA editor/preview block-scaffolding and the .opt__tok--brace sibling. Continuous block-coloured text, no box.
2026-06-09 19:33:29 +03:00
DumiFlex 36942a27ec feat(spa): stamp sp2b schema_version on publish when range/independent used
buildPublishBody now picks the community catalog schema_version per-payload via schemaVersionForPayload: SP2B_SCHEMA_VERSION (3) when the payload text uses a range count or the ~ independent flag, else CURRENT_SCHEMA_VERSION (2). Mirrors the community publish-side marker scan so both ends agree on which shipped text needs the higher catalog version. SP2b adds no structural migrator (a v2 payload is shape-identical), so CURRENT_SCHEMA_VERSION stays 2 — the stamp-version and the migrate-to-version are decoupled. The boot catalog probe already tolerates a head greater than CURRENT: decideInstallPath is per-payload and refuses only a breaking-in-interval shape.
2026-06-09 18:53:54 +03:00
DumiFlex ef6fadcf24 feat(spa): decompose brace blocks into scaffolding + inner chips, render v2
Decompose every {…} brace-block token in the SPA atom parser into a flat run of block-coloured scaffolding-text atoms + inner @{uuid}/$var chip atoms (token shape + engine untouched, so cross-language parity holds). The editor and read-only preview render scaffolding as continuous block-coloured text (amber {a|b}, green {N$$…}) with no background or box; inner refs/vars stay RefChips, the only bordered elements. parseForSurface preserves blockColor when folding surface-literal arms so a collapsed arm keeps the block's colour, and only merges text atoms that share a blockColor. Wave-5 atom-direct delete already treats a decomposed inner block chip as one cursor stop.
2026-06-09 18:43:48 +03:00
DumiFlex 63b824e4a1 feat(engine): range count + independent mode + lone-ref pool multi-pick
Resolve the SP2b multi-pick semantics:
- range count drawn in [min,max] (a fixed {N$$} keeps min==max and does NOT
  draw, so its seed sequence is byte-for-byte unchanged);
- independent (~) = N draws WITH replacement, each branch resolved
  independently (a lone ref re-rolls every time);
- unique lone-ref (count>=2, wildcard surface) = N DISTINCT options from the
  ref filtered pool — a per-site SP2a multi-select. count==1 stays on the
  legacy single-pick branch path (seed safe).

Extracts ref_option_pool (catalog lookup + :expr/!null filter + chain
constraints, incl. the strict-unknown raise + warnings) from _resolve_ref so
both the single pick and the lone-ref multi-pick share it. Also closes the
coverage gap: a $var branch inside a multi-block on the combine surface.
2026-06-09 18:08:52 +03:00
DumiFlex 0b36ce892b feat(engine): multi-pick prefix gains N-M range + ~ independent flag
The {N$$sep$$} brace prefix now also accepts a count range ({N-M$$}) and a
~ independent flag ({N~$$} / {N-M~$$}, repeats allowed; absent = unique).
Both tokenizers (Python engine + TS richTokenize) parse it identically
into dp-multi meta {min, max, independent}; legacy `count` is kept (== max)
so existing readers + the resolver behave unchanged for a fixed {N$$}.
Shared syntax-corpus.json locks Py≡TS parity (existing multi_pick entries
gain the new keys; new range/lone-ref cases added).

Resolution of the range/independent semantics lands next (engine resolver);
this commit is tokenizer + parity only — a fixed {N$$} is byte-for-byte
unchanged.
2026-06-09 17:54:48 +03:00
DumiFlex 94ccd7f192 test(wp_api): lock comma rejection on variable names server-side
The engine handlers already enforce _IDENT_RE on output_var / var_binding
/ fixed values[].name via validate_payload, wired into every
create/update/import through _validate_payload_for_type (returns 400 on
ValueError). Existing tests cover leading-digit / dunder / too-long /
empty cases; add the explicit comma scenario the user reported so the
exact "no settle-delimiter in a var name" contract is regression-proof
for all three module types. No production change — server gate already
existed; this characterizes + locks it.
2026-06-09 15:39:16 +03:00
DumiFlex c661550091 feat(manager): enforce identifier grammar on variable names
A produced-var name becomes a $var, so it must be a clean identifier
([A-Za-z_][A-Za-z0-9_]*). Anything else can never be referenced: the
tokenizer wont match it and the rich-text editors settle delimiters
(comma/space/;:/(){}[]!?) split it. This allowlist forbids the comma the
user hit AND every other junk char + leading digit.

Two TS layers:
- validateModule: flags an invalid output_var / var_binding / fixed
  values[].name as an error in the VALID column, catching bad names from
  ANY path (import, embed override, legacy data), not just typed input.
- embed/context instance-override inputs (wildcard + combine binding,
  fixed-values name) now strip non-identifier chars on keystroke and
  force-sync the controlled input, matching the main library editors. The
  fixed-values VALUE field stays free-form.

slug.ts gains stripNonIdentifierChars as the shared keystroke sanitiser.
Mirrors the engine FixedValuesHandler._IDENT_RE (Python = TS).
2026-06-09 15:32:06 +03:00
DumiFlex bf189b33d5 fix(manager): render unsettled $var/@ref as plain text in editor
textAtomHtml now collapses BOTH var and ref on every surface, so an
inline (unsettled, not-yet-a-chip) $name / @{uuid} renders as plain text
instead of the violet .wp-rt-var / magenta .wp-rt-ref highlight. The
absence of a chip already signals "not committed yet" — a second colour
just competed with the settled-chip (RefChip) palette and read as a
different kind of token (user feedback).

Surfaced by the Wave-5 atom-direct delete: backspacing re-renders the
remaining raw token through textAtomHtml, which previously painted it
violet. Settled chips keep their colour; the read-only RichTextPreview
is a separate render path and is unchanged; inline {a|b} brace / multi /
weight / escape colouring is untouched (those are not chippable tokens,
so their highlight is the only signal).
2026-06-09 01:02:06 +03:00
DumiFlex af90203f39 fix(manager): never chipify on backspace/delete in rich-text editor
Backspace/Delete now delete directly off the live atom structure
(readHostAsAtoms + deleteRawRange) instead of re-tokenizing the
remaining raw text via parseForSurface. Chip formation stays ONLY on
the commit paths (settle-delimiter / blur / autocomplete).

Fixes user-reported edit corruption: raw "$mood.1" + Backspace deleted
the "1" then chipified the remaining "$mood" into a chip plus a stray
"." text; any mid-edit "$name" token was eaten/chipified the instant a
char was removed. A committed chip still deletes whole (the deletion
span widens to chip boundaries); raw text that merely looks like a chip
loses exactly one char and stays raw until a real commit point.

Removes the surface-gated chip-regex + ZWSP charCodeAt guards + escape
guards from the delete path — all dead under the atom-direct model.
2026-06-09 00:21:08 +03:00
DumiFlex 435cdb178b fix(resolver): carry structured list-vars through static resolve so $mood.0 indexes 2026-06-08 23:39:16 +03:00
DumiFlex 743f8b08c8 fix(spa): single-char backspace/delete on raw-typed $var (atomic-delete only committed chips) 2026-06-08 23:24:38 +03:00
DumiFlex d9235c39b1 fix(spa): keep autocomplete open across a $var.K accessor (no Enter newline split) 2026-06-08 22:57:20 +03:00
DumiFlex b9df102a80 fix(combine): filter multi-pick representative to engine pool + honor .K in template preview 2026-06-08 22:52:07 +03:00
DumiFlex 2a24f9ff3a fix(pool): custom pick steppers + min<=max clamp + null-aware enabled/probability 2026-06-08 22:41:53 +03:00
DumiFlex d3881c84a1 fix(snapshot): include SP2a pick fields in instance baseline + snapshot type 2026-06-08 22:11:11 +03:00
DumiFlex 31fe070a45 fix(pool): surface multi-pick count in pool summary + instance subtitle 2026-06-08 21:56:41 +03:00
DumiFlex 7d267d0be6 fix(pool): strike + uncheck null row in multi-pick; align pick stepper styling 2026-06-08 21:49:33 +03:00
DumiFlex f922dca356 fix(graph): honor .K accessor + multi-pick representative in static preview 2026-06-08 21:42:14 +03:00
DumiFlex dc05a5d176 fix(assembler): render list-valued vars + .K accessor in node preview 2026-06-08 21:34:41 +03:00
DumiFlex 12082228e7 fix(spa): stable $var.K chip round-trip in rich-text editor 2026-06-08 21:24:54 +03:00
DumiFlex bc7ea336e7 fix(spa): resolve \.K to base name in derivation validation + conflict scan 2026-06-08 21:18:04 +03:00
DumiFlex 6703583db8 fix(preview): serialize list-valued vars in resolve endpoint 2026-06-08 21:11:59 +03:00
DumiFlex ace0486f9d fix(engine): guard junk pick counts + warn on multi-pick constraint source 2026-06-08 21:06:35 +03:00
DumiFlex d41be86fae fix(engine): fold list-valued vars in derivation + converter reads 2026-06-08 20:58:59 +03:00
DumiFlex 8d7bda2234 test(spa): lock accessor-aware var autocomplete match 2026-06-08 20:11:28 +03:00
DumiFlex d1ee97b3ec test(sp2a): lock multi-pick debug render + var.K cascade/validation 2026-06-08 15:08:22 +03:00
DumiFlex d167a1f564 feat(pool): multi-select pick-count control + null disable in multi 2026-06-08 14:59:10 +03:00
DumiFlex 64d1cb157e feat(spa): render var.K as a single chip + accessor-aware autocomplete 2026-06-08 14:50:26 +03:00
DumiFlex 17a2149fb0 feat: pick_min/pick_max/pick_separator wildcard instance fields 2026-06-08 14:40:40 +03:00
DumiFlex 443db49546 feat(engine): multi-select wildcard binds a list variable 2026-06-08 14:31:26 +03:00
DumiFlex 8eeb0a8032 feat(engine): list-backed variables + var.K accessor 2026-06-08 14:21:40 +03:00
DumiFlex 3ac0afa979 feat(engine): tokenize var.K list accessor (py+ts parity) 2026-06-08 14:12:42 +03:00
DumiFlex e5f54c6ba8 refactor(engine): extract pick_k_unique from multi-pick resolver 2026-06-08 10:13:40 +03:00
DumiFlex c5720e79bd fix(ui): peel !null in nested-ref filter chips on canvas + preview
The widget lexer comma-splits a nested ref's `:body` without peeling the
`!null` marker, so two chip renderers showed the glued raw filter:
- the canvas pool OptionRow printed `· warm or intense!null` inline;
- RichTextPreview fed the glued body to RefChip's legacy `subCategories`
  prop, whose reconstruction only stripped a standalone "null" element —
  so the hover title read `warm or intense!null` and the exclude-null ban
  never showed. That path drives the debug-node warnings, TestRunner, and
  constraint-exception previews.

Add a single-source `splitRefFilter` peel in richTokenize and route every
filter chip through it:
- OptionRow now renders the SPA RefChip grammar — funnel (expression) +
  ban (null excluded), expression normalized into the hover title, nothing
  inline.
- RefChip's legacy subCategories path peels a glued `!null` (fixes the
  whole RichTextPreview tree at once).
- RichTextInput's splitColonBody delegates to splitRefFilter so the editor,
  RefChip, and canvas can never drift on `!null` handling again.
2026-06-08 07:10:50 +03:00
DumiFlex 4cda98f227 fix(cascade): discover v2 boolean sub-category filter refs
Sub-category rename/delete discovery keyed on the whole filter body, so
it missed any ref using an SP1 boolean filter. `@{mood:warm or intense}`
indexed as `mood:warm or intense` (TS) / matched by `sub == "warm"` exact
equality (Python), so a rename or delete of `warm` neither updated the
SPA badge nor counted the ref in the server-side preview — even though
the rewrite (engine/cascade/fixers.py) already token-matches and DID
rewrite it on apply. Preview undercounted what apply changed.

Both discovery layers now collect the expression's tags via the shared
parser and index/match per-tag (negated tags count; the `!null` marker is
not a tag):
- TS: new collectTags in subcatExprCascade.ts; reverse-dep-index builds
  one toSubcat entry per tag.
- Python: new collect_tags in fixers.py; scan._scan_subcat tests tag
  membership instead of raw-string equality.
2026-06-08 06:41:15 +03:00
DumiFlex 5a0e05e6bf fix(validation): parse v2 boolean sub-category ref filters
The rich-text lexer still comma-split the `@{uuid:...}` filter body using
the v1 grammar and never peeled the `!null` marker, so validateModule saw
the whole SP1 expression as one literal sub-category name. A valid ref
like `@{mood:warm or intense!null}` was flagged "has no subcategory
\"warm or intense!null\"".

extractRefs now rejoins the body and carries the raw expression; the
wildcard validator peels the trailing `!null`, then validates each tag
via the shared subcatFilter.validateExpression parser — the same check
the picker runs inline, so list view and editor agree by construction.
Also refresh the now-misleading v1 lexer comment.
2026-06-08 06:22:52 +03:00
DumiFlex c77781062a fix(ui): shrink ref-filter action buttons by overriding the fixed .wp-btn height
The earlier padding/font tweaks never shortened Delete/Skip/Apply because
.wp-btn sets a fixed height: var(--wp-btn-h) (~34px) — padding cannot shrink
a fixed-height flex button. Override height to 24px in the picker actions so
they match the popover density (verified 24px live).
2026-06-08 04:01:25 +03:00
DumiFlex ff0f9dc0cd fix(ui): anchor ref-filter popover snug to its chip + move delete button far-left
- Popover floated too far from the pill. clampPickerIntoView() re-clamped
  using the over-estimated PICKER_APPROX_H, parking a flipped popover ~140px
  above the chip. Now it captures the trigger rect at open and, after the
  popover paints, re-anchors against it with the REAL measured height (6px
  gap) before clamping into the viewport — so it sits right on the chip.
- Delete moved to the far left of the picker actions (margin-right:auto),
  separated from Skip/Apply on the right — destructive-action-left so it is
  not adjacent to Apply.
2026-06-08 03:54:04 +03:00
DumiFlex b23e29e625 fix(ui): clamp ref-filter popover on-screen, show match count, link null toggle to exclude_null
Follow-up to the round-2 polish.

- Ref-filter popover ran off-screen. The enlarged popover outgrew the stale
  PICKER_W/PICKER_APPROX_H anchor estimates, so a chip near the page edge
  opened the picker partly off-screen. Bumped the estimates and added
  clampPickerIntoView(): after the popover paints, measure its real box and
  nudge it fully into the viewport (handles the variable palette height).
- Match count was hidden. WildcardEditor only forwarded uuid-to-sub-categories
  to RichTextInput, so the picker got an empty option-tag-set denominator and
  suppressed the "N of M options match" line — letting users build an
  always-empty filter blind. Now also forwards uuid-to-option-tag-sets (match
  count) and uuid-to-tag-groups (grouped insert palette).
- Null option row and "Exclude null" were independent. The graph pool's null
  row checkbox wrote enabled_options while the Exclude toggle wrote
  exclude_null, so the two desynced. isEnabled now governs the null slot
  solely by exclude_null (checked before the enabled_options gate), and
  toggling the null row routes to exclude_null — the two controls are one.

Tests: probability null-vs-enabled_options; PoolSection null-row drives
exclude_null.
2026-06-08 03:43:57 +03:00
DumiFlex 54054fb172 test(cascade): edge-case qa for subcat rename/delete (collapse, null, multi-surface)
QA pass on the SP1 Chunk E cascade. Adds eight regression cases beyond the
existing basic suite, all green: operator collapse with `not` and nested
parens (`warm and not cold` -> `warm`), middle-of-three OR delete, `!null`
marker preserved through a collapse, delete-only-tag leaving a bare
`@{uuid!null}`, a same-named subcat in a *different* wildcard left untouched
on rename, and a single rename/delete that has to land across every surface
at once — registry, option membership, constraint matrix key (source + target
side), and a nested `@{}` ref expression. No engine bugs found.
2026-06-08 03:27:18 +03:00
DumiFlex c2d0ef5379 fix(ui): subcategory polish across spa + graph (colours, exclude-null, palette, popover)
Round-2 feedback on the SP1 sub-category UI.

SPA (manager):
- #1/#2 SubcategoryFilterPicker — enlarge the ref-filter popover (340->400px
  min, taller 44px / 14px expression input) and shrink the Delete/Skip/Apply
  buttons (the default .wp-btn size read as oversized in the popover).
- #6 per-option tag picker — selected rows now tint + bold + fill the check
  box (hue-independent) instead of a near-invisible box-only tint.
- #8 group boxes — coloured left accent + coloured header keyed to the axis
  hue, plus a stronger pill tint, so each axis reads as a colour cluster.

Graph (canvas instance modal):
- #4 exclude-null never persisted — exclude_null was missing from
  RELEVANT_INSTANCE_KEYS (stripped from the preview body + chain hash), from
  INSTANCE_FIELDS_PER_KIND / RESET_FIELDS, and from the engine INSTANCE_SCHEMAS
  (the SP1 chunk documented it but never added the line); added everywhere.
  Toggle now uses native @change on the input instead of a label @click.prevent
  that fought the checkbox own toggle, so the box reflects the new state.
- #5 exclude-null control now hidden entirely when the wildcard has no null
  option (was shown-but-dimmed).
- #7 advanced expression editor gains an insert-at-cursor palette (grouped
  sub-categories + operators), mirroring the SPA ref-filter.
- #9/#6 pool quick-pills — faint axis-hue border so a group reads as a
  cluster even unselected; selected pills get a solid hue fill + bold +
  leading check so on is visible for any hue (incl. the grey ungrouped).

Tests: PoolSection exclude-null (emit/reflect/hidden) + advanced palette
insert; _shell + engine instance-schema parity updated for exclude_null.
2026-06-08 03:18:57 +03:00
DumiFlex 723adde0bb fix(manager): dismiss subcat menus on outside click + flip near edge + drag-to-regroup
Five live SPA bugs in the wildcard editor sub-category UI:

- kebab menu + per-option tag picker clipped off the page bottom on the last
  rows — measure the trigger on open and flip the dropdown upward when it would
  overflow the viewport (subcat-menu--up / opt-tags__picker--up).
- kebab / new-tag input / option picker stayed open when clicking elsewhere —
  add a document-level click-outside + Escape handler (closeAllMenus).
- dragging a pill to another group box did nothing — wire HTML5 drag/drop on the
  pills + group boxes, reusing moveTagToGroup (drop on ungrouped box = ungroup).
- SubcategoryFilterPicker (nested-ref filter popover) was cramped — widen the
  popover + expression input, shrink the insert chips + action buttons.

Regression tests cover outside-click-close + drag-to-regroup; flip-up is
geometry-driven and was verified live.
2026-06-08 02:27:40 +03:00
DumiFlex 3bcafc986c fix(ui): style wildcard editor sub-category groups + option-tag picker
WildcardEditor.vue shipped the Chunk H markup (group boxes, hued pills with kebab, per-option multi-select picker) but its <style> still held only the OLD sub-category classes — every new class was unstyled, so the section rendered as glued plain text ('calm2', 'ungroupedcalmintense'). Added the missing CSS using manager tokens: .subcat-group* boxes, .subcat-pill* (axis-hued, separated count badge), .subcat-menu* kebab dropdown, .subcat-addtag*/.subcat-add-group, and the H2 .opt-tags* chips + chevron + grouped checkbox .opt-tags__picker. Kebab opens rightward (left:0) so it does not clip on left-edge pills. Verified live in the SPA (build:manager) across all three popovers.
2026-06-08 01:46:21 +03:00
DumiFlex a62851256b fix(engine): slugify nested-ref tags in v1->v2 migrate + sp1 e2e smoke
The migrator now slugifies each tag inside a nested @{} ref's filter list (cross-wildcard refs track the target's renamed registry entry — deterministic, same _slug the target applies), not just comma->or + null->!null. Idempotency-safe: the rewrite branch only fires on v1-shaped (comma/null) refs; v2 refs are left verbatim. Mirrored in src/manager/import-export/migrations.ts.

tests/test_sp1_smoke.py: end-to-end SP1 acceptance — a v1 library payload migrates to v2 (registry + option tags slugified, nested ref comma->or + slug + !null, instance category_filter list->expr + exclude_null), the migrated payload validates, and the boolean pool matcher resolves over the new multi-tag option shape.
2026-06-07 05:01:05 +03:00
DumiFlex c374863c87 feat(ui): wildcard editor sub-category groups + per-option multi-tag
api/types.ts: WildcardOption.sub_category -> sub_categories[] (default []); WildcardPayload.tag_groups. Cascaded the type flip across the manager: resolver.ts constraint matrix keys on the primary tag (sub_categories[0], SP1 stopgap mirroring the engine; SP3 multiplies), ConstraintEditor local type, DebugViewer pick display, starter-recipe seeds, + field-shape migration in the affected tests (behavioral assertions intact).

Chunk H1: WildcardEditor renders the sub-category registry as group boxes (one per tag_groups axis + an ungrouped box) with inline +tag / +Group and a per-pill kebab (rename/move/delete via the expression-aware cascade) — drops the old global add bar + external edit button. H2: per-option sub-category cell is now a grouped multi-select (removable chips + a grouped checkbox picker), toggling option.sub_categories. Full vue-tsc + vitest green.
2026-06-07 04:55:06 +03:00
DumiFlex 33e6bd0d34 feat(ui): nested-ref filter popover + chip + in-graph pool editor
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.
2026-06-07 03:41:01 +03:00
DumiFlex 11ad8ca4bc feat(cascade): expression-aware subcat rename/delete (engine + ts)
engine/cascade/fixers.py: _rewrite_subcat_ref_in_string + _strip_subcat_ref_in_string now parse a nested ref's :expr segment via subcat_filter, map the tag token (rename, or remove + collapse childless operators), and re-serialize via reads_as — preserving #name + !null. Bare-subcat refs (@{u:cold}) collapse to a bare @{u} on delete; boolean refs (@{u:warm or cold}) collapse to @{u:warm}. Only refs that actually contain the target tag are rewritten (no cosmetic churn).

src/manager/cascade/subcatExprCascade.ts: TS mirror (renameSubcatInExpr / removeSubcatFromExpr via parse + readsAs + mapTags) for the client-side cascade.
2026-06-07 02:33:05 +03:00
DumiFlex 221aa9097b feat(manager): mirror v1->v2 migration + bump client schema version
src/manager/import-export/migrations.ts: CURRENT_SCHEMA_VERSION 1->2; migrateV1ToV2 envelope step (mirror of engine/migrations/v1_to_v2.py) registered in MIGRATION_CHAIN — option sub_category->sub_categories[], sub-category name slugify+cascade, instance category_filter list->boolean-expr + exclude_null, nested @{uuid:a,b,null}->@{uuid:a or b!null}. Idempotent.

v2 Zod validators (src/validators/module/v2.ts): wildcardV2 (sub_categories[] + optional tag_groups); other module subtypes + bundle root unchanged, re-registered under the :2 registry keys. Export moduleRowBase from v1. Regenerate the engine-parity wildcard fixture to v2 shape.

The WildcardOption TS interface + its UI consumers stay on the v1 shape (deferred to the UI chunk) — this commit is runtime parity only (migrator + Zod + stamping), so vue-tsc stays green. Update version/entity-count/MIGRATED-label assertions in the migration, import-tab, import-picker, and parse tests.
2026-06-07 02:07:43 +03:00
DumiFlex 39529a669b feat(engine): migrate library v1->v2 (multi-tag) + bump schema version
engine/migrations/v1_to_v2.py: option sub_category->sub_categories[]; sub-category name slugify (whitespace/grammar chars->_, reserved suffixed, de-duped) cascaded to options + tag_groups; instance category_filter list->boolean-expr string + exclude_null flag; nested @{uuid:a,b,null} refs->@{uuid:a or b!null}. Idempotent. Two entry points share one core: envelope migrate_v1_to_v2 (import/export, registered in _CHAIN) + per-row migrate_module/bundle_v1_to_v2 (lazy/bulk DB path).

Register _CHAIN[1] + bump engine CURRENT_SCHEMA_VERSION 1->2. New SQL migration 016 marches stale DB rows 1->2 at boot via run_bulk_lazy + the per-row migrators (014's documented bump pattern). TS mirror (migrations.ts) + client CURRENT bump follow next (no cross-language version assertion couples them). Update version/entity-count assertions in payload-migration, migration-runner, and export-build tests.
2026-06-07 01:40:53 +03:00
DumiFlex 13de622c6b feat(engine): multi-tag options + boolean sub-category filters (v2)
Option sub_category -> sub_categories[] (default []). Both pool filters (instance category_filter, nested @{} ref) become boolean expressions (and/or/not/parens; comma=or) matched against an option's tag set via the shared engine/syntax/subcat_filter matcher, applied BEFORE constraints. Exclude-null is a separate flag: instance exclude_null + the trailing !null ref marker, replacing the old 'null' keyword.

4-segment ref grammar @{uuid#name:expr!null} single-sourced across tokenize.py/scan.py/pipeline.py/_constraints.py (! excluded from name/expr captures); tokenizer REF meta now carries filter_expr + exclude_null instead of a comma-split list. scan.py findall unpack sites updated for the 4th group.

validate_payload: per-option sub_categories[] must be in the registry; optional tag_groups (UI-only axes, members in-registry, <=1 group); registry names validated via subcat_filter.validate_subcat_name (rejects whitespace, boolean/ref chars, reserved words incl. null). wp_api/_validators delegates to the same function.

Constraints stay alive via a primary-tag stopgap: _apply_constraint_to_options + _record_pick use sub_categories[0] as the matrix key; true multi-tag matrix matching is SP3. Cascade fix_subcat_delete/rename + orchestrator scrub the per-option list; instance_schemas category_filter -> string. Existing engine tests migrated to the v2 shape; added tests for the ref regex, payload validation, and multi-tag pool filtering.
2026-06-07 00:43:55 +03:00
DumiFlex 0e1803ec12 feat(syntax): boolean sub-category filter parser, matcher + ts parity
engine/syntax/subcat_filter.py: tokenize->AST parse, matches(), reads_as() normalizer, validate_subcat_name + validate_expression (grammar: not>and>or, comma=OR, n-ary flatten). src/manager/parsing/subcatFilter.ts: behavior-identical TS mirror. Shared fixture routed through validate_expression in both languages so syntax + reserved/unknown errors stay in parity.
2026-06-06 23:22:44 +03:00
DumiFlex 04841aaab9 fix(community): sync embed theme with the manager toggle live
Community.vue mounted the embed with a hardcoded theme: "auto" and never
watched the manager theme, so flipping light/dark left the embed stuck
(it tracked the OS, not our toggle). Forward uiStore.themeMode at mount
and watch it through the new handle.setTheme so the embed re-skins live.

setTheme is optional + guarded in the local EmbedHandle mirror: the
bundle loads at runtime from the community host, which under
community-first deploy ordering may be an older build without it.
2026-06-06 18:25:36 +03:00
DumiFlex 4fabb7685e feat(import): surface cross-kind id clashes as CLASH; never replace
Import-from-file picker now threads module type into its library map, so detectCollisions classifies a same-id different-kind row as type-conflict. ConflictModal renders it as a violet CLASH badge (vs the misleading MODIFIED/EXISTING), and partitionSelection coerces any replace/accept on a clash into install-as-new so the manual import path can't clobber a different-kind live row either. Completes the shared classifyOne unification across all three surfaces.
2026-06-06 17:01:28 +03:00
DumiFlex ca54a3a323 feat(install): flag + backstop cross-kind id clashes (never replace)
detectInstallCollisions now flags a typeConflict when the live row at an incoming id is a DIFFERENT module kind (shared 8-hex id-space). enforceClashSafety coerces any 'replace' on a clash into install-as-new, regardless of what the resolveCollisions UI returned — a UI-agnostic data-layer backstop that prevents clobbering an unrelated item. Both snapshotLibrary copies (main.ts + CommunityUpdateDialog) now carry module type so the check can run. Community-embed install modal honoring the flag (hide Replace, show clash) is a follow-up in the community repo.
2026-06-06 16:49:16 +03:00
DumiFlex c13351d21c feat(context): type-aware drift/missing + id-clash state via classifyOne
Workflow drift/missing now runs through the shared classifyOne verdict: a same-id, DIFFERENT-kind library row surfaces as a distinct 'clash' state (violet badge) instead of a misleading drift/missing dot, and Refresh is suppressed + guarded in mergeRefresh so it can never clobber an unrelated item. Hybrid content-key: the workflow path compares payload_hash (drift semantics unchanged) while the import picker keeps fingerprint; classifyOne is content-key agnostic. /wp/api/modules/hashes now returns {type, payload_hash} per id; drift-store map + AppLayout fingerprint + setLibraryHash updated to the object shape.
2026-06-06 16:39:49 +03:00
DumiFlex 885f7fb98a feat(api): modules/hashes returns type + fingerprint per id 2026-06-06 16:10:33 +03:00
DumiFlex 9e34d23129 feat(collision): add type-conflict state + classifyOne core 2026-06-06 16:05:09 +03:00
DumiFlex 4b491b8fa5 feat(manager): nsfw content filter on every list view + all items
Adds a tri-state Content filter (All / SFW / 18+) to the shared list
filter panel in ModuleListView, so it shows on all per-kind module pages
AND the All-items aggregate. Client-side over the row's content_rating
(the same field driving the 18+ pill), so no fetch. Persisted via the
shared BASE_LIST_SCHEMA (`?nsfw=`) so it survives reload + is bookmarkable
across every list view at once. Clear-filters resets it.

Updates the useListUrlState schema test for the new 8th base field.
2026-06-06 14:08:14 +03:00
DumiFlex 0da118e10b fix(manager): bundle-editor constraint names + list keyboard nav reachability
Two issues surfaced after the prior batch was actually built + run:

- bundle editor (#2): a constraint child's matrix axes showed "Source ·
  source" / "Target · target" because BundleChildPane never passed
  source-name/target-name to ConstraintMatrixSection (unlike the Context
  ConstraintInstanceModal). Now resolves source_wildcard_id /
  target_wildcard_id against the bundle's other children (passed down as
  sibling-modules) → "Source · <wildcard name>". All siblings are
  in-memory, so no async resolver needed.

- list arrows (#3): the @keydown lived on the <tbody>, but clicking a row
  focuses one of its cell buttons (select / expand / tag / pill), never
  the tbody, so the handler never fired. Moved tabindex + @keydown to the
  scroll wrapper — a common ancestor that receives bubbled keydown from
  any focused descendant and is itself focusable for Tab / gutter clicks.
2026-06-06 13:35:09 +03:00
DumiFlex 915e0cce0d fix(manager): row actions/focus follow hover+keyboard, not a sticky state
Follow-up to the list-keyboard work: the prior fix seeded focusedRowId
whenever the <tbody> gained focus (incl. a click landing in the list),
and row actions/stripe were driven off the data-focused state — so a
clicked row kept showing its action buttons + accent stripe after the
pointer left ("rows stay selected / show actions when not hovered").

- Drop the tbody @focus seeding; the keydown handler already seeds row 0
  on the first ArrowDown/Up (moveFocus i<0 → 0), so arrows still work
  once the list is focused without leaving a sticky row on mere click.
- Row actions now reveal on :hover (mouse) or :focus-visible (keyboard)
  only — not :focus-within / data-focused — so a mouse click never
  leaves them stuck on.
- Focus stripe likewise moves to :focus-visible, so the accent marker
  tracks the keyboard cursor and never lingers after a click.
2026-06-06 13:19:37 +03:00
DumiFlex 24e83ea9b1 fix(manager): community pill lifecycle, list keyboard nav, dashboard intro
Batch of community-integration UX fixes:

- update pill (#4/#6): re-run the community update check on every
  library drift delta (AppLayout watches), so the per-row pill tracks
  reality without a manual refresh — clears after an in-place update,
  reappears after deleting an install-as-new copy.
- update pill (#5): communityUpdateStore now suppresses the pill on any
  row whose post already has a sibling installed at latest, so
  "install as new" clears the older row's pill (and it returns if that
  copy is later deleted).
- list selection (#7): arrow-key nav now pushes real DOM focus onto the
  active row, so :focus-visible tracks the roving-tabindex state instead
  of lingering on the originally-clicked row — single highlight.
- list keyboard (#8): <tbody> is focusable and seeds the first row on
  focus, so arrows work from a cold start without clicking a row first.
- dashboard (#9): the "New here?" intro card now sits ABOVE an
  always-rendered recents list (was mutually exclusive) and has a
  persisted dismiss X.
- all items (#10): forward community_post_slug / version / content_rating
  through the row mappers so the globe + 18+ pills render in the
  aggregate view like they do per-kind.
2026-06-06 11:38:21 +03:00
DumiFlex 51f6793177 test(validators): engine-parity harness to stop validator drift
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.
2026-06-06 10:44:01 +03:00
DumiFlex 8a9ee8c85e fix(validators): match real engine shapes + widget-native bundle children
Engine↔community shape audit. The TS validators were hand-authored
from a guess and drifted from the real engine output, so valid modules
failed strict publish validation.

Standalone module validators (module/v1.ts), aligned to the engine
handlers:
- wildcard: var_binding + sub_categories are engine-optional; options
  gain the real `is_null` field (the null-option contract) -- strict
  was rejecting null options, and strip would have dropped is_null,
  producing an empty-value option the consumer rejects on install.
- combine: input_vars optional. constraint: source/target optional,
  matrix/exceptions optional. (Engine reads all via .get with defaults.)

Bundle children (bundle/v1.ts): a child is the engine's frozen WIDGET
snapshot ({id, type, meta:{name}, payload, instance, enabled,
collapsed}) or a nested-bundle ref -- NOT a flat module row. Modeling
it as a module row rejected the real shape AND lost the name (it lives
under meta), so installed bundles round-tripped to "(unnamed)".
bundleV1Child now validates the snapshot loosely (require id+type,
enumerate known fields so tolerant strip keeps them, payload opaque).

single-row-publish: buildBundlePublishable ships children verbatim as
widget snapshots (history stripped per child) -- lossless round-trip,
no resolver/flatten. Dropped ModuleResolver + the catalog-fetch in
CommunityRowActions + the resolver arg in ExportTab.

Tests rewritten: children ship verbatim with meta.name preserved +
history stripped; bundle passes strict validation. 24 validator +
import-export tests green.
2026-06-06 09:52:02 +03:00
DumiFlex 191f880fa2 fix(publish): correct fixed_values shape + strip history sidecar
Publishing any fixed_values module (standalone or as a bundle child)
failed strict validation: "payload.entries Required".

Two root causes:

1. The v1 fixedValuesV1 validator + its fixture were authored in Task
   11 from a guess -- `payload.entries: [{variable_name, value}]` --
   and never exercised against a real row. The actual engine shape
   (engine/modules/fixed_values_handler.py) is
   `payload.values: [{id, name, value}]`. Corrected the validator +
   the module-fixed_values.json fixture to match reality.

2. Edited modules carry a local `history` sidecar in their payload
   (utils/history.ts, max 3 entries). The strict validators reject it
   as an unrecognized key, and it shouldn't ship to the community
   anyway (leaks prior values + bloats the payload). stripHistory() in
   buildModulePublishable drops it for BOTH publish and copy-to-
   clipboard; the bundle-child flatten fallback strips it too. The
   resolve + passthrough child paths already route through
   buildModulePublishable.

Tests: buildModulePublishable strips history; a real edited
fixed_values row (values shape + history) passes strict validation.
26 validator + import-export tests green.
2026-06-06 09:25:20 +03:00
DumiFlex f8efcc92d7 fix(community): preserve bundles/library/deleted views in host nav
The embed -> host -> URL -> parseRoute round-trip dropped every view
except detail/profile/publish. Community.vue's parseRoute fell through
to browse for bundles/library/deleted, and onNavigate's else branch
router.replace'd to bare /community (browse). So:

- Clicking Bundles / My library went to those views for one frame,
  then the host round-trip clobbered them back to Browse.
- The in-embed Back button (targets the previous LIST view, e.g.
  library/bundles) collapsed to Browse too -- "back button does not
  work at all".
- Browse stayed highlighted because the resolved view really was
  browse after the round-trip.

Fix: map each view to its own URL segment in BOTH directions --
/community/bundles, /community/library, /community/deleted. parseRoute
reads them back; onNavigate writes them. publish stays unmirrored on
purpose (host-initiated with a payload hash router.replace would
strip). EmbedNavigateTarget/MountOptions types gain "deleted".
2026-06-06 09:02:41 +03:00
DumiFlex 47b153503f fix(publish): normalize bundle children to module-row shape
Publishing a bundle failed strict validation with dozens of
"children[N].name Required" + "Unrecognized key(s): enabled, collapsed,
meta, payload_hash, instance, entries" errors.

Root cause: bundles created in-app store their children in the
WP_Context widget shape -- name nested under `meta`, payload in widget
form (fixed_values uses a top-level `entries` / `values` rather than the
library `payload.entries`), plus instance/enabled/collapsed/payload_hash
extras. buildBundlePublishable shipped that verbatim, but the community
bundle validator wants each child as a canonical module row
(`{id, type, name, description, category_id, tags, is_favorite, payload}`).
The Export-tab file path never validated, so this went unnoticed until
the publish path (which validates) exercised it.

Fix: normalizeBundleChild() converts each stored child, most-correct
first:
1. Already module-row shaped -> re-stamp via buildModulePublishable.
2. Resolve the live library module by id -> guaranteed validator-correct
   payload (same row a standalone module publishes). This is the primary
   path and why the fixed_values payload now carries `entries`.
3. Fallback: flatten the snapshot's own fields + console.warn (only when
   the source module isn't in the local library).
Nested-bundle children throw a clear "flatten it first" error (the
community child schema is module-only).

buildBundlePublishable now takes a ModuleResolver. CommunityRowActions
fetches the module catalog (if empty) + builds the resolver from it;
ExportTab resolves against its loaded module list. Publish errors now
surface as a toast instead of an unhandled throw.

Tests: normalize resolves widget->row, flattens on miss (warns), passes
through canonical, throws on nested bundle; buildBundlePublishable output
survives strict buildPublishBody validation (the regression).
2026-06-06 08:32:04 +03:00
DumiFlex bbd1a37bd8 feat(publish): writeback community origin to local row after publish
Publishing a local row to community never linked the row back to the
resulting post, so: no community pill, no update-available tracking,
and the re-publish version-bump flow was unreachable (origin slug was
always null -> publishMode could only ever be 'create').

Adds the writeback path:
- ModuleRepository / BundleRepository.set_community_origin(id, slug,
  version): stamps community_post_slug + community_version_number
  WITHOUT a version bump or fingerprint change -- origin metadata is
  not runtime-affecting, so a published row stays byte-identical to
  what consumers installed.
- wp_api: POST /wp/api/modules/{id}/community-origin + the bundles
  twin. Validates {post_slug: str, version_number: int}.
- api client: modules.setCommunityOrigin + bundles.setCommunityOrigin.
- main.ts host bridge: new optional `markPublished(localId, kind,
  slug, version)` method. Calls the right setCommunityOrigin, then
  refreshes both catalogs so the pill appears without a reload.
  Optional + try/catch-returns-false so a writeback miss never breaks
  the embed's publish-success path. No ABI bump -- adding a method is
  non-breaking; the embed guards on its presence.

Tests: set_community_origin stamps slug+version, does NOT bump
version/fingerprint, raises on missing row.

Pairs with the community embed commit that calls markPublished after
createPost / createVersion.
2026-06-06 06:48:43 +03:00
DumiFlex 63a7cd9ae4 fix(publish): bypass vue-router hash encoder
vue-router 4's `router.push({hash})` percent-encodes the hash value
on its way through. When the hash is built via URLSearchParams.toString()
(which already encodes `+`, `,`, `=`, etc. to %2B, %2C, %3D), the
router double-encodes the `%` itself -- `tags=tag1,tag2` arrived at
the embed as literal `tag1%2Ctag2` in the input field, and the
base64 payload's `+` chars got mangled past the point where atob
could decode them. Payload textarea stayed empty.

Fix: navigate to the path via the router as before, but assign the
hash via `window.location.hash` AFTER the route settles. Direct
assignment bypasses the encoder; the browser fires `hashchange`
which the embed picks up.

Pairs with embed listener added in the matching community web commit.
2026-06-06 06:30:26 +03:00
DumiFlex 39c06371e3 fix(publish): forward content_rating + tags + origin slug to embed
Sister-side publish flow only sent name + description + payload via
the URL hash. content_rating + tags fields stayed at their defaults
in the embed publish form ('sfw' + empty), forcing the user to
re-enter them by hand every time -- and on NSFW modules that's a
foot-gun: easy to forget to flip the radio before clicking Publish.

PublishablePayload now carries:
- content_rating: engine-side ('safe'|'nsfw'); mapped to community's
  'sfw'/'nsfw' at the URL hash boundary.
- tags: prefills the comma-separated tags input.
- community_post_slug: routed through as `origin_slug` in the hash so
  the embed publish form can detect re-publish scenarios (own post =
  version bump; foreign post = fork prompt -- consumer lands next).
2026-06-06 06:09:21 +03:00
DumiFlex ede4f4e0f2 docs: document schema_version machine + column-add procedure
New section covering:
- The three axes (schema_version, producer_engine_version,
  version_number) and what each means.
- Engine row columns added in migration 014 (schema_version,
  original_payload_json, tolerant_drift_status, schema_migrated_at).
- The lazy_migrate_row machine: validator registry,
  parseTolerantAsCurrentShape, migratePayload, install decision tree.
- The proper way to bump schema_version when a payload shape change
  lands (new validator + migrator step + fixtures + constant bump +
  test + community catalog coordination + server-first deploy).
- Row-level column changes (e.g. content_rating in migration 015)
  as a distinct procedure -- no schema bump needed since they live
  outside the payload JSON.

References the cross-repo spec at the community web repo.
2026-06-06 05:54:36 +03:00
DumiFlex 99374cf76f feat(manager): nsfw toggle in editors + pill on library rows
Wires the content_rating axis end-to-end on the SPA + HTTP layer:

- TS types: ModuleRow / BundleRow / *CreateInput / *UpdateInput +
  InstallOrigin all gain a `content_rating?: 'safe' | 'nsfw'` field.
- install.ts: when `opts.origin.content_rating` is set, every entity
  going in has its content_rating stamped -- the embed's community
  install passes 'nsfw' through to the engine without needing a
  follow-up fetch.
- IdentityCard: adds a Toggle in a new "Content rating" field. Same
  component is the source for all 6 module editors + BundleEditor,
  so the toggle lives in one place. A `showContentRating` opt-out
  lets TemplateEditor hide the field (templates table doesn't carry
  the column).
- All 6 module editors (Fixed, Wildcard, Combine, Constraint,
  Derivation, Bundle) plumb a `contentRating` ref through load,
  save, and IdentityCard binding.
- wp_api modules + bundles: POST/PUT endpoints accept content_rating
  in the body and forward to the repository.
- ModuleListView: renders a new `wp-row-community-pill--nsfw` pill
  on rows where row.content_rating === 'nsfw'. Uses the harmonized
  primitive from b5ce9e4 -- the comment block there explicitly
  seeded this variant.
2026-06-06 05:43:40 +03:00
DumiFlex 395af63b1a feat(engine): content_rating column on library rows (migration 015)
Adds `content_rating TEXT NOT NULL DEFAULT 'safe'` to modules + bundles.
Defaults all pre-015 rows to 'safe'. Repository create/update accept a
new content_rating kwarg with validation against {'safe', 'nsfw'}.
Importer _insert_module / _insert_bundle pass entity.content_rating
through; _update_module / _update_bundle use COALESCE(?, content_rating)
so an install path that omits the key preserves the existing flag.

Why: user wants two NSFW behaviors:
1. Edit page toggle to mark a module/bundle as NSFW locally.
2. Community posts with content_rating='nsfw' auto-stamp NSFW on
   the local row at install time.

This is the engine half of that work -- the schema, the data
plumbing, and validation. The SPA half (IdentityCard toggle + pill
render on ModuleListView + install.ts plumbing) lands next.

Tests cover: migration head bump to 15, column existence on both
tables, safe default backfill, accepts nsfw, rejects invalid, update
toggle round-trip, update without content_rating preserves prior.
2026-06-06 05:22:02 +03:00
DumiFlex 87b8c6d837 refactor(manager): harmonize module-list row pills
Restyle .wp-row-community-pill + --update variant to the unified
metadata-pill shape: 18px tall, 4px-rect radius, 10px label,
bordered. Drop redundant "community" text from the origin pill --
the globe glyph carries the meaning, and harmonized sizing means
the icon-only chip stays legible.

Why: pre-existing pills used a 999px pill radius with mixed sizes
and the accent purple bled into surrounding subtype chips. New
shape gives the row a quieter inspector-ribbon feel while
preserving the click affordance on update-available.

The same primitive will house future variants (e.g. nsfw, once
the content_rating column lands on the engine row in migration
015).
2026-06-06 05:01:51 +03:00
DumiFlex 73ca8a3f9a feat(engine): bulk lazy runner — eager iteration of lazy_migrate_row
Implements spec §4 Flow 4: bulk-at-boot is NOT a parallel forward-chain
implementation; it's eager iteration of lazy_migrate_row over every
stale row. Same original-first logic, same re-projection, zero
divergence. Eliminates the transient-wrong window where bulk could
write fabricated defaults over recoverable data in original_payload_json.

run_bulk_lazy returns the failure count; caller surfaces a dashboard
banner when > 0. STALE_PREDICATE catches both 'sv < current' AND
'tolerantly-installed row whose original now outranks the projection'.

Migration 014 invokes run_bulk_lazy at the end of up() — no-op pass
at v1, but validates the wiring is correct. Future bump migrations
(015 = v2, etc) will reuse the same helper.
2026-06-05 14:21:31 +03:00
DumiFlex ad0f8c2cd8 feat(import-export): publish stamps schema_version + producer_engine_version
buildPublishBody runs the producer's strict v1 validator against the
payload before returning. Stamps schema_version = CURRENT_SCHEMA_VERSION
and producer_engine_version from package.json. Same validator code is
re-used by consumers at install-time strict validation (spec §5f) — a
bug has to exist in both producer + consumer at the same version to
slip past, which means it's a generic validator bug, not a
vendor-drift bug.

publishToCommunity now calls buildPublishBody before navigation, so
structural validation fires at publish-click time. The actual POST
still happens in community web's EmbedPublish after the user fills
the form — sister doesn't make the request directly.
2026-06-05 14:07:14 +03:00
DumiFlex 19836dd2d3 feat(import-export): install decision tree per spec §4 Flow 3
decideInstallPath returns one of:
- install_direct (payload at CURRENT)
- install_with_chain (payload below CURRENT, forward migrate)
- install_tolerant (payload above CURRENT, AND-fold over catalog
  interval is all-additive; strip + preserve original verbatim)
- refuse (payload above CURRENT, any breaking step in interval)

The AND-fold check is the spec §3 #1 fix — fold over the WHOLE
interval (CURRENT, payload_v], not just the payload's own catalog
row. A breaking step earlier in the interval still poisons later
additive steps.

This is the decision function only — wiring it into the full
installEnvelope flow (path 3a strict-validate, path 3b tolerant
strip + verbatim local persist) lands in a follow-up task.
2026-06-05 13:56:09 +03:00
DumiFlex 07fe3ab064 feat(engine): lazy_migrate_row — the ONE migration runtime
Implements spec §4 Flow 5 in full:
- No-op when row is at CURRENT with no future original
- NULL-drop optimization when row.sv == original.sv == CURRENT
- Recovery exact catch-up: promote original into payload (the §4 bug fix —
  write-back gate is 'base_v < CURRENT OR base IS original', not just
  'base_v < CURRENT'; otherwise exact catch-up silently no-ops)
- Still-drifted re-project: tolerantly strip original down to CURRENT
- Deferred strict-validate failure → tolerant_drift_status='broken'
- Standard forward chain otherwise

original_payload_json is inert storage: two writers only (initial
tolerant install + this NULL-drop). No other path rewrites it.
schema_migrated_at is separate from updated_at (structural migration
is not a user edit).
2026-06-05 13:46:49 +03:00
DumiFlex 320315c0ae refactor(import-export): per-row migratePayload contract per spec §2
Two distinct migration domains now coexist in migrations.ts:

- migrateImportEnvelope (renamed from migratePayload) — operates on
  the full multi-row import envelope (RawPayload with bundles[],
  wildcards[], etc). Used by parsePayload + the manual JSON import.
- migratePayload (new) — operates on a SINGLE ROW (module or bundle
  dict). Per spec §2: forward-only chain with registry-keyed dispatch
  by (kind, fromVersion); context is opt-in via requiresContext;
  bundle migrators recurse via applyModuleStep helper enforcing the
  single-version-per-root invariant.

migratePayload throws clearly when called for an interval that has
no registered step (caller bug — every shipped bump must register
its migrator) or when a declared context key is missing.
2026-06-05 13:25:58 +03:00
DumiFlex 4758c8402d feat(import-export): parseTolerantAsCurrentShape — strip newer fields
Per spec §3: when payload.schema_version > CURRENT and all bumps in
the interval are additive (AND-fold check), strip unknown top-level
fields and validate the projection against CURRENT's strict shape.

Recursive on bundles: each child is stripped against its subtype's
tolerant validator before the bundle root validates. Single-version-
per-root invariant: bundle root version governs all children.
2026-06-05 13:13:39 +03:00
DumiFlex 810e33ebf4 feat(validators): v1 strict + tolerant validators per subtype + bundle
Per spec §2's 'authoritative on sister' rule: sister owns
structural validation. Adds Zod schemas for all 5 module subtypes
at v1 + bundle v1. Registry keyed by (kind, subtype, version,
mode); strict mode used for declared-version checks (§2 #1) and
publish gating; tolerant mode used on the install-from-newer-
payload strip path (§3).

Fixtures double as the test-derivation source for is_breaking_from_previous
in future schema bumps (§5e).

Adds zod as a runtime dependency.
2026-06-05 13:02:40 +03:00
DumiFlex c46cbbf509 feat(engine): migration 014 - payload schema_version + verbatim-local mirror
Adds the four payload-versioning columns to modules + bundles per
the community-web spec sections 4-5:
- schema_version (NOT NULL DEFAULT 1)
- original_payload_json (NULL - only set during tolerant install)
- tolerant_drift_status ('none' | 'pending' | 'broken')
- schema_migrated_at (separate from updated_at - structural migration
  is not a user edit)

Backfill is implicit via the DEFAULT on the NOT NULL columns.
2026-06-05 12:50:54 +03:00
DumiFlex 97c92b97e0 fix(manager): keep editor header Publish + Copy grouped
CommunityRowActions in labeled mode rendered as fragment, so the two
buttons became separate flex items inside EditorFrame's header-extra
slot. wp-page__header uses justify-content space-between, which
distributed Title - Publish - Copy across the row instead of keeping
the pills together on the right.

Wrap labeled mode in a single .wpc-row-actions-labeled flex container
so it's one flex item from the parent's perspective. Icon-only mode
stays as a fragment (row-action cells want each button as a sibling).
2026-06-05 09:59:58 +03:00
DumiFlex 9c7a110d22 fix: refresh token field + show update pill outside #name slot
Two independent fixes in the sister.

1. tokenStore refresh body field. Backend's RefreshRequest uses
   pydantic extra=forbid and requires refresh_token (not refresh);
   the embed was sending {refresh: ...} which got rejected as 422.
   The wipe path only fires on 401/403, so the failed refresh
   returned null silently and the embed lost auth at the first
   access-token expiry. Refresh now correctly rotates and the
   embed stays logged in.

2. ModuleListView update-available pill rendered inside the #name
   slot's fallback only. Every list view (Wildcards / FixedValues /
   Combines / Derivations / Constraints / Bundles) overrides
   #name, so the fallback never ran and the pill never showed -
   even when communityUpdateStore logged fetched=N updates=N.
   Move community + update pills outside the slot into a new
   .wp-row-name-wrap flex container that always renders alongside
   the slot output, regardless of override.
2026-06-05 09:54:57 +03:00
DumiFlex ea3c628615 feat(manager): move editor Publish + Copy from footer to header
Footer-left was crowded (History + Publish + Copy + Delete) and the
community actions visually clashed with the destructive Delete pill
next door. Move CommunityRowActions into EditorFrame's header-extra
slot so Publish + Copy sit top-right next to the page title, matching
the actions-on-the-right convention the rest of the SPA already uses.

Footer now only carries Delete (left) and Cancel + Save (right).

Affects all six editors: Wildcard, Fixed, Combine, Derivation,
Constraint, Bundle. Five already used header-extra for the 'used by'
cascade-refs badge; the new actions render next to it.
2026-06-05 09:40:05 +03:00
DumiFlex 59c5d705bd feat(community): route Publish through embed instead of new tab
Per-row Upload buttons used window.open(WPC_API_URL/upload) which
threw away the in-extension bearer token (cross-origin localStorage)
and forced a fresh device-flow login. Now routes to /community/publish
inside the SPA — the community embed bundle's EmbedPublish reads
window.location.hash on mount and hydrates the form. Auth survives;
the user stays inside ComfyUI.

- single-row-publish.ts publishToCommunity(pub, router) → router.push
- Community.vue parseRoute recognizes 'publish' segment
- embedLoader EmbedNavigateTarget / EmbedMountOptions widened to
  match community's EmbedView union (adds bundles/library/publish)
- CommunityRowActions + ExportTab inject useRouter and pass through

Pairs with community web's new EmbedPublish view.
2026-06-05 08:37:56 +03:00
DumiFlex 9c0b11f7da feat(manager): editor footers gain Upload + Copy buttons
Each of the six editors (Wildcard, FixedValues, Combine, Derivation, Constraint, Bundle) now renders Publish + Copy pills in the footer-left slot next to Delete (only when isEdit, i.e. when there's an actual row to act on). Uses the same CommunityRowActions component the list rows added in 34d58e6 — single source of truth via single-row-publish helpers.
2026-06-05 08:23:09 +03:00
DumiFlex 7a5c382eb4 feat(manager): per-row community upload + copy + update diag
Pulls publish + copy helpers out of ExportTab into shared single-row-publish util. New CommunityRowActions component renders Upload + Copy icon buttons; wired into all six library list views (Wildcards, FixedValues, Combines, Derivations, Constraints, Bundles) so users no longer need the Import/Export tab for single-row publish. Also adds diagnostic console.info in communityUpdateStore.check showing module/bundle/candidate/update counts per check — helps debug 'no update pill' reports without UI changes.
2026-06-05 08:10:11 +03:00
DumiFlex b85f21a01d fix(manager): dashboard tabs left + settings restart confirm
- Dashboard's Recents card moved the tab row (Recently opened /
  Recent edits / Favorites) out of the Card #actions slot (which
  forces right alignment via wp-spacer) and into the card body
  itself. Tabs now sit on the LEFT; View all stays right via a
  spacer. The left half of the row used to be empty.

- Settings → Database card's Restart ComfyUI button now opens a
  ConfirmDialog before kicking the process. Same gate StaleBanner
  uses for its Restart pill; the SPA had two surfaces firing the
  restart action and only one was confirmed.
2026-06-05 07:48:21 +03:00
DumiFlex af314d3abe feat(community): update-action dialog with in-place + as-new + dismiss
Closes the loop on community-tracked rows. The warn-coloured update
pill on ModuleListView is now a button; clicking it opens
CommunityUpdateDialog with three resolutions:

1. Update in place — fetches the post's latest version payload, pre-
   injects a history snapshot of the local row's pre-update state
   into the new payload's `history` sidecar (modules only — bundles
   have no per-row history feature), wraps in the engine envelope,
   then routes through installEnvelope with a forced `replace`
   decision. Engine `_update_module` / `_update_bundle` now rewrite
   community_post_slug + community_version_number when the new
   content carries them, so the row's tracked version bumps to the
   post's latest in one transaction. The user can roll back via the
   editor's existing History panel.

2. Install as new entry — installEnvelope with a forced `rename`
   decision (auto-suffix `-v{N}` on the entity name). Keeps the
   original row exactly as it was; the new row also stamps community
   origin so it gets tracked on its own.

3. Dismiss — persists `{post_slug: version}` to localStorage. The
   community update store reads this map on every check; pills clear
   for the dismissed version, but pop again if the post ships a
   newer version (dismissal is per-version, not per-post).

Importer extends `_update_module` + `_update_bundle` to rewrite
community_* columns when the incoming content carries them. Vanilla
Import-tab replaces (no community origin in the entity) keep the
pre-existing UPDATE shape so locally-authored rows don't get their
community_* fields silently nulled by a non-community replace.

ModuleListView mounts the dialog once at the list root and stores
the active update entry locally — pill click hands the entry to the
dialog, dialog drives all three actions through stores + install
pipeline + localStorage, then closes.
2026-06-05 06:49:22 +03:00
DumiFlex 9957702099 feat(community): track install origin + update-available indicator
Library rows installed from the community embed now carry the source
post slug + installed version_number in the engine sqlite. The SPA
uses both surfaces: a "community" pill next to the row name (so users
can tell at a glance which entries were authored locally vs pulled
from the embed), and a warn-coloured "v{N}" pill when the post has
shipped a newer version since install.

Engine:
- Migration 013 adds community_post_slug + community_version_number
  to modules + bundles. Both nullable; locally-authored rows stay
  NULL forever. Implemented as a .py migration with PRAGMA
  table_info guards so rerunning against an already-migrated DB
  (test fixtures rewind migrations to exercise older paths) is a
  clean no-op rather than "duplicate column name".
- _insert_module / _insert_bundle (importer.py) persist the fields
  when the entity dict carries them.
- _row_to_module / _row_to_bundle (repositories.py) surface them on
  every read path. Try/except guard for pre-013 DB shape so a
  connection opened mid-rollout does not crash.

Manager:
- installEnvelope grows optional `origin: { post_slug, version_number }`
  in InstallOptions. When set, stamps every module + bundle in the
  selection before the commit roundtrip.
- Host bridge in main.ts threads origin from `install(envelope, opts)`
  through to installEnvelope. ABI stays at 1 (additive).
- New communityUpdateStore walks the library snapshot, batch-fetches
  each unique community post (deduped, 8-wide concurrency), surfaces
  rows whose installed version is behind the post's
  latest_version_number. Network failures clear the store silently
  so an offline session does not render misleading "v2 available"
  pills.
- AppLayout kicks one check after the initial library fetch
  resolves. No background polling.
- ModuleListView renders two distinct pills per row: a static
  "community" indicator on origin-stamped rows, and a separate
  warn-coloured "v{N}" pill when the update store has an entry.

Migration tests updated for the new schema head + bundle/module
column sets.
2026-06-04 17:21:04 +03:00
DumiFlex 519abd399a feat(install): pre-commit collision detection + resolver callback
installEnvelope grows an optional library snapshot + resolveCollisions
callback in its options. When both are wired (host bridge passes
them per call), the function walks every incoming module + bundle
against the live library before the commit roundtrip, surfaces
duplicates to the resolver, and applies the user's per-id decisions
(skip / replace / rename) to the commit payload.

Decisions translate to existing commit-shape verbs:
- skip drops the entity from the bucket
- replace rewrites the decision wrapper from {add} to {replace}
- rename mints a fresh 8-hex id via newShortId, swaps the entity's
  id field, and emits {rename, new_id, new_name} — same shape the
  Import tab's picker produces

main.ts wires the host bridge to snapshot moduleStore.catalog +
bundleStore.catalog on every install() call and forwards the
embed's resolveCollisions callback through. The bridge accepts an
optional second arg; callers that don't pass it keep the original
server-rejects-on-conflict behaviour for back-compat.

ABI stays at 1 — additive change to install signature.
2026-06-04 16:52:51 +03:00
DumiFlex bb82b667dd fix(manager): confirm before destructive actions
Three UX gaps from the v1.7 release:

1. Editor Delete pill (Wildcard, Constraint, FixedValues, Combine,
   Derivation, Bundle) used to skip the confirm dialog when the
   entity had zero inbound refs. A user editing a newly-installed
   module could lose it on a single click. All six editors now
   always open the cascade dialog — its dry-run handles 0 refs
   cleanly (empty affected list, Delete button still front-and-
   centre), just with a 100ms scan round-trip.

2. StaleBanner's Restart ComfyUI pill now goes through a themed
   ConfirmDialog instead of firing on click. Restart kicks the
   process, interrupting any in-flight workflow / queued prompts,
   so the user gets a chance to bail out.

3. AppSidebar's search input gains margin-top so it doesn't read
   like it's glued to whatever sits above it in the nav column.
2026-06-04 16:21:48 +03:00
DumiFlex c6c42c4d9b feat(manager): add Publish to community button on Export tab
Single-row deeplink to community web's /upload?from=spa flow. Builds
the engine-row payload from the live ModuleRow / BundleRow, utf8-safe
base64-encodes it, and opens
  ${WPC_API_URL}/upload?from=spa#payload=<b64>&name=<n>&description=<d>
in a new tab. Community web's PublishView.hydrateFromSpaHash decodes
the hash on mount and prefills the textarea + metadata fields.

Hash (not query) keeps the payload off the request log and sidesteps
Edge's ~2KB address-bar limit on bundle exports. Button is enabled
only when exactly one module OR one bundle is selected — multi-row
publish is not a flow the community supports (each post is one
entity). Categories + templates are excluded; the community can't
host those yet.
2026-06-04 15:43:47 +03:00
DumiFlex 3d92673078 fix(manager): add Delete button to WildcardEditor 2026-06-04 15:32:43 +03:00
DumiFlex a2cc1bb844 fix(community): tokenStore refresh hits /auth/token/refresh not /auth/refresh
Embed token refresh was 404-ing silently because the path used /auth/refresh while the backend router exposes /auth/token/refresh (see api/app/routers/auth.py line 425). Body key also went from refresh_token to refresh to match the backend RefreshBody schema. Effect: bearer auth survived past the access-token TTL without forcing the user to re-login.
2026-06-04 15:15:31 +03:00
DumiFlex 738ede31c3 fix(cascade): allow whole-entity delete for fixed_values/combine/derivation/constraint
Orchestrator dispatch table only handled wildcard/category/bundle/option deletes (the kinds that have reverse-refs to fix). Leaf module types fell through to the unsupported (kind, action) error path even though there is no cascade work to do for them — just drop the row + register undo.

Added a fallback branch for (fixed_values | combine | derivation | constraint, delete) that emits empty touched/diff lists; _delete_target was already routing the kind through ModuleRepository correctly via its else-branch, just needed the kind added to the whole-entity-delete guard.

Caveat noted in code: combine.output_var is referenced by name from downstream payloads. A combine delete leaves any consumer with an unresolved variable, surfaced separately by the integrity/broken-refs scan rather than by cascade-apply.
2026-06-04 15:10:16 +03:00
DumiFlex b9132af764 fix(install): drop normalizeEntity now that community speaks engine-row shape
Community web reshaped its seed/validator/preview to emit engine-row payloads directly (top-level name; type-specific fields nested under payload). normalizeEntity was lifting meta.name to mask a community-side bug; the community side is fixed at the source now, so the install pipeline can trust whatever wire shape lands. Anything that's still missing the top-level name is a real bug to fix at source, not silently patch in the bridge.
2026-06-04 14:54:58 +03:00
DumiFlex 160b729e15 fix(install): lift meta.name to top-level name in install pipeline
Engine importer expects every entity to carry a top-level 'name', but engine EXPORTS (and therefore community-uploaded payloads round-tripped through the export envelope) park it at meta.name. The on-disk shape and the importer's required-fields shape differ by one nesting level, so install through the community embed kept failing with 'add for wildcard missing required field(s): [name]'.

normalizeEntity() in install.ts now lifts meta.name to top level when absent, with a short-id fallback if both slots are empty. Idempotent: existing top-level name is left alone. Applied per-entity in wrapAdds + wrapCategoryAdds before the commit assembles.
2026-06-04 14:45:43 +03:00
DumiFlex a10a321e3b feat(manager): expose window.__wpcRuntime host bridge for community installs
New import-export/install.ts wraps parsePayload + buildCommitPayload + POST /import/commit into a single installEnvelope() function. Same migration + integrity guarantees the Import tab gives, but skips the per-entity picker — every entity flows as add (server collision detector surfaces dupes if any).

main.ts sets window.__wpcRuntime = {abi: 1, schemaVersion, install} after the manager mounts. The community embed bundle (loaded into this document from wp.dumiflex.dev) detects this global at runtime and calls install(envelope) instead of saving a JSON file. ABI gate keeps older hosts safe from contract drift; schemaVersion lets the embed refuse newer-than-supported packs upfront.
2026-06-04 14:28:32 +03:00
DumiFlex c0cfd134cb feat(manager): real 404 page + global error boundary
Router catch-all was `redirect: '/dashboard'` — typos and dead deep-links silently bounced home, so users assumed nothing was wrong. New `views/NotFound.vue` is now the catch-all component (named not-found) and shows the URL that didn't match plus links back to dashboard / library.

`components/ErrorBoundary.vue` mirrors the SPA's boundary: `onErrorCaptured` returns false to contain a render-time throw inside the main content slot, route-fullPath watcher auto-resets so a one-off crash doesn't strand the user on the error card after they navigate away. AppLayout wraps the RouterView with it; sidebar + topbar stay interactive throughout.
2026-06-04 14:05:00 +03:00
DumiFlex 84cd82faa6 fix(manager): parseRoute handles :rest(.*)* array params
vue-router 4 hands back route.params.rest as an ARRAY of segments for a  matcher with a star quantifier, not a pre-joined string. String(arr) comma-joined; the subsequent split('/') failed; parseRoute always returned browse; the route watcher's handle.navigate({view:'browse'}) immediately re-pushed /community after every detail nav — URL flickered to /community/p/owner/name and bounced back. Use Array.isArray(raw) ? raw : raw ? [raw] : [] so both shapes work.
2026-06-04 14:01:01 +03:00
DumiFlex 6f2b804af4 fix(manager): community routes share layoutKey to stop embed re-mount
Both /community and /community/p/* are different route records using
the same Community.vue component. vue-router treats them as distinct
records, so AppLayout's keyed RouterView re-mounted the host on every
nav between the two — the embed bundle tore down + remounted, the
first card click silently re-mounted (losing state), and only the
second click actually navigated. Adding meta.layoutKey='community' on
both records (same pattern docs routes use) makes the keyed view
reuse the same instance across the catch-all family.
2026-06-04 13:45:20 +03:00
DumiFlex 6f51e4a804 feat(community): surface underlying import error in EmbedLoadError
The offline screen previously said 'Failed to load community embed from <url>' with no detail — useless for diagnosing CORS / CSP / MIME / network problems because every failure mode rendered the same message. Now the constructor appends the caught error's .message so the offline screen shows the actual reason (e.g. CSP refusal, network DNS, MIME rejection).
2026-06-04 12:40:36 +03:00
DumiFlex 7d43164d26 feat(manager): embed Wildcard Pipeline Community via runtime bundle
The Community tab no longer renders a WIP placeholder — it dynamic-
imports `wpc-embed.js` from WPC_API_URL and mounts the embed into a
host div, exposing browse / detail / profile views with the same
auth + theming contract documented in the community web repo.

Three new modules:
- community/embedLoader.ts — cached `loadEmbed()`, typed mount opts,
  `EmbedLoadError` for offline / CORS / host-down paths.
- community/tokenStore.ts — localStorage-backed TokenStore. Reports
  null until device flow lands (anonymous render is the v1
  fallback); proactive refresh with skew window + in-flight
  coalescing so concurrent mounts don't burn the refresh token.
- views/Community.vue — host shell. Parses `/community/:rest` into
  embed view targets, mirrors embed-initiated nav back into the
  router so the URL bar tracks state, ships an offline retry +
  Discord invite escape hatch.

Router catch-all (`community/:rest(.*)*`) lets the embed own its own
deep-link grammar (`/community/p/owner/name`, `/community/u/handle`)
without a route per shape.

CommunityWip.vue is now orphan and removed; links.ts doc comment
points at Community.vue.
2026-06-04 12:16:26 +03:00
semantic-release-bot 78d1f07027 chore(release): 2.1.0 [skip ci] 2026-06-03 01:46:25 +00:00
DumiFlex c6a1030035 feat(manager): add always-visible restart comfyui row to maintenance section 2026-06-03 04:22:40 +03:00
DumiFlex 843a679c25 fix(manager): probe manager via get /manager/version instead of head reboot 2026-06-03 04:14:41 +03:00
DumiFlex 02c83002dc feat(manager): add restart comfyui button via manager reboot endpoint 2026-06-03 04:05:48 +03:00
DumiFlex efb38ff894 feat(manager): heartbeat poll every 30s so stale banner appears without tab switch 2026-06-03 03:55:55 +03:00
DumiFlex 9a053e8bdf feat(manager): fade routes on tab switch with reduced-motion fallback 2026-06-03 03:28:34 +03:00
DumiFlex 710205768b feat(manager): detect server restart and prompt stale page reload 2026-06-03 03:24:26 +03:00
DumiFlex d23a72887b chore: ignore db-config.json sidecar and root db dir 2026-06-03 02:48:08 +03:00
DumiFlex eb48d7fc60 feat(manager): add location selector with copy or move flow 2026-06-03 02:43:41 +03:00
DumiFlex 8eb59674a8 feat(manager): extend database store with config and pending move actions 2026-06-03 02:28:39 +03:00
DumiFlex 8c760f2bef feat(manager): add db config types and api client methods 2026-06-03 02:22:52 +03:00
DumiFlex 04d3795103 feat(api): expose db config get/put endpoints with pending-move 2026-06-03 02:16:51 +03:00
DumiFlex 747a84ac16 feat(engine): add user/global/root location path helpers 2026-06-03 02:09:20 +03:00
DumiFlex 00b0c735bb feat(api): run pending db move before migration on startup 2026-06-03 02:02:40 +03:00
DumiFlex 52966e69d1 feat(engine): execute pending db moves at plugin load 2026-06-03 01:58:15 +03:00
DumiFlex c03fc070ba feat(engine): honor sidecar preference and add root db location 2026-06-03 01:54:05 +03:00
DumiFlex 069527a066 feat(engine): add sidecar db-config.json reader/writer 2026-06-03 01:45:39 +03:00
DumiFlex a89bf7d085 fix(manager): flex-based row layout for database info, wrap long paths 2026-06-03 01:35:18 +03:00
DumiFlex 885a2d06ac feat(manager): replace storage card with browser prefs and database cards 2026-06-03 01:21:33 +03:00
DumiFlex df7eb75e50 feat(manager): add database card with info and maintenance 2026-06-03 01:07:53 +03:00
DumiFlex 7cfe29b435 feat(manager): add maintenance op three-phase modal 2026-06-03 00:54:50 +03:00
DumiFlex b2b75d4cb0 feat(manager): browser preferences card with full prefix coverage 2026-06-03 00:39:42 +03:00
DumiFlex 2003f38a55 feat(manager): add databaseStore Pinia store 2026-06-03 00:30:18 +03:00
DumiFlex f20a9e376f feat(manager): add database API client + types 2026-06-03 00:25:02 +03:00
DumiFlex 0909817018 feat(api): add POST /wp/api/database/maintenance 2026-06-03 00:19:59 +03:00
DumiFlex dc839bfb99 feat(api): add GET /wp/api/database/info 2026-06-03 00:15:04 +03:00
DumiFlex 5a62929f91 feat(engine): add run_migrations maintenance op 2026-06-03 00:03:17 +03:00
DumiFlex d39add1a44 feat(engine): add analyze maintenance op 2026-06-02 23:58:55 +03:00
DumiFlex 7f125756a3 feat(engine): add integrity_check maintenance op 2026-06-02 23:55:09 +03:00
DumiFlex 4abe679525 feat(engine): add vacuum maintenance op 2026-06-02 23:50:52 +03:00
DumiFlex ecdbf6c7f5 chore(tests): fix fresh_db fixture return type annotation 2026-06-02 23:43:28 +03:00
DumiFlex 2b5f2b7af7 feat(engine): add gather_info DB introspection 2026-06-02 23:39:56 +03:00
DumiFlex 7e12626248 feat(engine): expose db path resolution source 2026-06-02 23:33:29 +03:00
630 changed files with 74999 additions and 6011 deletions
+5 -1
View File
@@ -1,8 +1,12 @@
name: CI
on:
# Every branch, not just main. The pre-commit hook now scopes its checks to
# the files that changed (see .husky/pre-commit), which is only safe because
# the full suite runs here on every push — previously a feature branch got no
# remote verification at all until a PR was opened.
push:
branches: [main]
branches: ["**"]
pull_request:
branches: [main]
+82
View File
@@ -0,0 +1,82 @@
name: Discord announce
# Two channels, deliberately asymmetric.
#
# staff — posts AUTOMATICALLY when semantic-release publishes a release.
# The post carries a link back to this workflow, so the review and
# the publish button are the same artefact.
# public — only ever from a manual run. A release can never announce itself
# to everyone without someone having read it first.
#
# Deliberately a SEPARATE workflow from release.yml: a Discord outage, a
# rotated webhook or a rate limit must never fail a release that already
# shipped. Re-run this job instead.
on:
release:
types: [published]
workflow_dispatch:
inputs:
channel:
description: "Where to post."
required: true
type: choice
default: staff
options: [staff, public]
since:
description: "Roll up every release after this tag (e.g. v2.10.2). Blank = latest release only."
required: false
type: string
dry_run:
description: "Print the message instead of posting it."
required: false
type: boolean
default: true
permissions:
contents: read
jobs:
announce:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: "22"
# setup-node sniffs the lockfile and tries to prime a pnpm cache,
# which fails because pnpm is never installed here. It is not needed:
# the announce script has no dependencies at all — plain ESM and the
# global fetch built into Node 18+. Nothing is installed, so there is
# nothing to cache.
package-manager-cache: false
# A `release` event always posts, and always to staff. A dispatch honours
# both inputs, with dry_run defaulting to true so a manual run cannot
# surprise anyone.
- name: Announce
env:
DISCORD_WEBHOOK_STAFF: ${{ secrets.DISCORD_WEBHOOK_STAFF }}
DISCORD_WEBHOOK_PUBLIC: ${{ secrets.DISCORD_WEBHOOK_PUBLIC }}
# Kept so an existing single-webhook setup keeps working as staff.
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_ROLE_ID: ${{ secrets.DISCORD_ROLE_ID }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
ARGS=""
if [ "${{ github.event_name }}" = "release" ]; then
ARGS="--version ${{ github.event.release.tag_name }} --channel staff --post"
else
ARGS="--channel ${{ inputs.channel }}"
if [ -n "${{ inputs.since }}" ]; then
ARGS="$ARGS --since ${{ inputs.since }}"
fi
if [ "${{ inputs.dry_run }}" = "false" ]; then
ARGS="$ARGS --post"
fi
fi
echo "Running: discord-announce.mjs $ARGS"
node scripts/discord-announce.mjs $ARGS
+19
View File
@@ -3,6 +3,25 @@ name: Release
on:
push:
branches: [main]
# Tooling-only pushes must not be able to cut a release, whatever their
# commit type says.
#
# 2.13.0 shipped as an empty release because three commits touching only
# `scripts/` and `.github/` were typed `feat(announce)` / `fix(announce)`.
# Both are release-triggering, so semantic-release did exactly what it was
# told — every user on 2.12.0 was offered an update with a blank changelog.
# Relying on remembering to type those `ci:` had already failed once.
#
# Neither path is part of what a user installs: `scripts/` is dev tooling
# and `.github/` is CI. GitHub skips the run only when EVERY changed file
# matches, so a push that touches shipped code alongside a workflow still
# releases correctly.
#
# `docs/**` is deliberately NOT here — `docs(readme)` is configured to bump
# a patch, and `docs/release-notes/next.md` is the release body itself.
paths-ignore:
- 'scripts/**'
- '.github/**'
permissions:
contents: write
+14
View File
@@ -12,6 +12,12 @@ node_modules/
pnpm-debug.log
.pnpm-store/
# Vite per-contributor env overrides (e.g. VITE_WPC_API_URL pointing
# at a dev community host). Never commit — leaks dev URLs into the
# tree and makes `pnpm build` produce divergent bundles per checkout.
.env.local
.env.*.local
# Build outputs
js/
web/
@@ -46,9 +52,17 @@ Thumbs.db
/docs/*
!/docs/help/
!/docs/import-export-contract.md
# Curated release highlights — read by .releaserc.js at release time, so it
# must be tracked (the release step commits the cleared file back).
!/docs/release-notes/
# Semantic release
.releaserc.local.js
# Throwaway design / scratch artifacts (mockups, screenshots, demos)
/_temp/
# DB location control — per-contributor sidecar config + root-location DB dir
db-config.json
/db/
.tmp-check/
+64 -14
View File
@@ -1,18 +1,68 @@
# Lint + typecheck + frontend tests
pnpm lint || exit 1
pnpm typecheck || exit 1
pnpm test || exit 1
# Pre-commit gate, scoped to what actually changed.
#
# This used to run the whole CI suite on every commit — lint, typecheck, the
# full 4000-test vitest run, a build, the size gate, ruff and the full pytest
# suite — about 4.5 minutes regardless of whether the commit touched one Vue
# file or one markdown file. CI (.github/workflows/ci.yml) now runs on every
# push, not just main, so the local hook's job is to catch mistakes fast rather
# than re-prove the whole tree.
#
# Measured on this machine:
# lint 11s · typecheck 18s · full vitest 150s · build 10s · size 2s
# ruff 0s · full pytest 76s · vitest scoped to changed files ~30s
#
# So a frontend-only commit drops from ~4.5min to ~70s, a Python-only commit
# skips the JS half, and a docs-only commit exits immediately.
#
# Escape hatch: `WP_FULL=1 git commit …` runs everything, unscoped.
# Bundle size budget — build the extension chunk and run the size check so
# regressions get caught locally before the CI flags them on PR. Skips when
# js/ output already matches the source by mtime to keep repeat commits fast.
pnpm build:extension || exit 1
pnpm size || exit 1
staged=$(git diff --cached --name-only --diff-filter=ACMR)
[ -z "$staged" ] && exit 0
# Python checks — only if ruff/pytest reachable on PATH
if command -v ruff >/dev/null 2>&1; then
ruff check . || exit 1
has() { echo "$staged" | grep -qE "$1"; }
FRONT=0
PY=0
# Anything that can change the bundle or its types.
has '^(src/|scripts/|package\.json|pnpm-lock\.yaml|vite\.|tsconfig|eslint)' && FRONT=1
# Anything the Python suites cover.
has '(\.py$|^(engine/|wp_nodes/|wp_api/|tests/|pyproject\.toml))' && PY=1
if [ "$WP_FULL" = "1" ]; then
FRONT=1
PY=1
fi
if command -v pytest >/dev/null 2>&1; then
pytest -q || exit 1
if [ "$FRONT" = "1" ]; then
pnpm lint || exit 1
pnpm typecheck || exit 1
# Only the tests reachable from the staged sources. `vitest related` walks
# the import graph, so a change to a shared module still pulls in every
# dependent suite — narrower than the full run, not weaker for the code
# being committed. CI still runs the whole suite with coverage.
if [ "$WP_FULL" = "1" ]; then
pnpm test || exit 1
else
changed_src=$(echo "$staged" | grep -E '^src/.*\.(ts|vue)$' | tr '\n' ' ')
if [ -n "$changed_src" ]; then
# shellcheck disable=SC2086
pnpm exec vitest related --run $changed_src || exit 1
fi
fi
# Bundle budget — cheap, and a regression here stays invisible until release.
pnpm build:extension || exit 1
pnpm size || exit 1
fi
if [ "$PY" = "1" ]; then
if command -v ruff >/dev/null 2>&1; then
ruff check . || exit 1
fi
if command -v pytest >/dev/null 2>&1; then
pytest -q || exit 1
fi
fi
exit 0
+38 -14
View File
@@ -18,6 +18,21 @@
* notes shrink to just the meaningful changes (typically <50 entries
* per release).
*/
import fs from "node:fs";
// Curated, human-readable highlights for the NEXT release. A maintainer
// writes plain-language "what changed for you" bullets here before merging a
// release; if the file is empty/absent the release body simply falls back to
// the auto-generated grouped changelog. The file is read at release time (in
// CI) and cleared by the prepare step below so highlights never repeat.
const HIGHLIGHTS_FILE = "docs/release-notes/next.md";
let highlights = "";
try {
highlights = fs.readFileSync(HIGHLIGHTS_FILE, "utf8").trim();
} catch {
/* no curated highlights for this release */
}
export default {
branches: ["main"],
plugins: [
@@ -69,24 +84,30 @@ export default {
],
},
writerOpts: {
// headerPartial puts a fixed "TL;DR + useful links" block at
// the very top of every release body, so the first ~600
// chars GitHub renders above the "Read more" fold on the
// releases list view stay useful no matter how long the
// changelog gets. {{version}} expands to the new tag.
// The body IS the curated notes. See docs/release-notes/next.md for
// the structure and the rules that keep it short.
//
// The per-commit list used to be inlined here inside a <details>.
// It was dropped: with the hidden-type filter already applied it
// said the same things as the curated notes, only worse, and it grew
// linearly with the release (81 entries for this one). A compare
// link is strictly more useful -- diffable, searchable, one line.
headerPartial:
"## 🎉 Wildcard Pipeline {{version}}\n\n" +
"📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · " +
"💬 [Discord](https://discord.gg/BFYR9WQdVR) · " +
"📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · " +
"🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)\n\n" +
"<details>\n" +
"<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>\n\n",
// footerPartial closes the <details> wrapper so the wall of
// commits stays collapsed by default. GitHub renders
// <details> natively in markdown — readers click to expand
// when they actually need the per-commit log.
footerPartial: "\n</details>",
(highlights ? `${highlights}\n\n` : ""),
// Compare link instead of the commit dump.
footerPartial:
"\n---\n\n**Full changelog:** " +
"[{{previousTag}}…{{currentTag}}]" +
"(https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/{{previousTag}}...{{currentTag}})\n",
// Render no commit sections. The curated notes above ARE the release
// notes; commits still drive the version bump (see commit-analyzer)
// and still live in git -- they are simply not restated here.
transform: () => false,
},
},
],
@@ -102,14 +123,17 @@ export default {
// @semantic-release/github plugin can attach it to the
// release. Zip is built AFTER versioning so the file lands
// with the right version in its name.
// 3. Clear the consumed highlights file so the next release starts
// from a clean slate (the content already lives in this release's
// notes + CHANGELOG.md).
prepareCmd:
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node scripts/pack-release.mjs ${nextRelease.version}",
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node -e \"require('fs').writeFileSync('docs/release-notes/next.md','')\" && node scripts/pack-release.mjs ${nextRelease.version}",
},
],
[
"@semantic-release/git",
{
assets: ["CHANGELOG.md", "package.json", "pyproject.toml"],
assets: ["CHANGELOG.md", "package.json", "pyproject.toml", "docs/release-notes/next.md"],
// Plain message — `${nextRelease.notes}` here would expand to
// the entire changelog, which on a divergent history (where
// semantic-release can't find the last tag in main's first-
+905
View File
@@ -1,3 +1,908 @@
## 🎉 Wildcard Pipeline 2.13.2
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
### Firefox: the editor no longer dies when you insert a nested reference
Picking a wildcard from the `@` autocomplete could take down the whole editor —
the field stopped accepting text, Backspace did nothing, and Save and Cancel
stopped responding until the page was reloaded. Select-all then cut inside an
option value did the same thing.
Both were the same cause. Firefox removes the empty text nodes that Vue uses to
mark where each chip and text run begins and ends, and once they are gone any
edit that adds or removes a chip runs off the end of the field's contents.
Chromium keeps those markers, which is why this only ever happened on Firefox
and never reproduced during development.
The editor now notices when those markers have been removed and rebuilds the
field instead of tripping over them. This affects every rich text field —
option values, combine templates, derivation rules and injector bindings, on
the canvas and in the manager alike.
### Your library is protected against version mismatches
If a database has been through migrations that this build does not recognise —
after downgrading, hand-installing an older copy, or switching versions in
ComfyUI Manager — Wildcard Pipeline now stops with a clear message instead of
quietly reading a library it does not understand. Previously it carried on, and
every later edit was written through assumptions that no longer held.
### Release archives now include the interface translations
The release zip was missing `locales/`, so node names, descriptions and setting
labels fell back to raw identifiers for anyone installing from it. Installs
from ComfyUI Manager and the Comfy Registry were unaffected.
---
**Full changelog:** [v2.13.1…v2.13.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.1...v2.13.2)
## 🎉 Wildcard Pipeline 2.13.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
A security fix for the database location setting. Update when you can.
> **Already on 2.13.0?** This is the same release. 2.13.0 reached GitHub but not the
> ComfyUI Registry, so it never arrived for anyone installing through ComfyUI Manager.
> 2.13.1 is that same build under a version number the registry will accept — nothing
> in it differs.
### Highlights
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
<!-- /modal -->
### Upgrade notes
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
<!--
HOW TO WRITE THIS FILE
Order, and nothing else:
Headline one sentence, what this release is about
### Highlights 4-5 max
<!- /modal -> the update dialog stops rendering here
<details> Also in this… grouped one-liners, collapsed
### Upgrade notes only when the reader must act; delete otherwise
— keep this OUTSIDE the <details>, it needs seeing
This file IS the release body — the compare link is appended automatically and
the per-commit list is deliberately not included. It is consumed at release
time and cleared afterwards, so anything left here ships.
RULES
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
6-commit release and an 80-commit release roughly the same height. If a
sixth feels essential, something else was not a highlight.
2. A highlight passes one of three tests:
- new capability — you can do something you could not before
- wall removed — something that blocked you now works
- surprising — behaviour changed in a way you should not meet alone
Polish, truncation, layout and invisible perf fail all three.
3. Commit count is NOT a test. The categories icon picker was two commits and a
real feature; `ui` was nine commits and almost entirely polish.
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
names are free-form — use whatever the release actually contains. It sits in
a <details> so a long release does not double the page; put the count in the
summary so the reader knows what they are opening. That number is written by
hand here and can drift — the update dialog computes its own from the same
bullets, so if the two disagree, this one is wrong. Note also that collapsed
text is not reliably reachable by browser find-in-page, which is the cost.
5. Write what changed for the user, not what changed in the code. Name the
symptom, not the fix: "the field went blank" beats "guarded a null deref".
6. Perf belongs in the tail WITH its numbers, so the people it affects
recognise themselves. "Performance improvements" tells nobody anything.
7. Security fixes say what an attacker could have done, in plain language, and
whether the reader must act. Never ship one as "hardening" or "internal
improvements" — someone deciding whether to update tonight needs the facts,
and vagueness reads as a cover-up once the diff is public anyway.
-->
---
**Full changelog:** [v2.13.0…v2.13.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.0...v2.13.1)
## 🎉 Wildcard Pipeline 2.13.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
A security fix for the database location setting. Update when you can.
### Highlights
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
<!-- /modal -->
### Upgrade notes
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
<!--
HOW TO WRITE THIS FILE
Order, and nothing else:
Headline one sentence, what this release is about
### Highlights 4-5 max
<!- /modal -> the update dialog stops rendering here
<details> Also in this… grouped one-liners, collapsed
### Upgrade notes only when the reader must act; delete otherwise
— keep this OUTSIDE the <details>, it needs seeing
This file IS the release body — the compare link is appended automatically and
the per-commit list is deliberately not included. It is consumed at release
time and cleared afterwards, so anything left here ships.
RULES
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
6-commit release and an 80-commit release roughly the same height. If a
sixth feels essential, something else was not a highlight.
2. A highlight passes one of three tests:
- new capability — you can do something you could not before
- wall removed — something that blocked you now works
- surprising — behaviour changed in a way you should not meet alone
Polish, truncation, layout and invisible perf fail all three.
3. Commit count is NOT a test. The categories icon picker was two commits and a
real feature; `ui` was nine commits and almost entirely polish.
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
names are free-form — use whatever the release actually contains. It sits in
a <details> so a long release does not double the page; put the count in the
summary so the reader knows what they are opening. That number is written by
hand here and can drift — the update dialog computes its own from the same
bullets, so if the two disagree, this one is wrong. Note also that collapsed
text is not reliably reachable by browser find-in-page, which is the cost.
5. Write what changed for the user, not what changed in the code. Name the
symptom, not the fix: "the field went blank" beats "guarded a null deref".
6. Perf belongs in the tail WITH its numbers, so the people it affects
recognise themselves. "Performance improvements" tells nobody anything.
7. Security fixes say what an attacker could have done, in plain language, and
whether the reader must act. Never ship one as "hardening" or "internal
improvements" — someone deciding whether to update tonight needs the facts,
and vagueness reads as a cover-up once the diff is public anyway.
-->
---
**Full changelog:** [v2.12.0…v2.13.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.12.0...v2.13.0)
## 🎉 Wildcard Pipeline 2.12.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
Full support for ComfyUI's Nodes 2.0 renderer, and a pass over everything to do with knowing where a value came from.
### Highlights
- **Every node works under Nodes 2.0.** ComfyUI's new Vue renderer changed how nodes are laid out, sized and scrolled, and our widgets were built against the old one. Nodes now render, resize, scroll and merge their wires correctly under both renderers — including switching between them with a workflow already open.
- **See where a variable or reference comes from.** Hover any `$var` or `@{ref}` chip and the card names the module that writes it, the node it lives in, and — for references — the pool it resolves against and how many options currently match. Variables produced by a Context Injector, by a sibling module, or by another node are all attributed instead of reading "binds at runtime".
- **The `@` and `$` pickers were rebuilt.** Rows carry an identity so two similarly-named entries are tellable apart, pools supplied by a node are marked as such, and the sub-category filter panel is redesigned — chips reflect the parsed expression, and exception values get a picker of real option values instead of a free-text box.
- **Wildcard options and fixed values became editable at scale.** A pinned search bar, tag filtering, drag-to-reorder, and bulk edit that can move rows to top or bottom and add or remove sub-categories across a selection.
- **Text editing in options no longer breaks.** Cut, paste and select-all could leave a field stuck, mangled, or typing into the wrong place, and a selection would skip over chips. Keystrokes also no longer escape a modal into ComfyUI's global shortcuts — pressing a plain letter could trigger a node-definition refresh and freeze the page.
<!-- /modal -->
<details>
<summary><b>Also in this release</b> — 26 smaller changes</summary>
**Sequences and seeds**
- Lock a frame's seed or bypass it straight from its chip on the Context Loop — shift-click to lock, alt-click to bypass — instead of opening the per-iteration seeds dialog
- The Seed List gets the same frame chips; clicking one locks its seed, and frames bypassed by a wired Context Loop are shown but not editable there, since that setting belongs to the loop
- The frame grid collapses, and scrolls once it outgrows four rows, so a hundred-frame loop no longer owns the canvas. Collapsed state is saved with the workflow, and the header keeps reporting how many frames are locked or bypassed
**Library and organisation**
- Categories get an icon picker, and the icon follows the category everywhere it appears
- Export and import pickers gain cross-bucket search and per-row detail (`$variable`, option counts)
- Copy payload emits an importable envelope, stamped with its schema version
- Re-link matches bundles on child content rather than only a stored hash, and offers itself for modules that have no hash at all
- An unlinked bundle now reads as missing rather than modified, and reset-to-snapshot refuses to empty one
**Chips and references**
- Nested `@{uuid}` references are discovered even when written outside a wildcard payload
- Reference hovers name the pool they counted and flag drift against the library
- Wildcard option chips get the same hover card as every other surface
- Sub-category filter state is derived from the parsed expression, so the chips match what the filter actually does
- A reference to a module you have deleted from the library now reads as broken. It used to keep the deleted module's name and colour, and its hover card quoted the option count the module had before it was deleted — the node badge said the reference was broken while the chip inside the editor disagreed
**The manager**
- **What's new** moved to its own page, reached from the topbar with a dot when there is something unread. It is also the only place to read the notes for the version you are running — the update pill only exists while a newer one is available
- The update check no longer burns through GitHub's hourly allowance. It was making one request per page load per consumer, so a few dozen reloads could exhaust the quota for everything else on that connection; results are now reused for six hours and a refusal is honoured rather than retried
**Editing and layout**
- Fixed-value and combine fields grow with their content, with a corner grip that stays reachable and a manual drag that overrides the auto height
- A scrollable field no longer hands its scroll to the page behind it
- Rule previews truncate with an ellipsis instead of being clipped or faded, and long values wrap
- Tag menus stay inside the viewport, keep their own scroll, and fold by axis
- Dropdowns say "type to filter", which they have always supported
- The save shortcut reads `Ctrl` on Windows and Linux instead of a Mac-only glyph
- Test-runner histogram rows are banded so a row reads across its columns, and its module picker survives duplicate names
**Performance and correctness**
- Canvas polling is roughly eight times cheaper on a large graph — the walkers now share one parse instead of re-parsing a 294 KB widget value ten times per node every cycle (~21% of a core down to ~2.6%)
- Wildcard snapshots refresh when the window regains focus instead of waiting out a timer, and expire so the canvas stops drifting from the library
- The Prompt Cleaner no longer rewrites `embedding:name` or `<lora:name:1.0>` references — punctuation stripping renamed embeddings ending in `_`, and fuzzy dedupe dropped one of two versioned files
- Variable override counts report writers rather than write statements, so a module that creates a variable is no longer described as overriding writes that never happened
</details>
<!--
HOW TO WRITE THIS FILE
Order, and nothing else:
Headline one sentence, what this release is about
### Highlights 4-5 max
<!- /modal -> the update dialog stops rendering here
<details> Also in this… grouped one-liners, collapsed
### Upgrade notes only when the reader must act; delete otherwise
— keep this OUTSIDE the <details>, it needs seeing
This file IS the release body — the compare link is appended automatically and
the per-commit list is deliberately not included. It is consumed at release
time and cleared afterwards, so anything left here ships.
RULES
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
6-commit release and an 80-commit release roughly the same height. If a
sixth feels essential, something else was not a highlight.
2. A highlight passes one of three tests:
- new capability — you can do something you could not before
- wall removed — something that blocked you now works
- surprising — behaviour changed in a way you should not meet alone
Polish, truncation, layout and invisible perf fail all three.
3. Commit count is NOT a test. The categories icon picker was two commits and a
real feature; `ui` was nine commits and almost entirely polish.
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
names are free-form — use whatever the release actually contains. It sits in
a <details> so a long release does not double the page; put the count in the
summary so the reader knows what they are opening. That number is written by
hand here and can drift — the update dialog computes its own from the same
bullets, so if the two disagree, this one is wrong. Note also that collapsed
text is not reliably reachable by browser find-in-page, which is the cost.
5. Write what changed for the user, not what changed in the code. Name the
symptom, not the fix: "the field went blank" beats "guarded a null deref".
6. Perf belongs in the tail WITH its numbers, so the people it affects
recognise themselves. "Performance improvements" tells nobody anything.
-->
---
**Full changelog:** [v2.11.0…v2.12.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.11.0...v2.12.0)
## 🎉 Wildcard Pipeline 2.11.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
### ✨ What's new
A correctness fix for held modules on multi-frame runs, plus a set of tools for reconnecting workflow items to your library.
- **Held modules stay held for the whole run.** On a multi-frame sequence, a module set to **Hold across run** could quietly re-roll after the first frame — a held outfit or location would change mid-sequence — and every frame after the first filled the debug panel with constraint warnings. Held picks now carry through every frame, so constraints that depend on them keep applying, cross-node holds stay put, and the warning flood is gone.
- **Re-link items that lost their library connection.** When a re-import gives a library entry a new id, the copy in your workflow stops matching it: no drift, no **Edit library entry in SPA**, and saving creates a duplicate. **Push to library** now offers **Re-link to an existing library entry** when it finds one holding the same content — picking it automatically when there's exactly one match, or letting you choose from a list you can search by name *or* id. Works for bundles too.
- **Disconnected items now show as drifted** instead of looking like local-only rows. That means the **refresh drifted** button reconnects them in one click, and the library link, drift dot and refresh action all start working again.
- **"Reset to library snapshot" can no longer empty a bundle.** If the bundle wasn't linked to a library entry, or the snapshot came back empty, reset used to replace your modules with nothing. It now refuses and explains why, leaving the bundle untouched.
- **Importing content you already have offers to link it.** When an import contains an item identical to one already in your library under a different id, it's flagged **IN LIBRARY** with the matching entry named, and you can either link to it — references follow to the existing entry — or import the copy anyway. Nothing is linked or overwritten without you choosing.
- **Every edit modal shows the item's id.** A small chip in the header, click to copy — the same id that `@{...}` references, constraint sources and library rows all match on.
- **Nested references and variables render as chips** in derivation rule values and constraint exceptions, instead of showing raw text inside `{a|b}` braces. Exception values also get a proper picker of real option values rather than a free-text box.
- **Hover any reference chip** to see what it resolves to: its id, its sub-category filter in plain language, and how many options currently match — read live from your library, so the count moves when the library does.
- **Search by id.** Pasting an 8-character id into a search box now finds the item, in the manager lists and in the remap / reattach pickers. Previously only names matched, which was useless when two entries shared one.
- **Long values wrap** in the derivation override boxes instead of being clipped at the edge of the field.
---
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #11 from DumiFlex/fix/held-constraint-registration ([fd4c501](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd4c501)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
* docs(release-notes): summarise held-module fix + library re-link tooling ([9fb653f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9fb653f))
* chore(size): raise total bundle budget to 461 KB ([3f45b26](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f45b26))
* fix(bundle): never let "Reset to library snapshot" silently empty a bundle ([78297c4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/78297c4))
* fix(chips): render nested refs + vars in derivation and exception surfaces ([a64e56d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a64e56d)), closes [label/#option](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/option)
* fix(engine): carry held modules' frame-0 constraint state to later frames ([1b32a8a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b32a8a))
* fix(linkage): surface drift for a library-uuid module with an empty local hash ([bb5f6b8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5f6b8))
* fix(search): match modules, bundles and templates by id as well as name ([44d0ca2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d0ca2))
* feat(chips): hover card on nested-ref and variable chips ([699deda](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/699deda))
* feat(editors): show module identity in edit-modal headers + wrap long values ([5a83e61](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a83e61))
* feat(import): link-to-existing decision for content duplicates (D3b core) ([e63be54](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e63be54)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
* feat(import): surface content duplicates as a resolvable conflict row (D3b) ([c268e75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c268e75))
* feat(relink): content-aware library re-link for detached bundle instances ([ecebd55](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ecebd55))
* feat(relink): content-aware library re-link for detached module rows ([a059b48](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a059b48))
* feat(relink): content-aware re-link match + apply helpers (pure) ([9ef1fb6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9ef1fb6))
</details>
## 🎉 Wildcard Pipeline 2.10.2
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
### ✨ What's new
A bug-fix release for the in-app updater and name validation.
- **Fixed an updater downgrade.** On 2.10.0, clicking **Update Now** could install an *older* version — it asked ComfyUI Manager for "latest" against a stale cache. The updater now installs the exact new version and refuses to ever move backwards.
- **The update badge appears right away** after **Check now** — no page refresh needed.
- **Cleaner release notes** in the update dialog (proper formatting instead of raw `<details>` tags).
- **Names are validated as you rename them.** Renaming a sub-category or a variable now enforces the same rules as creating one, so invalid names (spaces, reserved characters) are caught immediately instead of failing later on save.
---
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #10 from DumiFlex/fix/updater-downgrade ([a2e93fe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2e93fe)), closes [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
* fix(manager): prevent updater downgrade, fix pill reactivity + release notes ([1e42f9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1e42f9b))
</details>
## 🎉 Wildcard Pipeline 2.10.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #9 from DumiFlex/fix/subcat-rename-validation ([74c7331](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/74c7331)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
* fix(manager): validate names on rename via shared validators (#7) ([00b735d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b735d)), closes [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
</details>
## 🎉 Wildcard Pipeline 2.10.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #8 from DumiFlex/feat/manager-qol ([fe2423f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fe2423f)), closes [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8)
* feat(loop): add optional bypass toggle to seed-lock row ([d524d14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d524d14))
* feat(loop): dashed border on bypassed edit-frame chips ([70283ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70283ce))
* feat(loop): emit only non-bypassed frames with pinned identity ([9d372b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d372b1))
* feat(loop): mirror bypass_frames in the ts config type ([da2239d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/da2239d))
* feat(loop): mirror the loop's bypassed frames in the seed list UI ([f670c3d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f670c3d))
* feat(loop): opt-in bypass column in the per-iteration modal ([651ca65](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/651ca65))
* feat(loop): parse bypass_frames in the loop config ([7aab09d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7aab09d))
* feat(loop): purple wash + dashed bar on bypassed seed rows ([ea4ed05](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea4ed05))
* feat(loop): wire per-frame bypass through the loop widget ([7545ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7545ade))
* feat(manager): add checkOnLaunch update preference to uiStore ([d42b7ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d42b7ca))
* feat(manager): add ComfyUI Manager update composable ([327e7cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/327e7cc))
* feat(manager): add safe markdown renderer for release notes ([830776e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/830776e))
* feat(manager): add UpdateDialog with changelog + Manager-driven update ([290e7a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/290e7a8))
* feat(manager): add Updates settings card with check-on-launch + check now ([6c20f53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6c20f53))
* feat(manager): bulk select + delete for exceptions + derivation rules (#11) ([ed10b88](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ed10b88)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
* feat(manager): bulk select + delete for fixed-values (#11) ([cbf102b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cbf102b)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
* feat(manager): drag-and-drop file import (#5) ([d4e6d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d4e6d0c)), closes [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5)
* feat(manager): move Target reach above the constraint rule matrix (#10a) ([d039364](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d039364)), closes [#10a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10a)
* feat(manager): open UpdateDialog from the topbar update pill ([936a139](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/936a139))
* feat(manager): per-branch collapse + collapse-all for derivation rules (#9) ([8ea1571](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ea1571)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9)
* feat(manager): per-kind quick-select presets + import-tab mirror (#1) ([8912937](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8912937)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
* feat(manager): per-rule branch collapse + collapse rules/branches by default ([fcde3b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fcde3b3))
* feat(manager): relevant + colour-coded bulk sub-category menus ([be70a5f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be70a5f)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* feat(manager): rework release check to launch-once + manual, expose release notes ([bdcf85c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bdcf85c))
* feat(manager): sticky list headers across editors ([6802f11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6802f11))
* fix(loop): parse parity + edge coverage from final review ([bab87cd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bab87cd))
* fix(loop): seed list mirrors the loop's bypassed frames ([af95f08](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af95f08))
* fix(manager): count templates in the import loaded-bar total ([2295ce0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2295ce0))
* fix(manager): detect bundle collisions on import instead of blind-adding (#6) ([b54302d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b54302d)), closes [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
* fix(manager): fresh dates for imported adds (#12) + live counts after import (#7) ([696711d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/696711d)), closes [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
* fix(manager): keep bulk-edit toolbar stuck with sticky card header ([6f69304](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f69304))
* fix(manager): resolve inline multi-pick {N-M$$sep$$…} in the test runner ([8dcef0f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcef0f))
* fix(manager): sticky-header subpixel bleed + confusing weight validation ([0bd4382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bd4382))
* test(engine): un-skip pipeline trace tests after handler migration ([7d2bd63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d2bd63))
</details>
## 🎉 Wildcard Pipeline 2.9.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* fix(derivation-modal): absolute max-height for the rules scroll (revert flex) ([5d715fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5d715fb))
* fix(derivation-modal): pin rule cards so the rule list actually scrolls ([fc9d8a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fc9d8a8))
* fix(derivation-modal): scroll the rule LIST, not the whole modal ([6f664ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f664ce))
* fix(derivation-modal): scroll the rules region, pin header + footer ([e98aca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e98aca3))
* fix(engine): derivation hold freezes each branch roll, not the branch choice ([df76abd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df76abd))
* fix(engine): hold the resolved VALUE across iterations, not just the seed ([58c5b27](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/58c5b27))
* fix(injector): keep custom pin labels with the socket on drag-reorder ([41fae1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/41fae1e))
* fix(injector): persist custom pin labels durably across collapse + reload ([8568aa2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8568aa2))
* fix(injector): reconstruct collapsed-wire labels from data, not the dead stash ([0ede205](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0ede205))
* fix(injector): repair collapse-wire labels corrupted across a reload ([0281b8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0281b8e))
* fix(injector): right-align the type pill on label-less ref/slot rows ([bf61f64](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf61f64))
* docs(spa): describe hold as a value freeze (constrained picks + nested refs) ([1b3e4aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b3e4aa))
* docs(spa): document per-iteration overrides, seed scope + lock-previous ([4a34c9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4a34c9b))
* docs(spa): mention the right-click Hold toggle shortcut ([a738618](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a738618))
* docs(spa): note derivation supports Hold across run ([aba4e8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/aba4e8c))
* feat(context): quick Hold toggle in the module row context menu ([8dcebdc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcebdc))
* feat(engine): derivation honors seed_scope=hold (freeze frame-0 output) ([ff6825c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff6825c))
</details>
## 🎉 Wildcard Pipeline 2.8.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* fix(context): add hover states to the per-kind edit modal footer buttons ([b292699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b292699))
* fix(context): capture the loop's per-frame seeds so the lock pins the previous roll ([b33afd8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b33afd8))
* fix(context): clear redundant frame lock-null override when the base unlocks ([f38a316](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f38a316))
* fix(context): coerce disabled_frames to int set and guard frame toggle ([65683ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/65683ae))
* fix(context): modal lock button pins the rolled seed via injected resolver ([e56fd22](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e56fd22))
* fix(context): name the disabled toggles in the per-frame runtime hint + wrap loop frame chips ([2fb853f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2fb853f))
* fix(context): per-frame enable overrides base enabled (frame_enabled) ([6eb4b73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6eb4b73))
* fix(context): per-frame seed lock captures that frame's own rolled seed ([25388b4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/25388b4))
* fix(context): per-frame seed lock computes the frame's real seed from the loop ([1f164df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1f164df))
* fix(context): restrict per-frame overrides to an allowlist of meaningful fields ([bb5e84d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5e84d))
* fix(context): resync editor draft on frame revert + restyle the banner for readability ([39fdb6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39fdb6f))
* fix(context): scope the wrapping grid to the frame chips only ([c48aafb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c48aafb))
* fix(context): stack the frame banner above the editor card instead of beside it ([3c492f1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3c492f1))
* fix(context): sweep redundant frame lock-null when base unlocks via the modal ([e1885a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e1885a3))
* fix(engine): apply iteration_overrides for generic-object modules ([942834d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/942834d))
* fix(engine): include disabled_frames in the generic-object snapshot keys ([03796c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/03796c5))
* fix(engine): stop double-shifting the per-iteration override seed ([3527ada](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3527ada))
* fix(extension): read loop seed and count as numbers for per-frame seed lock ([67a09df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67a09df))
* feat(context-loop): add edit-frame selector chip row ([ece6d49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ece6d49))
* feat(context-loop): add session frame-cursor singleton ([6dc033d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6dc033d))
* feat(context): add hold-across-run toggle to the runtime sections ([9663636](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9663636))
* feat(context): add lock-previous-seed to the iteration-seed modals ([c7c867e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c7c867e))
* feat(context): add pure frame-override helpers ([7b416ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b416ad))
* feat(context): disable run-level controls while editing a frame ([1822297](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1822297))
* feat(context): frame banner with revert-to-base in the module editor ([79cf385](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79cf385))
* feat(context): held and per-frame override row badges ([e93f7ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e93f7ba))
* feat(context): make the row quick-lock act on the active frame ([21f2850](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/21f2850))
* feat(context): per-frame enable/disable via disabled_frames ([8a9dc9e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9dc9e))
* feat(context): redirect edits into iteration_overrides when a frame is active ([4e71e04](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4e71e04))
* feat(engine): apply per-frame iteration_overrides onto module instance ([10dcac4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/10dcac4))
* feat(engine): combine honors hold seed_scope ([8871a28](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8871a28))
* feat(engine): fixed_values honors hold seed_scope ([15c1ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/15c1ade))
* feat(engine): thread a hold seed and loop index into the pipeline run ([0f7aaab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f7aaab))
* feat(engine): wildcard honors hold seed_scope, locked_seed still wins ([072e4e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/072e4e7))
* feat(manager): snapshot source/target tag_groups into published constraints ([ff4d8fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff4d8fa))
* feat(types): add seed_scope and iteration_overrides to module config ([8910b98](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8910b98))
* revert(context): drop per-frame seed capture, superseded by double-shift fix ([91b5f75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/91b5f75))
* refactor(context): unify frame-hint markup and add false-case coverage ([8f696d6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8f696d6))
</details>
## 🎉 Wildcard Pipeline 2.7.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* feat(engine): add apply_seed_locks for per-iteration seed overrides ([5da64f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5da64f8))
* feat(loop): apply per-iteration seed locks in the context loop node ([4884598](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4884598))
* feat(loop): per-iteration seeds button + modal; shrink iteration-var buttons ([86eb15c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/86eb15c))
* feat(seedlist): add paste-to-replace locks from copied text ([fa73750](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa73750))
* feat(seedlist): alt-click copy to grab only the locked seeds ([589f45e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/589f45e))
* feat(seedlist): apply per-iteration seed locks to the output series ([fd2cdcb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd2cdcb))
* feat(seedlist): flash a copied indicator on the modal copy button ([34ec18f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/34ec18f))
* feat(seedlist): per-iteration seeds button + modal ([43d775e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43d775e))
* feat(seedlist): preview effective base/count/strategy from the wired loop ([68910bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/68910bf))
* feat(seedlist): show out-of-range locks dimmed but still editable ([b49a579](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b49a579))
* feat(shared): add per-iteration view/set/lock row (SeedLockRow) ([1ad7e8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ad7e8e))
* feat(shared): ts mirror of loop seed derivation with py-ts parity corpus ([cc64ca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cc64ca3))
* feat(shared): view/set/lock per-iteration seeds modal ([0f0cd47](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f0cd47))
* feat(widgets): add seed_locks field to loop + seed-list configs ([5a3e834](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a3e834))
* feat(widgets): feed stock base-seed + count into the seed modal ([fb19e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fb19e3c))
* feat(widgets): seed-list override hint + capitalize loop bypass label ([44d8ab9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d8ab9))
* test(widgets): restore parse-recovery coverage dropped alongside seed_locks tests ([b5262c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b5262c2))
</details>
## 🎉 Wildcard Pipeline 2.6.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* fix(api): keep duplicate children when creating or updating bundles ([c1f45a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c1f45a3))
* fix(constraint): drop matrix scroll-container padding that leaked scrolled cells ([d3e25b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3e25b7))
* fix(constraint): stop scrolled cells bleeding through the frozen matrix headers ([4098009](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4098009))
* fix(manager): decimal option probabilities and distinct sub-category group colors ([11d205b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11d205b))
* feat(constraint): group the canvas matrix axes like the SPA ([538db06](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/538db06))
* feat(manager): author and save bundles directly in the manager ([a77e60b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a77e60b))
* feat(manager): group constraint matrix axes with header chips and column bands ([c0f354c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0f354c))
* feat(manager): label the matrix ungrouped axis run as "uncategorized" ([eac852f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eac852f))
* feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart ([0bbf150](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bbf150))
* feat(manager): type-to-filter all dropdowns ([b3537ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b3537ad))
</details>
## 🎉 Wildcard Pipeline 2.5.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* feat(manager): one-time library repair for non-hex module/bundle ids ([02cc9f7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02cc9f7))
* fix(import): normalize non-hex module/bundle ids on import + download ([a1d0511](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a1d0511))
</details>
## 🎉 Wildcard Pipeline 2.4.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
</details>
## 🎉 Wildcard Pipeline 2.3.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
</details>
## 🎉 Wildcard Pipeline 2.3.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #2 from DumiFlex/feat/manager-bulk-editor ([6e8696f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6e8696f)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
* feat(manager): add bulk editor for wildcard options and fixed values ([959cfbe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/959cfbe))
* feat(widgets): shield clipboard shortcuts on editable widget inputs ([325c908](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/325c908))
* fix(manager): recover from rich-text editor crash and persist drafts on unload ([0f5166e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f5166e))
</details>
## 🎉 Wildcard Pipeline 2.2.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #1 from DumiFlex/feat/community-embed ([a53d526](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a53d526)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
* test: lock cascade-restore end-to-end constraint repoint (#2) ([67de90c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67de90c)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
* test(cascade): edge-case qa for subcat rename/delete (collapse, null, multi-surface) ([54054fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/54054fb))
* test(constraint): double-cast findComponent to VueWrapper in cached-names test ([c102361](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c102361))
* test(engine): lock constraint source-pick fallback semantics ([690169b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/690169b))
* test(sp2a): lock multi-pick debug render + var.K cascade/validation ([d1ee97b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d1ee97b))
* test(spa): lock accessor-aware var autocomplete match ([8d7bda2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8d7bda2))
* test(syntax): move subcat_filter tests into tests/syntax for the coverage gate ([3065e99](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3065e99))
* test(validators): engine-parity harness to stop validator drift ([51f6793](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51f6793))
* test(wp_api): lock comma rejection on variable names server-side ([94ccd7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/94ccd7f))
* docs: document schema_version machine + column-add procedure ([ede4f4e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ede4f4e))
* docs: flag pick-bucket collision + deprecate dead remapBundleUuids ([96fc26a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/96fc26a))
* docs: update constraint conventions to the SP3 reach model ([8314963](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8314963))
* docs(constraint): document target reach selectors and axis reattach ([844d16e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/844d16e))
* docs(derivation): document nested refs in action values ([402fdcc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/402fdcc))
* docs(syntax): document multi-pick, nested-in-inline, $var.K, and the surface gate ([7297b2f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7297b2f))
* docs(wildcard): document multi-pick, sub-categories, and nested refs ([8b99795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8b99795))
* feat: pick_min/pick_max/pick_separator wildcard instance fields ([17a2149](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/17a2149))
* feat(api): modules/hashes returns type + fingerprint per id ([885f7fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885f7fb))
* feat(bundles): add extractBundleChildren fresh-id materialization helper ([223770e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/223770e))
* feat(bundles): extract bundle children to library with ref remap ([59af070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59af070))
* feat(bundles): manual per-child reattach with community download ([f58d849](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f58d849))
* feat(bundles): move extract action to children header, used-by beside title ([f1ab33c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f1ab33c))
* feat(bundles): relink bundle to extracted modules + gate extract on dangling children ([b1fa0ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b1fa0ce))
* feat(canvas): @{} chips + autocomplete in derivation instance modal ([ab813bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ab813bb))
* feat(cascade): expression-aware subcat rename/delete (engine + ts) ([11ad8ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11ad8ca)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
* feat(cascade): per-entity wildcard-delete cleanup via cleanup_ids ([9a97613](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a97613))
* feat(cascade): reconcileSubcatExpr token reconciler for remap preview ([fd25a63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd25a63))
* feat(cascade): rewriteBrokenRef pure rewriter for broken-ref remap ([1d59df9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1d59df9))
* feat(collision): add type-conflict state + classifyOne core ([9e34d23](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9e34d23))
* feat(community): add public post detail + download fetch helpers ([c858bef](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c858bef))
* feat(community): add transitive dependency closure walker ([1ee8fe2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ee8fe2))
* feat(community): orchestrate transitive dep download for dangling refs ([7dc9817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7dc9817))
* feat(community): route Publish through embed instead of new tab ([59c5d70](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/59c5d70))
* feat(community): surface underlying import error in EmbedLoadError ([6f51e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f51e4a))
* feat(community): track install origin + update-available indicator ([9957702](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9957702))
* feat(community): update-action dialog with in-place + as-new + dismiss ([af314d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af314d3))
* feat(conflicts): surface broken nested @{} refs at module row ([fd254a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd254a6))
* feat(constraint): backfill cached wildcard name from live catalog on save ([d188ead](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d188ead))
* feat(constraint): cache source/target wildcard names for broken-ref banner ([70da7e0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70da7e0))
* feat(constraint): canvas modal detects dangling source/target and reattaches via walkremap ([77f00ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/77f00ae))
* feat(constraint): canvas parity for stranded read-only matrix + colored exception modes ([5ae0ff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5ae0ff0))
* feat(constraint): download missing deps from community + reattach ([02ad0eb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ad0eb))
* feat(constraint): list derivation-nested occurrences in reach pick-list + orphan scan ([098c069](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/098c069))
* feat(constraint): lock stranded wildcard fields + color-code exception modes ([4598f68](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4598f68))
* feat(constraint): reattach section for dangling source/target with blast-radius warning ([f7369ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f7369ff))
* feat(constraint): recognise derivations as @{} carriers (branch-key option ids) ([8247d73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8247d73))
* feat(constraint): show stranded constraint matrix + exceptions read-only in spa editor ([fa68a36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa68a36))
* feat(constraint): spa editor reattach control mirrors the canvas modal ([1bf1e42](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1bf1e42))
* feat(context): type-aware drift/missing + id-clash state via classifyOne ([c13351d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c13351d))
* feat(engine): bulk lazy runner — eager iteration of lazy_migrate_row ([73ca8a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73ca8a3))
* feat(engine): capture constraint's own bundle_origin on registered meta ([febb9f3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/febb9f3))
* feat(engine): constraint reach selector (first/next/all) + multi-constraint combine; retire one-shot ([be1f765](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be1f765))
* feat(engine): content_rating column on library rows (migration 015) ([395af63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/395af63))
* feat(engine): independent (allow-repeats) multi-pick for the wildcard pool ([33b3600](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33b3600))
* feat(engine): lazy_migrate_row — the ONE migration runtime ([07fe3ab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07fe3ab))
* feat(engine): list-backed variables + var.K accessor ([8eeb0a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eeb0a8))
* feat(engine): migrate library v1->v2 (multi-tag) + bump schema version ([39529a6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39529a6))
* feat(engine): migration 014 - payload schema_version + verbatim-local mirror ([c46cbbf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c46cbbf))
* feat(engine): multi-pick prefix gains N-M range + ~ independent flag ([0b36ce8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0b36ce8))
* feat(engine): multi-select wildcard binds a list variable ([443db49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/443db49))
* feat(engine): multi-tag options + boolean sub-category filters (v2) ([13de622](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/13de622))
* feat(engine): per-pick recording + apply constraints via combine_constraint_factor ([f68d2ec](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f68d2ec))
* feat(engine): pure combine_constraint_factor + shared constraint corpus ([dd785c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dd785c5))
* feat(engine): range count + independent mode + lone-ref pool multi-pick ([63b824e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63b824e))
* feat(engine): record per-origin source pick view in __wp_picks__ ([d6094ed](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d6094ed))
* feat(engine): resolve @{} refs on the derivation surface ([059517f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/059517f))
* feat(engine): stamp derivation carrier (uid, branch_key) for nested reach ([599c3b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/599c3b1))
* feat(engine): thread bundle_origin into resolve ctx per module ([7bcfcac](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7bcfcac))
* feat(engine): thread carrier uid + option id for nested-pick constraint reach ([4165b2e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4165b2e))
* feat(engine): tokenize var.K list accessor (py+ts parity) ([3ac0afa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3ac0afa))
* feat(engine): validate + register constraint target_select; instance override field ([8c3c9ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c3c9ae))
* feat(import-export): install decision tree per spec §4 Flow 3 ([19836dd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/19836dd))
* feat(import-export): parseTolerantAsCurrentShape — strip newer fields ([4758c84](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4758c84))
* feat(import-export): publish stamps schema_version + producer_engine_version ([ad0f8c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ad0f8c2))
* feat(import): apply walkremap follow-through over committed entities on rename ([236c39a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/236c39a))
* feat(import): surface cross-kind id clashes as CLASH; never replace ([4fabb76](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4fabb76))
* feat(import): walkremap follow-through helper for renamed friend ids ([4dd6d6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4dd6d6c))
* feat(install): add buildReattachRemap slug-bridge helper ([7845d91](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7845d91))
* feat(install): add reattach type surface (deps, slug, count) ([e138868](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e138868))
* feat(install): auto-reattach inner-bundle references on bundle install ([b4f2d6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b4f2d6b))
* feat(install): auto-reattach references via slug-bridge on install ([0494e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0494e3c))
* feat(install): flag + backstop cross-kind id clashes (never replace) ([ca54a3a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ca54a3a))
* feat(install): host bridge forwards post deps + library slug ([a9bde14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a9bde14))
* feat(install): pre-commit collision detection + resolver callback ([519abd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/519abd3))
* feat(manager): add Publish to community button on Export tab ([c6c42c4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6c42c4))
* feat(manager): editor footers gain Upload + Copy buttons ([9c0b11f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c0b11f))
* feat(manager): embed Wildcard Pipeline Community via runtime bundle ([7d43164](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d43164))
* feat(manager): enforce identifier grammar on variable names ([c661550](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c661550))
* feat(manager): expose window.__wpcRuntime host bridge for community installs ([a10a321](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a10a321))
* feat(manager): mirror v1->v2 migration + bump client schema version ([221aa90](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/221aa90))
* feat(manager): move editor Publish + Copy from footer to header ([ea3c628](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea3c628))
* feat(manager): nsfw content filter on every list view + all items ([4b491b8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4b491b8))
* feat(manager): nsfw toggle in editors + pill on library rows ([99374cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/99374cf))
* feat(manager): per-row community upload + copy + update diag ([7a5c382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7a5c382))
* feat(manager): real 404 page + global error boundary ([c0cfd13](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0cfd13))
* feat(manager): remaprefpopup for healing broken nested chips ([e897bd3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e897bd3))
* feat(manager): thread module-id so remap clears broken-ref warnings in all #3 hosts ([770909d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/770909d)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* feat(manager): wire @remap in richtextinput to heal the open field ([a75c737](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a75c737))
* feat(pool): multi-select pick-count control + null disable in multi ([d167a1f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d167a1f))
* feat(publish): add listReferencedUuids dependency detector ([e709d53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e709d53))
* feat(publish): auto-detect + inject module dependencies into publish hash ([107d5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/107d5f4))
* feat(publish): guided unmet-dependency checklist before publish ([292a0a5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/292a0a5))
* feat(publish): surface bundle children external refs as dependencies ([33316aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33316aa))
* feat(publish): surface inner-bundle dependencies in guided publish ([e7e844b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e7e844b))
* feat(publish): verify published deps + reclassify stale ones to publish-first ([feb760e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/feb760e))
* feat(publish): wire inner-bundle dependency publish button to navigate ([f8edcfe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8edcfe))
* feat(publish): writeback community origin to local row after publish ([bbd1a37](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bbd1a37))
* feat(refchip): distinct remap emit for unresolved broken chips ([43de217](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43de217))
* feat(spa): "Allow repeats" toggle for the wildcard pool multi-pick ([e785928](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e785928))
* feat(spa): author @{} nested refs in derivation action values ([3564938](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3564938))
* feat(spa): canvas wildcard editor decomposes blocks into inner chips (sp2b) ([0962aad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0962aad))
* feat(spa): decompose brace blocks into scaffolding + inner chips, render v2 ([ef6fadc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ef6fadc))
* feat(spa): render var.K as a single chip + accessor-aware autocomplete ([64d1cb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/64d1cb1))
* feat(spa): resolve inline blocks in the combine preview (deterministic) ([62f2e4a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62f2e4a))
* feat(spa): stamp sp2b schema_version on publish when range/independent used ([36942a2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/36942a2))
* feat(syntax): boolean sub-category filter parser, matcher + ts parity ([0e1803e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e1803e))
* feat(ts): badge reframe — reach suffix, contributor cluster + solid collapse chip, before name ([02ed686](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02ed686))
* feat(ts): combineConstraintFactor mirror + shared corpus parity ([2eb0e36](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2eb0e36))
* feat(ts): constraint reach selector — segmented modes + cross-node pick checklist ([c80605a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c80605a))
* feat(ts): debug rows for constraint never_applied + partial_reach ([764c82e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/764c82e))
* feat(ts): library-default target_select in the manager constraint editor ([a0f9651](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a0f9651))
* feat(ts): pairings mark-all reach + contributors (drop exclusive claim) ([3902210](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3902210))
* feat(ts): per-selector constraint orphan scan (drop one-shot claim-order) ([3f14a6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f14a6f))
* feat(ts): per-WP_Context node_label, tagged through the cross-node walker + editable header chip ([a94cd54](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a94cd54))
* feat(ts): preview resolver applies combine + reach (engine parity) ([b20faa0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b20faa0))
* feat(ts): stamp schema_version 4 when constraint target_select is non-default ([826d86f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/826d86f))
* feat(ts): transitive nested-target carrier walk (cap + cycle) with route chain ([1ddbe45](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ddbe45))
* feat(ui): nested-ref filter popover + chip + in-graph pool editor ([33e6bd0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/33e6bd0))
* feat(ui): wildcard editor sub-category groups + per-option multi-tag ([c374863](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c374863))
* feat(validators): v1 strict + tolerant validators per subtype + bundle ([810e33e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/810e33e)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
* fix: refresh token field + show update pill outside #name slot ([9c7a110](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9c7a110)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
* fix(assembler): render list-valued vars + .K accessor in node preview ([dc05a5d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc05a5d))
* fix(bundle): remap constraint source/target to restored uuids on cascade push ([bb9ec6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb9ec6d))
* fix(bundles): reconcile restored inner-bundle fingerprint to clear false drift ([e458e8d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e458e8d))
* fix(bundles): repoint cascade-restored standalone library refs (phase 1 pass 2) ([fa0f2ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa0f2ce))
* fix(bundles): repoint workflow module refs on cascade restore, not just ids ([6fe52c0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6fe52c0))
* fix(bundles): stamp payload_hash on cascade-restored bundle children so re-insert shows no drift ([eff7a6a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eff7a6a))
* fix(bundles): sync restored module payload_hash on cascade so canvas shows no drift ([37ca8bd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/37ca8bd))
* fix(canvas): picker z-index + teleport theme + inline ↪#N badge in derivation summary ([ec3508a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ec3508a)), closes [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N) [#N](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/N)
* fix(cascade): allow whole-entity delete for fixed_values/combine/derivation/constraint ([738ede3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/738ede3))
* fix(cascade): discover v2 boolean sub-category filter refs ([4cda98f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4cda98f))
* fix(cascade): keep bundles frozen on wildcard rename (no auto-mutate) ([e748064](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e748064)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name)
* fix(cascade): stamp deleted wildcard name onto dependent constraints for broken-ref banner ([97d3036](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97d3036))
* fix(cascade): wildcard-delete leaves constraints, opt-in nested-ref strip ([e288d1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e288d1b))
* fix(combine): filter multi-pick representative to engine pool + honor .K in template preview ([b9df102](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9df102))
* fix(community): preserve bundles/library/deleted views in host nav ([f8efcc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f8efcc9))
* fix(community): sync embed theme with the manager toggle live ([04841aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04841aa))
* fix(community): tokenStore refresh hits /auth/token/refresh not /auth/refresh ([a2cc1bb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2cc1bb))
* fix(constraint): derive real dropped-cell count for reattach preview ([47af97e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47af97e))
* fix(constraint): float cached-name match in reattach dropdown (auto-suggest) ([07dcf31](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/07dcf31)), closes [#name](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/name) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* fix(constraint): keep @{} carrier regex strict-hex (engine/dep-graph parity) ([b27b9f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b27b9f8))
* fix(constraint): origin-stamp downloaded deps so they get the community badge ([0984a7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0984a7f))
* fix(context): constraint-reach QA, node codenames, bundle-aware toolbar ([73a00a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/73a00a3))
* fix(engine): bind constraint to its own bundle copy's source pick ([9071dfb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9071dfb))
* fix(engine): fold list-valued vars in derivation + converter reads ([d41be86](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d41be86))
* fix(engine): guard junk pick counts + warn on multi-pick constraint source ([ace0486](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ace0486))
* fix(engine): slugify nested-ref tags in v1->v2 migrate + sp1 e2e smoke ([a628512](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a628512))
* fix(graph): honor .K accessor + multi-pick representative in static preview ([f922dca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f922dca))
* fix(import-export): refresh also reloads the export tab listing ([7b2d057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b2d057))
* fix(install): drop normalizeEntity now that community speaks engine-row shape ([b9132af](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9132af))
* fix(install): lift meta.name to top-level name in install pipeline ([160b729](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/160b729))
* fix(library): validate all-items bundles against loaded catalog not empty store ([c66c1f5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c66c1f5))
* fix(manager): add Delete button to WildcardEditor ([3d92673](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3d92673))
* fix(manager): anchor remap popup to the chip using its real height ([498a4ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/498a4ba))
* fix(manager): bundle-editor constraint names + list keyboard nav reachability ([0da118e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0da118e)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* fix(manager): community pill lifecycle, list keyboard nav, dashboard intro ([24e83ea](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/24e83ea)), closes [4/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
* fix(manager): community routes share layoutKey to stop embed re-mount ([6f2b804](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f2b804))
* fix(manager): confirm before destructive actions ([bb82b66](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb82b66))
* fix(manager): dashboard tabs left + settings restart confirm ([b85f21a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b85f21a)), closes [#actions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/actions)
* fix(manager): dismiss subcat menus on outside click + flip near edge + drag-to-regroup ([723adde](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/723adde))
* fix(manager): keep editor header Publish + Copy grouped ([97c92b9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/97c92b9))
* fix(manager): never chipify on backspace/delete in rich-text editor ([af90203](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af90203))
* fix(manager): parseRoute handles :rest(.*)* array params ([84cd82f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/84cd82f))
* fix(manager): re-anchor remap popup on resize + cap height to viewport ([3884ded](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3884ded))
* fix(manager): render unsettled $var/@ref as plain text in editor ([bf189b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf189b3))
* fix(manager): row actions/focus follow hover+keyboard, not a sticky state ([915e0cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/915e0cc))
* fix(pool): custom pick steppers + min<=max clamp + null-aware enabled/probability ([2a24f9f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a24f9f))
* fix(pool): strike + uncheck null row in multi-pick; align pick stepper styling ([7d267d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d267d0))
* fix(pool): surface multi-pick count in pool summary + instance subtitle ([31fe070](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/31fe070))
* fix(preview): serialize list-valued vars in resolve endpoint ([6703583](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6703583))
* fix(publish): bypass vue-router hash encoder ([63a7cd9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/63a7cd9))
* fix(publish): correct fixed_values shape + strip history sidecar ([191f880](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/191f880))
* fix(publish): forward content_rating + tags + origin slug to embed ([39c0637](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39c0637))
* fix(publish): forward schema_version stamp into the community publish hash ([9a3b5fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a3b5fa))
* fix(publish): normalize bundle children to module-row shape ([47b1535](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/47b1535))
* fix(publish): stamp constraint source/target wildcard names so shares self-describe ([50612dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/50612dc))
* fix(resolver): carry structured list-vars through static resolve so $mood.0 indexes ([435cdb1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/435cdb1))
* fix(schema): accept natively-supported future versions on install (max-known, not chain head) ([9f7ee12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9f7ee12))
* fix(schema): advertise max-known schema version to publish gate, not chain head ([0cb5706](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0cb5706))
* fix(snapshot): include SP2a pick fields in instance baseline + snapshot type ([d3881c8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3881c8))
* fix(spa): $var autocomplete fires after the $$ multi-pick delimiter ([3a9a09a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3a9a09a))
* fix(spa): block native delete eating the ZWSP pad at field boundaries ([2a1123f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2a1123f))
* fix(spa): chip whitespace-padded block arms + recolour blocks on edit ([51a526e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/51a526e)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* fix(spa): colour rich-text syntax on the ComfyUI canvas ([ba455fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ba455fb))
* fix(spa): detect @{uuid} refs nested inside brace/multi blocks ([6097590](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6097590))
* fix(spa): drop leftover background box behind multi-pick blocks ([b9ae37a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b9ae37a))
* fix(spa): keep autocomplete open across a $var.K accessor (no Enter newline split) ([d9235c3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d9235c3))
* fix(spa): keep brace-block scaffolding colour through atom-direct edits ([619cd5a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/619cd5a)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* fix(spa): resolve \.K to base name in derivation validation + conflict scan ([bc7ea33](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bc7ea33))
* fix(spa): show rich-text placeholder ghost (zwsp pad defeated :empty) ([e69ceae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e69ceae))
* fix(spa): show the !null ban mark on canvas inner-block ref chips ([55d9ef1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/55d9ef1))
* fix(spa): single-char backspace/delete on raw-typed $var (atomic-delete only committed chips) ([743f8b0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/743f8b0))
* fix(spa): stable $var.K chip round-trip in rich-text editor ([1208222](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1208222))
* fix(ts): pick reach stores bare _uid; partial_reach severity info + stale-comment cleanup ([d826c6b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d826c6b))
* fix(ts): thread cross-node chain to the constraint modal (chain-modules prop) ([71e05b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/71e05b1))
* fix(ui): anchor ref-filter popover snug to its chip + move delete button far-left ([ff0f9dc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff0f9dc))
* fix(ui): clamp ref-filter popover on-screen, show match count, link null toggle to exclude_null ([b23e29e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b23e29e))
* fix(ui): peel !null in nested-ref filter chips on canvas + preview ([c5720e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c5720e7))
* fix(ui): render embed/canvas checkboxes via a scoped WpCheck component ([8000e1b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8000e1b))
* fix(ui): shrink ref-filter action buttons by overriding the fixed .wp-btn height ([c777810](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c777810))
* fix(ui): stack the pool allow-repeats + exclude-null toggles ([8ad2d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ad2d0c))
* fix(ui): style wildcard editor sub-category groups + option-tag picker ([3bcafc9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3bcafc9))
* fix(ui): subcategory polish across spa + graph (colours, exclude-null, palette, popover) ([c2d0ef5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c2d0ef5)), closes [1/#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2) [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6) [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [9/#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
* fix(validation): parse v2 boolean sub-category ref filters ([5a0e05e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a0e05e))
* fix(validators): match real engine shapes + widget-native bundle children ([8a9ee8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9ee8c))
* refactor(bundles): delete dead remapBundleUuids, keep walkRemap ([a4d1913](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a4d1913))
* refactor(constraint): centralise derivation branch-key format + py/ts corpus parity ([0e9b5cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9b5cf))
* refactor(engine): extract pick_k_unique from multi-pick resolver ([e5f54c6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e5f54c6))
* refactor(import-export): per-row migratePayload contract per spec §2 ([320315c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/320315c))
* refactor(install): applyCollisionDecisions returns rename map ([a5f95cf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a5f95cf))
* refactor(manager): harmonize module-list row pills ([87b8c6d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/87b8c6d))
* refactor(ui): convert remaining native checkboxes to the shared wp-check look ([c6baeb8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6baeb8))
* refactor(ui): make .wp-check the one checkbox style + restyle to wildcard-option look ([62d3768](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/62d3768))
</details>
## 🎉 Wildcard Pipeline 2.1.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* feat(api): add GET /wp/api/database/info ([dc839bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/dc839bf))
* feat(api): add POST /wp/api/database/maintenance ([0909817](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0909817))
* feat(api): expose db config get/put endpoints with pending-move ([04d3795](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/04d3795))
* feat(api): run pending db move before migration on startup ([00b0c73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b0c73))
* feat(engine): add analyze maintenance op ([d39add1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d39add1))
* feat(engine): add gather_info DB introspection ([2b5f2b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2b5f2b7))
* feat(engine): add integrity_check maintenance op ([7f12575](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7f12575))
* feat(engine): add run_migrations maintenance op ([5a62929](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a62929))
* feat(engine): add sidecar db-config.json reader/writer ([069527a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/069527a))
* feat(engine): add user/global/root location path helpers ([747a84a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/747a84a))
* feat(engine): add vacuum maintenance op ([4abe679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4abe679))
* feat(engine): execute pending db moves at plugin load ([52966e6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/52966e6))
* feat(engine): expose db path resolution source ([7e12626](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7e12626))
* feat(engine): honor sidecar preference and add root db location ([c03fc07](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c03fc07))
* feat(manager): add always-visible restart comfyui row to maintenance section ([c6a1030](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c6a1030))
* feat(manager): add database API client + types ([f20a9e3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f20a9e3))
* feat(manager): add database card with info and maintenance ([df7eb75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df7eb75))
* feat(manager): add databaseStore Pinia store ([2003f38](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2003f38))
* feat(manager): add db config types and api client methods ([8c760f2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8c760f2))
* feat(manager): add location selector with copy or move flow ([eb48d7f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eb48d7f))
* feat(manager): add maintenance op three-phase modal ([7cfe29b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7cfe29b))
* feat(manager): add restart comfyui button via manager reboot endpoint ([02c8300](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02c8300))
* feat(manager): browser preferences card with full prefix coverage ([b2b75d4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b2b75d4))
* feat(manager): detect server restart and prompt stale page reload ([7102057](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7102057))
* feat(manager): extend database store with config and pending move actions ([8eb5967](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8eb5967))
* feat(manager): fade routes on tab switch with reduced-motion fallback ([9a053e8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9a053e8))
* feat(manager): heartbeat poll every 30s so stale banner appears without tab switch ([efb38ff](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/efb38ff))
* feat(manager): replace storage card with browser prefs and database cards ([885a2d0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/885a2d0))
* fix(manager): flex-based row layout for database info, wrap long paths ([a89bf7d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a89bf7d))
* fix(manager): probe manager via get /manager/version instead of head reboot ([843a679](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/843a679))
* chore: ignore db-config.json sidecar and root db dir ([d23a728](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d23a728))
* chore(tests): fix fresh_db fixture return type annotation ([ecdbf6c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ecdbf6c))
</details>
## 🎉 Wildcard Pipeline 2.0.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
+83 -2
View File
@@ -44,8 +44,8 @@ ruff check . # Python lint
- **Toast singleton** — one Vue app mounted to body-level div renders all toasts pushed via `src/components/shared/toast-store.ts`. Every Context node import same store so toasts surface from anywhere. Position mirror ComfyUI native PrimeVue toast anchor (rect-relative to `.graph-canvas-container`).
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — no available target instance DOWNSTREAM, count-aware: N constraints targeting same wildcard need N downstream instances). Engine emits parallel runtime warning `constraint_never_applied` (warning) when a constraint's target never came up.
- **Constraint first-instance** — each constraint module is a one-shot. Fires on the FIRST target wildcard instance encountered downstream at runtime (including via nested `@{uuid}` refs), then is consumed. Engine tracks consumption in `ctx["__wp_consumed_constraints__"]` (set keyed by the owning constraint module's id). To affect multiple target instances, author multiple constraint modules. Position in chain controls which target each constraint claims — no Local/Global / Stack/Override scope modes.
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — the constraint's reach selector covers ZERO reachable downstream instances; per-selector, NOT count-aware — overlapping constraints may freely share an instance). Engine emits runtime warnings `constraint_never_applied` (the reach matched nothing at runtime) and `constraint_partial_reach` (a `next N` / `pick` reach matched fewer instances than requested).
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
@@ -65,6 +65,87 @@ ruff check . # Python lint
- **Reactivity** `extension/reactive.ts:reactiveFromGraph` — wires `onConnectionsChange` chain + 400ms polling fallback + `afterConfigureGraph` re-sync so widgets recompute on graph edits + workflow loads without flashing stale state.
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). Use `import("./widgets/<name>")` so stay out of entry chunk + size gate keep holding.
## Schema versioning + lazy migration machine
Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-schema-versioning-migration-design.md` (cross-repo lock; read before bumping).
**Three axes** every payload + library row carries:
1. **`schema_version`** (per row, engine column added in migration 014): which payload shape this row's `payload` is in. Single version per payload root — bundles' children inherit the bundle's `schema_version`.
2. **`producer_engine_version`** (per published community version, server-side only): diagnostic. Never load-bearing.
3. **`version_number`** (per community post, server-side): the user-facing 1/2/3 publish revision. Unrelated to schema_version.
**Engine row columns** (migration 014):
- `schema_version INTEGER NOT NULL DEFAULT 1` — current shape this row targets.
- `original_payload_json TEXT` — verbatim local mirror of the payload as it arrived from community (or import). Lazy migrator reads from here, never from the live `payload` field. Lets us re-project from the original if a migrator chain gets fixed retroactively.
- `tolerant_drift_status TEXT` — `none` | `tolerant` | `strict`. Set by parseTolerantAsCurrentShape during install.
- `schema_migrated_at TEXT` — wall-clock stamp of last lazy migration step.
**The machine**:
- **Validator registry** (`src/manager/import-export/validators/`): one Zod validator per known `schema_version`. Strict at `≤ CURRENT_SCHEMA_VERSION`; future versions go through `parseTolerantAsCurrentShape` (additive-strip).
- **`parseTolerantAsCurrentShape(raw, currentVersion)`** (`src/manager/import-export/tolerant-parse.ts`): strips fields the current shape doesn't know, returns the projection that matches `currentVersion`. Used at install when source `schema_version > CURRENT`.
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migration-machine.ts`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
- **`lazy_migrate_row(row, currentVersion, migrators, validators)`** (`engine/db/lazy_migrate.py`): reads `original_payload_json` (or `payload` if absent), walks the migrator chain to `currentVersion`, validates, writes back. Bulk-at-boot runner is eager iteration of this routine.
- **Install decision tree** (`src/manager/import-export/install.ts`): branches on `source_schema_version` vs `CURRENT_SCHEMA_VERSION`:
- `=`: strict validate + insert.
- `<`: migratePayload then strict validate + insert.
- `>` and tolerant-only diff: parseTolerantAsCurrentShape, mark `tolerant_drift_status='tolerant'`, insert.
- `>` and breaking-future diff (per catalog `is_breaking_from_previous` AND-fold across the interval): refuse install with a clear error.
**Server probe**: `engine-export-wrap.ts:ENGINE_SCHEMA_VERSION` is the sister's pinned CURRENT — it ships in the sister bundle, not fetched from server. The host bridge install path reads it from `window.__wpcRuntime.schemaVersion`. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's CURRENT.
### Bumping `schema_version` (the proper way)
When a payload shape change lands (not a row-column change — those don't bump schema):
1. **Add the new shape's validator.** New file under `src/manager/import-export/validators/v<N>.ts` exporting a Zod schema. Register in `validators/index.ts`.
2. **Add the migrator step.** In `src/manager/import-export/migration-machine.ts`, register `migrators[N-1 → N]: (payload) => ...`. Forward-only. The function is pure — no side effects.
3. **Add fixtures.** `src/manager/__tests__/import-export/fixtures/v<N>/` — at minimum: a `valid-strict.json`, a `from-v<N-1>-migrated.json` round-trip case, and one `breaking-shape.json` if the bump is breaking.
4. **Bump the constant.** `CURRENT_SCHEMA_VERSION` lives in the validator registry. Bump it to `N`. Also bump `ENGINE_SCHEMA_VERSION` in `src/manager/components/engine-export-wrap.ts`.
5. **Write the migration test.** `tests/engine/db/test_lazy_migrate.py` should cover v<N-1>→v<N> round-trip + the breaking-future path if applicable.
6. **Run `pytest tests/engine/ -q && pnpm test` until green.** Sister pre-commit hook (~2 min) also runs lint + typecheck + build + size; expect that wait.
7. **Coordinate the community-side catalog row.** Community web's `schema_catalog` table gets the matching `(version, is_breaking_from_previous, notes)` row + a deploy that lands BEFORE sister's PR merges. See community CLAUDE.md "Bumping the schema" for the server side.
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's CURRENT, sister can safely refuse breaking-future shapes.
### Validator ↔ engine parity (DON'T skip)
The community TS validators in `src/validators/` are a hand-authored
re-implementation of the engine's payload shapes, and re-implementations
drift — fixed_values shipped for weeks as `entries/{variable_name}` when
the engine has always used `values/{id,name,value}`. The guard against
this:
- `scripts/dump_engine_shapes.py` builds one sample payload per subtype,
validates each against that subtype's **own engine `Handler.validate_payload`**
(the authority — raises if not engine-valid), and writes the engine-row
shape to `src/validators/fixtures/engine-parity/*.json`.
- `src/validators/__tests__/engine-parity.test.ts` asserts the **strict**
community validator accepts every one of those fixtures.
**Any time you change an engine module payload shape** (a `*_handler.py`
`validate_payload`, or what the editor stores), run
`python scripts/dump_engine_shapes.py` and commit the regenerated
fixtures. If the sample no longer validates against the handler, the
script fails — update the sample. If the TS validator then rejects the
new shape, the parity test fails — update the validator. Either failure
means the two have diverged; fix it before publish breaks for a user.
### Row-level column changes (no schema_version bump needed)
The most recent example is **migration 015 (`content_rating` column)**. Adding a column to `modules` or `bundles` is just an Alembic-style SQLite migration — no payload-shape change, no validator update, no migrator chain. Procedure:
1. **New file** `engine/db/migrations_sql/<NNN>_<name>.py` mirroring 013/015 shape: `_has_column` gate + `with conn: conn.execute("ALTER TABLE … ADD COLUMN …")` for both `modules` and `bundles` if the field applies to both.
2. **Update `_row_to_module` / `_row_to_bundle`** in `engine/db/repositories.py` — wrap the new column read in `try/except (IndexError, KeyError)` so pre-migration fixtures don't blow up.
3. **Update `ModuleRepository.create/update` + `BundleRepository.create/update`** to accept the new field as a kwarg.
4. **Update `_insert_module` / `_insert_bundle` (+ `_update_module` / `_update_bundle` if needed)** in `engine/importer.py` to pass the field through from the entity dict. Use `COALESCE(?, col)` on the UPDATE side when you want missing-in-content to preserve existing value (NOT overwrite to NULL).
5. **Update `wp_api/modules.py` + `wp_api/bundles.py`**: add the field to `_UPDATABLE_FIELDS` + plumb into the create handlers.
6. **TS types**: extend `ModuleRow` / `BundleRow` + `*CreateInput` / `*UpdateInput` in `src/manager/api/types.ts`.
7. **Bump the head-migration test**: `tests/engine/db/test_migrations.py:test_migrate_records_version` asserts the head version — update to `<NNN>`. Same for the expected-columns set tests.
8. **Write a migration test** at `tests/engine/db/test_migration_<NNN>.py` — column exists, default backfill correct, idempotent rerun, accepts the new values.
If the column also flows through the host-bridge install seam (community origin metadata), extend `InstallOrigin` in `src/manager/import-export/install.ts` AND the embed's `HostInstallOpts.origin` in `web/src/embed/EmbedDetail.vue` (community web repo). Map community-side naming → engine-side naming at the seam if they differ (e.g. `'sfw'` → `'safe'`).
## Anti-patterns (inherited from reference project `ComfyUI-Wildcard-Pipeline`)
- Never use V1 `NODE_CLASS_MAPPINGS` / `EXTENSION_WEB_DIRS`.
+4 -3
View File
@@ -16,7 +16,7 @@
</p>
<p align="center">
<img src="public/images/docs/flow-intro.svg" alt="Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP" />
<img src="public/images/docs/flow-intro.svg" alt="Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits named $variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning" />
</p>
## What it does
@@ -33,6 +33,7 @@ Stock ComfyUI prompt nodes either take a single literal string or pick from a fl
- **Persistent library** — a manager SPA backed by SQLite stores reusable modules, frozen bundles, prompt templates, and a category tree. Browse / edit / fork / push from the canvas.
- **Inspector** — `WP Debug` shows the post-run snapshot, per-module trace, per-wildcard picks, and the conflict-scanner warnings.
- **Type-coercion helpers** — `WP Var → Int / Float / Bool` parse typed values out of any `$variable` so wildcards can drive image width, step count, sampler cfg, conditional switches.
- **Works on both renderers** — the legacy canvas and ComfyUI's Nodes 2.0 Vue renderer, including switching between them with a workflow already open.
## Installation
@@ -105,7 +106,7 @@ Full module reference: [Modules wiki page](https://github.com/DumiFlex/ComfyUI-W
## Looping a chain — one click, N coherent prompts
<p align="center">
<img src="public/images/docs/flow-loop.svg" alt="WP Context Loop + WP Seed List → KSampler — both lists fan out in lockstep" />
<img src="public/images/docs/flow-loop.svg" alt="WP Context Loop drives WP Context and the Prompt Assembler; CLIP Encode turns each prompt into conditioning for KSampler, while WP Seed List supplies the matching seed directly" />
</p>
Drop `WP Context Loop` before your `WP Context` and pair it with `WP Seed List` for distinct sampler seeds. Both lists fan out in lockstep so iteration N's prompt always pairs with seed N. Each iteration carries its own `$iteration` / `$iteration_total` for in-template labelling (`frame 1 of 4 — a fox`). See [Concepts → Seeds and loops](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Concepts#seeds-and-loops).
@@ -145,7 +146,7 @@ See [Nodes → WP Debug](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/w
## Status
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every PR.
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every branch push and PR.
## Contributing
View File
+253 -85
View File
@@ -37,6 +37,9 @@ import sqlite3
from typing import Any
from engine.db.repositories import BundleRepository, ModuleRepository
from engine.syntax.subcat_filter import ParseError as _ParseError
from engine.syntax.subcat_filter import parse as _parse_subcat
from engine.syntax.subcat_filter import reads_as as _reads_as
# ---------------------------------------------------------------------------
# Internal helpers
@@ -59,24 +62,112 @@ def _rewrite_var_in_string(s: str, old_name: str, new_name: str) -> str:
return pattern.sub(f"${new_name}", s)
def _map_tags(ast: dict[str, Any] | None, fn) -> dict[str, Any] | None:
"""Apply *fn* to each tag leaf of a parsed sub-category expression;
drop tags *fn* maps to ``None`` and collapse the now-childless
operator (a one-child and/or becomes that child). Returns ``None``
when the whole expression empties. Mirror of TS `mapTags`
(src/manager/cascade/subcatExprCascade.ts)."""
if ast is None:
return None
if "tag" in ast:
v = fn(ast["tag"])
return None if v is None else {"tag": v}
if ast["op"] == "not":
x = _map_tags(ast["x"], fn)
return {"op": "not", "x": x} if x is not None else None
kids = [k for k in (_map_tags(c, fn) for c in ast["kids"]) if k is not None]
if not kids:
return None
return kids[0] if len(kids) == 1 else {"op": ast["op"], "kids": kids}
def _expr_tags(ast: dict[str, Any] | None) -> set[str]:
out: set[str] = set()
def walk(n: dict[str, Any] | None) -> None:
if n is None:
return
if "tag" in n:
out.add(n["tag"])
return
if n["op"] == "not":
walk(n["x"])
return
for k in n["kids"]:
walk(k)
walk(ast)
return out
def collect_tags(expr: str | None) -> set[str]:
"""Distinct sub-category tags referenced by a filter *expr* string.
Mirror of TS ``collectTags`` (src/manager/cascade/subcatExprCascade.ts).
Empty or malformed expressions yield the empty set — the caller simply
won't link the ref; ``validate_expression`` surfaces real syntax errors
elsewhere. Used by the discovery scan so a rename/delete of any one tag
finds every ref whose boolean filter mentions it (negated tags count).
"""
if not expr or not expr.strip():
return set()
try:
ast = _parse_subcat(expr)
except _ParseError:
return set()
return _expr_tags(ast)
def _ref_expr_pattern(wildcard_id: str) -> re.Pattern[str]:
# 4-segment ref to a specific wildcard: groups = (#name, :expr, !null).
return re.compile(
r"@\{" + re.escape(wildcard_id)
+ r"(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
)
def _rewrite_ref_expr(s: str, wildcard_id: str, target: str, fn) -> str:
"""Rewrite the boolean ``:expr`` of every ``@{wildcard_id...}`` ref
whose expression contains *target*, mapping its tags via *fn*
(rename, or remove + collapse). Refs that don't mention *target* are
left verbatim — no cosmetic re-serialization. ``#name`` + ``!null``
are preserved; an expression that empties drops the ``:expr``
segment, collapsing ``@{uuid:cold}`` to a bare ``@{uuid}``."""
pattern = _ref_expr_pattern(wildcard_id)
def repl(m: re.Match[str]) -> str:
name, expr, null = m.group(1), m.group(2), m.group(3)
if expr is None or not expr.strip():
return m.group(0)
ast = _parse_subcat(expr)
if target not in _expr_tags(ast):
return m.group(0)
new_expr = _reads_as(_map_tags(ast, fn))
out = "@{" + wildcard_id + (f"#{name}" if name else "")
if new_expr:
out += ":" + new_expr
if null == "null":
out += "!null"
return out + "}"
return pattern.sub(repl, s)
def _rewrite_subcat_ref_in_string(
s: str,
wildcard_id: str,
old_subcat: str,
new_subcat: str,
) -> str:
"""Replace ``@{wildcard_id[#name]:old_subcat}`` →
``@{wildcard_id[#name]:new_subcat}``. Preserves any optional
``#name`` segment so a subcat rename doesn't strip the wildcard's
cached display name from refs."""
pattern = re.compile(
r"@\{" + re.escape(wildcard_id)
+ r"(#[^#:}@{]*)?:" + re.escape(old_subcat) + r"\}"
"""Rename ``old_subcat`` -> ``new_subcat`` inside every
``@{wildcard_id...}`` ref's boolean expression (e.g.
``@{u:warm or cold}`` -> ``@{u:warm or chilly}``). Structure,
``#name``, and ``!null`` are preserved."""
return _rewrite_ref_expr(
s, wildcard_id, old_subcat,
lambda t: new_subcat if t == old_subcat else t,
)
def repl(m: re.Match[str]) -> str:
name_seg = m.group(1) or ""
return f"@{{{wildcard_id}{name_seg}:{new_subcat}}}"
return pattern.sub(repl, s)
def _strip_subcat_ref_in_string(
@@ -84,13 +175,38 @@ def _strip_subcat_ref_in_string(
wildcard_id: str,
subcat_name: str,
) -> str:
"""Remove ``@{wildcard_id[#name]:subcat_name}`` occurrences from
*s*. Matches with or without the optional ``#name`` segment."""
pattern = re.compile(
r"@\{" + re.escape(wildcard_id)
+ r"(?:#[^#:}@{]*)?:" + re.escape(subcat_name) + r"\}"
"""Remove ``subcat_name`` from every ``@{wildcard_id...}`` ref's
expression, collapsing operators (``@{u:warm or cold}`` ->
``@{u:warm}``); a ref whose expression empties collapses to the bare
``@{wildcard_id}`` (the nested insertion survives, only its filter
is dropped)."""
return _rewrite_ref_expr(
s, wildcard_id, subcat_name,
lambda t: None if t == subcat_name else t,
)
return pattern.sub("", s)
def _strip_whole_ref_in_string(s: str, wildcard_id: str) -> str:
"""Remove every whole ``@{wildcard_id...}`` token from *s*.
Unlike ``_strip_subcat_ref_in_string`` (which only drops the
``:expr`` filter, collapsing to a bare ``@{uuid}``), this deletes the
entire nested-insertion token — used by ``fix_wildcard_delete`` when
the referenced wildcard itself is gone, so the insertion can't
resolve at all. Surrounding text is preserved; whitespace left at the
seam is collapsed (doubled spaces -> single) and the result trimmed,
so ``"warm @{u} glow"`` -> ``"warm glow"`` rather than
``"warm glow"``.
Reuses the shared 4-segment ref pattern (``_ref_expr_pattern``) so the
``#name`` / ``:expr`` / ``!null`` variants of the token all match.
"""
pattern = _ref_expr_pattern(wildcard_id)
stripped = pattern.sub("", s)
if stripped == s:
return s
# Collapse whitespace left at the seam, then trim edges.
return re.sub(r"[ \t]{2,}", " ", stripped).strip()
def _rewrite_ref_name_in_string(
@@ -124,21 +240,40 @@ def _rewrite_ref_name_in_string(
def fix_wildcard_delete(
conn: sqlite3.Connection,
wildcard_id: str,
cleanup_ids: list[str],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""Strip all references to *wildcard_id* from constraints.
"""Opt-in strip of nested ``@{wildcard_id...}`` refs to a deleted wildcard.
* Constraints whose source or target is *wildcard_id* are deleted —
they reference the wildcard by id only, with no fallback shape.
Constraints are never DELETED here. They reference the wildcard by id
only and are healable via the reattach UI — the cascade leaves them
broken so the user can re-point them later, rather than deleting them
(the old behavior, removed). The ONE constraint mutation that does run
is a display-only name stamp: the deleted wildcard's name is cached onto
every dependent constraint's ``source_wildcard_name`` /
``target_wildcard_name`` (the last moment the live name is known), so the
broken-reference banner shows "(was …)" instead of a bare uuid. The
id/matrix/exceptions stay untouched. The stamp is in `touched` (undo) but
not in `diff` (it's not a ref removal). It runs regardless of cleanup_ids.
Bundles are intentionally NOT touched. Bundle children are full
frozen snapshots: the wildcard's payload, options, and meta were
deep-copied into ``bundle.children[i]`` at insert time, so the
bundle keeps working even when the source library row is deleted.
Removing the snapshot would destroy data the user explicitly
captured for offline / portable use. The MISSING badge on the
referenced workflow rows still fires (drift-store sees the id is
gone) — that's the honest signal; stripping the snapshot would be
over-eager housekeeping.
Nested ``@{wildcard_id...}`` refs (the deleted wildcard inserted
inside ANOTHER wildcard's option value, or a derivation action's
string value) are stripped ONLY for the specific entity ids the
caller lists in *cleanup_ids*. Every entity not in *cleanup_ids* is
left untouched — its broken ref survives and is healed later via a
remap. An empty/absent *cleanup_ids* therefore strips nothing: only
the wildcard row itself is dropped (by the orchestrator), and all
constraints + nested refs survive.
For each entity in *cleanup_ids*:
* ``wildcard`` — every ``@{wildcard_id...}`` token is removed from
each ``options[].value`` string (whole token, surrounding text
preserved).
* ``derivation`` — same strip applied to every string value inside
``rules[].branches[].actions[]`` (mirrors ``_scan_wildcard_delete``).
Bundles are intentionally NOT touched (unchanged): bundle children
are full frozen snapshots, so the bundle keeps resolving with its
captured payload even when the source library row is deleted.
The fixer does NOT delete the wildcard row itself; the orchestrator
owns that mutation.
@@ -148,16 +283,78 @@ def fix_wildcard_delete(
mod_repo = ModuleRepository(conn)
# --- Constraints --------------------------------------------------------
# Stamp the about-to-be-deleted wildcard's name onto every dependent
# constraint's source/target name cache, BEFORE the orchestrator drops the
# row. This is the last moment the live name is known; without it a
# constraint broken by the deletion shows only a bare uuid in the reattach
# banner ("Source wildcard 1e9c0e2c is not in your library"). Display-only:
# the id/matrix/exceptions are untouched and the constraint is never deleted
# (still healable via reattach). Recorded in `touched` for undo but NOT
# emitted as a `diff` entry — it's not a ref removal, so it must not inflate
# affected_count. Runs regardless of cleanup_ids (the empty-cleanup
# early-return is below).
target = mod_repo.get(wildcard_id)
wc_name = target.get("name") if target else None
if wc_name:
for c in mod_repo.list():
if c["type"] != "constraint":
continue
cp = c.get("payload") or {}
new_cp = copy.deepcopy(cp)
stamped = False
if cp.get("source_wildcard_id") == wildcard_id:
if cp.get("source_wildcard_name") != wc_name:
new_cp["source_wildcard_name"] = wc_name
stamped = True
if cp.get("target_wildcard_id") == wildcard_id:
if cp.get("target_wildcard_name") != wc_name:
new_cp["target_wildcard_name"] = wc_name
stamped = True
if stamped:
touched.append(_deepcopy_row(c))
mod_repo.update(c["id"], payload=new_cp)
cleanup = set(cleanup_ids)
if not cleanup:
return touched, diff
for m in mod_repo.list():
if m["type"] != "constraint":
if m["id"] not in cleanup:
continue
t = m["type"]
p = m.get("payload") or {}
if (p.get("source_wildcard_id") == wildcard_id
or p.get("target_wildcard_id") == wildcard_id):
new_payload = copy.deepcopy(p)
changed = False
if t == "wildcard":
for opt in new_payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _strip_whole_ref_in_string(v, wildcard_id)
if new_v != v:
opt["value"] = new_v
changed = True
elif t == "derivation":
for rule in new_payload.get("rules") or []:
for branch in rule.get("branches") or []:
for action in branch.get("actions") or []:
if not isinstance(action, dict):
continue
for k, v in list(action.items()):
if isinstance(v, str):
new_v = _strip_whole_ref_in_string(v, wildcard_id)
if new_v != v:
action[k] = new_v
changed = True
if changed:
touched.append(_deepcopy_row(m))
mod_repo.delete(m["id"])
diff.append({"entity_id": m["id"], "removed": True})
mod_repo.update(m["id"], payload=new_payload)
diff.append({
"entity_id": m["id"],
"remove_ref": {"kind": "wildcard", "wildcard_id": wildcard_id},
})
return touched, diff
@@ -193,11 +390,12 @@ def fix_subcat_delete(
s for s in declared if s != subcat_name
]
changed = True
# Per-option singular assignment — null out options pointing at
# the deleted subcat (preserves the option, releases the link)
# Per-option membership — drop the deleted subcat from each
# option's sub_categories list (preserves the option).
for opt in new_payload.get("options") or []:
if opt.get("sub_category") == subcat_name:
opt["sub_category"] = None
subs = opt.get("sub_categories")
if isinstance(subs, list) and subcat_name in subs:
opt["sub_categories"] = [s for s in subs if s != subcat_name]
changed = True
elif t == "constraint":
@@ -270,10 +468,13 @@ def fix_subcat_rename(
new_name if s == old_name else s for s in declared
]
changed = True
# Per-option singular assignment (`opt.sub_category`, not plural)
# Per-option membership list (`opt.sub_categories`)
for opt in new_payload.get("options") or []:
if opt.get("sub_category") == old_name:
opt["sub_category"] = new_name
subs = opt.get("sub_categories")
if isinstance(subs, list) and old_name in subs:
opt["sub_categories"] = [
new_name if s == old_name else s for s in subs
]
changed = True
elif t == "constraint":
@@ -335,9 +536,12 @@ def fix_wildcard_rename_name(
Combine templates, fixed-values, and constraint payloads don't hold
``@{}`` text refs (constraints reference by id only via
``source_wildcard_id`` / ``target_wildcard_id``), so they're
skipped. Bundle children carry frozen module snapshots whose option
values may contain refs; we walk those too so a save propagates
into bundle copies.
skipped. Bundle children are frozen point-in-time snapshots and are
intentionally NOT rewritten (mirrors ``fix_wildcard_delete``): a rename
leaves the bundle's cached ``#name`` / refs stale until the user
explicitly refreshes the bundle. Otherwise editing ANY module would
silently mutate every bundle holding a snapshot of it — a bundle must
stay a fixed snapshot, not auto-track its source modules.
Skips the renamed wildcard itself — its own name lives on the row,
not in its own option values.
@@ -346,7 +550,6 @@ def fix_wildcard_rename_name(
diff: list[dict[str, Any]] = []
mod_repo = ModuleRepository(conn)
bundle_repo = BundleRepository(conn)
for m in mod_repo.list():
if m["id"] == wildcard_id:
@@ -390,45 +593,10 @@ def fix_wildcard_rename_name(
},
})
# Bundle child snapshots — walk frozen module copies and rewrite
# ref strings in their option values. Children may reference the
# renamed wildcard via @{uuid} in their stored snapshots.
for b in bundle_repo.list():
children = b.get("children") or []
new_children = copy.deepcopy(children)
bundle_changed = False
for ch in new_children:
if not isinstance(ch, dict):
continue
if ch.get("type") != "wildcard":
continue
if ch.get("id") == wildcard_id:
# The renamed wildcard's own snapshot in this bundle —
# update the cached `name` field instead of refs.
if ch.get("name") != new_name:
ch["name"] = new_name
bundle_changed = True
continue
payload = ch.get("payload") or {}
for opt in payload.get("options") or []:
v = opt.get("value")
if isinstance(v, str):
new_v = _rewrite_ref_name_in_string(v, wildcard_id, new_name)
if new_v != v:
opt["value"] = new_v
bundle_changed = True
if bundle_changed:
touched.append(_deepcopy_row(b))
bundle_repo.update(b["id"], children=new_children)
diff.append({
"entity_id": b["id"],
"rename_ref": {
"kind": "wildcard_name",
"wildcard_id": wildcard_id,
"new": new_name,
},
})
# Bundle children are frozen snapshots — intentionally NOT rewritten
# here (see docstring). They drift; the explicit bundle-refresh flow
# re-snapshots them on demand, so a module edit never auto-mutates a
# bundle.
return touched, diff
+46 -10
View File
@@ -53,9 +53,7 @@ def _rename_subcat_target_only(
"""Rename a subcat inside the source wildcard ONLY (no cascade to refs).
Touches both the top-level `payload.sub_categories` declared list and
each option's singular `sub_category` field. The plural-array shape
that the prior version operated on does not exist in real data — see
`engine/modules/wildcard_handler.py:206` for the actual schema.
each option's `sub_categories` membership list (SP1 multi-tag shape).
"""
mod = ModuleRepository(conn)
wc = mod.get(wildcard_id)
@@ -71,8 +69,11 @@ def _rename_subcat_target_only(
new_options = []
for opt in payload.get("options") or []:
new_opt = dict(opt)
if new_opt.get("sub_category") == old_name:
new_opt["sub_category"] = new_name
subs = new_opt.get("sub_categories")
if isinstance(subs, list) and old_name in subs:
new_opt["sub_categories"] = [
new_name if s == old_name else s for s in subs
]
new_options.append(new_opt)
payload["options"] = new_options
@@ -181,7 +182,12 @@ def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, An
key = (kind, action)
if key == ("wildcard", "delete"):
touched_before, diff = fix_wildcard_delete(conn, target_id)
# Opt-in nested-ref cleanup: only entities the caller
# lists get their @{wildcard_id...} token stripped.
# Constraints are never deleted (healed via reattach UI);
# an empty list strips nothing.
cleanup_ids = req.get("cleanup_ids") or []
touched_before, diff = fix_wildcard_delete(conn, target_id, cleanup_ids)
elif key == ("subcategory", "delete"):
touched_before, diff = fix_subcat_delete(
conn, target_id, extra.get("subcat_name", "")
@@ -202,16 +208,46 @@ def apply_cascade(conn: sqlite3.Connection, req: dict[str, Any]) -> dict[str, An
touched_before, diff = fix_option_delete(
conn, extra.get("wildcard_id", ""), target_id,
)
elif action == "delete" and kind in (
"fixed_values",
"combine",
"derivation",
"constraint",
):
# These module types are leaves from a reverse-ref
# perspective: nothing in another row's payload points
# at them by id the way `option.id` is referenced from
# constraint matrices or `wildcard.id` is referenced
# from constraint source/target. A whole-entity delete
# is just a row drop + undo snapshot — no fix step,
# no diff entries.
#
# Caveat: combine.output_var IS referenced by name
# from downstream payloads, but the cascade pipeline
# treats that as a "rename" concern (already wired as
# combine_output_var/rename above). Deleting a combine
# leaves any consumer referencing the output_var with
# an unresolved variable, surfaced separately by the
# integrity/broken-refs scan.
touched_before, diff = [], []
else:
return {
"ok": False,
"error": f"unsupported (kind, action) pair: ({kind}, {action})",
}
# Delete target entity for whole-entity delete ops.
# Subcategory deletes are NOT in this set — the fixer already
# mutated the source wildcard's payload; there is no separate row.
if action == "delete" and kind in ("wildcard", "category", "bundle"):
# Delete target entity for whole-entity delete ops. Module
# subtypes route through ModuleRepository via _delete_target
# (the else-branch of its dispatch).
if action == "delete" and kind in (
"wildcard",
"category",
"bundle",
"fixed_values",
"combine",
"derivation",
"constraint",
):
target_snapshot = _delete_target(conn, kind, target_id)
touched_before.append(target_snapshot)
diff.append({"entity_id": target_id, "removed": True})
+21 -10
View File
@@ -17,14 +17,18 @@ import re
import sqlite3
from typing import Any
from engine.cascade.fixers import collect_tags
from engine.db.repositories import ModuleRepository
# ``@{8hex}``, ``@{8hex#name}``, ``@{8hex:subcat}``, or
# ``@{8hex#name:subcat}`` — uuid + optional cached display name +
# optional subcat filter. Mirrors `engine/syntax/tokenize.py:_REF_RE`
# and the TS twin in `src/manager/cascade/dep-graph.ts`. Name segment
# is informational; this scanner only ever reads the captured uuid.
_REF_REGEX = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
# 4-segment ref: ``@{8hex [#name] [:expr] [!null]}`` — uuid + optional
# cached display name + optional boolean sub-category filter expression
# + optional exclude-null marker. Mirrors `engine/syntax/tokenize.py:_REF_RE`
# and the TS twin in `src/manager/cascade/dep-graph.ts`. findall groups:
# (uuid, name, expr, null-marker). This scanner reads the uuid; the
# subcat scan also reads the raw expr (see `_scan_subcat`).
_REF_REGEX = re.compile(
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
)
# ``$varname`` — leading-letter identifier
_VAR_REGEX = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
@@ -66,7 +70,7 @@ def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[di
v = opt.get("value")
if not isinstance(v, str):
continue
if any(uid == wildcard_id for uid, _name, _sub in _REF_REGEX.findall(v)):
if any(m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)):
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
break
continue
@@ -85,7 +89,7 @@ def _scan_wildcard_delete(conn: sqlite3.Connection, wildcard_id: str) -> list[di
continue
for v in action.values():
if isinstance(v, str) and any(
uid == wildcard_id for uid, _name, _sub in _REF_REGEX.findall(v)
m.group(1) == wildcard_id for m in _REF_REGEX.finditer(v)
):
out.append(_ref_entry(
"derivation", m,
@@ -149,8 +153,15 @@ def _scan_subcat(
v = opt.get("value")
if not isinstance(v, str):
continue
for uid, _name, sub in _REF_REGEX.findall(v):
if uid == wildcard_id and sub == subcat_name:
# SP1: `sub` is the raw `:expr` segment (the 4-segment regex
# already split `!null` into the null group). A boolean filter
# like `warm or cold` references multiple tags, so parse it and
# test tag membership — the same set the fixer rewrites
# (engine/cascade/fixers.py). Exact-string equality would miss
# every multi-tag / negated / parenthesized filter and make the
# preview undercount what the apply actually changes.
for uid, _name, sub, _null in _REF_REGEX.findall(v):
if uid == wildcard_id and subcat_name in collect_tags(sub):
out.append(_ref_entry("wildcard", m, f"options[{idx}].value"))
break
+61
View File
@@ -0,0 +1,61 @@
"""Prompt-syntax atoms the cleaner must not rewrite.
The cleaner's rules treat a prompt as prose or as a comma-separated tag list.
That is the right model for tags, and the wrong one for the handful of tokens
that are *addresses* rather than words — `embedding:name` and `<lora:name:1.0>`
resolve to a file on disk, so a single edited character silently changes what
the sampler loads, or loads nothing at all.
Two rules were provably destructive (P3 #31, pinned by
`tests/engine/cleaner/test_embedding_roundtrip.py`):
* punctuation stripping — `_` and `-` are in the stripped set, so
`embedding:bad_prompt_` (a real published shape) became
`embedding:bad_prompt`. ComfyUI resolves a missing embedding to nothing,
so the negative prompt quietly stopped working, with no error.
* fuzzy dedupe — versioned names are near-identical strings and the
threshold is 0.9, so `embedding:badhandv4, embedding:badhandv5` lost the
second. Two different files, one silently dropped.
This widens the cleaner's remit from "text" to "knows a little prompt syntax",
which the findings called out as the cost of fixing it. The alternative was
leaving a rule that corrupts a file reference without saying so.
Deliberately narrow: only the well-known forms, and only whole tokens. Anything
not matched here keeps its existing treatment.
"""
from __future__ import annotations
import re
# `embedding:name`, optionally weighted / parenthesised by the caller. The name
# runs to the next separator: whitespace, comma, or a closing paren/angle.
_EMBEDDING_RE = re.compile(r"\bembedding:[^\s,)\]>]+", re.IGNORECASE)
# `<lora:name:1.0>` and the siblings that share its shape. Matched whole, so
# internal punctuation and decimal weights are untouchable.
_ANGLE_RE = re.compile(
r"<\s*(?:lora|lyco|locon|hypernet|embedding)\s*:[^>]*>",
re.IGNORECASE,
)
def contains_atom(text: str) -> bool:
"""True when `text` carries a file-reference token cleaning must preserve."""
if not text:
return False
return bool(_EMBEDDING_RE.search(text) or _ANGLE_RE.search(text))
def ends_with_atom(text: str) -> bool:
"""True when `text` ENDS in an atom, so trimming its trailing edge would
eat part of the reference. Used by the text-mode punctuation pass, which
trims the whole string rather than per-tag."""
if not text:
return False
stripped = text.rstrip()
for pattern in (_EMBEDDING_RE, _ANGLE_RE):
for match in pattern.finditer(stripped):
if match.end() == len(stripped):
return True
return False
+11
View File
@@ -8,6 +8,7 @@
"""
from __future__ import annotations
from engine.cleaner.atoms import contains_atom
from engine.cleaner.types import RuleResult
_FUZZY_THRESHOLD = 0.9
@@ -74,7 +75,17 @@ def apply_fuzzy(text: str, mode: str, config: dict) -> RuleResult:
for tag in tags:
key = tag.casefold()
is_dup = False
# Versioned file references are near-identical strings by design --
# `embedding:badhandv4` vs `...v5` scores well above the 0.9 threshold
# while naming two different files. Exact dedupe still collapses a
# genuinely repeated one; only the FUZZY pass is held back.
tag_is_atom = contains_atom(tag)
for kept_tag in kept:
if tag_is_atom or contains_atom(kept_tag):
if key == kept_tag.casefold():
is_dup = True
break
continue
if _ratio(key, kept_tag.casefold()) >= _FUZZY_THRESHOLD:
is_dup = True
break
+18 -3
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import re
from engine.cleaner.atoms import contains_atom, ends_with_atom
from engine.cleaner.types import RuleResult
_PUNCT_CHARS = r".,:;!?\-_"
@@ -37,6 +38,13 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
if _PURE_PUNCT_RE.match(tag):
stripped += 1
continue
# `embedding:name` / `<lora:name:1.0>` are file references, and
# `_` and `-` are in the stripped set — trimming an edge renames
# the file. ComfyUI resolves a missing embedding to nothing, so
# the damage is silent. See engine/cleaner/atoms.py.
if contains_atom(tag):
kept.append(tag)
continue
edged, changed = _strip_edges(tag)
if changed:
stripped += 1
@@ -44,7 +52,14 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
kept.append(edged)
out = ", ".join(kept)
else:
out, changed = _strip_edges(text)
if changed:
stripped += 1
# Text mode trims the whole string's edges rather than per tag, so the
# only risk is a prompt that ENDS in an atom.
if ends_with_atom(text):
out = _LEADING_PUNCT_RE.sub("", text)
if out != text:
stripped += 1
else:
out, changed = _strip_edges(text)
if changed:
stripped += 1
return {"text": out, "stats": {"stripped": stripped}}
+5 -4
View File
@@ -10,6 +10,8 @@ Each function:
import re
from typing import Protocol
from engine.syntax.types import deref_var_value, split_var_accessor
class _CtxLike(Protocol):
context: dict[str, object]
@@ -80,7 +82,6 @@ def lookup_var(context: _CtxLike, name: str) -> str:
bare = (name or "").lstrip("$").strip()
if not bare:
return ""
value = context.context.get(bare) if context and getattr(context, "context", None) else None
if value is None:
return ""
return value if isinstance(value, str) else str(value)
base, index = split_var_accessor(bare)
raw = context.context.get(base) if context and getattr(context, "context", None) else None
return deref_var_value(raw, index)
+112
View File
@@ -0,0 +1,112 @@
"""Sidecar config for DB location preference + pending move state.
Lives at ``<plugin>/db-config.json`` so the resolver can read it BEFORE
opening any DB connection (chicken-and-egg: the preference about where
the DB lives can't live inside the DB itself).
Atomic writes: write-to-temp-then-rename so a crash mid-write can't
corrupt the sidecar.
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any, Literal, TypedDict
Preference = Literal["user", "global", "root"]
MoveMode = Literal["copy", "move"]
# Plugin root: this file is at <plugin>/engine/db/config.py — go up three.
_PLUGIN_ROOT = Path(__file__).resolve().parents[2]
SIDECAR_PATH = _PLUGIN_ROOT / "db-config.json"
_VALID_PREFERENCES = {"user", "global", "root"}
_VALID_MODES = {"copy", "move"}
class PendingMove(TypedDict, total=False):
from_: str # NB: "from" is a Python keyword; we map this on read/write
to: str
mode: MoveMode
class DbConfig(TypedDict, total=False):
preference: Preference
pending_move: dict[str, str] # {"from": "...", "to": "...", "mode": "..."}
def _read_raw(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _resolve_sidecar(path: Path | None) -> Path:
"""Resolve the effective sidecar path.
Reads ``SIDECAR_PATH`` from this module dynamically when ``path`` is
``None`` so tests can ``monkeypatch.setattr("engine.db.config.SIDECAR_PATH", ...)``
and have all three of ``load``/``save``/``clear_pending_move`` honor
the override. Using a default argument like ``path = SIDECAR_PATH``
captures the value at function-definition time, which breaks
monkeypatching the module attribute."""
return path if path is not None else SIDECAR_PATH
def load(path: Path | None = None) -> DbConfig:
"""Return the sidecar config or {} if absent / unreadable / invalid."""
raw = _read_raw(_resolve_sidecar(path))
out: DbConfig = {}
pref = raw.get("preference")
if isinstance(pref, str) and pref in _VALID_PREFERENCES:
out["preference"] = pref # type: ignore[typeddict-item]
pm = raw.get("pending_move")
if isinstance(pm, dict):
mode = pm.get("mode")
src = pm.get("from")
dst = pm.get("to")
if (isinstance(mode, str) and mode in _VALID_MODES
and isinstance(src, str) and isinstance(dst, str)):
out["pending_move"] = {"from": src, "to": dst, "mode": mode}
return out
def save(config: DbConfig, path: Path | None = None) -> None:
"""Atomically write the sidecar (write-temp + rename)."""
path = _resolve_sidecar(path)
path.parent.mkdir(parents=True, exist_ok=True)
# Strip any unknown keys; only persist the validated shape.
out: dict[str, Any] = {}
if "preference" in config:
out["preference"] = config["preference"]
pm = config.get("pending_move")
if isinstance(pm, dict) and {"from", "to", "mode"} <= pm.keys():
out["pending_move"] = {"from": pm["from"], "to": pm["to"], "mode": pm["mode"]}
fd, tmp_str = tempfile.mkstemp(prefix=".db-config-", dir=str(path.parent))
tmp_path = Path(tmp_str)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2)
os.replace(tmp_path, path)
except OSError:
tmp_path.unlink(missing_ok=True)
raise
def clear_pending_move(path: Path | None = None) -> None:
"""Remove just the pending_move field; preserve preference."""
path = _resolve_sidecar(path)
cfg = load(path)
if "pending_move" in cfg:
cfg.pop("pending_move", None)
save(cfg, path)
def plugin_root() -> Path:
"""Expose the plugin root so the resolver can compute the `root` location."""
return _PLUGIN_ROOT
+105 -35
View File
@@ -2,22 +2,28 @@
DB path resolution order (highest priority first):
1. ``WP_DB_PATH`` env var — explicit override, used as-is.
1. ``WP_DB_PATH`` env var — explicit override, used as-is. Source ``"WP_DB_PATH"``.
2. ``COMFYUI_USER_DIR`` env var — places the DB under
``$COMFYUI_USER_DIR/wildcard-pipeline.db``.
3. ComfyUI's user directory, detected at runtime. The detector tries
``folder_paths.get_user_directory()`` first (canonical inside the
ComfyUI process), then walks up from ``__file__`` to find the
ComfyUI root and appends ``user/``. This is the default for any
user running the plugin inside a normal ComfyUI install.
4. Legacy path ``~/.comfyui/wildcard-pipeline.db`` — kept as a
backward-compat fallback. If a DB already exists at the legacy
path AND the detected ComfyUI user dir has none, the resolver
returns the legacy path so existing installs don't silently
start with an empty DB. Run with ``WP_DB_PATH`` (or copy the
file) to migrate to the new location.
``$COMFYUI_USER_DIR/wildcard-pipeline.db``. Source ``"COMFYUI_USER_DIR"``.
3. Sidecar ``preference`` field at ``<plugin>/db-config.json`` (loaded
indirectly through ``_load_sidecar`` so tests can stub it):
- ``"user"`` → ComfyUI user dir + ``wildcard-pipeline.db``,
source ``"user"``. Falls through if the user dir can't be detected.
- ``"global"`` → ``~/.comfyui/wildcard-pipeline.db``, source ``"global"``.
- ``"root"`` → ``<plugin>/db/wildcard-pipeline.db``, source ``"root"``.
4. Default — ComfyUI user dir + ``wildcard-pipeline.db``, source ``"user"``.
Falls through if detection fails.
5. Last-ditch fallback — ``~/.comfyui/wildcard-pipeline.db``, source
``"global"`` (the design folds the old ``"legacy"`` label into
``"global"`` since the path is identical and the UI exposes a Global
radio for it).
Tests can short-circuit the entire chain by setting ``WP_DB_PATH``.
The pre-feature back-compat clause that preferred legacy when the new
path had no DB has been removed — the design intentionally surprises
users with an empty new DB so they migrate explicitly via Settings.
Tests can short-circuit the entire chain by setting ``WP_DB_PATH`` or by
stubbing ``_load_sidecar`` to return a chosen preference.
"""
from __future__ import annotations
@@ -68,39 +74,103 @@ def _comfyui_user_dir_from_path() -> Path | None:
def _legacy_home_path() -> Path:
"""Return ``~/.comfyui/wildcard-pipeline.db``.
Still named ``_legacy_*`` for historical reasons but now serves as
both the explicit ``global`` preference target and the last-ditch
fallback when no ComfyUI user dir can be detected. Both report
source ``"global"``."""
return Path.home() / ".comfyui" / DB_FILENAME
def resolve_db_path() -> Path:
# 1. Explicit override.
def _plugin_root_db_path() -> Path:
"""Return ``<plugin>/db/wildcard-pipeline.db`` — the ``root``
preference target. Lives inside the plugin install dir so it
survives ComfyUI reinstalls; vulnerable to plugin git pulls that
wipe untracked files (called out in the UI as "not recommended")."""
from engine.db.config import plugin_root
return plugin_root() / "db" / DB_FILENAME
def _load_sidecar() -> dict:
"""Indirection over ``engine.db.config.load`` so tests can stub the
sidecar read without touching the real ``<plugin>/db-config.json``
file."""
from engine.db.config import load
return dict(load())
def user_location_path() -> Path | None:
"""Return the ``user`` preference target path, or ``None`` if the
ComfyUI user dir can't be detected.
Same detector chain that ``resolve_db_path_with_source`` uses for the
``user`` branch. Exposed for the SPA Settings API so the frontend can
list all three potential locations regardless of which one is
currently active (or whether one is detectable at all)."""
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
return (comfy_user / DB_FILENAME) if comfy_user else None
def global_location_path() -> Path:
"""Return the ``global`` preference target — ``~/.comfyui/wildcard-pipeline.db``.
Always available regardless of host detection (just ``Path.home()``)."""
return _legacy_home_path()
def root_location_path() -> Path:
"""Return the ``root`` preference target — ``<plugin>/db/wildcard-pipeline.db``."""
return _plugin_root_db_path()
def resolve_db_path_with_source() -> tuple[Path, str]:
"""Like ``resolve_db_path`` but also reports which resolver rule won.
Source values:
- ``"WP_DB_PATH"`` — explicit env override
- ``"COMFYUI_USER_DIR"`` — env user-dir
- ``"user"`` — ComfyUI user dir (sidecar preference or default)
- ``"global"`` — ~/.comfyui/wildcard-pipeline.db
- ``"root"`` — <plugin>/db/wildcard-pipeline.db
"""
override = os.environ.get("WP_DB_PATH")
if override:
return Path(override)
return Path(override), "WP_DB_PATH"
# 2. COMFYUI_USER_DIR env var. Honour even when the directory
# doesn't exist yet — get_connection() will mkdir before opening.
user_dir_env = os.environ.get("COMFYUI_USER_DIR")
if user_dir_env:
return Path(user_dir_env) / DB_FILENAME
return Path(user_dir_env) / DB_FILENAME, "COMFYUI_USER_DIR"
# 3. ComfyUI user dir via API or path traversal. Both detectors
# return None if they can't find a usable directory; we fall
# through to the legacy path in that case.
sidecar = _load_sidecar()
preference = sidecar.get("preference")
if preference == "global":
return _legacy_home_path(), "global"
if preference == "root":
return _plugin_root_db_path(), "root"
if preference == "user":
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
if comfy_user is not None:
return comfy_user / DB_FILENAME, "user"
# Detection failed — fall through to the default chain rather
# than crash. Last-ditch fallback below will catch it.
# Default — same as explicit "user" preference.
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
if comfy_user is not None:
new_path = comfy_user / DB_FILENAME
# 4. Backward-compat: if the new ComfyUI-user location has no
# DB yet but the legacy ~/.comfyui location does, prefer the
# legacy file so existing installs keep their data without a
# silent reset. Users can copy the file across (or set
# WP_DB_PATH) when they're ready to migrate.
legacy = _legacy_home_path()
if not new_path.exists() and legacy.exists():
return legacy
return new_path
return comfy_user / DB_FILENAME, "user"
# 5. Standalone / no-ComfyUI fallback.
return _legacy_home_path()
# Last-ditch fallback. The design folds the old "legacy" label into
# "global" because the path is identical and the UI has no separate
# "legacy" radio.
return _legacy_home_path(), "global"
def resolve_db_path() -> Path:
"""Path-only convenience wrapper — preserved for all existing callers."""
return resolve_db_path_with_source()[0]
def get_connection(path: Path | None = None) -> sqlite3.Connection:
+286
View File
@@ -0,0 +1,286 @@
"""Read-only DB introspection + safe maintenance operations.
Pure Python — no ComfyUI imports, no aiohttp. Each public function takes
a ``sqlite3.Connection`` and returns a dict. Expected-failure modes
(locked DB, integrity errors) are reported via ``ok: False`` payloads,
not exceptions, so the API layer can pass results through verbatim.
"""
from __future__ import annotations
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from engine.db.migrations import _discover, current_version
# Map module-row "type" column values to the human/UI-facing names used
# in the counts response. Kept in one place so future kinds get a single
# insertion point.
_KIND_TYPES: dict[str, str] = {
"wildcards": "wildcard",
"fixed_values": "fixed_values",
"combines": "combine",
"derivations": "derivation",
"constraints": "constraint",
}
# Maps the UI-facing count key to the real SQLite table name. Some
# tables have a `module_` prefix in storage but show up unprefixed in
# the response (e.g. `module_categories` -> "categories") to keep the
# settings card readable.
_TABLE_COUNTS: dict[str, str] = {
"bundles": "bundles",
"templates": "templates",
"categories": "module_categories",
}
def _iso(ts: float) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
def _count_modules_by_type(conn: sqlite3.Connection, type_value: str) -> int:
row = conn.execute(
"SELECT COUNT(*) FROM modules WHERE type = ?", (type_value,)
).fetchone()
return int(row[0]) if row else 0
def _count_table(conn: sqlite3.Connection, table: str) -> int:
try:
row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
except sqlite3.OperationalError:
# Table might not exist on very old DBs that haven't migrated yet.
return 0
return int(row[0]) if row else 0
def _pragma_int(conn: sqlite3.Connection, name: str) -> int:
row = conn.execute(f"PRAGMA {name}").fetchone()
return int(row[0]) if row else 0
def _pragma_str(conn: sqlite3.Connection, name: str) -> str:
row = conn.execute(f"PRAGMA {name}").fetchone()
return str(row[0]) if row else ""
def _migration_names() -> dict[int, str]:
"""Discover known migration files and map version -> stem name.
The migrations table only stores ``version`` and ``applied_at``;
the human-readable name lives in the filename
(``012_templates.sql`` -> ``012_templates``). Walking the same
discovery routine the migrator uses keeps this in sync.
"""
return {version: path.stem for version, path in _discover()}
def _applied_migrations(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Return all rows from the migrations table joined with the
on-disk migration name. Empty list if the table doesn't exist
(pre-migration DB)."""
table = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='migrations';"
).fetchone()
if not table:
return []
rows = conn.execute(
"SELECT version, applied_at FROM migrations ORDER BY version ASC"
).fetchall()
names = _migration_names()
return [
{
"version": int(r[0]),
"name": names.get(int(r[0]), f"migration_{int(r[0]):03d}"),
"applied_at": str(r[1]),
}
for r in rows
]
def gather_info(
conn: sqlite3.Connection, db_path: Path, *, source: str = "unknown"
) -> dict[str, Any]:
"""Return a read-only snapshot of the DB suitable for the SPA card.
``source`` is passed through verbatim. The resolver in
``engine.db.connection`` produces one of:
``"WP_DB_PATH" | "COMFYUI_USER_DIR" | "user" | "global" | "root"``.
``"unknown"`` is reserved for callers that don't have a real source
(e.g. engine unit tests that build a connection directly)."""
counts: dict[str, int] = {}
for ui_name, type_value in _KIND_TYPES.items():
counts[ui_name] = _count_modules_by_type(conn, type_value)
for ui_name, table_name in _TABLE_COUNTS.items():
counts[ui_name] = _count_table(conn, table_name)
try:
stat = db_path.stat()
size_bytes = stat.st_size
mtime_iso = _iso(stat.st_mtime)
except OSError:
size_bytes = 0
mtime_iso = ""
return {
"path": str(db_path),
"source": source,
"size_bytes": size_bytes,
"mtime_iso": mtime_iso,
"counts": counts,
"migration": {
"current_version": current_version(conn),
"applied": _applied_migrations(conn),
},
"pragma": {
"journal_mode": _pragma_str(conn, "journal_mode"),
"foreign_keys": _pragma_int(conn, "foreign_keys"),
"page_size": _pragma_int(conn, "page_size"),
"page_count": _pragma_int(conn, "page_count"),
"freelist_count": _pragma_int(conn, "freelist_count"),
},
}
def _db_filename(conn: sqlite3.Connection) -> str | None:
"""Return the on-disk filename for the main DB attached to this
connection, or None if it's an in-memory / un-attached connection.
Uses ``PRAGMA database_list`` which returns one row per attached
DB; the ``main`` one is what we care about. The third column is the
absolute file path (empty string for ``:memory:``)."""
try:
for row in conn.execute("PRAGMA database_list"):
# row order: seq, name, file
if row[1] == "main":
fname = row[2]
return fname if fname else None
except sqlite3.OperationalError:
return None
return None
def _file_size(conn: sqlite3.Connection) -> int:
"""Return current DB file size from the filesystem. Falls back to
page_size * page_count (logical size) when the connection is
in-memory or the file can't be stat'd."""
fname = _db_filename(conn)
if fname:
try:
return Path(fname).stat().st_size
except OSError:
pass
page_size = _pragma_int(conn, "page_size")
page_count = _pragma_int(conn, "page_count")
return page_size * page_count
def vacuum(conn: sqlite3.Connection) -> dict[str, Any]:
"""Run VACUUM. Returns {ok, op, duration_ms, bytes_reclaimed}.
bytes_reclaimed is the on-disk size delta of the main DB file. In
WAL mode pending writes live in the ``-wal`` sidecar until a
checkpoint flushes them into the main DB, so we issue a TRUNCATE
checkpoint BOTH before VACUUM (so ``size_before`` reflects the real
occupied size including the freelist we're about to reclaim) and
after VACUUM (so ``size_after`` reflects the post-vacuum shrinkage
immediately rather than waiting for the next automatic checkpoint).
"""
t0 = time.monotonic()
try:
# Pre-checkpoint: flush any pending WAL writes into the main DB
# file so size_before captures the true on-disk footprint.
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
size_before = _file_size(conn)
conn.execute("VACUUM")
conn.commit()
# Post-checkpoint: VACUUM itself writes via the WAL; flush again
# so the shrinkage is reflected on the main DB file right now.
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except sqlite3.OperationalError as e:
return {
"ok": False,
"op": "vacuum",
"duration_ms": int((time.monotonic() - t0) * 1000),
"error": str(e),
}
size_after = _file_size(conn)
return {
"ok": True,
"op": "vacuum",
"duration_ms": int((time.monotonic() - t0) * 1000),
"bytes_reclaimed": max(0, size_before - size_after),
}
def integrity_check(conn: sqlite3.Connection) -> dict[str, Any]:
"""Run PRAGMA integrity_check. ``ok=True`` iff output is exactly ['ok']."""
t0 = time.monotonic()
try:
rows = conn.execute("PRAGMA integrity_check").fetchall()
except sqlite3.OperationalError as e:
return {
"ok": False,
"op": "integrity",
"duration_ms": int((time.monotonic() - t0) * 1000),
"output": [],
"error": str(e),
}
output = [str(r[0]) for r in rows]
is_ok = output == ["ok"]
return {
"ok": is_ok,
"op": "integrity",
"duration_ms": int((time.monotonic() - t0) * 1000),
"output": output,
}
def analyze(conn: sqlite3.Connection) -> dict[str, Any]:
"""Run ANALYZE to refresh the query planner's statistics tables."""
t0 = time.monotonic()
try:
conn.execute("ANALYZE")
conn.commit()
except sqlite3.OperationalError as e:
return {
"ok": False,
"op": "analyze",
"duration_ms": int((time.monotonic() - t0) * 1000),
"error": str(e),
}
return {
"ok": True,
"op": "analyze",
"duration_ms": int((time.monotonic() - t0) * 1000),
}
def run_migrations(conn: sqlite3.Connection) -> dict[str, Any]:
"""Force-run pending migrations. Returns the list of versions applied
(empty when the DB was already up to date)."""
from engine.db.migrations import migrate # local import to keep top-level clean
before = current_version(conn)
t0 = time.monotonic()
try:
migrate(conn)
except Exception as e: # noqa: BLE001 — surface to UI as a structured failure
return {
"ok": False,
"op": "migrate",
"duration_ms": int((time.monotonic() - t0) * 1000),
"error": str(e),
"applied": [],
}
after = current_version(conn)
applied = list(range(before + 1, after + 1)) if after > before else []
return {
"ok": True,
"op": "migrate",
"duration_ms": int((time.monotonic() - t0) * 1000),
"applied": applied,
}
+165
View File
@@ -0,0 +1,165 @@
"""Lazy-read routine — the ONE migration runtime.
See community-web spec
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
2026-06-05-schema-versioning-migration-design.md §4 Flow 5. Every row
read (loadModule / loadBundle) goes through this function; the engine
startup bulk runner also calls it for each stale row.
Six branches:
1. No-op (row at CURRENT, original NULL or original.sv ≤ CURRENT).
2. NULL-drop optimization (row.sv == CURRENT, original.sv == CURRENT).
3. Recovery exact catch-up (original.sv ≤ CURRENT, strict-validate
succeeds): promote original into payload, NULL original column.
4. Still-drifted re-project (original.sv > CURRENT): tolerantly strip.
5. Deferred strict-validate failure: mark row 'broken'.
6. Standard forward chain (row.sv < CURRENT, no recovery applicable).
CRITICAL bug-fix from spec §4: the write-back gate is
'base_v < current OR base IS original', NOT just 'base_v < current'.
Otherwise the recovery exact-catch-up case (base_v == current AND base
IS original) silently no-ops and the row stays drifted.
The three dependency-injected dicts:
migrators[(kind, v)] → (payload, ctx) -> next_payload
validators[(kind, v, subtype)] → payload -> bool
tolerant_strip[(kind, target_v, sub)] → payload -> stripped_payload
Subtype lookup: for modules, derived from payload["type"]; for bundles, None.
original_payload_json is INERT — only the initial tolerant install + this
NULL-drop touch it. schema_migrated_at is SEPARATE from updated_at
(structural migration is not a user edit).
"""
from __future__ import annotations
import json
import sqlite3
from collections.abc import Callable
from typing import Any
from engine._utils import now_iso
Migrators = dict[tuple[str, int], Callable[[dict[str, Any], dict], dict]]
Validators = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], bool]]
TolerantStrip = dict[tuple[str, int, str | None], Callable[[dict[str, Any]], dict]]
def lazy_migrate_row(
conn: sqlite3.Connection,
*,
kind: str,
row_id: str,
current_version: int,
migrators: Migrators,
validators: Validators,
tolerant_strip: TolerantStrip | None = None,
) -> None:
"""Apply lazy migration to a single row.
Idempotent: if row is already at CURRENT and original is NULL or
matches CURRENT, returns without writing (or fires NULL-drop only).
"""
table = "modules" if kind == "module" else "bundles"
row = conn.execute(f"SELECT * FROM {table} WHERE id=?", (row_id,)).fetchone()
if row is None:
return
sv = row["schema_version"]
original_raw = row["original_payload_json"]
original = json.loads(original_raw) if original_raw else None
# Narrow original_v to int so the rest of the function's `original is not None`
# checks correctly imply an int. A malformed original (no schema_version, or
# non-int) is demoted to the no-original branch — the row's payload column is
# the only thing we trust.
original_v: int | None = None
if original is not None:
raw_v = original.get("schema_version")
if isinstance(raw_v, int):
original_v = raw_v
else:
original = None
payload = json.loads(row["payload"])
subtype = payload.get("type") if kind == "module" else None
# Branch 1: no-op or NULL-drop
if sv == current_version and (
original is None or (original_v is not None and original_v <= current_version)
):
if original is not None and original_v == sv:
conn.execute(
f"UPDATE {table} SET original_payload_json=NULL, "
f"tolerant_drift_status='none' WHERE id=?",
(row_id,),
)
conn.commit()
return
# Decide base
base_is_original = False
base_v: int
if original is not None and original_v is not None and original_v > sv:
if original_v <= current_version:
# Recovery candidate — deferred strict-validate FIRST
strict = validators.get((kind, original_v, subtype))
if strict is not None and not strict(original):
conn.execute(
f"UPDATE {table} SET tolerant_drift_status='broken', "
f"schema_migrated_at=? WHERE id=?",
(now_iso(), row_id),
)
conn.commit()
return
base = original
base_v = original_v
base_is_original = True
else:
# Still drifted; tolerantly strip original down to current
strip = (tolerant_strip or {}).get((kind, current_version, subtype))
if strip is None:
return
projected = strip(original)
conn.execute(
f"UPDATE {table} SET payload=?, schema_version=?, "
f"tolerant_drift_status='pending', schema_migrated_at=? WHERE id=?",
(
json.dumps(projected),
current_version,
now_iso(),
row_id,
),
)
conn.commit()
return
else:
base = payload
base_v = sv
# Write-back gate (THE BUG FIX): write when base_v < current OR base IS original.
# The recovery exact-catch-up case has zero chain steps (base_v == current_version)
# but still needs the write to promote original into payload + NULL the original
# column. Gating on 'base_v < current_version' alone silently no-ops the recovery.
needs_write = (base_v < current_version) or base_is_original
if not needs_write:
return
new_payload = base
for v in range(base_v, current_version):
migrator = migrators.get((kind, v))
if migrator is None:
return
new_payload = migrator(new_payload, {})
promote_from_original = base_is_original
conn.execute(
f"UPDATE {table} SET payload=?, schema_version=?, schema_migrated_at=?, "
f"original_payload_json=?, tolerant_drift_status=? WHERE id=?",
(
json.dumps(new_payload),
current_version,
now_iso(),
None if promote_from_original else row["original_payload_json"],
"none" if promote_from_original else row["tolerant_drift_status"],
row_id,
),
)
conn.commit()
+107 -9
View File
@@ -14,6 +14,7 @@ import graph.
"""
from __future__ import annotations
import hashlib
import importlib.util
import re
import sqlite3
@@ -26,6 +27,24 @@ _SQL_DIR = Path(__file__).parent / "migrations_sql"
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
class SchemaAheadError(RuntimeError):
"""The database was migrated by a build this one cannot safely read.
Raised instead of letting the runner's "skip anything already applied"
rule quietly hand a foreign schema to code that expects its own.
"""
def _checksum(path: Path) -> str:
"""sha256 of a migration file's bytes.
Bytes, not parsed content: a `.py` migration's behaviour lives in code the
runner `exec`s, so any edit at all is a different migration regardless of
whether the SQL it emits looks the same.
"""
return hashlib.sha256(path.read_bytes()).hexdigest()
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
row = conn.execute(
"SELECT name FROM sqlite_master "
@@ -75,8 +94,93 @@ def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
return up # type: ignore[no-any-return]
def head_version() -> int:
"""Highest migration version this build ships."""
pairs = _discover()
return pairs[-1][0] if pairs else 0
def _has_checksum_column(conn: sqlite3.Connection) -> bool:
cur = conn.execute("PRAGMA table_info(migrations);")
return any(row[1] == "checksum" for row in cur.fetchall())
def _record(conn: sqlite3.Connection, version: int, mig_file: Path) -> None:
"""Insert the applied-migration row, with a checksum when we can store one.
Migration 017 is what adds the column, so on a fresh database every
migration before it inserts without one. Those rows stay NULL forever —
see the 017 docblock for why backfilling them would defeat the check.
"""
if _has_checksum_column(conn):
conn.execute(
"INSERT INTO migrations(version, applied_at, checksum) VALUES(?, ?, ?);",
(version, now_iso(), _checksum(mig_file)),
)
else:
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
def assert_schema_compatible(conn: sqlite3.Connection) -> None:
"""Refuse to touch a database some other build's migrations shaped.
Two distinct failures, both silent before this existed:
1. The database is AHEAD — migrated by a newer build. Every migration is
`<= MAX(version)`, so the runner does nothing and the caller reads a
schema it has never seen.
2. The database is at the SAME version but a DIFFERENT shape, because a
migration file changed after it had already run somewhere. Version
numbers match, so nothing compares anything.
Both are cumulative: each subsequent write goes through code whose
assumptions about the row shape are wrong. Raising is recoverable;
continuing is not.
"""
if not _migrations_table_exists(conn):
return
applied = current_version(conn)
head = head_version()
if applied > head:
raise SchemaAheadError(
f"This database was migrated to version {applied}, but this build only "
f"knows up to {head}. It was almost certainly written by a newer "
f"version of Wildcard Pipeline. Reading it here could corrupt your "
f"library — install the newer version again, or restore a backup."
)
if not _has_checksum_column(conn):
return
known = dict(_discover())
rows = conn.execute(
"SELECT version, checksum FROM migrations WHERE checksum IS NOT NULL;"
).fetchall()
for version, checksum in rows:
mig_file = known.get(int(version))
# Unknown version is the `applied > head` case, already handled above.
if mig_file is None:
continue
if _checksum(mig_file) != checksum:
raise SchemaAheadError(
f"Migration {int(version):03d} was applied by a build whose copy of "
f"it differs from this one's. The version numbers match but the "
f"schemas may not, so this build cannot safely read your library. "
f"Restore a backup taken before that build ran."
)
def migrate(conn: sqlite3.Connection) -> None:
"""Apply all pending migrations. Safe to call repeatedly."""
"""Apply all pending migrations. Safe to call repeatedly.
Guards first: applying this build's migrations on top of a schema another
build shaped is how a mismatch becomes permanent.
"""
assert_schema_compatible(conn)
applied = current_version(conn)
for version, mig_file in _discover():
if version <= applied:
@@ -85,10 +189,7 @@ def migrate(conn: sqlite3.Connection) -> None:
up = _load_py_migration(mig_file)
with conn:
up(conn)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
_record(conn, version, mig_file)
else:
sql = mig_file.read_text(encoding="utf-8")
with conn:
@@ -96,7 +197,4 @@ def migrate(conn: sqlite3.Connection) -> None:
stmt = stmt.strip()
if stmt:
conn.execute(stmt)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
_record(conn, version, mig_file)
@@ -0,0 +1,49 @@
"""Migration 013 — community install origin columns.
Adds ``community_post_slug`` and ``community_version_number`` to both
``modules`` and ``bundles``. Stamped at install time by the host-bridge
``install()`` path (via the embed's ``origin`` opt) so the SPA can:
- Render an "installed from community" pill on origin-stamped rows.
- Run an update-availability check that compares
``community_version_number`` against the post's
``latest_version_number`` in the community API.
Both columns are nullable on purpose — locally-authored rows (Create
flow, Import-from-file, starter set) leave them NULL forever.
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
the migration against a DB that already has the columns is a no-op.
The migration runner's existing version table guards against re-runs
in production; this guard exists for tests that explicitly rewind the
``migrations`` table to exercise the migration on a populated DB
(see ``tests/engine/db/test_migration_010.py``).
"""
from __future__ import annotations
import sqlite3
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
with conn:
if not _has_column(conn, "modules", "community_post_slug"):
conn.execute("ALTER TABLE modules ADD COLUMN community_post_slug TEXT;")
if not _has_column(conn, "modules", "community_version_number"):
conn.execute("ALTER TABLE modules ADD COLUMN community_version_number INTEGER;")
if not _has_column(conn, "bundles", "community_post_slug"):
conn.execute("ALTER TABLE bundles ADD COLUMN community_post_slug TEXT;")
if not _has_column(conn, "bundles", "community_version_number"):
conn.execute("ALTER TABLE bundles ADD COLUMN community_version_number INTEGER;")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_modules_community_slug "
"ON modules(community_post_slug);"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_bundles_community_slug "
"ON bundles(community_post_slug);"
)
@@ -0,0 +1,86 @@
"""Migration 014: payload schema_version + verbatim-local mirror columns.
See the community-web design doc at
docs/superpowers/specs/2026-06-05-schema-versioning-migration-design.md
(in the community-web repo) sections 4 + 5 for the design. The columns:
- schema_version (INTEGER NOT NULL DEFAULT 1): the payload's shape
version. ``1`` is the implicit baseline — every row that existed
before this migration was authored against shape v1, so the DEFAULT
doubles as the backfill.
- original_payload_json (TEXT, nullable): verbatim future-shaped
producer bytes preserved through tolerant installs. NULL means no
preservation was needed (row was installed at-or-below CURRENT, i.e.
no future-shape tolerance was applied).
- tolerant_drift_status (TEXT NOT NULL DEFAULT 'none'): enum tracking
the drift state of the row's payload vs the catalog. ``'none'`` = no
drift; ``'pending'`` = drifted (tolerant-installed from a future
shape and not yet reconciled); ``'broken'`` = deferred strict-validate
failed.
- schema_migrated_at (TEXT, nullable): timestamp of the last structural
migration on the row. Intentionally SEPARATE from ``updated_at`` —
structural migration is the engine moving the payload between shape
versions, not a user edit, so it must not dirty user-edit state.
Backfill: every pre-existing row stamps ``schema_version=1`` and
``tolerant_drift_status='none'`` implicitly via the DEFAULT on the
NOT NULL columns — SQLite materializes the default into existing rows
when ADD COLUMN runs.
Idempotent: each ALTER is gated on ``PRAGMA table_info`` so re-running
the migration against a DB that already has the columns is a no-op.
The migration runner's existing version table guards against re-runs
in production; this guard exists for tests that explicitly rewind the
``migrations`` table to exercise the migration on a populated DB
(see ``tests/engine/db/test_migration_010.py``).
"""
from __future__ import annotations
import sqlite3
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
with conn:
# modules
if not _has_column(conn, "modules", "schema_version"):
conn.execute(
"ALTER TABLE modules ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
)
if not _has_column(conn, "modules", "original_payload_json"):
conn.execute("ALTER TABLE modules ADD COLUMN original_payload_json TEXT;")
if not _has_column(conn, "modules", "tolerant_drift_status"):
conn.execute(
"ALTER TABLE modules ADD COLUMN tolerant_drift_status "
"TEXT NOT NULL DEFAULT 'none';"
)
if not _has_column(conn, "modules", "schema_migrated_at"):
conn.execute("ALTER TABLE modules ADD COLUMN schema_migrated_at TEXT;")
# bundles
if not _has_column(conn, "bundles", "schema_version"):
conn.execute(
"ALTER TABLE bundles ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 1;"
)
if not _has_column(conn, "bundles", "original_payload_json"):
conn.execute("ALTER TABLE bundles ADD COLUMN original_payload_json TEXT;")
if not _has_column(conn, "bundles", "tolerant_drift_status"):
conn.execute(
"ALTER TABLE bundles ADD COLUMN tolerant_drift_status "
"TEXT NOT NULL DEFAULT 'none';"
)
if not _has_column(conn, "bundles", "schema_migrated_at"):
conn.execute("ALTER TABLE bundles ADD COLUMN schema_migrated_at TEXT;")
# Bulk run: at the time 014 ships, CURRENT_SCHEMA_VERSION is still 1, so
# every existing row is already at CURRENT and the bulk is a no-op pass.
# We invoke it anyway to exercise + validate the wiring on first run.
# FUTURE: when a bump migration 015 ships, it ALSO calls run_bulk_lazy
# with the new CURRENT and the bump's migrators.
run_bulk_lazy(conn, current_version=1, migrators={}, validators={}, tolerant_strip={})
@@ -0,0 +1,44 @@
"""Migration 015 -- content_rating column on library rows.
Adds ``content_rating`` (TEXT, default 'safe') to both ``modules`` and
``bundles``. Stamped by:
- The IdentityCard's NSFW toggle in every editor (user-controlled
flag the author sets on their own row).
- The community install bridge when ``opts.origin.content_rating`` is
supplied -- NSFW community posts auto-stamp the row at install
time, so the SPA can render the ``18+`` pill without an extra
fetch.
Default 'safe' means rows that pre-date this migration (every
locally-authored row prior to v2.3) end up marked safe. Toggle to
'nsfw' if needed. No CHECK constraint at the DB level (SQLite ALTER
TABLE does not support adding CHECK); validation happens at the
application layer (``set_content_rating``).
Idempotent: each ALTER is gated on ``PRAGMA table_info``. The
migration runner's version table is the primary re-run guard; the
column check exists for test fixtures that rewind ``migrations``.
"""
from __future__ import annotations
import sqlite3
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
with conn:
if not _has_column(conn, "modules", "content_rating"):
conn.execute(
"ALTER TABLE modules ADD COLUMN content_rating TEXT "
"NOT NULL DEFAULT 'safe';"
)
if not _has_column(conn, "bundles", "content_rating"):
conn.execute(
"ALTER TABLE bundles ADD COLUMN content_rating TEXT "
"NOT NULL DEFAULT 'safe';"
)
@@ -0,0 +1,41 @@
"""Migration 016: wildcard multi-sub-category v2 (SP1).
Marches every stale row from schema_version 1 -> 2 by eager iteration of
the lazy-read routine (see `_014_helpers.run_bulk_lazy`), using the SP1
per-row migrators. The transform:
* option `sub_category: "x"` -> `sub_categories: ["x"]`;
* sub-category names slugified (whitespace / grammar chars -> `_`),
reserved words suffixed, de-duped, cascaded to options + groups;
* nested `@{uuid:a,b,null}` refs -> `@{uuid:a or b!null}`.
See engine/migrations/v1_to_v2.py for the shared transform + the envelope
mirror used by import/export, and CLAUDE.md "Schema versioning".
Idempotent: rows already at schema_version 2 are not stale, so a re-run
(or a run on a fresh DB with no v1 rows) is a no-op. validators /
tolerant_strip are empty — the forward 1->2 chain needs neither (there is
no future-shaped `original_payload_json` to recover at this bump).
"""
from __future__ import annotations
import sqlite3
from engine.db.migrations_sql._014_helpers import run_bulk_lazy
from engine.migrations.v1_to_v2 import (
migrate_bundle_v1_to_v2,
migrate_module_v1_to_v2,
)
def up(conn: sqlite3.Connection) -> None:
run_bulk_lazy(
conn,
current_version=2,
migrators={
("module", 1): migrate_module_v1_to_v2,
("bundle", 1): migrate_bundle_v1_to_v2,
},
validators={},
tolerant_strip={},
)
@@ -0,0 +1,41 @@
"""Migration 017 — record WHICH migration ran, not just how many.
``migrations`` tracked only ``version``, and the runner skips anything
``<= MAX(version)``. That makes an integer the entire identity of a
schema, which it is not:
- A build whose head is 015 opened against a DB migrated to 016 skips
every migration, finds nothing to do, and reads a schema it has never
seen. Additive columns survive; a shape change does not.
- Worse, two builds can both call something "016" and mean different
things — a reworked migration, a hand-edited file, a branch that never
shipped. The version matches, so nothing anywhere notices.
Both are silent, and both corrupt cumulatively: every subsequent write
goes through code whose assumptions about the row shape are wrong.
``checksum`` fixes it by storing the sha256 of the migration file that
actually ran. ``assert_schema_compatible`` then compares applied
checksums against the files this build ships, so a divergence is a
loud, recoverable error instead of silence.
Nullable on purpose: rows applied before this migration existed have no
recorded checksum and cannot retroactively grow one. The guard skips
them rather than guessing — a NULL means "unknown", never "mismatched".
Idempotent via ``PRAGMA table_info`` so tests that rewind the
``migrations`` table can re-run it against a populated DB.
"""
from __future__ import annotations
import sqlite3
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
cur = conn.execute(f"PRAGMA table_info({table});")
return any(row[1] == column for row in cur.fetchall())
def up(conn: sqlite3.Connection) -> None:
if not _has_column(conn, "migrations", "checksum"):
conn.execute("ALTER TABLE migrations ADD COLUMN checksum TEXT;")
+73
View File
@@ -0,0 +1,73 @@
"""Helpers for migration 014 — the bulk runner used at startup + at
future payload-bump migrations.
See community-web spec
D:\\Desktop\\Wildcard-Pipeline-Community\\docs\\superpowers\\specs\\
2026-06-05-schema-versioning-migration-design.md §4 Flow 4: bulk is
EAGER ITERATION of the lazy-read routine, NOT a parallel forward-chain
implementation. This eliminates the transient-wrong window where bulk
could write fabricated defaults over recoverable data preserved in
original_payload_json.
"""
from __future__ import annotations
import sqlite3
from engine.db.lazy_migrate import lazy_migrate_row
STALE_PREDICATE = (
"schema_version < ? "
"OR (original_payload_json IS NOT NULL AND "
" json_extract(original_payload_json, '$.schema_version') > schema_version)"
)
def run_bulk_lazy(
conn: sqlite3.Connection,
*,
current_version: int,
migrators,
validators,
tolerant_strip,
) -> int:
"""Eager iteration of lazy_migrate_row over every stale row.
The stale predicate catches BOTH 'sv < current' AND 'tolerantly-
installed row whose original now outranks the projection'. The
second clause handles rows where the extension has caught up after
a previous tolerant install.
Returns count of rows that failed to migrate (caller surfaces a
dashboard banner when > 0).
"""
failed = 0
for table, kind in [("modules", "module"), ("bundles", "bundle")]:
ids = [
row["id"]
for row in conn.execute(
f"SELECT id FROM {table} WHERE {STALE_PREDICATE}",
(current_version,),
).fetchall()
]
for row_id in ids:
try:
lazy_migrate_row(
conn,
kind=kind,
row_id=row_id,
current_version=current_version,
migrators=migrators,
validators=validators,
tolerant_strip=tolerant_strip,
)
except Exception: # noqa: BLE001 — defensive sweep
failed += 1
continue
# After lazy_migrate_row: if the row's schema_version is still
# below current_version, count as failed.
after = conn.execute(
f"SELECT schema_version FROM {table} WHERE id=?", (row_id,)
).fetchone()
if after and after["schema_version"] < current_version:
failed += 1
return failed
+55
View File
@@ -0,0 +1,55 @@
"""Execute deferred DB file moves at plugin load.
The SPA Settings UI lets users change the DB location. The actual file
operation runs here, on next ComfyUI start, BEFORE any DB connection
opens. Errors are logged and never crash the host.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from engine.db.config import clear_pending_move, load
logger = logging.getLogger(__name__)
def execute_pending_move() -> None:
"""If the sidecar has a pending_move, run it and clear the entry.
Idempotent if the move already happened (source missing, dest present).
Failures (source missing without dest, permission errors) are logged;
sidecar is left intact so the UI can show the pending state on next
page load.
"""
cfg = load()
pm = cfg.get("pending_move")
if not pm:
return
src = Path(pm["from"])
dst = Path(pm["to"])
mode = pm["mode"]
# Resume: previous run completed the move but failed to clear sidecar.
if not src.exists() and dst.exists():
logger.info("wp_db_pending_move: already-done (src missing, dst present), clearing")
clear_pending_move()
return
if not src.exists():
logger.error("wp_db_pending_move: source %s missing, skipping", src)
return
try:
dst.parent.mkdir(parents=True, exist_ok=True)
if mode == "copy":
shutil.copy2(src, dst)
else: # move
shutil.move(str(src), str(dst))
except OSError as e:
logger.exception("wp_db_pending_move: %s failed: %s", mode, e)
return
clear_pending_move()
logger.info("wp_db_pending_move: %s %s -> %s OK", mode, src, dst)
+158 -18
View File
@@ -19,6 +19,21 @@ from engine.modules.snapshot import payload_hash
_VALID_TYPES: frozenset[str] = frozenset({
"wildcard", "fixed_values", "combine", "derivation", "constraint",
})
# Free-text adult-content flag on every library row (migration 015).
# Stamped by the IdentityCard's NSFW toggle or auto-stamped at install
# time when a community post carried `content_rating='nsfw'`. No DB-level
# CHECK constraint (SQLite ALTER limit); validate at the repository.
_VALID_CONTENT_RATINGS: frozenset[str] = frozenset({"safe", "nsfw"})
def _validate_content_rating(rating: str) -> str:
if rating not in _VALID_CONTENT_RATINGS:
raise ValueError(
f"content_rating must be one of {sorted(_VALID_CONTENT_RATINGS)}, "
f"got {rating!r}"
)
return rating
# Module ids are 8-hex short uuids — same shape the tokenizer's
# `@{8hex}` ref token captures and the engine catalog keys by. Slug
# prefixes (e.g. `wc_outfit_a1b2c3d4`) were removed in migration 004
@@ -55,6 +70,24 @@ class ModuleNotFound(LookupError):
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
payload = json.loads(row["payload"])
# Community origin columns (migration 013) — NULL for any row that
# didn't come from a community install. Surfaced verbatim so the
# SPA can render an "installed from community" badge + drive
# update-available checks.
try:
community_post_slug = row["community_post_slug"]
community_version_number = row["community_version_number"]
except (IndexError, KeyError):
# Defensive: a connection opened against a DB that hasn't run
# migration 013 yet (e.g. mid-rollout, test fixtures) won't
# have these columns. Treat absence as NULL.
community_post_slug = None
community_version_number = None
try:
content_rating = row["content_rating"]
except (IndexError, KeyError):
# Pre-015 fixture: column absent, treat as 'safe'.
content_rating = "safe"
return {
"id": row["id"],
"type": row["type"],
@@ -69,6 +102,9 @@ def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"community_post_slug": community_post_slug,
"community_version_number": community_version_number,
"content_rating": content_rating,
}
@@ -125,13 +161,14 @@ class ModuleRepository:
payload: dict[str, Any],
is_favorite: bool = False,
id: str | None = None,
content_rating: str = "safe",
) -> dict[str, Any]:
"""Insert a new module row.
``id`` is normally generated; pass an explicit id to import
a workflow-resident snapshot back into the library at the
SAME uuid (so a freshly-saved row immediately matches the
workflow's existing references — no broken `@{uuid}` refs).
workflow's existing references -- no broken `@{uuid}` refs).
Validates the supplied id is 8-hex to prevent the workflow
from injecting arbitrary primary-key shapes.
"""
@@ -139,6 +176,7 @@ class ModuleRepository:
raise ValueError(
f"unknown module type {type!r}; expected one of {sorted(_VALID_TYPES)}"
)
_validate_content_rating(content_rating)
if type == "wildcard":
payload = self._backfill_option_ids(payload)
if id is None:
@@ -163,12 +201,13 @@ class ModuleRepository:
self._conn.execute(
"INSERT INTO modules("
"id, type, name, description, category_id, tags, "
"is_favorite, payload, snapshot_fingerprint, version, created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
"is_favorite, payload, snapshot_fingerprint, version, "
"created_at, updated_at, content_rating"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
(
mid, type, name, description, category_id,
json.dumps(tags), int(is_favorite),
json.dumps(payload), fp, now, now,
json.dumps(payload), fp, now, now, content_rating,
),
)
return self.get(mid)
@@ -226,6 +265,7 @@ class ModuleRepository:
tags: list[str] | _Unset = _UNSET,
payload: dict[str, Any] | _Unset = _UNSET,
is_favorite: bool | _Unset = _UNSET,
content_rating: str | _Unset = _UNSET,
) -> dict[str, Any]:
existing = self.get(module_id)
# Backfill missing option ids when caller writes a fresh wildcard payload.
@@ -235,6 +275,8 @@ class ModuleRepository:
and isinstance(payload, dict)
):
payload = self._backfill_option_ids(payload)
if not isinstance(content_rating, _Unset):
_validate_content_rating(content_rating)
new = {
"name": existing["name"] if isinstance(name, _Unset) else name,
"description": (
@@ -248,6 +290,11 @@ class ModuleRepository:
"is_favorite": (
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
),
"content_rating": (
existing["content_rating"]
if isinstance(content_rating, _Unset)
else content_rating
),
}
new_payload_hash = payload_hash(new["payload"])
new_fp = module_fingerprint({
@@ -263,16 +310,44 @@ class ModuleRepository:
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ? "
"version = version + 1, updated_at = ?, content_rating = ? "
"WHERE id = ?;",
(
new["name"], new["description"], new["category_id"],
json.dumps(new["tags"]), int(new["is_favorite"]),
json.dumps(new["payload"]), new_fp, now, module_id,
json.dumps(new["payload"]), new_fp, now,
new["content_rating"], module_id,
),
)
return self.get(module_id)
def set_community_origin(
self, module_id: str, *, post_slug: str, version_number: int,
) -> dict[str, Any]:
"""Stamp community origin on an existing row WITHOUT a version
bump or fingerprint change.
Used by the publish-writeback path (host bridge
``markPublished``): after a row is published to community, the
local row gets linked to the resulting post so the SPA renders
the community pill, the update-available checker tracks it, and
a future re-publish detects "this is my own post" (version bump
vs fork). Origin metadata is NOT runtime-affecting, so unlike
``update`` this neither bumps ``version`` nor recomputes the
snapshot fingerprint -- a published row stays byte-identical to
what consumers already installed.
"""
with self._conn:
cur = self._conn.execute(
"UPDATE modules SET "
"community_post_slug = ?, community_version_number = ? "
"WHERE id = ?;",
(post_slug, version_number, module_id),
)
if cur.rowcount == 0:
raise ModuleNotFound(module_id)
return self.get(module_id)
def delete(self, module_id: str) -> None:
with self._conn:
cur = self._conn.execute("DELETE FROM modules WHERE id = ?;", (module_id,))
@@ -307,7 +382,15 @@ class ModuleRepository:
.replace("%", "\\%")
.replace("_", "\\_")
)
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
# Match the name OR the uuid — searching by id is the fast path for
# debugging library linkage (finding the row a workflow's @{id} /
# constraint source/target points at). NOCASE so an 8-hex id typed
# in any case still matches.
clauses.append(
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
)
params.append(f"%{escaped}%")
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
@@ -481,6 +564,16 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
intentionally frozen — the saved hash is the source of truth for
insert-time `inserted_at_hash` capture + library-drift detection.
"""
try:
community_post_slug = row["community_post_slug"]
community_version_number = row["community_version_number"]
except (IndexError, KeyError):
community_post_slug = None
community_version_number = None
try:
content_rating = row["content_rating"]
except (IndexError, KeyError):
content_rating = "safe"
return {
"id": row["id"],
"name": row["name"],
@@ -494,6 +587,9 @@ def _row_to_bundle(row: sqlite3.Row) -> dict[str, Any]:
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"community_post_slug": community_post_slug,
"community_version_number": community_version_number,
"content_rating": content_rating,
}
@@ -529,7 +625,9 @@ class BundleRepository:
children: list[dict[str, Any]] | None = None,
is_favorite: bool = False,
id: str | None = None,
content_rating: str = "safe",
) -> dict[str, Any]:
_validate_content_rating(content_rating)
if id is None:
bid = self._gen_id()
else:
@@ -541,7 +639,7 @@ class BundleRepository:
)
bid = id
children_blob = list(children) if children else []
# `payload_hash` is computed off the children blob — the only
# `payload_hash` is computed off the children blob -- the only
# field whose change should signal "library has a newer
# version" to inserted instances.
ph = payload_hash({"children": children_blob})
@@ -551,12 +649,12 @@ class BundleRepository:
"INSERT INTO bundles("
"id, name, description, color, category_id, tags, "
"is_favorite, children, payload_hash, version, "
"created_at, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
"created_at, updated_at, content_rating"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?);",
(
bid, name, description, color, category_id,
json.dumps(tags or []), int(is_favorite),
json.dumps(children_blob), ph, now, now,
json.dumps(children_blob), ph, now, now, content_rating,
),
)
return self.get(bid)
@@ -580,8 +678,11 @@ class BundleRepository:
tags: list[str] | _Unset = _UNSET,
children: list[dict[str, Any]] | _Unset = _UNSET,
is_favorite: bool | _Unset = _UNSET,
content_rating: str | _Unset = _UNSET,
) -> dict[str, Any]:
existing = self.get(bundle_id)
if not isinstance(content_rating, _Unset):
_validate_content_rating(content_rating)
new = {
"name": existing["name"] if isinstance(name, _Unset) else name,
"description": (
@@ -598,11 +699,16 @@ class BundleRepository:
"is_favorite": (
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
),
"content_rating": (
existing["content_rating"]
if isinstance(content_rating, _Unset)
else content_rating
),
}
# Recompute payload_hash whenever children change. Other field
# edits (rename, recolor, retag) don't affect runtime behavior
# so they don't bump the hash — inserted instances stay clean
# on a pure-cosmetic library update.
# edits (rename, recolor, retag, NSFW toggle) don't affect runtime
# behavior so they don't bump the hash -- inserted instances stay
# clean on a pure-cosmetic library update.
ph = (
existing["payload_hash"]
if isinstance(children, _Unset)
@@ -614,17 +720,35 @@ class BundleRepository:
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ? "
"version = version + 1, updated_at = ?, content_rating = ? "
"WHERE id = ?;",
(
new["name"], new["description"], new["color"],
new["category_id"], json.dumps(new["tags"]),
int(new["is_favorite"]),
json.dumps(new["children"]), ph, now, bundle_id,
json.dumps(new["children"]), ph, now,
new["content_rating"], bundle_id,
),
)
return self.get(bundle_id)
def set_community_origin(
self, bundle_id: str, *, post_slug: str, version_number: int,
) -> dict[str, Any]:
"""Stamp community origin on a bundle row WITHOUT a version bump
or payload-hash change. See ModuleRepository.set_community_origin
for the rationale -- origin metadata is not runtime-affecting."""
with self._conn:
cur = self._conn.execute(
"UPDATE bundles SET "
"community_post_slug = ?, community_version_number = ? "
"WHERE id = ?;",
(post_slug, version_number, bundle_id),
)
if cur.rowcount == 0:
raise BundleNotFound(bundle_id)
return self.get(bundle_id)
def delete(self, bundle_id: str) -> None:
with self._conn:
cur = self._conn.execute(
@@ -651,7 +775,15 @@ class BundleRepository:
.replace("%", "\\%")
.replace("_", "\\_")
)
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
# Match the name OR the uuid — searching by id is the fast path for
# debugging library linkage (finding the row a workflow's @{id} /
# constraint source/target points at). NOCASE so an 8-hex id typed
# in any case still matches.
clauses.append(
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
)
params.append(f"%{escaped}%")
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
@@ -835,7 +967,15 @@ class TemplateRepository:
.replace("%", "\\%")
.replace("_", "\\_")
)
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
# Match the name OR the uuid — searching by id is the fast path for
# debugging library linkage (finding the row a workflow's @{id} /
# constraint source/target points at). NOCASE so an 8-hex id typed
# in any case still matches.
clauses.append(
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
)
params.append(f"%{escaped}%")
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
+119 -35
View File
@@ -154,8 +154,10 @@ def _insert_module(
"INSERT INTO modules("
"id, type, name, description, category_id, tags, "
"is_favorite, payload, snapshot_fingerprint, version, "
"created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
"created_at, updated_at, "
"community_post_slug, community_version_number, "
"content_rating"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
mid, kind, entity["name"], entity.get("description", ""),
entity.get("category_id"),
@@ -166,6 +168,17 @@ def _insert_module(
entity.get("version", 1),
entity.get("created_at", now),
entity.get("updated_at", now),
# Community origin -- only set when the install bridge stamps
# them on the entity (see manager/main.ts snapshotLibrary +
# the embed's install call). Local Create/Import-from-file
# paths leave these NULL so the row stays "locally authored".
entity.get("community_post_slug"),
entity.get("community_version_number"),
# content_rating defaults to 'safe' when the entity carries
# no value -- migration 015 stamps the same default. Community
# install passes 'nsfw' through via the entity when the post
# carries content_rating='nsfw'.
entity.get("content_rating", "safe"),
),
)
return mid
@@ -193,8 +206,10 @@ def _insert_bundle(
"INSERT INTO bundles("
"id, name, description, color, category_id, tags, "
"is_favorite, children, payload_hash, version, "
"created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
"created_at, updated_at, "
"community_post_slug, community_version_number, "
"content_rating"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);",
(
bid, entity["name"], entity.get("description", ""),
entity.get("color"),
@@ -206,6 +221,11 @@ def _insert_bundle(
entity.get("version", 1),
entity.get("created_at", now),
entity.get("updated_at", now),
# Community origin -- see _insert_module comment above.
entity.get("community_post_slug"),
entity.get("community_version_number"),
# content_rating -- see _insert_module comment above.
entity.get("content_rating", "safe"),
),
)
return bid
@@ -219,50 +239,114 @@ def _update_module(
Preserves `mid` and the row's `type` (the kind dispatch already
routed us here so type is implied). Bumps `version` and stamps
`updated_at` like `ModuleRepository.update`.
Community origin (migration 013) is rewritten ONLY when the new
content explicitly carries it — a community Update-in-place
install stamps both fields on the entity, while a vanilla Import
tab replace leaves them absent and the row keeps its prior
community_* values (or NULL for locally-authored rows).
"""
_require_entity_fields(kind, "replace", content, ("name",))
now = now_iso()
fp = _module_fp_for_entity({**content, "type": kind})
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ? "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(content.get("payload") or {}),
fp, now, mid,
),
)
if "community_post_slug" in content or "community_version_number" in content:
# `content_rating` uses COALESCE so missing key preserves the
# existing value -- community-update installs that only bump
# the version don't accidentally wipe a user-set NSFW flag.
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ?, "
"community_post_slug = ?, community_version_number = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(content.get("payload") or {}),
fp, now,
content.get("community_post_slug"),
content.get("community_version_number"),
content.get("content_rating"),
mid,
),
)
else:
conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, snapshot_fingerprint = ?, "
"version = version + 1, updated_at = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(content.get("payload") or {}),
fp, now,
content.get("content_rating"),
mid,
),
)
def _update_bundle(
conn: sqlite3.Connection, bid: str, content: dict[str, Any],
) -> None:
"""UPDATE the bundles row identified by `bid` to mirror `content`."""
"""UPDATE the bundles row identified by `bid` to mirror `content`.
See _update_module for the community_* rewrite semantics.
"""
_require_entity_fields("bundle", "replace", content, ("name",))
children_blob = list(content.get("children") or [])
ph = payload_hash({"children": children_blob})
now = now_iso()
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ? "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("color"),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(children_blob), ph, now, bid,
),
)
if "community_post_slug" in content or "community_version_number" in content:
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ?, "
"community_post_slug = ?, community_version_number = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("color"),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(children_blob), ph, now,
content.get("community_post_slug"),
content.get("community_version_number"),
content.get("content_rating"),
bid,
),
)
else:
conn.execute(
"UPDATE bundles SET "
"name = ?, description = ?, color = ?, category_id = ?, "
"tags = ?, is_favorite = ?, children = ?, payload_hash = ?, "
"version = version + 1, updated_at = ?, "
"content_rating = COALESCE(?, content_rating) "
"WHERE id = ?;",
(
content["name"], content.get("description", ""),
content.get("color"),
content.get("category_id"),
json.dumps(content.get("tags") or []),
int(content.get("is_favorite", False)),
json.dumps(children_blob), ph, now,
content.get("content_rating"),
bid,
),
)
def _module_exists(conn: sqlite3.Connection, mid: str) -> bool:
+24 -3
View File
@@ -10,11 +10,26 @@ from __future__ import annotations
from typing import Any
from engine.migrations.v0_to_v1 import migrate_v0_to_v1
from engine.migrations.v1_to_v2 import migrate_v1_to_v2
CURRENT_SCHEMA_VERSION = 1
CURRENT_SCHEMA_VERSION = 2
# Highest schema version this runtime can natively READ + WRITE — distinct
# from CURRENT_SCHEMA_VERSION (the migration-chain head, which stays 2 because
# v2->v3/v3->v4 are no-ops). v3 is the SP2b text-grammar bump (shape-identical
# to v2) and v4 is the additive `target_select` constraint reach; both are
# handled natively, so a payload at CURRENT < v <= MAX_KNOWN installs AS-IS
# with no migration. Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
# `src/manager/import-export/migrations.ts`.
#
# MAINTENANCE CONTRACT: bump this whenever the TS `schemaVersionForPayload()`
# learns to stamp a new (higher) version — otherwise this commit-side
# re-validate will reject the very shapes the runtime just learned to produce.
MAX_KNOWN_SCHEMA_VERSION = 4
_CHAIN = {
0: migrate_v0_to_v1,
1: migrate_v1_to_v2,
}
@@ -32,10 +47,16 @@ def migrate_payload(payload: dict[str, Any]) -> dict[str, Any]:
version = payload.get("schema_version")
if not isinstance(version, int):
return {"ok": False, "reason": "payload missing schema_version field"}
if version > CURRENT_SCHEMA_VERSION:
# Reject threshold reads MAX_KNOWN (highest natively-supported version),
# NOT CURRENT (the migration-chain head). A payload at
# CURRENT < v <= MAX_KNOWN is shape-compatible and handled natively, so it
# passes through AS-IS — the while-loop below is bound by CURRENT, so there
# is nothing to migrate and schema_version is preserved. Only > MAX_KNOWN
# rejects. Mirror of TS `migrateImportEnvelope`.
if version > MAX_KNOWN_SCHEMA_VERSION:
return {
"ok": False,
"reason": f"future schema version {version} (current: {CURRENT_SCHEMA_VERSION})",
"reason": f"future schema version {version} (max known: {MAX_KNOWN_SCHEMA_VERSION})",
}
current = {
"schema_version": version,
+199
View File
@@ -0,0 +1,199 @@
"""v1 -> v2 payload migration (SP1 multi-tag + boolean sub-category filter).
Transforms, all idempotent:
* option `sub_category: "x"` -> `sub_categories: ["x"]` (None/absent -> []);
* registry + option + tag_group sub-category names slugified to a single
safe token (whitespace / boolean-grammar / ref chars -> `_`), reserved
words suffixed (`or` -> `or_1`), de-duped — cascaded so an option/group
membership tracks its renamed registry entry;
* instance `category_filter: [...]` -> a boolean expression string
(`" or ".join`) + a separate `exclude_null` flag (the old `"null"`
list-entry);
* nested `@{uuid#name:a,b,null}` refs -> `@{uuid#name:a or b!null}`
(comma list -> `or`, `null` keyword -> trailing `!null` marker).
Two entry shapes share one core (`_deep_migrate`):
* `migrate_v1_to_v2(envelope)` — the import/export multi-row envelope
(`wildcards[]`/`bundles[]`/...); registered in the version `_CHAIN`.
* `migrate_module_v1_to_v2` / `migrate_bundle_v1_to_v2` — per-row payload
migrators for the lazy/bulk DB path (`engine/db/lazy_migrate.py`); the
row's `payload` column is the inner module payload (`{options, ...}`,
no `type` field — wildcard-ness is detected structurally).
Mirror: src/manager/import-export/migrations.ts.
"""
from __future__ import annotations
import re
from typing import Any
from engine.syntax.subcat_filter import RESERVED
# Chars a v2 sub-category name may not contain (mirrors subcat_filter
# validate_subcat_name): whitespace + the boolean-grammar + ref delimiters.
_BAD = re.compile(r"[\s()!,#:}@{$]+")
# Broad ref matcher (v1 OR v2 form) so v1 comma/null lists get rewritten
# while v2 refs pass through untouched. Groups: uuid, `#name` seg, `:tail`.
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(#[^:}]*)?(:[^}]*)?\}")
def _slug(name: str) -> str:
return _BAD.sub("_", name).strip("_") or "_"
def _name_map(registry: list[str]) -> dict[str, str]:
"""old sub-category name -> sanitized, reserved-safe, unique name."""
out: dict[str, str] = {}
used: set[str] = set()
for raw in registry:
s = _slug(raw)
if s.lower() in RESERVED:
s = f"{s}_1"
base, n = s, 1
while s in used:
n += 1
s = f"{base}_{n}"
used.add(s)
out[raw] = s
return out
def _rewrite_ref(m: re.Match[str]) -> str:
uuid = m.group(1)
name_seg = m.group(2) or ""
tail = m.group(3)
if tail is None:
return m.group(0) # bare `@{uuid}` / `@{uuid#name}` — nothing to do
lst = tail[1:] # strip the leading ':'
has_comma = "," in lst
parts = [x.strip() for x in lst.split(",")]
has_null = "null" in parts
if not has_comma and not has_null:
# Single tag OR an already-v2 boolean expression — leave as-is so
# the rewrite is idempotent and never corrupts a v2 ref.
return m.group(0)
# Slug each tag so a cross-wildcard ref tracks the target's renamed
# registry entry (deterministic — same _slug the target applies).
tags = [_slug(p) for p in parts if p and p != "null"]
expr = " or ".join(tags)
out = "@{" + uuid + name_seg
if expr:
out += ":" + expr
if has_null:
out += "!null"
return out + "}"
def _rewrite_refs(s: str) -> str:
return _REF_RE.sub(_rewrite_ref, s)
def _is_wildcard_payload(p: Any) -> bool:
"""A wildcard payload is the only shape carrying a sub-category model:
an `options` list plus either a `sub_categories` registry or an option
bearing the legacy singular `sub_category` field."""
if not isinstance(p, dict) or not isinstance(p.get("options"), list):
return False
if "sub_categories" in p:
return True
return any(isinstance(o, dict) and "sub_category" in o for o in p["options"])
def _apply_wildcard_payload(
p: dict[str, Any], nmap: dict[str, str] | None = None
) -> dict[str, Any]:
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
if nmap is None:
nmap = _name_map(reg)
out = {**p, "sub_categories": [nmap[s] for s in reg]}
new_opts: list[Any] = []
for o in p.get("options", []):
if not isinstance(o, dict):
new_opts.append(o)
continue
o = dict(o)
if isinstance(o.get("sub_categories"), list): # already-v2 / idempotent
o["sub_categories"] = [nmap.get(t, _slug(t)) for t in o["sub_categories"]]
else:
sc = o.pop("sub_category", None)
o["sub_categories"] = (
[nmap.get(sc, _slug(sc))] if isinstance(sc, str) and sc else []
)
if isinstance(o.get("value"), str):
o["value"] = _rewrite_refs(o["value"])
new_opts.append(o)
out["options"] = new_opts
if isinstance(p.get("tag_groups"), dict):
out["tag_groups"] = {
g: [nmap.get(m, _slug(m)) for m in mem if isinstance(m, str)]
for g, mem in p["tag_groups"].items()
}
return out
def _migrate_instance(inst: dict[str, Any], nmap: dict[str, str]) -> dict[str, Any]:
cf = inst.get("category_filter")
if not isinstance(cf, list):
return inst
out = dict(inst)
has_null = "null" in cf
tags = [nmap.get(c, _slug(c)) for c in cf if isinstance(c, str) and c and c != "null"]
out["category_filter"] = " or ".join(tags)
out["exclude_null"] = bool(out.get("exclude_null")) or has_null
return out
def _deep_migrate(obj: Any) -> Any:
"""Recursive transform shared by the per-row module/bundle migrators:
migrate any wildcard-shaped dict in place, rewrite `@{}` refs in every
string, recurse into containers. Idempotent."""
if isinstance(obj, dict):
if _is_wildcard_payload(obj):
return _apply_wildcard_payload(obj)
return {k: _deep_migrate(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_deep_migrate(x) for x in obj]
if isinstance(obj, str):
return _rewrite_refs(obj)
return obj
def migrate_module_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
"""Per-row module-payload migrator for the lazy/bulk DB path."""
return _deep_migrate(payload)
def migrate_bundle_v1_to_v2(payload: dict[str, Any], ctx: Any = None) -> dict[str, Any]:
"""Per-row bundle-payload migrator — recurses into frozen child snapshots."""
return _deep_migrate(payload)
def _migrate_wildcard_entry(w: dict[str, Any]) -> dict[str, Any]:
p = w.get("payload")
nmap: dict[str, str] = {}
out = dict(w)
if isinstance(p, dict):
reg = [s for s in p.get("sub_categories", []) if isinstance(s, str)]
nmap = _name_map(reg)
out["payload"] = _apply_wildcard_payload(p, nmap)
inst = w.get("instance")
if isinstance(inst, dict):
out["instance"] = _migrate_instance(inst, nmap)
return out
def migrate_v1_to_v2(payload: dict[str, Any]) -> dict[str, Any]:
"""Envelope migrator (mirrors the TS chain step). Maps each wildcard
entry structurally (+ its instance), deep-migrates the other entity
arrays (ref rewrite + any nested wildcard snapshots), bumps to v2."""
out = dict(payload)
out["wildcards"] = [
_migrate_wildcard_entry(w) if isinstance(w, dict) else w
for w in payload.get("wildcards", [])
]
for key in ("bundles", "fixed_values", "combines", "derivations",
"constraints", "categories", "templates"):
if isinstance(payload.get(key), list):
out[key] = [_deep_migrate(e) for e in payload[key]]
out["schema_version"] = 2
return out
+57 -18
View File
@@ -31,15 +31,30 @@ class _RuntimeResolveContext:
# bucket is empty/None.
_constraints: list[dict[str, Any]] = field(default_factory=list)
_picks: dict[str, dict[str, Any]] = field(default_factory=dict)
# First-instance consume bookkeeping (2026-05-24 spec). Threaded
# by reference so mutations inside apply_constraints_for_target
# propagate back to the pipeline's ctx bucket and to subsequent
# target-instance resolves later in the same chain.
_consumed: set[str] = field(default_factory=set)
# SP3 reach-selector hit counter (per-constraint, keyed by
# `__constraint_module_id__`). Threaded by reference so increments
# inside apply_constraints_for_target on the nested-ref path share
# one counter with the chain-level direct path + the pipeline's ctx
# bucket — first/next coverage spans both surfaces.
_hits: dict[str, int] = field(default_factory=dict)
# SP3 nested-pick carrier identity for the CURRENT resolve frame:
# the wildcard whose chosen option value textually contains the
# `@{T}` ref being resolved, plus that option's id. MUTABLE +
# save/restored by the resolver around each nested-option recursion
# (mirroring the rng swap) so `_resolve_ref` can tell
# apply_constraints_for_target which (carrier_uid, option_id) is
# firing target T — the only thing a `pick` selector's `nested`
# occurrence matches on. None outside any carrier option.
_carrier_uid: str | None = None
_carrier_option_id: str | None = None
def get_var(self, name: str) -> str | None:
# SP2a: return the raw stored value (may be a ListVar from a
# multi-select wildcard); the resolver formats it. Typed str | None
# for the Protocol — a ListVar flows through at runtime via the
# Any-valued vars dict, and the resolver narrows on isinstance.
if name in self._vars:
return str(self._vars[name])
return self._vars[name]
return None
def get_module(self, uuid: str) -> dict[str, Any] | None:
@@ -71,13 +86,37 @@ class _RuntimeResolveContext:
source wildcard's pick when reweighting the target."""
return self._picks
def get_consumed_constraints(self) -> set[str]:
"""``ctx['__wp_consumed_constraints__']`` — set of constraint
module ids that have already fired against their first
downstream target instance. Threaded by reference so the
nested-ref resolver's mutation is observed by the pipeline + by
subsequent target-instance resolves later in the chain."""
return self._consumed
def get_constraint_hits(self) -> dict[str, int]:
"""``ctx['__wp_constraint_hits__']`` — per-constraint firing
count keyed by module id. Threaded by reference so the
nested-ref resolver's increments are observed by the pipeline +
by subsequent target-instance resolves later in the chain
(first/next coverage spans direct + nested encounters)."""
return self._hits
def set_carrier(self, carrier_uid: str | None, option_id: str | None) -> None:
"""Set the CURRENT carrier identity (the wildcard whose chosen
option value contains the `@{T}` ref about to resolve, + that
option's id). The resolver save/restores this around each nested
option recursion (mirroring the rng swap)."""
self._carrier_uid = carrier_uid
self._carrier_option_id = option_id
def get_carrier(self) -> tuple[str | None, str | None]:
"""Return the current ``(carrier_uid, option_id)`` so the
resolver can save it before a recursion and restore it after."""
return self._carrier_uid, self._carrier_option_id
def get_carrier_ctx(self) -> dict[str, Any] | None:
"""The current carrier as the dict shape
``apply_constraints_for_target`` /
``_constraints._occurrence_matches`` expect, or ``None`` when no
carrier is set (outside any carrier option — e.g. a top-level
ref). A `pick` selector's `nested` entry matches on these two
keys; every other selector mode ignores them."""
if self._carrier_uid is None and self._carrier_option_id is None:
return None
return {"carrier_uid": self._carrier_uid, "option_id": self._carrier_option_id}
def build_resolve_ctx(
@@ -93,7 +132,7 @@ def build_resolve_ctx(
"""
constraints = ctx.get("__wp_constraints__")
picks = ctx.get("__wp_picks__")
consumed = ctx.setdefault("__wp_consumed_constraints__", set())
hits = ctx.setdefault("__wp_constraint_hits__", {})
return _RuntimeResolveContext( # type: ignore[return-value]
rng=ctx["__wp_rng__"],
max_ref_depth=int(ctx.get("__wp_max_ref_depth__", 8)),
@@ -110,10 +149,10 @@ def build_resolve_ctx(
# support) — defaults handle that path.
_constraints=constraints if isinstance(constraints, list) else [],
_picks=picks if isinstance(picks, dict) else {},
# First-instance consume set — passed by reference so the
# nested-ref resolver's mutation sticks in ctx for subsequent
# target-instance resolves later in the chain.
_consumed=consumed if isinstance(consumed, set) else set(),
# SP3 hit counter — passed by reference so the nested-ref
# resolver's increments stick in ctx + share one counter with
# the direct path for first/next coverage.
_hits=hits if isinstance(hits, dict) else {},
)
+62
View File
@@ -0,0 +1,62 @@
"""Pure constraint re-weight math (SP3). No engine/ctx imports — testable bare.
One multiply operator at three levels: across an option's matching matrix
cells (multi-tag), across source picks (multi-pick), and — by the caller —
across constraints. `exclude` (factor 0) is the absorbing element.
"""
from __future__ import annotations
from typing import Any
EXCLUDE = object() # sentinel: option drops out of the pool
def _apply_rule(rule: dict[str, Any]):
mode = rule.get("mode")
if mode == "exclude":
return EXCLUDE
if mode in ("boost", "reduce"):
try:
return max(0.0, float(rule.get("factor", 1.0)))
except (TypeError, ValueError):
return 1.0
return 1.0 # allow / unknown -> no weight change
def combine_constraint_factor(source_picks, option, matrix, exceptions):
matrix = matrix or {}
exc_by_pair: dict[tuple[str, str], dict] = {}
for e in (exceptions or []):
if not isinstance(e, dict):
continue
s = e.get("source_value")
if s is None:
s = e.get("source")
t = e.get("target_value")
if t is None:
t = e.get("target")
if isinstance(s, str) and isinstance(t, str):
exc_by_pair[(s, t)] = e
opt_value = str(option.get("value", ""))
opt_tags = option.get("tags") or []
factor = 1.0
for pick in (source_picks or []):
p_value = str(pick.get("value", ""))
p_tags = pick.get("tags") or []
exc = exc_by_pair.get((p_value, opt_value))
if exc is not None:
r = _apply_rule(exc)
if r is EXCLUDE:
return EXCLUDE
factor *= r
continue
for s in p_tags:
row = matrix.get(s)
if not isinstance(row, dict):
continue
for t in opt_tags:
rule = row.get(t)
if isinstance(rule, dict):
r = _apply_rule(rule)
if r is EXCLUDE:
return EXCLUDE
factor *= r
return factor
+131 -116
View File
@@ -17,89 +17,10 @@ handler) or narrow ResolveContext-style accessors (ref resolver).
"""
from __future__ import annotations
import re
from typing import Any
from engine.modules.wildcard_handler import _apply_constraint_to_options
# Mirror of `engine.syntax.tokenize._REF_RE` (kept local to avoid
# importing a private symbol across the module boundary). Captures the
# 8-hex uuid; the optional `#name` / `:subcat` segments are skipped.
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}")
def claim_carrier_constraints(
options: list[dict[str, Any]],
constraints: list[dict[str, Any]] | None,
picks: dict[str, dict[str, Any]] | None,
consumed: set[str] | None,
) -> None:
"""First-instance positional-claim failsafe (2026-05-26).
After a wildcard rolls + resolves its chosen option, any *unconsumed*
constraint whose target this wildcard CARRIES — i.e. has an
``@{target}`` ref in ANY of its options, not just the chosen one — is
consumed-as-skipped, provided the constraint's source has already
been picked.
Why: the pair badge assigns a constraint to the FIRST downstream
wildcard that could host its target (direct instance OR nested-ref
carrier). Without this failsafe the runtime only consumed a
constraint when the target *actually* resolved, so a carrier whose
chosen option happened to omit the ref let the constraint "spill"
onto a later target instance — contradicting the badge and
surprising the user. Claiming here binds the constraint to the
carrier positionally: if the carrier's roll skips the ref, the
constraint is spent (no spill) and no never_applied warning fires
for it (the carrier did roll — it just didn't use the ref).
No-op cases:
- chosen option DID resolve the ref → the nested-resolve path
already consumed the constraint; the ``cid in consumed`` guard
skips it here.
- source not yet picked → don't claim (matches the direct-target
apply path, which also defers + warns rather than consuming an
un-appliable constraint). Lets it potentially fire once the
source is available later in the chain.
"""
if not constraints or not isinstance(constraints, list):
return
if consumed is None:
return
if not isinstance(picks, dict):
picks = {}
carried: set[str] = set()
for opt in options:
val = opt.get("value")
if isinstance(val, str):
for m in _REF_RE.finditer(val):
carried.add(m.group(1))
if not carried:
return
# Claim at most ONE constraint per carried target — the first
# unconsumed one in registration order. A carrier hosts a single
# nested resolution per target, so it consumes a single constraint
# for it (matching `apply_constraints_for_target`, which also fires
# one per call). Claiming ALL would let a carrier swallow an entire
# family of same-target constraints, starving a later direct target
# instance that the pair badge assigns to constraint #2.
claimed_targets: set[str] = set()
for c in constraints:
if not isinstance(c, dict):
continue
tgt = c.get("target_wildcard_id")
if tgt not in carried or tgt in claimed_targets:
continue
cid = c.get("__constraint_module_id__")
if cid is None or cid in consumed:
continue
src_id = c.get("source_wildcard_id")
src_pick = picks.get(src_id) if isinstance(src_id, str) else None
if not isinstance(src_pick, dict):
continue
consumed.add(cid)
claimed_targets.add(tgt)
def _push_constraint_warning(
warnings: list[dict[str, Any]],
@@ -126,6 +47,80 @@ def _push_constraint_warning(
})
def _occurrence_matches(pick_entries, firing_uid, carrier_ctx) -> bool:
"""True when an explicit ``pick`` reach selector covers THIS firing
target occurrence.
Two occurrence kinds:
- ``direct`` — a top-level wildcard instance. Matched by its
per-instance uid (``firing_uid``). ``carrier_ctx`` is ``None``
for these (the wildcard handler fires them directly).
- ``nested`` — a ``@{target}`` ref resolved inside a carrier
wildcard's chosen option. Matched by (carrier_uid, option_id).
``carrier_ctx`` carries those, threaded by the nested-resolve
path (``engine.syntax.resolve``). The direct-firing wildcard
handler passes ``carrier_ctx=None`` (it fires top-level
instances, not nested refs), so the two kinds never cross-match.
"""
for p in pick_entries:
if not isinstance(p, dict):
continue
if carrier_ctx is None:
if p.get("kind") == "direct" and p.get("uid") == firing_uid:
return True
else:
if (
p.get("kind") == "nested"
and p.get("carrier_uid") == carrier_ctx.get("carrier_uid")
and p.get("option_id") == carrier_ctx.get("option_id")
):
return True
return False
def _select_source_pick(
picks: dict[str, dict[str, Any]],
src_id: Any,
constraint: dict[str, Any],
) -> dict[str, Any] | None:
"""Resolve the source pick a constraint should re-weight against.
Selection rule (task_5200c1fc — source-instance binding):
1. Look up the top-level entry `picks[src_id]` (today's bucket, the
last-writer-wins survivor when two instances share a library uuid).
2. If the constraint carries a `bundle_origin` AND that entry has a
`by_origin[origin]` view, return that view — the source instance in
the SAME bundle copy as this constraint.
3. Otherwise return the top-level entry as-is = TODAY'S behavior,
exactly. Covers: a no-bundle constraint, a constraint inside a
bundle whose source is OUTSIDE the bundle, and every legacy
workflow. This exact-fallback is what makes the change safe.
DOCUMENTED LIMITATION (task_5200c1fc): the same library wildcard added
MANUALLY twice OUTSIDE any bundle shares no `bundle_origin`, so both
manual instances land in the same top-level bucket and the constraint
falls back to last-writer-wins. The constraint payload genuinely does
not encode WHICH manual instance the user meant, so the design declines
to guess rather than guessing wrong. This is a principled residual, not
a gap; an explicit stored `source_uid` binding (Option 2 in the design)
can be layered on later with precedence explicit > bundle-scope >
fallback, foreclosing nothing.
"""
if not isinstance(src_id, str):
return None
top = picks.get(src_id)
if not isinstance(top, dict):
return None
origin = constraint.get("__constraint_bundle_origin__")
if origin:
by_origin = top.get("by_origin")
if isinstance(by_origin, dict):
scoped = by_origin.get(origin)
if isinstance(scoped, dict):
return scoped
return top
def apply_constraints_for_target(
options: list[dict[str, Any]],
target_uuid: str,
@@ -133,54 +128,60 @@ def apply_constraints_for_target(
picks: dict[str, dict[str, Any]] | None,
warnings: list[dict[str, Any]],
*,
consumed: set[str] | None = None,
consumed=None,
hits=None,
firing_uid=None,
carrier_ctx=None,
) -> tuple[list[dict[str, Any]], bool]:
"""Apply the FIRST unconsumed constraint targeting ``target_uuid``.
"""Apply EVERY constraint whose reach selector covers this firing
target instance, combined sequentially.
Returns ``(adjusted_options, any_applied)``. ``any_applied`` is True
iff a constraint actually fired — callers use it to decide whether
to run the excludes-all check.
iff at least one constraint actually fired — callers use it to
decide whether to run the excludes-all check.
**One-shot semantic (2026-05-24 first-instance redesign).** Each
constraint module is a single ammo round: it fires on the first
downstream target wildcard instance, gets marked consumed via its
``__constraint_module_id__`` key, and is skipped on subsequent
target instances. A second target instance later in the chain
claims the NEXT unconsumed constraint, not the same one again.
**Reach selector (SP3).** Each constraint carries
``target_select = {mode, count?, picks?}`` (default ``{mode: "all"}``).
On every firing target instance the per-constraint hit counter
``hits[cid]`` increments; the selector then decides coverage:
- ``all`` — covers every instance (re-weights every downstream
target occurrence).
- ``first`` — covers only the 1st (``n == 1``).
- ``next`` — covers the first ``count`` (``n <= count``).
- ``pick`` — covers the explicitly-listed occurrences
(``_occurrence_matches``).
Multiple covering constraints stack: their factors multiply in
registration order and ``exclude`` is absorbing. This replaces the
pre-SP3 one-shot / consumed-set model.
Constraints are walked in registration (chain) order so the first
unconsumed match wins. After applying, the function **breaks** out
of the loop — exactly one constraint application per call.
Reach is downstream-relative by construction: a constraint only
appears in ``ctx["__wp_constraints__"]`` after its own module ran,
so target occurrences upstream of it never see it.
``consumed`` is the ctx-resident ``__wp_consumed_constraints__``
set; the caller is expected to thread it from ctx. ``None`` is
accepted only by legacy test paths that pre-date the consume model.
``hits`` is the ctx-resident ``__wp_constraint_hits__`` dict; the
caller threads it from ctx so first/next counters persist across the
run (direct + nested encounters share one counter). ``consumed`` is
accepted but ignored — kept so legacy callers don't break.
Each constraint warns + skips when its source wildcard hasn't been
picked yet — caller code is responsible for ordering source
wildcards before the target's roll. The warning gives the user a
signal in WP_Debug instead of a silent no-op.
See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
"""
if not constraints or not isinstance(constraints, list):
return options, False
if not isinstance(picks, dict):
picks = {}
if consumed is None:
consumed = set()
if hits is None:
hits = {}
any_applied = False
for c in constraints:
if not isinstance(c, dict):
continue
if c.get("target_wildcard_id") != target_uuid:
if not isinstance(c, dict) or c.get("target_wildcard_id") != target_uuid:
continue
cid = c.get("__constraint_module_id__")
if cid is not None and cid in consumed:
continue
src_id = c.get("source_wildcard_id")
src_pick = picks.get(src_id) if isinstance(src_id, str) else None
src_pick = _select_source_pick(picks, src_id, c)
if not isinstance(src_pick, dict):
_push_constraint_warning(
warnings,
@@ -195,14 +196,33 @@ def apply_constraints_for_target(
},
)
continue
adjustment_warnings: list[dict[str, Any]] = []
options = _apply_constraint_to_options(
options, c, src_pick, adjustment_warnings,
)
if cid is not None:
consumed.add(cid)
hits[cid] = hits.get(cid, 0) + 1
n = hits.get(cid, 1)
sel = c.get("target_select") or {"mode": "all"}
mode = sel.get("mode", "all")
if mode == "first":
covered = n == 1
elif mode == "next":
try:
cnt = int(sel.get("count", 1))
except (TypeError, ValueError):
cnt = 1
covered = n <= cnt
elif mode == "pick":
covered = _occurrence_matches(
sel.get("picks") or [], firing_uid, carrier_ctx
)
else:
covered = True
if not covered:
continue
adjust_warnings: list[dict[str, Any]] = []
options = _apply_constraint_to_options(
options, c, src_pick, adjust_warnings,
)
any_applied = True
for w in adjustment_warnings:
for w in adjust_warnings:
_push_constraint_warning(
warnings,
type_=w.get("type", "unknown_constraint_mode"),
@@ -213,11 +233,6 @@ def apply_constraints_for_target(
"target_wildcard_id": target_uuid,
},
)
# Key change for first-instance semantic: one constraint per
# target-instance call. Subsequent target instances re-enter
# this function and pick up the NEXT unconsumed constraint.
break
return options, any_applied
+10 -1
View File
@@ -101,7 +101,16 @@ class CombineHandler(ModuleHandler):
# - chain seed otherwise → re-rolls each queue
# Both feed derive_module_rng(seed, output_var) so locking with
# locked_seed = chain_seed reproduces the unlocked roll exactly.
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
if instance.get("seed_scope") == "hold":
# Held: reuse the frame-0 resolved value verbatim (from the
# pipeline's base pass) so a held combine's alternation stays
# frozen across the whole run, nested refs included.
_hb = ctx.get("__wp_hold_base_ctx__")
if isinstance(_hb, dict) and output_var in _hb:
return {output_var: _hb[output_var]}
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
else:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if isinstance(locked_seed, (int, float)):
effective_seed = int(locked_seed)
+93 -16
View File
@@ -24,6 +24,55 @@ from engine.modules.dispatcher import ModuleHandler
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
# SP3 reach selector — which downstream target instance(s) a constraint
# reaches. Shape only here; reach *behaviour* is applied by a later task.
_REACH_MODES = {"first", "next", "all", "pick"}
def _validate_target_select(ts):
"""Validate the optional ``target_select`` reach selector.
Shape: ``{mode: "first"|"next"|"all"|"pick", count?, picks?}``. ``None``
(absent) is allowed — the resolver defaults it to ``{mode: "all"}``.
"""
if ts is None:
return
if not isinstance(ts, dict):
raise ValueError("constraint payload.target_select must be an object")
mode = ts.get("mode", "all")
if mode not in _REACH_MODES:
raise ValueError(
f"constraint payload.target_select.mode must be one of {sorted(_REACH_MODES)}"
)
if mode == "next":
count = ts.get("count")
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
raise ValueError(
"constraint payload.target_select.count must be an int >= 1 for mode 'next'"
)
if mode == "pick":
picks = ts.get("picks")
if not isinstance(picks, list):
raise ValueError(
"constraint payload.target_select.picks must be a list for mode 'pick'"
)
for i, p in enumerate(picks):
if not isinstance(p, dict) or p.get("kind") not in ("direct", "nested"):
raise ValueError(
f"constraint payload.target_select.picks[{i}].kind must be 'direct' or 'nested'"
)
if p["kind"] == "direct" and not isinstance(p.get("uid"), str):
raise ValueError(
f"constraint payload.target_select.picks[{i}].uid must be a string"
)
if p["kind"] == "nested" and not (
isinstance(p.get("carrier_uid"), str) and isinstance(p.get("option_id"), str)
):
raise ValueError(
f"constraint payload.target_select.picks[{i}] nested "
"needs carrier_uid + option_id"
)
def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
"""Append ``meta`` to ``ctx['__wp_constraints__']`` (best-effort).
@@ -185,6 +234,7 @@ class ConstraintHandler(ModuleHandler):
raise ValueError(
f"constraint payload.exceptions[{i}].factor must not be negative"
)
_validate_target_select(payload.get("target_select"))
@classmethod
def resolve(
@@ -208,6 +258,16 @@ class ConstraintHandler(ModuleHandler):
exception_factor_overrides = instance.get("exception_factor_overrides") or {}
extra_exceptions = instance.get("extra_exceptions") or []
# SP3 reach selector — instance override wins over the payload's
# value, falling back to the {mode:"all"} default. Shape was
# already validated in validate_payload (payload side); the
# instance side is validated when the instance is loaded.
ts = (
instance.get("target_select")
or payload.get("target_select")
or {"mode": "all"}
)
# ── Matrix: filter then override ─────────────────────────────
raw_matrix = payload.get("matrix", {})
matrix: dict[str, Any] = {}
@@ -353,20 +413,22 @@ class ConstraintHandler(ModuleHandler):
exceptions.append(dict(exc))
# Carry the owning constraint module's id into the registered
# bucket entry so `apply_constraints_for_target` can mark it
# consumed once its first downstream target instance fires
# (one-shot semantic per 2026-05-24 first-instance spec).
# bucket entry as `__constraint_module_id__` (the `cid`). Under the
# SP3 reach model it identifies the constraint for two things:
# (1) the per-constraint hit counter `__wp_constraint_hits__[cid]`
# that drives first / next / pick coverage in
# `apply_constraints_for_target`, and (2) the dedup key + ref of
# the never-applied / partial-reach finalisation warnings.
#
# Key on the PER-INSTANCE uid (`__wp_current_module_uid__`), not
# the library uuid (`__wp_current_module_id__`): two instances of
# the same library constraint entry must be INDEPENDENT one-shots
# the same library constraint entry must reach INDEPENDENTLY
# (CLAUDE.md — "author multiple constraint modules" to affect
# multiple target instances). Pre-2026-05-26 both keyed on the
# shared library uuid, so claiming/consuming one silently spent
# the other — a carrier could swallow the whole family and a
# downstream direct target then rolled unconstrained. The pipeline
# falls back to the library id when `_uid` is absent (legacy /
# hand-built test modules), preserving the old behaviour there.
# shared library uuid, so the two instances' counters collided —
# one instance's encounters advanced the other's first/next
# selector. The pipeline falls back to the library id when `_uid`
# is absent (legacy / hand-built test modules).
module_id = None
try:
if hasattr(ctx, "get"):
@@ -376,27 +438,33 @@ class ConstraintHandler(ModuleHandler):
)
except Exception:
module_id = None
# The per-instance `_uid` (12+ chars) keys the consumed-set so two
# canvas instances of the same library constraint stay independent
# one-shots. The LIBRARY id (8-hex) is also stashed because the
# never-applied warning text needs it to render the chip — the SPA
# / Debug viewer's chip resolver looks up library uuids in the
# module catalog, and a per-instance `_uid` would never resolve
# (silently rendering as raw `@{…}` text in the warning).
# The per-instance `_uid` (12+ chars) keys the per-constraint hit
# counter so two canvas instances of the same library constraint
# reach independently. The LIBRARY id (8-hex) is also stashed
# because the never-applied / partial-reach warning text needs it
# to render the chip — the SPA / Debug viewer's chip resolver looks
# up library uuids in the module catalog, and a per-instance `_uid`
# would never resolve (silently rendering as raw `@{…}` text in the
# warning).
library_id = None
library_name = None
bundle_origin = None
try:
if hasattr(ctx, "get"):
library_id = ctx.get("__wp_current_module_id__")
library_name = ctx.get("__wp_current_module_name__")
bundle_origin = ctx.get("__wp_current_module_bundle_origin__")
except Exception:
library_id = None
library_name = None
bundle_origin = None
meta = {
"source_wildcard_id": payload["source_wildcard_id"],
"target_wildcard_id": payload["target_wildcard_id"],
"matrix": matrix,
"exceptions": exceptions,
# SP3 reach selector — recorded for the (later) apply pass.
"target_select": ts,
"__constraint_module_id__": module_id,
"__constraint_library_id__": library_id,
# Cached display name (e.g. "Starter pairing"). The never-applied
@@ -407,6 +475,15 @@ class ConstraintHandler(ModuleHandler):
# endpoint would 404 there, leaving the chip stuck on the raw
# uuid otherwise.
"__constraint_library_name__": library_name,
# Per-insertion bundle discriminator (task_5200c1fc). When this
# constraint instance lives inside a bundle copy, it shares one
# `bundle_origin` with the source wildcard instance in the SAME
# copy. `apply_constraints_for_target` uses it to read that
# copy's source pick (`by_origin[origin]`) rather than the
# last-writer survivor. Falsy (None) for a top-level / manual /
# legacy constraint — then apply falls back to today's
# last-writer-wins lookup, unchanged.
"__constraint_bundle_origin__": bundle_origin,
}
_ctx_set_constraint(ctx, meta)
return {}
+101 -15
View File
@@ -19,6 +19,7 @@ from engine.modules import build_resolve_ctx
from engine.modules._seed import derive_module_rng
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
from engine.syntax.types import deref_var_value, split_var_accessor
_VALID_OPS = {
"equals", "not_equals", "contains", "matches",
@@ -33,25 +34,34 @@ _VALID_OPS = {
_VALID_MODES = {"replace", "append", "prepend"}
def _ctx_get(ctx: Any, name: str) -> str:
def _ctx_get_raw(ctx: Any, name: str) -> Any:
"""Read the RAW stored value for `name` (may be a ListVar) through the
callable-getter -> dict-lookup indirection. Missing / error -> None."""
if ctx is None:
return ""
return None
getter = getattr(ctx, "get", None)
if callable(getter):
try:
value = getter(name, "")
return getter(name, None)
except TypeError:
try:
value = getter(name)
return getter(name)
except Exception:
return ""
return "" if value is None else str(value)
return None
try:
if name in ctx: # type: ignore[operator]
return str(ctx[name]) # type: ignore[index]
return ctx[name] # type: ignore[index]
except Exception:
return ""
return ""
return None
return None
def _ctx_get(ctx: Any, name: str) -> str:
"""Read `name` as a string, honoring a `.K` list accessor and folding a
ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` +
index 0. The fold/accessor contract lives in deref_var_value."""
base, index = split_var_accessor(name)
return deref_var_value(_ctx_get_raw(ctx, base), index)
def _ctx_has(ctx: Any, name: str) -> bool:
@@ -66,6 +76,7 @@ def _ctx_has(ctx: Any, name: str) -> bool:
"""
if ctx is None:
return False
name = split_var_accessor(name)[0] # SP2a: presence checks the base var
# Engine ctx is a dict in tests + at runtime; check `__contains__`
# before falling back to a getter probe so we get the cheap path.
try:
@@ -91,6 +102,7 @@ def _ctx_has(ctx: Any, name: str) -> bool:
def _ctx_set(ctx: Any, name: str, value: str) -> None:
if ctx is None:
return
name = split_var_accessor(name)[0] # SP2a: writing $x.K targets the base var
setter = getattr(ctx, "set", None)
if callable(setter):
setter(name, value)
@@ -132,17 +144,48 @@ def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
return False
def branch_carrier_key(rule_id: str, branch: int | str) -> str:
"""Carrier key for a derivation branch occurrence -- the `option_id` the
SP3 nested-occurrence model matches a constraint `pick` against. `branch`
is the 0-based branch index (0 = IF) or the literal ``"else"``.
MUST stay byte-identical to the TS twin ``branchKey`` in
``src/extension/constraint-pairs.ts``; both are corpus-locked against
``tests/fixtures/constraint-corpus.json`` ``branch_key_cases``.
"""
return f"{rule_id}:{branch}"
def _apply_action(
action: dict[str, Any],
ctx: Any,
resolve_ctx: Any,
carrier_key: str | None = None,
) -> tuple[str, str] | None:
target = action.get("target_var", "")
if not target:
return None
mode = action.get("mode", "replace")
raw_value = str(action.get("value", ""))
new_value = resolve_text(raw_value, resolve_ctx)
# Stamp this derivation instance as the CARRIER of any `@{}` in the action
# value, keyed by the branch key (`${rule_id}:${bi}` / `${rule_id}:else`) —
# the `option_id` the SP3 nested-occurrence model matches a `pick` against.
# Save/restore around the resolve, mirroring the resolver's own carrier
# save/restore for nested recursion.
set_carrier = getattr(resolve_ctx, "set_carrier", None)
get_carrier = getattr(resolve_ctx, "get_carrier", None)
if carrier_key is not None and callable(set_carrier) and callable(get_carrier):
module_uid = None
if isinstance(ctx, dict):
module_uid = ctx.get("__wp_current_module_uid__") or ctx.get("__wp_current_module_id__")
prev = get_carrier()
set_carrier(module_uid, carrier_key)
try:
new_value = resolve_text(raw_value, resolve_ctx)
finally:
set_carrier(*prev)
else:
new_value = resolve_text(raw_value, resolve_ctx)
if mode == "replace":
result = new_value
elif mode == "append":
@@ -252,8 +295,30 @@ class DerivationHandler(ModuleHandler):
# action.value across runs; without lock the chain seed
# propagates per-queue. Falls back to the host ctx's RNG when
# the ctx wasn't built with `__wp_rng__` (legacy callers).
# seed_scope == "hold" freezes the DICE per branch, NOT which branch
# fires. The conditions still pick a branch live each iteration, so a
# varying input can switch IF→ELIF and we use THAT branch — but every
# branch's inline {a|b} / @{} roll is frozen to its frame-0 value via
# the constant hold seed, keyed PER BRANCH (see _branch_resolve_ctx).
# So each branch's value is identical every iteration it fires and
# equals the value it WOULD have had at frame 0, independent of which
# other branches fired. (Contrast wildcard/combine/fixed_values hold,
# which freeze the whole resolved VALUE because a constraint can
# reshape their pool; a derivation branch's pool is fixed, so a frozen
# seed alone is enough — and freezing the branch SELECTION would be
# wrong: the user wants the chosen branch to follow the inputs.)
_hold = False
effective_seed = 0
module_id = "derivation"
ctx_local: dict[str, Any] = {}
if isinstance(ctx, dict) and "__wp_rng__" in ctx:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
_hold = instance.get("seed_scope") == "hold"
if _hold:
chain_seed = int(
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
)
else:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
effective_seed = (
int(locked_seed) if isinstance(locked_seed, (int, float))
@@ -266,6 +331,19 @@ class DerivationHandler(ModuleHandler):
else:
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
def _branch_resolve_ctx(branch_key: str) -> Any:
# Non-hold (or no rng): the shared rng stream — existing
# behaviour, branch values draw in fire order. Hold: an
# INDEPENDENT rng per branch (keyed by rule:branch) so its value
# is frozen to its frame-0 roll regardless of which other
# branches drew before it in the shared stream.
if not _hold:
return resolve_ctx
rng_b = derive_module_rng(effective_seed, f"{module_id}::{branch_key}")
return build_resolve_ctx(
{**ctx_local, "__wp_rng__": rng_b}, surface="derivation"
)
# Tier-D instance overrides (2026-05-10 cycle).
disabled_rule_ids = set(instance.get("disabled_rule_ids") or [])
disabled_branch_keys = set(instance.get("disabled_branch_keys") or [])
@@ -296,7 +374,7 @@ class DerivationHandler(ModuleHandler):
# Branch-level disable — `r1:1` etc. IF (bi=0) ignored
# even when listed because disabling IF == disabling rule
# (the per-rule toggle handles that case cleanly).
if bi != 0 and f"{rule_id}:{bi}" in disabled_branch_keys:
if bi != 0 and branch_carrier_key(rule_id, bi) in disabled_branch_keys:
continue
# Condition-value override per branch index.
@@ -320,7 +398,11 @@ class DerivationHandler(ModuleHandler):
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(action, ctx, resolve_ctx)
pair = _apply_action(
action, ctx,
_branch_resolve_ctx(branch_carrier_key(rule_id, bi)),
carrier_key=branch_carrier_key(rule_id, bi),
)
if pair is not None:
out[pair[0]] = pair[1]
applied = True
@@ -328,7 +410,7 @@ class DerivationHandler(ModuleHandler):
if not applied:
# ELSE skip when listed in disabled_branch_keys.
if f"{rule_id}:else" in disabled_branch_keys:
if branch_carrier_key(rule_id, "else") in disabled_branch_keys:
continue
else_clause = rule.get("else")
if isinstance(else_clause, dict):
@@ -341,7 +423,11 @@ class DerivationHandler(ModuleHandler):
)
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(action, ctx, resolve_ctx)
pair = _apply_action(
action, ctx,
_branch_resolve_ctx(branch_carrier_key(rule_id, "else")),
carrier_key=branch_carrier_key(rule_id, "else"),
)
if pair is not None:
out[pair[0]] = pair[1]
return out
+14 -1
View File
@@ -150,7 +150,20 @@ class FixedValuesHandler(ModuleHandler):
return out
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
if instance.get("seed_scope") == "hold":
# Held: reuse each name's frame-0 resolved value verbatim (from the
# pipeline's base pass) so a held fixed-values alternation stays
# frozen across the whole run.
_hb = ctx.get("__wp_hold_base_ctx__")
if isinstance(_hb, dict):
_names = [n for n in ((v.get("name") or "").strip() for v in values) if n]
if _names and all(n in _hb for n in _names):
return {n: _hb[n] for n in _names}
chain_seed = int(
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
)
else:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if isinstance(locked_seed, (int, float)):
effective_seed = int(locked_seed)
+46 -2
View File
@@ -70,6 +70,15 @@ def _fresh_instance() -> dict[str, Any]:
"enabled_options": None,
"category_filter": None,
"option_weights": None,
# SP2a multi-select + the null toggle — part of the pool-shaping
# override set, so a frozen snapshot carries them too (all None =
# "no override"). Keeps this dict honest with the "every field" claim
# above + the instance schema (engine/schemas/instance_schemas.py).
"exclude_null": None,
"pick_min": None,
"pick_max": None,
"pick_separator": None,
"pick_independent": None, # SP2c: multi-pick with replacement when true
# Pick mode — `None`/missing → `random` (legacy behavior:
# weighted RNG over enabled options). `pinned` short-circuits
# the RNG and always picks `pinned_option_id`. `subcategory`
@@ -202,7 +211,7 @@ class WalkResult:
walk_overflow: list[WalkOverflow] = field(default_factory=list)
def _extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
def extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
"""Collect all ``@{8hex}`` refs referenced inside a payload's
string-typed leaves. Uses ``tokenize_text`` exclusively — only
canonical 8-hex-char refs (spec §2.4) are followed.
@@ -251,6 +260,41 @@ def _row_to_snapshot_entry(
}
def ref_seed_uuids(modules: Iterable[Any] | None) -> set[str]:
"""Every ``@{uuid}`` target referenced by ANY module in a node's list.
A catalog is synthesised from wildcard modules only, and
``walk_transitive_refs`` follows refs found in wildcard payloads only. That
covers a ref authored inside a wildcard option, but ``@{uuid}`` is legal on
other surfaces too — most notably a derivation rule's action value, and the
per-instance ``action_value_overrides`` / ``condition_value_overrides`` the
edit modal writes when a user retargets a branch on the canvas.
Refs on those surfaces were never discovered, so their target reached the
catalog only by coincidence — when the user happened to ALSO have that
wildcard picked in the same node. Removing the wildcard from the node (a
reasonable thing to do once a derivation references it by uuid) dropped it
with nothing to put it back, and every such ref resolved to ``unknown_ref``
→ empty string no matter what the live library held.
Seeds therefore come from each module's ``payload`` AND its ``instance``:
the instance is where a canvas-side retarget lives, and it is not part of
the library payload the walker would otherwise see.
Tolerant of malformed rows — a corrupt widget value must never break a
graph run.
"""
seeds: set[str] = set()
for module in modules or []:
if not isinstance(module, dict):
continue
for key in ("payload", "instance"):
value = module.get(key)
if isinstance(value, dict):
seeds |= extract_at_uuid_refs(value)
return seeds
def walk_transitive_refs(
root_uuids: Iterable[str],
*,
@@ -338,7 +382,7 @@ def walk_transitive_refs(
continue
new_ancestors = ancestors | {uuid}
for child_uuid in _extract_at_uuid_refs(row["payload"]):
for child_uuid in extract_at_uuid_refs(row["payload"]):
queue.append((child_uuid, depth + 1, uuid, new_ancestors))
# After walk: re-stamp every dep's parent list (BFS may have accumulated
+438 -183
View File
@@ -24,25 +24,40 @@ from engine.modules import build_resolve_ctx
from engine.modules._seed import derive_module_rng as _derive_module_rng
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
from engine.syntax.resolve import pick_k_unique
from engine.syntax.subcat_filter import (
matches as _subcat_matches,
)
from engine.syntax.subcat_filter import (
parse as _parse_subcat,
)
from engine.syntax.subcat_filter import (
validate_subcat_name,
)
from engine.syntax.types import ListVar
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
_MAX_IDENT_LEN = 64
# Characters reserved by the nested-ref grammar `@{uuid[#name][:subcat,subcat...]}`.
# Forbidden in:
# - wildcard `meta.name` / library `name` (the `#name` segment)
# - wildcard `payload.sub_categories[*]` (the `:subcat` segment)
# Comma is in here because subcat lists are comma-separated; the other
# chars are structural delimiters of the ref grammar. Mirror in
# `src/manager/utils/validateName.ts` so the editor + API agree.
_REF_FORBIDDEN_CHARS = frozenset("{}:#@,")
# `_derive_module_rng` lifted to engine/modules/_seed.py so combine +
# fixed_values handlers share the same helper (Phase: combine v2 +
# syntax parity cycle). The private alias preserves the existing call
# site at line ~376 unchanged.
def _coerce_pick_int(value: Any, default: int) -> int:
"""SP2a: coerce a pick-count field (`pick_min`/`pick_max`) to int, falling
back to `default` on junk. The engine is reachable by hand-built and legacy
instances, so a non-numeric value must degrade to single-pick rather than
raise out of resolve() — mirrors the locked_seed coercion guard."""
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
"""Pick one option weighted by `weight`. Uses rng (no module-global random)."""
if not options:
@@ -66,121 +81,289 @@ def _apply_constraint_to_options(
adjustment_warnings: list[dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""Adjust option weights according to a single constraint, given the
already-picked source option.
already-picked source option(s).
Two layers, in order:
1. Matrix lookup keyed by `(source.sub_category, target.sub_category)`.
Bulk rule expressing "long hair allows positive moods" etc.
2. Exception lookup keyed by the literal `(source.value, target.value)`
pair. Overrides the matrix cell when present — narrower rule wins.
SP3: weight adjustment is delegated to
:func:`engine.modules._constraint_math.combine_constraint_factor`, which
multiplies one factor per matching matrix cell (an option on N source-tag
rows multiplies N times — true multi-tag) across every source pick (true
multi-pick). The exception layer (literal `(source_value, target_value)`
pair) still overrides the matrix for that pick, and `exclude` is absorbing
(factor 0 → weight 0, option drops out).
Modes:
- ``allow`` → no change to weight (factor honoured but typically 1).
- ``exclude`` → weight set to 0 (option drops out of the pool).
- ``boost`` → multiply weight by factor (expect factor > 1).
- ``reduce`` → multiply weight by factor (expect factor < 1).
`source_pick["picks"]` is the per-pick list (`[{value, tags}]`) recorded by
`_record_pick` / `_record_pick_multi`. Legacy callers that hand-build a
source dict without it fall back to a single synthetic pick from the
top-level `value` + `sub_categories`.
Options not covered by either matrix or exception keep their
original weight. Returns shallow-copied list — the input options
array is never mutated, so the underlying snapshot stays clean
across runs.
Returns a shallow-copied list — the input options array is never mutated,
so the underlying snapshot stays clean across runs.
`adjustment_warnings` (optional) collects editor-footgun warnings the
caller forwards to the chain warning list:
- ``constraint_factor_ignored_on_allow`` — a `mode: allow` cell carries a
non-1 factor (user expects weight×factor, engine ignores it).
- ``unknown_constraint_mode`` — a typo'd mode (`exlcude`/`alllow`) the
combine fn treats as a no-op.
These are diagnostic only; they never change the computed weight.
"""
from engine.modules._constraint_math import EXCLUDE, combine_constraint_factor
matrix = constraint.get("matrix") or {}
exceptions = constraint.get("exceptions") or []
src_value = str(source_pick.get("value", ""))
src_sub = source_pick.get("sub_category")
# Index exceptions by (source_value, target_value) for O(1) lookup.
# Empty string is a valid key — it's the null-option marker (a
# wildcard option with `is_null: True` has `value: ""`). So
# `{source: "rain", target: ""}` excludes the null option when the
# source rolls "rain". The validator allows empty strings on these
# fields for the same reason; only fully-missing keys (None on both
# legacy + tier-2 names) are rejected upstream.
exc_by_pair: dict[tuple[str, str], dict[str, Any]] = {}
for exc in exceptions:
if not isinstance(exc, dict):
continue
s = exc.get("source")
t = exc.get("target")
if isinstance(s, str) and isinstance(t, str):
exc_by_pair[(s, t)] = exc
# Per-pick source list (SP3). Fall back to a single synthetic pick for
# legacy callers that pass only the top-level value + sub_categories.
picks = source_pick.get("picks")
if not isinstance(picks, list):
picks = [{
"value": source_pick.get("value", ""),
"tags": list(source_pick.get("sub_categories") or []),
}]
matrix_row = matrix.get(src_sub) if src_sub else None
# Pre-scan for diagnostic warnings only. The combine fn intentionally
# treats `allow` and unknown modes as factor 1.0 (no weight change), so it
# can't surface them — mirror its (pick × option) rule selection here,
# exception-pair winning over matrix cell, and warn where the selected rule
# is an ignored-factor `allow` or an unrecognised mode.
if adjustment_warnings is not None:
_scan_constraint_warnings(
picks, options, matrix, exceptions, adjustment_warnings,
)
adjusted: list[dict[str, Any]] = []
for opt in options:
opt_value = str(opt.get("value", ""))
opt_sub = opt.get("sub_category")
option = {"value": opt.get("value", ""), "tags": opt.get("sub_categories") or []}
f = combine_constraint_factor(picks, option, matrix, exceptions)
weight = float(opt.get("weight", 1))
# Exception layer (specific value pair) wins over matrix layer.
rule = exc_by_pair.get((src_value, opt_value))
if rule is None and isinstance(matrix_row, dict) and opt_sub is not None:
rule = matrix_row.get(opt_sub)
if isinstance(rule, dict):
mode = rule.get("mode")
try:
factor = float(rule.get("factor", 1.0))
except (TypeError, ValueError):
factor = 1.0
if mode == "exclude":
weight = 0.0
elif mode == "boost" or mode == "reduce":
weight = max(0.0, weight * factor)
elif mode == "allow":
# `allow` is a no-op weight-wise; surface a warning when
# the SPA stored a non-1 factor on it because that's
# almost certainly a user expecting weight*factor and
# silently getting full weight.
if factor != 1.0:
out_warns = adjustment_warnings if adjustment_warnings is not None else None
if out_warns is not None:
out_warns.append({
"type": "constraint_factor_ignored_on_allow",
"factor": factor,
"src_value": src_value,
"opt_value": opt_value,
})
else:
# Unknown mode — typo like "exlcude" / "alllow". Silent
# no-op was the worst-of-both: weight unchanged + zero
# signal that the rule was malformed. Bubble it up so
# the caller can emit a `unknown_constraint_mode` warning.
out_warns = adjustment_warnings if adjustment_warnings is not None else None
if out_warns is not None:
out_warns.append({
"type": "unknown_constraint_mode",
"mode": mode,
"src_value": src_value,
"opt_value": opt_value,
})
weight = 0.0 if f is EXCLUDE else max(0.0, weight * float(f))
adjusted.append({**opt, "weight": weight})
return adjusted
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce"})
def _exc_pair_index(exceptions: list[Any]) -> dict[tuple[str, str], dict[str, Any]]:
"""Index exceptions by (source_value, target_value), mirroring the
combine fn's key fallback (tier-2 `source_value`/`target_value` names,
then legacy `source`/`target`). Empty string is a valid key — it's the
null-option marker (`is_null` options carry `value: ""`)."""
out: dict[tuple[str, str], dict[str, Any]] = {}
for exc in exceptions or []:
if not isinstance(exc, dict):
continue
s = exc.get("source_value")
if s is None:
s = exc.get("source")
t = exc.get("target_value")
if t is None:
t = exc.get("target")
if isinstance(s, str) and isinstance(t, str):
out[(s, t)] = exc
return out
def _scan_constraint_warnings(
picks: list[dict[str, Any]],
options: list[dict[str, Any]],
matrix: dict[str, Any],
exceptions: list[Any],
adjustment_warnings: list[dict[str, Any]],
) -> None:
"""Append `constraint_factor_ignored_on_allow` / `unknown_constraint_mode`
diagnostics for the rules that fire (per pick × option). Selection order
matches `combine_constraint_factor`: an exception on the literal value pair
wins over a matrix cell, and only the matched rule is inspected."""
exc_by_pair = _exc_pair_index(exceptions)
for pick in picks:
p_value = str(pick.get("value", ""))
p_tags = pick.get("tags") or []
for opt in options:
opt_value = str(opt.get("value", ""))
opt_tags = opt.get("sub_categories") or []
exc = exc_by_pair.get((p_value, opt_value))
if exc is not None:
_warn_for_rule(exc, p_value, opt_value, adjustment_warnings)
continue
for s in p_tags:
row = matrix.get(s)
if not isinstance(row, dict):
continue
for t in opt_tags:
rule = row.get(t)
if isinstance(rule, dict):
_warn_for_rule(rule, p_value, opt_value, adjustment_warnings)
def _warn_for_rule(
rule: dict[str, Any],
src_value: str,
opt_value: str,
adjustment_warnings: list[dict[str, Any]],
) -> None:
mode = rule.get("mode")
if mode == "allow":
# `allow` is a weight no-op; a non-1 factor is almost certainly a
# user expecting weight×factor and silently getting full weight.
try:
factor = float(rule.get("factor", 1.0))
except (TypeError, ValueError):
factor = 1.0
if factor != 1.0:
adjustment_warnings.append({
"type": "constraint_factor_ignored_on_allow",
"factor": factor,
"src_value": src_value,
"opt_value": opt_value,
})
elif mode not in _KNOWN_CONSTRAINT_MODES:
# Typo like "exlcude" / "alllow". The combine fn no-ops it; bubble up
# so the caller can surface `unknown_constraint_mode`.
adjustment_warnings.append({
"type": "unknown_constraint_mode",
"mode": mode,
"src_value": src_value,
"opt_value": opt_value,
})
def _file_pick_by_origin(
ctx: Any,
entry: dict[str, Any],
bucket: dict[str, Any],
module_id: Any,
) -> None:
"""Attach `entry` under `bucket[module_id]["by_origin"][origin]` when the
active module carries a `bundle_origin` (stamped by pipeline.py). This is
the per-instance source-pick view a constraint in the same bundle copy
reads (task_5200c1fc); the top-level `bucket[module_id]` stays the
last-writer-wins fallback. No-op when no origin is stamped, so a
top-level / manual / legacy module's pick entry is byte-for-byte today's
shape. The `by_origin` map is carried FORWARD across re-records of the
same module_id so two origins writing the same library uuid each keep
their own view (the 2nd record must not drop the 1st origin's bucket).
"""
origin = ctx.get("__wp_current_module_bundle_origin__") if ctx is not None else None
if not origin:
return
prev = bucket.get(module_id)
by_origin = {}
if isinstance(prev, dict) and isinstance(prev.get("by_origin"), dict):
by_origin = dict(prev["by_origin"])
by_origin[origin] = {k: v for k, v in entry.items() if k != "by_origin"}
entry["by_origin"] = by_origin
def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
"""Stash the picked option dict in `ctx["__wp_picks__"][module_id]` so a
downstream constraint-aware wildcard can look up its source's value +
sub_category. Keyed by the active module id (set by pipeline.py).
sub_categories. Keyed by the active module id (set by pipeline.py).
"""
# KNOWN COLLISION (task_5200c1fc): module_id is the LIBRARY uuid
# (pipeline.py stamps __wp_current_module_id__ = library id). Two
# instances of one library wildcard (bundle inserted twice, or the same
# wildcard added twice) share this bucket key — the 2nd pick clobbers the
# 1st, and every constraint sourcing that uuid reads the survivor. Fix =
# key on the per-instance _uid + give constraints a source-INSTANCE bind.
# Do NOT "fix" by regenerating ids on insert: that severs drift/refresh-
# by-id lineage (see the remapBundleUuids dead-code note + task_5200c1fc).
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
if not module_id:
return
bucket = ctx.setdefault("__wp_picks__", {})
if isinstance(bucket, dict):
bucket[module_id] = {
entry = {
"value": chosen.get("value"),
"sub_category": chosen.get("sub_category"),
"sub_categories": chosen.get("sub_categories", []),
"id": chosen.get("id"),
# SP3: per-pick list the combine fn reads. A single pick is a
# one-element list so the multi-tag/multi-pick applier has a
# uniform shape regardless of single- vs multi-select source.
"picks": [{
"value": chosen.get("value", ""),
"tags": list(chosen.get("sub_categories") or []),
}],
}
# Additive per-instance view (task_5200c1fc). When this wildcard
# carries a `bundle_origin`, ALSO file the pick under
# `by_origin[origin]` so a constraint in the SAME bundle copy can
# read its own source instance instead of the last-writer survivor
# at the top-level key. The top-level entry above is left as the
# fallback bucket (unchanged shape) — anything reading it is
# unaffected. Omitted entirely when no origin is stamped.
_file_pick_by_origin(ctx, entry, bucket, module_id)
bucket[module_id] = entry
def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) -> None:
"""Multi-select counterpart to `_record_pick` (SP2a). Stash the joined
value + the individual picked values + the union of their sub-categories,
so the debug Picks view can render the list. Single-pick records (via
`_record_pick`) keep their original shape."""
# Same library-uuid bucket collision as `_record_pick` (see task_5200c1fc).
module_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
if not module_id:
return
bucket = ctx.setdefault("__wp_picks__", {})
if not isinstance(bucket, dict):
return
values = [str(c.get("value", "")) for c in chosen_list]
union: list[str] = []
for c in chosen_list:
for t in (c.get("sub_categories") or []):
if t not in union:
union.append(t)
# SP3: per-pick list (value + that pick's own tags, NOT the union) so the
# combine fn can apply the matrix per pick. The union above stays for the
# debug Picks view; `picks` is what constraint application reads.
per_pick = [
{"value": str(c.get("value", "")), "tags": list(c.get("sub_categories") or [])}
for c in chosen_list
]
entry = {
"value": sep.join(values),
"values": values,
"sub_categories": union,
"id": chosen_list[0].get("id") if chosen_list else None,
"picks": per_pick,
}
# Same per-instance view as `_record_pick` (task_5200c1fc).
_file_pick_by_origin(ctx, entry, bucket, module_id)
bucket[module_id] = entry
class WildcardHandler(ModuleHandler):
type_id = "wildcard"
@staticmethod
def _apply_pool_filter(
options: list[dict[str, Any]],
expr: str,
*,
exclude_null: bool,
) -> list[dict[str, Any]]:
"""Narrow an option pool by a boolean sub-category expression.
`expr` is parsed via the shared subcat_filter matcher and tested
against each non-null option's tag set (`sub_categories`). Empty
/ whitespace `expr` ⇒ no tag filtering. The null option
(`is_null`) is governed solely by `exclude_null`; the expression
never applies to it (spec §3.4).
"""
ast = _parse_subcat(expr) if isinstance(expr, str) else None
out: list[dict[str, Any]] = []
for o in options:
if o.get("is_null"):
if not exclude_null:
out.append(o)
continue
if _subcat_matches(ast, set(o.get("sub_categories") or [])):
out.append(o)
return out
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
@@ -188,6 +371,56 @@ class WildcardHandler(ModuleHandler):
options = payload.get("options")
if not isinstance(options, list):
raise ValueError("wildcard payload.options must be a list")
# Sub-category registry — the allowed tag names. Validated first
# so each option's membership + tag_groups can be checked against
# it. `validate_subcat_name` enforces the §3.6 name rules (single
# token; no whitespace / boolean-grammar / ref chars; not a
# reserved word incl. `null`).
registry_list = payload.get("sub_categories", [])
if not isinstance(registry_list, list):
raise ValueError("wildcard payload.sub_categories must be a list")
registry: set[str] = set()
for i, sc in enumerate(registry_list):
if not isinstance(sc, str):
raise ValueError(
f"wildcard payload.sub_categories[{i}] must be a string"
)
name_err = validate_subcat_name(sc)
if name_err:
raise ValueError(
f"wildcard payload.sub_categories[{i}]: {name_err}"
)
registry.add(sc)
# Optional UI-only grouping of registry tags into named axes. The
# engine ignores it at resolve time; validate the shape, that
# every member is a registry tag, and that a tag lives in at most
# one group.
tag_groups = payload.get("tag_groups")
if tag_groups is not None:
if not isinstance(tag_groups, dict):
raise ValueError("wildcard payload.tag_groups must be an object")
seen_grouped: set[str] = set()
for gname, members in tag_groups.items():
if not isinstance(gname, str) or not isinstance(members, list):
raise ValueError(
"wildcard payload.tag_groups entries must be "
"name -> string[]"
)
for mem in members:
if mem not in registry:
raise ValueError(
f"wildcard payload.tag_groups[{gname!r}]: {mem!r} "
f"not in sub_categories registry"
)
if mem in seen_grouped:
raise ValueError(
f"wildcard payload.tag_groups: {mem!r} appears in "
f"more than one group"
)
seen_grouped.add(mem)
seen_ids: set[str] = set()
null_count = 0
for i, opt in enumerate(options):
@@ -220,12 +453,12 @@ class WildcardHandler(ModuleHandler):
f"wildcard payload.options[{i}] null option "
f"{opt_id!r} must have empty value (got {value!r})"
)
sub_null = opt.get("sub_category")
if sub_null is not None and sub_null != "":
null_subs = opt.get("sub_categories", [])
if null_subs:
raise ValueError(
f"wildcard payload.options[{i}] null option "
f"{opt_id!r} must have no sub_category (got "
f"{sub_null!r})"
f"{opt_id!r} must have no sub_categories (got "
f"{null_subs!r})"
)
else:
if not isinstance(value, str) or value == "":
@@ -242,11 +475,22 @@ class WildcardHandler(ModuleHandler):
raise ValueError(
f"wildcard payload.options[{i}].weight must not be negative"
)
sub_cat = opt.get("sub_category")
if sub_cat is not None and not isinstance(sub_cat, str):
subs = opt.get("sub_categories", [])
if not isinstance(subs, list):
raise ValueError(
f"wildcard payload.options[{i}].sub_category must be a string"
f"wildcard payload.options[{i}].sub_categories must be a list"
)
for s in subs:
if not isinstance(s, str):
raise ValueError(
f"wildcard payload.options[{i}].sub_categories must be "
f"strings"
)
if s not in registry:
raise ValueError(
f"wildcard payload.options[{i}].sub_categories: {s!r} "
f"not in sub_categories registry"
)
if null_count > 1:
raise ValueError(
f"wildcard payload may have at most one null option "
@@ -277,35 +521,6 @@ class WildcardHandler(ModuleHandler):
raise ValueError(
f"wildcard payload.var_binding {binding!r} is not a valid identifier"
)
sub_categories = payload.get("sub_categories", [])
if not isinstance(sub_categories, list):
raise ValueError("wildcard payload.sub_categories must be a list")
for i, sc in enumerate(sub_categories):
if not isinstance(sc, str):
raise ValueError(
f"wildcard payload.sub_categories[{i}] must be a string"
)
# `null` is a reserved keyword in the nested-ref filter
# syntax — `@{uuid:warm,null}` opts the null option into
# the pool. Forbid sub_category names colliding with it.
if sc == "null":
raise ValueError(
f"wildcard payload.sub_categories[{i}] 'null' is reserved "
f"(used by the nested ref filter syntax)"
)
# Sub-category name must not contain characters used by the
# nested-ref grammar: `,` (subcat list separator), `:`
# (subcat segment opener), `}` (ref terminator), `#` (name
# segment opener), `@` / `{` (ref-prefix collision risk).
# Editor + API guard against this so the regex never sees
# ambiguous input.
bad = set(sc) & _REF_FORBIDDEN_CHARS
if bad:
raise ValueError(
f"wildcard payload.sub_categories[{i}] {sc!r} contains "
f"forbidden characters {sorted(bad)} "
f"(would break the @{{uuid:subcat}} syntax)"
)
@classmethod
def resolve(
@@ -342,21 +557,23 @@ class WildcardHandler(ModuleHandler):
return {binding: resolve_text(value, resolve_ctx)}
# else: pinned target is missing — fall through to random.
# `category_filter` narrows the option pool to entries whose
# `sub_category` is in the chosen list. Empty list / None = no
# filter (all sub-categories eligible). Options with a `None`
# / missing sub_category are excluded by an explicit filter
# (they're "unsorted" and the user opted into a curated set) —
# EXCEPT the null option (`is_null: True`), which is an
# orthogonal "no-output" slot and survives every category
# filter. See docs/superpowers/specs/2026-05-24-null-wildcard-option-design.md.
# `category_filter` narrows the option pool to entries whose tag
# set (`sub_categories`) satisfies a boolean expression
# (`and`/`or`/`not`/parens; comma=or). Empty / missing = no tag
# filter. `exclude_null` is a separate flag: when true the null
# option is dropped, otherwise it is an orthogonal "no-output"
# slot that survives every filter (the expression never applies
# to it). Untagged non-null options bypass the expression unless
# a bare tag term excludes them (spec §3.4). See the shared
# matcher in engine/syntax/subcat_filter.py.
category_filter = instance.get("category_filter")
if isinstance(category_filter, list) and category_filter:
allowed_cats = set(category_filter)
options = [
o for o in options
if o.get("is_null") or o.get("sub_category") in allowed_cats
]
exclude_null = bool(instance.get("exclude_null", False))
if (isinstance(category_filter, str) and category_filter.strip()) or exclude_null:
options = cls._apply_pool_filter(
options,
category_filter if isinstance(category_filter, str) else "",
exclude_null=exclude_null,
)
enabled = instance.get("enabled_options")
if enabled is not None:
@@ -400,16 +617,22 @@ class WildcardHandler(ModuleHandler):
if my_id:
constraints = ctx.get("__wp_constraints__") if ctx is not None else None
picks = ctx.get("__wp_picks__") if ctx is not None else None
# First-instance one-shot: pass the ctx-resident consumed
# set so the apply_constraints helper marks fired
# constraints + skips them on subsequent target instances.
consumed = (
ctx.setdefault("__wp_consumed_constraints__", set())
# SP3 reach selector: thread the ctx-resident per-constraint
# hit counter so each covering constraint re-weights this
# firing target instance (no more one-shot / consumed-set).
# `firing_uid` identifies THIS direct top-level instance for
# the `pick` selector's occurrence match.
hits = (
ctx.setdefault("__wp_constraint_hits__", {})
if isinstance(ctx, dict) else {}
)
firing_uid = (
ctx.get("__wp_current_module_uid__")
if isinstance(ctx, dict) else None
)
options, any_constraint_applied = apply_constraints_for_target(
options, my_id, constraints, picks, ctx["__wp_warnings__"],
consumed=consumed,
hits=hits, firing_uid=firing_uid,
)
if any_constraint_applied:
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
@@ -422,7 +645,17 @@ class WildcardHandler(ModuleHandler):
# unlocked pick exactly. This is the contract the lock UX
# relies on: the user sees a roll, locks it, captures the
# current chain seed, future runs reproduce the same roll.
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
if instance.get("seed_scope") == "hold":
# Held: reuse the frame-0 fully-resolved value verbatim (from the
# pipeline's base pass). Skips the pick AND nested-ref resolution,
# so a constrained or @{}-bearing wildcard stays frozen across the
# whole run — "green jeans" on frame 1 stays "green jeans".
_hb = ctx.get("__wp_hold_base_ctx__")
if isinstance(_hb, dict) and binding in _hb:
return {binding: _hb[binding]}
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
else:
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
locked_seed = instance.get("locked_seed")
if locked_seed is not None:
try:
@@ -434,6 +667,48 @@ class WildcardHandler(ModuleHandler):
effective_seed = chain_seed
rng = _derive_module_rng(effective_seed, binding)
# SP2a multi-select: a count range other than (1, 1) picks N options
# without replacement and binds a ListVar. The null option is never in
# the multi-pick pool (exclude_null is single-pick-only); "maybe
# nothing" is min=0. (1, 1) falls through to the single-pick path
# below unchanged — same RNG draws, so existing locked seeds reproduce.
raw_min = _coerce_pick_int(instance.get("pick_min"), 1)
raw_max = _coerce_pick_int(instance.get("pick_max"), raw_min)
lo = max(0, min(raw_min, raw_max))
hi = max(lo, raw_max)
if not (lo == 1 and hi == 1):
pool = [o for o in options if not o.get("is_null")]
weights = [max(0.0, float(o.get("weight", 1))) for o in pool]
pool_n = len(pool)
independent = bool(instance.get("pick_independent", False))
if independent:
# SP2c: independent multi-pick draws WITH replacement (repeats
# allowed), mirroring the inline `~` flag — so the count is the
# requested range and is NOT clamped to the pool size.
count = lo if lo == hi else lo + int(rng.random() * (hi - lo + 1))
picks = [
p for p in (_pick_weighted(pool, rng) for _ in range(count))
if p is not None
]
else:
lo_c, hi_c = min(lo, pool_n), min(hi, pool_n)
count = lo_c if lo_c == hi_c else lo_c + int(rng.random() * (hi_c - lo_c + 1))
picks = pick_k_unique(pool, weights, count, rng)
sep = instance.get("pick_separator")
if not isinstance(sep, str):
sep = ", "
items: list[str] = []
saved_rng_multi = ctx.get("__wp_rng__")
ctx["__wp_rng__"] = rng
try:
multi_ctx = build_resolve_ctx(ctx, surface="wildcard")
for opt in picks:
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
finally:
ctx["__wp_rng__"] = saved_rng_multi
_record_pick_multi(ctx, picks, sep)
return {binding: ListVar(items, sep)}
chosen = _pick_weighted(options, rng)
if chosen is None:
return {binding: ""}
@@ -444,39 +719,9 @@ class WildcardHandler(ModuleHandler):
# constraint exception keys on the literal empty pick value).
_record_pick(ctx, chosen)
# First-instance positional-claim failsafe. This wildcard may
# CARRY a constraint's target via an `@{target}` ref in one of
# its options. The pair badge already assigned the constraint to
# this carrier, so once the carrier ROLLS — regardless of which
# option won, including the `null` / empty-value option — it
# claims the constraint so it doesn't spill onto a later target
# instance. `claim_carrier_constraints` is a no-op when the
# chosen option DID resolve the ref (the nested-resolve path
# below already consumed it) or the source isn't picked yet.
#
# CRITICAL: must fire on EVERY roll path, including the
# empty-value early return — a backdrop that rolls its `null`
# option still "rolled" and must still claim its carried
# constraints. The 2026-05-26 bug was calling this only after
# resolve_text, so a null/empty pick skipped the claim and the
# constraint either spilled or surfaced a misleading
# never_applied warning.
def _claim_carried() -> None:
if ctx is None:
return
from engine.modules._constraints import claim_carrier_constraints
claim_carrier_constraints(
options,
ctx.get("__wp_constraints__"),
ctx.get("__wp_picks__"),
ctx.get("__wp_consumed_constraints__"),
)
value = str(chosen.get("value", ""))
if not value:
# Empty / null pick — no ref to resolve, but the carrier
# still rolled, so claim before returning.
_claim_carried()
# Empty / null pick — no ref to resolve.
return {binding: ""}
# Swap `ctx['__wp_rng__']` to the per-module rng for the
@@ -495,12 +740,22 @@ class WildcardHandler(ModuleHandler):
ctx["__wp_rng__"] = rng
try:
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
# SP3 nested-pick reach: THIS wildcard is the carrier of any
# `@{T}` ref inside `value`. Stamp (its per-instance uid, the
# chosen option's id) on the resolve frame so a constraint on
# T whose `pick` selector lists this (carrier_uid, option_id)
# nested occurrence matches. A `pick` keyed on a DEEPER
# carrier won't match this top-level uid — that's by design
# (only one-hop carriers are individually pickable). Set on
# the freshly-built ctx (carrier defaults None); no restore
# needed — resolve_ctx is scoped to this resolve_text call.
set_carrier = getattr(resolve_ctx, "set_carrier", None)
if callable(set_carrier):
set_carrier(
ctx.get("__wp_current_module_uid__"), chosen.get("id"),
)
resolved = resolve_text(value, resolve_ctx)
finally:
ctx["__wp_rng__"] = saved_rng
# Claim AFTER resolve_text so a chosen option that DID carry the
# ref gets consumed-with-effect by the nested-resolve path
# first; the claim then no-ops on it.
_claim_carried()
return {binding: resolved}
+301 -42
View File
@@ -20,11 +20,14 @@ from engine.modules.snapshot import coerce_legacy_module
logger = logging.getLogger(__name__)
# `@{uuid}` ref matcher — mirror of engine.syntax.tokenize._REF_RE.
# Used by the never_applied warning classifier to tell "target has a
# carrier/instance somewhere in the chain" apart from "target uuid is
# nowhere to be found".
_PIPELINE_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}")
# `@{uuid}` ref matcher — mirror of engine.syntax.tokenize._REF_RE
# (4-segment: uuid + optional #name / :expr / !null; only the uuid is
# captured here). Used by the never_applied warning classifier to tell
# "target has a carrier/instance somewhere in the chain" apart from
# "target uuid is nowhere to be found".
_PIPELINE_REF_RE = re.compile(
r"@\{([0-9a-f]{8})(?:#[^#:}@{!]*)?(?::[^}!]*)?(?:![^}]*)?\}"
)
def _module_field(module: Any, key: str) -> Any:
@@ -233,6 +236,18 @@ def _extract_static_meta(
return meta
def _module_seed_scope(module: object) -> str | None:
"""Read a module's base ``instance.seed_scope`` ("hold"/"vary"/None).
Works for both dict and object module shapes. seed_scope is a base-only
setting (disabled in per-frame edit mode), so the base instance is
authoritative."""
inst = module.get("instance") if isinstance(module, dict) else getattr(module, "instance", None)
if isinstance(inst, dict):
s = inst.get("seed_scope")
return s if isinstance(s, str) else None
return None
class PipelineEngine:
"""Runs an ordered list of modules against a context dict."""
@@ -241,18 +256,84 @@ class PipelineEngine:
modules: list[Module],
ctx: Context | None = None,
seed: int = 0,
hold_seed: int | None = None,
loop_index: int = 0,
) -> Context:
"""Execute modules top-to-bottom, mutating ctx, returning ctx."""
ctx = {} if ctx is None else ctx
ctx["__wp_node_seed__"] = seed
ctx["__wp_node_seed_hold__"] = hold_seed if hold_seed is not None else seed
ctx["__wp_loop_index__"] = loop_index
ctx["__wp_rng__"] = random.Random(seed)
ctx.setdefault("__wp_warnings__", [])
ctx.setdefault("__wp_trace__", [])
ctx.setdefault("__wp_internal_flags__", {})
# Tracks constraint module ids that have already fired against
# their first downstream target instance. See
# docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
ctx.setdefault("__wp_consumed_constraints__", set())
# SP3 reach selector: per-constraint firing count keyed by
# `__constraint_module_id__`. Drives first/next/all/pick
# coverage in apply_constraints_for_target + the never_applied /
# partial_reach finalisation below. Replaces the pre-SP3
# one-shot consumed-set.
ctx.setdefault("__wp_constraint_hits__", {})
# "Hold the value" base pass. A module on seed_scope=hold must resolve
# to its frame-0 (loop_index=0) value on EVERY iteration — INCLUDING
# nested @{} refs, which resolve_text bakes into the stored value.
# Re-resolving each iteration (the old seed-swap) failed for
# constrained wildcards (the constraint reshapes the pool per
# iteration) and for nested refs. So when a loop is driving
# (loop_index != 0) and any module holds, resolve the chain ONCE at
# loop_index=0 with the constant hold seed; held handlers copy their
# resolved value from this base context verbatim. The deepcopy keeps
# the throwaway pass's warnings/trace/constraint-state off the real
# ctx. loop_index=0 here means the recursive run skips its own base
# pass — no infinite recursion.
_any_hold = any(_module_seed_scope(m) == "hold" for m in modules)
if loop_index != 0 and _any_hold:
import copy as _copy
_hb = int(ctx.get("__wp_node_seed_hold__", 0) or 0)
base_ctx = PipelineEngine().run(
modules,
ctx=_copy.deepcopy(ctx),
seed=_hb,
hold_seed=_hb,
loop_index=0,
)
ctx["__wp_hold_base_ctx__"] = base_ctx
# Held wildcards copy their frozen VALUE from the base pass but
# early-return before `_record_pick` (wildcard_handler), so their
# frame-0 pick never lands in the real ctx. Downstream constraints
# then can't find the held source — they mis-fire (spurious
# source_missing / never_applied warnings) and, cross-node, leave a
# held target UNCONSTRAINED (the held-outfit drift: kimono at
# frame 0, unconstrained pick on frames 2+). Carry the base pass's
# pick + hit state for held modules into the real ctx HERE, before
# the per-module loop, so the registration is present for this
# node's constraints AND flows downstream (via __wp_picks__ /
# __wp_constraints__ carry-over) into the next node's base pass.
# The value-freeze path in the wildcard handler is untouched.
_base_picks = base_ctx.get("__wp_picks__")
if isinstance(_base_picks, dict):
_real_picks = ctx.setdefault("__wp_picks__", {})
if isinstance(_real_picks, dict):
for _m in modules:
if _module_seed_scope(_m) != "hold":
continue
_mid = (
_m.get("id", "") if isinstance(_m, dict)
else getattr(_m, "id", "")
)
if _mid and _mid in _base_picks and _mid not in _real_picks:
_real_picks[_mid] = _base_picks[_mid]
_base_hits = base_ctx.get("__wp_constraint_hits__")
if isinstance(_base_hits, dict):
_real_hits = ctx.setdefault("__wp_constraint_hits__", {})
if isinstance(_real_hits, dict):
for _cid, _n in _base_hits.items():
_real_hits[_cid] = max(
int(_real_hits.get(_cid, 0) or 0), int(_n or 0)
)
for index, module in enumerate(modules):
# Normalise id/type/enabled reads to work for both dicts and objects.
@@ -275,25 +356,68 @@ class PipelineEngine:
# imported from someone else's machine), letting WP_Debug
# render a readable chip instead of a raw uuid.
_module_name = module.get("name", "") or ""
# Per-insertion bundle discriminator. Two instances of one
# library wildcard (a bundle inserted twice) carry DISTINCT
# `bundle_origin` values (insert.ts stamps each child with
# its immediate BundleInstance._uid). Threaded into ctx so a
# constraint can bind to the source pick from its OWN bundle
# copy (task_5200c1fc). Empty string when the module has no
# bundle origin (top-level / manually-added / legacy).
_module_bundle_origin = module.get("bundle_origin", "") or ""
else:
_module_id = getattr(module, "id", "")
_module_uid = getattr(module, "_uid", "") or ""
_module_type_raw = getattr(module, "type", None) or ""
_module_enabled = getattr(module, "enabled", True)
_module_name = getattr(module, "name", "") or ""
_module_bundle_origin = getattr(module, "bundle_origin", "") or ""
if not _module_enabled:
# Disabled modules don't run — but the trace row still
# surfaces what the module would have written, so the
# debug viewer can label it `$varname (disabled)`
# instead of falling back to an opaque short-uuid.
_k = int(ctx.get("__wp_loop_index__", 0))
# Per-frame enable override. `frame_enabled[str(k)]` overrides the
# base `enabled` flag for frame k in EITHER direction: a base-off
# module can be turned ON for select frames, a base-on module OFF
# for others. effective = frame_enabled.get(k, base_enabled).
# Legacy `disabled_frames` (a one-way blocklist) is still honoured —
# each listed frame reads as frame_enabled[k]=False.
_frame_enabled_map = (
module.get("frame_enabled")
if isinstance(module, dict)
else getattr(module, "frame_enabled", None)
)
_legacy_disabled = (
module.get("disabled_frames")
if isinstance(module, dict)
else getattr(module, "disabled_frames", None)
)
_frame_override: bool | None = None
if isinstance(_frame_enabled_map, dict) and str(_k) in _frame_enabled_map:
_frame_override = bool(_frame_enabled_map[str(_k)])
elif isinstance(_legacy_disabled, (list, tuple)) and _k in {
int(f)
for f in _legacy_disabled
if isinstance(f, (int, float)) and not isinstance(f, bool)
}:
_frame_override = False
_effective_enabled = (
_frame_override if _frame_override is not None else bool(_module_enabled)
)
if not _effective_enabled:
# Module is suppressed this frame. Distinguish a per-frame
# disable (a frame override forced it off) from a global
# disable (base off, no frame override) so the debug viewer
# can label `skipped_frame` vs `skipped_disabled`. Either way
# the trace row still surfaces what it WOULD have written so
# the debug viewer can label `$varname (disabled)` rather than
# falling back to an opaque short-uuid.
_frame_forced_off = _frame_override is False
meta = _extract_static_meta(module)
ctx["__wp_trace__"].append({
"id": _module_id,
"_uid": _module_uid,
"type": _module_type_raw,
"enabled": False,
"status": "skipped_disabled",
"enabled": bool(_module_enabled) if _frame_forced_off else False,
"status": "skipped_frame" if _frame_forced_off else "skipped_disabled",
"writes": [],
"error": None,
**meta,
@@ -311,17 +435,38 @@ class PipelineEngine:
else:
# Generic object: pull known snapshot keys via getattr so
# class-level attributes (common in tests) are visible.
# `iteration_overrides` is load-bearing — the per-frame
# override patch below reads it back off `raw`. `disabled_frames`
# is listed for snapshot honesty only (the skip gate already
# read it module-direct above).
raw = {
k: getattr(module, k)
for k in (
"id", "type", "enabled", "payload", "entries",
"meta", "library_id", "library_snapshot_at",
"library_version_at_snapshot", "name", "category_id",
"instance",
"instance", "iteration_overrides",
"disabled_frames", "frame_enabled",
)
if hasattr(module, k)
}
snapshot = coerce_legacy_module(raw)
# Per-frame iteration override: patch snapshot["instance"]
# for the current loop index BEFORE the handler runs.
# iteration_overrides is a dict keyed by stringified index;
# the matching partial dict is shallow-merged into instance.
_ov = (
raw.get("iteration_overrides")
if isinstance(raw, dict)
else getattr(module, "iteration_overrides", None)
)
if isinstance(_ov, dict):
_frame_key = str(ctx.get("__wp_loop_index__", 0))
_patch = _ov.get(_frame_key)
if isinstance(_patch, dict) and isinstance(
snapshot.get("instance"), dict
):
snapshot["instance"] = {**snapshot["instance"], **_patch}
except Exception as e:
logger.warning(
"Failed to coerce module %r at index %s: %s", module, index, e
@@ -351,15 +496,24 @@ class PipelineEngine:
# wildcard handler matches THIS against a constraint's
# `target_wildcard_id` (which is a library uuid).
# - `__wp_current_module_uid__` = per-instance uid (`_uid`).
# The constraint handler keys its consumed-set entry on
# THIS so two instances of the same library constraint
# entry are independent one-shots (per CLAUDE.md: "author
# multiple constraint modules" to affect multiple
# targets). Falls back to the library id when `_uid` is
# absent (legacy / hand-built test modules).
# This is the BARE per-instance uid (no node prefix). The
# constraint handler keys its `__constraint_module_id__`
# (the SP3 per-constraint hit counter) on THIS so two
# instances of the same library constraint entry reach
# INDEPENDENTLY (per CLAUDE.md: "author multiple constraint
# modules" to affect multiple targets). It's ALSO the
# `firing_uid` a direct `pick` selector matches against —
# so a persisted `pick` must carry the bare `_uid`, not the
# UI's badge rowKey (`${nodeId}#${_uid}`). Falls back to the
# library id when `_uid` is absent (legacy / test modules).
ctx["__wp_current_module_id__"] = _module_id
ctx["__wp_current_module_uid__"] = _module_uid or _module_id
ctx["__wp_current_module_name__"] = _module_name
# See the `_module_bundle_origin` derivation above — exposed to
# handlers (wildcard_handler records it on the pick; constraint_
# handler captures it on the registered meta) so source-instance
# binding can match a constraint to its own bundle copy's pick.
ctx["__wp_current_module_bundle_origin__"] = _module_bundle_origin
meta = _extract_static_meta(module)
try:
bindings = resolve_module(snapshot, ctx)
@@ -466,20 +620,22 @@ class PipelineEngine:
ctx.pop("__wp_current_module_id__", None)
ctx.pop("__wp_current_module_uid__", None)
ctx.pop("__wp_current_module_name__", None)
ctx.pop("__wp_current_module_bundle_origin__", None)
# First-instance one-shot semantic: emit a soft (info) warning
# for every registered constraint whose target instance never
# came up during the chain. Surfaces in WP_Debug so the user
# sees "you put a constraint here but nothing downstream took
# it" — the chain still succeeds.
# See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
# SP3 reach selector finalisation: emit `constraint_never_applied`
# for every registered constraint whose selector covered ZERO
# firing target instances (hit count 0). Surfaces in WP_Debug so
# the user sees "you put a constraint here but nothing downstream
# took it" — the chain still succeeds. The `partial_reach` pass
# below covers constraints that DID fire but reached fewer
# instances than a `next N` / `pick` selector asked for.
#
# Dedup by cid — sibling instances of the same library entry
# share `__constraint_module_id__`, so they show up as two
# entries in the bucket. Without the dedup the user sees the
# same warning twice for the same logical constraint (2026-05-26).
constraints_bucket = ctx.get("__wp_constraints__") or []
consumed_set = ctx.get("__wp_consumed_constraints__") or set()
hits = ctx.get("__wp_constraint_hits__") or {}
catalog = ctx.get("__wp_catalog__")
warned_cids: set[str] = set()
if isinstance(constraints_bucket, list):
@@ -487,7 +643,7 @@ class PipelineEngine:
if not isinstance(c, dict):
continue
cid = c.get("__constraint_module_id__")
if not cid or cid in consumed_set:
if not cid or hits.get(cid, 0) != 0:
continue
if cid in warned_cids:
continue
@@ -523,24 +679,22 @@ class PipelineEngine:
cid_suffix = _build_ref_name_suffix(c.get("__constraint_library_name__"))
tgt_suffix = _build_ref_name_suffix(target_name)
# Classify the cause so the message is actionable.
# With the carrier-claim failsafe in place, a carrier
# that rolls ANY option claims its constraint — so an
# unconsumed constraint whose target IS present in the
# chain means the target instance/carrier ran BEFORE
# this constraint registered (ordering), or sits in a
# Reach is downstream-relative: a constraint only applies
# to target occurrences that resolve AFTER it registers.
# So a never-applied constraint whose target IS present in
# the chain means every occurrence ran BEFORE this
# constraint registered (ordering), or sits in a
# branch/Context this run didn't reach. Absent target =
# a wrong/stale uuid.
present = _target_present_in_chain(
target_uuid, modules, catalog,
)
if present:
# Target IS in the chain. With the carrier-claim
# failsafe, any carrier/instance that ROLLS after the
# constraint registers claims it — so reaching this
# branch means the target's only appearance(s) ran
# Target IS in the chain but the selector covered no
# firing occurrence — every appearance resolved
# BEFORE this constraint registered (e.g. a source
# wildcard that nests its own @{target}), or live in
# a branch / Context this run didn't execute, or are
# wildcard that nests its own @{target}), or lives in
# a branch / Context this run didn't execute, or is
# disabled. Deliberately NOT prescribing "move the
# constraint up" — that's wrong when the target's
# carrier is also the constraint's source (the source
@@ -590,5 +744,110 @@ class PipelineEngine:
# otherwise render as plain text).
"message": message,
})
# Source-side companion warning. The `never_applied` message
# above is target-focused; when the constraint's SOURCE
# wildcard is ALSO absent from the chain, surface that
# distinctly so the Debug viewer shows BOTH ends (mirrors the
# editor's separate "source missing" / "target missing" row
# badges). A constraint registers by its own module position,
# so it can sit in the bucket with neither end present.
source_uuid = c.get("source_wildcard_id", "")
if source_uuid and not _target_present_in_chain(
source_uuid, modules, catalog,
):
src_row = (
catalog.get(source_uuid) if isinstance(catalog, dict) else None
)
source_name = (
(src_row.get("name", "") or "")
if isinstance(src_row, dict) else ""
)
src_suffix = _build_ref_name_suffix(source_name)
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
if isinstance(warnings_bucket, list):
warnings_bucket.append({
"type": "constraint_source_missing",
"severity": "warn",
"module_id": cid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"constraint_id": cid,
"source_wildcard_id": source_uuid,
},
"message": (
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
f"this chain, so it never registers a source pick."
),
})
# SP3 partial-reach finalisation: a constraint that DID fire but
# whose `next N` / `pick` selector matched FEWER occurrences than
# requested. Distinct from never_applied (which fired zero times)
# — this is "the selector asked for more than the chain offered
# downstream". `all` / `first` can't be partial. Deduped by cid,
# same as never_applied.
partial_warned: set[str] = set()
if isinstance(constraints_bucket, list):
for c in constraints_bucket:
if not isinstance(c, dict):
continue
cid = c.get("__constraint_module_id__")
seen = hits.get(cid, 0) if cid else 0
if not cid or seen == 0 or cid in partial_warned:
continue
sel = c.get("target_select") or {"mode": "all"}
mode = sel.get("mode", "all")
if mode == "next":
try:
want = int(sel.get("count", 1))
except (TypeError, ValueError):
want = 1
elif mode == "pick":
want = len(sel.get("picks") or [])
else:
continue
# LIMITATION (holistic-review L1): `seen` is the per-cid
# TOTAL target-encounter count (`hits[cid]`, bumped on
# every firing in apply_constraints_for_target), NOT the
# count of picks that actually MATCHED. So a `pick`
# partial signal is imprecise: it compares encounters to
# `len(picks)` rather than matched-vs-listed. A precise
# count would need a separate ctx-threaded matched-pick
# counter (new bookkeeping across the _constraints apply
# boundary) — deferred to avoid over-plumbing. `next` is
# exact (it covers the first N encounters by construction).
if seen >= want:
continue
partial_warned.add(cid)
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
if isinstance(warnings_bucket, list):
warnings_bucket.append({
# Spec: partial reach is INFO, not warn — the
# selector simply found fewer downstream targets
# than requested this run (not an error condition).
# `constraint_never_applied` stays `warn` (a
# constraint that fired zero times is worth a louder
# signal). DebugViewer maps `info` → blue/accent.
"type": "constraint_partial_reach",
"severity": "info",
"module_id": cid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"constraint_id": cid,
"target_wildcard_id": c.get("target_wildcard_id"),
"requested": want,
"reached": seen,
},
"message": (
f"constraint reach selector ({mode}) asked for "
f"{want} target instance(s) but only {seen} "
f"resolved downstream this run."
),
})
return ctx
+49 -1
View File
@@ -14,6 +14,9 @@ from typing import Any, Literal
InstanceFieldType = Literal[
"string", "list[string]", "dict[string,number]", "dict[string,dict]",
"dict[string,string]",
# Generic object — used by SP3 `target_select`, whose interior shape is
# validated by the constraint handler rather than this type-only schema.
"dict",
"boolean", "number", "list[dict]",
]
@@ -22,9 +25,32 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
"variable_binding": "string",
"enabled_options": "list[string]",
"option_weights": "dict[string,number]",
"category_filter": "list[string]",
# `category_filter` is a boolean sub-category expression string
# (SP1). The separate `exclude_null` instance flag is declared
# here + in the TS `_shell.ts` twin together in the SP1 UI chunk
# (keeps the parity guard green); the engine resolver already
# reads it directly.
"category_filter": "string",
"exclude_null": "boolean",
"locked_seed": "number",
# Iteration seed scope: "hold" pins RNG to the run base seed (same
# across loop iterations, re-rolls per run); "vary" (default/absent)
# rides the per-iteration seed. `locked_seed` takes priority when set.
"seed_scope": "string",
"internal": "boolean",
# SP2a multi-select: per-instance count range (pick_min..pick_max; a
# range other than 1..1 resolves N options without replacement into a
# list-valued variable) + the separator used to join it for a bare
# `$var`. Instance-local: NOT part of a standalone module's published
# payload — but bundle children freeze the whole instance as a
# snapshot, so a published bundle DOES carry them (intentional
# frozen-snapshot fidelity).
"pick_min": "number",
"pick_max": "number",
"pick_separator": "string",
# SP2c: multi-pick draws WITH replacement (repeats allowed) when true,
# without (unique) when false/absent. Mirrors the inline `~` flag.
"pick_independent": "boolean",
# `mode` and `pinned_option_id` removed in v2 — resolve mode is
# implicit in pool state. Engine handler still reads them when
# present in legacy snapshots; the schema validator now flags
@@ -37,6 +63,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
# resolution gives fixed_values seed-lock parity with wildcard
# + combine. Locked seed pins the alternation outcome.
"locked_seed": "number",
# Iteration seed scope: hold/vary control (parity with wildcard).
"seed_scope": "string",
},
"combine": {
# v2 single-pane modal exposes a richer override surface:
@@ -47,6 +75,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
"template_override": "string",
"variable_binding": "string",
"locked_seed": "number",
# Iteration seed scope: hold/vary control (parity with wildcard).
"seed_scope": "string",
"internal": "boolean",
},
"derivation": {
@@ -86,6 +116,9 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
"exception_mode_overrides": "dict[string,string]",
"exception_factor_overrides": "dict[string,number]",
"extra_exceptions": "list[dict]",
# SP3 reach selector override. Interior shape (mode/count/picks) is
# validated by ConstraintHandler; here it is type-only ("is a dict").
"target_select": "dict",
},
}
@@ -101,6 +134,8 @@ def _matches_type(value: Any, spec: InstanceFieldType) -> bool:
return isinstance(value, list) and all(isinstance(x, str) for x in value)
if spec == "list[dict]":
return isinstance(value, list) and all(isinstance(x, dict) for x in value)
if spec == "dict":
return isinstance(value, dict)
if spec == "dict[string,number]":
return (
isinstance(value, dict)
@@ -140,4 +175,17 @@ def validate_instance(kind: str, instance: dict[str, Any]) -> list[str]:
warnings.append(
f"{kind}.{field} type mismatch: expected {spec}, got {type(value).__name__}"
)
# SP2a advisory soft-warns for the pick range (not type errors).
if kind == "wildcard":
pmin = instance.get("pick_min")
pmax = instance.get("pick_max")
nums = (int, float)
if (isinstance(pmin, nums) and not isinstance(pmin, bool)
and isinstance(pmax, nums) and not isinstance(pmax, bool)):
if pmin == 0 and pmax == 0:
warnings.append("wildcard pick range 0..0 — always empty (resolves to nothing)")
elif pmax < pmin:
warnings.append(
f"wildcard.pick_max ({pmax}) is below pick_min ({pmin}) — clamped to pick_max"
)
return warnings
+23 -3
View File
@@ -41,6 +41,19 @@ def derive_loop_seeds(base: int, count: int, strategy: str) -> list[int]:
raise ValueError(f"unknown loop seed strategy: {strategy!r}")
def apply_seed_locks(derived: list[int], locks: dict[int, int]) -> list[int]:
"""Overlay per-index locked seeds on a derived series.
`locks` maps a 0-based iteration index to a pinned seed. Locked
indices replace the derived value (masked to MAX_SAFE_SEED);
out-of-range indices are ignored. Pure — no ComfyUI imports.
"""
return [
(locks[i] & MAX_SAFE_SEED) if i in locks else seed
for i, seed in enumerate(derived)
]
def effective_chain_seed(
*,
widget_seed: int,
@@ -53,8 +66,10 @@ def effective_chain_seed(
1. ``seed_override`` (from ContextLoop) replaces ``widget_seed`` as
base when present.
2. Otherwise ``widget_seed`` is the base.
3. ``loop_index`` is mixed via XOR with a stable hash. ``loop_index=0``
→ no-op (backwards-compat for chains without ContextLoop).
3. ``loop_index`` is mixed via XOR with a stable hash ONLY when there is
no override (to vary a constant ``widget_seed`` per iteration). An
override is already per-iteration (the ContextLoop derived series) so
it is used as-is. ``loop_index=0`` → no-op.
Locked-module precedence lives in the per-module handlers, NOT here.
Locked modules read ``instance.locked_seed`` directly and ignore the
@@ -64,7 +79,12 @@ def effective_chain_seed(
matches the visual shape of ComfyUI's frontend-randomised seeds.
"""
base = seed_override if seed_override is not None else widget_seed
if loop_index == 0:
# An override IS the loop's already-per-iteration derived seed → use it
# verbatim. The loop_index XOR exists only to vary a CONSTANT widget seed
# (the override-OFF path); re-mixing an override that already differs per
# iteration is a double-shift that made the actual seed diverge from the
# derived series the preview + per-iteration seed-modal locks show.
if loop_index == 0 or seed_override is not None:
return base & MAX_SAFE_SEED
digest = hashlib.sha256(f"loop:{loop_index}".encode()).digest()
shift = int.from_bytes(digest[:8], "big")
+282 -145
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import logging
from engine.syntax.subcat_filter import matches as _subcat_matches
from engine.syntax.subcat_filter import parse as _parse_subcat
from engine.syntax.tokenize import tokenize_text
from engine.syntax.types import (
CycleDetectedError,
@@ -17,6 +19,7 @@ from engine.syntax.types import (
Token,
TokenKind,
UnknownRefError,
deref_var_value,
)
logger = logging.getLogger(__name__)
@@ -114,7 +117,12 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
message=f"Unknown variable ${name}",
)
return ""
return str(value)
# SP2a list accessor: `$name` joins a ListVar with its separator;
# `$name.K` indexes (0-based, out-of-range -> ""). A plain string behaves
# as a 1-element list so `$str.0` == `$str` and `$str.1` == "". The
# accessor + ListVar-fold contract lives in deref_var_value
# (engine/syntax/types.py); the derivation + converter reads share it.
return deref_var_value(value, tok.meta.get("index"))
def _push_warning(ctx: ResolveContext, **fields) -> None:
@@ -161,8 +169,19 @@ def _resolve_inline_pick(
branches: list[str] = tok.meta.get("branches", [])
if not branches:
return ""
chosen_idx = ctx.rng.randrange(len(branches))
chosen = branches[chosen_idx]
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
# honors. Strip the prefix off every branch so it never leaks into output;
# only switch to weighted selection when a branch actually carries a weight,
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
# randrange path — changing the draw would shift every existing seed).
values = [_strip_branch_weight(b) for b in branches]
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
if has_weights:
weights = [_parse_branch_weight(b) for b in branches]
chosen_idx = _weighted_pick_index(weights, ctx.rng)
else:
chosen_idx = ctx.rng.randrange(len(branches))
chosen = values[chosen_idx]
if not chosen:
return ""
nested_tokens = tokenize_text(chosen)
@@ -209,53 +228,120 @@ def _weighted_pick_index(weights: list[float], rng) -> int:
return len(weights) - 1
def pick_k_unique(items, weights, k, rng):
"""Pick ``k`` items from ``items`` without replacement, weighted by the
parallel ``weights`` list, returned in pick order. ``k`` is clamped to
``len(items)``; ``k <= 0`` -> ``[]``.
Shared by the inline ``{N$$sep$$}`` block (``_resolve_multi_pick``) and by
multi-select wildcards (``wildcard_handler``). Draws from ``rng`` in the
same order the inline block always has, so seed behavior is unchanged.
"""
n = min(max(0, k), len(items))
available_idx = list(range(len(items)))
available_w = list(weights)
chosen = []
for _ in range(n):
if not available_idx:
break
j = _weighted_pick_index(available_w, rng)
chosen.append(items[available_idx.pop(j)])
available_w.pop(j)
return chosen
def _resolve_multi_pick(
tok: Token,
ctx: ResolveContext,
depth: int,
visited: tuple[str, ...],
) -> str:
"""Pick `count` branches without replacement, weighted, joined by `sep`.
"""Resolve a multi-pick block, joined by `sep`.
Each chosen branch is recursively resolved.
SP2b: `count` is a range `[min, max]` (a fixed `{N$$}` has min==max), and
`independent` (the `~` flag) toggles unique-without-replacement (default)
vs N draws WITH replacement. A lone nested-ref body in unique mode draws N
DISTINCT options from the ref's filtered pool (a per-site SP2a). Each chosen
value is recursively resolved.
"""
# Seedless assembler surface — see _resolve_inline_pick. Render verbatim
# rather than freezing a deterministic seed-0 multi-pick.
if ctx.surface == "assembler":
return tok.raw
count: int = tok.meta.get("count", 0)
cmin: int = tok.meta.get("min", tok.meta.get("count", 0))
cmax: int = tok.meta.get("max", tok.meta.get("count", 0))
independent: bool = bool(tok.meta.get("independent", False))
sep: str = tok.meta.get("sep", "")
branches: list[str] = list(tok.meta.get("branches", []))
# Range count: a fixed N (min==max) does NOT draw from rng, so a `{N$$}`
# reproduces the pre-SP2b seed sequence byte-for-byte. A true range draws.
count = cmin if cmin == cmax else ctx.rng.randint(cmin, cmax)
if count <= 0:
return ""
# Clamp count to available branches (without-replacement constraint)
# Lone nested-ref body, unique mode, count>=2, wildcard surface → pick N
# DISTINCT options from the ref's filtered pool (per-site SP2a multi-select).
# count==1 stays on the legacy branch path below so its seed is unchanged.
if not independent and count >= 2 and len(branches) == 1 and ctx.surface == "wildcard":
bt = tokenize_text(branches[0])
if len(bt) == 1 and bt[0].kind == TokenKind.REF:
ref_tok = bt[0]
ref_uuid = ref_tok.meta.get("uuid", "")
pool = ref_option_pool(ref_tok, ctx)
if not pool:
return ""
values = [str(o.get("value", "")) for o in pool]
weights = [float(o.get("weight", 1) or 1) for o in pool]
chosen = pick_k_unique(values, weights, count, ctx.rng)
# The multi-picked ref is the carrier of any `@{X}` inside the
# values it resolves. We pick on VALUES (not option dicts) so a
# specific option id isn't tracked here — set carrier to
# (ref_uuid, None) for the recursion. option_id None can't match
# a real `pick` entry, so a deeper `@{X}` falls back to
# positional first/next/all (design: multi-pick children aren't
# individually pickable). Restore the enclosing carrier after.
get_carrier = getattr(ctx, "get_carrier", None)
set_carrier = getattr(ctx, "set_carrier", None)
saved_carrier = (
get_carrier() if callable(get_carrier) else None
)
if callable(set_carrier):
set_carrier(ref_uuid, None)
try:
return sep.join(
_resolve_tokens(
tokenize_text(v), ctx, depth=depth + 1,
visited=visited + (ref_uuid,),
) if v else ""
for v in chosen
)
finally:
if callable(set_carrier) and saved_carrier is not None:
set_carrier(*saved_carrier)
weights = [_parse_branch_weight(b) for b in branches]
branch_values = [_strip_branch_weight(b) for b in branches]
resolved: list[str] = []
if independent:
# N draws WITH replacement over the branches; each resolved
# independently (a lone ref re-rolls every time → variety from nesting).
for _ in range(count):
j = _weighted_pick_index(weights, ctx.rng)
v = branch_values[j]
resolved.append(
_resolve_tokens(tokenize_text(v), ctx, depth=depth, visited=visited)
if v else ""
)
return sep.join(resolved)
# Unique over branches (legacy path, now range-aware): N distinct branches.
n_picks = min(count, len(branches))
if n_picks == 0:
return ""
# Weighted without-replacement: branch weights default to 1; if a branch
# uses the `N::value` form (a feature of the inline-pick weight micro-syntax),
# the weight is parsed from the prefix. For simplicity, this implementation
# treats all branches as weight=1 unless they begin with `<int>::`.
weights = [_parse_branch_weight(b) for b in branches]
branch_values = [_strip_branch_weight(b) for b in branches]
chosen_indices: list[int] = []
available_indices = list(range(len(branches)))
available_weights = list(weights)
for _ in range(n_picks):
if not available_indices:
break
idx = _weighted_pick_index(available_weights, ctx.rng)
chosen_indices.append(available_indices.pop(idx))
available_weights.pop(idx)
resolved: list[str] = []
for idx in chosen_indices:
branch_text = branch_values[idx]
chosen_values = pick_k_unique(branch_values, weights, n_picks, ctx.rng)
for branch_text in chosen_values:
if not branch_text:
resolved.append("")
continue
@@ -265,6 +351,145 @@ def _resolve_multi_pick(
return sep.join(resolved)
def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
"""Filtered + constrained option pool a ``@{uuid[#name][:expr][!null]}`` ref
resolves against — catalog lookup, the ``:expr``/``!null`` sub-category
filter, and chain constraints. Shared by ``_resolve_ref`` (single weighted
pick) and the SP2b lone-ref multi-pick path.
Returns ``[]`` (pushing the same ``unknown_ref`` /
``ref_subcategory_empty_pool`` warnings, and raising ``UnknownRefError`` in
strict mode for an unknown ref) when there is nothing to pick. Does NOT
apply the depth / cycle / surface guards — those stay in ``_resolve_ref``
and the multi-pick caller.
"""
uuid = tok.meta.get("uuid", "")
module = ctx.get_module(uuid)
if module is None:
if ctx.strict:
raise UnknownRefError(uuid)
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
_push_warning(
ctx,
type="unknown_ref",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "name": cached_name},
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
)
return []
payload_dict = module.get("payload")
if isinstance(payload_dict, dict):
options = list(payload_dict.get("options", []))
else:
options = list(module.get("options", []))
filter_expr = tok.meta.get("filter_expr")
exclude_null = bool(tok.meta.get("exclude_null"))
if (isinstance(filter_expr, str) and filter_expr.strip()) or exclude_null:
pool_total = len(options)
ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None
options = [
o for o in options
if isinstance(o, dict)
and (
(o.get("is_null") and not exclude_null)
or (
not o.get("is_null")
and _subcat_matches(ast, set(o.get("sub_categories") or []))
)
)
]
if not options:
# "Matched no options" on its own is true and useless — it cannot
# tell a wrong filter from a right filter aimed at a stale pool.
# Report WHICH pool was searched, how big it was, and what tags it
# actually offers, so the panel answers the question instead of
# restating the symptom.
available = sorted({
t
for o in (payload_dict.get("options", []) if isinstance(payload_dict, dict)
else module.get("options", []))
if isinstance(o, dict)
for t in (o.get("sub_categories") or [])
if isinstance(t, str)
})
pool_origin = module.get("pool_origin")
library_count = module.get("library_option_count")
detail = {
"uuid": uuid,
"filter_expr": filter_expr,
"exclude_null": exclude_null,
"pool_total": pool_total,
"available_sub_categories": available,
}
if pool_origin:
detail["pool_origin"] = pool_origin
if isinstance(library_count, int):
detail["library_option_count"] = library_count
where = (
"this node's copy" if pool_origin == "node"
else "the library" if pool_origin == "library"
else "the pool"
)
drift = (
f"; the library now has {library_count}"
if isinstance(library_count, int) else ""
)
_push_warning(
ctx,
type="ref_subcategory_empty_pool",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail=detail,
message=(
f"@{{{uuid}:{filter_expr or ''}"
f"{'!null' if exclude_null else ''}}} matched no options — "
f"searched {where} ({pool_total} option"
f"{'' if pool_total == 1 else 's'}){drift}"
),
)
return []
get_constraints = getattr(ctx, "get_constraints", None)
get_picks = getattr(ctx, "get_picks", None)
if callable(get_constraints) and callable(get_picks):
constraints = get_constraints()
if constraints:
from engine.modules._constraints import (
apply_constraints_for_target,
warn_excludes_all,
)
# SP3: share the ctx-resident hit counter so a nested-ref
# target occurrence counts toward the same first/next
# coverage as direct top-level instances. `carrier_ctx` is
# the identity of the carrier wildcard whose chosen option
# value textually contains THIS `@{uuid}` ref — set on the
# resolve frame by the enclosing wildcard handler (top-level
# carrier) or by `_resolve_ref` (deeper carrier). A `pick`
# selector's `nested` occurrence matches on it; first / next
# / all ignore it. None when no carrier is set (a ref at the
# surface root) — then `pick` simply won't cover.
get_hits = getattr(ctx, "get_constraint_hits", None)
hits = get_hits() if callable(get_hits) else {}
get_carrier_ctx = getattr(ctx, "get_carrier_ctx", None)
carrier_ctx = get_carrier_ctx() if callable(get_carrier_ctx) else None
options, any_applied = apply_constraints_for_target(
options, uuid, constraints, get_picks(), ctx.warnings,
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
)
if any_applied:
warn_excludes_all(options, uuid, ctx.warnings)
return options
def _resolve_ref(
tok: Token,
ctx: ResolveContext,
@@ -312,8 +537,10 @@ def _resolve_ref(
)
return ""
# Surface check
if ctx.surface != "wildcard":
# Surface check — wildcard options AND derivation action values may host
# `@{}` refs (both run resolve_text inside a seeded Context with the chain
# constraint bucket threaded). combine + assembler stay gated.
if ctx.surface not in ("wildcard", "derivation"):
if ctx.strict:
raise RefOutOfSurfaceError(uuid, ctx.surface)
# Try to resolve module name for the warning detail (best-effort)
@@ -332,121 +559,13 @@ def _resolve_ref(
)
return ""
# Catalog lookup
module = ctx.get_module(uuid)
if module is None:
if ctx.strict:
raise UnknownRefError(uuid)
# Surface the cached `#name` from the ref token if the original
# syntax carried one (`@{uuid#name}`). Lets WP_Debug + RichTextPreview
# render a friendly label even when the target wildcard has been
# deleted from the library — same fallback chain RichTextPreview
# uses for unresolved chips elsewhere.
cached_name = tok.meta.get("name") if hasattr(tok, "meta") else None
_push_warning(
ctx,
type="unknown_ref",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "name": cached_name},
message=f"Unknown wildcard ref @{{{uuid}{'#' + cached_name if cached_name else ''}}}",
)
# Catalog lookup + `:expr`/`!null` filter + chain constraints, factored
# into ref_option_pool (shared with the SP2b lone-ref multi-pick path).
# The strict-unknown raise + the unknown / empty-pool warnings live there.
options = ref_option_pool(tok, ctx)
if not options:
return ""
# Pick weighted option.
# Support both SnapshotEntry shape (payload.options, spec §2.4) and the
# legacy flat shape used by existing unit tests (options at top level).
payload_dict = module.get("payload")
if isinstance(payload_dict, dict):
options = list(payload_dict.get("options", []))
else:
options = list(module.get("options", []))
# Optional per-call sub-category filter: `@{uuid:warm,cool}` keeps
# only options whose `sub_category` is in the requested list. Same
# semantics as chain-level `instance.category_filter` but scoped to
# this specific ref, so a shared library wildcard (e.g. `@{color}`)
# can be narrowed differently at every call site without authoring
# separate library entries. Empty post-filter pool → empty string
# + warning so the user sees the unsatisfiable filter rather than
# silently falling through to the unfiltered list.
#
# Null semantics (inverted 2026-05-25): the wildcard's `is_null`
# option is INCLUDED by default — alongside whatever sub-cats the
# filter lists. The reserved keyword `"null"` in the filter list
# EXCLUDES the null option from the pool. So:
# `@{uuid}` → all options (incl. null)
# `@{uuid:warm}` → warm options + null
# `@{uuid:warm,null}` → warm options, null excluded
# `@{uuid:null}` → all non-null options (filter-only-null
# = "exclude null, no sub-cat filter")
# Sub-category names called literally `"null"` are forbidden by
# WildcardHandler.validate_payload so the keyword can never clash
# with a real sub-cat.
sub_filter = tok.meta.get("sub_categories")
if isinstance(sub_filter, list) and sub_filter:
allowed_subs = set(sub_filter)
exclude_null = "null" in allowed_subs
allowed_subs.discard("null")
options = [
o for o in options
if isinstance(o, dict)
and (
(o.get("is_null") and not exclude_null)
or (
not o.get("is_null")
and (not allowed_subs or o.get("sub_category") in allowed_subs)
)
)
]
if not options:
_push_warning(
ctx,
type="ref_subcategory_empty_pool",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"uuid": uuid, "sub_categories": list(sub_filter)},
message=(
f"@{{{uuid}:{','.join(sub_filter)}}} matched no options"
),
)
return ""
# Apply chain-level constraints whose `target_wildcard_id` matches
# this nested ref's uuid. Pre-2026-05 only top-level wildcards saw
# constraints — nested `@{B}` from inside another wildcard's
# option value silently bypassed every rule, defeating composed
# pipelines. ResolveContext exposes `get_constraints` / `get_picks`
# for this path; both default to empty for hand-built resolve
# contexts in tests so the legacy fast path keeps working.
get_constraints = getattr(ctx, "get_constraints", None)
get_picks = getattr(ctx, "get_picks", None)
if callable(get_constraints) and callable(get_picks):
constraints = get_constraints()
if constraints:
from engine.modules._constraints import (
apply_constraints_for_target,
warn_excludes_all,
)
# First-instance one-shot semantic: thread the consumed
# set so this nested-ref resolve path participates in the
# same one-shot bookkeeping as top-level wildcard rolls.
# See docs/superpowers/specs/2026-05-24-constraint-first-instance-design.md.
get_consumed = getattr(ctx, "get_consumed_constraints", None)
consumed = get_consumed() if callable(get_consumed) else set()
options, any_applied = apply_constraints_for_target(
options, uuid, constraints, get_picks(), ctx.warnings,
consumed=consumed,
)
if any_applied:
warn_excludes_all(options, uuid, ctx.warnings)
chosen = _pick_weighted(options, ctx.rng)
if chosen is None:
return ""
@@ -455,6 +574,24 @@ def _resolve_ref(
if not chosen_value:
return ""
# Recursive resolve with depth+1, visited extended
# Recursive resolve with depth+1, visited extended. This ref'd
# wildcard is the carrier of any `@{X}` inside its OWN chosen option
# value, so stamp (its uuid, the chosen option's id) on the frame for
# the duration of the recursion, then restore the prior carrier. The
# carrier_uid here is the library uuid (a ref'd wildcard has no row
# `_uid`); a `pick` keyed on a top-level row uid won't match it —
# that's the expected deeper-than-one-hop behaviour (positional
# first/next/all cover deeper nesting). Mirrors the rng save/restore.
nested_tokens = tokenize_text(chosen_value)
get_carrier = getattr(ctx, "get_carrier", None)
set_carrier = getattr(ctx, "set_carrier", None)
if callable(get_carrier) and callable(set_carrier):
saved_carrier = get_carrier()
set_carrier(uuid, chosen.get("id"))
try:
return _resolve_tokens(
nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,)
)
finally:
set_carrier(*saved_carrier)
return _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
+176
View File
@@ -0,0 +1,176 @@
"""Boolean sub-category filter — parser, matcher, normalizer, validators.
Grammar (precedence not > and > or; or/and are n-ary, flattened):
expr := or
or := and ( ("or" | ",") and )*
and := unary ( "and" unary )*
unary := "not" unary | atom
atom := SUBCAT | "(" or ")"
Mirror: src/manager/parsing/subcatFilter.ts — keep behavior identical
(shared fixture in tests/fixtures/subcat_filter_cases.json).
"""
from __future__ import annotations
import re
from typing import Any
RESERVED = {"and", "or", "not", "null"}
# A sub-category NAME is one safe token (see validate_subcat_name).
_NAME_RE = re.compile(r"^[^\s()!,#:}@{$]+$")
_TOKEN_RE = re.compile(r"\s*(\(|\)|,|[^\s()!,#:}@{$]+)")
class ParseError(ValueError):
pass
def _tokenize(s: str) -> list[str]:
out: list[str] = []
i = 0
while i < len(s):
m = _TOKEN_RE.match(s, i)
if not m:
if s[i:].strip() == "": # only trailing whitespace left
break
raise ParseError(f"unexpected character {s[i]!r}")
out.append(m.group(1))
i = m.end()
return out
def parse(s: str) -> dict[str, Any] | None:
"""Parse an expression string to an AST, or None when empty."""
toks = _tokenize(s)
if not toks:
return None
pos = 0
def peek() -> str | None:
return toks[pos] if pos < len(toks) else None
def eat() -> str:
nonlocal pos
t = toks[pos]
pos += 1
return t
def p_or() -> dict[str, Any]:
kids = [p_and()]
while peek() in ("or", ","):
eat()
kids.append(p_and())
return kids[0] if len(kids) == 1 else {"op": "or", "kids": kids}
def p_and() -> dict[str, Any]:
kids = [p_unary()]
while peek() == "and":
eat()
kids.append(p_unary())
return kids[0] if len(kids) == 1 else {"op": "and", "kids": kids}
def p_unary() -> dict[str, Any]:
if peek() == "not":
eat()
return {"op": "not", "x": p_unary()}
return p_atom()
def p_atom() -> dict[str, Any]:
t = peek()
if t == "(":
eat()
inner = p_or()
if peek() != ")":
raise ParseError("missing closing paren )")
eat()
return inner
if t is None:
raise ParseError("expression incomplete (missing a term)")
if t in ("and", "or", "not", ")", ","):
raise ParseError(f"unexpected token {t!r} (missing an operator/term?)")
eat()
return {"tag": t}
ast = p_or()
if pos < len(toks):
raise ParseError(f"unexpected token {toks[pos]!r} (missing an operator?)")
return ast
def matches(ast: dict[str, Any] | None, tags: set[str]) -> bool:
"""True iff the option's `tags` satisfy the expression. Empty expr => True."""
if ast is None:
return True
if "tag" in ast:
return ast["tag"] in tags
op = ast["op"]
if op == "not":
return not matches(ast["x"], tags)
if op == "and":
return all(matches(k, tags) for k in ast["kids"])
return any(matches(k, tags) for k in ast["kids"]) # or
def reads_as(ast: dict[str, Any] | None) -> str:
"""Canonical pretty-print: flatten same-op chains; parenthesize only a
child whose operator differs from the parent's."""
if ast is None:
return ""
if "tag" in ast:
return ast["tag"]
op = ast["op"]
if op == "not":
c = ast["x"]
inner = reads_as(c)
return f"not ({inner})" if ("op" in c and c["op"] in ("and", "or")) else f"not {inner}"
parts = []
for k in ast["kids"]:
inner = reads_as(k)
if "op" in k and k["op"] in ("and", "or") and k["op"] != op:
parts.append(f"({inner})")
else:
parts.append(inner)
return f" {op} ".join(parts)
def validate_subcat_name(name: str) -> str | None:
"""Return an error string, or None when the name is a valid single token."""
if not name:
return "name is empty"
if name.lower() in RESERVED:
return f"reserved word: {name!r} (and/or/not/null are not allowed)"
if any(ch.isspace() for ch in name):
return "name must not contain whitespace"
if not _NAME_RE.match(name):
bad = next(ch for ch in name if ch in "()!,#:}@{$")
return f"disallowed character: {bad!r}"
return None
def validate_expression(s: str, known: set[str]) -> str | None:
"""Return an error string, or None when the expression is valid against `known`."""
try:
ast = parse(s)
except ParseError as e:
return str(e)
if ast is None:
return None
seen: set[str] = set()
def walk(n: dict[str, Any]) -> None:
if "tag" in n:
seen.add(n["tag"])
return
if n["op"] == "not":
walk(n["x"])
return
for k in n["kids"]:
walk(k)
walk(ast)
for t in seen:
if t.lower() in RESERVED:
return f"reserved word used as a term: {t!r}"
if t not in known:
return f"Unknown sub-category: {t!r}"
return None
+61 -39
View File
@@ -13,26 +13,34 @@ import re
from engine.syntax.types import Token, TokenKind
# `$name` — identifier starts with letter or underscore, then word chars
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
# `@{8hex}`, `@{8hex#name}`, `@{8hex:subcat[,subcat...]}`, or
# `@{8hex#name:subcat[,subcat...]}` — exactly 8 lowercase hex chars,
# optionally followed by a `#name` segment (display label cached at
# write-time so a broken ref still tells the user what the wildcard
# was called), optionally followed by a `:` + comma-separated
# sub-category filter. Filter restricts the nested wildcard's pick to
# options whose `sub_category` is in the supplied list — same
# semantics as instance.category_filter at the chain level, but
# scoped per-call so one library wildcard can be narrowed differently
# from each call site. The name segment is purely informational —
# resolver matches on uuid only.
# `$name` or `$name.K` — identifier starts with letter or underscore, then
# word chars, with an optional `.K` list-index accessor (SP2a). Group 2 = the
# index digits when present.
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?")
# 4-segment ref grammar (fixed order): `@{8hex [#name] [:expr] [!null]}`.
# group 1 — uuid: exactly 8 lowercase hex chars.
# group 2 — optional `#name`: display label cached at write-time so a
# broken ref still tells the user what the wildcard was
# called. Purely informational — the resolver matches uuid.
# group 3 — optional `:expr`: a BOOLEAN sub-category filter expression
# (`and`/`or`/`not`/parens/comma=or). Parsed + matched by the
# shared `engine.syntax.subcat_filter` matcher; narrows the
# nested pick to options whose tag set satisfies the
# expression — same matcher as instance.category_filter.
# group 4 — optional `!null`: the exclude-null marker (drops the null
# option from the nested pick). Separate from the expression.
#
# Name char-class `[^#:}@{]` mirrors the wildcard-name validator so
# names that would re-trigger this regex (or break it) are rejected at
# the editor + API boundary, not at parse time.
_REF_RE = re.compile(r"@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}")
# Multi-pick prefix: {N$$sep$$ where N is one or more digits and sep can be empty.
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+)\$\$(.*?)\$\$", flags=re.DOTALL)
# Name/expr char-classes exclude `!` (the null-marker delimiter) so the
# segments stay unambiguous; sub-category names additionally forbid the
# boolean grammar chars (see `subcat_filter.validate_subcat_name`),
# enforced at the editor + API boundary, not at parse time.
_REF_RE = re.compile(
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::([^}!]*))?(?:!([^}]*))?\}"
)
# Multi-pick prefix: {N$$sep$$ or {N-M~$$sep$$ — group 1 is the count (a fixed
# `N` or a `N-M` range), group 2 is the optional `~` independent flag (repeats
# allowed; absent = unique), group 3 is the separator (may be empty). SP2b.
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+(?:-\d+)?)(~?)\$\$(.*?)\$\$", flags=re.DOTALL)
def _split_top_level_pipes(s: str) -> list[str]:
@@ -54,7 +62,7 @@ def _split_top_level_pipes(s: str) -> list[str]:
def _scan_brace_block(
text: str, start: int
) -> tuple[int, list[str], int | None, str | None] | None:
) -> tuple[int, list[str], tuple[int, int, bool] | None, str | None] | None:
"""Try to scan a `{...}` block starting at `text[start] == '{'`.
Returns:
@@ -97,13 +105,21 @@ def _scan_brace_block(
body_with_braces = "{" + body # match against `{N$$sep$$...`
multi_match = _MULTI_PREFIX_RE.match(body_with_braces)
if multi_match:
count = int(multi_match.group(1))
sep = multi_match.group(2)
count_raw = multi_match.group(1) # "N" or "N-M"
independent = multi_match.group(2) == "~" # SP2b: repeats allowed
sep = multi_match.group(3)
# Body remaining after the prefix is the branch list
prefix_len = multi_match.end() - 1 # subtract the leading `{` we added
rest = body[prefix_len:]
branches = _split_top_level_pipes(rest)
return (end_index, branches, count, sep)
if "-" in count_raw:
lo, hi = count_raw.split("-", 1)
cmin, cmax = int(lo), int(hi)
else:
cmin = cmax = int(count_raw)
if cmin > cmax: # normalize a swapped range
cmin, cmax = cmax, cmin
return (end_index, branches, (cmin, cmax, independent), sep)
# Single-pick: must contain at least one top-level `|`
branches = _split_top_level_pipes(body)
@@ -169,12 +185,15 @@ def tokenize_text(text: str) -> list[Token]:
m = _VAR_RE.match(text, i)
if m:
_flush_text(i)
var_meta: dict[str, object] = {"name": m.group(1)}
if m.group(2) is not None:
var_meta["index"] = int(m.group(2))
out.append(Token(
kind=TokenKind.VAR,
raw=m.group(0),
start=i,
end=m.end(),
meta={"name": m.group(1)},
meta=var_meta,
))
i = m.end()
continue
@@ -185,24 +204,21 @@ def tokenize_text(text: str) -> list[Token]:
m = _REF_RE.match(text, i)
if m:
_flush_text(i)
# Group 1: uuid. Group 2: optional display name. Group
# 3: optional sub-category filter (split on comma, strip
# whitespace, drop empties). Empty / whitespace-only
# filter is equivalent to "no filter" — keeps
# `@{xyz:}` from accidentally banning every option.
filter_raw = m.group(3)
sub_categories: list[str] = []
if filter_raw is not None:
sub_categories = [
sc for sc in (s.strip() for s in filter_raw.split(","))
if sc
]
# Group 1: uuid. Group 2: optional cached display name.
# Group 3: optional boolean sub-category filter
# expression (stored raw; the resolver parses it via the
# shared subcat_filter matcher). A whitespace-only
# `:expr` is "no filter" — keeps `@{xyz:}` from banning
# every option. Group 4: the `!null` exclude-null marker.
meta: dict = {"uuid": m.group(1)}
name_raw = m.group(2)
if name_raw:
meta["name"] = name_raw
if sub_categories:
meta["sub_categories"] = sub_categories
expr_raw = m.group(3)
if expr_raw is not None and expr_raw.strip():
meta["filter_expr"] = expr_raw
if m.group(4) == "null":
meta["exclude_null"] = True
out.append(Token(
kind=TokenKind.REF,
raw=m.group(0),
@@ -228,12 +244,18 @@ def tokenize_text(text: str) -> list[Token]:
meta={"branches": branches},
))
else:
cmin, cmax, independent = count
out.append(Token(
kind=TokenKind.DP_MULTI,
raw=text[i:end_index],
start=i,
end=end_index,
meta={"count": count, "sep": sep, "branches": branches},
# `count` kept == max for back-compat readers; min/max
# carry the range, independent carries the `~` flag (SP2b).
meta={
"min": cmin, "max": cmax, "independent": independent,
"count": cmax, "sep": sep, "branches": branches,
},
))
i = end_index
continue
+65 -2
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import enum
import random
import re
from dataclasses import dataclass, field
from typing import (
Any,
@@ -53,6 +54,66 @@ class Token:
meta: dict[str, Any] = field(default_factory=dict)
@dataclass
class ListVar:
"""A list-valued variable plus the separator used to join it for a bare
``$name`` reference (SP2a multi-select). Produced only by a multi-select
wildcard; exists only inside one resolution pass — never persisted or
published. ``$name`` renders ``sep.join(items)``; ``$name.K`` indexes
(0-based; out-of-range -> "").
"""
items: list[str]
sep: str
def __str__(self) -> str:
"""A bare ``$name`` reference renders the list joined by ``sep``.
Defining ``__str__`` (the @dataclass ``__repr__`` stays, for debug)
keeps a list value that leaks into a plain ``str()`` context rendering
as the user-facing joined string instead of ``ListVar(items=[...])``."""
return self.sep.join(self.items)
_VAR_ACCESSOR_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\.(\d+)$")
def split_var_accessor(name: str) -> tuple[str, int | None]:
"""Split a bare var reference into ``(base_name, index)`` (SP2a `.K`).
``"mood.0"`` -> ``("mood", 0)``; ``"mood"`` -> ``("mood", None)``. A name
that is not an ``ident.digits`` shape is returned whole with
``index=None`` so unusual keys pass through unchanged. Callers strip any
leading ``$`` before calling.
"""
if not isinstance(name, str):
return ("", None)
m = _VAR_ACCESSOR_RE.match(name)
if m:
return (m.group(1), int(m.group(2)))
return (name, None)
def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
"""Render a resolved var value to a string, honoring an optional ``.K``
list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` ->
``items[K]`` or ``""`` when out of range. A plain string behaves as a
1-element list (``.0`` == itself, ``.K`` > 0 == ``""``). ``None`` -> ``""``.
Single source of the accessor contract: resolve.py, derivation_handler.py
and converters.py all defer here so the read sites never drift.
"""
if value is None:
return ""
if isinstance(value, ListVar):
if index is not None:
return value.items[index] if 0 <= index < len(value.items) else ""
return value.sep.join(value.items)
s = value if isinstance(value, str) else str(value)
if index is not None:
return s if index == 0 else ""
return s
@runtime_checkable
class ResolveContext(Protocol):
"""Interface the resolver depends on. Construct via build_resolve_ctx in
@@ -67,8 +128,10 @@ class ResolveContext(Protocol):
developer_mode: bool
warnings: list[dict[str, Any]]
def get_var(self, name: str) -> str | None:
"""Return the bound value of `name`, or None if unbound."""
def get_var(self, name: str) -> str | ListVar | None:
"""Return the bound value of `name`, or None if unbound. A
multi-select wildcard binds a ``ListVar`` (SP2a); everything else
binds a ``str``."""
...
def get_module(self, uuid: str) -> dict[str, Any] | None:
+10 -7
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.0.1",
"version": "2.13.2",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
@@ -13,6 +13,7 @@
"build:manager": "vite build --mode manager",
"build": "pnpm build:extension && pnpm build:manager",
"docs:copy": "node scripts/copy-help-docs.mjs",
"docs:figures": "node scripts/build-flow-figures.mjs",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,vue}\"",
"size": "node scripts/check-bundle-size.mjs",
@@ -24,18 +25,17 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:py": "pytest",
"prepare": "husky"
"prepare": "husky",
"announce": "node scripts/discord-announce.mjs"
},
"dependencies": {
"pinia": "^2.2.6",
"primeicons": "^7.0.0",
"vue": "^3.5.12",
"vue-router": "^4.4.5",
"pinia": "^2.2.6",
"primeicons": "^7.0.0"
"zod": "^3.25.76"
},
"devDependencies": {
"tailwindcss": "^3.4.14",
"postcss": "^8.4.47",
"autoprefixer": "^10.4.20",
"@comfyorg/comfyui-frontend-types": "^1.42.15",
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
@@ -48,12 +48,15 @@
"@vitest/coverage-v8": "^2.1.9",
"@vue/test-utils": "^2.4.8",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.31.0",
"eslint-plugin-vuejs-accessibility": "^2.5.0",
"husky": "^9.1.6",
"jsdom": "^29.0.2",
"postcss": "^8.4.47",
"semantic-release": "^25.0.3",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-css-injected-by-js": "^3.5.2",
+3
View File
@@ -20,6 +20,9 @@ importers:
vue-router:
specifier: ^4.4.5
version: 4.6.4(vue@3.5.33(typescript@5.9.3))
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@comfyorg/comfyui-frontend-types':
specifier: ^1.42.15
+131 -127
View File
@@ -1,130 +1,134 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 280" role="img" aria-label="WP Context emits resolved variables, Prompt Assembler fills them, CLIP Encode produces conditioning">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 202" width="880" height="202" role="img" aria-label="Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits resolved dollar-variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning">
<style><![CDATA[
.bg { fill: #14161e; }
.panel { fill: #1a1d2a; }
.border-cyan { stroke: #3a8fb7; }
.border-violet { stroke: #7c6ad6; }
.border-neutral { stroke: #6a6a6a; }
.border-dim { stroke: #353841; }
.ico-cyan { fill: #7ec8e3; }
.ico-violet { fill: #c4b5fd; }
.ico-neutral { fill: #aab; }
.name { fill: #d6d8e0; font: 600 13px 'Inter', system-ui, sans-serif; }
.sub { fill: #6f7384; font: 400 10px ui-monospace, monospace; }
.arrow-lbl { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
.arrow-head { fill: #4a4d55; }
.modules-header { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
.pill-bg { fill: #1a1d2a; }
.pill-stroke-wc { stroke: #b29cf2; }
.pill-stroke-fv { stroke: #2dd4bf; }
.pill-stroke-cb { stroke: #6ad28b; }
.pill-stroke-dv { stroke: #f4a261; }
.pill-stroke-cs { stroke: #ec96b8; }
.pill-stroke-var { stroke: #c4b5fd; }
.pill-text-wc { fill: #b29cf2; }
.pill-text-fv { fill: #2dd4bf; }
.pill-text-cb { fill: #6ad28b; }
.pill-text-dv { fill: #f4a261; }
.pill-text-cs { fill: #ec96b8; }
.pill-text-var { fill: #c4b5fd; }
.pill-text { font: 500 10px 'Inter', system-ui, sans-serif; }
.pill { fill-opacity: 0.12; stroke-width: 1; }
.resolved { fill: #6f7384; font: 600 9.5px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
.down-arrow { stroke: #4a4d55; stroke-width: 1.5; fill: none; }
.frame, .card, .pill, .chip { stroke-width: 1; }
.cap { font: 400 10px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .06em; }
.feeder { font: 400 10.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
.nm { font: 600 12px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
.id { font: 400 9.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
.lbl, .varcap { font: 400 9.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .05em; }
.chipt { font: 400 10.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
.frame { fill: #1e1e1e; stroke: #3a3a3a; }
.card { fill: #232323; stroke: #4a4a4a; }
.card--wp { stroke: #2dd4bf; stroke-opacity: .55; }
.ico--wp { color: #2dd4bf; }
.ico--ext { color: #aaaaaa; }
.ico--wildcard { color: #a78bfa; }
.ico--fixed { color: #22d3ee; }
.ico--combine { color: #34d399; }
.ico--derivation { color: #fbbf24; }
.ico--constraint { color: #f472b6; }
.pill { fill: #232323; stroke: #3a3a3a; }
.feeder { fill: #aaaaaa; }
.nm { fill: #dddddd; }
.id, .lbl, .cap, .varcap { fill: #666666; }
.ln { stroke: #4a4a4a; }
.wash { stop-color: #2dd4bf; }
.flow0 { stop-color: #4a4a4a; }
.head { fill: #4a4a4a; }
.chip { fill: #232323; }
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
.chip--c { stroke: #34d399; stroke-opacity: .4; }
.chipt--a { fill: #c4b5fd; }
.chipt--b { fill: #67e8f9; }
.chipt--c { fill: #86efac; }
@media (prefers-color-scheme: light) {
.frame { fill: #f5f5f5; stroke: #b0b0b0; }
.card { fill: #ffffff; stroke: #999999; }
.card--wp { stroke: #0f766e; stroke-opacity: .55; }
.ico--wp { color: #0f766e; }
.ico--ext { color: #555555; }
.ico--wildcard { color: #6d28d9; }
.ico--fixed { color: #1d4ed8; }
.ico--combine { color: #065f46; }
.ico--derivation { color: #92400e; }
.ico--constraint { color: #9d174d; }
.pill { fill: #ffffff; stroke: #b0b0b0; }
.feeder { fill: #555555; }
.nm { fill: #1a1a1a; }
.id, .lbl, .cap, .varcap { fill: #888888; }
.ln { stroke: #999999; }
.wash { stop-color: #0f766e; }
.flow0 { stop-color: #999999; }
.head { fill: #999999; }
.chip { fill: #ffffff; }
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
.chip--c { stroke: #34d399; stroke-opacity: .4; }
.chipt--a { fill: #6d28d9; }
.chipt--b { fill: #1d4ed8; }
.chipt--c { fill: #065f46; }
}
]]></style>
<rect class="bg" width="980" height="280" rx="8" />
<!-- MODULES INSIDE THE CONTEXT header -->
<text class="modules-header" x="490" y="28" text-anchor="middle">Modules inside the context</text>
<!-- Module kind pills row -->
<g transform="translate(245, 42)">
<!-- Wildcard -->
<rect class="pill pill-stroke-wc" x="0" y="0" width="76" height="22" rx="11" fill="#b29cf2" />
<text class="pill-text pill-text-wc" x="38" y="15" text-anchor="middle">✦ Wildcard</text>
<!-- Fixed Values -->
<rect class="pill pill-stroke-fv" x="84" y="0" width="92" height="22" rx="11" fill="#2dd4bf" />
<text class="pill-text pill-text-fv" x="130" y="15" text-anchor="middle">⌘ Fixed Values</text>
<!-- Combine -->
<rect class="pill pill-stroke-cb" x="184" y="0" width="76" height="22" rx="11" fill="#6ad28b" />
<text class="pill-text pill-text-cb" x="222" y="15" text-anchor="middle">⇄ Combine</text>
<!-- Derivation -->
<rect class="pill pill-stroke-dv" x="268" y="0" width="84" height="22" rx="11" fill="#f4a261" />
<text class="pill-text pill-text-dv" x="310" y="15" text-anchor="middle">⇆ Derivation</text>
<!-- Constraint -->
<rect class="pill pill-stroke-cs" x="360" y="0" width="84" height="22" rx="11" fill="#ec96b8" />
<text class="pill-text pill-text-cs" x="402" y="15" text-anchor="middle">⊥ Constraint</text>
</g>
<!-- Down arrow indicating these go INTO the Context -->
<path class="down-arrow" d="M 490 78 L 490 96 M 484 90 L 490 96 L 496 90" />
<!-- ===== Main flow row at y≈170 ===== -->
<!-- WP Context box -->
<g transform="translate(40, 130)">
<rect class="panel border-cyan" x="0" y="0" width="160" height="100" rx="10" stroke-width="2" />
<text class="ico-cyan" x="80" y="32" text-anchor="middle" font-size="20">⌬</text>
<text class="name" x="80" y="58" text-anchor="middle">WP Context</text>
<text class="sub" x="80" y="78" text-anchor="middle">rolls + resolves</text>
</g>
<!-- EMITS arrow -->
<g transform="translate(210, 180)">
<text class="arrow-lbl" x="50" y="-12" text-anchor="middle">Emits</text>
<line class="arrow-line" x1="0" y1="0" x2="92" y2="0" />
<polygon class="arrow-head" points="92,-5 100,0 92,5" />
</g>
<!-- Resolved vars cluster (no box; chips on transparent) -->
<g transform="translate(320, 160)">
<!-- $subject -->
<rect class="pill pill-stroke-var" x="0" y="0" width="68" height="22" rx="11" fill="#c4b5fd" />
<text class="pill-text pill-text-var" x="34" y="15" text-anchor="middle">$subject</text>
<!-- $style -->
<rect class="pill pill-stroke-var" x="76" y="0" width="52" height="22" rx="11" fill="#c4b5fd" />
<text class="pill-text pill-text-var" x="102" y="15" text-anchor="middle">$style</text>
<!-- $lighting (second row, centered) -->
<rect class="pill pill-stroke-var" x="32" y="30" width="72" height="22" rx="11" fill="#c4b5fd" />
<text class="pill-text pill-text-var" x="68" y="45" text-anchor="middle">$lighting</text>
<!-- RESOLVED $VARS caption -->
<text class="resolved" x="64" y="72" text-anchor="middle">Resolved $vars</text>
</g>
<!-- FILLS arrow -->
<g transform="translate(465, 180)">
<text class="arrow-lbl" x="50" y="-12" text-anchor="middle">Fills</text>
<line class="arrow-line" x1="0" y1="0" x2="92" y2="0" />
<polygon class="arrow-head" points="92,-5 100,0 92,5" />
</g>
<!-- Prompt Assembler box -->
<g transform="translate(575, 130)">
<rect class="panel border-violet" x="0" y="0" width="184" height="100" rx="10" stroke-width="2" />
<text class="ico-violet" x="92" y="32" text-anchor="middle" font-size="20">≡</text>
<text class="name" x="92" y="58" text-anchor="middle">Prompt Assembler</text>
<text class="sub" x="92" y="78" text-anchor="middle">"a $style portrait…"</text>
</g>
<!-- PROMPT arrow -->
<g transform="translate(770, 180)">
<text class="arrow-lbl" x="40" y="-12" text-anchor="middle">Prompt</text>
<line class="arrow-line" x1="0" y1="0" x2="72" y2="0" />
<polygon class="arrow-head" points="72,-5 80,0 72,5" />
</g>
<!-- CLIP Encode box -->
<g transform="translate(860, 130)">
<rect class="panel border-neutral" x="0" y="0" width="88" height="100" rx="10" stroke-width="2" />
<text class="ico-neutral" x="44" y="32" text-anchor="middle" font-size="20">⚡</text>
<text class="name" x="44" y="58" text-anchor="middle">CLIP</text>
<text class="sub" x="44" y="78" text-anchor="middle">conditioning</text>
</g>
<!-- Caption -->
<text class="resolved" x="490" y="260" text-anchor="middle" style="text-transform:none; letter-spacing:0;">
Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP
</text>
<defs>
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
width by 100 percent of the HEIGHT, anchored top-left — so it spans
the whole panel. Squashing it by the aspect ratio instead pins it to
the corner and leaves the rest looking flat. -->
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
<stop class="wash" offset="0" stop-opacity="0.10"/>
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
<stop class="wash" offset="1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.25" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.2" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
<rect class="frame" x="0.5" y="0.5" width="879" height="201" rx="12"/>
<rect x="0.5" y="0.5" width="879" height="201" rx="12" fill="url(#wash)"/>
<text class="cap" x="440" y="30" text-anchor="middle">MODULES INSIDE THE CONTEXT</text>
<rect class="pill" x="214.69" y="40" width="77.68" height="21" rx="10.5"/>
<g class="ico--wildcard"><g fill="currentColor" transform="translate(224.69 45.5) scale(0.4166666666666667)"><path fill-rule="evenodd" clip-rule="evenodd" d="M18.8923 5.29743L20.1462 5.71539C20.4197 5.80656 20.4197 6.19343 20.1462 6.2846L18.8923 6.70256C18.8027 6.73242 18.7324 6.80272 18.7026 6.8923L18.2846 8.14618C18.1934 8.41969 17.8066 8.41969 17.7154 8.14618L17.2974 6.8923C17.2676 6.80272 17.1973 6.73242 17.1077 6.70256L15.8538 6.2846C15.5803 6.19343 15.5803 5.80656 15.8538 5.71539L17.1077 5.29743C17.1973 5.26757 17.2676 5.19728 17.2974 5.1077L17.7154 3.85381C17.8066 3.5803 18.1934 3.5803 18.2846 3.85381L18.7026 5.1077C18.7324 5.19728 18.8027 5.26757 18.8923 5.29743ZM9.19361 5.04743C9.45192 4.27248 10.5481 4.2725 10.8064 5.04743L12.3429 9.65707L16.9526 11.1936C17.7275 11.4519 17.7275 12.5481 16.9526 12.8064L12.3429 14.3429L10.8064 18.9526C10.5481 19.7275 9.45192 19.7275 9.19361 18.9526L7.65706 14.3429L3.04742 12.8064C2.27247 12.5481 2.27249 11.4519 3.04742 11.1936L7.65706 9.65707L9.19361 5.04743ZM9.99999 7.37171L8.97731 10.4397C8.89271 10.6935 8.69354 10.8927 8.43973 10.9773L5.3717 12L8.43973 13.0227C8.69354 13.1073 8.89271 13.3064 8.97731 13.5603L9.99999 16.6283L11.0227 13.5603C11.1073 13.3064 11.3064 13.1073 11.5603 13.0227L14.6283 12L11.5603 10.9773C11.3064 10.8927 11.1073 10.6935 11.0227 10.4397L9.99999 7.37171ZM20.1462 17.7154L18.8923 17.2974C18.8027 17.2676 18.7324 17.1973 18.7026 17.1077L18.2846 15.8538C18.1934 15.5803 17.8066 15.5803 17.7154 15.8538L17.2974 17.1077C17.2676 17.1973 17.1973 17.2676 17.1077 17.2974L15.8538 17.7154C15.5803 17.8066 15.5803 18.1934 15.8538 18.2846L17.1077 18.7026C17.1973 18.7324 17.2676 18.8027 17.2974 18.8923L17.7154 20.1462C17.8066 20.4197 18.1934 20.4197 18.2846 20.1462L18.7026 18.8923C18.7324 18.8027 18.8027 18.7324 18.8923 18.7026L20.1462 18.2846C20.4197 18.1934 20.4197 17.8066 20.1462 17.7154Z"/></g></g>
<text class="feeder" x="238.69" y="54">Wildcard</text>
<rect class="pill" x="298.37" y="40" width="99.52" height="21" rx="10.5"/>
<g class="ico--fixed"><g fill="currentColor" transform="translate(308.37 45.5) scale(0.4166666666666667)"><path d="M12.32,19.98c-.58,0-1.16-.22-1.6-.66l-6.48-6.47c-.14-.14-.22-.33-.22-.53V4.77c0-.41,.34-.75,.75-.75h7.54c.2,0,.39,.08,.53,.22l6.48,6.48c.87,.88,.87,2.31,0,3.19,0,0,0,0,0,0l-5.41,5.41c-.44,.44-1.02,.66-1.6,.66Zm-6.8-7.97l6.26,6.25c.3,.29,.78,.29,1.07,0l5.41-5.41c.29-.29,.29-.77,0-1.07l-6.26-6.26H5.52v6.48Zm13.27,1.37h0Zm-10.29-3.63c-.69,0-1.25-.56-1.25-1.25s.56-1.25,1.25-1.25,1.25,.56,1.25,1.25-.56,1.25-1.25,1.25Z"/></g></g>
<text class="feeder" x="322.37" y="54">Fixed Values</text>
<rect class="pill" x="403.89" y="40" width="72.22" height="21" rx="10.5"/>
<g class="ico--combine"><g fill="currentColor" transform="translate(413.89 45.5) scale(0.4166666666666667)"><g id="link"><path d="M7.23,18.25A4,4,0,0,1,4.4,17.09a4.23,4.23,0,0,1,.15-5.95L8.31,7.35A4.44,4.44,0,0,1,11.42,6,4,4,0,0,1,14.25,7.2a4.25,4.25,0,0,1-.15,6l-1.26,1.26a.75.75,0,1,1-1.06-1.06L13,12.1a2.73,2.73,0,0,0,.14-3.84,2.77,2.77,0,0,0-3.8.15L5.61,12.19A2.74,2.74,0,0,0,5.46,16a2.5,2.5,0,0,0,2,.71.74.74,0,0,1,.81.67.75.75,0,0,1-.67.82Z"/><path d="M12.58,18A4,4,0,0,1,9.75,16.8a4.25,4.25,0,0,1,.15-6l1.26-1.26a.75.75,0,1,1,1.06,1.06L11,11.9a2.73,2.73,0,0,0-.14,3.84,2.77,2.77,0,0,0,3.8-.15l3.77-3.78A2.74,2.74,0,0,0,18.54,8a2.5,2.5,0,0,0-2-.71.74.74,0,0,1-.81-.67.75.75,0,0,1,.67-.82A4,4,0,0,1,19.6,6.91a4.23,4.23,0,0,1-.15,5.95l-3.76,3.79A4.44,4.44,0,0,1,12.58,18Z"/></g></g></g>
<text class="feeder" x="427.89" y="54">Combine</text>
<rect class="pill" x="482.11" y="40" width="88.6" height="21" rx="10.5"/>
<g class="ico--derivation"><g fill="currentColor" transform="translate(492.11 45.5) scale(0.4166666666666667)"><path d="M19.75,16c0,.41-.34,.75-.75,.75H6.81l1.22,1.22c.29,.29,.29,.77,0,1.06-.15,.15-.34,.22-.53,.22s-.38-.07-.53-.22l-2.5-2.5c-.07-.07-.12-.15-.16-.24-.08-.18-.08-.39,0-.57,.04-.09,.09-.17,.16-.24l2.5-2.5c.29-.29,.77-.29,1.06,0s.29,.77,0,1.06l-1.22,1.22h12.19c.41,0,.75,.34,.75,.75ZM5,8.75h12.19l-1.22,1.22c-.29,.29-.29,.77,0,1.06,.15,.15,.34,.22,.53,.22s.38-.07,.53-.22l2.5-2.5c.07-.07,.12-.15,.16-.24,.08-.18,.08-.39,0-.57-.04-.09-.09-.17-.16-.24l-2.5-2.5c-.29-.29-.77-.29-1.06,0s-.29,.77,0,1.06l1.22,1.22H5c-.41,0-.75,.34-.75,.75s.34,.75,.75,.75Z"/></g></g>
<text class="feeder" x="506.11" y="54">Derivation</text>
<rect class="pill" x="576.71" y="40" width="88.6" height="21" rx="10.5"/>
<g class="ico--constraint"><g fill="currentColor" transform="translate(586.71 45.5) scale(0.4166666666666667)"><g id="filter"><path d="M14,20.5H10a.76.76,0,0,1-.75-.75V12L3.9,4.69a.73.73,0,0,1-.07-.78A.76.76,0,0,1,4.5,3.5h15a.76.76,0,0,1,.67.41.73.73,0,0,1-.07.78L14.75,12v7.75A.76.76,0,0,1,14,20.5ZM10.75,19h2.5V11.75a.71.71,0,0,1,.15-.44L18,5H6l4.62,6.31a.71.71,0,0,1,.15.44Z"/></g></g></g>
<text class="feeder" x="600.71" y="54">Constraint</text>
<g class="ico--ext"><g fill="currentColor" transform="translate(434 72) scale(0.5)"><g id="angle-down"><path d="M12,14.5a.74.74,0,0,1-.53-.22L8,10.78A.75.75,0,0,1,9,9.72l3,3,3-3A.75.75,0,0,1,16,10.78l-3.5,3.5A.74.74,0,0,1,12,14.5Z"/></g></g></g>
<rect class="card card--wp" x="50" y="104" width="124" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 115) scale(0.6666666666666666)"><g id="sitemap"><path d="M13.5,9.75h-3A1.76,1.76,0,0,1,8.75,8V5A1.76,1.76,0,0,1,10.5,3.25h3A1.76,1.76,0,0,1,15.25,5V8A1.76,1.76,0,0,1,13.5,9.75Zm-3-5a.25.25,0,0,0-.25.25V8a.25.25,0,0,0,.25.25h3A.25.25,0,0,0,13.75,8V5a.25.25,0,0,0-.25-.25Z"/><path d="M6,19.75H4A1.76,1.76,0,0,1,2.25,18V16A1.76,1.76,0,0,1,4,14.25H6A1.76,1.76,0,0,1,7.75,16v2A1.76,1.76,0,0,1,6,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25H6A.25.25,0,0,0,6.25,18V16A.25.25,0,0,0,6,15.75Z"/><path d="M13,19.75H11A1.76,1.76,0,0,1,9.25,18V16A1.76,1.76,0,0,1,11,14.25h2A1.76,1.76,0,0,1,14.75,16v2A1.76,1.76,0,0,1,13,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M20,19.75H18A1.76,1.76,0,0,1,16.25,18V16A1.76,1.76,0,0,1,18,14.25h2A1.76,1.76,0,0,1,21.75,16v2A1.76,1.76,0,0,1,20,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M19,15.75a.76.76,0,0,1-.75-.75V13a.25.25,0,0,0-.25-.25H6a.25.25,0,0,0-.25.25v2a.75.75,0,0,1-1.5,0V13A1.76,1.76,0,0,1,6,11.25H18A1.76,1.76,0,0,1,19.75,13v2A.76.76,0,0,1,19,15.75Z"/><path d="M12,15.75a.76.76,0,0,1-.75-.75V9a.75.75,0,0,1,1.5,0v6A.76.76,0,0,1,12,15.75Z"/></g></g></g>
<text class="nm" x="112" y="147" text-anchor="middle">WP Context</text>
<text class="id" x="112" y="161" text-anchor="middle">rolls + resolves</text>
<text class="lbl" x="226" y="128" text-anchor="middle">EMITS</text>
<rect fill="url(#flow)" x="182" y="136.2" width="82" height="1.6"/>
<path class="head" d="M264 132.5 L270 137 L264 141.5 Z"/>
<rect class="chip chip--a" x="287.4" y="102" width="64.4" height="19" rx="9.5"/>
<text class="chipt chipt--a" x="319.59999999999997" y="115" text-anchor="middle">$subject</text>
<rect class="chip chip--b" x="356.79999999999995" y="102" width="51.8" height="19" rx="9.5"/>
<text class="chipt chipt--b" x="382.69999999999993" y="115" text-anchor="middle">$style</text>
<rect class="chip chip--c" x="312.65" y="126" width="70.69999999999999" height="19" rx="9.5"/>
<text class="chipt chipt--c" x="348" y="139" text-anchor="middle">$lighting</text>
<text class="varcap" x="348" y="164" text-anchor="middle">RESOLVED $VARS</text>
<text class="lbl" x="457" y="128" text-anchor="middle">FILLS</text>
<rect fill="url(#flow)" x="426" y="136.2" width="56" height="1.6"/>
<path class="head" d="M482 132.5 L488 137 L482 141.5 Z"/>
<rect class="card card--wp" x="496" y="104" width="140" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(558 115) scale(0.6666666666666666)"><g id="align-left"><path d="M13.93,11h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,7h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,15h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M13.93,19h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/></g></g></g>
<text class="nm" x="566" y="147" text-anchor="middle">Prompt Assembler</text>
<text class="id" x="566" y="161" text-anchor="middle">"A $style portrait…"</text>
<text class="lbl" x="673" y="128" text-anchor="middle">PROMPT</text>
<rect fill="url(#flow)" x="644" y="136.2" width="52" height="1.6"/>
<path class="head" d="M696 132.5 L702 137 L696 141.5 Z"/>
<rect class="card card--ext" x="710" y="104" width="124" height="66" rx="10"/>
<g class="ico ico--ext"><g fill="currentColor" transform="translate(764 115) scale(0.6666666666666666)"><g id="bolt"><path d="M11.09,21.5a.67.67,0,0,1-.24,0,.83.83,0,0,1-.59-.81v-.11l.9-6.35H6.82a.8.8,0,0,1-.71-.43.85.85,0,0,1,0-.86l2-3.49,4.1-6.52a.79.79,0,0,1,.92-.35.83.83,0,0,1,.59.81v.11l-.9,6.35h4.35a.8.8,0,0,1,.71.43.85.85,0,0,1,0,.86l-2,3.49-4.1,6.52A.79.79,0,0,1,11.09,21.5Zm-.53-1.21h0Zm-2.63-7.6h4a.84.84,0,0,1,.83.85v.11l-.59,4.14,2.5-4,1.44-2.48h-4a.84.84,0,0,1-.83-.85v-.11l.59-4.14-2.5,4Zm-.57,1Zm9.28-3.34ZM13.44,3.7h0Z"/></g></g></g>
<text class="nm" x="772" y="147" text-anchor="middle">CLIP Encode</text>
<text class="id" x="772" y="161" text-anchor="middle">conditioning</text>
</svg>

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+129 -99
View File
@@ -1,102 +1,132 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 240" role="img" aria-label="WP Context Loop emits N prompts and feeds WP Seed List N seeds; both fan out into KSampler in lockstep">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 286" width="880" height="286" role="img" aria-label="WP Context Loop drives WP Context and the Prompt Assembler to produce N prompts, CLIP Encode turns them into conditioning for KSampler, and WP Seed List supplies the matching seed for each iteration directly to KSampler">
<style><![CDATA[
.bg { fill: #14161e; }
.panel { fill: #1a1d2a; }
.border-loop { stroke: #7c6ad6; }
.border-ctx { stroke: #3a8fb7; }
.border-asm { stroke: #7c6ad6; }
.border-seed { stroke: #3a8fb7; }
.border-k { stroke: #6a6a6a; }
.ico-loop { fill: #c4b5fd; }
.ico-ctx { fill: #7ec8e3; }
.ico-asm { fill: #c4b5fd; }
.ico-seed { fill: #7ec8e3; }
.ico-k { fill: #aab; }
.name { fill: #d6d8e0; font: 600 13px 'Inter', system-ui, sans-serif; }
.sub { fill: #6f7384; font: 400 10px ui-monospace, monospace; }
.arrow-lbl { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
.arrow-line-dim { stroke: #353841; stroke-width: 2; fill: none; stroke-dasharray: 5 4; }
.arrow-head { fill: #4a4d55; }
.caption { fill: #6f7384; font: 500 11px 'Inter', system-ui, sans-serif; }
.frame, .card, .pill, .chip { stroke-width: 1; }
.cap { font: 400 10px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .06em; }
.feeder { font: 400 10.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
.nm { font: 600 12px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
.id { font: 400 9.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
.lbl, .varcap { font: 400 9.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .05em; }
.chipt { font: 400 10.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
.frame { fill: #1e1e1e; stroke: #3a3a3a; }
.card { fill: #232323; stroke: #4a4a4a; }
.card--wp { stroke: #2dd4bf; stroke-opacity: .55; }
.ico--wp { color: #2dd4bf; }
.ico--ext { color: #aaaaaa; }
.ico--wildcard { color: #a78bfa; }
.ico--fixed { color: #22d3ee; }
.ico--combine { color: #34d399; }
.ico--derivation { color: #fbbf24; }
.ico--constraint { color: #f472b6; }
.pill { fill: #232323; stroke: #3a3a3a; }
.feeder { fill: #aaaaaa; }
.nm { fill: #dddddd; }
.id, .lbl, .cap, .varcap { fill: #666666; }
.ln { stroke: #4a4a4a; }
.wash { stop-color: #2dd4bf; }
.flow0 { stop-color: #4a4a4a; }
.head { fill: #4a4a4a; }
.chip { fill: #232323; }
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
.chip--c { stroke: #34d399; stroke-opacity: .4; }
.chipt--a { fill: #c4b5fd; }
.chipt--b { fill: #67e8f9; }
.chipt--c { fill: #86efac; }
@media (prefers-color-scheme: light) {
.frame { fill: #f5f5f5; stroke: #b0b0b0; }
.card { fill: #ffffff; stroke: #999999; }
.card--wp { stroke: #0f766e; stroke-opacity: .55; }
.ico--wp { color: #0f766e; }
.ico--ext { color: #555555; }
.ico--wildcard { color: #6d28d9; }
.ico--fixed { color: #1d4ed8; }
.ico--combine { color: #065f46; }
.ico--derivation { color: #92400e; }
.ico--constraint { color: #9d174d; }
.pill { fill: #ffffff; stroke: #b0b0b0; }
.feeder { fill: #555555; }
.nm { fill: #1a1a1a; }
.id, .lbl, .cap, .varcap { fill: #888888; }
.ln { stroke: #999999; }
.wash { stop-color: #0f766e; }
.flow0 { stop-color: #999999; }
.head { fill: #999999; }
.chip { fill: #ffffff; }
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
.chip--c { stroke: #34d399; stroke-opacity: .4; }
.chipt--a { fill: #6d28d9; }
.chipt--b { fill: #1d4ed8; }
.chipt--c { fill: #065f46; }
}
]]></style>
<rect class="bg" width="980" height="240" rx="8" />
<!-- WP Context Loop -->
<g transform="translate(30, 70)">
<rect class="panel border-loop" x="0" y="0" width="160" height="100" rx="10" stroke-width="2" />
<text class="ico-loop" x="80" y="32" text-anchor="middle" font-size="20">↺</text>
<text class="name" x="80" y="58" text-anchor="middle">WP Context Loop</text>
<text class="sub" x="80" y="78" text-anchor="middle">N prompts</text>
</g>
<!-- Loop splits into TWO lanes -->
<!-- Lane A: loop_config → Seed List → seed[] -->
<g transform="translate(200, 60)">
<text class="arrow-lbl" x="50" y="-6" text-anchor="middle">loop_config</text>
<line class="arrow-line" x1="0" y1="6" x2="92" y2="6" />
<polygon class="arrow-head" points="92,1 100,6 92,11" />
</g>
<g transform="translate(310, 30)">
<rect class="panel border-seed" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
<text class="ico-seed" x="80" y="28" text-anchor="middle" font-size="18">#</text>
<text class="name" x="80" y="50" text-anchor="middle">WP Seed List</text>
<text class="sub" x="80" y="68" text-anchor="middle">N seeds</text>
</g>
<!-- Lane B: context → WP Context -->
<g transform="translate(200, 160)">
<text class="arrow-lbl" x="50" y="-6" text-anchor="middle">context</text>
<line class="arrow-line" x1="0" y1="6" x2="92" y2="6" />
<polygon class="arrow-head" points="92,1 100,6 92,11" />
</g>
<g transform="translate(310, 130)">
<rect class="panel border-ctx" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
<text class="ico-ctx" x="80" y="28" text-anchor="middle" font-size="18">⌬</text>
<text class="name" x="80" y="50" text-anchor="middle">WP Context</text>
<text class="sub" x="80" y="68" text-anchor="middle">module stack</text>
</g>
<!-- WP Context → Prompt Assembler -->
<g transform="translate(480, 160)">
<line class="arrow-line" x1="0" y1="6" x2="62" y2="6" />
<polygon class="arrow-head" points="62,1 70,6 62,11" />
</g>
<!-- Prompt Assembler -->
<g transform="translate(560, 130)">
<rect class="panel border-asm" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
<text class="ico-asm" x="80" y="28" text-anchor="middle" font-size="18">≡</text>
<text class="name" x="80" y="50" text-anchor="middle">Prompt Assembler</text>
<text class="sub" x="80" y="68" text-anchor="middle">N prompts</text>
</g>
<!-- Seed List → KSampler (top lane) -->
<g transform="translate(480, 60)">
<text class="arrow-lbl" x="180" y="-6" text-anchor="middle">seed[]</text>
<path class="arrow-line" d="M 0 6 L 360 6 L 360 64" />
<polygon class="arrow-head" points="355,59 360,67 365,59" />
</g>
<!-- Assembler → KSampler (bottom lane joining top arrow at sampler) -->
<g transform="translate(730, 160)">
<text class="arrow-lbl" x="40" y="-6" text-anchor="middle">prompt[]</text>
<line class="arrow-line" x1="0" y1="6" x2="62" y2="6" />
<polygon class="arrow-head" points="62,1 70,6 62,11" />
</g>
<!-- KSampler -->
<g transform="translate(810, 130)">
<rect class="panel border-k" x="0" y="0" width="140" height="80" rx="10" stroke-width="2" />
<text class="ico-k" x="70" y="28" text-anchor="middle" font-size="18">⚡</text>
<text class="name" x="70" y="50" text-anchor="middle">KSampler</text>
<text class="sub" x="70" y="68" text-anchor="middle">N unique images</text>
</g>
<!-- Caption -->
<text class="caption" x="490" y="225" text-anchor="middle">
Loop emits N prompts · Seed List emits N seeds · both lists fan out into the sampler in lockstep — iteration N's prompt pairs with seed N
</text>
<defs>
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
width by 100 percent of the HEIGHT, anchored top-left — so it spans
the whole panel. Squashing it by the aspect ratio instead pins it to
the corner and leaves the rest looking flat. -->
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
<stop class="wash" offset="0" stop-opacity="0.10"/>
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
<stop class="wash" offset="1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.25" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.2" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
<rect class="frame" x="0.5" y="0.5" width="879" height="285" rx="12"/>
<rect x="0.5" y="0.5" width="879" height="285" rx="12" fill="url(#wash)"/>
<text class="cap" x="440" y="30" text-anchor="middle">BOTH LISTS FAN OUT IN LOCKSTEP — ITERATION N MEETS SEED N</text>
<rect class="card card--wp" x="48.199999999999996" y="54" width="127.60000000000001" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 65) scale(0.6666666666666666)"><g id="refresh"><path d="M12,20.75a7.25,7.25,0,0,1,0-14.5h2.5a.75.75,0,0,1,0,1.5H12a5.75,5.75,0,1,0,5.75,5.75.75.75,0,0,1,1.5,0A7.26,7.26,0,0,1,12,20.75Z"/><path d="M12,10.75a.74.74,0,0,1-.53-.22.75.75,0,0,1,0-1.06L13.94,7,11.47,4.53a.75.75,0,1,1,1.06-1.06l3,3a.75.75,0,0,1,0,1.06l-3,3A.74.74,0,0,1,12,10.75Z"/></g></g></g>
<text class="nm" x="112" y="97" text-anchor="middle">WP Context Loop</text>
<text class="id" x="112" y="111" text-anchor="middle">N iterations</text>
<rect class="card card--wp" x="50" y="158" width="124" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 169) scale(0.6666666666666666)"><g id="list"><path d="M19,12.75H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M19,8.25H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M19,17.25H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M5,8.5a1,1,0,0,1-.38-.07,1.46,1.46,0,0,1-.33-.22A1,1,0,0,1,4,7.5a1.05,1.05,0,0,1,.29-.71.93.93,0,0,1,.33-.21,1,1,0,0,1,.76,0,1,1,0,0,1,.33.21A1.05,1.05,0,0,1,6,7.5a1,1,0,0,1-.29.71,1.46,1.46,0,0,1-.33.22A1,1,0,0,1,5,8.5Z"/><path d="M5,13a1,1,0,0,1-.38-.08,1.15,1.15,0,0,1-.33-.21A1,1,0,0,1,4,12a1.05,1.05,0,0,1,.29-.71,1.15,1.15,0,0,1,.33-.21A1,1,0,0,1,5.2,11l.18.06.18.09a1.58,1.58,0,0,1,.15.12A1.05,1.05,0,0,1,6,12a1,1,0,0,1-1,1Z"/><path d="M5,17.5a1,1,0,0,1-.38-.07,1.46,1.46,0,0,1-.33-.22,1.15,1.15,0,0,1-.21-.33.94.94,0,0,1,0-.76,1.15,1.15,0,0,1,.21-.33,1,1,0,0,1,1.09-.21,1,1,0,0,1,.33.21,1.15,1.15,0,0,1,.21.33.94.94,0,0,1,0,.76,1.15,1.15,0,0,1-.21.33A1,1,0,0,1,5,17.5Z"/></g></g></g>
<text class="nm" x="112" y="201" text-anchor="middle">WP Seed List</text>
<text class="id" x="112" y="215" text-anchor="middle">N seeds</text>
<rect class="card card--wp" x="278" y="54" width="124" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(332 65) scale(0.6666666666666666)"><g id="sitemap"><path d="M13.5,9.75h-3A1.76,1.76,0,0,1,8.75,8V5A1.76,1.76,0,0,1,10.5,3.25h3A1.76,1.76,0,0,1,15.25,5V8A1.76,1.76,0,0,1,13.5,9.75Zm-3-5a.25.25,0,0,0-.25.25V8a.25.25,0,0,0,.25.25h3A.25.25,0,0,0,13.75,8V5a.25.25,0,0,0-.25-.25Z"/><path d="M6,19.75H4A1.76,1.76,0,0,1,2.25,18V16A1.76,1.76,0,0,1,4,14.25H6A1.76,1.76,0,0,1,7.75,16v2A1.76,1.76,0,0,1,6,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25H6A.25.25,0,0,0,6.25,18V16A.25.25,0,0,0,6,15.75Z"/><path d="M13,19.75H11A1.76,1.76,0,0,1,9.25,18V16A1.76,1.76,0,0,1,11,14.25h2A1.76,1.76,0,0,1,14.75,16v2A1.76,1.76,0,0,1,13,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M20,19.75H18A1.76,1.76,0,0,1,16.25,18V16A1.76,1.76,0,0,1,18,14.25h2A1.76,1.76,0,0,1,21.75,16v2A1.76,1.76,0,0,1,20,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M19,15.75a.76.76,0,0,1-.75-.75V13a.25.25,0,0,0-.25-.25H6a.25.25,0,0,0-.25.25v2a.75.75,0,0,1-1.5,0V13A1.76,1.76,0,0,1,6,11.25H18A1.76,1.76,0,0,1,19.75,13v2A.76.76,0,0,1,19,15.75Z"/><path d="M12,15.75a.76.76,0,0,1-.75-.75V9a.75.75,0,0,1,1.5,0v6A.76.76,0,0,1,12,15.75Z"/></g></g></g>
<text class="nm" x="340" y="97" text-anchor="middle">WP Context</text>
<text class="id" x="340" y="111" text-anchor="middle">module stack</text>
<rect class="card card--wp" x="499.08" y="54" width="133.84" height="66" rx="10"/>
<g class="ico ico--wp"><g fill="currentColor" transform="translate(558 65) scale(0.6666666666666666)"><g id="align-left"><path d="M13.93,11h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,7h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,15h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M13.93,19h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/></g></g></g>
<text class="nm" x="566" y="97" text-anchor="middle">Prompt Assembler</text>
<text class="id" x="566" y="111" text-anchor="middle">N prompts</text>
<rect class="card card--ext" x="504" y="158" width="124" height="66" rx="10"/>
<g class="ico ico--ext"><g fill="currentColor" transform="translate(558 169) scale(0.6666666666666666)"><g id="bolt"><path d="M11.09,21.5a.67.67,0,0,1-.24,0,.83.83,0,0,1-.59-.81v-.11l.9-6.35H6.82a.8.8,0,0,1-.71-.43.85.85,0,0,1,0-.86l2-3.49,4.1-6.52a.79.79,0,0,1,.92-.35.83.83,0,0,1,.59.81v.11l-.9,6.35h4.35a.8.8,0,0,1,.71.43.85.85,0,0,1,0,.86l-2,3.49-4.1,6.52A.79.79,0,0,1,11.09,21.5Zm-.53-1.21h0Zm-2.63-7.6h4a.84.84,0,0,1,.83.85v.11l-.59,4.14,2.5-4,1.44-2.48h-4a.84.84,0,0,1-.83-.85v-.11l.59-4.14-2.5,4Zm-.57,1Zm9.28-3.34ZM13.44,3.7h0Z"/></g></g></g>
<text class="nm" x="566" y="201" text-anchor="middle">CLIP Encode</text>
<text class="id" x="566" y="215" text-anchor="middle">N conditionings</text>
<rect class="card card--ext" x="710" y="158" width="124" height="66" rx="10"/>
<g class="ico ico--ext"><g fill="currentColor" transform="translate(764 169) scale(0.6666666666666666)"><g id="image"><path d="M18,4.25H6A2.75,2.75,0,0,0,3.25,7V17A2.75,2.75,0,0,0,6,19.75H18A2.75,2.75,0,0,0,20.75,17V7A2.75,2.75,0,0,0,18,4.25ZM6,5.75H18A1.25,1.25,0,0,1,19.25,7v8.19l-2.72-2.72a.7.7,0,0,0-.56-.22.79.79,0,0,0-.55.27l-1.29,1.55-4.6-4.6A.7.7,0,0,0,9,9.25a.79.79,0,0,0-.55.27L4.75,13.93V7A1.25,1.25,0,0,1,6,5.75ZM4.75,17v-.73l4.3-5.16,4.12,4.12-2.52,3H6A1.25,1.25,0,0,1,4.75,17ZM18,18.25H12.6l3.45-4.14,3.15,3.15A1.23,1.23,0,0,1,18,18.25Z"/></g></g></g>
<text class="nm" x="772" y="201" text-anchor="middle">KSampler</text>
<text class="id" x="772" y="215" text-anchor="middle">N images</text>
<text class="lbl" x="226.9" y="78" text-anchor="middle">CONTEXT</text>
<rect fill="url(#flow)" x="183.8" y="86.2" width="80.19999999999999" height="1.6"/>
<path class="head" d="M264 82.5 L270 87 L264 91.5 Z"/>
<text class="lbl" x="122" y="143">LOOP_CONFIG</text>
<rect fill="url(#flowv)" x="111.2" y="120" width="1.6" height="32"/>
<path class="head" d="M108 152 L112 158 L116 152 Z"/>
<text class="lbl" x="450.53999999999996" y="78" text-anchor="middle">$VARS</text>
<rect fill="url(#flow)" x="410" y="86.2" width="75.07999999999998" height="1.6"/>
<path class="head" d="M485.08 82.5 L491.08 87 L485.08 91.5 Z"/>
<text class="lbl" x="576" y="141">PROMPT</text>
<rect fill="url(#flowv)" x="565.2" y="120" width="1.6" height="32"/>
<path class="head" d="M562 152 L566 158 L570 152 Z"/>
<text class="lbl" x="669" y="182" text-anchor="middle">COND</text>
<rect fill="url(#flow)" x="636" y="190.2" width="60" height="1.6"/>
<path class="head" d="M696 186.5 L702 191 L696 195.5 Z"/>
<path class="ln" fill="none" d="M112 224 V 252 H 772 V 230"/>
<path class="head" d="M768 230 L772 224 L776 230 Z"/>
<text class="lbl" x="420" y="244" text-anchor="middle">SEED[]</text>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.0.1"
version = "2.13.2"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+342
View File
@@ -0,0 +1,342 @@
/**
* Generate the README flow figures.
*
* The SPA draws these as Vue + CSS with PrimeIcons webfont glyphs
* (`src/manager/components/docs/PipelineDiagram.vue`, framed by
* `DocFigure.vue`). A README can run neither: GitHub serves the file through
* `<img>`, so there is no component runtime and no icon font.
*
* So this reproduces that figure in SVG, deriving every value from the real
* component rather than approximating it by eye:
*
* frame --wp-bg-1 / --wp-border, radius --wp-radius-lg (12)
* card --wp-bg-2, --wp-border-strong, radius 10, min-width 120,
* column layout: icon 16 / name 12 semibold / id 9.5 mono dim
* WP card border tinted with --wp-node, icon in --wp-node
* feeders pills, --wp-bg-2 + --wp-border, 10.5px, icon in the kind colour
* labels 9.5px uppercase, letter-spacing .04em, --wp-text-dim
* var chips mono 10.5px, pill, per-chip accent border
*
* Icons are the REAL PrimeIcons paths from `primeicons/raw-svg` — the same
* source the webfont is built from — so the README shows the same glyphs the
* app does.
*
* Both themes ship in one file: GitHub honours `prefers-color-scheme` inside
* an SVG served to `<img>`, so the light block below mirrors the SPA's light
* theme rather than washing the dark one out.
*
* Run: node scripts/build-flow-figures.mjs
*/
import fs from "node:fs";
import path from "node:path";
const ICON_DIR = "node_modules/primeicons/raw-svg";
const OUT_DIR = "public/images/docs";
/* ── tokens, lifted from src/components/shared/theme.css + manager tokens ── */
const DARK = {
frameBg: "#1e1e1e", frameBd: "#3a3a3a",
cardBg: "#232323", cardBd: "#4a4a4a",
node: "#2dd4bf",
text: "#dddddd", muted: "#aaaaaa", dim: "#666666",
wildcard: "#a78bfa", fixed: "#22d3ee", combine: "#34d399",
derivation: "#fbbf24", constraint: "#f472b6",
chipA: "#c4b5fd", chipAb: "#8b5cf6",
chipB: "#67e8f9", chipBb: "#22d3ee",
chipC: "#86efac", chipCb: "#34d399",
};
const LIGHT = {
frameBg: "#f5f5f5", frameBd: "#b0b0b0",
cardBg: "#ffffff", cardBd: "#999999",
node: "#0f766e",
text: "#1a1a1a", muted: "#555555", dim: "#888888",
wildcard: "#6d28d9", fixed: "#1d4ed8", combine: "#065f46",
derivation: "#92400e", constraint: "#9d174d",
chipA: "#6d28d9", chipAb: "#8b5cf6",
chipB: "#1d4ed8", chipBb: "#22d3ee",
chipC: "#065f46", chipCb: "#34d399",
};
const SANS = "'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
const MONO = "'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace";
/** Rough advance width. Good enough to size pills; every label is centred, so
* a few px of slop never misaligns text against its box. */
const textW = (s, px, mono = false) => s.length * px * (mono ? 0.6 : 0.52);
/** Inner markup of a PrimeIcon, scaled to `size` and centred on (cx, cy).
*
* Colour comes from the wrapping `<g fill="currentColor">`, NOT from rewriting
* each path. Most PrimeIcons declare no `fill` at all (only `sparkles` does),
* so a rewrite-what-exists approach leaves them at SVG's default black — which
* is exactly how every node icon came out black while the feeder pills, whose
* icons happened to carry fills, coloured correctly.
*
* `fill="none"` is preserved: on the icons that use it, it marks the part that
* is deliberately hollow, and filling it would blob the glyph.
*/
function icon(name, cx, cy, size) {
const raw = fs.readFileSync(path.join(ICON_DIR, `${name}.svg`), "utf8");
const inner = raw
.replace(/^[\s\S]*?<svg[^>]*>/, "")
.replace(/<\/svg>\s*$/, "")
.replace(/\s(?:width|height)="[^"]*"/g, "")
// Drop concrete fills so the group's currentColor wins; keep `none`.
.replace(/\sfill="(?!none)[^"]*"/g, "")
.replace(/\sstroke="(?!none)[^"]*"/g, ' stroke="currentColor"')
.trim();
const s = size / 24;
return `<g fill="currentColor" transform="translate(${cx - size / 2} ${cy - size / 2}) scale(${s})">${inner}</g>`;
}
/** A node card. Vertical, centred: icon, name, mono subtitle — the SPA's
* `.wp-doc-diagram__node-box`. `wp` cards take the teal node accent. */
function card(cx, top, label, sub, iconName, kind = "wp") {
const w = Math.max(124, textW(label, 12) + 34, textW(sub, 9.5, true) + 26);
const h = 66;
const x = cx - w / 2;
return {
w, h,
svg: [
`<rect class="card card--${kind}" x="${x}" y="${top}" width="${w}" height="${h}" rx="10"/>`,
`<g class="ico ico--${kind}">${icon(iconName, cx, top + 19, 16)}</g>`,
`<text class="nm" x="${cx}" y="${top + 43}" text-anchor="middle">${label}</text>`,
`<text class="id" x="${cx}" y="${top + 57}" text-anchor="middle">${sub}</text>`,
].join("\n "),
};
}
/** Labelled connector, matching `.wp-doc-diagram__arrow`: uppercase label
* above a hairline that ends in a small solid head. */
function connector(x1, x2, y, label) {
const mid = (x1 + x2) / 2;
return [
label ? `<text class="lbl" x="${mid}" y="${y - 9}" text-anchor="middle">${label}</text>` : "",
// Gradient fill, not a stroke: the SPA's `.wp-doc-diagram__arrow-ln` fades
// in and out at both ends so a connector reads as a flow rather than a
// drawn border. It has to be a RECT — a horizontal <line> has a zero-height
// bounding box, so an objectBoundingBox gradient on it collapses and the
// line disappears entirely, leaving a floating arrowhead.
`<rect fill="url(#flow)" x="${x1}" y="${y - 0.8}" width="${x2 - 6 - x1}" height="1.6"/>`,
`<path class="head" d="M${x2 - 6} ${y - 4.5} L${x2} ${y} L${x2 - 6} ${y + 4.5} Z"/>`,
].join("\n ");
}
/** Pill row of the five module kinds — `.wp-doc-diagram__feeder`. */
function feeders(cx, y) {
const items = [
["sparkles", "Wildcard", "wildcard"],
["tag", "Fixed Values", "fixed"],
["link", "Combine", "combine"],
["arrow-right-arrow-left", "Derivation", "derivation"],
["filter", "Constraint", "constraint"],
];
const gap = 6;
const pills = items.map(([ico, label, kind]) => {
const w = 9 + 10 + 6 + textW(label, 10.5) + 9;
return { ico, label, kind, w };
});
const total = pills.reduce((a, p) => a + p.w, 0) + gap * (pills.length - 1);
let x = cx - total / 2;
const out = pills.map((p) => {
const s = [
`<rect class="pill" x="${x}" y="${y}" width="${p.w}" height="21" rx="10.5"/>`,
`<g class="ico--${p.kind}">${icon(p.ico, x + 15, y + 10.5, 10)}</g>`,
`<text class="feeder" x="${x + 24}" y="${y + 14}">${p.label}</text>`,
].join("\n ");
x += p.w + gap;
return s;
});
return out.join("\n ");
}
/** The `$vars` stage: chips over a small uppercase caption. */
function varBag(cx, top) {
const chips = [["$subject", "a"], ["$style", "b"], ["$lighting", "c"]];
const gap = 5;
const sized = chips.map(([t, v]) => ({ t, v, w: textW(t, 10.5, true) + 14 }));
// Two rows, mirroring the SPA's 150px-capped wrapping bag.
const row1 = sized.slice(0, 2);
const row2 = sized.slice(2);
const line = (items, y) => {
const total = items.reduce((a, c) => a + c.w, 0) + gap * (items.length - 1);
let x = cx - total / 2;
return items.map((c) => {
const s = [
`<rect class="chip chip--${c.v}" x="${x}" y="${y}" width="${c.w}" height="19" rx="9.5"/>`,
`<text class="chipt chipt--${c.v}" x="${x + c.w / 2}" y="${y + 13}" text-anchor="middle">${c.t}</text>`,
].join("\n ");
x += c.w + gap;
return s;
}).join("\n ");
};
return [
line(row1, top),
line(row2, top + 24),
`<text class="varcap" x="${cx}" y="${top + 62}" text-anchor="middle">RESOLVED $VARS</text>`,
].join("\n ");
}
function themeBlock(t, prefix = "") {
return `
${prefix}.frame { fill: ${t.frameBg}; stroke: ${t.frameBd}; }
${prefix}.card { fill: ${t.cardBg}; stroke: ${t.cardBd}; }
${prefix}.card--wp { stroke: ${t.node}; stroke-opacity: .55; }
${prefix}.ico--wp { color: ${t.node}; }
${prefix}.ico--ext { color: ${t.muted}; }
${prefix}.ico--wildcard { color: ${t.wildcard}; }
${prefix}.ico--fixed { color: ${t.fixed}; }
${prefix}.ico--combine { color: ${t.combine}; }
${prefix}.ico--derivation { color: ${t.derivation}; }
${prefix}.ico--constraint { color: ${t.constraint}; }
${prefix}.pill { fill: ${t.cardBg}; stroke: ${t.frameBd}; }
${prefix}.feeder { fill: ${t.muted}; }
${prefix}.nm { fill: ${t.text}; }
${prefix}.id, ${prefix}.lbl, ${prefix}.cap, ${prefix}.varcap { fill: ${t.dim}; }
${prefix}.ln { stroke: ${t.cardBd}; }
${prefix}.wash { stop-color: ${t.node}; }
${prefix}.flow0 { stop-color: ${t.cardBd}; }
${prefix}.head { fill: ${t.cardBd}; }
${prefix}.chip { fill: ${t.cardBg}; }
${prefix}.chip--a { stroke: ${t.chipAb}; stroke-opacity: .4; }
${prefix}.chip--b { stroke: ${t.chipBb}; stroke-opacity: .4; }
${prefix}.chip--c { stroke: ${t.chipCb}; stroke-opacity: .4; }
${prefix}.chipt--a { fill: ${t.chipA}; }
${prefix}.chipt--b { fill: ${t.chipB}; }
${prefix}.chipt--c { fill: ${t.chipC}; }`;
}
function shell(w, h, label, body) {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="${label}">
<style><![CDATA[
.frame, .card, .pill, .chip { stroke-width: 1; }
.cap { font: 400 10px ${SANS}; letter-spacing: .06em; }
.feeder { font: 400 10.5px ${SANS}; }
.nm { font: 600 12px ${SANS}; }
.id { font: 400 9.5px ${MONO}; }
.lbl, .varcap { font: 400 9.5px ${SANS}; letter-spacing: .05em; }
.chipt { font: 400 10.5px ${MONO}; }
${themeBlock(DARK)}
@media (prefers-color-scheme: light) {${themeBlock(LIGHT, " ")}
}
]]></style>
<defs>
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
width by 100 percent of the HEIGHT, anchored top-left — so it spans
the whole panel. Squashing it by the aspect ratio instead pins it to
the corner and leaves the rest looking flat. -->
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
<stop class="wash" offset="0" stop-opacity="0.10"/>
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
<stop class="wash" offset="1" stop-opacity="0"/>
</radialGradient>
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.25" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
<stop class="flow0" offset="0" stop-opacity="0"/>
<stop class="flow0" offset="0.2" stop-opacity="1"/>
<stop class="flow0" offset="0.8" stop-opacity="1"/>
<stop class="flow0" offset="1" stop-opacity="0"/>
</linearGradient>
</defs>
<rect class="frame" x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="12"/>
<rect x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="12" fill="url(#wash)"/>
${body}
</svg>
`;
}
/* ── figure 1 — what the pipeline does ─────────────────────────────────── */
function buildIntro() {
const W = 880;
const H = 202;
const cx = W / 2;
const rowTop = 104;
const midY = rowTop + 33;
const ctx = card(112, rowTop, "WP Context", "rolls + resolves", "sitemap");
const asm = card(566, rowTop, "Prompt Assembler", '"A $style portrait…"', "align-left");
const clip = card(772, rowTop, "CLIP Encode", "conditioning", "bolt", "ext");
const varsCx = 348;
const body = [
`<text class="cap" x="${cx}" y="30" text-anchor="middle">MODULES INSIDE THE CONTEXT</text>`,
feeders(cx, 40),
`<g class="ico--ext">${icon("angle-down", cx, 78, 12)}</g>`,
ctx.svg,
connector(112 + ctx.w / 2 + 8, varsCx - 78, midY, "EMITS"),
varBag(varsCx, rowTop - 2),
connector(varsCx + 78, 566 - asm.w / 2 - 8, midY, "FILLS"),
asm.svg,
connector(566 + asm.w / 2 + 8, 772 - clip.w / 2 - 8, midY, "PROMPT"),
clip.svg,
].join("\n ");
return shell(
W,
H,
"Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits resolved dollar-variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning",
body,
);
}
/* ── figure 2 — looping a chain ────────────────────────────────────────────
* The old figure ran the assembled prompt straight into KSampler. It does not
* go there: text is encoded first and only conditioning reaches the sampler.
* Seeds are the one thing that DOES arrive directly, so they get their own
* lane BELOW the text path rather than cutting through it. */
function buildLoop() {
const W = 880;
const H = 286;
const cx = W / 2;
const r1 = 54;
const r2 = 158;
const mid1 = r1 + 33;
const mid2 = r2 + 33;
const laneY = 252;
const loop = card(112, r1, "WP Context Loop", "N iterations", "refresh");
const seeds = card(112, r2, "WP Seed List", "N seeds", "list");
const ctx = card(340, r1, "WP Context", "module stack", "sitemap");
const asm = card(566, r1, "Prompt Assembler", "N prompts", "align-left");
const clip = card(566, r2, "CLIP Encode", "N conditionings", "bolt", "ext");
const ks = card(772, r2, "KSampler", "N images", "image", "ext");
const body = [
`<text class="cap" x="${cx}" y="30" text-anchor="middle">BOTH LISTS FAN OUT IN LOCKSTEP — ITERATION N MEETS SEED N</text>`,
loop.svg, seeds.svg, ctx.svg, asm.svg, clip.svg, ks.svg,
connector(112 + loop.w / 2 + 8, 340 - ctx.w / 2 - 8, mid1, "CONTEXT"),
// WP_ContextLoop has a `loop_config` side-output and WP_SeedList takes it
// as an optional input, so the seed list mirrors the loop's count,
// strategy and base seed. That link is what makes the caption's "lockstep"
// true; without it the two read as unrelated sources.
`<text class="lbl" x="122" y="${(r1 + 66 + r2) / 2 + 4}">LOOP_CONFIG</text>`,
`<rect fill="url(#flowv)" x="111.2" y="${r1 + 66}" width="1.6" height="${r2 - 6 - (r1 + 66)}"/>`,
`<path class="head" d="M108 ${r2 - 6} L112 ${r2} L116 ${r2 - 6} Z"/>`,
connector(340 + ctx.w / 2 + 8, 566 - asm.w / 2 - 8, mid1, "$VARS"),
// assembler drops straight down into the encoder
`<text class="lbl" x="576" y="141">PROMPT</text>`,
`<rect fill="url(#flowv)" x="565.2" y="${r1 + 66}" width="1.6" height="${r2 - 6 - (r1 + 66)}"/>`,
`<path class="head" d="M562 ${r2 - 6} L566 ${r2} L570 ${r2 - 6} Z"/>`,
connector(566 + clip.w / 2 + 8, 772 - ks.w / 2 - 8, mid2, "COND"),
// seeds take their own lane under the text path and rise into the sampler
`<path class="ln" fill="none" d="M112 ${r2 + 66} V ${laneY} H 772 V ${r2 + 72}"/>`,
`<path class="head" d="M768 ${r2 + 72} L772 ${r2 + 66} L776 ${r2 + 72} Z"/>`,
`<text class="lbl" x="420" y="${laneY - 8}" text-anchor="middle">SEED[]</text>`,
].join("\n ");
return shell(
W,
H,
"WP Context Loop drives WP Context and the Prompt Assembler to produce N prompts, CLIP Encode turns them into conditioning for KSampler, and WP Seed List supplies the matching seed for each iteration directly to KSampler",
body,
);
}
fs.writeFileSync(path.join(OUT_DIR, "flow-intro.svg"), buildIntro());
fs.writeFileSync(path.join(OUT_DIR, "flow-loop.svg"), buildLoop());
console.log("wrote flow-intro.svg + flow-loop.svg");
+104 -1
View File
@@ -144,7 +144,110 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// in onUpdate/onValueRestored/microtask). Net ~0.5 KB gzip; 2 KB
// headroom buffer to avoid the "17 bytes from budget" tightness from
// the previous bump.
const TOTAL_LIMIT = 395 * 1024; // 395 KB
// Bumped 395 -> 405 KB on 2026-06-07 for SP1 Chunk G (in-graph multi-tag
// pool editor). PoolSection gains grouped quick pills + an ƒ(x) Advanced
// boolean-expression editor; OptionRow gains per-tag CATEGORY chips; and
// the shared engine-parity subcat_filter parser (parse/matches/readsAs)
// is now imported into the wildcard graph editor chunk. ~4 KB net gzip
// across the lazy ContextWidget chunk; entry stays put. Approved with the
// SP1 multi-tag + boolean-filter feature (docs/superpowers/specs/
// 2026-06-06-wildcard-multi-subcategory-boolean-filter-design.md §4.2).
// Bumped 405 -> 430 KB on 2026-06-11 for SP3 constraint reach UI headroom.
// SP3 Task 13 lands the TS combineConstraintFactor mirror (+ shared corpus
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
// the SP3 constraint feature.
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
// exceptions trash, across all 6 edit modals.
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
// across the ContextWidget + conflicts chunks; entry stays put.
// Bumped 432 -> 435 KB on 2026-06-22 for the canvas constraint-matrix grouping
// parity. MatrixSection (the ComfyUI instance-modal grid) gains the SPA's
// grouped-axis layout — column bands, row header chips, solo eyebrow, the
// labelled "uncategorized" bucket, and frozen headers + both-axis scroll —
// plus the shared components/shared/matrix-axis module the SPA matrix now
// imports too. ~1.1 KB net gzip in the lazy ContextWidget chunk; entry stays
// put. Approved with the matrix-parity feature request.
// Bumped 435 -> 442 KB on 2026-06-23 for per-iteration seeds modal
// (T8+T9). ContextLoopWidget gains the SeedListModal + SeedLockRow lazy
// body (v-if gated) — adds ~5.5 KB net gzip to the ContextLoopWidget
// lazy chunk. SeedListWidget gains a matching button in T9. Headroom
// covers both T8 and T9 landings without per-task bump friction.
// Bumped 442 -> 443 KB on 2026-06-23 for T9 (SeedListWidget seeds
// button). The SeedListWidget chunk grew ~0.7 KB gzip adding the button
// template + ref/computed script + seedbtn CSS; T8's headroom estimate
// was tight. Minimal bump to the actual measured ceiling.
// Bumped 443 -> 444 KB on 2026-06-23 for T12 (seed-list preview
// effective base/count/strategy from wired loop). Adds preview.ts pure
// resolver + graph-walk glue in seed_list.ts + previewStrategy prop in
// SeedListWidget.vue. ~0.4 KB net gzip in the seed_list widget chunk.
// Bumped 444 -> 445 KB on 2026-06-23 for T16 (seed modal paste-to-replace).
// Adds parseSeedLocks in seed-derive + pasteAll/Paste button in SeedListModal.
// Measured 454900 bytes (244 over the 444 ceiling); minimal bump.
// Bumped 445 -> 446 KB on 2026-06-24 for UI-C (wire ContextWidget frame edits).
// Adds imports of frame-cursor / frame-overrides / INSTANCE_FIELDS_PER_KIND into
// the ContextWidget lazy chunk + the merge-on-open / write-redirect logic.
// Measured 455772 bytes (92 over the 445 ceiling); minimal bump.
// Bumped 446 -> 447 KB on 2026-06-24 for RF2 (frame-aware row quick-lock).
// Adds toggleFrameLock helper in frame-overrides.ts + effectiveLockedSeed +
// frame branch in toggleLockOnCard + frame-aware isLocked. Net ~168 bytes
// gzip in the lazy ContextWidget chunk; entry stays put.
// Bumped 447 -> 448 KB on 2026-06-24 for the symmetric per-frame enable
// redesign (a `frame_enabled` override map replaces the `disabled_frames`
// blocklist so a base-off module can be turned ON for select frames). Adds
// frameEnabledAt / effectiveEnabled / frameEnableOverride / toggleFrameEnabled
// to frame-overrides.ts, the effective-enabled checkbox + bidirectional
// on-#k / off-#k badge in ModuleRow, and the .wp-mod-badge--on CSS. Measured
// 457868 bytes (140 over the 447 ceiling); minimal bump.
// Bumped 448 -> 449 KB on 2026-06-25 for the per-frame seed-lock correctness
// work: the Loop's derived-series capture (loop_seeds UI payload + onExecuted
// store + frameSeedReader preference, so the lock pins the PREVIOUS run's
// per-frame seed under control_after_generate=randomize) plus the modal
// Lock-button resolver (lock-seed-ctx provide/inject into SeedLockControls).
// Landed exactly at the 448 ceiling (458752/458752); bump for headroom.
// Bumped 449 -> 450 KB on 2026-06-28 for the row context-menu Hold toggle.
// Adds HOLD_KINDS + isHoldable + toggleHold to ContextWidget plus the quick
// "Hold across run" / "Release hold" menu item (2-click seed_scope toggle,
// base-only with a frame-active disabled hint) in the lazy ContextWidget
// chunk; entry stays put. Measured 459909 bytes (133 over the 449 ceiling);
// minimal bump.
// Bumped 450 -> 453 KB on 2026-07-21 for loop bypass frames (#13): the
// `bypass_frames` config field + the SeedLockRow bypass toggle, the
// SeedListModal opt-in bypass column, and the ContextLoopWidget "N bypassed"
// badge. Config field alone landed 34 bytes over 450; the ~3 KB headroom
// covers the UI additions in the same feature.
// Bumped 453 -> 461 KB on 2026-07-23 for the chip-UX + library-linkage feature
// set (issues #1-#9). Workstream B (RefChip hover card: uuid + filter reads-as
// + live "N of M options match" from the extended preview-resolver) landed the
// canvas within 10 bytes of the 453 ceiling; the +8 KB gives the remaining
// workstreams D (library re-link UI) and E (nested-ref propagation) room in the
// same approved feature set. Explicitly approved.
// Bumped 461 -> 468 KB on 2026-07-30 for the QoL sweep (P0+P1). The bulk is UI
// that had to grow: export/import row detail per entity kind, the category
// IconPicker, the feedback modal, cross-bucket search, collapsible tag pools,
// and finally `collectUpstreamProducers` + the var-chip producer card, which
// tipped it 180 bytes over. Explicitly approved by the maintainer, who asked
// for the budget to be raised as needed rather than features trimmed to fit.
// Bumped 468 -> 490 KB on 2026-08-02 for the `@`/`$` autocomplete + sub-category
// filter redesign. The popover rows carry structure now (icon, uuid, option /
// axis / tag facts, and for `$` the producer line and override badge) instead
// of one line of text, and the shared filter panel that follows adds the
// axis-grouped palette with collapse + search, the live match count and the
// zero-match / unknown-term states. Sized with headroom deliberately: the
// previous bump left 179 bytes free, which meant every subsequent change had
// to argue with the gate. Explicitly approved by the maintainer, who asked for
// the budget to be raised as needed rather than features trimmed to fit.
const TOTAL_LIMIT = 490 * 1024; // 490 KB
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* Post a release announcement to Discord.
*
* Derives the message from the release notes that already exist — there is
* nothing extra to write. Run by `.github/workflows/discord-announce.yml` when
* a release is published, and runnable by hand for the catch-up case.
*
* Usage:
* node scripts/discord-announce.mjs --version v2.12.0 # dry run
* node scripts/discord-announce.mjs --version v2.12.0 --post # → staff
* node scripts/discord-announce.mjs --version v2.12.0 --post --channel public
* node scripts/discord-announce.mjs --since v2.10.2 --post # catch-up
* node scripts/discord-announce.mjs --notes-file docs/release-notes/next.md \
* --version v2.12.0 # preview
*
* DRY RUN IS THE DEFAULT. `--post` is required to send anything, so previewing
* an announcement can never accidentally publish one.
*
* Environment:
* DISCORD_WEBHOOK_STAFF staff / pre-announcement channel (the default)
* DISCORD_WEBHOOK_PUBLIC public announcement channel (--channel public only)
* DISCORD_WEBHOOK_URL legacy fallback for the staff webhook
* DISCORD_ROLE_ID optional; pinged as <@&ID>, PUBLIC channel only
* DISCORD_USERNAME optional; overrides the posting name
* DISCORD_AVATAR_URL optional; overrides the posting avatar (raster only)
* GITHUB_TOKEN optional; raises the API rate limit for --since
*/
import { readFileSync } from "node:fs";
import { buildPayload, buildRollupPayload, normalizeRoleId } from "./lib/discord-notes.mjs";
const REPO = "DumiFlex/ComfyUI-Wildcard-Pipeline";
/**
* Two channels, deliberately asymmetric.
*
* `staff` posts automatically on every release and carries a link to publish
* the same message onward — it is the review step. `public` only ever runs
* from a deliberate manual dispatch, so a release can never announce itself to
* everyone without someone having read it first.
*/
const CHANNELS = {
staff: ["DISCORD_WEBHOOK_STAFF", "DISCORD_WEBHOOK_URL"],
public: ["DISCORD_WEBHOOK_PUBLIC"],
};
function webhookFor(channel) {
for (const name of CHANNELS[channel]) {
if (process.env[name]) return { url: process.env[name], via: name };
}
return { url: null, via: CHANNELS[channel][0] };
}
function arg(name, fallback = undefined) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const next = process.argv[i + 1];
return next && !next.startsWith("--") ? next : true;
}
function ghHeaders() {
const h = { Accept: "application/vnd.github+json", "User-Agent": "wp-discord-announce" };
if (process.env.GITHUB_TOKEN) h.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
return h;
}
async function fetchRelease(tag) {
const url = tag
? `https://api.github.com/repos/${REPO}/releases/tags/${tag}`
: `https://api.github.com/repos/${REPO}/releases/latest`;
const res = await fetch(url, { headers: ghHeaders() });
if (!res.ok) throw new Error(`GitHub ${res.status} fetching ${tag ?? "latest"}`);
return res.json();
}
/** Releases newer than `sinceTag`, newest first. Drafts and prereleases skipped. */
async function fetchReleasesSince(sinceTag) {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100`, {
headers: ghHeaders(),
});
if (!res.ok) throw new Error(`GitHub ${res.status} listing releases`);
const all = (await res.json()).filter((r) => !r.draft && !r.prerelease);
const idx = all.findIndex((r) => r.tag_name === sinceTag);
if (idx === -1) throw new Error(`No release tagged ${sinceTag}`);
return all.slice(0, idx);
}
async function main() {
const post = arg("post", false) === true;
const channel = arg("channel", "staff");
if (channel !== "staff" && channel !== "public") {
console.error(`Unknown --channel "${channel}". Use staff or public.`);
process.exit(1);
}
// Belt to the workflow's braces. The workflow only passes `--channel public`
// on a manual dispatch, but a release event reaching this line with public
// selected would announce to everyone with nobody having read it.
if (channel === "public" && process.env.GITHUB_EVENT_NAME === "release") {
console.error("Refusing to post to the public channel from a release event.");
process.exit(1);
}
// Only the public post pings. A role ping in the staff channel on every
// release is noise in a room that already exists to watch releases.
const rawRole = channel === "public" ? process.env.DISCORD_ROLE_ID : undefined;
const roleId = normalizeRoleId(rawRole);
// `buildPayload` drops a malformed id either way, but silence would leave
// someone wondering why the ping never fired.
if (rawRole && rawRole.trim() && !roleId) {
console.warn(
`DISCORD_ROLE_ID is not a valid role id (${JSON.stringify(rawRole)}) — posting without a ping.`,
);
}
// Undefined falls through to the defaults in discord-notes.mjs — an empty
// env var must not blank the identity and hand the post back to the
// webhook's own name.
const username = process.env.DISCORD_USERNAME || undefined;
const avatarUrl = process.env.DISCORD_AVATAR_URL || undefined;
const since = arg("since");
const notesFile = arg("notes-file");
let version = arg("version");
let payload;
if (typeof since === "string") {
const releases = await fetchReleasesSince(since);
if (!releases.length) {
console.error(`Nothing published after ${since} — nothing to announce.`);
process.exit(0);
}
payload = buildRollupPayload({
releases: releases.map((r) => ({ version: r.tag_name, body: r.body ?? "" })),
sinceVersion: since,
releaseUrl: releases[0].html_url,
roleId,
username,
avatarUrl,
channel,
});
} else if (typeof notesFile === "string") {
// Local preview against unreleased notes. No network, so the compare link
// is unknown and simply omitted.
payload = buildPayload({
version: version || "(unreleased)",
body: readFileSync(notesFile, "utf8"),
roleId,
username,
avatarUrl,
channel,
});
} else {
const rel = await fetchRelease(typeof version === "string" ? version : undefined);
version = rel.tag_name;
payload = buildPayload({
version,
body: rel.body ?? "",
releaseUrl: rel.html_url,
roleId,
username,
avatarUrl,
channel,
});
}
if (!post) {
console.log("--- DRY RUN (pass --post to send) ---\n");
// Identity is part of what a preview is for: it is the thing that was
// wrong on the first real post, and it is invisible in the embed body.
console.log(`channel: ${channel}`);
console.log(`posting as: ${payload.username}`);
console.log(`avatar: ${payload.avatar_url}`);
console.log("");
console.log(payload.embeds[0].title);
console.log("-".repeat(60));
console.log(payload.embeds[0].description);
console.log("-".repeat(60));
console.log(`${payload.embeds[0].description.length} / 4096 chars`);
return;
}
const { url: webhook, via } = webhookFor(channel);
if (!webhook) {
console.error(`${via} is not set — refusing to post to the ${channel} channel.`);
process.exit(1);
}
const res = await fetch(`${webhook}?wait=true`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
console.error(`Discord ${res.status}: ${await res.text()}`);
process.exit(1);
}
console.log(`Announced ${payload.embeds[0].title} → ${channel}`);
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
+162
View File
@@ -0,0 +1,162 @@
"""Generate engine-parity fixtures for the community validator suite.
WHY THIS EXISTS
---------------
The community-web TS validators (`src/validators/`) are a hand-authored
re-implementation of the engine's payload shapes. Re-implementations
drift: fixed_values was published for weeks as `entries/{variable_name}`
when the engine has always used `values/{id,name,value}`; wildcard
options omitted the real `is_null` field; etc. Every drift surfaced as a
publish failure in front of a user.
This script makes the ENGINE the single source of truth. For each module
subtype it builds a sample payload, runs it through that subtype's own
`Handler.validate_payload` (the engine authority -- raises if the sample
is not engine-valid), and dumps the engine-row shape to a committed
fixture. The companion test `src/validators/__tests__/engine-parity.test.ts`
asserts the STRICT community validator accepts every fixture.
Result: if the engine shape changes (a handler gains/renames a field) the
sample stops validating here and this script fails; if the TS validator
drifts from the engine, the parity test fails. Drift is caught at CI time
instead of in front of a publisher.
USAGE
-----
python scripts/dump_engine_shapes.py
Run it whenever an engine module shape changes, then commit the updated
fixtures. Exit 0 = all samples engine-valid + written; exit 1 = a sample
is no longer engine-valid (fix the sample to match the new shape).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# Make `engine` importable when run as a bare script (mirrors the
# tests/conftest.py sys.path shim).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from engine.modules.combine_handler import CombineHandler # noqa: E402
from engine.modules.constraint_handler import ConstraintHandler # noqa: E402
from engine.modules.derivation_handler import DerivationHandler # noqa: E402
from engine.modules.fixed_values_handler import FixedValuesHandler # noqa: E402
from engine.modules.wildcard_handler import WildcardHandler # noqa: E402
# Output dir, relative to repo root (this file lives in scripts/).
_OUT_DIR = (
Path(__file__).resolve().parent.parent
/ "src" / "validators" / "fixtures" / "engine-parity"
)
# Each entry: (handler, type-specific payload). The payload must pass the
# handler's validate_payload -- keep these as the MINIMAL real shape per
# subtype, including fields that the strip-tolerant validator must
# preserve (e.g. wildcard `is_null`).
_SAMPLES: dict[str, tuple[type, dict]] = {
"wildcard": (
WildcardHandler,
{
"var_binding": "mood",
"sub_categories": ["soft", "bold"],
"options": [
{"id": "opt00001", "value": "serene", "weight": 1, "sub_category": "soft"},
# Null option: the is_null contract. Must round-trip
# through the validator or install rebuilds it as an
# invalid empty-value option.
{"id": "opt00002", "value": "", "weight": 1, "sub_category": None, "is_null": True},
],
},
),
"fixed_values": (
FixedValuesHandler,
{"values": [{"id": "val00001", "name": "style", "value": "oil painting"}]},
),
"combine": (
CombineHandler,
{"template": "$mood $subject", "output_var": "scene", "input_vars": ["mood", "subject"]},
),
"derivation": (
DerivationHandler,
{
"rules": [
{
"id": "rule0001",
"branches": [
{
"condition": {
"var": "mood", "op": "equals", "value": "dramatic",
},
"action": {
"target_var": "accent", "mode": "replace",
"value": "cinematic lighting",
},
},
],
"else": {
"action": {
"target_var": "accent", "mode": "replace",
"value": "soft lighting",
},
},
},
],
},
),
"constraint": (
ConstraintHandler,
{
"source_wildcard_id": "src00001",
"target_wildcard_id": "tgt00001",
"exceptions": [],
"matrix": {"soft": {"bold": {"mode": "boost", "factor": 3}}},
},
),
}
def _engine_row(type_id: str, payload: dict) -> dict:
"""Wrap a type-specific payload in the canonical engine-row shape
the publisher ships (see manager/import-export/single-row-publish.ts:
buildModulePublishable)."""
return {
"id": f"parity{type_id[:2]}",
"type": type_id,
"name": f"parity-{type_id}",
"description": "",
"category_id": None,
"tags": [],
"is_favorite": False,
"payload": payload,
}
def main() -> int:
_OUT_DIR.mkdir(parents=True, exist_ok=True)
failures: list[str] = []
for type_id, (handler, payload) in _SAMPLES.items():
try:
# Engine authority: this raises if the sample is not a valid
# payload for the CURRENT engine handler.
handler.validate_payload(payload)
except Exception as exc: # noqa: BLE001 -- report any validation failure
failures.append(f"{type_id}: sample is no longer engine-valid: {exc}")
continue
row = _engine_row(type_id, payload)
out = _OUT_DIR / f"{type_id}.json"
out.write_text(json.dumps(row, indent=2) + "\n", encoding="utf-8")
print(f"[parity] wrote {out.relative_to(_OUT_DIR.parent.parent.parent)}")
if failures:
print("\n[parity] FAILED -- update the sample(s) to match the engine:", file=sys.stderr)
for f in failures:
print(f" - {f}", file=sys.stderr)
return 1
print(f"[parity] {len(_SAMPLES)} engine-valid fixtures written to {_OUT_DIR}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+301
View File
@@ -0,0 +1,301 @@
/**
* Release notes → Discord message.
*
* The announcement is DERIVED, never authored. `docs/release-notes/next.md` is
* already written by hand for every release and already is the GitHub release
* body — so the only reason a Discord post was a separate chore is that nothing
* transformed one into the other. Nothing here asks for new prose.
*
* Discord is not a markdown renderer in the way GitHub is, and the gaps are
* specific rather than general:
*
* - Masked links `[text](url)` render ONLY inside an embed. In plain message
* content they show as literal brackets, which is why the notes go in an
* embed and the message content stays a bare ping.
* - There are no tables. A pipe table degrades into unreadable pipes.
* - `<details>` is HTML; it shows as raw tags.
* - Embed description caps at 4096 characters, and the whole payload at 6000.
*/
/** Everything below this marker is the long tail; the SPA modal cuts here too. */
const MODAL_CUT = /<!--\s*\/modal\s*-->/i;
/** Discord's hard cap on a single embed description. */
export const EMBED_DESCRIPTION_LIMIT = 4096;
/** Accent purple, matching `--wp-accent-500` in the manager. */
export const BRAND_COLOR = 0x8b5cf6;
/**
* Who the post appears to be from.
*
* A webhook posts under whatever name and avatar it was created with — the
* Discord default is a generic name like "Captain Hook" and a placeholder
* image, which is what an announcement went out as. `username` and
* `avatar_url` override that PER MESSAGE, so the identity lives here rather
* than in a webhook's settings: it stays right if the webhook is ever rotated
* or recreated, and it is the same in every channel the automation posts to.
*/
export const WEBHOOK_USERNAME = "Wildcard Pipeline";
/**
* Discord fetches this server-side, so it has to be a public raster URL — an
* SVG will not render, which rules out the repo's `favicon.svg`. Pinned to
* `main` rather than a tag so replacing the logo does not need a code change.
*/
export const WEBHOOK_AVATAR_URL =
"https://raw.githubusercontent.com/DumiFlex/ComfyUI-Wildcard-Pipeline/main/public/images/web-app-manifest-512x512.png";
/**
* A role id we are willing to turn into a ping, or `undefined`.
*
* An unset secret was always safe, but a MALFORMED one was not: any truthy
* string went straight into `<@&…>`, so a stray space produced a literal
* `<@& >` at the top of the announcement and a pasted mention produced
* `<@&<@&123>>`. Both render as visible junk in the most public message the
* project sends.
*
* Accepts a bare snowflake or the `<@&123>` form, since "Copy ID" and copying
* the mention text are equally easy mistakes to make. Anything else is
* rejected rather than guessed at — a ping that silently does not happen is a
* much smaller problem than a broken one that does.
*/
export function normalizeRoleId(raw) {
if (typeof raw !== "string" && typeof raw !== "number") return undefined;
const trimmed = String(raw).trim();
if (!trimmed) return undefined;
const unwrapped = trimmed.replace(/^<@&(\d+)>$/, "$1");
// Discord snowflakes are 17-20 digits today; the range leaves room without
// accepting arbitrary numbers.
return /^\d{17,20}$/.test(unwrapped) ? unwrapped : undefined;
}
/** Where the manual "publish to the public channel" run is started. */
export const DISPATCH_URL =
"https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions/workflows/discord-announce.yml";
/**
* Split a release body into the part worth announcing and what is left behind.
*
* The cut is the same one the in-app update dialog makes, for the same reason:
* an announcement is an interruption and should stay short. The tail is not
* discarded silently — `tailCount` lets the message say how much it is not
* showing, so a big release does not read as a small one.
*/
export function splitBody(md) {
const at = String(md ?? "").search(MODAL_CUT);
if (at === -1) return { head: String(md ?? ""), tail: "", tailCount: 0 };
const head = md.slice(0, at);
// Count against comment-stripped text, NOT details-stripped text. The
// "Also in this release" bullets live INSIDE the <details> block, so
// stripping it first counts nothing real — while the authoring guidance in
// the trailing HTML comment contains bullets of its own that must not be
// counted. Getting this backwards reported a 26-change release as 3.
const tail = stripComments(md.slice(at));
const tailCount = (tail.match(/^\s*[-*]\s+\S/gm) ?? []).length;
return { head, tail, tailCount };
}
function stripDetails(md) {
return md.replace(/<details[\s\S]*?<\/details>/gi, "");
}
function stripComments(md) {
return md.replace(/<!--[\s\S]*?-->/g, "");
}
/**
* Drop the header block the GitHub template prepends.
*
* It is a row of links to the wiki, Discord and the issue tracker. On Discord
* the Discord link is noise, and the rest belong in the embed's own fields
* rather than buried in the body.
*/
function stripTemplateHeader(md) {
return md
.replace(/^\s*##\s*🎉[^\n]*\n/, "")
.replace(/^\s*(?:📖|💬|📦|🐛)[^\n]*\n/gm, "");
}
/** Pipe tables have no Discord equivalent; each row becomes a bullet. */
function tablesToBullets(md) {
const lines = md.split("\n");
const out = [];
for (const line of lines) {
const isRow = /^\s*\|.*\|\s*$/.test(line);
if (!isRow) { out.push(line); continue; }
// Separator row (|---|---|) carries no content.
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
const cells = line.trim().slice(1, -1).split("|").map((c) => c.trim());
if (!cells.some(Boolean)) continue;
out.push(`- ${cells.filter(Boolean).join(" — ")}`);
}
return out.join("\n");
}
/**
* Headings become bold lines.
*
* Discord does render `#`-style headings now, but at sizes chosen for chat
* rather than for a dense embed — an `##` inside an embed is larger than the
* embed title above it. Bold keeps the hierarchy legible without competing.
*/
function headingsToBold(md) {
return md.replace(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm, (_m, text) => `**${text.trim()}**`);
}
/** Horizontal rules have no embed equivalent and read as stray dashes. */
function stripRules(md) {
return md.replace(/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/gm, "");
}
/** Collapse the blank lines the earlier passes leave behind. */
function tidy(md) {
return md.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+$/gm, "").trim();
}
/**
* Truncate at a bullet boundary rather than mid-sentence.
*
* A hard slice at 4096 can cut inside a link or a bold run, which Discord then
* renders as broken markup — worse than saying less.
*/
export function clampToLimit(text, limit = EMBED_DESCRIPTION_LIMIT, suffix = "\n\n…") {
if (text.length <= limit) return text;
const room = limit - suffix.length;
const slice = text.slice(0, room);
const cut = Math.max(slice.lastIndexOf("\n- "), slice.lastIndexOf("\n\n"));
return (cut > room * 0.5 ? slice.slice(0, cut) : slice).trimEnd() + suffix;
}
/** Full markdown → Discord-embed markdown. */
export function toDiscordMarkdown(md) {
return tidy(
stripRules(
headingsToBold(
tablesToBullets(
stripTemplateHeader(stripComments(stripDetails(String(md ?? "")))),
),
),
),
);
}
/**
* Build the description for one release.
*
* `tailCount` is appended rather than dropped: a release whose highlights are
* three bullets and whose tail is forty changes should not look like a quiet
* week.
*/
export function buildDescription(body, { compareUrl } = {}) {
const { head, tailCount } = splitBody(body);
let out = toDiscordMarkdown(head);
if (tailCount > 0) {
const line = `*…plus ${tailCount} smaller change${tailCount === 1 ? "" : "s"}*`;
out += `\n\n${compareUrl ? `[${line.slice(1, -1)}](${compareUrl})` : line}`;
}
return clampToLimit(out);
}
/**
* One release → a Discord webhook payload.
*
* `allowed_mentions` is always explicit. A webhook with no restriction will
* happily resolve an `@everyone` that happened to appear in release notes, and
* the notes are not written with that in mind.
*/
export function buildPayload({
version,
body,
releaseUrl,
compareUrl,
roleId: rawRoleId,
username = WEBHOOK_USERNAME,
avatarUrl = WEBHOOK_AVATAR_URL,
channel = "staff",
installHint = 'ComfyUI Manager → search "Wildcard Pipeline" → Update → restart ComfyUI',
}) {
const description = buildDescription(body, { compareUrl });
// Normalised HERE rather than at the call site so every caller — CLI,
// roll-up, future ones — gets the same guarantee.
const roleId = normalizeRoleId(rawRoleId);
return {
username,
avatar_url: avatarUrl,
content: roleId ? `<@&${roleId}>` : "",
allowed_mentions: { parse: [], roles: roleId ? [roleId] : [] },
embeds: [
{
title: `Wildcard Pipeline ${version}`,
url: releaseUrl || undefined,
description,
color: BRAND_COLOR,
fields: reviewFields(channel),
footer: { text: installHint },
},
],
};
}
/**
* The staff copy carries its own publish button; the public copy does not.
*
* This is what makes the staff channel a review step rather than a duplicate
* feed: the post you are reading is the post that will go out, and the link to
* send it is attached to it. Without that, "go and publish it" is a separate
* instruction living in someone's head.
*
* Returned as `undefined` for the public channel rather than an empty array —
* Discord rejects `fields: []` on some client versions, and an empty field
* block would render as dead space in the announcement everyone sees.
*/
function reviewFields(channel) {
if (channel !== "staff") return undefined;
return [
{
name: "Ready to go public?",
value:
`[Run the workflow](${DISPATCH_URL}) with **channel: public** and ` +
"**dry_run: false** to post this to the announcement channel.",
},
];
}
/**
* Several releases → one catch-up payload.
*
* For the case that actually happens: announcements skipped for a few versions,
* then one post covering the gap. Each release keeps its own heading so readers
* can tell which version brought what, and the newest goes first because that is
* the one people are deciding whether to install.
*
* `releases` is newest-first: `{ version, body }`.
*/
export function buildRollupPayload({
releases, sinceVersion, releaseUrl, roleId, installHint, username, avatarUrl,
channel = "staff",
}) {
const latest = releases[0];
const sections = releases.map((r) => {
const { head } = splitBody(r.body);
const text = toDiscordMarkdown(head);
return `**${r.version}**\n${text}`;
});
const joined = sections.join("\n\n");
const base = buildPayload({
version: latest?.version ?? "",
body: "",
releaseUrl,
roleId,
installHint,
username,
avatarUrl,
channel,
});
base.embeds[0].title = sinceVersion
? `Wildcard Pipeline ${latest?.version ?? ""} — everything since ${sinceVersion}`
: `Wildcard Pipeline ${latest?.version ?? ""}`;
base.embeds[0].description = clampToLimit(joined);
return base;
}
+348
View File
@@ -0,0 +1,348 @@
import { describe, it, expect } from "vitest";
import {
DISPATCH_URL,
normalizeRoleId,
splitBody,
toDiscordMarkdown,
buildDescription,
buildPayload,
buildRollupPayload,
clampToLimit,
EMBED_DESCRIPTION_LIMIT,
} from "./discord-notes.mjs";
/**
* The announcement is derived from notes that already exist, so the risk is not
* "does it write good copy" — it is "does it mangle copy that was already
* fine". Most of these pin a Discord-specific rendering gap.
*/
const REAL_SHAPE = `## 🎉 Wildcard Pipeline v2.11.0
📖 [Docs (wiki)](https://x/wiki) · 💬 [Discord](https://discord.gg/x) · 📦 [Install](https://x/q) · 🐛 [Issues](https://x/i)
### ✨ What's new
A correctness fix for held modules, plus tools for reconnecting workflow items.
- **Held modules stay held.** A module set to **Hold across run** could re-roll.
- **Search by id.** Pasting an 8-character id now finds the item.
<!-- /modal -->
### Also in this release
- Wire collapse survives a live renderer switch
- Modal keystrokes no longer escape
---
**Full changelog:** [v2.10.2…v2.11.0](https://x/compare)
`;
describe("splitBody", () => {
it("cuts at the modal marker and counts what is left behind", () => {
const { head, tailCount } = splitBody(REAL_SHAPE);
expect(head).toContain("Held modules stay held");
expect(head).not.toContain("Also in this release");
expect(tailCount).toBe(2);
});
it("treats a body with no marker as all highlights", () => {
const { head, tailCount } = splitBody("- one\n- two");
expect(head).toBe("- one\n- two");
expect(tailCount).toBe(0);
});
it("survives an empty or missing body", () => {
expect(splitBody("").head).toBe("");
expect(splitBody(undefined).head).toBe("");
});
});
describe("toDiscordMarkdown", () => {
it("drops the GitHub template header — the Discord link is noise on Discord", () => {
const out = toDiscordMarkdown(REAL_SHAPE);
expect(out).not.toContain("🎉 Wildcard Pipeline");
expect(out).not.toContain("discord.gg");
expect(out).not.toContain("Docs (wiki)");
});
it("keeps masked links, which DO render inside an embed", () => {
expect(toDiscordMarkdown("see [the docs](https://x/d)")).toContain("[the docs](https://x/d)");
});
it("turns headings into bold — an embed h2 outsizes the embed title", () => {
const out = toDiscordMarkdown("### What's new\nbody");
expect(out).toContain("**What's new**");
expect(out).not.toMatch(/^#/m);
});
it("converts tables to bullets, since Discord has none", () => {
const out = toDiscordMarkdown("| Item | Result |\n| --- | --- |\n| #29 | shipped |");
expect(out).toContain("- Item — Result");
expect(out).toContain("- #29 — shipped");
expect(out).not.toContain("|");
});
it("strips <details>, which renders as raw tags", () => {
const out = toDiscordMarkdown("keep\n<details><summary>x</summary>\n- hidden\n</details>");
expect(out).toContain("keep");
expect(out).not.toContain("hidden");
expect(out).not.toContain("<details>");
});
it("strips HTML comments and horizontal rules", () => {
const out = toDiscordMarkdown("a\n\n<!-- note -->\n\n---\n\nb");
expect(out).not.toContain("note");
expect(out).not.toMatch(/^-{3,}$/m);
expect(out).toContain("a");
expect(out).toContain("b");
});
it("leaves bold, italics and bullets alone", () => {
const out = toDiscordMarkdown("- **bold** and *italic* and `code`");
expect(out).toBe("- **bold** and *italic* and `code`");
});
});
describe("buildDescription", () => {
// A three-bullet highlights section over a forty-change release should not
// read as a quiet week.
it("says how much it is not showing", () => {
const out = buildDescription(REAL_SHAPE);
expect(out).toMatch(/plus 2 smaller changes/);
});
it("links the tail to the compare view when there is one", () => {
const out = buildDescription(REAL_SHAPE, { compareUrl: "https://x/compare" });
expect(out).toContain("](https://x/compare)");
});
// The "Also in this release" bullets live INSIDE a <details> block, and the
// file ends with an authoring comment that contains bullets of its own.
// Stripping details before counting reported a real 26-change release as 3.
it("counts the bullets inside <details>, and ignores those in comments", () => {
const body = [
"- highlight",
"<!-- /modal -->",
"<details><summary>Also in this release — 3 smaller changes</summary>",
"- one",
"- two",
"- three",
"</details>",
"<!--",
" - not a change, this is authoring guidance",
" - nor is this",
"-->",
].join("\n");
expect(splitBody(body).tailCount).toBe(3);
expect(buildDescription(body)).toMatch(/plus 3 smaller changes/);
});
it("says nothing about a tail that does not exist", () => {
expect(buildDescription("- only this")).not.toMatch(/smaller change/);
});
it("uses the singular for one", () => {
expect(buildDescription("- a\n<!-- /modal -->\n- b")).toMatch(/1 smaller change\b/);
});
});
describe("clampToLimit", () => {
it("leaves anything under the cap untouched", () => {
expect(clampToLimit("short")).toBe("short");
});
// A hard slice can land inside a link or a bold run, which Discord then
// renders as broken markup — worse than saying less.
it("cuts at a bullet boundary, not mid-markup", () => {
const body = Array.from({ length: 400 }, (_, i) => `- item ${i} with some text`).join("\n");
const out = clampToLimit(body);
expect(out.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
expect(out.endsWith("…")).toBe(true);
expect(out.split("\n").at(-3)).toMatch(/^- item \d+ with some text$/);
});
});
describe("buildPayload", () => {
it("puts the notes in an embed, because masked links need one", () => {
const p = buildPayload({ version: "v2.12.0", body: REAL_SHAPE, releaseUrl: "https://x/r" });
expect(p.embeds).toHaveLength(1);
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0");
expect(p.embeds[0].url).toBe("https://x/r");
expect(p.embeds[0].footer.text).toMatch(/ComfyUI Manager/);
});
// Release notes are not written with @everyone in mind, and a webhook will
// happily resolve one that slipped in.
it("never lets the notes mention anyone by accident", () => {
const p = buildPayload({ version: "v1", body: "ping @everyone please" });
expect(p.allowed_mentions.parse).toEqual([]);
expect(p.allowed_mentions.roles).toEqual([]);
});
/**
* An unset secret was always safe. A MALFORMED one was not: any truthy string
* went straight into `<@&…>`, so a stray space produced a literal `<@& >`
* at the top of the most public message the project sends.
*/
describe("role ping", () => {
const content = (roleId) => buildPayload({ version: "v1", body: "x", roleId }).content;
it("stays silent for every flavour of absent", () => {
expect(content(undefined)).toBe("");
expect(content(null)).toBe("");
expect(content("")).toBe("");
expect(content(" ")).toBe("");
});
it("refuses anything that is not a snowflake, rather than guessing", () => {
expect(content("not-an-id")).toBe("");
expect(content("12345")).toBe(""); // too short
expect(content("1234567890123456789012")).toBe(""); // too long
expect(content("123abc456def78901")).toBe("");
});
it("never emits a half-formed mention", () => {
for (const bad of [" ", "not-an-id", "<@&>", "@everyone"]) {
expect(content(bad)).not.toContain("<@&");
}
});
// "Copy ID" and copying the mention text are equally easy mistakes.
it("accepts a pasted mention as well as a bare id", () => {
expect(content("123456789012345678")).toBe("<@&123456789012345678>");
expect(content("<@&123456789012345678>")).toBe("<@&123456789012345678>");
expect(content(" 123456789012345678 ")).toBe("<@&123456789012345678>");
});
it("keeps allowed_mentions in step with the content", () => {
const bad = buildPayload({ version: "v1", body: "x", roleId: "junk" });
expect(bad.allowed_mentions.roles).toEqual([]);
const good = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
expect(good.allowed_mentions.roles).toEqual(["123456789012345678"]);
expect(good.allowed_mentions.parse).toEqual([]);
});
it("normalizeRoleId is the single decision point", () => {
expect(normalizeRoleId("<@&123456789012345678>")).toBe("123456789012345678");
expect(normalizeRoleId("nope")).toBeUndefined();
expect(normalizeRoleId(undefined)).toBeUndefined();
});
});
it("pings only the configured role, and only when configured", () => {
expect(buildPayload({ version: "v1", body: "x" }).content).toBe("");
const p = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
expect(p.content).toBe("<@&123456789012345678>");
expect(p.allowed_mentions.roles).toEqual(["123456789012345678"]);
});
// A webhook posts under whatever name and avatar it was created with —
// Discord's default is a generic name ("Captain Hook") and a placeholder
// image, which is exactly what v2.12.0 went out as.
it("posts under the project's own name and avatar, not the webhook's", () => {
const p = buildPayload({ version: "v1", body: "x" });
expect(p.username).toBe("Wildcard Pipeline");
expect(p.avatar_url).toMatch(/^https:\/\/.+\.png$/);
});
it("uses a raster avatar — Discord will not render an SVG", () => {
expect(buildPayload({ version: "v1", body: "x" }).avatar_url).not.toMatch(/\.svg$/i);
});
it("lets a caller override the identity", () => {
const p = buildPayload({
version: "v1", body: "x", username: "WP Staff", avatarUrl: "https://x/a.png",
});
expect(p.username).toBe("WP Staff");
expect(p.avatar_url).toBe("https://x/a.png");
});
/**
* Two channels, deliberately asymmetric. The staff copy is the review step,
* so it carries the button that publishes it onward; the public copy is the
* announcement itself and must not tell readers how to re-send it.
*/
it("gives the staff copy a publish link, so review and button are one thing", () => {
const p = buildPayload({ version: "v1", body: "x", channel: "staff" });
const field = p.embeds[0].fields[0];
expect(field.value).toContain(DISPATCH_URL);
expect(field.value).toMatch(/channel.*public/i);
});
it("defaults to the staff copy — the safe one to send by accident", () => {
expect(buildPayload({ version: "v1", body: "x" }).embeds[0].fields).toBeTruthy();
});
it("leaves the public copy without the publish link", () => {
const p = buildPayload({ version: "v1", body: "x", channel: "public" });
// `undefined`, not `[]` — an empty field block renders as dead space.
expect(p.embeds[0].fields).toBeUndefined();
});
it("stays inside the embed description cap", () => {
const huge = Array.from({ length: 900 }, (_, i) => `- change ${i}`).join("\n");
const p = buildPayload({ version: "v1", body: huge });
expect(p.embeds[0].description.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
});
});
describe("buildRollupPayload", () => {
// The case that actually happens: several versions skipped, then one post.
it("labels each version so readers can tell what came from where", () => {
const p = buildRollupPayload({
releases: [
{ version: "v2.12.0", body: "- newest thing" },
{ version: "v2.11.0", body: "- older thing" },
],
sinceVersion: "v2.10.2",
releaseUrl: "https://x/r",
});
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0 — everything since v2.10.2");
expect(p.embeds[0].description).toContain("**v2.12.0**");
expect(p.embeds[0].description).toContain("**v2.11.0**");
});
it("carries the channel through a roll-up", () => {
const staff = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "staff",
});
const pub = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "public",
});
expect(staff.embeds[0].fields).toBeTruthy();
expect(pub.embeds[0].fields).toBeUndefined();
});
it("keeps the identity on a roll-up too", () => {
const p = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- x" }],
sinceVersion: "v2.10.2",
});
expect(p.username).toBe("Wildcard Pipeline");
expect(p.avatar_url).toMatch(/\.png$/);
});
it("leads with the newest, which is what people decide to install", () => {
const p = buildRollupPayload({
releases: [
{ version: "v2.12.0", body: "- newest" },
{ version: "v2.11.0", body: "- older" },
],
sinceVersion: "v2.10.2",
});
const d = p.embeds[0].description;
expect(d.indexOf("**v2.12.0**")).toBeLessThan(d.indexOf("**v2.11.0**"));
});
it("drops each release's own tail so a roll-up does not become a changelog", () => {
const p = buildRollupPayload({
releases: [{ version: "v2.12.0", body: "- kept\n<!-- /modal -->\n- dropped" }],
sinceVersion: "v2.11.0",
});
expect(p.embeds[0].description).toContain("kept");
expect(p.embeds[0].description).not.toContain("dropped");
});
});
+55
View File
@@ -17,6 +17,7 @@
* - web/ (built manager SPA)
* - public/ (favicons + doc images referenced by the SPA)
* - docs/help/ (per-node help markdown the canvas reads)
* - locales/ (node display names + settings labels ComfyUI reads)
* - README.md
* - LICENSE
*
@@ -59,8 +60,55 @@ const SHIP_PATHS = [
"web",
"public",
"docs/help",
// ComfyUI reads `custom_nodes/<pack>/locales/<lang>/` for node display
// names, descriptions and settings labels (app/custom_node_manager.py).
// The registry archive picks this up automatically because it ships
// everything git-tracked that `.comfyignore` doesn't exclude; this list is
// hand-maintained, so it did not. The two artifacts silently disagreed —
// see DEV_ONLY below for the guard that now stops that recurring.
"locales",
];
/**
* Top-level tracked entries that deliberately do NOT ship.
*
* Exists to make the ship list total: every top-level path in git is either
* shipped or listed here, and `assertShipListIsTotal` fails the release if a
* new one appears in neither. Without it, adding a runtime directory and
* forgetting this file produces a zip that installs and silently misbehaves —
* which is exactly how `locales/` went missing.
*/
const DEV_ONLY = new Set([
".comfyignore", ".gitattributes", ".github", ".gitignore", ".husky", ".npmrc",
".releaserc.js", "CHANGELOG.md", "CLAUDE.md", "config", "conftest.py",
"eslint.config.js", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",
"scripts", "src", "tests", "tsconfig.json", "tsconfig.node.json",
"vite.config.mts", "vitest.config.ts",
]);
function assertShipListIsTotal() {
const ls = spawnSync("git", ["ls-files"], { cwd: root, encoding: "utf8" });
if (ls.status !== 0) {
console.warn("pack-release: `git ls-files` unavailable — skipping ship-list completeness check");
return;
}
// A ship path may name a subtree (`docs/help`); its top-level segment is
// what makes the parent directory "accounted for".
const covered = new Set(SHIP_PATHS.map((p) => p.split("/")[0]));
const tracked = new Set(
ls.stdout.split("\n").map((line) => line.trim().split("/")[0]).filter(Boolean),
);
const unaccounted = [...tracked].filter((e) => !covered.has(e) && !DEV_ONLY.has(e)).sort();
if (unaccounted.length > 0) {
console.error("pack-release: top-level paths in git that are neither shipped nor dev-only:");
for (const p of unaccounted) console.error(` - ${p}`);
console.error("Add each to SHIP_PATHS (ships to users) or DEV_ONLY (stays in the repo).");
process.exit(1);
}
}
assertShipListIsTotal();
// Sanity: every path must exist before zipping. If `js/` or `web/`
// is missing the release would ship a broken bundle — catch here so the
// release workflow fails fast rather than uploading a half-baked zip.
@@ -93,6 +141,13 @@ const args = [
];
const result = spawnSync("zip", args, { stdio: "inherit", cwd: root });
if (result.error?.code === "ENOENT") {
// Windows has no `zip` by default, so a maintainer running this locally
// otherwise gets a bare "exited with status null".
console.error("pack-release: the `zip` CLI was not found on PATH.");
console.error("The release workflow runs on ubuntu-latest where it is always present.");
process.exit(1);
}
if (result.status !== 0) {
console.error(`pack-release: zip exited with status ${result.status}`);
process.exit(result.status ?? 1);
+39
View File
@@ -0,0 +1,39 @@
/**
* Runtime contracts shared with the community web service.
*
* The community service is the schema authority — the sister project
* (this manager) consumes these contracts when importing/installing
* payloads downloaded from the community side. Keep these in sync
* with `web/src/api/types.ts` in the Wildcard-Pipeline-Community repo.
*
* This module deliberately holds runtime/transport contracts only.
* Manager-internal types (engine row shapes, UI state) live under
* `src/manager/api/types.ts`.
*/
/**
* One row from the community service's schema_catalog table (community
* spec §3). Returned alongside a download payload so the installer can
* plan its migration / tolerant-parse strategy without a second
* round-trip. Mirrors the Pydantic schema in
* `api/app/schemas/catalog.py` on the community side.
*
* Fields:
* - `version`: schema version this row describes.
* - `is_breaking_from_previous`: true when going from `version - 1`
* to `version` introduced a NON-additive change (field removal,
* type change, semantics change). Used by `decideInstallPath`'s
* AND-fold check.
* - `min_consumer_engine_version`: engine semver gate, or null when
* no minimum is enforced.
* - `notes`: free-form release notes for this schema bump.
* - `created_at`: ISO timestamp the row was inserted, or null on
* hand-seeded rows.
*/
export interface SchemaCatalogEntry {
version: number;
is_breaking_from_previous: boolean;
min_consumer_engine_version: string | null;
notes: string;
created_at: string | null;
}
@@ -183,6 +183,54 @@ describe("AssemblerHelper resolvedMap (#12 — boundary-disambiguation fix)", ()
});
});
describe("AssemblerHelper resolvedMap (SP2a — list-valued vars + .K accessor)", () => {
it("joins a bare $mood (ListVar) and indexes $mood.0 / $mood.1", () => {
const wrapper = mount(AssemblerHelper, {
props: {
upstreamVars: ["mood"],
templateVars: ["mood"],
template: "$mood.0, $mood.1, $mood",
resolvedMap: { mood: { items: ["sleepy", "serene", "dramatic"], sep: ", " } },
},
});
const preview = wrapper.find('[data-test="asm-preview"]');
const varSpans = preview.findAll("span.res");
expect(varSpans.length).toBe(3);
expect(varSpans[0].text()).toBe("sleepy"); // $mood.0
expect(varSpans[1].text()).toBe("serene"); // $mood.1
expect(varSpans[2].text()).toBe("sleepy, serene, dramatic"); // bare $mood → joined
// No stranded `.0` / `.1` accessor leaked into the literal spans.
expect(preview.text()).not.toContain(".0");
expect(preview.text()).not.toContain(".1");
});
it("renders an out-of-range $mood.5 as empty, not a leaked accessor", () => {
const wrapper = mount(AssemblerHelper, {
props: {
upstreamVars: ["mood"],
templateVars: ["mood"],
template: "[$mood.5]",
resolvedMap: { mood: { items: ["a"], sep: ", " } },
},
});
const preview = wrapper.find('[data-test="asm-preview"]');
expect(preview.text()).toBe("[]");
});
it("keeps an unresolved $name.K accessor visible in the chip text", () => {
const wrapper = mount(AssemblerHelper, {
props: {
upstreamVars: [],
templateVars: ["ghost"],
template: "$ghost.2",
resolvedMap: {},
},
});
const preview = wrapper.find('[data-test="asm-preview"]');
expect(preview.text()).toContain("$ghost.2");
});
});
describe("AssemblerHelper missing-chip click (#20 — autoremove regression)", () => {
it("clicking a missing chip calls onRemoveVar with the var name", async () => {
let removed = "";
+14 -5
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
import { varColorClass } from "../shared/var-color";
import ContextMenu, { type ContextMenuItem } from "../shared/ContextMenu.vue";
import { kindIcon, type WpKind } from "../shared/kind-icons";
import { applyVarAccessor, type ResolvedValue } from "../../widgets/richTokenize";
const props = defineProps<{
/** Flat array of upstream variable names. Primary prop shape. */
@@ -15,7 +16,7 @@ const props = defineProps<{
* fragile parallel-walk over the resolved string. When present, takes
* precedence over `resolved`.
*/
resolvedMap?: Record<string, string>;
resolvedMap?: Record<string, ResolvedValue>;
/**
* Pre-resolved string — template with $var replaced by resolved values.
* Legacy fallback when `resolvedMap` not provided. Suffers from
@@ -81,7 +82,10 @@ const upstreamNames = computed(() => props.upstreamVars ?? []);
// doubled, it can't appear in real prompts.
const ESCAPE_PLACEHOLDER = "��";
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
// SP2a: capture an optional `.K` list accessor (group 2) so `$mood.0` consumes
// the whole token — `m[0].length` then advances past `.K` (no stranded ".0"
// literal) and group 2 drives applyVarAccessor in the substitution below.
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/g;
/** Variable names referenced in the template. */
const templateVarsInternal = computed(() => {
@@ -142,11 +146,16 @@ const previewTokens = computed<PreviewToken[]>(() => {
const idx = m.index ?? 0;
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
const name = m[1];
const idxStr = m[2];
const index = idxStr != null ? parseInt(idxStr, 10) : undefined;
const has = Object.prototype.hasOwnProperty.call(props.resolvedMap, name);
// Unresolved var → keep `$name` so user sees what's missing.
// Resolved → join (bare $name) or index (.K) via the shared accessor;
// unresolved → keep the raw `$name.K` so the user sees what's missing.
tokens.push({
kind: "var",
text: has ? props.resolvedMap[name] : `$${name}`,
text: has
? applyVarAccessor(props.resolvedMap[name], index)
: `$${name}${idxStr != null ? "." + idxStr : ""}`,
varName: name,
});
last = idx + m[0].length;
@@ -165,7 +174,7 @@ const previewTokens = computed<PreviewToken[]>(() => {
for (const m of props.template.matchAll(TEMPLATE_VAR_RE)) {
const idx = m.index ?? 0;
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
tokens.push({ kind: "var", text: `$${m[1]}`, varName: m[1] });
tokens.push({ kind: "var", text: m[0], varName: m[1] });
last = idx + m[0].length;
}
if (last < props.template.length) tokens.push({ kind: "literal", text: props.template.slice(last) });
@@ -260,7 +260,7 @@ async function submit(mode: "update" | "new") {
font-family: inherit;
}
.wp-stm-input:focus { outline: 1px solid var(--wp-accent-500, var(--wp-accent)); }
.wp-stm-input--multi { resize: vertical; min-height: 46px; }
.wp-stm-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 46px; }
.wp-stm-select { cursor: pointer; }
.wp-stm-preview {
margin: 0;
+1 -1
View File
@@ -174,7 +174,7 @@ function onCancel(): void {
padding: 8px 10px;
font-family: var(--wp-font-mono, ui-monospace, "JetBrains Mono", monospace);
font-size: 12px;
resize: vertical;
resize: vertical; overscroll-behavior: contain;
box-sizing: border-box;
outline: none;
}
@@ -1,7 +1,10 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import ContextLoopWidget from "./ContextLoopWidget.vue";
import SeedListModal from "../shared/SeedListModal.vue";
import { emptyContextLoopConfig } from "./types";
import { currentFrame } from "./frame-cursor";
import { deriveLoopSeeds } from "../shared/seed-derive";
describe("ContextLoopWidget", () => {
it("renders defaults — hash chip active, both switches off, internal toggles on", () => {
@@ -89,3 +92,215 @@ describe("ContextLoopWidget", () => {
expect(w.find('[data-test="loop-iteration-internal"]').classes()).toContain("wp-loop__pi-btn--on");
});
});
function ws(extra: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}) {
return mount(ContextLoopWidget, {
props: { modelValue: { ...emptyContextLoopConfig(), ...extra }, baseSeed: 42, count: 4 },
attachTo: document.body,
global: { stubs: { teleport: true } },
});
}
describe("ContextLoopWidget seeds button", () => {
it("renders the Per-iteration seeds button", () => {
expect(ws().find('[data-test="loop-seeds-btn"]').exists()).toBe(true);
});
it("badge shows the locked count when >0", () => {
expect(ws({ seed_locks: { "1": 9, "2": 8 } }).find('[data-test="loop-seeds-badge"]').text()).toMatch(/2 locked/i);
});
it("hides the badge when nothing is locked", () => {
expect(ws().find('[data-test="loop-seeds-badge"]').exists()).toBe(false);
});
it("clicking opens the modal", async () => {
const wr = ws();
await wr.find('[data-test="loop-seeds-btn"]').trigger("click");
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
});
it("capitalizes the bypass row label", () => {
expect(ws().text()).toContain("Bypass loop");
});
it("passes the override hint to the modal only when Override Context seed is off", async () => {
const off = ws(); // override_seed false by default → nudge to turn it on
await off.find('[data-test="loop-seeds-btn"]').trigger("click");
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
const on = ws({ override_seed: true });
await on.find('[data-test="loop-seeds-btn"]').trigger("click");
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
});
});
describe("ContextLoopWidget frame selector", () => {
beforeEach(() => { currentFrame.value = null; });
function fw(count = 3) {
return mount(ContextLoopWidget, {
props: { modelValue: emptyContextLoopConfig(), count },
attachTo: document.body, global: { stubs: { teleport: true } },
});
}
it("renders base + one chip per iteration", () => {
expect(fw(3).findAll('[data-test^="loop-frame-"]')).toHaveLength(4);
});
it("clicking a frame chip sets the shared cursor; base clears it", async () => {
const wr = fw(3);
await wr.find('[data-test="loop-frame-2"]').trigger("click");
expect(currentFrame.value).toBe(1);
await wr.find('[data-test="loop-frame-base"]').trigger("click");
expect(currentFrame.value).toBe(null);
});
});
describe("ContextLoopWidget bypass (#13)", () => {
it("shows an N-bypassed badge on the seeds button", () => {
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
expect(w.find('[data-test="loop-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
});
it("marks bypassed edit-frame chips with a dashed-border class", () => {
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
// frame index 1 -> chip #2 (data-test loop-frame-2)
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--bypassed");
expect(w.find('[data-test="loop-frame-4"]').classes()).toContain("wp-fchips__chip--bypassed");
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-fchips__chip--bypassed");
});
it("has no bypass badge when none are bypassed", () => {
const w = mount(ContextLoopWidget, { props: { modelValue: emptyContextLoopConfig(), count: 5 } });
expect(w.find('[data-test="loop-seeds-bypass-badge"]').exists()).toBe(false);
});
it("writes bypass changes from the modal back into the config", async () => {
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [] };
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 4 } });
await w.get('[data-test="loop-seeds-btn"]').trigger("click");
w.findComponent(SeedListModal).vm.$emit("update:bypassFrames", [2]);
const calls = w.emitted("update:modelValue")!;
const last = calls[calls.length - 1][0] as typeof cfg;
expect(last.bypass_frames).toEqual([2]);
});
});
/**
* Modifier-click on the frame chips. Both states were already visible here but
* only changeable inside the modal, two clicks away and covering the node you
* were reading.
*/
describe("ContextLoopWidget frame chip modifier-clicks", () => {
beforeEach(() => { currentFrame.value = null; });
function fw(cfgPatch: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}, count = 4) {
return mount(ContextLoopWidget, {
props: { modelValue: { ...emptyContextLoopConfig(), ...cfgPatch }, count, baseSeed: 42 },
});
}
const lastEmit = (w: ReturnType<typeof fw>) => {
const calls = w.emitted("update:modelValue");
return calls?.[calls.length - 1]?.[0] as ReturnType<typeof emptyContextLoopConfig>;
};
it("shift-click locks the frame to the seed it would have used", async () => {
const w = fw();
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
const next = lastEmit(w);
// Frame index 2, and the value must be a real derived seed — not a
// placeholder, or the lock would silently change the output it froze.
expect(Object.keys(next.seed_locks!)).toEqual(["2"]);
expect(Number.isInteger(next.seed_locks!["2"])).toBe(true);
});
it("shift-click on a locked frame unlocks it", async () => {
const w = fw({ seed_locks: { "2": 777 } });
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
expect(lastEmit(w).seed_locks).toEqual({});
});
it("the chip-lock value matches what the modal's Lock all would write", async () => {
const w = fw();
await w.find('[data-test="loop-frame-2"]').trigger("click", { shiftKey: true });
const chipValue = lastEmit(w).seed_locks!["1"];
expect(chipValue).toBe(deriveLoopSeeds(42, 4, "hash_index")[1]);
});
it("alt-click bypasses a frame, and again re-enables it", async () => {
const w = fw();
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
expect(lastEmit(w).bypass_frames).toEqual([1]);
const w2 = fw({ bypass_frames: [1] });
await w2.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
expect(lastEmit(w2).bypass_frames).toEqual([]);
});
it("keeps bypassed indices sorted so the config does not churn", async () => {
const w = fw({ bypass_frames: [3] });
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
expect(lastEmit(w).bypass_frames).toEqual([1, 3]);
});
it("refuses to bypass the last running frame — a loop needs one", async () => {
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
await w.find('[data-test="loop-frame-4"]').trigger("click", { altKey: true });
expect(w.emitted("update:modelValue")).toBeUndefined();
});
it("still lets the last running frame be re-enabled and locked", async () => {
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
await w.find('[data-test="loop-frame-1"]').trigger("click", { altKey: true });
expect(lastEmit(w).bypass_frames).toEqual([1, 2]);
});
// Moving the editor to another frame as a side effect of locking one is the
// kind of surprise that makes people stop trusting a shortcut.
it("a modifier click never also moves the edit cursor", async () => {
const w = fw();
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
expect(currentFrame.value).toBe(null);
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
expect(currentFrame.value).toBe(null);
});
it("a plain click still just selects the frame", async () => {
const w = fw();
await w.find('[data-test="loop-frame-3"]').trigger("click");
expect(currentFrame.value).toBe(2);
expect(w.emitted("update:modelValue")).toBeUndefined();
});
it("base takes no modifiers — it has no seed and nothing to bypass", async () => {
const w = fw();
await w.find('[data-test="loop-frame-base"]').trigger("click", { shiftKey: true });
await w.find('[data-test="loop-frame-base"]').trigger("click", { altKey: true });
expect(w.emitted("update:modelValue")).toBeUndefined();
});
it("marks locked chips so the state is readable without the modal", () => {
const w = fw({ seed_locks: { "1": 123 } });
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--locked");
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-fchips__chip--locked");
});
it("a frame can be both locked and bypassed", () => {
const w = fw({ seed_locks: { "1": 123 }, bypass_frames: [1] });
const chip = w.find('[data-test="loop-frame-2"]');
expect(chip.classes()).toContain("wp-fchips__chip--locked");
expect(chip.classes()).toContain("wp-fchips__chip--bypassed");
});
it("ignores non-numeric lock keys rather than rendering a NaN state", () => {
const w = fw({ seed_locks: { junk: 1, "1": 123 } as Record<string, number> });
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--locked");
});
it("advertises the combos in the widget, not just in tooltips", () => {
const w = fw();
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/shift/i);
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt|option/i);
});
it("the tooltip names the action the next click will take", () => {
const w = fw({ seed_locks: { "1": 123 }, bypass_frames: [1] });
const t = w.find('[data-test="loop-frame-2"]').attributes("title")!;
expect(t).toMatch(/unlock/i);
expect(t).toMatch(/re-enable/i);
});
});
@@ -12,8 +12,13 @@
* host glue (`src/widgets/context_loop.ts`) serializes to JSON and
* pushes via `host.setValue` so ComfyUI's widget value matches.
*/
import { computed } from "vue";
import { computed, ref } from "vue";
import type { ContextLoopConfig, LoopStrategy } from "./types";
import SeedListModal from "../shared/SeedListModal.vue";
import { deriveLoopSeeds } from "../shared/seed-derive";
import { pushToast } from "../shared/toast-store";
import { currentFrame, setFrame } from "./frame-cursor";
import FrameChips from "../shared/FrameChips.vue";
const props = withDefaults(
defineProps<{
@@ -21,12 +26,24 @@ const props = withDefaults(
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
nodeMode?: number;
/** Base seed forwarded from the host ComfyUI INT widget. */
baseSeed?: number;
/** Iteration count forwarded from the host ComfyUI INT widget. */
count?: number;
/** The seed each frame used on the PREVIOUS run, captured from the node's
* executed `loop_seeds` UI payload by the host glue. Drives the seed
* modal's per-frame "lock previous" button. Null until a run lands. */
previousSeeds?: number[] | null;
}>(),
{ nodeMode: 0 },
{ nodeMode: 0, baseSeed: 0, count: 1, previousSeeds: null },
);
const emit = defineEmits<{ "update:modelValue": [next: ContextLoopConfig] }>();
/** Frame-grid collapse. Owned by the widget glue so it can persist to
* `node.properties` and survive a workflow save. */
const chipsCollapsed = defineModel<boolean>("chipsCollapsed", { default: false });
const STRATEGIES: { id: LoopStrategy; label: string; hint: string }[] = [
{ id: "hash_index", label: "hash", hint: "Independent per-iteration; recommended for varied results." },
{ id: "sequential", label: "sequential", hint: "base, base+1, base+2, … Predictable diffs." },
@@ -46,6 +63,81 @@ const OVERRIDE_SEED_TOOLTIP =
const isMuted = computed<boolean>(() => props.nodeMode === 2);
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
const seedsOpen = ref(false);
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
function onSeedLocks(next: Record<string, number>): void {
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
}
const bypassedCount = computed(() => (props.modelValue.bypass_frames ?? []).length);
/** 0-based bypassed frame indices — drives the dashed border on edit-frame
* chips so bypassed frames read at a glance without opening the modal. */
const bypassSet = computed(() => new Set(props.modelValue.bypass_frames ?? []));
function onBypassFrames(next: number[]): void {
emit("update:modelValue", { ...props.modelValue, bypass_frames: next });
}
/* ---------------------------------------------------------------------------
* Modifier-click on a frame chip — lock / bypass without the modal.
*
* Both states were already VISIBLE on the chips but only CHANGEABLE two clicks
* away, inside a modal that covers the node you are reading. Toggling a couple
* of frames is the common case; the modal earns its place for the bulk
* operations (lock all, paste a series, out-of-range locks) and keeps them.
*
* Modifier choice: Alt and Shift both survive the trip. Ctrl is out because on
* macOS Ctrl-click IS a right-click, so the combo would open a context menu
* instead. Alt for bypass follows the seed modal's existing Alt-click-for-a-
* variant idiom on its Copy button.
* ------------------------------------------------------------------------- */
/** 0-based locked frame indices. Keys are strings in the config; anything
* non-numeric is ignored rather than becoming `NaN` in the set. */
const lockSet = computed(() => {
const out = new Set<number>();
for (const k of Object.keys(props.modelValue.seed_locks ?? {})) {
const n = Number(k);
if (Number.isInteger(n)) out.add(n);
}
return out;
});
/** The seed series this loop WOULD produce — the same values "Lock all" in
* the modal writes, so a chip-lock and a modal-lock agree. */
const derivedSeeds = computed(() =>
deriveLoopSeeds(props.baseSeed, Math.max(1, props.count ?? 1), props.modelValue.strategy),
);
/** In-range frames that are still running. Bypassing the last one would leave
* the loop with nothing to execute, so the modal forbids it and so do we. */
const activeFrameCount = computed(() => {
let n = 0;
for (let i = 0; i < Math.max(1, props.count ?? 1); i++) if (!bypassSet.value.has(i)) n++;
return n;
});
function toggleFrameLock(i: number): void {
const next = { ...(props.modelValue.seed_locks ?? {}) };
if (lockSet.value.has(i)) delete next[String(i)];
else next[String(i)] = derivedSeeds.value[i] ?? props.baseSeed;
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
}
function toggleFrameBypass(i: number): void {
const on = bypassSet.value.has(i);
if (!on && activeFrameCount.value <= 1) {
// Silence here would read as a broken click, and the reason is not
// guessable from the chip.
pushToast("A loop needs at least one running frame.", { severity: "warning" });
return;
}
const next = new Set(bypassSet.value);
if (on) next.delete(i);
else next.add(i);
onBypassFrames([...next].sort((a, b) => a - b));
}
function pickStrategy(s: LoopStrategy): void {
if (props.modelValue.strategy === s) return;
emit("update:modelValue", { ...props.modelValue, strategy: s });
@@ -108,6 +200,32 @@ function toggleTotalInternal(): void {
</div>
</div>
<button type="button" class="wp-loop__seedbtn" data-test="loop-seeds-btn" @click="seedsOpen = true">
<span class="wp-loop__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
Per-iteration seeds
<span class="wp-loop__seedbtn-fill" />
<span v-if="lockedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-badge">{{ lockedCount }} locked</span>
<span v-if="bypassedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
<svg class="wp-loop__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
</button>
<!-- `base` is not a frame: it has no seed to lock and nothing to bypass,
so it takes no modifiers. -->
<FrameChips
label="edit frame"
:count="Math.max(1, count ?? 1)"
:locked="[...lockSet]"
:bypassed="[...bypassSet]"
v-model:collapsed="chipsCollapsed"
:current="currentFrame"
selectable
show-base
bypass-interactive
@select="setFrame"
@toggle-lock="toggleFrameLock"
@toggle-bypass="toggleFrameBypass"
/>
<div
class="wp-loop__row"
:title="OVERRIDE_SEED_TOOLTIP"
@@ -169,7 +287,7 @@ function toggleTotalInternal(): void {
</div>
<div class="wp-loop__row">
<span class="wp-loop__row-label">bypass loop</span>
<span class="wp-loop__row-label">Bypass loop</span>
<button
type="button"
class="wp-loop__switch"
@@ -181,6 +299,13 @@ function toggleTotalInternal(): void {
<span class="wp-loop__switch-thumb" />
</button>
</div>
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
:previous-seeds="previousSeeds"
:bypass-frames="modelValue.bypass_frames ?? []"
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
@update:seed-locks="onSeedLocks" @update:bypass-frames="onBypassFrames" @close="seedsOpen = false" />
</div>
</template>
@@ -204,9 +329,11 @@ function toggleTotalInternal(): void {
}
.wp-loop__chips { display: flex; gap: 4px; }
/* Strategy chips only — the per-frame grid now lives in FrameChips.vue. */
.wp-loop__chip {
flex: 1;
padding: 4px 6px;
text-align: center;
background: var(--wp-bg-deep, var(--wp-bg, #0e1015));
border: 1px solid var(--wp-border, #353841);
border-radius: 3px;
@@ -289,8 +416,8 @@ function toggleTotalInternal(): void {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: 18px;
height: 18px;
margin-left: 2px;
padding: 0;
background: transparent;
@@ -306,8 +433,15 @@ function toggleTotalInternal(): void {
border-color: var(--wp-accent, #c4b5fd);
color: var(--wp-accent, #c4b5fd);
}
.wp-loop__pi-btn .pi { font-size: 11px; }
.wp-loop__pi-btn-label { font: 600 12px var(--wp-font-mono, monospace); line-height: 1; }
.wp-loop__pi-btn .pi { font-size: 9px; }
.wp-loop__pi-btn-label { font: 600 10px var(--wp-font-mono, monospace); line-height: 1; }
.wp-loop__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
.wp-loop__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
.wp-loop__seedbtn-ico { color: var(--wp-accent); display: flex; }
.wp-loop__seedbtn-fill { flex: 1; }
.wp-loop__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
.wp-loop__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
/* Bypass-on visually dims the strategy/override/var rows since the loop
* is effectively off. The bypass row itself stays bright. */
@@ -0,0 +1,13 @@
import { describe, it, expect, beforeEach } from "vitest";
import { currentFrame, setFrame } from "./frame-cursor";
describe("frame-cursor", () => {
beforeEach(() => { currentFrame.value = null; });
it("defaults to null (base)", () => { expect(currentFrame.value).toBe(null); });
it("setFrame updates the shared ref", () => {
setFrame(2);
expect(currentFrame.value).toBe(2);
setFrame(null);
expect(currentFrame.value).toBe(null);
});
});
@@ -0,0 +1,12 @@
import { ref } from "vue";
// Module-scoped reactive singleton: the "current editing frame" cursor, shared
// across every ContextWidget + ContextLoopWidget Vue app on the page. Each node
// mounts its own app, so provide/inject can't span them — a shared module import
// is the cross-app channel (same pattern as context/drag-store.ts). `null` = base
// (no frame). Session-only — never serialized into the workflow.
export const currentFrame = ref<number | null>(null);
export function setFrame(frame: number | null): void {
currentFrame.value = frame;
}
+41
View File
@@ -30,6 +30,8 @@ describe("parseContextLoopConfig", () => {
bypass: true,
iteration_internal: false,
total_internal: false,
seed_locks: {},
bypass_frames: [],
};
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -42,6 +44,8 @@ describe("parseContextLoopConfig", () => {
bypass: false,
iteration_internal: true,
total_internal: true,
seed_locks: {},
bypass_frames: [],
};
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -65,8 +69,45 @@ describe("parseContextLoopConfig", () => {
bypass: true,
iteration_internal: false,
total_internal: true,
seed_locks: {},
bypass_frames: [],
};
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
expect(round).toEqual(cfg);
});
});
describe("ContextLoopConfig seed_locks", () => {
it("defaults seed_locks to {}", () => {
expect(emptyContextLoopConfig().seed_locks).toEqual({});
});
it("parses seed_locks", () => {
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
});
it("drops non-numeric lock values", () => {
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
});
it("round-trips through serialize", () => {
const cfg = { ...emptyContextLoopConfig(), seed_locks: { "0": 7 } };
expect(parseContextLoopConfig(serializeContextLoopConfig(cfg)).seed_locks).toEqual({ "0": 7 });
});
});
describe("bypass_frames parse", () => {
it("defaults to empty", () => {
expect(emptyContextLoopConfig().bypass_frames).toEqual([]);
expect(parseContextLoopConfig("{}").bypass_frames).toEqual([]);
});
it("dedups + sorts, keeps out-of-range, drops junk + negatives", () => {
expect(parseContextLoopConfig('{"bypass_frames":[3,1,3,0]}').bypass_frames).toEqual([0, 1, 3]);
expect(parseContextLoopConfig('{"bypass_frames":[2,-1,"x",2,999]}').bypass_frames).toEqual([2, 999]);
});
it("drops coercible non-numbers, not coerce them (Python parity)", () => {
// true/"3"/null would coerce to 1/3/0 under Number() — must be dropped.
expect(parseContextLoopConfig('{"bypass_frames":[true,"3",null,2]}').bypass_frames).toEqual([2]);
});
it("malformed collapses to empty", () => {
expect(parseContextLoopConfig('{"bypass_frames":"nope"}').bypass_frames).toEqual([]);
expect(parseContextLoopConfig("not json").bypass_frames).toEqual([]);
});
});
+28
View File
@@ -20,6 +20,13 @@ export interface ContextLoopConfig {
iteration_internal: boolean;
/** Same idea for `$<iteration_var_name>_total`. */
total_internal: boolean;
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
* indices re-derive from base+strategy. Empty by default. */
seed_locks: Record<string, number>;
/** 0-based iteration indices to bypass (skip generation + overrides).
* Sorted, deduped, non-negative. Out-of-range (>= count) entries are
* kept and re-apply if count grows. Empty by default. */
bypass_frames: number[];
}
const STRATEGIES = new Set<LoopStrategy>(["sequential", "hash_index", "prime_stride"]);
@@ -32,6 +39,8 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
bypass: false,
iteration_internal: true,
total_internal: true,
seed_locks: {},
bypass_frames: [],
};
}
@@ -60,6 +69,25 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
if (typeof obj.bypass === "boolean") out.bypass = obj.bypass;
if (typeof obj.iteration_internal === "boolean") out.iteration_internal = obj.iteration_internal;
if (typeof obj.total_internal === "boolean") out.total_internal = obj.total_internal;
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
const locks: Record<string, number> = {};
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
}
out.seed_locks = locks;
}
if (Array.isArray(obj.bypass_frames)) {
const frames = new Set<number>();
for (const x of obj.bypass_frames) {
// Type-guard before accepting — do NOT coerce. Mirrors the Python
// `_parse_config` (which rejects non-int via isinstance), so both
// parsers degrade corrupt widget JSON identically (`true`, `"3"`,
// `null` are dropped on both sides, not coerced to 1/3/0).
if (typeof x !== "number" || !Number.isInteger(x) || x < 0) continue;
frames.add(x);
}
out.bypass_frames = [...frames].sort((a, b) => a - b);
}
return out;
}
+5 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import ModalShell from "../shared/ModalShell.vue";
import ConfirmDialog from "../shared/ConfirmDialog.vue";
import { api } from "../../manager/api/client";
@@ -165,14 +165,15 @@ function onKeydown(ev: KeyboardEvent) {
}
}
onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
// Keys arrive from ModalShell (@keydown below) rather than window: the modal
// shield stops inside-modal keys at the overlay so they cannot reach ComfyUI's
// global shortcuts, so a window listener here would never fire.
const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
</script>
<template>
<ModalShell :visible="visible" @close="emit('close')">
<ModalShell :visible="visible" @close="emit('close')" @keydown="onKeydown">
<div class="wp-bp" role="dialog">
<header class="wp-bp__head">
<div class="wp-bp__head-text">
+606 -6
View File
@@ -1,7 +1,9 @@
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import ContextWidget from "./ContextWidget.vue";
import { baseCodename } from "../../extension/node-codename";
import { _resetForTests as resetDriftStore } from "./drift-store";
import { currentFrame } from "../context-loop/frame-cursor";
beforeEach(() => {
// Phase 5.5.4 — the picker now fetches the library on mount. Stub
@@ -171,7 +173,7 @@ describe("ContextWidget drift dot", () => {
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
// Live hash differs from embedded → drift.
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-LIVE" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -206,6 +208,75 @@ describe("ContextWidget drift dot", () => {
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
wrapper.unmount();
});
it("shows drift for an empty-hash module whose uuid IS in the live library", async () => {
// Repro: a workflow whose embedded module carries payload_hash "" (a
// malformed/legacy/builder-emitted copy) but whose uuid exists in the
// library. It must NOT be treated as inline/verdict-less — surface it as
// drift so the bulk "refresh drifted" button can relink it.
resetDriftStore();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { bbbbbbbb: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
);
const initialJson = JSON.stringify({
version: 1,
modules: [
{
id: "bbbbbbbb", type: "wildcard", enabled: true,
meta: { name: "wc", description: "", tags: [] }, entries: [],
payload: { options: [] }, payload_hash: "",
},
],
});
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 98, initialJson, upstreamVars: [], onChange: () => {} },
});
await vi.waitFor(() => {
expect(wrapper.find(".wp-mod-dot--drift").exists()).toBe(true);
});
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
wrapper.unmount();
});
it("leaves a genuinely inline module (empty hash, uuid NOT in library) verdict-less", async () => {
resetDriftStore();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { somethingelse: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
);
const initialJson = JSON.stringify({
version: 1,
modules: [
{
id: "cccccccc", type: "wildcard", enabled: true,
meta: { name: "inline", description: "", tags: [] }, entries: [],
payload: { options: [] }, payload_hash: "",
},
],
});
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 97, initialJson, upstreamVars: [], onChange: () => {} },
});
// Give the poll a beat to land, then assert no drift/missing badges.
await flushPromises();
await flushPromises();
expect(wrapper.find(".wp-mod-dot--drift").exists()).toBe(false);
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
wrapper.unmount();
});
});
describe("ContextWidget bulk refresh", () => {
@@ -215,7 +286,7 @@ describe("ContextWidget bulk refresh", () => {
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-A", bbbbbbbb: "h-B" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-A" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-B" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -251,7 +322,7 @@ describe("ContextWidget bulk refresh", () => {
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
// Both embedded hashes differ from live → both drifted.
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A", bbbbbbbb: "live-B" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" }, bbbbbbbb: { type: "wildcard", payload_hash: "live-B" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -292,7 +363,7 @@ describe("ContextWidget bulk refresh", () => {
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" } } }), { status: 200 });
}
if (typeof url === "string" && url.endsWith("/embed-bundle")) {
const body = JSON.parse(String(init?.body ?? "{}")) as { uuids: string[] };
@@ -402,7 +473,7 @@ describe("ContextWidget bulk refresh", () => {
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live-A" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live-A" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -647,6 +718,105 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
wrapper.unmount();
});
it("Reset REFUSES (keeps children) when the instance isn't library-linked", async () => {
resetDriftStore();
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 })));
const bundleUid = "buidEMPTY001";
const initialJson = JSON.stringify({
version: 1,
modules: [
{
id: "dddddddd", type: "wildcard", enabled: true,
meta: { name: "LocallyEdited" }, entries: [],
payload: { var_binding: "v", options: [{ id: "o2", value: "y", weight: 2 }] },
payload_hash: "h-edited", bundle_origin: bundleUid,
},
],
bundles: [
{
_uid: bundleUid, library_id: "", start_idx: 0, end_idx: 0,
enabled: true, collapsed: false, inserted_at_hash: "", name: "Unlinked", color: null,
},
],
});
const onChange = vi.fn();
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 305, initialJson, upstreamVars: [], onChange },
});
await flushPromises();
onChange.mockClear();
const header = document.querySelector(`[data-bundle-uid="${bundleUid}"][data-bundle-header]`);
expect(header).toBeTruthy();
header!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await flushPromises();
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
const reset = items.find((el) => el.textContent?.includes("Reset to library snapshot"));
expect(reset).toBeTruthy();
reset!.click();
await flushPromises();
// Refused — the live child is untouched, no destructive empty commit.
expect(wrapper.find('[data-module-id="dddddddd"]').exists()).toBe(true);
expect(onChange).not.toHaveBeenCalled();
wrapper.unmount();
});
it("Reset REFUSES (keeps children) when the library snapshot comes back empty", async () => {
resetDriftStore();
const fetchSpy = vi.fn(async (url: string, init?: RequestInit) => {
if (typeof url === "string" && url.match(/\/wp\/api\/bundles\/bn_empty$/) && (!init || init.method === "GET")) {
return new Response(JSON.stringify({
id: "bn_empty", name: "EmptySnap", description: "", color: null,
category_id: null, tags: [], is_favorite: false,
children: [], payload_hash: "ph-empty", version: 1, created_at: "", updated_at: "",
}), { status: 200 });
}
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchSpy);
const bundleUid = "buidEMPTY002";
const initialJson = JSON.stringify({
version: 1,
modules: [
{
id: "dddddddd", type: "wildcard", enabled: true,
meta: { name: "LocallyEdited" }, entries: [],
payload: { var_binding: "v", options: [{ id: "o2", value: "y", weight: 2 }] },
payload_hash: "h-edited", bundle_origin: bundleUid,
},
],
bundles: [
{
_uid: bundleUid, library_id: "bn_empty", start_idx: 0, end_idx: 0,
enabled: true, collapsed: false, inserted_at_hash: "ph-old", name: "EmptySnap", color: null,
},
],
});
const onChange = vi.fn();
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 306, initialJson, upstreamVars: [], onChange },
});
await flushPromises();
onChange.mockClear();
const header = document.querySelector(`[data-bundle-uid="${bundleUid}"][data-bundle-header]`);
expect(header).toBeTruthy();
header!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
await flushPromises();
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
const reset = items.find((el) => el.textContent?.includes("Reset to library snapshot"));
expect(reset).toBeTruthy();
reset!.click();
await flushPromises();
// The empty snapshot must NOT wipe the live child.
expect(wrapper.find('[data-module-id="dddddddd"]').exists()).toBe(true);
wrapper.unmount();
});
it("Reset re-adds children removed since insert (bundle end_idx grew via reconcile)", async () => {
resetDriftStore();
// Library has TWO children; in-graph bundle currently has ONE
@@ -727,6 +897,124 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
});
});
describe("ContextWidget Hold toggle via ctxmenu", () => {
beforeEach(() => {
// Hold is base-level; pin the shared frame cursor to base so the
// menu item isn't disabled by a leftover edit-frame from another test.
currentFrame.value = null;
});
afterEach(() => {
currentFrame.value = null;
});
it("Hold across run stamps seed_scope=hold; reopening offers Release hold which flips back to vary", async () => {
resetDriftStore();
const onChange = vi.fn();
const initialJson = JSON.stringify({
version: 1,
modules: [
{
id: "aaaaaaaa", type: "wildcard", enabled: true,
meta: { name: "Hair" }, entries: [],
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
},
],
});
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 410, initialJson, upstreamVars: [], onChange },
});
await flushPromises();
onChange.mockClear();
// Un-held module → item reads "Hold across run" and is enabled.
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
await flushPromises();
let items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
expect(hold, "Hold item should appear for a wildcard row").toBeTruthy();
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(false);
hold!.click();
await flushPromises();
const afterHold = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
expect(JSON.parse(afterHold).modules[0].instance.seed_scope).toBe("hold");
// Held module → label swaps to "Release hold"; clicking it varies again.
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
await flushPromises();
items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
const release = items.find((el) => el.textContent?.includes("Release hold"));
expect(release, "Held row should offer Release hold").toBeTruthy();
release!.click();
await flushPromises();
const afterRelease = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
expect(JSON.parse(afterRelease).modules[0].instance.seed_scope).toBe("vary");
wrapper.unmount();
});
it("disables the Hold item when a loop edit-frame is active (hold is base-only)", async () => {
resetDriftStore();
const initialJson = JSON.stringify({
version: 1,
modules: [
{
id: "aaaaaaaa", type: "wildcard", enabled: true,
meta: { name: "Hair" }, entries: [],
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
},
],
});
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 412, initialJson, upstreamVars: [], onChange: () => {} },
});
await flushPromises();
currentFrame.value = 0; // user is editing frame #0 of a loop
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
await flushPromises();
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
expect(hold, "Hold item still present while a frame is active").toBeTruthy();
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(true);
wrapper.unmount();
});
it("omits the Hold item for a constraint row (seed_scope not honored)", async () => {
resetDriftStore();
const initialJson = JSON.stringify({
version: 1,
modules: [
{ id: "aaaaaaaa", type: "wildcard", enabled: true, meta: { name: "Hair" }, entries: [], payload: { options: [], var_binding: "hair" } },
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "Outfit" }, entries: [], payload: { options: [], var_binding: "outfit" } },
{
id: "cccccccc", type: "constraint", enabled: true,
meta: { name: "Hair × Outfit" }, entries: [],
payload: {
source_wildcard_id: "aaaaaaaa", target_wildcard_id: "bbbbbbbb",
matrix: { blonde: { casual: { mode: "allow", factor: 1 } } }, exceptions: [],
}, payload_hash: "h",
},
],
});
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 411, initialJson, upstreamVars: [], onChange: () => {} },
});
await flushPromises();
await wrapper.find('[data-module-id="cccccccc"]').trigger("contextmenu");
await flushPromises();
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
expect(items.length).toBeGreaterThan(0);
expect(items.find((el) => el.textContent?.includes("Hold across run"))).toBeUndefined();
expect(items.find((el) => el.textContent?.includes("Release hold"))).toBeUndefined();
wrapper.unmount();
});
});
describe("ContextWidget summaryFor — constraint", () => {
it("renders $src → $tgt · NxM with sibling lookup", async () => {
resetDriftStore();
@@ -946,6 +1234,64 @@ function mountWithModules(stubs: StubModule[]) {
});
}
// ── D3a: library re-link (onPushSaved path) ──────────────────────────────────
describe("ContextWidget module re-link", () => {
it("re-links a detached row + follows a sibling constraint's source_wildcard_id", async () => {
const onChange = vi.fn();
const wrapper = mount(ContextWidget, {
props: {
nodeId: 9100,
initialJson: JSON.stringify({
version: 1,
modules: [
{
id: "dead0001", type: "wildcard", enabled: true,
meta: { name: "hair", description: "", tags: [] }, entries: [],
payload: { options: [{ id: "o1", value: "red" }] }, payload_hash: "HASH_A",
},
{
id: "c1", type: "constraint", enabled: true,
meta: { name: "cn", description: "", tags: [] }, entries: [],
payload: { source_wildcard_id: "dead0001", target_wildcard_id: "w2", matrix: {} },
payload_hash: "HC",
},
],
}),
upstreamVars: [],
onChange,
},
});
// Let the initial parse settle (its suppressWatch cycle) so the relink
// change below isn't coalesced into the swallowed first watcher tick.
await flushPromises();
onChange.mockClear();
// Two PushToLibraryModal instances exist: ModuleEditModal's (bound to
// onPushToLibrarySaved, draft=null here) and ContextWidget's own (bound to
// onPushSaved, the right-click path). The latter is last in DFS order.
const ptls = wrapper.findAllComponents({ name: "PushToLibraryModal" });
expect(ptls.length).toBeGreaterThan(0);
const ptl = ptls[ptls.length - 1];
ptl.vm.$emit("saved", {
mode: "relink", id: "live0001", origId: "dead0001",
payload_hash: "HASH_A", name: "hair", bundles_updated: [],
});
await flushPromises();
const calls = onChange.mock.calls;
const lastJson = calls[calls.length - 1]?.[0] as string;
const parsed = JSON.parse(lastJson) as {
modules: Array<{ id: string; type: string; payload: Record<string, unknown> }>;
};
const wc = parsed.modules.find((m) => m.type === "wildcard");
const con = parsed.modules.find((m) => m.type === "constraint");
expect(wc?.id).toBe("live0001");
expect(con?.payload.source_wildcard_id).toBe("live0001");
wrapper.unmount();
});
});
// ── Sibling badge (Phase B: lives in summary line, not header) ────────────
//
// Phase A shipped the badge in the header always-visible. Phase B
@@ -1120,7 +1466,7 @@ describe("ContextWidget status badges", () => {
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "live" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -1149,6 +1495,56 @@ describe("ContextWidget status badges", () => {
wrapper.unmount();
});
it("an UNLINKED bundle reads as missing, not modified", async () => {
// A bundle with no `library_id` could never be MISSING (that check
// early-returned false without one), so its only badge was MODIFIED —
// diffing its children against a snapshot baseline from a library entry
// that isn't there. "Modified against what?" had no answer.
resetDriftStore();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/hashes")) {
return new Response(JSON.stringify({ hashes: {} }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
);
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 815,
initialJson: JSON.stringify({
version: 1,
modules: [
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "child" }, entries: [], payload: {} },
],
bundles: [{
_uid: "bu1",
// No library_id — the unlinked shape.
library_id: "",
name: "Pose Library",
start_idx: 0,
end_idx: 0,
enabled: true,
// A stale baseline that would otherwise light up MOD.
snapshot_fingerprint: "v2:deadbeef",
}],
}),
upstreamVars: [],
onChange: () => {},
},
});
await vi.waitFor(() => {
expect(wrapper.find(".wp-mod-badge--missing").exists()).toBe(true);
});
// MOD is meaningless with no insert to have diverged from.
expect(wrapper.find(".wp-mod-badge--modified").exists()).toBe(false);
wrapper.unmount();
});
it("renders 'missing' text badge when uuid is not in library", async () => {
resetDriftStore();
vi.stubGlobal(
@@ -1184,6 +1580,49 @@ describe("ContextWidget status badges", () => {
});
wrapper.unmount();
});
it("renders 'clash' badge when the library row at this id is a different kind", async () => {
resetDriftStore();
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
// Same id, DIFFERENT kind in library (wildcard) than the embedded
// constraint → type-conflict, not drift/missing.
return new Response(
JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "live" } } }),
{ status: 200 },
);
}
return new Response("{}", { status: 200 });
}),
);
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 802,
initialJson: JSON.stringify({
version: 1,
modules: [
{ id: "aaaaaaaa", type: "constraint", enabled: true, meta: { name: "c" }, entries: [], payload: {}, payload_hash: "embedded" },
],
}),
upstreamVars: [],
onChange: () => {},
},
});
await vi.waitFor(() => {
const badge = wrapper.find(".wp-mod-badge--clash");
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe("clash");
});
// type-conflict is mutually exclusive with drift + missing.
expect(wrapper.find(".wp-mod-badge--drift").exists()).toBe(false);
expect(wrapper.find(".wp-mod-badge--missing").exists()).toBe(false);
wrapper.unmount();
});
});
// ── B3: kind chip next to module name (mockup v5 lines 681, 696, 711, 722, 733) ─
@@ -2104,3 +2543,164 @@ describe("ContextWidget bundle MOD detection", () => {
bundleHashes.value = null; // restore
});
});
// ── Node codename header chip ───────────────────────────────────────────────
// The node header shows a FIXED, read-only codename — a stable
// adjective-noun derived from the litegraph node id (node-codename.ts) — so
// cross-node UI (constraint reach pick-list, pair popovers) can name WHICH
// node a target instance lives in. POV-independent + unique on the canvas,
// and NOT editable (replaced the old editable label + walk-position letter).
describe("ContextWidget node codename chip", () => {
it("renders [data-test=node-label] showing the node's stable codename", () => {
const wrapper = mount(ContextWidget, {
props: {
nodeId: 7000,
initialJson: '{"version":1,"modules":[]}',
upstreamVars: [],
onChange: () => {},
},
});
const chip = wrapper.find('[data-test="node-label"]');
expect(chip.exists()).toBe(true);
expect(chip.text()).toContain(baseCodename(7000));
wrapper.unmount();
});
it("is read-only — exposes no edit input", () => {
const wrapper = mount(ContextWidget, {
props: {
nodeId: 7001,
initialJson: '{"version":1,"modules":[]}',
upstreamVars: [],
onChange: () => {},
},
});
expect(wrapper.find('[data-test="node-label-input"]').exists()).toBe(false);
wrapper.unmount();
});
it("derives the codename from the node id, ignoring chainModules / position", () => {
// The core fix for the old A/B/C scheme: the chip is purely id-derived
// now, so it never depends on walk position or any chainModules label.
const wrapper = mount(ContextWidget, {
props: {
nodeId: 7002,
initialJson: '{"version":1,"modules":[]}',
upstreamVars: [],
chainModules: [
{ id: "own", rowKey: "7002#own-uid", type: "wildcard", payload: {}, nodeLabel: "ignored-label" },
],
onChange: () => {},
},
});
const chip = wrapper.find('[data-test="node-label"]');
expect(chip.text()).toContain(baseCodename(7002));
expect(chip.text()).not.toContain("ignored-label");
wrapper.unmount();
});
});
// ── Bulk toolbar: bundle-aware collapse / expand / toggle-all ────────────────
// Toggle-all must operate on TOP-LEVEL units — standalone modules + bundle
// MASTER toggles — never reach inside a bundle to flip child modules (the
// master gates them; effective = master AND child). Collapse/expand-all must
// flip bundle FRAMES too, not just module cards. The three buttons match the
// small row-icon size.
describe("ContextWidget bulk toolbar — bundle-aware", () => {
function mountMixed(opts: { bundleEnabled?: boolean; stdEnabled?: boolean } = {}) {
const bundleEnabled = opts.bundleEnabled ?? true;
const stdEnabled = opts.stdEnabled ?? true;
const modules = [
{
id: "ch0aaaaa", _uid: "u0", type: "wildcard", enabled: true, collapsed: false,
meta: { name: "ch0" }, entries: [], payload: { var_binding: "v0", options: [] },
payload_hash: "ph0", bundle_origin: "bUID",
},
{
id: "ch1aaaaa", _uid: "u1", type: "wildcard", enabled: true, collapsed: false,
meta: { name: "ch1" }, entries: [], payload: { var_binding: "v1", options: [] },
payload_hash: "ph1", bundle_origin: "bUID",
},
{
id: "stdaaaaa", _uid: "u2", type: "wildcard", enabled: stdEnabled, collapsed: false,
meta: { name: "std" }, entries: [], payload: { var_binding: "vs", options: [] },
payload_hash: "phs",
},
];
const bundles = [{
_uid: "bUID", library_id: "lib_b", start_idx: 0, end_idx: 1,
enabled: bundleEnabled, collapsed: false, name: "Group", color: null,
}];
const onChange = vi.fn();
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 9400,
initialJson: JSON.stringify({ version: 1, modules, bundles }),
upstreamVars: [],
onChange,
},
});
return { wrapper, onChange };
}
function lastParsed(onChange: ReturnType<typeof vi.fn>) {
const calls = onChange.mock.calls;
return JSON.parse(calls[calls.length - 1][0] as string) as {
modules: Array<{ _uid: string; enabled: boolean; collapsed?: boolean }>;
bundles: Array<{ _uid: string; enabled: boolean; collapsed: boolean }>;
};
}
const findMod = (p: ReturnType<typeof lastParsed>, uid: string) =>
p.modules.find((m) => m._uid === uid)!;
it("toggle-all flips the bundle MASTER + standalone module, leaving bundle children untouched", async () => {
const { wrapper, onChange } = mountMixed();
onChange.mockClear();
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
const p = lastParsed(onChange);
expect(p.bundles[0].enabled).toBe(false); // master flipped off
expect(findMod(p, "u2").enabled).toBe(false); // standalone flipped off
// Children NOT reached — gated by the master, restore-safe.
expect(findMod(p, "u0").enabled).toBe(true);
expect(findMod(p, "u1").enabled).toBe(true);
wrapper.unmount();
});
it("toggle-all re-enables when every top-level unit is already off", async () => {
const { wrapper, onChange } = mountMixed({ bundleEnabled: false, stdEnabled: false });
onChange.mockClear();
await wrapper.find('[aria-label="Toggle all enabled"]').trigger("click");
const p = lastParsed(onChange);
expect(p.bundles[0].enabled).toBe(true);
expect(findMod(p, "u2").enabled).toBe(true);
wrapper.unmount();
});
it("collapse-all collapses bundle FRAMES + module cards; expand-all expands both", async () => {
const { wrapper, onChange } = mountMixed();
onChange.mockClear();
await wrapper.find('[aria-label="Collapse all modules"]').trigger("click");
let p = lastParsed(onChange);
expect(p.bundles[0].collapsed).toBe(true);
expect(p.modules.every((m) => m.collapsed === true)).toBe(true);
onChange.mockClear();
await wrapper.find('[aria-label="Expand all modules"]').trigger("click");
p = lastParsed(onChange);
expect(p.bundles[0].collapsed).toBe(false);
expect(p.modules.every((m) => m.collapsed === false)).toBe(true);
wrapper.unmount();
});
it("the three bulk buttons use the small row-icon size class", () => {
const { wrapper } = mountMixed();
for (const label of ["Collapse all modules", "Expand all modules", "Toggle all enabled"]) {
const btn = wrapper.find(`[aria-label="${label}"]`);
expect(btn.exists()).toBe(true);
expect(btn.classes()).toContain("wp-btn--icon-sm");
expect(btn.classes()).not.toContain("wp-btn--icon");
}
wrapper.unmount();
});
});
File diff suppressed because it is too large Load Diff
+94 -2
View File
@@ -1,10 +1,24 @@
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import ModuleEditModal from "./ModuleEditModal.vue";
import type { ModuleEntry } from "../../widgets/_shared";
import { _resetForTests } from "../../extension/preview-resolver";
// The v2 DerivationInstanceModal fetches /wp/api/modules on open to build
// its `@{}` ref-data. Stub fetch so that round-trip settles harmlessly in
// the derivation dispatcher tests below (shape mirrors ModulePickerModal).
function stubEmptyModulesFetch(): void {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0 }),
})) as unknown as typeof fetch,
);
}
// ModalShell uses <Teleport to="body">. VTU's `find` only walks the
// component's own subtree, so disable teleport globally for these tests
// — content lands inline where wrapper.find can reach it.
@@ -523,7 +537,11 @@ describe("ModuleEditModal — combine v2 dispatcher", () => {
// ── Derivation v2 dispatcher branch ────────────────────────────────────
describe("ModuleEditModal — derivation v2 dispatcher", () => {
beforeEach(() => _resetForTests());
beforeEach(() => {
_resetForTests();
stubEmptyModulesFetch();
});
afterEach(() => vi.unstubAllGlobals());
it("renders DerivationInstanceModal (no tab strip) for derivation kind", async () => {
const wrapper = mount(ModuleEditModal, {
@@ -549,6 +567,47 @@ describe("ModuleEditModal — derivation v2 dispatcher", () => {
const saved = wrapper.emitted("save")?.[0][0] as ModuleEntry;
expect(saved.instance?.disabled_rule_ids).toEqual(["r1"]);
});
// Bug parity: the shell must thread upstream + sibling vars into the
// derivation modal so its action/condition override fields can offer
// `$var` autocomplete — the SAME props the wildcard + combine modals
// already receive. The `@{}` source is the LIBRARY (fetched inside the
// modal), so sibling/chain MODULES are intentionally NOT forwarded.
it("forwards upstream-vars + sibling-vars to DerivationInstanceModal", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: {
visible: true,
module: makeDerivation(),
upstreamVars: ["age", "season"],
siblingVars: ["mood"],
},
});
await nextTick();
const dvm = wrapper.findComponent({ name: "DerivationInstanceModal" });
expect(dvm.props("upstreamVars")).toEqual(["age", "season"]);
expect(dvm.props("siblingVars")).toEqual(["mood"]);
});
});
// ── Frame banner + revert-to-base (Phase E.1) ───────────────────────────
describe("ModuleEditModal — frame banner", () => {
beforeEach(() => _resetForTests());
it("shows the frame banner + emits revert-frame when a frame is active", async () => {
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: 1 } });
await nextTick();
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(true);
await w.find('[data-test="modal-frame-revert"]').trigger("click");
expect(w.emitted("revert-frame")).toBeTruthy();
});
it("no frame banner on base (currentFrame null)", async () => {
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: null } });
await nextTick();
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(false);
});
});
// ── Constraint v2 dispatcher branch ────────────────────────────────────
@@ -583,6 +642,39 @@ describe("ModuleEditModal — constraint v2 dispatcher", () => {
expect(saved.instance?.cell_mode_overrides).toEqual({ '["a","b"]': "exclude" });
});
// Cross-node chain (SP3 P6 T16): the mount layer threads the flattened
// chain as `chainModules`; the shell must forward it verbatim to the
// constraint modal so a target wildcard in another Context node still
// resolves. Resolution itself is covered in ConstraintInstanceModal.test.ts.
it("forwards chainModules prop through to ConstraintInstanceModal", async () => {
const chain = [
{
id: "bbbbbbbb",
rowKey: "99#bbbbbbbb",
type: "wildcard",
displayName: "outfit",
payload: { sub_categories: ["tee", "gown"], options: [] },
},
];
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeConstraint(), chainModules: chain },
});
await nextTick();
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
expect(cnm.props("chainModules")).toEqual(chain);
});
it("leaves the child chainModules undefined when the prop is omitted", async () => {
const wrapper = mount(ModuleEditModal, {
...mountOpts,
props: { visible: true, module: makeConstraint() },
});
await nextTick();
const cnm = wrapper.findComponent({ name: "ConstraintInstanceModal" });
expect(cnm.props("chainModules")).toBeUndefined();
});
it("Reset overrides clears all override fields, preserves Identity", async () => {
const m = {
...makeConstraint(),
+116 -18
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from "vue";
import { ref, computed, watch } from "vue";
import ModalShell from "../shared/ModalShell.vue";
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
import { type ResolvedValue } from "../../widgets/richTokenize";
import {
INSTANCE_FIELDS_PER_KIND,
type InstanceFieldKey,
@@ -15,7 +17,8 @@ import FixedValuesInstanceModal from "./editors/fixed-values/FixedValuesInstance
import CombineInstanceModal from "./editors/combine/CombineInstanceModal.vue";
import DerivationInstanceModal from "./editors/derivation/DerivationInstanceModal.vue";
import ConstraintInstanceModal from "./editors/constraint/ConstraintInstanceModal.vue";
import type { PairingBadge } from "../../extension/constraint-pairs";
import type { ChainModule, PairingBadge } from "../../extension/constraint-pairs";
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
const props = defineProps<{
@@ -23,15 +26,23 @@ const props = defineProps<{
module: ModuleEntry | null;
/** Variable names defined upstream — used for autocomplete + validity checks. */
upstreamVars?: string[];
/** `$var` → its producing module + node. Forwarded to the per-kind modals so
* var chips can name where a value comes from. */
upstreamProducers?: Record<string, VarProducerLike>;
/** Resolved upstream `$name → value` map. Combine modal uses this
* to render a live preview pane with vars substituted. */
upstreamResolved?: Record<string, string>;
upstreamResolved?: Record<string, ResolvedValue>;
/** Variable names defined by OTHER modules in the same node. */
siblingVars?: string[];
/** Other modules in the same WP_Context node — used by the constraint
* preview to resolve source/target uuids back to var-binding names.
* May include the module being edited; lookups by uuid skip self. */
siblingModules?: ModuleEntry[];
/** Flattened cross-node module chain (upstream + own + downstream
* WP_Context nodes). Forwarded to the constraint modal so it can
* resolve a source/target wildcard that lives in another Context
* node, not just `siblingModules` (same-node only). */
chainModules?: ChainModule[];
/** Per-option pair badges for the currently-edited wildcard when it
* acts as a constraint carrier (target reached via nested `@{uuid}`).
* Keyed by option id. Empty for non-wildcard or non-carrier rows. */
@@ -45,11 +56,15 @@ const props = defineProps<{
* generic chain seed.
*/
lastUsedSeedReader?: (moduleId?: string) => number | null;
/** When a frame override context is active, the 0-based frame index.
* Null / undefined = editing the base (no banner shown). */
currentFrame?: number | null;
}>();
const emit = defineEmits<{
(e: "save", value: ModuleEntry): void;
(e: "close"): void;
(e: "revert-frame"): void;
}>();
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
@@ -57,18 +72,22 @@ const emit = defineEmits<{
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
const draft = ref<ModuleEntry | null>(null);
watch(() => props.visible, (v) => {
if (v && props.module) {
watch([() => props.visible, () => props.module], () => {
if (props.visible && props.module) {
// Re-clone on open AND whenever the source module changes under an open
// modal — e.g. "Revert to base" clears the frame override, so editingModule
// recomputes and the draft must resync to the base instance. (During normal
// editing the draft is mutated locally; props.module identity is stable, so
// this does not clobber in-progress edits.)
draft.value = JSON.parse(JSON.stringify(props.module));
window.addEventListener("keydown", onKeydown);
} else {
window.removeEventListener("keydown", onKeydown);
draft.value = null;
}
}, { immediate: true });
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
// Keys arrive from ModalShell, not from window: the modal shield stops
// inside-modal keys at the overlay so they cannot reach ComfyUI's global
// shortcuts, which means a window listener here would never fire.
function onKeydown(ev: KeyboardEvent) {
if (!props.visible) return;
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
@@ -141,7 +160,10 @@ function onConfirmDialogCancel(): void {
* it's still on v1 and doesn't separate runtime from overrides.
*/
const RESET_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly InstanceFieldKey[]> = {
wildcard: ["enabled_options", "option_weights", "category_filter"],
wildcard: [
"enabled_options", "option_weights", "category_filter", "exclude_null",
"pick_min", "pick_max", "pick_separator",
],
fixed_values: ["values_overrides", "enabled_options"],
combine: ["template_override", "variable_binding"],
derivation: [
@@ -230,7 +252,7 @@ function onSaveToLibraryClick(): void {
}
interface PushSaveResult {
mode: "update" | "fork" | "reattach";
mode: "update" | "fork" | "reattach" | "relink";
id: string;
payload_hash: string;
bundles_updated: string[];
@@ -244,12 +266,17 @@ function onPushToLibrarySaved(result: PushSaveResult): void {
return;
}
setLibraryHash(result.id, result.payload_hash);
if (result.mode === "fork" || result.mode === "reattach") {
if (result.mode === "fork" || result.mode === "reattach" || result.mode === "relink") {
// Fork: the new entry replaces the draft's identity; `_originalId`
// lets saveEditedModule swap the row by old id during the next
// save(). Reattach: same wiring — the dead uuid gets swapped for
// the new one so MISSING clears.
(draft.value as ModuleEntry & { _originalId?: string })._originalId = draft.value.id;
// the new one so MISSING clears. Relink: point the detached row at an
// EXISTING library uuid; `_relinkFrom` (the OLD id, captured before the
// swap) additionally tells saveEditedModule to remap sibling @{} refs +
// constraint source/target so cross-references follow the new uuid.
const marked = draft.value as ModuleEntry & { _originalId?: string; _relinkFrom?: string };
marked._originalId = draft.value.id;
if (result.mode === "relink") marked._relinkFrom = draft.value.id;
draft.value.id = result.id;
draft.value.payload_hash = result.payload_hash;
draft.value.meta = {
@@ -260,7 +287,9 @@ function onPushToLibrarySaved(result: PushSaveResult): void {
pushToast(
result.mode === "reattach"
? `Re-attached "${result.name}" to library`
: `Saved as new library entry "${result.name}"`,
: result.mode === "relink"
? `Re-linked "${result.name}" to library`
: `Saved as new library entry "${result.name}"`,
{ severity: "success" },
);
save();
@@ -372,7 +401,24 @@ function cancel() {
</script>
<template>
<ModalShell :visible="visible" @close="cancel">
<ModalShell :visible="visible" @close="cancel" @keydown="onKeydown">
<!-- Column so the frame banner stacks ABOVE the per-kind modal card
(each per-kind modal renders a plain card; ModalShell's wrapper is
a centered flex ROW, so a bare banner sibling would land beside the
card). align-self:stretch on the banner spans it to the card width. -->
<div class="wp-frame-edit-body">
<!-- Frame-context banner — shown when the user is editing overrides
for a specific iteration frame (#k). Sits above all per-kind
modal bodies. Invisible on base editing (currentFrame == null). -->
<div
v-if="currentFrame != null"
class="wp-modal-frame-banner"
data-test="modal-frame-banner"
>
<span>Editing frame #{{ currentFrame + 1 }} — changes affect only this frame.</span>
<button type="button" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</button>
</div>
<!-- v2 wildcard branch — single-pane tailored modal renders its
own header/footer/body. No v1 chrome (no .wp-medit wrapper,
no tab strip, no LibraryRoundTripActions footer). -->
@@ -381,8 +427,10 @@ function cancel() {
:module="draft"
:is-modified="instanceModified"
:upstream-vars="upstreamVars"
:upstream-producers="upstreamProducers"
:sibling-vars="siblingVars"
:via-option-pairs="viaOptionPairs"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -395,6 +443,7 @@ function cancel() {
v-else-if="draft && draft.type === 'fixed_values'"
:module="draft"
:is-modified="instanceModified"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -410,8 +459,10 @@ function cancel() {
:module="draft"
:is-modified="instanceModified"
:upstream-vars="upstreamVars"
:upstream-producers="upstreamProducers"
:upstream-resolved="upstreamResolved"
:sibling-vars="siblingVars"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -422,11 +473,19 @@ function cancel() {
<!-- v2 derivation branch — single-pane tailored modal. Library
(rule conditions / branches / actions) stays in SPA; modal
exposes only display-name override + per-rule disable
toggles via instance.disabled_rule_ids. -->
toggles via instance.disabled_rule_ids. upstream/sibling vars
feed the rule override fields' `$var` autocomplete; the `@{}`
nested-ref source is the LIBRARY (fetched inside the modal), so
sibling/chain MODULES are intentionally NOT forwarded here. -->
<DerivationInstanceModal
v-else-if="draft && draft.type === 'derivation'"
:module="draft"
:is-modified="instanceModified"
:upstream-vars="upstreamVars"
:upstream-producers="upstreamProducers"
:sibling-vars="siblingVars"
:via-option-pairs="viaOptionPairs"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -444,13 +503,15 @@ function cancel() {
:module="draft"
:is-modified="instanceModified"
:sibling-modules="siblingModules"
:chain-modules="chainModules"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@save-to-library="onSaveToLibraryClick"
@clear-all-overrides="onClearAllOverrides"
/>
</div>
</ModalShell>
<!-- Themed confirm dialog — replaces window.confirm() for any
@@ -487,5 +548,42 @@ function cancel() {
* (WildcardInstanceModal, FixedValuesInstanceModal, etc.). The
* `.wp-medit` chrome was the v1 tabbed shell — removed alongside
* the v1 dispatch in the 2026-05-10 cleanup. */
.wp-frame-edit-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-height: 0;
max-height: 100%;
}
.wp-modal-frame-banner {
align-self: stretch;
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
padding: 7px 11px;
border-radius: 6px;
/* solid surface (amber mixed into the modal bg, not over the canvas) so the
* text stays readable; amber = the override/warning accent used elsewhere. */
background: color-mix(in oklab, var(--wp-amber) 14%, var(--wp-bg2));
border: 1px solid color-mix(in oklab, var(--wp-amber) 38%, transparent);
color: var(--wp-amber);
font: 500 11px var(--wp-font-sans);
}
.wp-modal-frame-banner button {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 4px;
border: 1px solid color-mix(in oklab, var(--wp-amber) 55%, transparent);
background: color-mix(in oklab, var(--wp-amber) 20%, transparent);
color: var(--wp-amber);
font: 600 10px var(--wp-font-sans);
cursor: pointer;
}
.wp-modal-frame-banner button:hover {
background: color-mix(in oklab, var(--wp-amber) 32%, transparent);
}
</style>
+14 -1
View File
@@ -316,7 +316,14 @@
:data-kind="byId.get(id)?.type"
>
<span class="wp-picker__sel-dot"></span>
{{ byId.get(id)?.name ?? id }}
<!-- Wrapped in its own span so it can ellipsis-clip. As a bare
text node it wrapped onto a second line inside a chip fixed at
22px tall, so a long name like "Accent piece (color-neutral)"
spilled out of its own pill. -->
<span
class="wp-picker__sel-name"
:title="byId.get(id)?.name ?? id"
>{{ byId.get(id)?.name ?? id }}</span>
<button
type="button"
class="wp-picker__sel-close"
@@ -1443,6 +1450,12 @@ onBeforeUnmount(detachCaptureListeners);
font-weight: 500;
max-width: 200px;
}
.wp-picker__sel-name {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wp-picker__sel-dot {
width: 7px; height: 7px;
border-radius: 999px;
+213
View File
@@ -0,0 +1,213 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import { ref } from "vue";
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import type { ModuleEntry } from "../../widgets/_shared";
import type { PairingBadge } from "../../extension/constraint-pairs";
/** Build a ModuleRowCtx whose pairing accessors return canned data and
* whose ~30 helpers are inert stubs — ModuleRow only renders, so the
* helpers just need to exist + be callable without throwing. */
function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
const noop = (): void => {};
return {
KIND_TITLE: { wildcard: "Wildcard", constraint: "Constraint" },
kindIcon: () => "pi pi-box",
kindChipModifier: (t: string) => t,
varColorClass: () => "var-1",
isCollapsed: () => true,
isLocked: () => false,
isInternal: () => false,
isSeedLockable: () => false,
isModified: () => false,
isDrifted: () => false,
isMissingFromLibrary: () => false,
isTypeConflict: () => false,
severityFor: () => null,
conflictTooltip: () => "",
conflictBadgeText: () => null,
modifiedTooltip: () => "",
summaryFor: () => "",
summaryTokens: () => [],
siblingInfo: () => null,
rowGap: () => null,
draggingModuleUid: ref<string | null>(null),
recentDropUids: ref(new Set<string>()),
pulseDelayFor: () => "0ms",
toggleCollapsed: noop,
toggleEnabled: noop,
removeModule: noop,
toggleLockOnCard: noop,
toggleInternalOnCard: noop,
onDragStart: noop,
onDragEnd: noop,
openContextMenu: noop,
onCardKeydown: noop,
pairingFor: () => null,
viaInboundFor: () => [],
contributorsFor: () => [],
currentFrame: ref<number | null>(null),
isHeld: () => false,
isOverriddenOnFrame: () => false,
effectiveLockedSeed: () => undefined,
effectiveEnabled: (m) => m.enabled !== false,
frameEnableOverride: () => null,
...over,
};
}
function module(over: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "m1",
_uid: "uid1",
type: "wildcard",
enabled: true,
meta: { name: "subject" },
entries: [],
...over,
};
}
function mountRow(ctx: ModuleRowCtx, mod: ModuleEntry) {
return mount(ModuleRow, {
props: { module: mod, idx: 0 },
global: { provide: { [ModuleRowCtxKey as symbol]: ctx } },
});
}
const contrib = (n: number, colorIndex = n): PairingBadge => ({
number: n,
targetUuid: "aaaaaaaa",
colorIndex,
isOrphan: false,
});
describe("ModuleRow.vue — badge before name", () => {
it("renders the pair badge BEFORE the module name in DOM order", () => {
const ctx = makeCtx({ contributorsFor: () => [contrib(1)] });
const wrapper = mountRow(ctx, module());
const html = wrapper.html();
const badgeIdx = html.indexOf("wp-pair-badge");
const nameIdx = html.indexOf("wp-module-name");
expect(badgeIdx).toBeGreaterThanOrEqual(0);
expect(nameIdx).toBeGreaterThanOrEqual(0);
expect(badgeIdx).toBeLessThan(nameIdx);
});
it("renders the sender badge before the name for a constraint row", () => {
const senderBadge: PairingBadge = {
number: 1,
targetUuid: "aaaaaaaa",
colorIndex: 2,
isOrphan: false,
reach: { mode: "all" },
};
const ctx = makeCtx({ pairingFor: () => senderBadge });
const wrapper = mountRow(ctx, module({ type: "constraint", meta: { name: "no red" } }));
const html = wrapper.html();
expect(html.indexOf("wp-pair-badge")).toBeLessThan(html.indexOf("wp-module-name"));
// Sender carries its reach suffix.
expect(wrapper.text()).toContain("·all");
});
});
describe("ModuleRow.vue — contributor cluster", () => {
it("renders one chip per contributor when there are 2 or fewer", () => {
const ctx = makeCtx({ contributorsFor: () => [contrib(1), contrib(2)] });
const wrapper = mountRow(ctx, module());
expect(wrapper.findAll(".wp-pair-badge")).toHaveLength(2);
expect(wrapper.find(".wp-pair-badge--collapse").exists()).toBe(false);
});
it("renders exactly ONE collapse chip ↥×N when there are 3 or more contributors", () => {
const ctx = makeCtx({ contributorsFor: () => [contrib(1), contrib(2), contrib(3)] });
const wrapper = mountRow(ctx, module());
const collapse = wrapper.findAll(".wp-pair-badge--collapse");
expect(collapse).toHaveLength(1);
expect(collapse[0].text()).toContain("↥×3");
// The collapse chip REPLACES the individual chips — no per-contributor
// direct chips alongside it.
expect(wrapper.findAll(".wp-pair-badge")).toHaveLength(1);
});
it("renders no contributor chip on a row no constraint covers", () => {
const ctx = makeCtx({ contributorsFor: () => [] });
const wrapper = mountRow(ctx, module());
expect(wrapper.find(".wp-pair-badge").exists()).toBe(false);
});
});
describe("ModuleRow.vue — iteration-aware badges", () => {
it("shows a held badge when seed_scope is hold", () => {
const mod = module({ instance: { seed_scope: "hold" } as ModuleEntry["instance"] });
const ctx = makeCtx({ isHeld: (m) => m.instance?.seed_scope === "hold" });
const wrapper = mountRow(ctx, mod);
expect(wrapper.find('[data-test="mod-held"]').exists()).toBe(true);
});
it("shows an override badge labelled with the current frame", () => {
const frame = ref<number | null>(1);
const mod = module({ iteration_overrides: { "1": { weight: 2 } } as ModuleEntry["iteration_overrides"] });
const ctx = makeCtx({
currentFrame: frame,
isOverriddenOnFrame: (m) => {
const k = frame.value;
return k != null && !!m.iteration_overrides?.[String(k)];
},
});
const wrapper = mountRow(ctx, mod);
const badge = wrapper.find('[data-test="mod-override"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toContain("#2");
});
});
describe("ModuleRow.vue — per-frame enable/disable", () => {
it("checkbox is unchecked when effectiveEnabled is false on the active frame", () => {
const frame = ref<number | null>(2);
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => false });
const wrapper = mountRow(ctx, module({ enabled: true }));
const checkbox = wrapper.find('input[type="checkbox"]');
expect(checkbox.exists()).toBe(true);
expect((checkbox.element as HTMLInputElement).checked).toBe(false);
});
it("checkbox is checked when effectiveEnabled is true (base-off but on for this frame)", () => {
const frame = ref<number | null>(2);
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => true });
const wrapper = mountRow(ctx, module({ enabled: false }));
const checkbox = wrapper.find('input[type="checkbox"]');
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
});
it("shows an off-#k badge when frameEnableOverride is 'off'", () => {
const frame = ref<number | null>(2);
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "off" });
const wrapper = mountRow(ctx, module({ enabled: true }));
const badge = wrapper.find('[data-test="mod-frame-disabled"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toContain("#3"); // frame index 2 (0-based) → "#3"
});
it("shows an on-#k badge when frameEnableOverride is 'on'", () => {
const frame = ref<number | null>(2);
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "on" });
const wrapper = mountRow(ctx, module({ enabled: false }));
const badge = wrapper.find('[data-test="mod-frame-enabled"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toContain("#3");
});
it("checkbox change calls ctx.toggleEnabled with the row index", async () => {
const frame = ref<number | null>(2);
const calls: number[] = [];
const ctx = makeCtx({
currentFrame: frame,
toggleEnabled: (idx) => { calls.push(idx); },
});
const wrapper = mountRow(ctx, module({ enabled: true }));
await wrapper.find('input[type="checkbox"]').trigger("change");
expect(calls).toEqual([0]);
});
});
+45 -7
View File
@@ -14,7 +14,7 @@ if (!ctx) throw new Error("ModuleRow: missing moduleRowCtx provider");
const {
KIND_TITLE, kindIcon, kindChipModifier, varColorClass,
isCollapsed, isLocked, isInternal, isSeedLockable,
isModified, isDrifted, isMissingFromLibrary,
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
severityFor, conflictTooltip, conflictBadgeText,
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
rowGap,
@@ -23,6 +23,7 @@ const {
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
pairingFor,
viaInboundFor,
contributorsFor,
} = ctx;
</script>
@@ -35,7 +36,7 @@ const {
tabindex="0"
draggable="true"
:class="{
'wp-disabled': !module.enabled,
'wp-disabled': !(ctx?.effectiveEnabled(module) ?? (module.enabled !== false)),
'wp-conflict-error': severityFor(module._uid ?? module.id) === 'error',
'wp-conflict-warning': severityFor(module._uid ?? module.id) === 'warning',
'wp-conflict-info': severityFor(module._uid ?? module.id) === 'info',
@@ -73,8 +74,12 @@ const {
:title="isCollapsed(module) ? 'Expand' : 'Collapse'" @click="toggleCollapsed(idx)">
<i :class="['pi', isCollapsed(module) ? 'pi-caret-right' : 'pi-caret-down']" aria-hidden="true"></i>
</button>
<label class="wp-toggle" draggable="false" :title="module.enabled ? 'Disable' : 'Enable'">
<input type="checkbox" :checked="module.enabled" :aria-label="`enable ${module.meta.name}`" @change="toggleEnabled(idx)" />
<label class="wp-toggle" draggable="false"
:title="ctx && ctx.currentFrame.value != null ? (ctx.effectiveEnabled(module) ? 'Disable on this frame' : 'Enable on this frame') : (module.enabled ? 'Disable' : 'Enable')">
<input type="checkbox"
:checked="ctx ? ctx.effectiveEnabled(module) : module.enabled"
:aria-label="`enable ${module.meta.name}`"
@change="toggleEnabled(idx)" />
<span class="wp-toggle-mark"></span>
</label>
<span class="wp-row-type-icon" :title="module.type" aria-hidden="true">
@@ -84,11 +89,36 @@ const {
class="wp-kind-chip" :class="`wp-kind-chip--${kindChipModifier(module.type)}`">
{{ KIND_TITLE[module.type] ?? module.type }}
</span>
<!-- Constraint (SENDER) row: the constraint's own `#N` chip,
carrying its reach selector (`·all` / `·first` / `·n2` /
`·pick`) + the ↪ via-glyph when its target is reached through
a nested ref. One chip per constraint row. -->
<PairBadge
v-if="pairingFor(module._uid ?? module.id)"
v-if="module.type === 'constraint' && pairingFor(module._uid ?? module.id)"
:pair="pairingFor(module._uid ?? module.id)!"
:variant="module.type === 'constraint' ? 'sender' : 'direct'"
variant="sender"
/>
<!-- Target (WILDCARD) row: the CONTRIBUTOR CLUSTER — every
constraint whose reach covers this instance. ≤2 contributors
render as individual `#N` chips; ≥3 collapse into ONE solid
`↥×N` chip (popover lists them all) so a heavily-constrained
row never sprawls into 3+ chips. -->
<template v-else-if="module.type !== 'constraint' && contributorsFor(module._uid ?? module.id).length > 0">
<template v-if="contributorsFor(module._uid ?? module.id).length <= 2">
<PairBadge
v-for="(c, ci) in contributorsFor(module._uid ?? module.id)"
:key="`contrib-${c.number}-${c.targetUuid}-${ci}`"
:pair="c"
variant="direct"
/>
</template>
<PairBadge
v-else
:pairs="contributorsFor(module._uid ?? module.id)"
:count="contributorsFor(module._uid ?? module.id).length"
variant="collapse"
/>
</template>
<!-- Carrier-side: ONE collapsed `↪×N` chip aggregating every
constraint whose target lives in one of THIS wildcard's
nested @{} refs. Solid background; multi-color fallback when
@@ -109,6 +139,10 @@ const {
title="Drifted — library has a newer version. Right-click → Refresh from library." aria-hidden="true"></span>
<span v-if="isDrifted(module)" class="wp-mod-badge wp-mod-badge--drift"
title="Drifted — library has a newer version. Right-click → Refresh from library.">drift</span>
<span v-if="isTypeConflict(module)" class="wp-mod-dot wp-mod-dot--clash"
title="Id clash — a different kind of item uses this id in your library. Right-click → Push to library to add this one as a new entry." aria-hidden="true"></span>
<span v-if="isTypeConflict(module)" class="wp-mod-badge wp-mod-badge--clash"
title="Id clash — a different kind of item uses this id in your library. Right-click → Push to library to add this one as a new entry.">clash</span>
<span v-if="isMissingFromLibrary(module)" class="wp-mod-dot wp-mod-dot--missing"
title="Not in library — right-click → Save to library to add it" aria-hidden="true"></span>
<span v-if="isMissingFromLibrary(module)" class="wp-mod-badge wp-mod-badge--missing"
@@ -119,11 +153,15 @@ const {
class="wp-conflict-badge"
:class="`wp-conflict-badge--${severityFor(module._uid ?? module.id)}`"
:title="conflictTooltip(module._uid ?? module.id)">{{ conflictBadgeText(module._uid ?? module.id) }}</span>
<span v-if="ctx?.isHeld(module)" class="wp-mod-badge wp-mod-badge--clash" data-test="mod-held" title="Held across the run">held</span>
<span v-if="ctx?.isOverriddenOnFrame(module)" class="wp-conflict-badge wp-conflict-badge--warning" data-test="mod-override" title="Overridden on this frame">override #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
<span v-if="ctx?.frameEnableOverride(module) === 'off'" class="wp-mod-badge wp-mod-badge--missing" data-test="mod-frame-disabled" title="Disabled on this frame">off #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
<span v-else-if="ctx?.frameEnableOverride(module) === 'on'" class="wp-mod-badge wp-mod-badge--on" data-test="mod-frame-enabled" title="Enabled on this frame">on #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
</span>
<div class="wp-mod-actions" draggable="false">
<button v-if="isSeedLockable(module)" type="button" class="wp-btn--icon-sm wp-btn--warn"
:class="{ 'is-locked': isLocked(module) }" data-test="row-action-lock"
:title="isLocked(module) ? `Locked seed: ${module.instance?.locked_seed}. Click to unlock.` : 'Lock seed'"
:title="isLocked(module) ? `Locked seed: ${ctx?.effectiveLockedSeed(module)}. Click to unlock.` : 'Lock seed'"
:aria-label="isLocked(module) ? 'Unlock seed' : 'Lock seed'"
@click.stop="toggleLockOnCard(idx)"><i class="pi pi-lock" /></button>
<button v-if="module.type === 'wildcard' || module.type === 'fixed_values' || module.type === 'combine' || module.type === 'derivation'"
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import PairBadge from "./PairBadge.vue";
import type { PairingBadge } from "../../extension/constraint-pairs";
afterEach(() => {
// Popover teleports to <body> — strip leftover overlays between tests.
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
});
/** Minimal sender badge with a given reach selector. */
function senderPair(reach: PairingBadge["reach"], over: Partial<PairingBadge> = {}): PairingBadge {
return {
number: 1,
targetUuid: "aaaaaaaa",
colorIndex: 3,
isOrphan: false,
reach,
...over,
};
}
describe("PairBadge.vue — sender reach suffix", () => {
it("renders ·all for the default all reach", () => {
const wrapper = mount(PairBadge, {
props: { pair: senderPair({ mode: "all" }), variant: "sender" },
});
expect(wrapper.text()).toContain("#1");
expect(wrapper.text()).toContain("·all");
});
it("renders ·first for first reach", () => {
const wrapper = mount(PairBadge, {
props: { pair: senderPair({ mode: "first" }), variant: "sender" },
});
expect(wrapper.text()).toContain("·first");
});
it("renders ·n{count} for next reach", () => {
const wrapper = mount(PairBadge, {
props: { pair: senderPair({ mode: "next", count: 2 }), variant: "sender" },
});
expect(wrapper.text()).toContain("·n2");
});
it("renders ·pick for pick reach", () => {
const wrapper = mount(PairBadge, {
props: { pair: senderPair({ mode: "pick", picks: [] }), variant: "sender" },
});
expect(wrapper.text()).toContain("·pick");
});
it("keeps the via ↪ glyph alongside the reach suffix", () => {
const wrapper = mount(PairBadge, {
props: {
pair: senderPair(
{ mode: "all" },
{ via: { carrierRowKey: "c1", carrierName: "backdrop", optionIds: ["o1"], routeChain: ["aaaaaaaa"] } },
),
variant: "sender",
},
});
expect(wrapper.text()).toContain("↪");
expect(wrapper.text()).toContain("·all");
});
it("does not append a reach suffix to a direct (target) chip", () => {
const wrapper = mount(PairBadge, {
props: { pair: { number: 1, targetUuid: "aaaaaaaa", colorIndex: 3, isOrphan: false }, variant: "direct" },
});
expect(wrapper.text()).toContain("#1");
expect(wrapper.text()).not.toContain("·");
});
});
describe("PairBadge.vue — collapse variant", () => {
const pairs: PairingBadge[] = [
{ number: 1, targetUuid: "aaaaaaaa", colorIndex: 1, isOrphan: false },
{ number: 2, targetUuid: "aaaaaaaa", colorIndex: 2, isOrphan: false },
{ number: 3, targetUuid: "aaaaaaaa", colorIndex: 3, isOrphan: false },
];
it("renders ↥×N from the pairs length", () => {
const wrapper = mount(PairBadge, {
props: { pairs, variant: "collapse" },
});
expect(wrapper.text()).toContain("↥×3");
});
it("renders ↥×N from an explicit count prop", () => {
const wrapper = mount(PairBadge, {
props: { count: 4, pairs, variant: "collapse" },
});
expect(wrapper.text()).toContain("↥×4");
});
it("carries the .wp-pair-badge--collapse class and not the carrier multi-color class", () => {
const wrapper = mount(PairBadge, {
props: { pairs, variant: "collapse" },
});
const chip = wrapper.find(".wp-pair-badge");
expect(chip.exists()).toBe(true);
expect(chip.classes()).toContain("wp-pair-badge--collapse");
// Solid collapse chip is NOT the dim multi-color carrier surface.
expect(chip.classes()).not.toContain("wp-pair-badge--via-multi");
});
it("opens a popover listing every collapsed pair", async () => {
const wrapper = mount(PairBadge, {
attachTo: document.body,
props: { pairs, variant: "collapse" },
});
await wrapper.find(".wp-pair-badge").trigger("click");
await wrapper.vm.$nextTick();
const pop = document.querySelector(".wp-pair-pop");
expect(pop).not.toBeNull();
// Each numbered entry should be present.
expect(pop?.textContent).toContain("#1");
expect(pop?.textContent).toContain("#2");
expect(pop?.textContent).toContain("#3");
});
});
describe("PairBadge.vue — popover REACH + PATH rows", () => {
it("renders a REACH row from the primary pair's reach", async () => {
const wrapper = mount(PairBadge, {
attachTo: document.body,
props: { pair: senderPair({ mode: "next", count: 2 }), variant: "sender" },
});
await wrapper.find(".wp-pair-badge").trigger("click");
await wrapper.vm.$nextTick();
const pop = document.querySelector(".wp-pair-pop");
expect(pop).not.toBeNull();
expect(pop?.textContent?.toLowerCase()).toContain("reach");
// "next 2" is the human label for a next/count reach.
expect(pop?.textContent).toContain("next 2");
});
it("renders a PATH row when via.routeChain is present", async () => {
const wrapper = mount(PairBadge, {
attachTo: document.body,
props: {
pair: senderPair(
{ mode: "all" },
{
via: {
carrierRowKey: "c1",
carrierName: "backdrop",
optionIds: ["o1"],
routeChain: ["bbbbbbbb", "aaaaaaaa"],
},
},
),
variant: "sender",
},
});
await wrapper.find(".wp-pair-badge").trigger("click");
await wrapper.vm.$nextTick();
const pop = document.querySelector(".wp-pair-pop");
expect(pop).not.toBeNull();
expect(pop?.textContent?.toLowerCase()).toContain("path");
// Hops rendered (uuids resolve to themselves when no name cached).
expect(pop?.textContent).toContain("bbbbbbbb");
expect(pop?.textContent).toContain("aaaaaaaa");
});
});
+153 -12
View File
@@ -35,16 +35,21 @@ import { computed, nextTick, onBeforeUnmount, ref } from "vue";
import type { PairingBadge } from "../../extension/constraint-pairs";
import { cacheVersion, ensure, lookup } from "../../extension/preview-resolver";
type Variant = "sender" | "direct" | "carrier" | "option";
type Variant = "sender" | "direct" | "carrier" | "option" | "collapse";
const props = defineProps<{
/** Single-pair variants (sender / direct / option / carrier-with-1) — the
* one pair this chip represents. Carrier variant accepts the array
* form via `pairs` instead. */
* one pair this chip represents. Carrier + collapse variants accept the
* array form via `pairs` instead. */
pair?: PairingBadge;
/** Multi-pair carrier variant — aggregate inbound via-pairs collapsed
* into one `↪×N` chip. Ignored for non-carrier variants. */
/** Multi-pair carrier / collapse variant — aggregate the pairs this chip
* stands in for, collapsed into one chip. Ignored for single-pair
* variants. */
pairs?: readonly PairingBadge[];
/** Optional explicit count for the `collapse` variant. When omitted the
* chip falls back to `pairs.length`. Lets a caller render `↥×N` from a
* total even if it hands a partial list to the popover. */
count?: number;
variant: Variant;
}>();
@@ -52,7 +57,7 @@ const props = defineProps<{
* a list (collapsed) or a single (legacy / single-inbound). Other
* variants always single. */
const pairList = computed<readonly PairingBadge[]>(() => {
if (props.variant === "carrier") {
if (props.variant === "carrier" || props.variant === "collapse") {
if (props.pairs && props.pairs.length > 0) return props.pairs;
if (props.pair) return [props.pair];
return [];
@@ -170,8 +175,77 @@ function routeLabel(p: PairingBadge): string {
return "Via this wildcard's nested ref";
}
/** Compact reach suffix for the SENDER chip, appended after `#N`.
* ALWAYS shown — the default `all` reads `·all` so the chip never
* hides which instances a constraint covers:
* - all → `·all`
* - first → `·first`
* - next → `·n{count}` (count coerced, default 1 → `·n1`)
* - pick → `·pick`
* Returns "" for any non-sender variant (only the originating
* constraint carries its own reach). */
const reachSuffix = computed<string>(() => {
if (props.variant !== "sender") return "";
const reach = primaryPair.value?.reach;
if (!reach) return "";
switch (reach.mode) {
case "first":
return "·first";
case "next": {
const cnt = Number.isFinite(Number(reach.count)) ? Number(reach.count) : 1;
return `·n${cnt}`;
}
case "pick":
return "·pick";
default:
return "·all";
}
});
/** Human-readable reach label for the popover REACH row. Mirrors
* `reachSuffix` but spelled out: `all` / `first` / `next {count}` /
* `pick`. Null when there's no reach to show (non-sender pairs). */
function reachLabel(p: PairingBadge): string | null {
const reach = p.reach;
if (!reach) return null;
switch (reach.mode) {
case "first":
return "first";
case "next": {
const cnt = Number.isFinite(Number(reach.count)) ? Number(reach.count) : 1;
return `next ${cnt}`;
}
case "pick":
return "pick";
default:
return "all";
}
}
/** Hop labels for the popover PATH row, target LAST. Resolves each uuid
* in `via.routeChain` to its display name via the preview-resolver
* cache (rendered `@name`), falling back to the bare uuid when no name
* is known yet. Empty when the pair has no routeChain. */
function pathHops(p: PairingBadge): string[] {
const chain = p.via?.routeChain;
if (!chain || chain.length === 0) return [];
return chain.map((uuid) => {
const name = nameForUuid(uuid);
return name ? `@${name}` : uuid;
});
}
const chipClasses = computed<string[]>(() => {
const cls = ["wp-pair-badge"];
// Collapse chip is its OWN solid surface (var(--wp-accent-300) fill via
// the modifier) — no per-target color class, no multi-color fallback,
// no carrier styling. Same base geometry (pad / font / radius) as a
// direct pair badge, just a different paint. Short-circuit so none of
// the color/carrier/orphan branches below apply.
if (props.variant === "collapse") {
cls.push("wp-pair-badge--collapse");
return cls;
}
if (isMultiColor.value) {
cls.push("wp-pair-badge--via-multi");
} else if (primaryPair.value) {
@@ -201,14 +275,43 @@ const chipClasses = computed<string[]>(() => {
* against `×`, and keeps the glyph readable at chip-size. */
const carrierText = computed<string>(() => `↪ ×${pairList.value.length}`);
/** Collapse chip count — explicit `count` prop wins, else the popover
* list length. Drives the `↥×N` glyph that REPLACES the individual
* contributor chips on a target row with 3+ contributors. */
const collapseCount = computed<number>(() =>
typeof props.count === "number" ? props.count : pairList.value.length,
);
const collapseText = computed<string>(() => `↥×${collapseCount.value}`);
const popoverAriaLabel = computed<string>(() => {
if (props.variant === "carrier") {
return `${pairList.value.length} constraint pair${pairList.value.length === 1 ? "" : "s"} via nested refs`;
}
if (props.variant === "collapse") {
return `${collapseCount.value} constraint contributor${collapseCount.value === 1 ? "" : "s"}`;
}
return primaryPair.value
? `Constraint pair ${primaryPair.value.number}`
: "Constraint pair";
});
/** True when the popover lists MORE THAN ONE pair (collapsed carrier or
* the new collapse contributor cluster). Drives the `×N` head, the
* per-entry `#N` head, and the multi-entry title/hint copy. */
const isMultiEntry = computed<boolean>(
() =>
(props.variant === "carrier" || props.variant === "collapse")
&& pairList.value.length > 1,
);
/** Head title for the multi-entry popover. Collapse (contributor
* cluster) reads "Contributing constraints"; the carrier route chip
* keeps its "Routed constraint pairs" wording. */
const multiEntryTitle = computed<string>(() =>
props.variant === "collapse"
? "Contributing constraints"
: "Routed constraint pairs",
);
</script>
<template>
@@ -230,12 +333,15 @@ const popoverAriaLabel = computed<string>(() => {
>
<template v-if="variant === 'sender' || variant === 'direct'">
<span v-if="primaryPair?.isOrphan" class="wp-pair-badge__warn">!</span>
<template v-if="variant === 'sender' && primaryPair?.via">#{{ primaryPair.number }} ↪</template>
<template v-else>#{{ primaryPair?.number }}</template>
<template v-if="variant === 'sender' && primaryPair?.via">#{{ primaryPair.number }} ↪{{ reachSuffix }}</template>
<template v-else>#{{ primaryPair?.number }}{{ reachSuffix }}</template>
</template>
<template v-else-if="variant === 'carrier'">
{{ carrierText }}
</template>
<template v-else-if="variant === 'collapse'">
{{ collapseText }}
</template>
<template v-else>
↪#{{ primaryPair?.number }}
</template>
@@ -252,10 +358,10 @@ const popoverAriaLabel = computed<string>(() => {
@mouseleave="onLeave"
>
<div class="wp-pair-pop__head">
<span v-if="variant === 'carrier' && pairList.length > 1" class="wp-pair-pop__num">×{{ pairList.length }}</span>
<span v-if="isMultiEntry" class="wp-pair-pop__num">×{{ pairList.length }}</span>
<span v-else-if="primaryPair" class="wp-pair-pop__num">#{{ primaryPair.number }}</span>
<span class="wp-pair-pop__title">
{{ variant === "carrier" && pairList.length > 1 ? "Routed constraint pairs" : "Constraint pair" }}
{{ isMultiEntry ? multiEntryTitle : "Constraint pair" }}
</span>
</div>
<div
@@ -264,13 +370,25 @@ const popoverAriaLabel = computed<string>(() => {
class="wp-pair-pop__entry"
:class="`wp-pair-pop__entry--c${p.colorIndex}`"
>
<div v-if="variant === 'carrier' && pairList.length > 1" class="wp-pair-pop__entry-head">
<div v-if="isMultiEntry" class="wp-pair-pop__entry-head">
<span class="wp-pair-pop__entry-num">#{{ p.number }}</span>
</div>
<div class="wp-pair-pop__row">
<span class="wp-pair-pop__lbl">Route</span>
<span class="wp-pair-pop__val">{{ routeLabel(p) }}</span>
</div>
<div v-if="reachLabel(p)" class="wp-pair-pop__row wp-pair-pop__row--reach">
<span class="wp-pair-pop__lbl">Reach</span>
<span class="wp-pair-pop__val wp-pair-pop__reach">{{ reachLabel(p) }}</span>
</div>
<div v-if="pathHops(p).length > 0" class="wp-pair-pop__row wp-pair-pop__row--path">
<span class="wp-pair-pop__lbl">Path</span>
<span class="wp-pair-pop__val wp-pair-pop__path">
<template v-for="(hop, hi) in pathHops(p)" :key="`${hi}-${hop}`"><span
class="wp-pair-pop__hop">{{ hop }}</span><span
v-if="hi < pathHops(p).length - 1" class="wp-pair-pop__hop-arrow"> → </span></template>
</span>
</div>
<div class="wp-pair-pop__row">
<span class="wp-pair-pop__lbl">Target</span>
<span class="wp-pair-pop__val">
@@ -296,6 +414,9 @@ const popoverAriaLabel = computed<string>(() => {
The constraint claims the next downstream wildcard instance with this uuid.
</template>
</div>
<div v-else-if="variant === 'collapse'" class="wp-pair-pop__hint">
Every constraint above covers this target instance. Each applies its own reach + weighting when it fires.
</div>
<div v-else class="wp-pair-pop__hint">
Each constraint fires when this wildcard rolls an option carrying its target ref.
</div>
@@ -312,7 +433,10 @@ const popoverAriaLabel = computed<string>(() => {
.wp-pair-pop {
position: fixed;
z-index: 9999;
/* Hover-card tier — above the popover tier (10020), since a pair badge can
be rendered inside a popover and its card must clear it. Matches
RefChip's hover card. */
z-index: 10030;
width: 280px;
padding: 8px 10px;
background: var(--wp-bg-1, var(--wp-bg2, #1a1d24));
@@ -410,6 +534,23 @@ const popoverAriaLabel = computed<string>(() => {
color: var(--wp-text-dim, var(--wp-text-muted, #8a8d99));
line-height: 1.4;
}
/* Reach row — green so the constraint's coverage selector reads as a
* distinct, positive fact in the card (mirrors the green reach accent
* the reach-selector UI uses elsewhere). */
.wp-pair-pop__reach {
color: var(--wp-green, #6bc96f);
font-weight: 600;
}
/* Path row — the transitive hop chain a constraint travels to reach a
* nested target, target last. Wraps + uses a dim arrow between hops. */
.wp-pair-pop__path {
display: inline;
word-break: break-word;
}
.wp-pair-pop__hop-arrow {
color: var(--wp-text-dim, #7a7d88);
white-space: pre;
}
/* Color-band the head badge color to match the chip. */
.wp-pair-pop--c1 .wp-pair-pop__num { color: var(--wp-var-1); }
@@ -0,0 +1,120 @@
/**
* PushBundleToLibraryModal — bundle re-link (D3a bundle side).
*
* A workflow BundleInstance is "detached" when its `library_id` is no longer
* in the polled `bundleHashes` map (a re-import minted a fresh uuid). Its
* `inserted_at_hash` IS the library bundle's payload_hash at insert time, so
* a content-identical library bundle under a DIFFERENT uuid matches on it —
* the same signal the module re-link uses.
*/
import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("#comfyui/app", () => ({
app: { graph: { _nodes: [], _nodes_by_id: {} } },
}));
import PushBundleToLibraryModal from "./PushBundleToLibraryModal.vue";
import { bundleHashes } from "./drift-store";
import type { BundleInstance } from "../../widgets/_shared";
function makeBundle(over: Partial<BundleInstance> = {}): BundleInstance {
return {
_uid: "buid0001",
library_id: "dead0001",
name: "Environments",
color: null,
enabled: true,
collapsed: false,
parent_uid: null,
start_idx: 0,
end_idx: 0,
inserted_at_hash: "HASH_A",
...over,
} as BundleInstance;
}
function resetBody(): void {
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
}
beforeEach(() => {
resetBody();
});
afterEach(() => {
vi.unstubAllGlobals();
resetBody();
bundleHashes.value = null;
});
async function openModal(bundle: BundleInstance | null) {
const wrap = mount(PushBundleToLibraryModal, {
attachTo: document.body,
props: { open: false, bundle: null, childrenForLibrary: [], childrenPreview: [] },
});
await wrap.setProps({ open: true, bundle, childrenForLibrary: [], childrenPreview: [] });
await flushPromises();
return wrap;
}
describe("PushBundleToLibraryModal re-link", () => {
it("offers re-link to a content-identical library bundle when detached", async () => {
// A DIFFERENT uuid carries this instance's inserted_at_hash.
bundleHashes.value = { live0001: "HASH_A" };
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/wp/api/bundles")) {
return new Response(
JSON.stringify({ items: [{ id: "live0001", name: "Environments" }] }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("{}", { status: 200 });
}));
const wrap = await openModal(makeBundle());
await flushPromises();
expect(document.querySelector('[data-test="pbtl-relink"]')).not.toBeNull();
document.querySelector<HTMLButtonElement>('[data-test="pbtl-relink-confirm"]')!.click();
await flushPromises();
const saved = wrap.emitted("saved");
expect(saved).toBeTruthy();
const result = saved![0][0] as { mode: string; id: string; origId: string };
expect(result.mode).toBe("relink");
expect(result.id).toBe("live0001");
expect(result.origId).toBe("dead0001");
wrap.unmount();
});
it("does NOT offer re-link when the bundle is still linked", async () => {
// library_id present in the live map → not detached.
bundleHashes.value = { dead0001: "HASH_A" };
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 })));
const wrap = await openModal(makeBundle());
await flushPromises();
expect(document.querySelector('[data-test="pbtl-relink"]')).toBeNull();
wrap.unmount();
});
it("does NOT offer re-link when no library bundle matches the content", async () => {
bundleHashes.value = { live0009: "OTHER_HASH" };
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/wp/api/bundles")) {
return new Response(
JSON.stringify({ items: [{ id: "live0009", name: "Unrelated" }] }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response("{}", { status: 200 });
}));
const wrap = await openModal(makeBundle({ name: "Environments" }));
await flushPromises();
expect(document.querySelector('[data-test="pbtl-relink"]')).toBeNull();
wrap.unmount();
});
});
@@ -17,7 +17,14 @@
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { bundleHashes } from "./drift-store";
import { api } from "../../manager/api/client";
import {
findRelinkCandidates,
autoRelinkTarget,
type RelinkCandidate,
} from "../../manager/import-export/relink-match";
import { bundleContentKey } from "./bundles/bundle-fingerprint";
import type { BundleInstance } from "../../widgets/_shared";
import WpCheck from "@/components/shared/WpCheck.vue";
interface ChildPreview {
name: string;
@@ -50,10 +57,12 @@ interface SaveResult {
* "(copy)" suffix; workflow row keeps its old library_id. `reattach`
* — POST when source entry was missing; no copy suffix, caller
* should rebind the workflow row's library_id to the new uuid so
* MISSING clears. */
mode: "update" | "fork" | "reattach";
/** New library uuid when forking/reattaching; same as bundle.library_id
* when updating. */
* MISSING clears. `relink` — no write: point the detached instance at
* an EXISTING content-identical library bundle; caller rebinds
* library_id + inserted_at_hash. */
mode: "update" | "fork" | "reattach" | "relink";
/** New library uuid when forking/reattaching/re-linking; same as
* bundle.library_id when updating. */
id: string;
/** payload_hash returned by the server. */
payload_hash: string;
@@ -121,10 +130,120 @@ const isLibraryMissing = computed(() => {
return !(props.bundle.library_id in bundleHashes.value);
});
/** Whether to OFFER the re-link picker — the bundle-side mirror of the module
* modal's `needsRelink`. Wider than `isLibraryMissing`, which additionally
* requires a `library_id`: a bundle that lost its link (shared workflow,
* stale cache) still corresponds to a library entry, and matching it on child
* content is what `bundleContentKey` is for. Costs nothing when there is no
* match — the picker only renders once candidates come back. */
const needsRelink = computed(() => {
const b = props.bundle;
if (!b || bundleHashes.value === null) return false;
return !b.library_id || !(b.library_id in bundleHashes.value);
});
const canUpdateExisting = computed(
() => isLibraryTracked.value && !isLibraryMissing.value,
);
/** uuid -> display name for every live library bundle, fetched once when the
* instance turns out to be detached. `bundleHashes` carries the content hash
* but not names, so the re-link picker needs this to label + weakly-match. */
const relinkNames = ref<Map<string, { name: string; type: string }>>(new Map());
/** uuid → child-content key for every live library bundle. Only consulted when
* the instance carries no `inserted_at_hash`. */
const relinkContentKeys = ref<Map<string, string>>(new Map());
async function fetchBundleNames(): Promise<void> {
try {
const res = await fetch("/wp/api/bundles");
if (!res.ok) return;
const data = (await res.json()) as
| { items?: Array<{ id?: string; name?: string; children?: unknown }> }
| Array<{ id?: string; name?: string; children?: unknown }>;
const items = Array.isArray(data) ? data : (data.items ?? []);
const next = new Map<string, { name: string; type: string }>();
const keys = new Map<string, string>();
for (const it of items) {
if (typeof it.id === "string" && it.id) {
next.set(it.id, { name: it.name ?? "", type: "bundle" });
if (Array.isArray(it.children)) {
keys.set(it.id, bundleContentKey(
it.children as Array<{ type?: string; payload_hash?: string }>,
));
}
}
}
relinkNames.value = next;
relinkContentKeys.value = keys;
} catch {
/* network — leave empty; no re-link offered */
}
}
/** Content-aware re-link candidates for a DETACHED bundle instance. The
* instance's `inserted_at_hash` IS the library bundle's payload_hash at
* insert time, so an identical-content library bundle under a different uuid
* matches on it exactly — the same signal the module re-link uses. */
const relinkCandidates = computed<RelinkCandidate[]>(() => {
const b = props.bundle;
if (!b || !needsRelink.value || bundleHashes.value === null) return [];
const live: Record<string, { type?: string; payload_hash: string; contentKey?: string }> = {};
for (const [uuid, h] of Object.entries(bundleHashes.value)) {
live[uuid] = { type: "bundle", payload_hash: h, contentKey: relinkContentKeys.value.get(uuid) };
}
return findRelinkCandidates(
{
id: b.library_id,
type: "bundle",
payload_hash: b.inserted_at_hash,
// Only when there's no stored hash to compare — the server hash stays
// authoritative whenever it exists.
contentKey: b.inserted_at_hash
? undefined
: bundleContentKey(
props.childrenForLibrary as Array<{ type?: string; payload_hash?: string }>,
),
name: b.name ?? "",
},
live,
(u) => relinkNames.value.get(u),
);
});
const relinkAuto = computed(() => autoRelinkTarget(relinkCandidates.value));
const relinkPick = ref<RelinkCandidate | null>(null);
const relinkQuery = ref<string>("");
const relinkFiltered = computed<RelinkCandidate[]>(() => {
const q = relinkQuery.value.trim().toLowerCase();
if (!q) return relinkCandidates.value;
return relinkCandidates.value.filter(
(c) => c.name.toLowerCase().includes(q) || c.uuid.toLowerCase().includes(q),
);
});
const relinkTarget = computed<RelinkCandidate | null>(
() => relinkPick.value ?? relinkAuto.value,
);
/** Danger tone only when nothing can be reconnected — a re-linkable bundle is
* a routine state, and red steered users toward the duplicate-making path. */
const relinkNoteClass = computed(() =>
relinkCandidates.value.length > 0 ? "wp-ptl-note--info" : "wp-ptl-note--danger",
);
function doRelink(): void {
const t = relinkTarget.value;
if (!t || !props.bundle) return;
emit("saved", {
mode: "relink",
id: t.uuid,
payload_hash: t.payloadHash,
name: t.name,
origId: props.bundle.library_id,
});
}
const tagsParsed = computed<string[]>(() =>
tagsText.value
.split(",")
@@ -143,8 +262,17 @@ function resetForm(): void {
// into library, so we make the safer choice the default and let the
// user opt out.
cascadeEnabled.value = cascadeAvailable.value;
relinkPick.value = null;
relinkQuery.value = "";
}
// Re-link candidates only matter for a DETACHED instance — skip the extra
// library-list fetch when the bundle is tracked + present. A drift poll can
// flip it to detached after open, so also fetch the first time that happens.
watch(needsRelink, (missing) => {
if (props.open && missing && relinkNames.value.size === 0) void fetchBundleNames();
});
/** Seed description + tags from the live library entry when the
* bundle is tracked. Skips on missing — the entry is gone. Only
* fills empty form fields so a mid-edit doesn't get clobbered. */
@@ -169,6 +297,7 @@ watch(
if (!isOpen) return;
resetForm();
void seedFromLibrary();
if (needsRelink.value) void fetchBundleNames();
},
);
@@ -393,11 +522,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
class="wp-ptl-cascade"
data-test="pbtl-cascade"
>
<label class="wp-ptl-cascade__toggle">
<input
<span class="wp-ptl-cascade__toggle">
<WpCheck
v-model="cascadeEnabled"
type="checkbox"
data-test="pbtl-cascade-toggle"
aria-label="Restore missing references first"
/>
<span>
Restore
@@ -408,7 +537,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
: "s"
}} first
</span>
</label>
</span>
<div class="wp-ptl-cascade__hint">
Cascade-creates fresh library entries for every missing
child (bottom-up — modules first, then inner bundles)
@@ -437,10 +566,68 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
</ul>
</div>
<div v-if="isLibraryMissing" class="wp-ptl-note wp-ptl-note--danger" data-test="pbtl-missing">
The library entry for this bundle has been deleted upstream.
"Update existing" is unavailable — use "Save as new entry" to
re-add it to the library.
<div
v-if="needsRelink && relinkCandidates.length"
class="wp-ptl-relink"
data-test="pbtl-relink"
>
<div class="wp-ptl-relink__title">Re-link to an existing library bundle</div>
<p class="wp-ptl-relink__hint">
This bundle's content matches a library entry under a different id (a re-import
minted a new one). Re-link to reconnect drift + reset instead of adding a duplicate.
</p>
<input
v-if="relinkCandidates.length > 1"
v-model="relinkQuery"
type="text"
class="wp-ptl-input"
data-test="pbtl-relink-search"
placeholder="Search by name or id…"
aria-label="Search library bundles to re-link"
spellcheck="false"
autocomplete="off"
/>
<ul class="wp-ptl-relink__list">
<li
v-for="c in relinkFiltered"
:key="c.uuid"
class="wp-ptl-relink__cand"
:class="{ 'is-picked': relinkTarget != null && relinkTarget.uuid === c.uuid }"
data-test="pbtl-relink-candidate"
@click="relinkPick = c"
>
<span class="wp-ptl-relink__name">{{ c.name || "(unnamed)" }}</span>
<code class="wp-ptl-relink__uuid">{{ c.uuid }}</code>
<span
class="wp-ptl-relink__tag"
:class="c.contentIdentical ? 'is-identical' : 'is-diff'"
>{{ c.contentIdentical ? "identical" : "content differs" }}</span>
</li>
</ul>
<button
type="button"
class="wp-ptl-btn wp-ptl-btn--primary"
data-test="pbtl-relink-confirm"
:disabled="!relinkTarget || undefined"
@click="doRelink"
>Re-link<template v-if="relinkTarget"> to “{{ relinkTarget.name || relinkTarget.uuid }}”</template></button>
</div>
<!-- States the observable fact, not a guessed cause — see the
matching note in PushToLibraryModal. Danger tone only when
there is genuinely nothing to reconnect to. -->
<div v-if="needsRelink" class="wp-ptl-note" :class="relinkNoteClass" data-test="pbtl-missing">
<template v-if="relinkCandidates.length > 0">
This bundle isn’t linked to any library entry under its recorded id.
{{ relinkTarget?.contentIdentical
? "A library bundle with identical children is selected above — re-linking reconnects them."
: "Pick a match above to re-link, or save it as a new entry." }}
</template>
<template v-else>
This bundle isn’t linked to any library entry under its recorded id, and
nothing in the library matches its children or name. “Update existing” is
unavailable — “Save as new entry” adds it to the library.
</template>
</div>
<div v-if="errorMsg" class="wp-ptl-error" data-test="pbtl-error">
@@ -537,7 +724,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
font-family: inherit;
}
.wp-ptl-input:focus { outline: 1px solid var(--wp-accent-500, #8b5cf6); }
.wp-ptl-input--multi { resize: vertical; min-height: 48px; }
.wp-ptl-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 48px; }
.wp-ptl-children {
margin: 0;
padding: 6px 8px;
@@ -629,6 +816,50 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 32%, transparent);
color: var(--wp-danger, #ef4444);
}
/* Informational tone for the "pick a match" case — mirrors PushToLibraryModal. */
.wp-ptl-note--info {
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 10%, transparent);
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
color: var(--wp-text, #e7e7ee);
}
.wp-ptl-relink {
padding: 10px 12px;
border-radius: 6px;
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 8%, transparent);
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
display: flex;
flex-direction: column;
gap: 8px;
}
.wp-ptl-relink__title { font-size: 12.5px; font-weight: 600; color: var(--wp-text, #e7e7ee); }
.wp-ptl-relink__hint { margin: 0; font-size: 11.5px; color: var(--wp-text-muted, #a1a1ad); }
.wp-ptl-relink__list {
list-style: none; margin: 0; padding: 0;
max-height: 160px; overflow-y: auto;
display: flex; flex-direction: column; gap: 2px;
}
.wp-ptl-relink__cand {
display: flex; align-items: baseline; gap: 8px;
padding: 5px 8px; border-radius: 4px; cursor: pointer;
}
.wp-ptl-relink__cand:hover { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 16%, transparent); }
.wp-ptl-relink__cand.is-picked { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 28%, transparent); }
.wp-ptl-relink__name { font-size: 12px; color: var(--wp-text, #e7e7ee); }
.wp-ptl-relink__uuid { font-family: var(--wp-font-mono, monospace); font-size: 10px; color: var(--wp-text-dim, #6e6e7c); }
.wp-ptl-relink__tag {
margin-left: auto; flex-shrink: 0;
font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
padding: 1px 6px; border-radius: 999px;
}
.wp-ptl-relink__tag.is-identical {
color: var(--wp-green, #6ad28b);
background: color-mix(in oklab, var(--wp-green, #6ad28b) 16%, transparent);
}
.wp-ptl-relink__tag.is-diff {
color: var(--wp-warn, #facc15);
background: color-mix(in oklab, var(--wp-warn, #facc15) 16%, transparent);
}
.wp-ptl-relink .wp-ptl-btn--primary { align-self: flex-start; }
.wp-ptl-error {
padding: 8px 10px;
border-radius: 6px;
@@ -23,6 +23,7 @@ vi.mock("#comfyui/app", () => ({
}));
import PushToLibraryModal from "./PushToLibraryModal.vue";
import { hashes } from "./drift-store";
import type { ModuleEntry } from "../../widgets/_shared";
const VALID_PAYLOAD = {
@@ -86,6 +87,9 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals();
resetBody();
// The re-link branch reads the drift-store live hashes; null it out so a
// set value can't leak into other tests and falsely trip isLibraryMissing.
hashes.value = null;
});
async function openModal(draft: ModuleEntry | null) {
@@ -247,15 +251,50 @@ describe("PushToLibraryModal", () => {
wrap.unmount();
});
it("offers re-link to a content-identical library row when the draft is detached", async () => {
// A DIFFERENT uuid carries the draft's payload_hash — the re-imported twin.
hashes.value = { live0001: { type: "wildcard", payload_hash: "HASH_A" } };
pushResponse({ items: [] }); // fetchBundlesContaining
pushResponse({ description: "", tags: [] }); // seedFromLibrary
pushResponse({ items: [{ id: "live0001", name: "test_w", type: "wildcard" }] }); // fetchLibraryNames
const wrap = await openModal(makeDraft({ id: "dead0001", payload_hash: "HASH_A" }));
const relink = document.querySelector('[data-test="ptl-relink"]');
expect(relink).not.toBeNull();
document.querySelector<HTMLButtonElement>('[data-test="ptl-relink-confirm"]')!.click();
await flushPromises();
const saved = wrap.emitted("saved");
expect(saved).toBeTruthy();
const result = saved![0][0] as { mode: string; id: string; origId: string };
expect(result.mode).toBe("relink");
expect(result.id).toBe("live0001");
expect(result.origId).toBe("dead0001");
wrap.unmount();
});
it("does not offer re-link when no content match exists (detached, novel content)", async () => {
// Library has a different-content row only → no identical + no name match
// for a differently-named draft → no re-link block (Save as new is the path).
hashes.value = { live0009: { type: "wildcard", payload_hash: "OTHER" } };
pushResponse({ items: [] }); // bundles
pushResponse({ description: "", tags: [] }); // seedFromLibrary
pushResponse({ items: [{ id: "live0009", name: "unrelated", type: "wildcard" }] }); // names
const wrap = await openModal(makeDraft({ id: "dead0002", payload_hash: "HASH_Z" }));
expect(document.querySelector('[data-test="ptl-relink"]')).toBeNull();
wrap.unmount();
});
it("opt-out checkbox round-trips into the PUT body", async () => {
pushResponse({ items: [{ id: "b1", name: "framing" }] });
pushResponse({ description: "", tags: [] }); // seedFromLibrary
pushResponse({ payload_hash: "h", bundles_updated: [], name: "test_w" });
const wrap = await openModal(makeDraft());
const cb = document.querySelector<HTMLInputElement>('[data-test="ptl-propagate"]')!;
cb.checked = false;
cb.dispatchEvent(new Event("change"));
// propagate defaults on; one click toggles it off (wp-check span).
const cb = document.querySelector<HTMLElement>('[data-test="ptl-propagate"]')!;
cb.click();
await flushPromises();
document.querySelector<HTMLButtonElement>('[data-test="ptl-update"]')!.click();
+281 -11
View File
@@ -27,8 +27,15 @@ import { computed, onBeforeUnmount, ref, watch } from "vue";
import { workflowSiblingCount } from "./duplicates/sibling-count";
import { forkModule } from "./duplicates/fork";
import { hashes as libraryHashes } from "./drift-store";
import {
findRelinkCandidates,
autoRelinkTarget,
type RelinkCandidate,
} from "../../manager/import-export/relink-match";
import { localPayloadFingerprint } from "../../manager/import-export/fingerprint";
import { app } from "#comfyui/app";
import type { ModuleEntry } from "../../widgets/_shared";
import WpCheck from "@/components/shared/WpCheck.vue";
interface Bundle {
id: string;
@@ -40,8 +47,10 @@ interface SaveResult {
* entry with a "(copy)" suffix; workflow row keeps its old id.
* `reattach` — fork when the source entry was missing upstream;
* no copy suffix, AND the caller should rebind the workflow row to
* the new uuid so MISSING clears. */
mode: "update" | "fork" | "reattach";
* the new uuid so MISSING clears. `relink` — no write at all: point
* the detached workflow row at an EXISTING content-identical library
* uuid; the caller swaps the row id + remaps sibling refs. */
mode: "update" | "fork" | "reattach" | "relink";
/** New library uuid when forking; same as draft.id when updating. */
id: string;
/** payload_hash returned by the server after the write. */
@@ -87,6 +96,22 @@ const isLibraryMissing = computed(() => {
return !(props.draft.id in libraryHashes.value);
});
/** Whether to OFFER the re-link picker.
*
* Wider than `isLibraryMissing`, which requires a payload_hash. A hash-less
* row was treated as "never library-tracked, nothing to re-link" — true for a
* module authored in the widget, wrong for the cases that actually bite: a
* shared workflow, or a row whose stored hash was lost. Those still correspond
* to a library entry, and matching them is what `contentKey` is for.
*
* Costs nothing when there IS no match: the picker only renders when
* candidates come back, so a genuinely new module still sees the plain
* save-as-new flow. */
const needsRelink = computed(() => {
if (!props.draft || libraryHashes.value === null) return false;
return !(props.draft.id in libraryHashes.value);
});
/** "Update existing" is enabled only when the entry exists AND is
* library-tracked. Disabled when never library-tracked OR when the
* upstream entry has been deleted. */
@@ -94,6 +119,107 @@ const canUpdateExisting = computed(
() => isLibraryTracked.value && !isLibraryMissing.value,
);
/** uuid → {name, type} for every live library module, fetched once on open.
* Backs the re-link candidate name lookup + the (type, name) fallback match.
* drift-store hashes carry (type, payload_hash) but not names, so the picker
* needs this to label + weakly-match candidates. */
const relinkNames = ref<Map<string, { name: string; type: string }>>(new Map());
/** uuid → locally computed payload fingerprint for every live library module.
* Only consulted for a HASH-LESS draft, where the server hash can't decide.
* Both sides are fingerprinted by the same local function, so this needs no
* parity with the engine's own hashing. */
const relinkContentKeys = ref<Map<string, string>>(new Map());
async function fetchLibraryNames(): Promise<void> {
try {
const res = await fetch("/wp/api/modules");
if (!res.ok) return;
const data = (await res.json()) as
| { items?: Array<{ id?: string; name?: string; type?: string; payload?: unknown }> }
| Array<{ id?: string; name?: string; type?: string; payload?: unknown }>;
const items = Array.isArray(data) ? data : (data.items ?? []);
const next = new Map<string, { name: string; type: string }>();
const keys = new Map<string, string>();
for (const it of items) {
if (typeof it.id === "string" && it.id) {
next.set(it.id, { name: it.name ?? "", type: it.type ?? "" });
if (it.payload !== undefined) {
keys.set(it.id, localPayloadFingerprint(it.payload));
}
}
}
relinkNames.value = next;
relinkContentKeys.value = keys;
} catch {
/* network — leave the maps empty; no re-link offered */
}
}
/** Content-aware re-link candidates for a DETACHED draft: existing library
* rows whose (type, payload_hash) match the draft's content (identical) or
* whose (type, name) match (content differs). Empty unless the draft is
* detached (`isLibraryMissing`). */
const relinkCandidates = computed<RelinkCandidate[]>(() => {
if (!props.draft || !needsRelink.value || libraryHashes.value === null) return [];
// Merge the locally computed content keys into the hash map the matcher
// walks, so a hash-less draft can still match on payload content.
const live: Record<string, { type?: string; payload_hash: string; contentKey?: string }> = {};
for (const [uuid, entry] of Object.entries(libraryHashes.value)) {
live[uuid] = { ...entry, contentKey: relinkContentKeys.value.get(uuid) };
}
return findRelinkCandidates(
{
id: props.draft.id,
type: props.draft.type,
payload_hash: props.draft.payload_hash,
contentKey: props.draft.payload_hash
? undefined
: localPayloadFingerprint(props.draft.payload),
name: props.draft.meta?.name ?? "",
},
live,
(u) => relinkNames.value.get(u),
);
});
/** The sole content-identical candidate, if unambiguous (else null). */
const relinkAuto = computed(() => autoRelinkTarget(relinkCandidates.value));
const relinkPick = ref<RelinkCandidate | null>(null);
const relinkQuery = ref<string>("");
const relinkFiltered = computed<RelinkCandidate[]>(() => {
const q = relinkQuery.value.trim().toLowerCase();
if (!q) return relinkCandidates.value;
return relinkCandidates.value.filter(
(c) => c.name.toLowerCase().includes(q) || c.uuid.toLowerCase().includes(q),
);
});
/** The active re-link target: an explicit pick wins, else the unambiguous
* auto-target (single identical candidate). */
const relinkTarget = computed<RelinkCandidate | null>(
() => relinkPick.value ?? relinkAuto.value,
);
/** Danger tone only when there is genuinely nothing to reconnect to. With a
* match on offer this is a routine "pick one" prompt, not a failure. */
const relinkNoteClass = computed(() =>
relinkCandidates.value.length > 0 ? "wp-ptl-note--info" : "wp-ptl-note--danger",
);
function doRelink(): void {
const t = relinkTarget.value;
if (!t || !props.draft) return;
emit("saved", {
mode: "relink",
id: t.uuid,
payload_hash: t.payloadHash,
bundles_updated: [],
name: t.name,
origId: props.draft.id,
});
}
const siblings = computed<number>(() => {
if (!props.draft) return 0;
try {
@@ -132,6 +258,8 @@ function resetForm(): void {
propagate.value = true;
busy.value = false;
errorMsg.value = "";
relinkPick.value = null;
relinkQuery.value = "";
}
/** When the row is library-tracked, fetch the live library entry and
@@ -183,9 +311,19 @@ watch(
resetForm();
void fetchBundlesContaining();
void seedFromLibrary();
// Re-link candidates only matter when the row's id ISN'T in the library —
// skip the extra library-list fetch entirely when it's present.
if (needsRelink.value) void fetchLibraryNames();
},
);
// A drift poll can flip the draft to unlinked AFTER open (hashes land late);
// fetch the candidate map the first time that happens so re-link appears
// without a reopen. One-shot: only when open, unlinked, and not yet loaded.
watch(needsRelink, (unlinked) => {
if (props.open && unlinked && relinkNames.value.size === 0) void fetchLibraryNames();
});
function close(): void {
if (busy.value) return;
emit("close");
@@ -395,14 +533,14 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
<ul class="wp-ptl-bundles__list">
<li v-for="b in bundles" :key="b.id">{{ b.name }}</li>
</ul>
<label class="wp-ptl-checkbox">
<input
<span class="wp-ptl-checkbox">
<WpCheck
v-model="propagate"
type="checkbox"
data-test="ptl-propagate"
aria-label="Propagate update to those bundles"
/>
Propagate update to those bundles
</label>
</span>
</div>
<div v-if="siblings > 1" class="wp-ptl-note wp-ptl-note--warn" data-test="ptl-siblings">
@@ -410,10 +548,82 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
changes the library entry every instance reads from.
</div>
<div v-if="isLibraryMissing" class="wp-ptl-note wp-ptl-note--danger" data-test="ptl-missing">
The library entry for this module has been deleted upstream. "Update existing"
is unavailable — use "Save as new entry" to re-add it to the library as a fresh
entry.
<div
v-if="needsRelink && relinkCandidates.length"
class="wp-ptl-relink"
data-test="ptl-relink"
>
<div class="wp-ptl-relink__title">Re-link to an existing library entry</div>
<p class="wp-ptl-relink__hint">
This module's content matches a library entry under a different id (a re-import
minted a new one). Re-link to reconnect drift + refresh instead of adding a duplicate.
</p>
<input
v-if="relinkCandidates.length > 1"
v-model="relinkQuery"
type="text"
class="wp-ptl-input"
data-test="ptl-relink-search"
placeholder="Search by name or id…"
aria-label="Search library entries to re-link"
spellcheck="false"
autocomplete="off"
/>
<ul class="wp-ptl-relink__list">
<li
v-for="c in relinkFiltered"
:key="c.uuid"
class="wp-ptl-relink__cand"
:class="{ 'is-picked': relinkTarget != null && relinkTarget.uuid === c.uuid }"
data-test="ptl-relink-candidate"
@click="relinkPick = c"
>
<span class="wp-ptl-relink__name">{{ c.name || "(unnamed)" }}</span>
<code class="wp-ptl-relink__uuid">{{ c.uuid }}</code>
<!-- "identical (content)" distinguishes a match established
by locally fingerprinting the payload — the hash-less
path — from one the server's own hash confirmed. Same
confidence in practice, but the label shouldn't imply
the authoritative hash agreed when there wasn't one. -->
<span
class="wp-ptl-relink__tag"
:class="c.contentIdentical ? 'is-identical' : 'is-diff'"
:title="c.matchedByContent
? 'Matched by comparing payload content — this module carries no stored hash'
: undefined"
>{{ c.contentIdentical
? (c.matchedByContent ? "identical (content)" : "identical")
: "content differs" }}</span>
</li>
</ul>
<button
type="button"
class="wp-ptl-btn wp-ptl-btn--primary"
data-test="ptl-relink-confirm"
:disabled="!relinkTarget || undefined"
@click="doRelink"
>Re-link<template v-if="relinkTarget"> to “{{ relinkTarget.name || relinkTarget.uuid }}”</template></button>
</div>
<!-- States the OBSERVABLE fact and points at Re-link first when a
match exists. The old copy asserted the entry "has been
deleted upstream" — a cause it cannot know (a shared workflow,
a re-import under a new id, or a lost hash all look identical
from here) — and recommended "Save as new entry" while the
Re-link panel sat directly above offering the better option,
which is how duplicates got made. -->
<div v-if="needsRelink" class="wp-ptl-note" :class="relinkNoteClass" data-test="ptl-missing">
<template v-if="relinkCandidates.length > 0">
This module isn’t linked to any library entry under its recorded id.
{{ relinkTarget?.contentIdentical
? "A library entry with identical content is selected above — re-linking reconnects them."
: "Pick a match above to re-link, or save it as a new entry." }}
</template>
<template v-else>
This module isn’t linked to any library entry under its recorded id, and
nothing in the library matches its content or name. “Update existing” is
unavailable — “Save as new entry” adds it to the library.
</template>
</div>
<div v-if="errorMsg" class="wp-ptl-error" data-test="ptl-error">
@@ -510,7 +720,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
font-family: inherit;
}
.wp-ptl-input:focus { outline: 1px solid var(--wp-accent-500, #8b5cf6); }
.wp-ptl-input--multi { resize: vertical; min-height: 48px; }
.wp-ptl-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 48px; }
.wp-ptl-payload {
margin: 0;
padding: 8px 10px;
@@ -558,6 +768,66 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 32%, transparent);
color: var(--wp-danger, #ef4444);
}
/* Informational tone for the "pick a match" case — a re-linkable module is a
routine state, not an error, and painting it red pushed users toward the
duplicate-creating escape hatch. */
.wp-ptl-note--info {
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 10%, transparent);
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
color: var(--wp-text, #e7e7ee);
}
.wp-ptl-relink {
padding: 10px 12px;
border-radius: 6px;
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 8%, transparent);
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
display: flex;
flex-direction: column;
gap: 8px;
}
.wp-ptl-relink__title { font-size: 12.5px; font-weight: 600; color: var(--wp-text, #e7e7ee); }
.wp-ptl-relink__hint { margin: 0; font-size: 11.5px; color: var(--wp-text-muted, #a1a1ad); }
.wp-ptl-relink__list {
list-style: none;
margin: 0;
padding: 0;
max-height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.wp-ptl-relink__cand {
display: flex;
align-items: baseline;
gap: 8px;
padding: 5px 8px;
border-radius: 4px;
cursor: pointer;
}
.wp-ptl-relink__cand:hover { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 16%, transparent); }
.wp-ptl-relink__cand.is-picked { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 28%, transparent); }
.wp-ptl-relink__name { font-size: 12px; color: var(--wp-text, #e7e7ee); }
.wp-ptl-relink__uuid { font-family: var(--wp-font-mono, monospace); font-size: 10px; color: var(--wp-text-dim, #6e6e7c); }
.wp-ptl-relink__tag {
margin-left: auto;
flex-shrink: 0;
font-size: 9.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 6px;
border-radius: 999px;
}
.wp-ptl-relink__tag.is-identical {
color: var(--wp-green, #6ad28b);
background: color-mix(in oklab, var(--wp-green, #6ad28b) 16%, transparent);
}
.wp-ptl-relink__tag.is-diff {
color: var(--wp-warn, #facc15);
background: color-mix(in oklab, var(--wp-warn, #facc15) 16%, transparent);
}
.wp-ptl-relink .wp-ptl-btn--primary { align-self: flex-start; }
.wp-ptl-error {
padding: 8px 10px;
border-radius: 6px;
@@ -75,6 +75,14 @@ const frameColor = computed(() =>
props.color && props.color.length ? props.color : "var(--wp-bundle-default)",
);
/** Tooltip for the MISSING badge. Names the two recoveries in the order the
* user should try them — Re-link reconnects to an existing entry, saving a
* new one is the fallback. The old copy only offered the fallback, which is
* what produced duplicate library entries. */
const missingTitle =
"No live library entry for this bundle under its recorded id. "
+ "Right-click → Push to library: re-link it to a matching entry, or save it as a new one.";
const summary = computed(() => {
const word = props.childCount === 1 ? "mod" : "mods";
const parts = [`${props.childCount} ${word}`];
@@ -165,16 +173,21 @@ const summary = computed(() => {
its own dot — re-stacking them at the bundle level would be
visual noise. -->
<span v-if="libraryMissing || libraryDrifted || snapshotModified" class="wp-mod-dots">
<!-- Visual hierarchy: MISSING (library deleted) is the most severe
<!-- Visual hierarchy: MISSING (no live library entry) is the most severe
and shows first; LIBRARY UPDATED (drift) dominates next; MOD
sits to its right. Multiple can show simultaneously — they
represent independent axes. -->
represent independent axes.
Copy states the observable fact rather than asserting the entry was
DELETED: an id absent from the library reads identically whether it
was deleted, re-imported under a new id, or arrived in a shared
workflow — and Push to library now offers Re-link for the last two,
which "deleted" steered users away from. -->
<template v-if="libraryMissing">
<span class="wp-mod-dot wp-mod-dot--missing"
title="The bundle's library entry has been deleted. Right-click → Push to library to re-add as a new entry."
:title="missingTitle"
aria-hidden="true"></span>
<span class="wp-mod-badge wp-mod-badge--missing"
title="The bundle's library entry has been deleted. Right-click → Push to library to re-add as a new entry.">missing</span>
:title="missingTitle">missing</span>
</template>
<template v-if="libraryDrifted">
<span class="wp-mod-dot wp-mod-dot--drift"
@@ -399,10 +412,19 @@ const summary = computed(() => {
color: var(--wp-warn);
background: color-mix(in srgb, var(--wp-warn) 7%, transparent);
}
/* Child-count / drift summary. Was 10px in --wp-text-dim (#666 on the dark
* theme), which sat right at the edge of legibility against the bundle frame
* tint — the one piece of information telling you how big a collapsed bundle
* is. Bumped to the muted token at 11px and given a faint pill so it reads as
* a value rather than a watermark. */
.wp-bundle-summary {
font: 500 10px/1.3 var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
font: 600 11px/1.3 var(--wp-font-sans);
color: var(--wp-text-muted, var(--wp-text2));
flex-shrink: 0;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in oklab, var(--wp-text-muted, var(--wp-text2)) 12%, transparent);
white-space: nowrap;
}
/* Bundle action button — base size + shape from shared
* .wp-btn--icon-sm. Bundle-specific tweak: bg + border tinted with
@@ -1,4 +1,43 @@
import { describe, it, expect } from "vitest";
import { bundleContentKey } from "./bundle-fingerprint";
describe("bundleContentKey", () => {
it("agrees across two copies of the same library bundle", () => {
// This is the point: `computeBundleFingerprint` folds in per-instance
// `_uid` / meta / instance, so a fresh insert never matches the library
// row it came from. This key keeps only what survives an insert.
const libraryChildren = [
{ type: "wildcard", payload_hash: "H1", id: "aaa" },
{ type: "constraint", payload_hash: "H2", id: "bbb" },
];
const workflowChildren = [
{ type: "wildcard", payload_hash: "H1", id: "zzz", _uid: "u1", meta: { name: "renamed" } },
{ type: "constraint", payload_hash: "H2", id: "yyy", _uid: "u2" },
];
expect(bundleContentKey(workflowChildren)).toBe(bundleContentKey(libraryChildren));
});
it("separates different children", () => {
expect(bundleContentKey([{ type: "wildcard", payload_hash: "H1" }]))
.not.toBe(bundleContentKey([{ type: "wildcard", payload_hash: "H2" }]));
});
it("respects child ORDER — a bundle is an ordered range", () => {
const a = [{ type: "wildcard", payload_hash: "H1" }, { type: "combine", payload_hash: "H2" }];
const b = [{ type: "combine", payload_hash: "H2" }, { type: "wildcard", payload_hash: "H1" }];
expect(bundleContentKey(a)).not.toBe(bundleContentKey(b));
});
it("keeps a hash-less child in the shape rather than skipping it", () => {
const withChild = [{ type: "wildcard", payload_hash: "H1" }, { type: "combine" }];
const without = [{ type: "wildcard", payload_hash: "H1" }];
expect(bundleContentKey(withChild)).not.toBe(bundleContentKey(without));
});
it("distinguishes an empty bundle from a one-child bundle", () => {
expect(bundleContentKey([])).not.toBe(bundleContentKey([{ type: "wildcard" }]));
});
});
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
import {
computeBundleFingerprint,
@@ -132,6 +132,31 @@ export function computeBundleFingerprint(
return `${FINGERPRINT_VERSION}:${djb2(parts.join("\n"))}`;
}
/**
* Cross-library content key for a bundle: the ordered list of its children's
* `(type, payload_hash)` pairs.
*
* Distinct from `computeBundleFingerprint`, which answers "have MY children
* been edited since insert" and therefore folds in per-instance state — `_uid`,
* `meta`, `instance`. That makes it useless for comparing a workflow bundle
* against a LIBRARY bundle: a fresh insert mints new uids, so two copies of the
* same library bundle never agree.
*
* This one deliberately keeps only what survives an insert, so a workflow
* bundle and the library row it came from produce the same key. Order matters
* (a bundle IS an ordered range) and children with no `payload_hash` contribute
* an empty slot rather than being skipped, so a hash-less child still affects
* the shape.
*
* Used by the re-link picker when a bundle carries no `inserted_at_hash`.
*/
export function bundleContentKey(
children: ReadonlyArray<{ type?: string; payload_hash?: string }>,
): string {
const parts = children.map((c) => `${c.type ?? ""}|${c.payload_hash ?? ""}`);
return `bk1:${djb2(parts.join("\n"))}`;
}
/** True when the bundle's current children diverge from the snapshot
* fingerprint stored on the instance. Returns FALSE when:
* - no fingerprint stored (workflow predates MOD detection), OR
@@ -0,0 +1,751 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { cascadeRestoreForBundle, rewriteChildId } from "./cascade-restore";
import {
bundleSnapshotModified,
computeBundleFingerprint,
} from "./bundle-fingerprint";
import { api } from "../../../manager/api/client";
import type { BundleRow, ModuleRow } from "../../../manager/api/types";
import {
emptyBundleInstance,
type BundleInstance,
type ModuleEntry,
} from "../../../widgets/_shared";
/**
* #2 regression: cascade-restore must remap references INSIDE a child's
* payload (constraint source/target, @{} refs), not just the child's own id.
* When Phase 1 re-creates a missing module under a fresh uuid, a sibling
* constraint that points at the old uuid must be re-pointed too, or the
* pushed bundle ships a dead source/target.
*/
describe("cascade-restore rewriteChildId — payload ref remap (#2)", () => {
it("remaps a constraint child's source/target uuids to the restored module ids", () => {
const moduleMap = new Map([
["aaaaaaaa", "bbbbbbbb"],
["cccccccc", "dddddddd"],
]);
const child = {
id: "cn000001",
type: "constraint",
payload: { source_wildcard_id: "aaaaaaaa", target_wildcard_id: "cccccccc", matrix: {} },
};
const out = rewriteChildId(child, moduleMap, new Map());
const p = out.payload as Record<string, unknown>;
expect(p.source_wildcard_id).toBe("bbbbbbbb");
expect(p.target_wildcard_id).toBe("dddddddd");
});
it("remaps embedded @{uuid} refs in a child's payload (preserving #name)", () => {
const moduleMap = new Map([["aabbccdd", "11223344"]]);
const child = {
id: "wc000001",
type: "wildcard",
payload: { options: [{ id: "o1", value: "see @{aabbccdd#color} here" }] },
};
const out = rewriteChildId(child, moduleMap, new Map());
expect(JSON.stringify(out.payload)).toContain("@{11223344#color}");
});
it("remaps refs found in a child's instance overrides too", () => {
const moduleMap = new Map([["aaaaaaaa", "bbbbbbbb"]]);
const child = {
id: "cn000002",
type: "constraint",
payload: { source_wildcard_id: "aaaaaaaa" },
instance: { target_select: { picks: [{ carrier_uid: "aaaaaaaa", option_id: "r1:0" }] } },
};
const out = rewriteChildId(child, moduleMap, new Map());
expect(JSON.stringify(out.instance)).toContain("bbbbbbbb");
expect(JSON.stringify(out.instance)).not.toContain("aaaaaaaa");
});
it("leaves refs to non-restored (external) uuids untouched", () => {
const child = {
id: "cn000003",
type: "constraint",
payload: { source_wildcard_id: "eeeeeeee", target_wildcard_id: "ffffffff" },
};
const out = rewriteChildId(child, new Map([["aaaaaaaa", "bbbbbbbb"]]), new Map());
const p = out.payload as Record<string, unknown>;
expect(p.source_wildcard_id).toBe("eeeeeeee");
expect(p.target_wildcard_id).toBe("ffffffff");
});
it("still rewrites the child's own top-level id (module + bundle)", () => {
const moduleMap = new Map([["aaaaaaaa", "bbbbbbbb"]]);
const innerBundleMap = new Map([["bndl1111", "bndl2222"]]);
const mod = rewriteChildId({ id: "aaaaaaaa", type: "wildcard", payload: {} }, moduleMap, innerBundleMap);
expect(mod.id).toBe("bbbbbbbb");
const bun = rewriteChildId({ id: "bndl1111", type: "bundle" }, moduleMap, innerBundleMap);
expect(bun.id).toBe("bndl2222");
});
});
/**
* #2 end-to-end: the headline claim is a CHAIN — push → cascade restore
* re-creates the missing wildcard under a fresh uuid → a sibling
* constraint pointing at the dead uuid is repointed in the children the
* outer ships. The pure `rewriteChildId` tests above lock the rewrite
* primitive; this exercises `cascadeRestoreForBundle` itself (Phase 1
* POST → Phase 3 children rebuild → Phase 4 workflow rebind) with the
* module POST mocked. Also re-asserts the #2 invariant inside the live
* flow: an embedded `@{uuid}` ref in the constraint follows the repoint
* too, not just the bare source/target fields.
*/
describe("cascadeRestoreForBundle — end-to-end ref repoint (#2)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function mkModule(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "h",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("restores a missing wildcard and repoints its sibling constraint's source + embedded @{} ref", async () => {
const fetchMock = vi.fn(
async () =>
({
ok: true,
json: async () => ({ id: "beef0001" }),
text: async () => "",
}) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
// deadbeef = the wildcard whose library row was deleted upstream.
const wildcard = mkModule({
id: "deadbeef",
type: "wildcard",
_uid: "u-w",
meta: { name: "Palette" },
payload: { options: [] },
bundle_origin: "outer-uid",
});
// A sibling constraint sources from deadbeef + carries an embedded
// @{deadbeef#palette} ref inside an exception value. facade00 is an
// EXTERNAL target (not restored) — it must survive untouched.
const constraint = mkModule({
id: "ca000001",
type: "constraint",
_uid: "u-c",
meta: { name: "Pairing" },
payload: {
source_wildcard_id: "deadbeef",
target_wildcard_id: "facade00",
matrix: {},
exceptions: [{ source_value: "see @{deadbeef#palette}", target_value: "sky" }],
},
bundle_origin: "outer-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("bndllib0"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 1,
name: "Kit",
};
const modules = [wildcard, constraint];
const result = await cascadeRestoreForBundle({
outer,
modules,
bundles: [outer],
isModuleMissing: (m) => m.id === "deadbeef",
isBundleMissing: () => false,
});
// Only the wildcard was missing → exactly one module POST.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.restoredModuleCount).toBe(1);
const cn = result.rewrittenChildren.find((c) => c.type === "constraint");
const p = cn?.payload as Record<string, unknown>;
expect(p.source_wildcard_id).toBe("beef0001"); // repointed to the restored id
expect(p.target_wildcard_id).toBe("facade00"); // external ref left alone
// #2 invariant re-asserted in the live flow: embedded @{} ref follows too,
// preserving its #name segment.
expect(JSON.stringify(cn?.payload)).toContain("@{beef0001#palette}");
expect(JSON.stringify(cn?.payload)).not.toContain("deadbeef");
// Workflow rebind swaps the dead id while preserving the per-instance _uid.
const w = result.newModules.find((m) => m._uid === "u-w");
expect(w?.id).toBe("beef0001");
});
});
/**
* QA bug (2026-06-12): bundle push-with-cascade reattached the constraint's
* TARGET but not its SOURCE — in the WORKFLOW. Root cause: Phase 4's
* `newModules` swapped each module's own `id` but never rewrote payload refs,
* so a restored constraint's source/target stayed pointed at the dead uuids
* on the canvas (the pushed library children, Phase 3, were already correct).
* This locks the workflow rebind: a restored module's INTERNAL refs follow
* the restore too, both endpoints.
*/
describe("cascadeRestoreForBundle — Phase 4 workflow rebind rewrites payload refs", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
function mkMod(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "h",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("repoints a restored constraint's source AND target in newModules, not just rewrittenChildren", async () => {
// Distinct id per POST, in outer-range order: subject, mood, constraint.
const minted = ["beef0001", "beef0002", "ca110011"];
let n = 0;
const fetchMock = vi.fn(
async () =>
({
ok: true,
// `headers` present because Phase 1 Pass 2's corrective PUT for the
// restored constraint routes through `api.modules.update` →
// `request()` → `checkStartupId(resp)`, which reads `resp.headers`.
headers: { get: () => null },
json: async () => ({ id: minted[n++] }),
text: async () => "",
}) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
const subject = mkMod({
id: "deadbeef",
type: "wildcard",
_uid: "u-s",
meta: { name: "subject" },
bundle_origin: "outer-uid",
});
const mood = mkMod({
id: "facade00",
type: "wildcard",
_uid: "u-m",
meta: { name: "mood" },
bundle_origin: "outer-uid",
});
const constraint = mkMod({
id: "ca000001",
type: "constraint",
_uid: "u-c",
meta: { name: "pairing" },
payload: { source_wildcard_id: "deadbeef", target_wildcard_id: "facade00", matrix: {} },
bundle_origin: "outer-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("bndllib1"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 2,
name: "Kit",
};
const modules = [subject, mood, constraint];
const result = await cascadeRestoreForBundle({
outer,
modules,
bundles: [outer],
isModuleMissing: (m) => ["deadbeef", "facade00", "ca000001"].includes(m.id),
isBundleMissing: () => false,
});
// All three restored → ids swapped in the workflow rebind.
const s = result.newModules.find((m) => m._uid === "u-s");
const t = result.newModules.find((m) => m._uid === "u-m");
const c = result.newModules.find((m) => m._uid === "u-c");
expect(s?.id).toBe("beef0001");
expect(t?.id).toBe("beef0002");
// The CANVAS constraint must repoint BOTH endpoints (the SRC-MISSING bug
// was: only target followed the restore, source stayed dead).
const p = c?.payload as Record<string, unknown>;
expect(p.source_wildcard_id).toBe("beef0001");
expect(p.target_wildcard_id).toBe("beef0002");
});
});
/**
* Phase 1 third-output bug (forward-ref): each missing module is POSTed
* RAW to mint a fresh library id (Pass 1). For a restored constraint, that
* standalone library entry ships DANGLING source/target — they still point
* at the OLD (now-deleted) uuids, because the constraint is POSTed BEFORE
* its target wildcard mints a new id (moduleMap is incomplete mid-loop).
* Phase 3 (pushed bundle children) + Phase 4 (workflow rebind) both already
* rewrite refs; Phase 1's standalone library entries did not.
*
* The fix is a two-pass Phase 1: Pass 1 mints ids (raw POST); Pass 2 runs
* AFTER the loop (moduleMap COMPLETE), walkRemaps each restored module's
* payload and, when refs actually changed, corrects the freshly-created
* library entry via `api.modules.update`. A pure wildcard with no internal
* refs must NOT trigger a corrective PUT.
*/
describe("cascadeRestoreForBundle — Phase 1 Pass 2 corrects standalone library refs", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mkMod(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "h",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("repoints a forward-ref constraint's source + target via api.modules.update, and skips a ref-free wildcard", async () => {
// Outer range order is FORWARD-REF: the constraint sits BEFORE its
// target wildcard, so moduleMap is incomplete when the constraint is
// POSTed — only a post-loop Pass 2 can repoint target_wildcard_id.
// [ subject (deadbeef), constraint (ca000001 → src deadbeef / tgt facade00), mood (facade00) ]
// POSTs mint, in order: beef0001 (subject), ca110011 (constraint), beef0002 (mood).
const minted = ["beef0001", "ca110011", "beef0002"];
let n = 0;
const fetchMock = vi.fn(
async () =>
({
ok: true,
json: async () => ({ id: minted[n++] }),
text: async () => "",
}) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
// Capture the corrective PUT(s) without hitting the network.
const updateSpy = vi
.spyOn(api.modules, "update")
.mockImplementation(async (id: string) => ({ id }) as unknown as ModuleRow);
const subject = mkMod({
id: "deadbeef",
type: "wildcard",
_uid: "u-s",
meta: { name: "subject" },
payload: { options: [] }, // no internal refs → must NOT trigger a PUT
bundle_origin: "outer-uid",
});
const constraint = mkMod({
id: "ca000001",
type: "constraint",
_uid: "u-c",
meta: { name: "pairing" },
payload: {
source_wildcard_id: "deadbeef",
target_wildcard_id: "facade00",
matrix: {},
},
bundle_origin: "outer-uid",
});
const mood = mkMod({
id: "facade00",
type: "wildcard",
_uid: "u-m",
meta: { name: "mood" },
payload: { options: [] }, // no internal refs → must NOT trigger a PUT
bundle_origin: "outer-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("bndllib2"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 2,
name: "Kit",
};
const modules = [subject, constraint, mood];
await cascadeRestoreForBundle({
outer,
modules,
bundles: [outer],
isModuleMissing: (m) => ["deadbeef", "ca000001", "facade00"].includes(m.id),
isBundleMissing: () => false,
});
// Exactly ONE corrective PUT — only the constraint carried sibling refs.
expect(updateSpy).toHaveBeenCalledTimes(1);
const [calledId, calledBody] = updateSpy.mock.calls[0];
// Corrective PUT targets the constraint's freshly-minted id.
expect(calledId).toBe("ca110011");
const p = (calledBody as { payload?: Record<string, unknown> }).payload;
expect(p).toBeDefined();
// BOTH endpoints repointed despite the forward-ref POST order.
expect(p?.source_wildcard_id).toBe("beef0001"); // subject's new id
expect(p?.target_wildcard_id).toBe("beef0002"); // mood's new id (forward ref!)
// The two ref-free wildcards (beef0001, beef0002) were NOT corrected.
const correctedIds = updateSpy.mock.calls.map((c) => c[0]);
expect(correctedIds).not.toContain("beef0001");
expect(correctedIds).not.toContain("beef0002");
});
});
/**
* QA bug (2026-06-12): after a cascade restore the canvas flashed DRIFT on a
* restored constraint. Root cause — Phase 4 rebound the workflow row's id +
* payload (refs repointed) but kept the STALE frozen-snapshot payload_hash,
* which no longer matched the corrected library entry's hash (a ref-free
* wildcard didn't drift because its payload, hence hash, was unchanged).
* Phase 1 now captures the library hash (POST + corrective update) and Phase 4
* stamps it, mirroring the bundle inserted_at_hash sync.
*/
describe("cascadeRestoreForBundle — Phase 4 syncs payload_hash to the library entry", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mkMod(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "stale-frozen-hash",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("stamps the corrected library hash on a restored constraint (no DRIFT), and the POST hash on a ref-free wildcard", async () => {
// POST mints {id, payload_hash} per restored module, in range order:
// subject (deadbeef) → beef0001 / "wh-post" (ref-free)
// constraint (ca000001, src deadbeef) → ca110011 / "cn-post"
const minted = [
{ id: "beef0001", payload_hash: "wh-post" },
{ id: "ca110011", payload_hash: "cn-post" },
];
let n = 0;
const fetchMock = vi.fn(
async () =>
({
ok: true,
json: async () => minted[n++],
text: async () => "",
}) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
// The corrective PUT returns the CORRECTED hash (the constraint's payload moved).
vi.spyOn(api.modules, "update").mockImplementation(
async (id: string) => ({ id, payload_hash: "cn-corrected" }) as unknown as ModuleRow,
);
const subject = mkMod({
id: "deadbeef",
type: "wildcard",
_uid: "u-s",
meta: { name: "subject" },
payload: { options: [] }, // ref-free → no corrective PUT → POST hash
bundle_origin: "outer-uid",
});
const constraint = mkMod({
id: "ca000001",
type: "constraint",
_uid: "u-c",
meta: { name: "pairing" },
payload: { source_wildcard_id: "deadbeef", target_wildcard_id: "facade00", matrix: {} },
bundle_origin: "outer-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("bndllib3"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 1,
name: "Kit",
};
const result = await cascadeRestoreForBundle({
outer,
modules: [subject, constraint],
bundles: [outer],
isModuleMissing: (m) => ["deadbeef", "ca000001"].includes(m.id),
isBundleMissing: () => false,
});
const s = result.newModules.find((m) => m._uid === "u-s");
const c = result.newModules.find((m) => m._uid === "u-c");
// Ref-free wildcard → its POST hash.
expect(s?.payload_hash).toBe("wh-post");
// Corrected constraint → the corrective-update hash, NOT the stale frozen
// hash (which is what produced the DRIFT badge).
expect(c?.payload_hash).toBe("cn-corrected");
expect(c?.payload_hash).not.toBe("stale-frozen-hash");
// Gap #1 (holistic audit 2026-06-13): the PUSHED bundle children (Phase 3)
// must carry the SAME live hashes. `toChildSnapshot` froze the stale
// pre-cascade hash, so without the rewriteChildId hash-stamp the pushed
// bundle ships a child whose payload was corrected but whose payload_hash
// is stale → RE-INSERTING the bundle shows spurious DRIFT against the live
// library entry.
const childWild = result.rewrittenChildren.find((c2) => c2.type === "wildcard");
const childCon = result.rewrittenChildren.find((c2) => c2.type === "constraint");
expect(childWild?.payload_hash).toBe("wh-post");
expect(childCon?.payload_hash).toBe("cn-corrected");
expect(childCon?.payload_hash).not.toBe("stale-frozen-hash");
});
});
/**
* QA bug (2026-06-15): pushing an outer bundle with the cascade "Restore N
* missing references first" option re-creates a missing INNER bundle, but the
* inner then flashes a false MODIFIED drift badge — even though it was just
* freshly created and matches the library. Root cause — Phase 4's `newBundles`
* rebinds each restored inner's `library_id` + `inserted_at_hash` but kept the
* STALE `snapshot_fingerprint` captured before the restore. The drift detector
* `bundleSnapshotModified` compares the LIVE fingerprint against that stale
* stored value → they differ → false MODIFIED. (`onBundlePushSaved` only
* recomputes the OUTER's fingerprint, never the inner ones.)
*
* The fix is the symmetric sibling of the inserted_at_hash sync: Phase 4 also
* reconciles each RESTORED inner's `snapshot_fingerprint` to
* `computeBundleFingerprint(rebound, newModules)` so the freshly-restored
* content matches its stored baseline (no drift). Only restored inners are
* touched — a genuinely-modified, non-restored bundle keeps its own
* fingerprint so real drift still surfaces.
*/
describe("cascadeRestoreForBundle — Phase 4 reconciles restored inner-bundle fingerprint", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mkMod(
over: Partial<ModuleEntry> & { bundle_origin?: string },
): ModuleEntry & { bundle_origin?: string } {
return {
id: "00000000",
type: "wildcard",
enabled: true,
collapsed: false,
meta: { name: "" },
entries: [],
payload: {},
instance: {},
payload_hash: "h",
_uid: "u",
...over,
} as ModuleEntry & { bundle_origin?: string };
}
it("clears false drift on a restored inner bundle carrying a stale snapshot_fingerprint", async () => {
// No modules are missing → fetch (module POST) is never called; only the
// inner bundle's library row was deleted upstream.
const fetchMock = vi.fn(
async () =>
({ ok: true, json: async () => ({}), text: async () => "" }) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
// The restored inner bundle's fresh library entry.
const createSpy = vi
.spyOn(api.bundles, "create")
.mockResolvedValue({ id: "innernew", payload_hash: "inner-fresh-hash" } as BundleRow);
// Outer (range 0-2): a top-level wildcard at idx 0, then an inner bundle
// covering idx 1-2 (two wildcards owned by the inner).
const topWild = mkMod({
id: "top00001",
type: "wildcard",
_uid: "u-top",
meta: { name: "top" },
bundle_origin: "outer-uid",
});
const innerA = mkMod({
id: "inna0001",
type: "wildcard",
_uid: "u-ia",
meta: { name: "innerA" },
payload_hash: "ha",
bundle_origin: "inner-uid",
});
const innerB = mkMod({
id: "innb0001",
type: "wildcard",
_uid: "u-ib",
meta: { name: "innerB" },
payload_hash: "hb",
bundle_origin: "inner-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("outerlib"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 2,
name: "Kit",
};
// Missing inner bundle carrying a STALE fingerprint that won't match the
// live content — this is what produces the false MODIFIED badge today.
const inner: BundleInstance = {
...emptyBundleInstance("innerlibOLD"),
_uid: "inner-uid",
parent_uid: "outer-uid",
start_idx: 1,
end_idx: 2,
name: "Inner",
snapshot_fingerprint: "v2:stale",
};
const modules = [topWild, innerA, innerB];
const bundles = [outer, inner];
const result = await cascadeRestoreForBundle({
outer,
modules,
bundles,
isModuleMissing: () => false,
isBundleMissing: (b) => b._uid === "inner-uid",
});
// The inner bundle was restored (Phase 2 POST) and rebound (Phase 4).
expect(createSpy).toHaveBeenCalledTimes(1);
expect(result.restoredBundleCount).toBe(1);
const reboundInner = result.newBundles.find((b) => b._uid === "inner-uid")!;
// Sanity: rebind swapped the dead library_id + synced inserted_at_hash.
expect(reboundInner.library_id).toBe("innernew");
expect(reboundInner.inserted_at_hash).toBe("inner-fresh-hash");
// The headline fix: the rebound inner's snapshot_fingerprint matches the
// freshly-restored content → NO false drift. (Stale "v2:stale" would fail.)
expect(reboundInner.snapshot_fingerprint).toBe(
computeBundleFingerprint(reboundInner, result.newModules),
);
expect(bundleSnapshotModified(reboundInner, result.newModules)).toBe(false);
});
it("does NOT touch a non-restored, genuinely-modified bundle's fingerprint", async () => {
const fetchMock = vi.fn(
async () =>
({ ok: true, json: async () => ({}), text: async () => "" }) as unknown as Response,
);
vi.stubGlobal("fetch", fetchMock);
const createSpy = vi
.spyOn(api.bundles, "create")
.mockResolvedValue({ id: "innernew", payload_hash: "inner-fresh-hash" } as BundleRow);
// Outer (range 0-3): top-level wildcard, a MISSING inner (idx 1), and a
// SIBLING inner (idx 2-3) that is NOT missing but genuinely drifted.
const topWild = mkMod({
id: "top00001",
type: "wildcard",
_uid: "u-top",
meta: { name: "top" },
bundle_origin: "outer-uid",
});
const missingChild = mkMod({
id: "miss0001",
type: "wildcard",
_uid: "u-miss",
meta: { name: "missingChild" },
payload_hash: "hm",
bundle_origin: "missing-uid",
});
const siblingA = mkMod({
id: "sibl0001",
type: "wildcard",
_uid: "u-sa",
meta: { name: "siblingA" },
payload_hash: "hs",
bundle_origin: "sibling-uid",
});
const siblingB = mkMod({
id: "sibl0002",
type: "wildcard",
_uid: "u-sb",
meta: { name: "siblingB" },
payload_hash: "hs2",
bundle_origin: "sibling-uid",
});
const outer: BundleInstance = {
...emptyBundleInstance("outerlib"),
_uid: "outer-uid",
start_idx: 0,
end_idx: 3,
name: "Kit",
};
const missingInner: BundleInstance = {
...emptyBundleInstance("missinglibOLD"),
_uid: "missing-uid",
parent_uid: "outer-uid",
start_idx: 1,
end_idx: 1,
name: "Missing",
snapshot_fingerprint: "v2:stale",
};
// A genuine pre-existing drift baseline: the sibling's stored fingerprint
// intentionally does NOT match its live content. Cascade must NOT erase it.
const driftedSibling: BundleInstance = {
...emptyBundleInstance("siblinglib"),
_uid: "sibling-uid",
parent_uid: "outer-uid",
start_idx: 2,
end_idx: 3,
name: "Sibling",
snapshot_fingerprint: "v2:genuinedrift",
};
const modules = [topWild, missingChild, siblingA, siblingB];
const bundles = [outer, missingInner, driftedSibling];
const result = await cascadeRestoreForBundle({
outer,
modules,
bundles,
isModuleMissing: () => false,
isBundleMissing: (b) => b._uid === "missing-uid",
});
expect(createSpy).toHaveBeenCalledTimes(1);
// The non-restored sibling keeps its stored fingerprint untouched → its
// genuine drift still surfaces. Blanket-recompute would have erased it.
const reboundSibling = result.newBundles.find((b) => b._uid === "sibling-uid")!;
expect(reboundSibling.snapshot_fingerprint).toBe("v2:genuinedrift");
expect(bundleSnapshotModified(reboundSibling, result.newModules)).toBe(true);
// The restored inner is still clean (fix applies only to it).
const reboundMissing = result.newBundles.find((b) => b._uid === "missing-uid")!;
expect(bundleSnapshotModified(reboundMissing, result.newModules)).toBe(false);
});
});
+140 -18
View File
@@ -40,6 +40,8 @@ import {
buildLibraryChildrenWithIntegrity,
toChildSnapshot,
} from "./save";
import { computeBundleFingerprint } from "./bundle-fingerprint";
import { walkRemap } from "./uuid-remap";
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
export interface CascadeRestoreInputs {
@@ -91,7 +93,28 @@ export async function cascadeRestoreForBundle(
// because inner-bundle children are still inside the outer's range —
// they need their uuids in the moduleMap before Phase 2 rebuilds the
// inner's children list.
//
// Two passes:
// Pass 1 — POST each missing module RAW to MINT a fresh id. The raw
// payload is just a seed; it may carry refs to SIBLING restored
// modules that haven't minted their new ids yet (forward refs),
// so the standalone library entry can ship dangling source/target
// / `@{}` refs at this point.
// Pass 2 — runs AFTER the loop, when `moduleMap` is COMPLETE. For
// each restored module it walkRemaps the payload against the full
// map; if any ref actually changed, it corrects the freshly-created
// library entry via `api.modules.update`. This is the same rewrite
// Phase 3 (pushed children) and Phase 4 (workflow rebind) apply —
// the standalone library entries are the third output that needs it.
// Ref-free modules (most wildcards) don't differ → no update.
const moduleMap = new Map<string, string>();
// Library payload_hash per restored module (keyed by OLD id). Phase 4 stamps
// it on the workflow row so the canvas doesn't flash DRIFT against the
// freshly-created/corrected library entry — mirror of the bundle
// inserted_at_hash sync below. A corrected constraint is the case that bites:
// its stale frozen-snapshot hash != the new entry's hash (its payload moved).
const moduleHashMap = new Map<string, string>();
const restored: { newId: string; module: ModuleEntry }[] = [];
for (let i = outer.start_idx; i <= outer.end_idx; i++) {
const m = modules[i];
if (!m) continue;
@@ -110,9 +133,32 @@ export async function cascadeRestoreForBundle(
const text = await res.text().catch(() => "");
throw new Error(`Module restore failed for "${baseName}": HTTP ${res.status} ${text}`);
}
const body = (await res.json()) as { id?: string };
const body = (await res.json()) as { id?: string; payload_hash?: string };
if (!body.id) throw new Error(`Module restore for "${baseName}" returned no id`);
moduleMap.set(m.id, body.id);
if (body.payload_hash) moduleHashMap.set(m.id, body.payload_hash);
restored.push({ newId: body.id, module: m });
}
// Pass 2: correct standalone library entries whose payload referenced a
// sibling restored module. `moduleMap` is complete now, so forward refs
// (a constraint POSTed before its target wildcard minted its id) resolve.
const restoreTable: Record<string, string> = {};
for (const [k, v] of moduleMap) restoreTable[k] = v;
if (Object.keys(restoreTable).length > 0) {
for (const { newId, module: m } of restored) {
const original = m.payload ?? {};
const rewrittenPayload = walkRemap(original, restoreTable) as Record<string, unknown>;
// Only PUT when a ref actually moved — ref-free modules are unchanged.
if (JSON.stringify(rewrittenPayload) === JSON.stringify(original)) continue;
const updated = await api.modules.update(newId, {
name: pickModuleName(m),
payload: rewrittenPayload,
});
// The corrected payload changes the hash — capture it so Phase 4 stamps
// the workflow row with the LIVE library hash (overrides the POST hash).
moduleHashMap.set(m.id, updated.payload_hash);
}
}
// ── Phase 2: restore missing inner bundles ──────────────────────────
@@ -130,7 +176,9 @@ export async function cascadeRestoreForBundle(
if (inner.parent_uid !== outer._uid) continue;
if (!isBundleMissing(inner)) continue;
const integrity = buildLibraryChildrenWithIntegrity(inner, modules, bundles);
const rewritten = integrity.children.map((c) => rewriteChildId(c, moduleMap, innerBundleMap));
const rewritten = integrity.children.map((c) =>
rewriteChildId(c, moduleMap, innerBundleMap, moduleHashMap),
);
const created = await api.bundles.create({
name: inner.name || "bundle",
color: inner.color ?? null,
@@ -143,7 +191,7 @@ export async function cascadeRestoreForBundle(
// ── Phase 3: build the outer's children with both rewrites applied ──
const outerIntegrity = buildLibraryChildrenWithIntegrity(outer, modules, bundles);
const rewrittenChildren = outerIntegrity.children.map((c) =>
rewriteChildId(c, moduleMap, innerBundleMap),
rewriteChildId(c, moduleMap, innerBundleMap, moduleHashMap),
);
// ── Phase 4: rebind workflow state so MISSING badges clear ──────────
@@ -152,18 +200,55 @@ export async function cascadeRestoreForBundle(
// since-deleted library entry at insert time) wouldn't match the new
// entry's hash and the BundleHeader would render LIBRARY UPDATED
// until the user manually reset.
const newModules = modules.map((m) =>
moduleMap.has(m.id) ? { ...m, id: moduleMap.get(m.id)! } : m,
);
// Rewrite table shared with Phase 3's child rewrite. The workflow modules
// need their INTERNAL refs repointed too — a restored constraint's
// source/target + any embedded `@{}` ref — not just their own `id`.
// Without this, after a cascade restore the canvas keeps a SRC/TGT-MISSING
// constraint even though the pushed library entry (rewrittenChildren) is
// correct: Phase 4 swapped ids but left payloads pointing at the dead uuids.
const refTable: Record<string, string> = {};
for (const [k, v] of moduleMap) refTable[k] = v;
for (const [k, v] of innerBundleMap) refTable[k] = v;
const hasRefs = Object.keys(refTable).length > 0;
const newModules = modules.map((m) => {
let next = moduleMap.has(m.id) ? { ...m, id: moduleMap.get(m.id)! } : m;
if (hasRefs) {
if (next.payload && typeof next.payload === "object") {
next = { ...next, payload: walkRemap(next.payload, refTable) as ModuleEntry["payload"] };
}
if (next.instance && typeof next.instance === "object") {
next = { ...next, instance: walkRemap(next.instance, refTable) as ModuleEntry["instance"] };
}
}
// Sync payload_hash to the restored library entry so the canvas doesn't
// flash DRIFT against it (mirror of newBundles' inserted_at_hash below).
// moduleHashMap only holds restored modules, so a non-restored row keeps
// its own hash.
const newHash = moduleHashMap.get(m.id);
if (newHash) next = { ...next, payload_hash: newHash };
return next;
});
const newBundles = bundles.map((b) => {
const next = innerBundleMap.get(b.library_id);
if (!next) return b;
const hash = innerHashMap.get(b.library_id);
return {
const rebound: BundleInstance = {
...b,
library_id: next,
...(hash ? { inserted_at_hash: hash } : {}),
};
// Reconcile the restored inner's snapshot_fingerprint to its freshly-
// restored content. Without this the rebound instance keeps the STALE
// fingerprint captured before the restore, so `bundleSnapshotModified`
// (live vs stored) flips true and the canvas flashes a false MODIFIED
// badge on a bundle that was just re-created to match the library.
// Mirror of the inserted_at_hash sync above. ONLY restored inners are
// reconciled (this branch) — a non-restored bundle returns early and
// keeps its own fingerprint, so genuine drift still surfaces.
return {
...rebound,
snapshot_fingerprint: computeBundleFingerprint(rebound, newModules),
};
});
return {
@@ -175,24 +260,61 @@ export async function cascadeRestoreForBundle(
};
}
/** Apply id rewrites to a single child entry. Bundle-typed children
* look up in `innerBundleMap`; module-typed children look up in
* `moduleMap`. Returns a NEW object only when a rewrite fires; otherwise
* reuses the original reference to avoid churn for downstream diff
* consumers. */
/** Apply id rewrites to a single child entry. Two layers:
*
* 1. The child's OWN top-level `id` — bundle-typed children look up in
* `innerBundleMap`; module-typed children in `moduleMap`.
* 2. References INSIDE the child's `payload` + `instance` that point at
* any restored module/inner-bundle — a constraint's
* `source_wildcard_id` / `target_wildcard_id`, or an `@{uuid}` ref in
* a wildcard/derivation value. Without (2), restoring a missing
* wildcard re-pointed its own child id but left a sibling
* constraint pointing at the dead uuid → the pushed bundle shipped a
* broken source/target (#2). The deep rewrite reuses `walkRemap`.
*
* Returns a NEW object only when a rewrite fires; otherwise reuses the
* original reference to avoid churn for downstream diff consumers. */
function rewriteChildId(
c: Record<string, unknown>,
moduleMap: Map<string, string>,
innerBundleMap: Map<string, string>,
moduleHashMap?: ReadonlyMap<string, string>,
): Record<string, unknown> {
let next: Record<string, unknown> = c;
// (1) Own id.
const id = typeof c.id === "string" ? c.id : "";
if (!id) return c;
if (c.type === "bundle") {
const next = innerBundleMap.get(id);
return next ? { ...c, id: next } : c;
if (id) {
const newId = c.type === "bundle" ? innerBundleMap.get(id) : moduleMap.get(id);
if (newId) next = { ...next, id: newId };
// (1b) Sync the child snapshot's `payload_hash` to the restored library
// entry's LIVE hash. `toChildSnapshot` froze the stale pre-cascade hash;
// for a restored module whose refs were corrected (Phase 1 Pass-2) the
// payload moved but the frozen hash didn't, so the pushed bundle would
// ship a child whose payload ≠ its payload_hash → RE-INSERTING the bundle
// shows spurious DRIFT against the live library entry. `moduleHashMap` is
// keyed by OLD id and holds only restored modules (POST hash, overridden
// by the corrective-update hash), so non-restored + bundle children are
// left untouched.
const freshHash = moduleHashMap?.get(id);
if (freshHash) next = { ...next, payload_hash: freshHash };
}
const next = moduleMap.get(id);
return next ? { ...c, id: next } : c;
// (2) Inner references (constraint source/target, @{} refs) → restored ids.
const table: Record<string, string> = {};
for (const [k, v] of moduleMap) table[k] = v;
for (const [k, v] of innerBundleMap) table[k] = v;
if (Object.keys(table).length > 0) {
if (next.payload && typeof next.payload === "object") {
next = { ...next, payload: walkRemap(next.payload, table) as Record<string, unknown> };
}
if (next.instance && typeof next.instance === "object") {
next = { ...next, instance: walkRemap(next.instance, table) as Record<string, unknown> };
}
}
return next;
}
/** Pre-scan to count what cascade would heal — used by the modal to
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { extractBundleChildren, type ExtractableChild } from "./extract-to-library";
// Deterministic id minter for tests: a1→n1, etc., in call order.
function seqMinter(): () => string {
let i = 0;
return () => `new${++i}`;
}
const wc = (id: string, value: string): ExtractableChild => ({
id, type: "wildcard", meta: { name: `wc-${id}` },
payload: { options: [{ id: "o1", value }] },
});
describe("extractBundleChildren", () => {
it("mints fresh ids and remaps an intra-bundle constraint's source/target", () => {
const children: ExtractableChild[] = [
wc("aaaa1111", "plain"),
wc("bbbb2222", "plain"),
{ id: "cccc3333", type: "constraint", meta: { name: "pair" },
payload: { source_wildcard_id: "aaaa1111", target_wildcard_id: "bbbb2222", matrix: {}, exceptions: [] } },
];
const { modules, remap, skipped } = extractBundleChildren(children, seqMinter());
expect(skipped).toBe(0);
expect(modules).toHaveLength(3);
expect(remap).toEqual({ aaaa1111: "new1", bbbb2222: "new2", cccc3333: "new3" });
const con = modules.find((m) => m.type === "constraint")!;
expect(con.id).toBe("new3");
expect(con.payload.source_wildcard_id).toBe("new1");
expect(con.payload.target_wildcard_id).toBe("new2");
expect(con.name).toBe("pair");
});
it("remaps a nested @{} ref pointing at a sibling child", () => {
const children: ExtractableChild[] = [
wc("aaaa1111", "plain"),
wc("bbbb2222", "see @{aaaa1111} here"),
];
const { modules } = extractBundleChildren(children, seqMinter());
const b = modules.find((m) => m.name === "wc-bbbb2222")!;
expect(b.payload.options).toEqual([{ id: "o1", value: "see @{new1} here" }]);
});
it("leaves refs pointing OUTSIDE the bundle verbatim", () => {
const children: ExtractableChild[] = [
{ id: "cccc3333", type: "constraint", meta: { name: "pair" },
payload: { source_wildcard_id: "zzzz9999", target_wildcard_id: "zzzz9999", matrix: {}, exceptions: [] } },
];
const { modules } = extractBundleChildren(children, seqMinter());
expect(modules[0].payload.source_wildcard_id).toBe("zzzz9999");
});
it("skips bundle-typed children (counts them) and extracts only leaf modules", () => {
const children: ExtractableChild[] = [
wc("aaaa1111", "plain"),
{ id: "dddd4444", type: "bundle", meta: { name: "nested" }, payload: {} },
];
const { modules, skipped } = extractBundleChildren(children, seqMinter());
expect(skipped).toBe(1);
expect(modules).toHaveLength(1);
expect(modules[0].type).toBe("wildcard");
});
it("carries name from meta.name, falling back to the id", () => {
const children: ExtractableChild[] = [
{ id: "aaaa1111", type: "wildcard", payload: { options: [] } },
];
const { modules } = extractBundleChildren(children, seqMinter());
expect(modules[0].name).toBe("aaaa1111");
});
});
@@ -0,0 +1,75 @@
/**
* Bundle "extract to library" (Feature 4).
*
* Pure transform: take a bundle's frozen child snapshots and produce
* standalone library-module entities with FRESH ids, remapping every
* intra-bundle reference (constraint source/target ids + nested `@{uuid}`)
* to the new ids. References pointing OUTSIDE the extracted set stay
* verbatim (they'll resolve against whatever lives in the target library,
* or become ordinary danglers eligible for reattach).
*
* Bundle-typed children are skipped (a nested bundle isn't a leaf module);
* the count is returned so the caller can surface "N skipped". Per-instance
* overrides are intentionally NOT extracted — a library module is the
* definition (payload), instance overrides are graph-specific.
*
* No I/O. The id minter is injected (defaults to `newShortId`) so tests can
* assert deterministic remaps.
*/
import { newShortId } from "../../../manager/utils/ids";
import { walkRemap } from "./uuid-remap";
/** Minimal child-snapshot shape this reads. Bundle children carry more
* (`instance`, `enabled`, `payload_hash`…) — ignored here. */
export interface ExtractableChild {
id: string;
type: string;
payload?: Record<string, unknown>;
meta?: { name?: string; description?: string; tags?: string[] };
}
/** A standalone library-module entity ready to install (engine-row shape). */
export interface ExtractedModule {
id: string;
type: string;
name: string;
description?: string;
tags?: string[];
payload: Record<string, unknown>;
}
export interface ExtractResult {
modules: ExtractedModule[];
/** oldChildId → freshId, for every extracted leaf. */
remap: Record<string, string>;
/** count of bundle-typed children skipped. */
skipped: number;
}
export function extractBundleChildren(
children: ExtractableChild[],
mintId: () => string = newShortId,
): ExtractResult {
const leaves = children.filter((c) => c.type !== "bundle");
const skipped = children.length - leaves.length;
// Pass 1: mint a fresh id for every leaf so intra-bundle refs can resolve
// against the COMPLETE table (a constraint's sibling may come later).
const remap: Record<string, string> = {};
for (const c of leaves) remap[c.id] = mintId();
// Pass 2: walkRemap each payload with the full table + lift meta → fields.
const modules: ExtractedModule[] = leaves.map((c) => {
const mod: ExtractedModule = {
id: remap[c.id],
type: c.type,
name: c.meta?.name ?? c.id,
payload: walkRemap(c.payload ?? {}, remap) as Record<string, unknown>,
};
if (c.meta?.description) mod.description = c.meta.description;
if (c.meta?.tags && c.meta.tags.length > 0) mod.tags = c.meta.tags;
return mod;
});
return { modules, remap, skipped };
}
+26 -168
View File
@@ -1,182 +1,40 @@
import { describe, it, expect } from "vitest";
import { remapBundleUuids, type ChildSnapshot } from "./uuid-remap";
import { walkRemap } from "./uuid-remap";
describe("remapBundleUuids — id regeneration", () => {
it("regenerates every child id to a fresh 12-char hex", () => {
const children: ChildSnapshot[] = [
{ id: "aaaa1111", type: "wildcard" },
{ id: "bbbb2222", type: "constraint" },
];
const result = remapBundleUuids(children);
expect(result.children).toHaveLength(2);
expect(result.children[0].id).not.toBe("aaaa1111");
expect(result.children[1].id).not.toBe("bbbb2222");
expect(result.children[0].id).toMatch(/^[0-9a-f]{12}$/);
expect(result.children[1].id).toMatch(/^[0-9a-f]{12}$/);
describe("walkRemap — partial remap table", () => {
it("rewrites a whole-string field whose value is a remapped id", () => {
const out = walkRemap("src11111", { src11111: "newsrc01" });
expect(out).toBe("newsrc01");
});
it("remap table maps every old id to its fresh id", () => {
const children: ChildSnapshot[] = [
{ id: "aaaa1111", type: "wildcard" },
{ id: "bbbb2222", type: "constraint" },
];
const result = remapBundleUuids(children);
expect(result.remap.aaaa1111).toBe(result.children[0].id);
expect(result.remap.bbbb2222).toBe(result.children[1].id);
expect(Object.keys(result.remap)).toHaveLength(2);
it("leaves a whole-string field untouched when not in the remap table", () => {
const out = walkRemap("other999", { src11111: "newsrc01" });
expect(out).toBe("other999");
});
it("does not mutate input children", () => {
const children: ChildSnapshot[] = [
{ id: "aaaa1111", type: "wildcard", payload: { var_binding: "x" } },
];
const snapshot = JSON.parse(JSON.stringify(children));
remapBundleUuids(children);
expect(children).toEqual(snapshot);
it("rewrites an embedded @{uuid} ref when the uuid is in the table", () => {
const out = walkRemap("see @{bbbb2222} here", { bbbb2222: "newbbbb1" });
expect(out).toBe("see @{newbbbb1} here");
});
});
describe("remapBundleUuids — @{uuid} text refs", () => {
it("rewrites @{uuid} in nested payload string values when uuid is in remap", () => {
const children: ChildSnapshot[] = [
{
id: "aaaa1111",
type: "wildcard",
payload: { options: [{ value: "see @{bbbb2222} here" }] },
},
{ id: "bbbb2222", type: "wildcard" },
];
const result = remapBundleUuids(children);
const newBbbb = result.remap.bbbb2222;
const opt = (result.children[0].payload as Record<string, unknown>).options as Array<{ value: string }>;
expect(opt[0].value).toBe(`see @{${newBbbb}} here`);
it("preserves the #name and :subcat segments of an @{} ref", () => {
const out = walkRemap("@{bbbb2222#Hair:long}", { bbbb2222: "newbbbb1" });
expect(out).toBe("@{newbbbb1#Hair:long}");
});
it("rewrites multiple @{uuid} refs in the same string", () => {
const children: ChildSnapshot[] = [
{
id: "aaaa1111",
type: "combine",
payload: { template: "$a @{bbbb2222} $b @{cccc3333} $c" },
},
{ id: "bbbb2222", type: "wildcard" },
{ id: "cccc3333", type: "wildcard" },
];
const result = remapBundleUuids(children);
const tpl = (result.children[0].payload as Record<string, string>).template;
expect(tpl).toBe(`$a @{${result.remap.bbbb2222}} $b @{${result.remap.cccc3333}} $c`);
it("leaves @{uuid} refs to non-remapped uuids verbatim", () => {
const out = walkRemap("see @{xxxx9999} here", { bbbb2222: "newbbbb1" });
expect(out).toBe("see @{xxxx9999} here");
});
it("leaves @{uuid} refs to non-bundle uuids untouched", () => {
const children: ChildSnapshot[] = [
{
id: "aaaa1111",
type: "wildcard",
payload: { options: [{ value: "see @{xxxx9999} here" }] },
},
];
const result = remapBundleUuids(children);
const opt = (result.children[0].payload as Record<string, unknown>).options as Array<{ value: string }>;
expect(opt[0].value).toBe("see @{xxxx9999} here");
});
});
describe("remapBundleUuids — constraint refs", () => {
it("rewrites source_wildcard_id + target_wildcard_id when both are in bundle", () => {
const children: ChildSnapshot[] = [
{ id: "aabbcc11", type: "wildcard" },
{ id: "ddeeff22", type: "wildcard" },
{
id: "11223344",
type: "constraint",
payload: {
source_wildcard_id: "aabbcc11",
target_wildcard_id: "ddeeff22",
matrix: {},
},
},
];
const result = remapBundleUuids(children);
const con = result.children[2].payload as Record<string, string>;
expect(con.source_wildcard_id).toBe(result.remap.aabbcc11);
expect(con.target_wildcard_id).toBe(result.remap.ddeeff22);
});
it("leaves source/target unchanged when they reference modules outside the bundle", () => {
const children: ChildSnapshot[] = [
{
id: "11223344",
type: "constraint",
payload: {
source_wildcard_id: "99887766",
target_wildcard_id: "55443322",
matrix: {},
},
},
];
const result = remapBundleUuids(children);
const con = result.children[0].payload as Record<string, string>;
expect(con.source_wildcard_id).toBe("99887766");
expect(con.target_wildcard_id).toBe("55443322");
});
});
describe("remapBundleUuids — instance fields", () => {
it("rewrites uuid-valued instance fields too", () => {
const children: ChildSnapshot[] = [
{ id: "aabbcc11", type: "wildcard" },
{
id: "11223344",
type: "constraint",
instance: {
// Bare-string uuid reference — gets rewritten via remap
// table lookup (full-string match).
pinned_source: "aabbcc11",
// Text containing `@{uuid}` — gets rewritten via the
// embedded-ref regex pass.
tooltip: "Pins to @{aabbcc11}",
},
},
];
const result = remapBundleUuids(children);
const inst = result.children[1].instance as Record<string, string>;
expect(inst.pinned_source).toBe(result.remap.aabbcc11);
expect(inst.tooltip).toBe(`Pins to @{${result.remap.aabbcc11}}`);
});
});
describe("remapBundleUuids — edge cases", () => {
it("handles empty children list", () => {
const result = remapBundleUuids([]);
expect(result.children).toEqual([]);
expect(result.remap).toEqual({});
});
it("handles children with no payload or instance", () => {
const children: ChildSnapshot[] = [
{ id: "aaaa1111", type: "wildcard" },
];
const result = remapBundleUuids(children);
expect(result.children[0].payload).toBeUndefined();
expect(result.children[0].instance).toBeUndefined();
expect(result.children[0].id).toMatch(/^[0-9a-f]{12}$/);
});
it("recurses into arrays of arrays of objects", () => {
const children: ChildSnapshot[] = [
{ id: "abcdef01", type: "x" },
{
id: "abcdef02",
type: "wildcard",
payload: {
deeply: [[{ ref: "abcdef01", text: "see @{abcdef01}" }]],
},
},
];
const result = remapBundleUuids(children);
const newId = result.remap.abcdef01;
const deeply = (result.children[1].payload as Record<string, unknown>).deeply as Array<Array<{ ref: string; text: string }>>;
expect(deeply[0][0].ref).toBe(newId);
expect(deeply[0][0].text).toBe(`see @{${newId}}`);
it("recurses into nested arrays + objects without mutating input", () => {
const input = { deeply: [[{ ref: "abcdef01", text: "see @{abcdef01}" }]] };
const snapshot = JSON.parse(JSON.stringify(input));
const out = walkRemap(input, { abcdef01: "newid001" }) as {
deeply: Array<Array<{ ref: string; text: string }>>;
};
expect(out.deeply[0][0].ref).toBe("newid001");
expect(out.deeply[0][0].text).toBe("see @{newid001}");
expect(input).toEqual(snapshot);
});
});
+4 -26
View File
@@ -18,8 +18,6 @@
* Pure-TS, no engine imports. Safe to import from frontend bundle code.
*/
import { newRowUid } from "../../../widgets/_shared";
export interface ChildSnapshot {
id: string;
type: string;
@@ -46,8 +44,10 @@ export interface RemapResult {
const REF_RE = /@\{([0-9a-f]{6,16})(#[^#:}@{]*)?(:[^}]*)?\}/gi;
/** Deep walks an unknown value, applying the remap. Returns a new
* object — never mutates input. */
function walkRemap(value: unknown, remap: Record<string, string>): unknown {
* object — never mutates input. Exported so the cascade-restore path
* can re-point constraint source/target + `@{}` refs at restored module
* uuids using a partial remap table (not a full id regen). */
export function walkRemap(value: unknown, remap: Record<string, string>): unknown {
if (typeof value === "string") {
let out = value;
// Whole-string match — e.g. constraint.source_wildcard_id = "src11111"
@@ -77,25 +77,3 @@ function walkRemap(value: unknown, remap: Record<string, string>): unknown {
}
return value;
}
/** Regenerate every child uuid in the snapshot list, then deep-walk
* every child's `payload` + `instance` rewriting any references to
* match. Constraint source/target ids + `@{uuid}` text refs all flow
* through `walkRemap` since they live inside `payload` / `instance`. */
export function remapBundleUuids(children: ChildSnapshot[]): RemapResult {
const remap: Record<string, string> = {};
for (const c of children) {
remap[c.id] = newRowUid();
}
const remapped: ChildSnapshot[] = children.map((c) => {
const next: ChildSnapshot = { ...c, id: remap[c.id] };
if (c.payload) {
next.payload = walkRemap(c.payload, remap) as Record<string, unknown>;
}
if (c.instance) {
next.instance = walkRemap(c.instance, remap) as Record<string, unknown>;
}
return next;
});
return { children: remapped, remap };
}
+43 -5
View File
@@ -14,7 +14,7 @@ beforeEach(() => {
"fetch",
vi.fn(async (url: string) => {
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
return new Response(JSON.stringify({ hashes: { aaaaaaaa: "h-a", bbbbbbbb: "h-b" } }), { status: 200 });
return new Response(JSON.stringify({ hashes: { aaaaaaaa: { type: "wildcard", payload_hash: "h-a" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-b" } } }), { status: 200 });
}
return new Response("{}", { status: 200 });
}),
@@ -32,7 +32,7 @@ describe("drift-store: subscribe/unsubscribe", () => {
expect(hashes.value).toBeNull();
subscribe();
await vi.advanceTimersByTimeAsync(0);
expect(hashes.value).toEqual({ aaaaaaaa: "h-a", bbbbbbbb: "h-b" });
expect(hashes.value).toEqual({ aaaaaaaa: { type: "wildcard", payload_hash: "h-a" }, bbbbbbbb: { type: "wildcard", payload_hash: "h-b" } });
unsubscribe();
});
});
@@ -82,7 +82,7 @@ describe("drift-store: forceRefresh", () => {
import type { ModuleEntry } from "../../widgets/_shared";
import type { SnapshotEntry } from "./drift-store";
import { refreshMany } from "./drift-store";
import { refreshMany, mergeRefresh } from "./drift-store";
function mkEntry(over: Partial<ModuleEntry> = {}): ModuleEntry {
return {
@@ -250,7 +250,8 @@ describe("setLibraryHash — direct hash update without re-fetch", () => {
setLibraryHash("abc123", "newhash456");
expect(hashes.value).not.toBeNull();
if (hashes.value) {
expect(hashes.value["abc123"]).toBe("newhash456");
// Optimistic entry stores { payload_hash } (type filled by next poll).
expect(hashes.value["abc123"]).toEqual({ payload_hash: "newhash456" });
}
});
@@ -258,6 +259,43 @@ describe("setLibraryHash — direct hash update without re-fetch", () => {
setLibraryHash("a", "ha");
setLibraryHash("b", "hb");
setLibraryHash("a", "ha-updated");
expect(hashes.value).toEqual({ a: "ha-updated", b: "hb" });
expect(hashes.value).toEqual({
a: { payload_hash: "ha-updated" },
b: { payload_hash: "hb" },
});
});
});
describe("drift-store: mergeRefresh type-guard", () => {
it("throws when the live snapshot is a DIFFERENT kind than the embedded row", () => {
const embedded = mkEntry({ id: "aabbccdd", type: "constraint" });
const live: SnapshotEntry = {
snapshot_version: 1,
uuid: "aabbccdd",
type: "wildcard",
name: "w",
payload: {},
payload_hash: "h2",
source: { kind: "user" },
};
// A same-id, different-kind library row must never merge over the
// embedded item — it would silently change the item's kind.
expect(() => mergeRefresh(embedded, live)).toThrow(/type mismatch/i);
});
it("merges normally when the kinds match", () => {
const embedded = mkEntry({ id: "aabbccdd", type: "wildcard" });
const live: SnapshotEntry = {
snapshot_version: 1,
uuid: "aabbccdd",
type: "wildcard",
name: "w2",
payload: { options: [] },
payload_hash: "h2",
source: { kind: "user" },
};
const merged = mergeRefresh(embedded, live);
expect(merged.type).toBe("wildcard");
expect(merged.payload_hash).toBe("h2");
});
});
+48 -11
View File
@@ -20,16 +20,18 @@ export interface RefreshResult {
failed: { id: string; reason: string }[];
}
/** Live-library hashes keyed by uuid. `null` while the first fetch is in
/** Live-library identity map keyed by uuid: each value carries the row's
* `{type, payload_hash}`. `type` enables cross-kind id-clash detection;
* `payload_hash` is the drift signal. `null` while the first fetch is in
* flight — predicates downstream use this to avoid flashing "missing" /
* "drift" before we know the truth.
*
* Invariant: values are always non-empty strings once the map is non-null.
* Consumers may assume `live === undefined` is equivalent to `!(uuid in
* hashes.value)` — the API never emits explicit-undefined entries. If this
* ever changes, both `isMissingFromLibrary` and `isDrifted` in
* `ContextWidget.vue` need to be tightened together. */
export const hashes: Ref<Record<string, string> | null> = ref(null);
* `type` is optional because the optimistic `setLibraryHash` writes a
* payload_hash-only entry (the 5s poll fills `type` shortly after).
* Consumers run `classifyOne` (import-export/collision) against these
* entries: `live === undefined` ≡ `!(uuid in hashes.value)` — the API
* never emits explicit-undefined entries. */
export const hashes: Ref<Record<string, { type?: string; payload_hash: string }> | null> = ref(null);
/** Same shape as `hashes` but keyed by bundle library uuid. Polled
* in parallel with module hashes so bundle drift detection
@@ -94,13 +96,35 @@ function sameHashes(
return true;
}
/** Object-aware sibling of `sameHashes` for the module map, whose values
* are `{type, payload_hash}` rather than flat strings. Same purpose: gate
* `hashes.value` reassignment so the 5s poll doesn't mint a new ref (and
* fire reactivity) when nothing changed. */
function sameModuleHashes(
a: Record<string, { type?: string; payload_hash: string }> | null,
b: Record<string, { type?: string; payload_hash: string }>,
): boolean {
if (a === null) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const k of keysA) {
const va = a[k];
const vb = b[k];
if (!vb || va.type !== vb.type || va.payload_hash !== vb.payload_hash) return false;
}
return true;
}
async function fetchModuleHashes(): Promise<void> {
try {
const res = await fetch("/wp/api/modules/hashes");
if (!res.ok) return;
const body = (await res.json()) as { hashes?: Record<string, string> };
const body = (await res.json()) as {
hashes?: Record<string, { type?: string; payload_hash: string }>;
};
if (body && body.hashes && typeof body.hashes === "object") {
if (!sameHashes(hashes.value, body.hashes)) {
if (!sameModuleHashes(hashes.value, body.hashes)) {
hashes.value = body.hashes;
}
}
@@ -189,6 +213,15 @@ export async function refreshMany(modules: ModuleEntry[]): Promise<RefreshResult
* actually move.
*/
export function mergeRefresh(m: ModuleEntry, live: SnapshotEntry): ModuleEntry {
if (m.type !== live.type) {
// Defense-in-depth: a same-id, DIFFERENT-kind library row must never be
// merged over an embedded item (it would silently change the item's
// kind). The UI suppresses Refresh for type-conflict rows; this guards
// the path even if a caller bypasses that.
throw new Error(
`refresh type mismatch: embedded ${m.type} vs library ${live.type} at id ${m.id}`,
);
}
const merged: ModuleEntry & { bundle_origin?: string } = {
type: live.type,
meta: {
@@ -220,8 +253,12 @@ export function mergeRefresh(m: ModuleEntry, live: SnapshotEntry): ModuleEntry {
*
* See: docs/superpowers/specs/2026-05-07-instance-overrides-modal-design.md §8.5
*/
export function setLibraryHash(id: string, hash: string): void {
hashes.value = { ...(hashes.value ?? {}), [id]: hash };
export function setLibraryHash(id: string, payloadHash: string): void {
// Optimistic entry — `type` omitted (the 5s poll fills it). Enough to
// clear the missing/drift dot immediately: classifyOne sees the id
// present + payload_hash match → silent-skip. A type-conflict can't
// arise here (you're saving your own entry), so omitting type is safe.
hashes.value = { ...(hashes.value ?? {}), [id]: { payload_hash: payloadHash } };
}
/** Test-only — reset internal counters between cases. */
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import InstanceIdChip from "./InstanceIdChip.vue";
describe("InstanceIdChip", () => {
beforeEach(() => {
// jsdom has no clipboard by default — stub a resolving writeText.
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn(async () => undefined) },
configurable: true,
});
});
it("renders the library id as the visible label", () => {
const w = mount(InstanceIdChip, { props: { id: "ab12cd34" } });
const chip = w.get('[data-test="instance-id-chip"]');
expect(chip.text()).toContain("ab12cd34");
});
it("renders nothing when id is empty (unlinked/inline module)", () => {
const w = mount(InstanceIdChip, { props: { id: "" } });
expect(w.find('[data-test="instance-id-chip"]').exists()).toBe(false);
});
it("surfaces both id and _uid in the tooltip", () => {
const w = mount(InstanceIdChip, { props: { id: "ab12cd34", uid: "uid-9f9f9f" } });
const title = w.get('[data-test="instance-id-chip"]').attributes("title") ?? "";
expect(title).toContain("ab12cd34");
expect(title).toContain("uid-9f9f9f");
});
it("copies the id to the clipboard and flips to a copied state on click", async () => {
const writeText = navigator.clipboard.writeText as ReturnType<typeof vi.fn>;
const w = mount(InstanceIdChip, { props: { id: "ab12cd34" } });
await w.get('[data-test="instance-id-chip"]').trigger("click");
await flushPromises();
expect(writeText).toHaveBeenCalledWith("ab12cd34");
expect(w.get('[data-test="instance-id-chip"]').classes()).toContain("wp-idchip--copied");
expect(w.get('[data-test="instance-id-chip"]').text()).toContain("copied");
});
});
@@ -0,0 +1,95 @@
<script setup lang="ts">
/**
* Small monospace, click-to-copy chip that surfaces a module/bundle's
* identity in the edit-modal header. Debugging aid (workstream C / issue #4):
* the library `id` is the uuid that `@{id}` nested refs, constraint
* source/target, and library rows all match on — so seeing it (and being
* able to copy it) makes drift/linking bugs traceable at a glance.
*
* Shows the library `id` as the visible label; the instance `_uid` (what
* constraint `pick` + `bundle_origin` key on) rides along in the tooltip so
* both identities are one hover away without cluttering the header.
*/
import { ref, onBeforeUnmount } from "vue";
const props = defineProps<{
/** Library uuid — the payload row id. Matches `@{id}`, constraint
* source/target ids, and library rows. Empty for inline-only modules. */
id: string;
/** Instance uid — per-Context-node instance identity used by constraint
* `pick` selectors + `bundle_origin`. Optional (absent on some surfaces). */
uid?: string;
}>();
const copied = ref(false);
let timer: number | undefined;
async function copyId(): Promise<void> {
if (!props.id) return;
try {
await navigator.clipboard.writeText(props.id);
copied.value = true;
if (timer !== undefined) window.clearTimeout(timer);
timer = window.setTimeout(() => { copied.value = false; }, 1200);
} catch {
// Clipboard blocked (insecure context / denied) — the id stays visible
// for manual selection, so a failed copy is a silent no-op.
}
}
onBeforeUnmount(() => {
if (timer !== undefined) window.clearTimeout(timer);
});
</script>
<template>
<button
v-if="id"
type="button"
class="wp-idchip"
:class="{ 'wp-idchip--copied': copied }"
:title="`library id: ${id}${uid ? `\ninstance uid: ${uid}` : ''}\n(click to copy id)`"
data-test="instance-id-chip"
@click.stop="copyId"
>
<i
class="pi wp-idchip__icon"
:class="copied ? 'pi-check' : 'pi-hashtag'"
aria-hidden="true"
/>
<span class="wp-idchip__id">{{ copied ? "copied" : id }}</span>
</button>
</template>
<style scoped>
.wp-idchip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border: 1px solid var(--wp-border, #33333f);
border-radius: var(--wp-radius-sm, 4px);
background: var(--wp-bg2, #1b1b22);
color: var(--wp-text-dim, #8a8a99);
font: 500 10px/1.4 var(--wp-font-mono, ui-monospace, "JetBrains Mono", monospace);
letter-spacing: 0.02em;
cursor: pointer;
user-select: none;
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.wp-idchip:hover {
color: var(--wp-text, #e7e7ee);
border-color: color-mix(in oklab, var(--wp-accent, #7c6ad6) 55%, var(--wp-border, #33333f));
}
.wp-idchip__icon {
font-size: 9px;
opacity: 0.8;
}
.wp-idchip__id {
font-family: inherit;
}
.wp-idchip--copied {
color: var(--wp-green, #6ad28b);
border-color: color-mix(in oklab, var(--wp-green, #6ad28b) 55%, transparent);
}
</style>
@@ -20,6 +20,12 @@
.wp-wcm__head { --modal-accent: var(--wp-kind-wildcard); }
.wp-cbm__head, .wp-cnm__head, .wp-dvm__head, .wp-fvm__head, .wp-wcm__head {
/* Pinned to the top of the scrolling modal body so the title + close
* stay visible while the content scrolls. Opaque (gradient bottoms out
* at --wp-bg2) so scrolled content doesn't bleed through. */
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 10px;

Some files were not shown because too many files have changed in this diff Show More