Compare commits

...
117 Commits
Author SHA1 Message Date
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
152 changed files with 8887 additions and 601 deletions
+3
View File
@@ -52,6 +52,9 @@ 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
+24 -2
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: [
@@ -80,6 +95,10 @@ export default {
"💬 [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" +
// Curated, plain-language highlights (when provided) render FIRST
// — this is what most readers see. The raw per-commit changelog
// stays below, collapsed.
(highlights ? `### ✨ What's new\n\n${highlights}\n\n---\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
@@ -102,14 +121,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-
+188
View File
@@ -1,3 +1,191 @@
## 🎉 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)
View File
+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)
+42 -3
View File
@@ -295,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))
@@ -309,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 [])
@@ -364,7 +399,9 @@ class DerivationHandler(ModuleHandler):
action = {**action, "value": action_override}
pair = _apply_action(
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, bi)
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]
@@ -387,7 +424,9 @@ class DerivationHandler(ModuleHandler):
if isinstance(action_override, str):
action = {**action, "value": action_override}
pair = _apply_action(
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, "else")
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]
+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)
+11 -1
View File
@@ -645,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:
+105 -8
View File
@@ -236,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."""
@@ -244,10 +256,14 @@ 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__", [])
@@ -259,6 +275,32 @@ class PipelineEngine:
# 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
for index, module in enumerate(modules):
# Normalise id/type/enabled reads to work for both dicts and objects.
if isinstance(module, dict):
@@ -296,18 +338,52 @@ class PipelineEngine:
_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,
@@ -325,17 +401,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
+8
View File
@@ -33,6 +33,10 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
"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
@@ -59,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:
@@ -69,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": {
+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")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.6.0",
"version": "2.10.2",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.6.0"
version = "2.10.2"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+49 -1
View File
@@ -178,7 +178,55 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// 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.
const TOTAL_LIMIT = 435 * 1024; // 435 KB
// 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.
const TOTAL_LIMIT = 453 * 1024; // 453 KB
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
@@ -1,7 +1,9 @@
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";
describe("ContextLoopWidget", () => {
it("renders defaults — hash chip active, both switches off, internal toggles on", () => {
@@ -89,3 +91,90 @@ 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-loop__chip--bypassed");
expect(w.find('[data-test="loop-frame-4"]').classes()).toContain("wp-loop__chip--bypassed");
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-loop__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]);
});
});
@@ -12,8 +12,10 @@
* 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 { currentFrame, setFrame } from "./frame-cursor";
const props = withDefaults(
defineProps<{
@@ -21,8 +23,16 @@ 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] }>();
@@ -45,6 +55,24 @@ const OVERRIDE_SEED_TOOLTIP =
const isMuted = computed<boolean>(() => props.nodeMode === 2);
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
const frameChips = computed(() =>
Array.from({ length: Math.max(1, props.count ?? 1) }, (_, i) => i),
);
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 });
}
function pickStrategy(s: LoopStrategy): void {
if (props.modelValue.strategy === s) return;
@@ -108,6 +136,27 @@ 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>
<div class="wp-loop__section">
<div class="wp-loop__label">edit frame</div>
<div class="wp-loop__chips wp-loop__chips--frames" role="radiogroup" aria-label="Edit frame">
<button type="button" class="wp-loop__chip" :class="{ 'wp-loop__chip--active': currentFrame === null }"
data-test="loop-frame-base" role="radio" :aria-checked="currentFrame === null" @click="setFrame(null)">base</button>
<button v-for="i in frameChips" :key="i" type="button" class="wp-loop__chip"
:class="{ 'wp-loop__chip--active': currentFrame === i, 'wp-loop__chip--bypassed': bypassSet.has(i) }"
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i"
:title="bypassSet.has(i) ? `Frame ${i + 1} is bypassed` : undefined" @click="setFrame(i)">#{{ i + 1 }}</button>
</div>
</div>
<div
class="wp-loop__row"
:title="OVERRIDE_SEED_TOOLTIP"
@@ -169,7 +218,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 +230,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 +260,18 @@ function toggleTotalInternal(): void {
}
.wp-loop__chips { display: flex; gap: 4px; }
/* Frame chips ONLY (the strategy row has 3 fixed chips that must stay one
row + show full labels): grid so 15+ frame chips WRAP instead of
overflowing the node. auto-fill keeps a uniform cell width; the last row
grows to 1fr to fill it. */
.wp-loop__chips--frames {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(38px, 1fr));
}
.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;
@@ -220,6 +285,14 @@ function toggleTotalInternal(): void {
border-color: var(--wp-accent, #c4b5fd);
color: var(--wp-accent, #c4b5fd);
}
/* Bypassed frame — interrupted (dashed) border + dimmed so it reads at a
* glance on the node. Composes with --active (dashed overrides the solid). */
.wp-loop__chip--bypassed {
border-style: dashed;
border-color: var(--wp-border-strong, #4a4d55);
opacity: 0.5;
}
.wp-loop__chip--bypassed:hover { opacity: 0.8; }
.wp-loop__row {
display: flex;
@@ -289,8 +362,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 +379,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;
}
@@ -3,6 +3,7 @@ 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
@@ -728,6 +729,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();
+165 -7
View File
@@ -28,6 +28,7 @@ import BundleFrame from "./bundles/BundleFrame.vue";
import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-ctx";
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import { LockSeedKey } from "./lock-seed-ctx";
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
@@ -74,7 +75,9 @@ import { dragState, queueHandoff, pendingHandoffs, takeHandoffsFor } from "./dra
import { nextBindingSuffix } from "./duplicates/binding-suffix";
import { pushToast } from "../shared/toast-store";
import { kindIcon } from "../shared/kind-icons";
import { KIND_TITLE } from "./editors/_shell";
import { KIND_TITLE, FRAME_OVERRIDABLE_FIELDS } from "./editors/_shell";
import { currentFrame } from "../context-loop/frame-cursor";
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled as feEffectiveEnabled, frameEnableOverride as feFrameEnableOverride } from "./frame-overrides";
import { varColorClass } from "../shared/var-color";
import wpLogoSvg from "../shared/wp-logo.svg?raw";
import {
@@ -151,6 +154,10 @@ const props = withDefaults(defineProps<{
nodeMode?: number;
/** Last-run seed reader keyed by module id. Null = user hasn't queued. */
lastUsedSeedReader?: (moduleId?: string) => number | null;
/** Per-frame seed reader — the seed THIS node rolls at iteration frame #k,
* computed from the upstream Loop without a run. Drives the per-frame seed
* lock so it pins the seed the module actually uses on that frame. */
frameSeedReader?: (frame: number) => number | null;
onChange: (json: string) => void;
/** Called whenever the formula-computed minimum node width changes
* (a conflict appears, a state badge gets attached). Mount glue
@@ -679,9 +686,11 @@ function onBundlePushClosed(): void {
pushBundleTargetUid.value = null;
pushBundleCascadeScan.value = null;
}
const editingModule = computed<ModuleEntry | null>(() =>
editingIdx.value != null ? (value.value.modules[editingIdx.value] ?? null) : null,
);
const editingModule = computed<ModuleEntry | null>(() => {
if (editingIdx.value == null) return null;
const m = value.value.modules[editingIdx.value] ?? null;
return m ? withFrameInstance(m, currentFrame.value) : null;
});
/** Per-occurrence pair lookup for the currently-edited CARRIER module.
* Map keys are the carrier's `via.optionIds` — wildcard option ids for a
@@ -2880,11 +2889,35 @@ function isInternalable(m: ModuleEntry): boolean {
}
function isLocked(m: ModuleEntry): boolean {
return typeof m.instance?.locked_seed === "number";
return typeof withFrameInstance(m, currentFrame.value).instance?.locked_seed === "number";
}
function isInternal(m: ModuleEntry): boolean {
return !!m.instance?.internal;
}
/** Kinds whose handlers honor `seed_scope: hold` — wildcard, combine,
* fixed_values, and derivation (which freezes its frame-0 output across a
* loop, constrained picks + nested @{} refs included). Now equal to
* SEED_LOCKABLE_KINDS, but kept distinct on purpose: lock-the-seed and
* hold-the-value are different concepts that could diverge again. Gates
* the row context-menu's quick Hold item + which RuntimeSections render
* the toggle. */
const HOLD_KINDS: ReadonlySet<string> = new Set([
"wildcard", "combine", "fixed_values", "derivation",
]);
function isHoldable(m: ModuleEntry): boolean {
return HOLD_KINDS.has(m.type);
}
function isHeld(m: ModuleEntry): boolean {
return m.instance?.seed_scope === "hold";
}
function isOverriddenOnFrame(m: ModuleEntry): boolean {
const k = currentFrame.value;
return k != null && !!m.iteration_overrides?.[String(k)];
}
function effectiveLockedSeed(m: ModuleEntry): number | undefined {
const v = withFrameInstance(m, currentFrame.value).instance?.locked_seed;
return typeof v === "number" ? v : undefined;
}
// ── Formula-driven minWidth ────────────────────────────────────────
// Pull-based pattern: createDomWidgetHost reads this through a
@@ -2938,6 +2971,22 @@ watch(
function toggleLockOnCard(idx: number) {
const m = value.value.modules[idx];
if (!m) return;
const k = currentFrame.value;
if (k != null) {
// Prefer the seed THIS module actually rolls at frame #k (computed from the
// upstream Loop), so the lock pins the frame's real seed — not the single
// representative seed a run surfaced. Falls back to last-used / stored when
// no Loop drives the node.
const frameSeed = props.frameSeedReader?.(k);
const lastUsed = props.lastUsedSeedReader?.(m._uid ?? m.id);
const fallback = typeof frameSeed === "number" ? frameSeed
: typeof lastUsed === "number" ? lastUsed
: (typeof m.instance?._ui?.last_locked_seed === "number" ? m.instance._ui.last_locked_seed : 0);
const list = [...value.value.modules];
list[idx] = toggleFrameLock(m, k, fallback);
commitModules(list);
return;
}
const inst = m.instance ?? {};
let nextInst: NonNullable<ModuleEntry["instance"]>;
if (typeof inst.locked_seed === "number") {
@@ -2980,7 +3029,9 @@ function toggleLockOnCard(idx: number) {
// map-by-id pass would toggle every sibling at once. Indexing into
// the array hits the specific instance the user clicked.
const list = [...value.value.modules];
list[idx] = { ...m, instance: nextInst };
// Unlocking base makes any frame `locked_seed: null` override redundant —
// sweep it so the row stops showing a stale "override #k". No-op on lock.
list[idx] = dropRedundantFrameLockNulls({ ...m, instance: nextInst });
commitModules(list);
}
@@ -3697,11 +3748,32 @@ function moveToEdge(idx: number, edge: "top" | "bottom") {
function toggleEnabled(idx: number) {
if (idx < 0 || idx >= value.value.modules.length) return;
const k = currentFrame.value;
if (k != null) {
// Frame-active: flip the module's per-frame enable override (symmetric —
// base-off turns ON for this frame, base-on turns OFF). Pure logic lives in
// frame-overrides.toggleFrameEnabled; base `enabled` is never touched here.
const m = value.value.modules[idx];
if (!m) return;
const list = [...value.value.modules];
list[idx] = toggleFrameEnabled(m, k);
commitModules(list);
return;
}
const list = [...value.value.modules];
list[idx] = { ...list[idx], enabled: !list[idx].enabled };
commitModules(list);
}
// Thin wrappers binding the pure frame-overrides enable helpers to the reactive
// `currentFrame` cursor — exposed to ModuleRow via moduleRowCtx.
function effectiveEnabled(m: ModuleEntry): boolean {
return feEffectiveEnabled(m, currentFrame.value);
}
function frameEnableOverride(m: ModuleEntry): "on" | "off" | null {
return feFrameEnableOverride(m, currentFrame.value);
}
function toggleCollapsed(idx: number) {
const target = value.value.modules[idx];
if (!target) return;
@@ -3718,6 +3790,29 @@ function toggleCollapsed(idx: number) {
commitModules(list);
}
// Quick "Hold across run" toggle from the row context menu — flips base
// `instance.seed_scope` between hold/vary without opening the editor
// (the 4-click → 2-click ask). Base-level only: when a loop edit-frame
// is active, hold applies to every frame, so we no-op here exactly as
// the RuntimeSection disables its toggle. Caller gates on isHoldable;
// re-checked here so a stray call can't stamp seed_scope on a kind
// (constraint/derivation) whose handler ignores it.
function toggleHold(idx: number): void {
if (currentFrame.value != null) return;
const target = value.value.modules[idx];
if (!target || !isHoldable(target)) return;
const held = target.instance?.seed_scope === "hold";
// Annotate so the literal doesn't widen to `string` in the object
// literal — instance.seed_scope is the "vary" | "hold" union.
const next: "vary" | "hold" = held ? "vary" : "hold";
const list = value.value.modules.map((m, i) =>
i === idx
? { ...m, instance: { ...(m.instance ?? {}), seed_scope: next } }
: m,
);
commitModules(list);
}
/** Bulk collapse/expand. Used by the section-header chevron — one
* click flips every card to the same state. Idempotent if all
* cards already match the target state (the deep watcher will
@@ -3815,7 +3910,22 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
const cleaned: ModuleEntry = { ...updated };
delete (cleaned as { _originalId?: string })._originalId;
const list = [...value.value.modules];
list[targetIdx] = cleaned;
const k = currentFrame.value;
if (k == null) {
list[targetIdx] = cleaned; // base edit — unchanged behavior
} else {
const baseModule = value.value.modules[targetIdx]; // base: payload + base instance intact
const fields = FRAME_OVERRIDABLE_FIELDS[baseModule.type] ?? [];
const delta = diffInstance(baseModule.instance, cleaned.instance, fields);
// base payload + base instance preserved; the frame's override = the instance delta (replace, not merge)
let next = clearFrameOverride(baseModule, k);
if (Object.keys(delta).length) next = setFrameOverride(next, k, delta);
list[targetIdx] = next;
}
// A base-unlock (here via the modal, or via the row toggle) makes any frame
// `locked_seed: null` override redundant — sweep it so the row stops showing
// a stale "override #k". No-op while the base stays locked.
list[targetIdx] = dropRedundantFrameLockNulls(list[targetIdx]);
commitModules(list);
if (updated._originalId && updated._originalId !== updated.id) {
nextTick(() => {
@@ -3831,6 +3941,18 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
editingIdx.value = null;
}
/** Revert the currently-edited module's frame-#k override back to the
* base instance (i.e. remove the iteration_overrides[k] entry entirely).
* Called from the frame banner's "Revert to base" button in ModuleEditModal. */
function revertEditingFrame(): void {
const idx = editingIdx.value;
const k = currentFrame.value;
if (idx == null || k == null) return;
const list = [...value.value.modules];
list[idx] = clearFrameOverride(value.value.modules[idx], k);
commitModules(list);
}
/**
* Open the BundleInstanceModal for `uid`. Snapshots the live
* BundleInstance into `bundleDraft` so the user's typing in
@@ -4034,6 +4156,26 @@ function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
const items: ContextMenuItem[] = [
{ label: "Edit", icon: "pi-pencil", onSelect: () => openEditModal(idx) },
];
// Quick Hold toggle — kept at the top so it's a 2-click reach
// (right-click → Hold) instead of opening the editor and digging into
// Runtime. Only for kinds that honor seed_scope. Hold is base-level;
// when a loop edit-frame is active it applies to every frame, so we
// surface it disabled with a hint rather than silently writing it.
if (isHoldable(m)) {
const held = isHeld(m);
const frameActive = currentFrame.value != null;
items.push({
label: held ? "Release hold" : "Hold across run",
icon: "pi-link",
disabled: frameActive,
subtitle: frameActive
? "Applies to every frame — switch to base to change"
: held
? undefined
: "Freeze this value for the whole batch",
onSelect: () => toggleHold(idx),
});
}
// Refresh + Save are mutually exclusive in normal use (a module is
// either drifted OR missing OR clean), so hiding the inactive entry
// beats greying it — matches Save's existing conditional-push pattern
@@ -4720,8 +4862,22 @@ const moduleRowCtx: ModuleRowCtx = {
pairingFor,
viaInboundFor,
contributorsFor,
currentFrame,
isHeld,
isOverriddenOnFrame,
effectiveLockedSeed,
effectiveEnabled,
frameEnableOverride,
};
provide(ModuleRowCtxKey, moduleRowCtx);
// Seed source for the edit-modal's Lock button — resolves the seed the module
// actually rolled (frame #k's captured seed when a frame is active, else the
// last-run seed) so locking pins what the user saw, not a random number.
provide(LockSeedKey, (m: ModuleEntry): number | null => {
const k = currentFrame.value;
if (k != null) return props.frameSeedReader?.(k) ?? null;
return props.lastUsedSeedReader?.(m._uid ?? m.id) ?? null;
});
const bundleFrameCtx: BundleFrameCtx = {
bundleChildDriftCount,
@@ -4986,8 +5142,10 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
:chain-modules="chainModules"
:via-option-pairs="editingModuleViaOptionPairs"
:last-used-seed-reader="lastUsedSeedReader"
:current-frame="currentFrame"
@save="saveEditedModule"
@close="editingIdx = null"
@revert-frame="revertEditingFrame"
/>
<!-- Bundle edit modal — sibling to ModuleEditModal, dispatched
@@ -590,6 +590,26 @@ describe("ModuleEditModal — derivation v2 dispatcher", () => {
});
});
// ── 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 ────────────────────────────────────
describe("ModuleEditModal — constraint v2 dispatcher", () => {
+72 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from "vue";
import ModalShell from "../shared/ModalShell.vue";
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
import { type ResolvedValue } from "../../widgets/richTokenize";
@@ -51,11 +52,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.
@@ -63,8 +68,13 @@ 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 {
@@ -382,6 +392,23 @@ function cancel() {
<template>
<ModalShell :visible="visible" @close="cancel">
<!-- 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). -->
@@ -392,6 +419,7 @@ function cancel() {
:upstream-vars="upstreamVars"
:sibling-vars="siblingVars"
:via-option-pairs="viaOptionPairs"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -404,6 +432,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"
@@ -421,6 +450,7 @@ function cancel() {
:upstream-vars="upstreamVars"
:upstream-resolved="upstreamResolved"
:sibling-vars="siblingVars"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -442,6 +472,7 @@ function cancel() {
:upstream-vars="upstreamVars"
:sibling-vars="siblingVars"
:via-option-pairs="viaOptionPairs"
:frame-active="currentFrame != null"
@update="onUpdate"
@save="save"
@cancel="cancel"
@@ -460,13 +491,14 @@ function cancel() {
: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
@@ -503,5 +535,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>
+80
View File
@@ -47,6 +47,12 @@ function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
pairingFor: () => null,
viaInboundFor: () => [],
contributorsFor: () => [],
currentFrame: ref<number | null>(null),
isHeld: () => false,
isOverriddenOnFrame: () => false,
effectiveLockedSeed: () => undefined,
effectiveEnabled: (m) => m.enabled !== false,
frameEnableOverride: () => null,
...over,
};
}
@@ -131,3 +137,77 @@ describe("ModuleRow.vue — contributor cluster", () => {
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]);
});
});
+12 -4
View File
@@ -36,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',
@@ -74,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">
@@ -149,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'"
@@ -21,8 +21,9 @@
* `runtime-seed-up`, `runtime-seed-down` — so the existing test
* suites pass without modification.
*/
import { computed } from "vue";
import { computed, inject } from "vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
import { LockSeedKey } from "../../lock-seed-ctx";
const props = withDefaults(
defineProps<{
@@ -36,6 +37,10 @@ const props = withDefaults(
);
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
// The seed the module actually rolled — provided by ContextWidget. Used as the
// default when locking so "Lock" pins what the user saw, not a fresh random.
const lockSeedFor = inject(LockSeedKey, null);
const instance = computed(() => props.module.instance ?? {});
const locked = computed(() => typeof instance.value.locked_seed === "number");
const seedValue = computed(() =>
@@ -71,7 +76,11 @@ function onLockClick(): void {
} else {
const ui = instance.value._ui ?? {};
const remembered = typeof ui.last_locked_seed === "number" ? ui.last_locked_seed : null;
const seed = remembered ?? randomSeed();
// Prefer the seed the module ACTUALLY rolled (frame #k's captured seed, or
// the last-run seed) so locking reproduces what the user saw; then the
// remembered seed; a fresh random only as a last resort.
const resolved = lockSeedFor?.(props.module);
const seed = typeof resolved === "number" ? resolved : (remembered ?? randomSeed());
emitInstance({
locked_seed: seed,
_ui: { ...ui, last_locked_seed: seed },
+19 -4
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { INSTANCE_FIELDS_PER_KIND } from "./_shell";
import { FRAME_OVERRIDABLE_FIELDS, INSTANCE_FIELDS_PER_KIND } from "./_shell";
describe("INSTANCE_FIELDS_PER_KIND registry", () => {
it("lists exactly the fields each kind exposes per spec §5.5", () => {
@@ -7,7 +7,7 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
// v2 dropped `mode` + `pinned_option_id` — see
// docs/superpowers/specs/2026-05-08-instance-overrides-v2-design.md §6.1.
"variable_binding", "enabled_options",
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
// SP2a multi-select (2026-06-08): per-instance count range + separator.
// SP2c: pick_independent (allow repeats / with replacement).
"pick_min", "pick_max", "pick_separator", "pick_independent",
@@ -16,15 +16,17 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
// `locked_seed` added in the combine + fixed_values syntax-parity
// cycle (2026-05-08): fixed_values now resolves `{a|b|c}` per
// value, so it gains seed-lock parity with wildcard + combine.
"values_overrides", "enabled_options", "locked_seed",
// `seed_scope` added for iteration-aware hold/vary control.
"values_overrides", "enabled_options", "locked_seed", "seed_scope",
]);
expect(INSTANCE_FIELDS_PER_KIND.combine).toEqual([
// v2 single-pane modal exposes per-instance overrides:
// - template_override (combine v2, this cycle)
// - variable_binding (matches wildcard's identity-section UX)
// - locked_seed (parity with wildcard + fixed_values)
// - seed_scope (iteration-aware hold/vary control)
// - internal (existing hide-from-prompt toggle)
"template_override", "variable_binding", "locked_seed", "internal",
"template_override", "variable_binding", "locked_seed", "seed_scope", "internal",
]);
expect(INSTANCE_FIELDS_PER_KIND.derivation).toEqual([
// Tier-D modal expansion (2026-05-10): per-rule + per-branch
@@ -54,3 +56,16 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
]);
});
});
describe("FRAME_OVERRIDABLE_FIELDS", () => {
const EXCLUDED = ["seed_scope", "variable_binding", "internal"] as const;
for (const kind of Object.keys(FRAME_OVERRIDABLE_FIELDS) as Array<keyof typeof FRAME_OVERRIDABLE_FIELDS>) {
it(`${kind}: excludes run-level/structural fields`, () => {
for (const ex of EXCLUDED) expect(FRAME_OVERRIDABLE_FIELDS[kind]).not.toContain(ex);
});
it(`${kind}: is a subset of INSTANCE_FIELDS_PER_KIND`, () => {
const sup = new Set(INSTANCE_FIELDS_PER_KIND[kind]);
for (const f of FRAME_OVERRIDABLE_FIELDS[kind]) expect(sup.has(f)).toBe(true);
});
}
});
+20 -4
View File
@@ -14,7 +14,7 @@ export type InstanceFieldKey =
| "variable_binding" | "enabled_options" | "option_weights" | "category_filter"
| "exclude_null"
| "pick_min" | "pick_max" | "pick_separator" | "pick_independent"
| "mode" | "pinned_option_id" | "locked_seed" | "internal" | "values_overrides"
| "mode" | "pinned_option_id" | "locked_seed" | "seed_scope" | "internal" | "values_overrides"
| "template_override"
| "disabled_rule_ids"
// Derivation tier-D modal expansion (2026-05-10 cycle)
@@ -46,13 +46,13 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
// implicit in pool state. Engine handler still reads them on
// legacy snapshots; UI never writes them.
"variable_binding", "enabled_options",
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
// SP2a multi-select: count range + join separator (per-instance, local).
// SP2c: pick_independent (allow repeats / with replacement).
"pick_min", "pick_max", "pick_separator", "pick_independent",
],
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
combine: ["template_override", "variable_binding", "locked_seed", "internal"],
fixed_values: ["values_overrides", "enabled_options", "locked_seed", "seed_scope"],
combine: ["template_override", "variable_binding", "locked_seed", "seed_scope", "internal"],
derivation: [
// Tier-D modal expansion (2026-05-10):
// - rule-group overrides (cleared by Reset btn)
@@ -83,3 +83,19 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
"target_select",
],
};
// Subset of INSTANCE_FIELDS_PER_KIND that is meaningful to override PER-FRAME.
// Excludes run-level (seed_scope), structural/reference (variable_binding), and
// internal (base-only; per-frame visibility unsupported in v1). Used only by the
// per-frame save-diff so toggling Hold/binding/Hide on a frame records no override.
export const FRAME_OVERRIDABLE_FIELDS: Record<ModuleEntryKind, readonly InstanceFieldKey[]> = {
wildcard: ["enabled_options", "option_weights", "category_filter", "exclude_null",
"locked_seed", "pick_min", "pick_max", "pick_separator", "pick_independent"],
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
combine: ["template_override", "locked_seed"],
derivation: ["disabled_rule_ids", "disabled_branch_keys", "action_value_overrides",
"condition_value_overrides", "rule_order_override", "locked_seed"],
constraint: ["disabled_exception_keys", "disabled_matrix_cells", "cell_mode_overrides",
"cell_factor_overrides", "exception_mode_overrides", "exception_factor_overrides",
"extra_exceptions", "target_select"],
};
@@ -186,6 +186,8 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
.wp-bdm__hint kbd { font: 9px var(--wp-font-mono); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); padding: 1px 4px; border-radius: 2px; color: var(--wp-text-muted, var(--wp-text2)); }
.wp-bdm__btn { padding: 5px 12px; border: 1px solid var(--wp-border); border-radius: 3px; background: transparent; color: var(--wp-text-muted, var(--wp-text2)); font: 11px var(--wp-font-sans); cursor: pointer; }
.wp-bdm__btn--primary { border-color: var(--wp-accent); background: var(--wp-accent); color: white; }
.wp-bdm__btn:hover { border-color: var(--wp-border-strong, var(--wp-border2)); color: var(--wp-text); }
.wp-bdm__btn--primary:hover { border-color: var(--wp-accent2, var(--wp-accent)); background: var(--wp-accent2, var(--wp-accent)); color: white; }
.wp-bdm__btn--quiet { border-color: transparent; color: var(--wp-text-dim, var(--wp-text3)); display: inline-flex; align-items: center; gap: 5px; font-size: 10px; }
.wp-bdm__btn--quiet:hover { border-color: var(--wp-border); color: var(--wp-text-muted, var(--wp-text2)); }
.wp-bdm__btn--quiet .pi { font-size: 10px; }
@@ -35,6 +35,9 @@ const props = withDefaults(
/** Sibling var names produced by other modules in this same Context
* node. Combined with upstreamVars to populate the dropdown. */
siblingVars?: string[];
/** When true, a frame override context is active. Run-level and
* structural controls are disabled in frame mode. */
frameActive?: boolean;
}>(),
{
isDrifted: false,
@@ -100,6 +103,7 @@ function onSpaClick(): void {
:module="module"
:upstream-vars="upstreamVars"
:sibling-vars="siblingVars"
:frame-active="frameActive"
@update="onUpdate"
/>
<TemplateSection
@@ -110,7 +114,7 @@ function onSpaClick(): void {
@update="onUpdate"
/>
<div class="wp-cbm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-cbm__foot">
<a
@@ -220,6 +224,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-cbm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-cbm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-cbm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -0,0 +1,46 @@
// R3: IdentitySection frame-active gating — combine variant.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import IdentitySection from "./IdentitySection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "cb12cd34",
type: "combine",
enabled: true,
meta: { name: "final_prompt" },
entries: [],
payload: { output_var: "final_prompt", template: "$style portrait" },
instance: {},
...overrides,
};
}
describe("combine IdentitySection — frameActive gating on variable binding", () => {
it("binding input is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(IdentitySection, { props: { module: makeModule() } });
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
});
it("binding input is NOT disabled when frameActive=false", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
});
it("binding input IS disabled when frameActive=true", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeDefined();
});
it("shows binding frame-active hint when frameActive=true", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(true);
});
it("does NOT show binding frame-active hint when frameActive=false", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(false);
});
});
@@ -21,6 +21,9 @@ const props = withDefaults(
upstreamVars?: string[];
/** Names produced by other modules in the SAME Context node. */
siblingVars?: string[];
/** When true, a frame is active — the variable binding input is
* structural (not per-frame) so it is disabled with a hint. */
frameActive?: boolean;
}>(),
{ upstreamVars: () => [], siblingVars: () => [] },
);
@@ -157,6 +160,7 @@ function onResetBinding(): void {
:value="bindingValue"
:placeholder="libraryBinding"
aria-label="Variable binding"
:disabled="frameActive || undefined"
@input="onBindingInput"
/>
</div>
@@ -171,6 +175,11 @@ function onResetBinding(): void {
><i class="pi pi-replay" aria-hidden="true" /></button>
</div>
</div>
<p
v-if="frameActive"
class="id__frame-hint"
data-test="id-binding-frame-hint"
>Applies to every frame — switch to base to change.</p>
<div
v-if="collidesWith !== null"
class="id__collision"
@@ -303,4 +312,9 @@ function onResetBinding(): void {
border: 1px solid color-mix(in srgb, var(--wp-accent) 35%, transparent);
}
.id__collision .pi { font-size: 11px; margin-top: 1px; flex-shrink: 0; }
.id__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -0,0 +1,61 @@
// R3: RuntimeSection frame-active gating — combine variant.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import RuntimeSection from "./RuntimeSection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "cb12cd34",
type: "combine",
enabled: true,
meta: { name: "final_prompt" },
entries: [],
payload: { output_var: "final_prompt", template: "$style portrait" },
instance: {},
...overrides,
};
}
describe("combine RuntimeSection — frameActive gating", () => {
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
});
it("hold toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
});
it("shows frame-active hint when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
});
it("does NOT show frame-active hint when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
});
});
@@ -112,4 +112,21 @@ describe("combine RuntimeSection", () => {
await w.find('[data-test="runtime-hide"]').trigger("click");
expect(lastPatch(w).instance?.internal).toBe(true);
});
it("seed-scope toggle is off (vary) by default", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
});
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
});
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
});
});
@@ -12,7 +12,7 @@ import { computed } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import SeedLockControls from "../../_shared/SeedLockControls.vue";
const props = defineProps<{ module: ModuleEntry }>();
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
const instance = computed(() => props.module.instance ?? {});
@@ -21,6 +21,11 @@ const internal = computed(() => instance.value.internal === true);
function onHideClick(): void {
emit("update", { instance: { ...instance.value, internal: !internal.value } });
}
const held = computed(() => instance.value.seed_scope === "hold");
function onHoldClick(): void {
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
}
</script>
<template>
@@ -34,11 +39,30 @@ function onHideClick(): void {
data-test="runtime-hide"
role="switch"
:aria-checked="internal"
:disabled="frameActive || undefined"
@click="onHideClick"
>
<i class="pi pi-eye-slash" aria-hidden="true" />
Hide from prompt
</button>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': held }"
data-test="runtime-hold"
role="switch"
:aria-checked="held"
:disabled="frameActive || undefined"
@click="onHoldClick"
>
<i class="pi pi-link" aria-hidden="true" />
Hold across run
</button>
<p
v-if="frameActive"
class="runtime__frame-hint"
data-test="runtime-frame-hint"
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
</section>
</template>
@@ -78,4 +102,13 @@ function onHideClick(): void {
background: rgba(99, 102, 241, 0.10);
}
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.runtime__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -65,6 +65,11 @@ const props = withDefaults(
* Optional — undefined in headless/legacy mounts falls back to
* `siblingModules` alone. */
chainModules?: ChainModule[];
/** When true, a frame override context is active. Accepted for
* interface uniformity; constraint has no RuntimeSection and its
* IdentitySection carries no variable-binding input, so nothing
* is forwarded to a child here. */
frameActive?: boolean;
}>(),
{ isDrifted: false, isModified: false, siblingModules: () => [] },
);
@@ -847,6 +852,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-cnm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-cnm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-cnm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -42,6 +42,9 @@ const props = withDefaults(
* engine branch key (`${rule_id}:${bi}` / `${rule_id}:else`). Forwarded
* verbatim to RulesSection, which renders the inline `↪#N` badge. */
viaOptionPairs?: Map<string, readonly PairingBadge[]>;
/** When true, a frame override context is active. Run-level controls
* are disabled in frame mode. */
frameActive?: boolean;
}>(),
{
isDrifted: false,
@@ -161,7 +164,7 @@ function onSpaClick(): void {
@update="onUpdate"
/>
<div class="wp-dvm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-dvm__foot">
<a
@@ -213,7 +216,17 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow-y: auto;
/* The MODAL does not scroll — only the RULES list inside does (it caps at
* an absolute max-height and scrolls in place). overflow:hidden here forces
* that: it's the only overflow path, so header + identity stay pinned at
* the top and the dock at the bottom while the rules scroll between them.
* (overflow-y:auto on the modal instead let the WHOLE modal scroll, sliding
* the bottom rules under the sticky dock — the "can't see the full rules"
* bug. An absolute inner max-height — not flex:1, which collapses because
* the overlay parents carry only max-height:100% — is the load-bearing
* part; RulesSection sizes it to 80vh minus this fixed chrome so nothing
* clips.) */
overflow: hidden;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
@@ -225,6 +238,7 @@ function onSpaClick(): void {
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
flex-shrink: 0;
}
.wp-dvm__foot {
display: flex;
@@ -270,6 +284,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-dvm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-dvm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-dvm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -693,10 +693,34 @@ function opUsesValue(op: string | undefined): boolean {
display: flex;
flex-direction: column;
gap: 6px;
/* Long rule lists scroll in place so identity / footer stay visible. */
max-height: 44vh;
/* Long rule lists scroll IN PLACE so identity / runtime / footer stay put.
* Absolute max-height (NOT flex:1) — same load-bearing trick as the
* wildcard's .pool__opts — because the modal's overlay parents carry only
* max-height:100% (no definite height), so a flex-fill child collapses or
* clips here. The cap = the modal budget (80vh, matching .dvm) MINUS the
* fixed chrome (header + identity + rules head + runtime/footer dock,
* measured ≈ 15rem, padded to 20rem so the dock never clips under .dvm's
* overflow:hidden). list + chrome stays under 80vh, so the modal never
* scrolls and THIS list owns the only scrollbar. */
max-height: calc(80vh - 20rem);
overflow-y: auto;
/* Keep the scrollbar CLEARLY visible — long rule lists weren't obviously
* scrollable before, so users thought rules were truncated. Stronger than
* the app's subtle token default on purpose. */
scrollbar-width: auto;
scrollbar-color: rgba(255, 255, 255, 0.38) transparent;
}
.rules__list::-webkit-scrollbar { width: 12px; }
.rules__list::-webkit-scrollbar-track {
background: var(--wp-bg-deep, var(--wp-bg));
border-radius: 6px;
}
.rules__list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.38);
border-radius: 6px;
border: 2px solid var(--wp-bg-deep, var(--wp-bg));
}
.rules__list::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.55); }
/* Rule card */
.rule-card {
@@ -704,6 +728,13 @@ function opUsesValue(op: string | undefined): boolean {
border: 1px solid var(--wp-border);
border-radius: 3px;
overflow: hidden;
/* THE load-bearing fix for "rules clipped, no scrollbar": the cards are
* flex children of the flex-column .rules__list, so by default they SHRINK
* to fit its max-height and `overflow:hidden` then clips their branches —
* the list never overflows, so it never scrolls and no scrollbar appears.
* Pinning flex-shrink:0 keeps each card at full height; the list then truly
* overflows its max-height and scrolls (with its scrollbar). */
flex-shrink: 0;
}
.rule-card--off { opacity: 0.55; }
.rule-card--dragging { opacity: 0.5; }
@@ -0,0 +1,48 @@
// R3: RuntimeSection frame-active gating — derivation variant.
// Derivation RuntimeSection only has runtime-hide (no runtime-hold),
// so only the Hide from prompt toggle needs disabling.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import RuntimeSection from "./RuntimeSection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "dv012345",
type: "derivation",
enabled: true,
meta: { name: "mood-rules" },
entries: [],
payload: { rules: [] },
instance: {},
...overrides,
};
}
describe("derivation RuntimeSection — frameActive gating", () => {
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
});
it("shows frame-active hint when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
});
it("does NOT show frame-active hint when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
});
});
@@ -36,6 +36,33 @@ describe("derivation RuntimeSection", () => {
expect(w.find('[data-test="runtime-hide"]').exists()).toBe(true);
});
it("renders the Hold across run toggle, off by default", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
const hold = w.find('[data-test="runtime-hold"]');
expect(hold.exists()).toBe(true);
expect(hold.text()).toContain("Hold across run");
expect(hold.classes()).not.toContain("toggle--on");
});
it("Hold toggle reflects seed_scope=hold as on", () => {
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
expect(w.find('[data-test="runtime-hold"]').classes()).toContain("toggle--on");
});
it("clicking Hold emits seed_scope = hold; clicking when held emits vary", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
const held = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
await held.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(held).instance?.seed_scope).toBe("vary");
});
it("Hold toggle is disabled while a loop edit-frame is active", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
});
it("seed input hidden when not locked", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-seed"]').exists()).toBe(false);
@@ -1,16 +1,18 @@
<script setup lang="ts">
/**
* Derivation RuntimeSection — composes the shared `<SeedLockControls />`
* (default "Lock seed" label) plus the Hide-from-prompt toggle.
* Mirrors combine's RuntimeSection 1:1 because the surface semantics
* are the same: derivation's `action.value` resolves `{a|b|c}`
* alternations, so locking the seed pins each pick across queues.
* (default "Lock seed" label) plus the Hide-from-prompt and
* Hold-across-run toggles. Mirrors combine's RuntimeSection 1:1 because
* the surface semantics are the same: derivation's `action.value`
* resolves `{a|b|c}` alternations (+ nested `@{}` refs), so locking the
* seed pins each pick across queues and Hold freezes the resolved output
* across a loop run.
*/
import { computed } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import SeedLockControls from "../../_shared/SeedLockControls.vue";
const props = defineProps<{ module: ModuleEntry }>();
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
const instance = computed(() => props.module.instance ?? {});
@@ -19,6 +21,11 @@ const internal = computed(() => instance.value.internal === true);
function onHideClick(): void {
emit("update", { instance: { ...instance.value, internal: !internal.value } });
}
const held = computed(() => instance.value.seed_scope === "hold");
function onHoldClick(): void {
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
}
</script>
<template>
@@ -32,11 +39,30 @@ function onHideClick(): void {
data-test="runtime-hide"
role="switch"
:aria-checked="internal"
:disabled="frameActive || undefined"
@click="onHideClick"
>
<i class="pi pi-eye-slash" aria-hidden="true" />
Hide from prompt
</button>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': held }"
data-test="runtime-hold"
role="switch"
:aria-checked="held"
:disabled="frameActive || undefined"
@click="onHoldClick"
>
<i class="pi pi-link" aria-hidden="true" />
Hold across run
</button>
<p
v-if="frameActive"
class="runtime__frame-hint"
data-test="runtime-frame-hint"
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
</section>
</template>
@@ -76,4 +102,13 @@ function onHideClick(): void {
background: rgba(99, 102, 241, 0.10);
}
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.runtime__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -12,6 +12,9 @@ const props = withDefaults(
/** True when draft has unsaved instance edits. Gates "Save to library"
* visibility — pushing an unmodified payload back is a no-op. */
isModified?: boolean;
/** When true, a frame override context is active. Run-level controls
* are disabled in frame mode. */
frameActive?: boolean;
}>(),
{ isDrifted: false, isModified: false },
);
@@ -67,7 +70,7 @@ function onSpaClick(): void {
<IdentitySection :module="module" @update="onUpdate" />
<ValuesSection :module="module" @update="onUpdate" />
<div class="wp-fvm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-fvm__foot">
<a
@@ -177,6 +180,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-fvm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-fvm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-fvm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -0,0 +1,61 @@
// R3: RuntimeSection frame-active gating — fixed-values variant.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import RuntimeSection from "./RuntimeSection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "fv012345",
type: "fixed_values",
enabled: true,
meta: { name: "presets" },
entries: [],
payload: { values: [] },
instance: {},
...overrides,
};
}
describe("fixed-values RuntimeSection — frameActive gating", () => {
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
});
it("hide toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
});
it("hold toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
});
it("shows frame-active hint when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
});
it("does NOT show frame-active hint when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
});
});
@@ -16,6 +16,11 @@ function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
};
}
function lastPatch(w: ReturnType<typeof mount>): Partial<ModuleEntry> {
const updates = w.emitted("update")! as unknown[][];
return updates[updates.length - 1][0] as Partial<ModuleEntry>;
}
describe("fixed-values RuntimeSection", () => {
it("renders Hide-from-prompt toggle", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
@@ -80,4 +85,21 @@ describe("fixed-values RuntimeSection", () => {
});
expect(on.find('[data-test="runtime-seed"]').exists()).toBe(true);
});
it("seed-scope toggle is off (vary) by default", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
});
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
});
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
});
});
@@ -1,18 +1,13 @@
<script setup lang="ts">
/**
* Combine RuntimeSection — composes the shared `<SeedLockControls />`
* (default "Lock seed" label; combine pins inline `{a|b|c}`
* alternation resolution, not an option pick) plus the
* Hide-from-prompt toggle.
*
* Slimmed during the 2026-05-09 extraction — previously held a
* verbatim copy of wildcard's lock UI.
* FixedValues RuntimeSection — composes the shared `<SeedLockControls />`
* plus the Hide-from-prompt and Hold-across-run toggles.
*/
import { computed } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import SeedLockControls from "../../_shared/SeedLockControls.vue";
const props = defineProps<{ module: ModuleEntry }>();
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
const instance = computed(() => props.module.instance ?? {});
@@ -21,6 +16,11 @@ const internal = computed(() => instance.value.internal === true);
function onHideClick(): void {
emit("update", { instance: { ...instance.value, internal: !internal.value } });
}
const held = computed(() => instance.value.seed_scope === "hold");
function onHoldClick(): void {
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
}
</script>
<template>
@@ -34,11 +34,30 @@ function onHideClick(): void {
data-test="runtime-hide"
role="switch"
:aria-checked="internal"
:disabled="frameActive || undefined"
@click="onHideClick"
>
<i class="pi pi-eye-slash" aria-hidden="true" />
Hide from prompt
</button>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': held }"
data-test="runtime-hold"
role="switch"
:aria-checked="held"
:disabled="frameActive || undefined"
@click="onHoldClick"
>
<i class="pi pi-link" aria-hidden="true" />
Hold across run
</button>
<p
v-if="frameActive"
class="runtime__frame-hint"
data-test="runtime-frame-hint"
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
</section>
</template>
@@ -78,4 +97,13 @@ function onHideClick(): void {
background: rgba(99, 102, 241, 0.10);
}
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.runtime__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -22,6 +22,9 @@ const props = withDefaults(
* (one of its options contains a nested `@{uuid}` that resolves to a
* downstream constraint's target). Keyed by option id. */
viaOptionPairs?: Map<string, PairingBadge[]>;
/** When true, a frame override context is active. Run-level and
* structural controls are disabled in frame mode. */
frameActive?: boolean;
}>(),
{ isDrifted: false, isModified: false, upstreamVars: () => [], siblingVars: () => [], viaOptionPairs: () => new Map() },
);
@@ -98,11 +101,12 @@ function onSpaClick(): void {
:module="module"
:upstream-vars="upstreamVars"
:sibling-vars="siblingVars"
:frame-active="frameActive"
@update="onUpdate"
/>
<PoolSection :module="module" :via-option-pairs="viaOptionPairs" @update="onUpdate" />
<div class="wp-wcm__dock">
<RuntimeSection :module="module" @update="onUpdate" />
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-wcm__foot">
<a
@@ -223,6 +227,15 @@ function onSpaClick(): void {
background: var(--wp-accent);
color: white;
}
.wp-wcm__btn:hover {
border-color: var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text);
}
.wp-wcm__btn--primary:hover {
border-color: var(--wp-accent2, var(--wp-accent));
background: var(--wp-accent2, var(--wp-accent));
color: white;
}
.wp-wcm__btn--quiet {
border-color: transparent;
color: var(--wp-text-dim, var(--wp-text3));
@@ -0,0 +1,52 @@
// R3: IdentitySection frame-active gating — wildcard variant.
// variable_binding is a structural field that is meaningless to change
// on a per-frame basis, so the binding input must be disabled when
// frameActive=true.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import IdentitySection from "./IdentitySection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "ab12cd34",
type: "wildcard",
enabled: true,
meta: { name: "outfit" },
entries: [],
payload: { var_binding: "outfit", options: [] },
instance: {},
...overrides,
};
}
describe("wildcard IdentitySection — frameActive gating on variable binding", () => {
it("binding input is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(IdentitySection, { props: { module: makeModule() } });
const input = w.find('[data-test="id-binding"]');
expect(input.attributes("disabled")).toBeUndefined();
});
it("binding input is NOT disabled when frameActive=false", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
const input = w.find('[data-test="id-binding"]');
expect(input.attributes("disabled")).toBeUndefined();
});
it("binding input IS disabled when frameActive=true", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
const input = w.find('[data-test="id-binding"]');
expect(input.attributes("disabled")).toBeDefined();
});
it("shows binding frame-active hint when frameActive=true", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(true);
});
it("does NOT show binding frame-active hint when frameActive=false", () => {
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(false);
});
});
@@ -13,6 +13,9 @@ const props = withDefaults(
upstreamVars?: string[];
/** Names produced by other modules in the SAME Context node. */
siblingVars?: string[];
/** When true, a frame is active — the variable binding input is
* structural (not per-frame) so it is disabled with a hint. */
frameActive?: boolean;
}>(),
{ upstreamVars: () => [], siblingVars: () => [] },
);
@@ -162,6 +165,7 @@ function onResetBinding(): void {
:value="bindingValue"
:placeholder="libraryBinding"
aria-label="Variable binding"
:disabled="frameActive || undefined"
@input="onBindingInput"
/>
</div>
@@ -176,6 +180,11 @@ function onResetBinding(): void {
><i class="pi pi-replay" aria-hidden="true" /></button>
</div>
</div>
<p
v-if="frameActive"
class="id__frame-hint"
data-test="id-binding-frame-hint"
>Applies to every frame — switch to base to change.</p>
<div
v-if="collidesWith !== null"
class="id__collision"
@@ -287,4 +296,9 @@ function onResetBinding(): void {
border: 1px solid color-mix(in srgb, var(--wp-accent) 35%, transparent);
}
.id__collision .pi { font-size: 11px; margin-top: 1px; flex-shrink: 0; }
.id__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -0,0 +1,73 @@
// R3: RuntimeSection frame-active gating — wildcard variant.
// Hide from prompt + Hold across run are run-level controls that apply
// to every frame, so they must be disabled (not interactive) when the
// user is editing a per-frame override context. Tests assert:
// - controls get `disabled` attribute when frameActive=true
// - controls are NOT disabled when frameActive is absent/false
// - a hint message is shown in frame-active mode
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import RuntimeSection from "./RuntimeSection.vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "ab12cd34",
type: "wildcard",
enabled: true,
meta: { name: "outfit" },
entries: [],
payload: { var_binding: "outfit", options: [] },
instance: {},
...overrides,
};
}
describe("wildcard RuntimeSection — frameActive gating", () => {
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
const btn = w.find('[data-test="runtime-hide"]');
expect(btn.attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
const btn = w.find('[data-test="runtime-hold"]');
expect(btn.attributes("disabled")).toBeUndefined();
});
it("hide toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
const btn = w.find('[data-test="runtime-hide"]');
expect(btn.attributes("disabled")).toBeUndefined();
});
it("hold toggle is NOT disabled when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
const btn = w.find('[data-test="runtime-hold"]');
expect(btn.attributes("disabled")).toBeUndefined();
});
it("hide toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
const btn = w.find('[data-test="runtime-hide"]');
expect(btn.attributes("disabled")).toBeDefined();
});
it("hold toggle IS disabled when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
const btn = w.find('[data-test="runtime-hold"]');
expect(btn.attributes("disabled")).toBeDefined();
});
it("shows frame-active hint when frameActive=true", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
});
it("does NOT show frame-active hint when frameActive=false", () => {
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
});
});
@@ -176,4 +176,21 @@ describe("RuntimeSection", () => {
expect(seed).toBeGreaterThanOrEqual(0);
expect(seed).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER);
});
it("seed-scope toggle is off (vary) by default", () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
});
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule() } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
});
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
await w.find('[data-test="runtime-hold"]').trigger("click");
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
});
});
@@ -11,7 +11,7 @@ import { computed } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import SeedLockControls from "../../_shared/SeedLockControls.vue";
const props = defineProps<{ module: ModuleEntry }>();
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
const instance = computed(() => props.module.instance ?? {});
@@ -20,6 +20,11 @@ const internal = computed(() => instance.value.internal === true);
function onHideClick(): void {
emit("update", { instance: { ...instance.value, internal: !internal.value } });
}
const held = computed(() => instance.value.seed_scope === "hold");
function onHoldClick(): void {
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
}
</script>
<template>
@@ -33,11 +38,30 @@ function onHideClick(): void {
data-test="runtime-hide"
role="switch"
:aria-checked="internal"
:disabled="frameActive || undefined"
@click="onHideClick"
>
<i class="pi pi-eye-slash" aria-hidden="true" />
Hide from prompt
</button>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': held }"
data-test="runtime-hold"
role="switch"
:aria-checked="held"
:disabled="frameActive || undefined"
@click="onHoldClick"
>
<i class="pi pi-link" aria-hidden="true" />
Hold across run
</button>
<p
v-if="frameActive"
class="runtime__frame-hint"
data-test="runtime-frame-hint"
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
</section>
</template>
@@ -77,4 +101,13 @@ function onHideClick(): void {
background: rgba(99, 102, 241, 0.10);
}
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
.toggle:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.runtime__frame-hint {
margin: 4px 0 0;
font: 10px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
</style>
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled, frameEnableOverride } from "./frame-overrides";
import type { ModuleEntry } from "../../widgets/_shared";
function mod(extra: Partial<ModuleEntry> = {}): ModuleEntry {
return { id: "m1", type: "wildcard", enabled: true, meta: { name: "x" }, entries: [], instance: {}, ...extra };
}
describe("frame-overrides", () => {
it("withFrameInstance overlays the frame patch onto the base instance", () => {
const m = mod({
instance: { variable_binding: "x" },
iteration_overrides: { "2": { pinned_option_id: "0", mode: "pinned" } },
});
const r = withFrameInstance(m, 2);
expect(r.instance).toEqual({ variable_binding: "x", pinned_option_id: "0", mode: "pinned" });
expect(withFrameInstance(m, null)).toBe(m);
expect(withFrameInstance(m, 1)).toBe(m);
});
it("diffInstance captures only changed declared fields", () => {
const base = { variable_binding: "x", pinned_option_id: null };
const edited = { variable_binding: "x", pinned_option_id: "0", mode: "pinned" };
expect(diffInstance(base, edited, ["pinned_option_id", "mode", "variable_binding"]))
.toEqual({ pinned_option_id: "0", mode: "pinned" });
});
it("setFrameOverride writes merged and clearFrameOverride removes", () => {
const m = mod();
const a = setFrameOverride(m, 2, { pinned_option_id: "0" });
expect(a.iteration_overrides).toEqual({ "2": { pinned_option_id: "0" } });
const b = clearFrameOverride(a, 2);
expect(b.iteration_overrides).toBeUndefined();
});
it("toggleFrameLock: locks an unlocked frame with the fallback", () => {
const m = mod();
const r = toggleFrameLock(m, 2, 999);
expect(r.iteration_overrides?.["2"]).toEqual({ locked_seed: 999 });
});
it("toggleFrameLock: unlocking a frame-only lock drops the key", () => {
const m = mod({ iteration_overrides: { "2": { locked_seed: 999 } } });
const r = toggleFrameLock(m, 2, 0);
expect(r.iteration_overrides).toBeUndefined();
});
it("toggleFrameLock: unlocking a frame writes null when base is locked", () => {
const m = mod({ instance: { locked_seed: 5 } });
const r = toggleFrameLock(m, 2, 0);
expect(r.iteration_overrides?.["2"]).toEqual({ locked_seed: null });
});
// ── per-frame enable override (frame_enabled) ──────────────────────────
it("effectiveEnabled: frame override wins, else base", () => {
expect(effectiveEnabled(mod({ enabled: true }), null)).toBe(true);
expect(effectiveEnabled(mod({ enabled: true }), 2)).toBe(true); // no override → base
const off = mod({ enabled: true, frame_enabled: { "2": false } });
expect(effectiveEnabled(off, 2)).toBe(false);
expect(effectiveEnabled(off, 1)).toBe(true);
const on = mod({ enabled: false, frame_enabled: { "2": true } });
expect(effectiveEnabled(on, 2)).toBe(true); // base off, frame on
expect(effectiveEnabled(on, 1)).toBe(false); // base off elsewhere
expect(effectiveEnabled(on, null)).toBe(false); // base view
});
it("effectiveEnabled: folds legacy disabled_frames", () => {
const m = mod({ enabled: true, disabled_frames: [2] });
expect(effectiveEnabled(m, 2)).toBe(false);
expect(effectiveEnabled(m, 0)).toBe(true);
});
it("frameEnableOverride: reports on / off / null vs base", () => {
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": false } }), 2)).toBe("off");
expect(frameEnableOverride(mod({ enabled: false, frame_enabled: { "2": true } }), 2)).toBe("on");
expect(frameEnableOverride(mod({ enabled: true }), 2)).toBeNull();
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": true } }), 2)).toBeNull(); // equals base
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": false } }), null)).toBeNull();
});
it("toggleFrameEnabled: a base-OFF module turns ON for the frame (the bug fix)", () => {
const r = toggleFrameEnabled(mod({ enabled: false }), 2);
expect(r.frame_enabled).toEqual({ "2": true });
expect(r.enabled).toBe(false); // base untouched
});
it("toggleFrameEnabled: a base-ON module turns OFF for the frame", () => {
const r = toggleFrameEnabled(mod({ enabled: true }), 2);
expect(r.frame_enabled).toEqual({ "2": false });
});
it("toggleFrameEnabled: toggling back to base drops the override + the field", () => {
const r = toggleFrameEnabled(mod({ enabled: true, frame_enabled: { "2": false } }), 2);
expect(r.frame_enabled).toBeUndefined();
});
it("toggleFrameEnabled: migrates legacy disabled_frames into frame_enabled", () => {
const r = toggleFrameEnabled(mod({ enabled: true, disabled_frames: [1] }), 2);
expect(r.disabled_frames).toBeUndefined();
expect(r.frame_enabled).toEqual({ "1": false, "2": false });
});
it("toggleFrameEnabled: recovers the user's stuck state (base off, stray disabled_frames)", () => {
// Repro of the QA bug: base disabled with a leftover blocklist entry;
// enabling frame #2 should run ONLY #2 and clear the noise.
const r = toggleFrameEnabled(mod({ enabled: false, disabled_frames: [1] }), 2);
expect(r.disabled_frames).toBeUndefined();
expect(r.frame_enabled).toEqual({ "2": true });
});
// ── dropRedundantFrameLockNulls ────────────────────────────────────────
it("dropRedundantFrameLockNulls: clears a frame locked_seed:null once base is unlocked", () => {
// Repro of the QA bug: lock #4, lock base, unlock #4 (writes null override),
// unlock base → the null override is now redundant and must be swept.
const m = mod({ instance: {}, iteration_overrides: { "3": { locked_seed: null } } });
expect(dropRedundantFrameLockNulls(m).iteration_overrides).toBeUndefined();
});
it("dropRedundantFrameLockNulls: KEEPS the null override while base is locked", () => {
const m = mod({ instance: { locked_seed: 5 }, iteration_overrides: { "3": { locked_seed: null } } });
expect(dropRedundantFrameLockNulls(m).iteration_overrides).toEqual({ "3": { locked_seed: null } });
});
it("dropRedundantFrameLockNulls: keeps a frame's real lock + other override fields", () => {
const m = mod({
instance: {},
iteration_overrides: { "1": { locked_seed: 99 }, "2": { locked_seed: null, pinned_option_id: "0" } },
});
expect(dropRedundantFrameLockNulls(m).iteration_overrides)
.toEqual({ "1": { locked_seed: 99 }, "2": { pinned_option_id: "0" } });
});
});
+158
View File
@@ -0,0 +1,158 @@
import type { ModuleEntry } from "../../widgets/_shared";
type Instance = NonNullable<ModuleEntry["instance"]>;
type IterationOverrides = NonNullable<ModuleEntry["iteration_overrides"]>;
/** Effective module at frame `k`: base instance overlaid with the frame's
* override patch. Returns the same reference when there's nothing to overlay. */
export function withFrameInstance(module: ModuleEntry, frame: number | null): ModuleEntry {
if (frame == null) return module;
const patch = module.iteration_overrides?.[String(frame)];
if (!patch) return module;
return { ...module, instance: { ...(module.instance ?? {}), ...patch } };
}
/** Changed override fields between base and edited instances, scoped to `fields`.
* Per-field JSON compare (values are small + JSON-able). Accepts loose
* Record type so callers can pass plain object literals without casting. */
export function diffInstance(
base: Record<string, unknown> | Instance | undefined,
edited: Record<string, unknown> | Instance | undefined,
fields: readonly string[],
): Record<string, unknown> {
const b = (base ?? {}) as Record<string, unknown>;
const e = (edited ?? {}) as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const f of fields) {
if (JSON.stringify(b[f]) !== JSON.stringify(e[f])) out[f] = e[f];
}
return out;
}
/** Merge `patch` into the module's frame override (clone). Empty result drops
* the frame key; empty map drops `iteration_overrides` entirely. */
export function setFrameOverride(
module: ModuleEntry, frame: number, patch: Record<string, unknown>,
): ModuleEntry {
const map: IterationOverrides = { ...(module.iteration_overrides ?? {}) };
const key = String(frame);
const merged = { ...(map[key] ?? {}), ...patch } as Partial<Instance>;
if (Object.keys(merged).length === 0) {
delete map[key];
} else {
map[key] = merged;
}
const out: ModuleEntry = { ...module, iteration_overrides: map };
if (Object.keys(map).length === 0) delete out.iteration_overrides;
return out;
}
/** Remove a frame's override entirely (clone). */
export function clearFrameOverride(module: ModuleEntry, frame: number): ModuleEntry {
if (!module.iteration_overrides) return module;
const map: IterationOverrides = { ...module.iteration_overrides };
delete map[String(frame)];
const out: ModuleEntry = { ...module, iteration_overrides: map };
if (Object.keys(map).length === 0) delete out.iteration_overrides;
return out;
}
/** Toggle locked_seed for ONE frame (the row quick-lock when a frame is active).
* Locks with `fallback` if currently unlocked at that frame; otherwise unlocks —
* writing an explicit `null` when the BASE is locked (to override it on this frame)
* or dropping the frame's locked_seed when the base is already unlocked. */
export function toggleFrameLock(module: ModuleEntry, frame: number, fallback: number): ModuleEntry {
const eff = withFrameInstance(module, frame).instance ?? {};
const baseLocked = typeof module.instance?.locked_seed === "number";
if (typeof eff.locked_seed === "number") {
if (baseLocked) return setFrameOverride(module, frame, { locked_seed: null });
const rest = { ...(module.iteration_overrides?.[String(frame)] ?? {}) } as Record<string, unknown>;
delete rest.locked_seed;
let next = clearFrameOverride(module, frame);
if (Object.keys(rest).length) next = setFrameOverride(next, frame, rest);
return next;
}
return setFrameOverride(module, frame, { locked_seed: fallback });
}
/** Per-frame enabled state at frame `k`: the explicit `frame_enabled[k]`
* override if present, else the legacy `disabled_frames` blocklist folded in
* (a listed frame reads as `false`), else `undefined` — meaning "no override,
* use base `enabled`". */
export function frameEnabledAt(module: ModuleEntry, k: number): boolean | undefined {
const fe = module.frame_enabled?.[String(k)];
if (typeof fe === "boolean") return fe;
if ((module.disabled_frames ?? []).includes(k)) return false;
return undefined;
}
/** Effective enabled state in the current view: `frame_enabled[k] ?? base
* enabled` when a frame is active, base `enabled` otherwise. */
export function effectiveEnabled(module: ModuleEntry, frame: number | null): boolean {
const base = module.enabled !== false;
if (frame == null) return base;
return frameEnabledAt(module, frame) ?? base;
}
/** Per-frame enable override vs base at `frame`: "on" (forced on while base
* off), "off" (forced off while base on), or null (no frame active / no
* override / equals base). Drives the on-#k / off-#k row badge. */
export function frameEnableOverride(
module: ModuleEntry, frame: number | null,
): "on" | "off" | null {
if (frame == null) return null;
const fe = frameEnabledAt(module, frame);
if (fe == null) return null;
const base = module.enabled !== false;
if (fe === base) return null;
return fe ? "on" : "off";
}
/** Toggle a module's per-frame enable state for ONE frame (the row checkbox when
* a frame is active). Sets `frame_enabled[frame]` to the opposite of the current
* effective state — symmetric, so a base-off module turns ON for the frame and a
* base-on module turns OFF. Migrates any legacy `disabled_frames` into the map,
* then drops entries that match base so only real overrides persist. Base
* `enabled` is never touched. */
export function toggleFrameEnabled(module: ModuleEntry, frame: number): ModuleEntry {
const base = module.enabled !== false;
const next = !(frameEnabledAt(module, frame) ?? base);
const map: Record<string, boolean> = { ...(module.frame_enabled ?? {}) };
for (const df of module.disabled_frames ?? []) map[String(df)] = false;
map[String(frame)] = next;
for (const key of Object.keys(map)) {
if (map[key] === base) delete map[key];
}
const out: ModuleEntry = { ...module };
delete out.disabled_frames;
if (Object.keys(map).length === 0) delete out.frame_enabled;
else out.frame_enabled = map;
return out;
}
/** Drop redundant frame `locked_seed: null` overrides. A frame's
* `locked_seed: null` exists ONLY to force that frame UNLOCKED over a BASE
* lock; once the base is itself unlocked it's meaningless, so it's swept (and
* the frame's override entry removed when nothing else remains). No-op while
* the base stays locked. Without this, unlocking base after unlocking a frame
* leaves a stale entry so the row keeps showing "override #k". */
export function dropRedundantFrameLockNulls(module: ModuleEntry): ModuleEntry {
const ov = module.iteration_overrides;
if (!ov || typeof module.instance?.locked_seed === "number") return module;
const next: IterationOverrides = {};
let changed = false;
for (const [k, patch] of Object.entries(ov)) {
if (patch && (patch as Record<string, unknown>).locked_seed === null) {
const rest = { ...patch } as Record<string, unknown>;
delete rest.locked_seed;
changed = true;
if (Object.keys(rest).length) next[k] = rest as Partial<Instance>;
} else {
next[k] = patch;
}
}
if (!changed) return module;
const out: ModuleEntry = { ...module, iteration_overrides: next };
if (Object.keys(next).length === 0) delete out.iteration_overrides;
return out;
}
+15
View File
@@ -0,0 +1,15 @@
import type { InjectionKey } from "vue";
import type { ModuleEntry } from "../../widgets/_shared";
/**
* Resolves the seed to pin when the user locks a module's seed — the seed the
* module ACTUALLY rolled: frame #k's captured seed when an iteration frame is
* active, else the module's last-run seed. Provided by ContextWidget (which
* owns the frame cursor + the run-seed readers) and injected by
* SeedLockControls, which lives deep inside the per-kind edit modal. Returns
* null when unavailable (cold start, no run yet) → the control falls back to
* its remembered seed, then a fresh random.
*/
export type LockSeedResolver = (module: ModuleEntry) => number | null;
export const LockSeedKey: InjectionKey<LockSeedResolver> = Symbol("wpLockSeed");
+19
View File
@@ -53,6 +53,25 @@ export interface ModuleRowCtx {
* single `↥×N` chip. Empty on rows no constraint covers (every
* constraint / source / non-target row). */
contributorsFor: (id: string) => PairingBadge[];
/** Reactive cursor: the frame currently open in the override editor,
* or `null` when no frame is active (base view). */
currentFrame: Ref<number | null>;
/** True when the module's seed is held constant across all loop
* iterations (`instance.seed_scope === "hold"`). */
isHeld: (m: ModuleEntry) => boolean;
/** True when the module has an override entry for `currentFrame`. */
isOverriddenOnFrame: (m: ModuleEntry) => boolean;
/** The effective locked_seed for this module at `currentFrame` (or base when
* no frame is active). `undefined` when the effective instance is unlocked. */
effectiveLockedSeed: (m: ModuleEntry) => number | undefined;
/** Effective enabled state for the current view: `frame_enabled[k] ?? base
* enabled` when a frame is active, base `enabled` otherwise. Drives the row
* checkbox + the disabled (hatched) row styling. */
effectiveEnabled: (m: ModuleEntry) => boolean;
/** Per-frame enable override vs base at `currentFrame`: "on" (forced on while
* base off), "off" (forced off while base on), or null (no frame active / no
* override / equals base). Drives the on-#k / off-#k row badge. */
frameEnableOverride: (m: ModuleEntry) => "on" | "off" | null;
}
export const ModuleRowCtxKey: InjectionKey<ModuleRowCtx> = Symbol("moduleRowCtx");
@@ -622,7 +622,13 @@ function onKeydown(ev: KeyboardEvent): void {
background: color-mix(in srgb, var(--wp-text3) 18%, transparent);
color: var(--wp-text2);
flex-shrink: 0;
/* Right-align the type pill even on rows WITHOUT a label (the label
* carries the `margin-left:auto` for labelled rows). When a label IS
* present it precedes the type, so reset the auto there and let the
* flex gap space them — keeping label + type grouped at the right. */
margin-left: auto;
}
.ibm__menu-label + .ibm__menu-type { margin-left: 0; }
/* Reset button styling shared via _modal-template-ctrls.css. */
@@ -54,6 +54,41 @@ describe("InjectorWidget — lifecycle", () => {
});
});
describe("InjectorWidget — durable custom slot labels", () => {
const labelledRow = (over: Record<string, unknown> = {}): string => JSON.stringify({
version: 1,
rows: [{ _uid: "uid_a", slot_name: "input_0", binding: "phrase", enabled: true, internal: false, ...over }],
});
it("renders the durable row.slot_label as the pin tag, even with an empty slotLabels prop", () => {
const w = mount(InjectorWidget, {
props: { nodeId: 7, initialJson: labelledRow({ slot_label: "test_label_1" }), connectedSlots: ["input_0"], slotLabels: {} },
});
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("test_label_1");
});
it("durable slot_label wins over a live slotLabels entry", () => {
const w = mount(InjectorWidget, {
props: { nodeId: 7, initialJson: labelledRow({ slot_label: "durable" }), connectedSlots: ["input_0"], slotLabels: { input_0: "live" } },
});
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("durable");
});
it("falls back to the live slotLabels entry when no durable slot_label", () => {
const w = mount(InjectorWidget, {
props: { nodeId: 7, initialJson: labelledRow(), connectedSlots: ["input_0"], slotLabels: { input_0: "live_label" } },
});
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("live_label");
});
it("falls back to slot_name when neither durable nor live label is set", () => {
const w = mount(InjectorWidget, {
props: { nodeId: 7, initialJson: labelledRow(), connectedSlots: ["input_0"], slotLabels: {} },
});
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("input_0");
});
});
describe("InjectorWidget — template persistence (Phase 6)", () => {
const ONE_ROW = JSON.stringify({
version: 1,
+2 -2
View File
@@ -812,7 +812,7 @@ function openRowContextMenu(ev: MouseEvent, idx: number): void {
? `$${row.binding}`
: isGeneral
? "template row"
: (props.slotLabels[row.slot_name] ?? row.slot_name);
: (row.slot_label ?? props.slotLabels[row.slot_name] ?? row.slot_name);
ctxMenu.value = {
visible: true,
x: Math.max(8, x),
@@ -880,7 +880,7 @@ defineExpose({ addRow, addGeneralRow, removeRow });
:reorderable="row.kind === 'general' ? generalRowCount > 1 : socketRowCount > 1"
:is-connected="row.kind === 'general' ? true : isConnected(row.slot_name)"
:value-type="row.kind === 'general' ? undefined : slotTypes[row.slot_name]"
:display-label="row.kind === 'general' ? undefined : slotLabels[row.slot_name]"
:display-label="row.kind === 'general' ? undefined : (row.slot_label ?? slotLabels[row.slot_name])"
:conflict-severity="conflictByUid[row._uid]?.severity"
:conflict-label="conflictByUid[row._uid]?.label"
:drop-indicator="dropIndicatorFor(row._uid)"
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import SeedListWidget from "./SeedListWidget.vue";
import SeedListModal from "../shared/SeedListModal.vue";
import { emptySeedListConfig } from "./types";
describe("SeedListWidget", () => {
@@ -142,3 +143,61 @@ describe("SeedListWidget", () => {
expect(w.emitted("update:modelValue")).toBeUndefined();
});
});
function ws(extra = {}) {
return mount(SeedListWidget, {
props: { modelValue: { ...emptySeedListConfig(), ...extra }, baseSeed: 7, count: 4 },
attachTo: document.body,
global: { stubs: { teleport: true } },
});
}
describe("SeedListWidget seeds button", () => {
it("renders the Per-iteration seeds button", () => {
expect(ws().find('[data-test="seedlist-seeds-btn"]').exists()).toBe(true);
});
it("badge shows the locked count when >0", () => {
expect(ws({ seed_locks: { "0": 9, "2": 8, "3": 1 } }).find('[data-test="seedlist-seeds-badge"]').text()).toMatch(/3 locked/i);
});
it("hides the badge when nothing is locked", () => {
expect(ws().find('[data-test="seedlist-seeds-badge"]').exists()).toBe(false);
});
it("clicking opens the modal", async () => {
const wr = ws();
await wr.find('[data-test="seedlist-seeds-btn"]').trigger("click");
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
});
it("previewStrategy is passed to modal when provided", async () => {
const wr = mount(SeedListWidget, {
props: { modelValue: emptySeedListConfig(), baseSeed: 7, count: 4, previewStrategy: "hash_index" },
attachTo: document.body,
global: { stubs: { teleport: true } },
});
await wr.find('[data-test="seedlist-seeds-btn"]').trigger("click");
expect(wr.findComponent(SeedListModal).props("strategy")).toBe("hash_index");
});
it("passes an override hint to the modal only when override_seed is on", async () => {
const on = ws({ override_seed: true });
await on.find('[data-test="seedlist-seeds-btn"]').trigger("click");
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
const off = ws(); // override_seed false by default
await off.find('[data-test="seedlist-seeds-btn"]').trigger("click");
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
});
});
describe("SeedListWidget bypass mirror (#13)", () => {
it("shows an N-bypassed badge from the loop's bypassedFrames", () => {
const w = mount(SeedListWidget, {
props: { modelValue: emptySeedListConfig(), bypassedFrames: [1, 3] },
});
expect(w.find('[data-test="seedlist-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
});
it("no bypass badge when the loop bypasses nothing", () => {
const w = mount(SeedListWidget, {
props: { modelValue: emptySeedListConfig(), bypassedFrames: [] },
});
expect(w.find('[data-test="seedlist-seeds-bypass-badge"]').exists()).toBe(false);
});
});
+53 -2
View File
@@ -12,8 +12,9 @@
* host glue (`src/widgets/seed_list.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 { SeedListConfig, SeedListStrategy } from "./types";
import SeedListModal from "../shared/SeedListModal.vue";
const props = withDefaults(
defineProps<{
@@ -21,8 +22,28 @@ 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;
/** Effective base seed for the modal preview (already resolved by the host
* glue — either local base_seed or the wired loop's seed, depending on
* override_seed + whether a loop is connected). */
baseSeed?: number;
/** Effective iteration count for the modal preview (resolved by host glue). */
count?: number;
/** Effective strategy for the modal preview. When supplied (loop wired +
* override_strategy on), the modal uses this instead of modelValue.strategy.
* The strategy CHIPS still reflect modelValue.strategy (the local config).
* Undefined = use modelValue.strategy (no loop / override off). */
previewStrategy?: SeedListStrategy;
/** 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;
/** 0-based frames the wired loop bypasses (resolved by the host glue —
* only when Override count from loop is on). Shown READ-ONLY in the
* modal (dimmed rows + "N bypassed" badge); bypass is owned by the loop.
* Empty when no loop is wired or none are bypassed. */
bypassedFrames?: number[];
}>(),
{ nodeMode: 0 },
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null, bypassedFrames: () => [] },
);
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
@@ -55,6 +76,14 @@ const STRATEGY_LOCKED_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);
const bypassedCount = computed(() => props.bypassedFrames.length);
function onSeedLocks(next: Record<string, number>): void {
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
}
/** True while strategy comes from the upstream loop_config wire.
* Mirrors what `setStockWidgetDisabled("count", ...)` does for the count
* stock widget in the host glue — chips can't live as a stock widget so
@@ -125,6 +154,15 @@ function toggleOverrideStrategy(): void {
</div>
</div>
<button type="button" class="wp-seedlist__seedbtn" data-test="seedlist-seeds-btn" @click="seedsOpen = true">
<span class="wp-seedlist__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-seedlist__seedbtn-fill" />
<span v-if="lockedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-badge">{{ lockedCount }} locked</span>
<span v-if="bypassedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
<svg class="wp-seedlist__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>
<div class="wp-seedlist__row" :title="OVERRIDE_SEED_TOOLTIP">
<span class="wp-seedlist__row-label">Override base seed from loop</span>
<button
@@ -172,6 +210,12 @@ function toggleOverrideStrategy(): void {
<span class="wp-seedlist__switch-thumb" />
</button>
</div>
<SeedListModal v-if="seedsOpen" :node-name="'WP Seed List'" :base-seed="baseSeed"
:count="count" :strategy="previewStrategy ?? modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
:previous-seeds="previousSeeds"
:bypass-frames="bypassedFrames.length ? bypassedFrames : undefined" :bypass-readonly="true"
:override-hint="modelValue.override_seed ? 'Base seed comes from the wired loop while Override base seed from loop is on.' : ''"
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
</div>
</template>
@@ -263,4 +307,11 @@ function toggleOverrideStrategy(): void {
/* Mute / bypass dim — same convention as WP_VarTo* and WP_ContextLoop. */
.wp-seedlist--muted { opacity: 0.45; pointer-events: none; }
.wp-seedlist--bypassed { opacity: 0.65; }
.wp-seedlist__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-seedlist__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
.wp-seedlist__seedbtn-ico { color: var(--wp-accent); display: flex; }
.wp-seedlist__seedbtn-fill { flex: 1; }
.wp-seedlist__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-seedlist__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
</style>
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { resolveSeedListPreview } from "./preview";
const local = { base: 5, count: 3, strategy: "sequential" as const };
const loop = { base: 100, count: 8, strategy: "hash_index" as const };
const NONE = { override_seed: false, override_count: false, override_strategy: false };
describe("resolveSeedListPreview", () => {
it("no loop wired → all local (even if toggles on)", () => {
expect(resolveSeedListPreview(local, { override_seed: true, override_count: true, override_strategy: true }, null))
.toEqual({ baseSeed: 5, count: 3, strategy: "sequential" });
});
it("no overrides → all local", () => {
expect(resolveSeedListPreview(local, NONE, loop)).toEqual({ baseSeed: 5, count: 3, strategy: "sequential" });
});
it("override_count only → loop count, local base+strategy", () => {
expect(resolveSeedListPreview(local, { ...NONE, override_count: true }, loop))
.toEqual({ baseSeed: 5, count: 8, strategy: "sequential" });
});
it("all overrides → all loop", () => {
expect(resolveSeedListPreview(local, { override_seed: true, override_count: true, override_strategy: true }, loop))
.toEqual({ baseSeed: 100, count: 8, strategy: "hash_index" });
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { SeedListStrategy } from "./types";
export interface SeriesParams { base: number; count: number; strategy: SeedListStrategy }
export interface OverrideFlags { override_seed: boolean; override_count: boolean; override_strategy: boolean }
/** Mirror of wp_nodes/seed_list.py::_resolve_config for the modal preview:
* an override flag pulls from the wired loop ONLY when a loop is present;
* otherwise the local widget value wins. */
export function resolveSeedListPreview(
local: SeriesParams,
flags: OverrideFlags,
loop: SeriesParams | null,
): { baseSeed: number; count: number; strategy: SeedListStrategy } {
return {
baseSeed: flags.override_seed && loop ? loop.base : local.base,
count: flags.override_count && loop ? loop.count : local.count,
strategy: flags.override_strategy && loop ? loop.strategy : local.strategy,
};
}
+20
View File
@@ -28,6 +28,7 @@ describe("parseSeedListConfig", () => {
override_seed: true,
override_count: true,
override_strategy: true,
seed_locks: {},
};
expect(parseSeedListConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -38,6 +39,7 @@ describe("parseSeedListConfig", () => {
override_seed: false,
override_count: true,
override_strategy: false,
seed_locks: {},
};
expect(parseSeedListConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -64,6 +66,7 @@ describe("parseSeedListConfig", () => {
override_seed: false,
override_count: false,
override_strategy: false,
seed_locks: {},
});
});
@@ -89,8 +92,25 @@ describe("parseSeedListConfig", () => {
override_seed: false,
override_count: true,
override_strategy: false,
seed_locks: {},
};
const round = parseSeedListConfig(serializeSeedListConfig(cfg));
expect(round).toEqual(cfg);
});
});
describe("SeedListConfig seed_locks", () => {
it("defaults seed_locks to {}", () => {
expect(emptySeedListConfig().seed_locks).toEqual({});
});
it("parses seed_locks", () => {
expect(parseSeedListConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
});
it("drops non-numeric lock values", () => {
expect(parseSeedListConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
});
it("round-trips through serialize", () => {
const cfg = { ...emptySeedListConfig(), seed_locks: { "0": 7 } };
expect(parseSeedListConfig(serializeSeedListConfig(cfg)).seed_locks).toEqual({ "0": 7 });
});
});
+11
View File
@@ -18,6 +18,9 @@ export interface SeedListConfig {
override_count: boolean;
/** Take `strategy` from the wired loop_config. */
override_strategy: boolean;
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
* indices re-derive from base+strategy. Empty by default. */
seed_locks: Record<string, number>;
}
const STRATEGIES = new Set<SeedListStrategy>(["hash_index", "sequential", "prime_stride"]);
@@ -28,6 +31,7 @@ export function emptySeedListConfig(): SeedListConfig {
override_seed: false,
override_count: false,
override_strategy: false,
seed_locks: {},
};
}
@@ -69,6 +73,13 @@ export function parseSeedListConfig(raw: string | null | undefined): SeedListCon
if (!hasNewCount) out.override_count = legacy;
if (!hasNewStrategy) out.override_strategy = legacy;
}
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;
}
return out;
}
+254
View File
@@ -0,0 +1,254 @@
<script setup lang="ts">
/** Per-iteration seed modal — shared by the Context Loop + Seed List
* node widgets. Computes the derived list locally (seed-derive mirror)
* and overlays seed_locks; edits re-emit the full merged lock map. */
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { deriveLoopSeeds, parseSeedLocks, type SeedStrategy } from "./seed-derive";
import SeedLockRow from "./SeedLockRow.vue";
const props = defineProps<{
nodeName: string;
baseSeed: number;
count: number;
strategy: SeedStrategy;
seedLocks: Record<string, number>;
/** Yellow override notice text; empty/undefined hides it. Context Loop
* shows it when "Override Context seed" is OFF (a nudge); Seed List shows
* it when "Override base seed from loop" is ON (base comes from the loop). */
overrideHint?: string;
/** The seed each frame used on the PREVIOUS run (captured loop series),
* indexed by frame. Drives the per-row "lock previous" button; absent or
* short → that frame's button greys out. Distinct from the computed
* `derived` series, which is the UPCOMING seeds from the current base. */
previousSeeds?: number[] | null;
/** 0-based bypassed frame indices. When PASSED, bypassed rows dim + strike.
* Absent → no bypass display at all. */
bypassFrames?: number[];
/** Read-only bypass (Seed List mirroring a loop): show bypassed rows dimmed
* but WITHOUT an interactive toggle — bypass is owned by the loop node.
* Default false (Context Loop = interactive toggles). */
bypassReadonly?: boolean;
}>();
const emit = defineEmits<{
"update:seedLocks": [next: Record<string, number>];
"update:bypassFrames": [next: number[]];
close: [];
}>();
const derived = computed(() => deriveLoopSeeds(props.baseSeed, Math.max(1, props.count), props.strategy));
const lockedCount = computed(() => Object.keys(props.seedLocks).length);
const copied = ref(false);
const pasted = ref(false);
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
let pastedTimer: ReturnType<typeof setTimeout> | undefined;
/** Locked indices beyond the current count — shown dimmed below the active
* rows so a forgotten out-of-range lock is visible (it re-applies if count
* grows back). */
const inactiveLocks = computed(() => {
const active = derived.value.length;
return Object.keys(props.seedLocks)
.map(Number)
.filter((i) => Number.isInteger(i) && i >= active)
.sort((a, b) => a - b);
});
const bypassEnabled = computed(() => props.bypassFrames !== undefined);
/** Interactive toggle only when bypass is enabled AND not read-only. In
* read-only mode rows still show the bypassed (dim + strike) state — that's
* the `:bypassed` prop, independent of `:bypassable`. */
const bypassInteractive = computed(() => bypassEnabled.value && !props.bypassReadonly);
const bypassSet = computed(() => new Set(props.bypassFrames ?? []));
const bypassCount = computed(() => (props.bypassFrames ?? []).length);
/** Number of in-range frames NOT bypassed. */
const activeFrameCount = computed(() => {
let n = 0;
for (let i = 0; i < derived.value.length; i++) if (!bypassSet.value.has(i)) n++;
return n;
});
/** Bypass toggle is disabled for an ACTIVE frame when it's the last one left. */
function bypassDisabledFor(i: number): boolean {
return !bypassSet.value.has(i) && activeFrameCount.value <= 1;
}
/** Bypassed indices beyond the current count — dimmed, re-apply if count grows. */
const inactiveBypass = computed(() =>
[...bypassSet.value].filter((i) => i >= derived.value.length).sort((a, b) => a - b),
);
/** Real derived seed for any index (out-of-range inactive rows compute it on
* the fly since `derived` only spans the active count). PRECONDITION: `i >= 1`
* — only ever called for inactive rows where `i >= derived.length >= 1`. At
* `i === 0` deriveLoopSeeds hits its `count <= 1` branch and returns the base
* seed, NOT the series' derived[0], so do not reuse this for in-range index 0. */
function seedAt(i: number): number {
return deriveLoopSeeds(props.baseSeed, i + 1, props.strategy)[i] ?? 0;
}
function onBypass(p: { index: number; bypassed: boolean }): void {
const next = new Set(bypassSet.value);
if (p.bypassed) next.add(p.index);
else next.delete(p.index);
emit("update:bypassFrames", [...next].sort((a, b) => a - b));
}
function onRow(p: { index: number; seed: number | null }): void {
const next = { ...props.seedLocks };
if (p.seed == null) delete next[String(p.index)];
else next[String(p.index)] = p.seed;
emit("update:seedLocks", next);
}
function lockAll(): void {
const next: Record<string, number> = {};
derived.value.forEach((s, i) => { next[String(i)] = s; });
emit("update:seedLocks", next);
}
function unlockAll(): void { emit("update:seedLocks", {}); }
/** Build the `#N: seed` clipboard text. lockedOnly → just the locked rows
* (round-trips through Paste to re-lock exactly those); else every iteration
* with locks overlaid. */
function copyText(lockedOnly: boolean): string {
if (lockedOnly) {
return Object.keys(props.seedLocks)
.map(Number)
.sort((a, b) => a - b)
.map((i) => `#${i + 1}: ${props.seedLocks[String(i)]}`)
.join("\n");
}
return derived.value.map((s, i) => `#${i + 1}: ${props.seedLocks[String(i)] ?? s}`).join("\n");
}
function copyAll(ev: MouseEvent): void {
void navigator.clipboard?.writeText(copyText(ev.altKey)); // Alt-click → locked seeds only
copied.value = true;
if (copiedTimer) clearTimeout(copiedTimer);
copiedTimer = setTimeout(() => { copied.value = false; }, 1500);
}
/** Read the clipboard, parse the `#N: seed` format Copy produces, and REPLACE
* the whole lock map with it. No-op on empty/garbage so a stray paste can't
* wipe existing locks. Pasted indices beyond count land as inactive rows. */
async function pasteAll(): Promise<void> {
let text = "";
try { text = (await navigator.clipboard?.readText()) ?? ""; } catch { text = ""; }
const next = parseSeedLocks(text);
if (Object.keys(next).length === 0) return;
emit("update:seedLocks", next);
pasted.value = true;
if (pastedTimer) clearTimeout(pastedTimer);
pastedTimer = setTimeout(() => { pasted.value = false; }, 1500);
}
function onKeydown(ev: KeyboardEvent): void {
if (ev.key === "Escape") { ev.preventDefault(); emit("close"); }
}
onMounted(() => window.addEventListener("keydown", onKeydown));
onBeforeUnmount(() => {
window.removeEventListener("keydown", onKeydown);
if (copiedTimer) clearTimeout(copiedTimer);
if (pastedTimer) clearTimeout(pastedTimer);
});
</script>
<template>
<Teleport to="body">
<div class="sm-overlay" @click="emit('close')">
<div class="sm" role="dialog" aria-modal="true" data-test="seed-modal" @click.stop>
<div class="sm__head">
<span class="sm__head-icon"><svg width="17" height="17" 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>
<div class="sm__title-block">
<div class="sm__title-row"><span class="sm__name">{{ nodeName }}</span><span class="sm__chip">Iteration Seeds</span></div>
<div class="sm__sub">base {{ baseSeed }} · {{ strategy }} · {{ Math.max(1, count) }} iterations</div>
</div>
<button class="sm__close" data-test="seed-modal-close" aria-label="Close" @click="emit('close')">
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M5 5l14 14M19 5L5 19" /></svg>
</button>
</div>
<div class="sm__bar">
<span class="sm__count">{{ Math.max(1, count) }} iterations</span>
<span class="sm__strat">{{ strategy }}</span>
<span v-if="lockedCount" class="sm__lockcount" data-test="mx-seed-lockcount">{{ lockedCount }} locked</span>
<span v-if="bypassEnabled && bypassCount" class="sm__lockcount" data-test="mx-bypass-count">{{ bypassCount }} bypassed</span>
<span class="sm__bar-spacer" />
<button class="ghost" data-test="mx-seed-lockall" @click="lockAll">Lock all</button>
<button class="ghost" data-test="mx-seed-unlockall" @click="unlockAll">Unlock all</button>
<button class="ghost" :class="{ 'ghost--copied': copied }" data-test="mx-seed-copy"
title="Copy all seeds · Alt-click to copy only the locked ones" @click="copyAll">
<svg v-if="copied" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
{{ copied ? "Copied" : "Copy" }}
</button>
</div>
<div v-if="overrideHint" class="sm__hint" data-test="mx-seed-hint">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9" /><path d="M12 11v5" stroke-linecap="round" /><circle cx="12" cy="7.5" r="1" fill="currentColor" stroke="none" /></svg>
<span>{{ overrideHint }}</span>
</div>
<div class="sm__list">
<SeedLockRow v-for="(s, i) in derived" :key="i" :index="i" :derived="s"
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null"
:bypassable="bypassInteractive" :bypassed="bypassSet.has(i)"
:bypass-disabled="bypassDisabledFor(i)"
@update="onRow" @bypass="onBypass" />
<template v-if="inactiveBypass.length">
<div class="sm__inactive-label" data-test="mx-bypass-inactive">
Bypassed · won't apply at count {{ derived.length }}
</div>
<SeedLockRow v-for="i in inactiveBypass" :key="`inbypass-${i}`" :index="i"
:derived="seedAt(i)"
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null"
:inactive="true" :bypassable="bypassInteractive" :bypassed="true"
@update="onRow" @bypass="onBypass" />
</template>
<template v-if="inactiveLocks.length">
<div class="sm__inactive-label" data-test="mx-seed-inactive">
Inactive · won't apply at count {{ derived.length }}
</div>
<SeedLockRow v-for="i in inactiveLocks" :key="`inact-${i}`" :index="i"
:derived="seedLocks[String(i)]" :locked="true" :seed="seedLocks[String(i)]"
:previous="previousSeeds?.[i] ?? null" :inactive="true" @update="onRow" />
</template>
</div>
<div class="sm__foot">
<span class="sm__foot-hint"><kbd>Esc</kbd> to close · locks save with the node</span>
<span class="sm__foot-spacer" />
<button class="ghost" :class="{ 'ghost--copied': pasted }" data-test="mx-seed-paste"
title="Replace all locks from copied text" @click="pasteAll">
<svg v-if="pasted" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2.5" width="8" height="4" rx="1" /><path d="M8 4.5H6a2 2 0 0 0-2 2V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6.5a2 2 0 0 0-2-2h-2" /></svg>
{{ pasted ? "Pasted" : "Paste" }}
</button>
<button class="btn" data-test="seed-modal-done" @click="emit('close')">Done</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
@import "./theme.css";
.sm-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(0,0,0,.62); font-family: var(--wp-font-sans, sans-serif); }
.sm { width: 486px; max-width: 100%; max-height: 86vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: 6px; overflow: hidden; color: var(--wp-text); font-size: 12px; box-shadow: 0 18px 50px rgba(0,0,0,.55); }
.sm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: linear-gradient(180deg, color-mix(in srgb, var(--wp-accent) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); }
.sm__head-icon { color: var(--wp-accent); width: 24px; display: flex; justify-content: center; }
.sm__title-block { flex: 1; min-width: 0; }
.sm__title-row { display: flex; align-items: center; gap: 8px; }
.sm__name { font: 600 13px var(--wp-font-sans); color: var(--wp-text); }
.sm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: 2px; background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent); }
.sm__sub { font: 400 10px var(--wp-font-mono); color: var(--wp-text-dim, var(--wp-text3)); margin-top: 3px; }
.sm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; display: flex; }
.sm__close:hover { color: var(--wp-text); }
.sm__bar { display: flex; align-items: center; gap: 8px; padding: 9px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); }
.sm__count { font: 600 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); }
.sm__strat { font: 600 9px var(--wp-font-mono); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); }
.sm__lockcount { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.12); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
.sm__bar-spacer { flex: 1; }
.ghost { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 3px; border: 1px solid var(--wp-border); background: transparent; color: var(--wp-text-muted, var(--wp-text2)); font: 11px var(--wp-font-sans); cursor: pointer; }
.ghost:hover { border-color: var(--wp-border-strong, var(--wp-border2)); color: var(--wp-text); }
.ghost--copied, .ghost--copied:hover { border-color: var(--wp-green, #6bc96f); color: var(--wp-green, #6bc96f); }
.sm__hint { display: flex; align-items: center; gap: 7px; padding: 7px 14px; flex-shrink: 0; font: 11px var(--wp-font-sans); color: color-mix(in srgb, var(--wp-amber, #fbbf24) 75%, var(--wp-text-muted)); background: color-mix(in srgb, var(--wp-amber, #fbbf24) 9%, transparent); border-bottom: 1px solid var(--wp-border); }
.sm__hint svg { flex-shrink: 0; color: var(--wp-amber, #fbbf24); }
.sm__list { overflow-y: auto; padding: 6px; flex: 1; min-height: 0; }
.sm__inactive-label { padding: 8px 8px 4px; margin-top: 4px; border-top: 1px dashed var(--wp-border); font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .08em; color: var(--wp-text-dim, var(--wp-text3)); }
.sm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; flex-shrink: 0; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
.sm__foot-hint { font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
.sm__foot-hint kbd { font: 9px var(--wp-font-mono); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); padding: 1px 4px; border-radius: 2px; }
.sm__foot-spacer { flex: 1; }
.btn { padding: 6px 14px; border: 1px solid var(--wp-accent); border-radius: 3px; background: var(--wp-accent); color: #fff; font: 11px var(--wp-font-sans); cursor: pointer; }
</style>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
/** One per-iteration row in SeedListModal — a per-row mirror of the
* module SeedLockControls. Unlocked shows the derived seed; locking
* captures it; locked exposes a seed input + ±spinners + roll. */
const props = defineProps<{
index: number; // 0-based
derived: number; // computed seed for this index (preview when unlocked)
locked: boolean;
seed: number | null; // locked seed value when locked
/** Locked index beyond the current count — dimmed but still editable;
* re-activates if count grows back. */
inactive?: boolean;
/** The seed this frame ACTUALLY used on the previous run (captured from the
* node's loop series). null/undefined when there's no prior run for this
* frame — the "lock previous" button greys out. Distinct from `derived`,
* which is the UPCOMING seed computed from the current base. */
previous?: number | null;
/** When true, render a per-frame bypass toggle (Context Loop only). */
bypassable?: boolean;
/** Whether this frame is currently bypassed. */
bypassed?: boolean;
/** Disable the bypass toggle (e.g. it's the last active frame). */
bypassDisabled?: boolean;
}>();
const emit = defineEmits<{
update: [payload: { index: number; seed: number | null }];
bypass: [payload: { index: number; bypassed: boolean }];
}>();
const SEED_MAX = (2 ** 50) - 1; // MAX_SAFE_SEED (matches engine)
function randomSeed(): number {
const hi = Math.floor(Math.random() * 0x40000); // 18 bits
const lo = Math.floor(Math.random() * 0x100000000); // 32 bits
return (hi * 0x100000000 + lo) % (SEED_MAX + 1);
}
function set(seed: number | null) { emit("update", { index: props.index, seed }); }
function onLock() { set(props.locked ? null : props.derived); }
/** Fill + lock the seed this frame used on the previous run. No-op (button is
* disabled) when there's no captured previous for this frame. */
function lockPrevious() { if (props.previous != null) set(props.previous); }
function onInput(ev: Event) {
const n = Number((ev.target as HTMLInputElement).value.trim());
if (Number.isFinite(n) && Number.isInteger(n)) set(n);
}
function bump(d: 1 | -1) {
if (!props.locked || props.seed == null) return;
set(Math.max(0, Math.min(SEED_MAX, props.seed + d)));
}
</script>
<template>
<div class="srow" :class="{ 'srow--locked': locked, 'srow--inactive': inactive, 'srow--bypassed': bypassed }">
<span class="srow__idx" data-test="seedrow-idx">#{{ index + 1 }}</span>
<button v-if="bypassable" type="button" class="toggle toggle--bypass"
:class="{ 'toggle--on': bypassed }" :disabled="bypassDisabled || undefined"
data-test="seedrow-bypass" role="switch" :aria-checked="bypassed"
:title="bypassed ? 'Frame bypassed — click to re-enable' : 'Bypass this frame (skip its generation)'"
@click="emit('bypass', { index, bypassed: !bypassed })">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9" /><path d="M5.6 5.6l12.8 12.8" stroke-linecap="round" /></svg>
{{ bypassed ? "Bypassed" : "Bypass" }}
</button>
<button type="button" class="toggle" :class="{ 'toggle--on': locked }"
data-test="seedrow-lock" role="switch" :aria-checked="locked" @click="onLock">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="11" width="14" height="9" rx="1.5" /><path :d="locked ? 'M8 11V8a4 4 0 0 1 8 0v3' : 'M8 11V8a4 4 0 0 1 8 0'" />
</svg>
{{ locked ? "Locked" : "Lock" }}
</button>
<button type="button" class="toggle toggle--prev" data-test="seedrow-lockprev"
:disabled="previous == null || undefined"
:title="previous == null
? 'No previous run captured for this frame'
: `Lock the seed this frame used last run (${previous})`"
@click="lockPrevious">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v5h5" /><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" /><path d="M12 7v5l3 2" />
</svg>
Prev
</button>
<span class="srow__fill" />
<span v-if="!locked" class="derived">
<span class="derived__val" data-test="seedrow-derived">{{ derived }}</span>
<span class="derived__tag">derived</span>
</span>
<span v-else class="seed-controls">
<span class="seed-input-wrap">
<input class="seed-input" data-test="seedrow-input" type="number" step="1"
:value="seed ?? ''" aria-label="Locked seed" @input="onInput" />
<span class="seed-spin">
<button type="button" class="seed-spin-btn" tabindex="-1" aria-label="Increase" @click="bump(1)">
<svg width="6" height="4" viewBox="0 0 8 5"><path d="M0 5 L4 0 L8 5 Z" fill="currentColor" /></svg></button>
<button type="button" class="seed-spin-btn" tabindex="-1" aria-label="Decrease" @click="bump(-1)">
<svg width="6" height="4" viewBox="0 0 8 5"><path d="M0 0 L4 5 L8 0 Z" fill="currentColor" /></svg></button>
</span>
</span>
<button type="button" class="seed-roll" data-test="seedrow-roll" title="Random seed"
aria-label="Random seed" @click="set(randomSeed())">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 4v5h-5" /></svg>
</button>
</span>
</div>
</template>
<style scoped>
.srow { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 5px; }
.srow--locked { background: rgba(99,102,241,0.07); box-shadow: inset 2px 0 0 var(--wp-accent, #6366f1); }
/* out-of-range lock: dimmed ("deemed down") but still interactive — hover lifts it. */
.srow--inactive { opacity: .5; }
.srow--inactive:hover { opacity: .85; }
/* Bypassed row — purple-dimmed wash + a dashed left accent bar so it reads
* clearly even when the struck-through seed alone is too faint. The dashed
* bar echoes the loop node's dashed bypass chips. */
.srow--bypassed {
background:
repeating-linear-gradient(
to bottom,
var(--wp-accent, #c4b5fd) 0 4px,
transparent 4px 8px
) left / 3px 100% no-repeat,
color-mix(in srgb, var(--wp-accent, #c4b5fd) 11%, transparent);
border-radius: 5px;
}
.srow--bypassed .derived__val { text-decoration: line-through; opacity: .65; }
.srow--bypassed .srow__idx { color: var(--wp-accent-text, #c4b5fd); opacity: .75; }
.toggle--bypass { color: var(--wp-text-dim, var(--wp-text3)); }
.toggle--bypass.toggle--on { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.10); }
.srow__idx { flex-shrink: 0; width: 30px; text-align: right; font: 600 11px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #7a7d88); }
.srow--locked .srow__idx { color: var(--wp-accent-text, #c4b5fd); }
.srow__fill { flex: 1; }
.toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; border: 1px solid var(--wp-border); border-radius: 3px; font: 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); cursor: pointer; background: var(--wp-bg-deep, var(--wp-bg)); flex-shrink: 0; }
.toggle:hover { border-color: var(--wp-border-strong, var(--wp-border2)); }
.toggle svg { color: var(--wp-text-dim, var(--wp-text3)); }
.toggle--on { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.10); }
.toggle--on svg { color: var(--wp-accent-text, var(--wp-text)); }
/* "Lock previous run's seed" — secondary action; greys out when the frame has
no captured previous (no prior run). */
.toggle--prev { color: var(--wp-text-dim, var(--wp-text3)); }
.toggle:disabled { opacity: .4; cursor: not-allowed; }
.toggle:disabled:hover { border-color: var(--wp-border); color: var(--wp-text-dim, var(--wp-text3)); }
.derived { display: inline-flex; align-items: center; gap: 8px; }
.derived__val { font: 11px var(--wp-font-mono, monospace); color: var(--wp-text4, #888); }
.derived__tag { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .07em; color: var(--wp-text-dim, var(--wp-text3)); border: 1px solid var(--wp-border); border-radius: 3px; padding: 1px 5px; }
.seed-controls { display: inline-flex; align-items: center; gap: 6px; }
.seed-input-wrap { display: inline-flex; align-items: stretch; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; height: 24px; width: 178px; overflow: hidden; }
.seed-input-wrap:focus-within { border-color: var(--wp-accent); }
.seed-input { flex: 1; background: transparent; border: 0; padding: 0 7px; font: 11px var(--wp-font-mono, monospace); color: var(--wp-text); text-align: right; min-width: 0; -moz-appearance: textfield; }
.seed-input::-webkit-outer-spin-button, .seed-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.seed-input:focus { outline: none; }
.seed-spin { display: flex; flex-direction: column; width: 14px; flex-shrink: 0; border-left: 1px solid var(--wp-border); background: rgba(99,102,241,.04); }
.seed-spin-btn { flex: 1; display: flex; align-items: center; justify-content: center; background: transparent; border: 0; padding: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; line-height: 0; }
.seed-spin-btn + .seed-spin-btn { border-top: 1px solid var(--wp-border); }
.seed-spin-btn:hover { color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.12); }
.seed-roll { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 2px 4px; display: flex; }
.seed-roll:hover { color: var(--wp-accent-text, var(--wp-text)); }
</style>
@@ -0,0 +1,160 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import SeedListModal from "../SeedListModal.vue";
// jsdom has no real clipboard; defineProperty so it survives a read-only getter.
// Returns the array capturing every writeText payload.
function stubClipboard(readValue = "") {
const writes: string[] = [];
Object.defineProperty(navigator, "clipboard", {
value: {
readText: () => Promise.resolve(readValue),
writeText: (t: string) => { writes.push(t); return Promise.resolve(); },
},
configurable: true,
});
return writes;
}
function modal(props = {}) {
return mount(SeedListModal, {
props: { nodeName: "WP Context Loop", baseSeed: 42, count: 4, strategy: "sequential",
seedLocks: { "1": 999 }, overrideHint: "Test hint.", ...props },
attachTo: document.body,
// The modal body renders inside `<Teleport to="body">`; stubbing teleport
// renders the children in place so the wrapper's `find()` reaches them
// (repo convention — see RemapRefPopup.test.ts, ConfirmDialog.test.ts).
global: { stubs: { teleport: true } },
});
}
// ES2020 lib — no Array.prototype.at; read the last emit by index.
function lastEmit(w: ReturnType<typeof modal>) {
const ev = w.emitted("update:seedLocks")!;
return ev[ev.length - 1][0];
}
describe("SeedListModal", () => {
it("renders one row per iteration (count)", () => {
expect(modal().findAll('[data-test="seedrow-idx"]')).toHaveLength(4);
});
it("shows the locked-count pill", () => {
expect(modal().find('[data-test="mx-seed-lockcount"]').text()).toMatch(/1 locked/i);
});
it("shows the override hint with the provided text; hidden when empty", () => {
const shown = modal({ overrideHint: "Heads up: override is on." });
expect(shown.find('[data-test="mx-seed-hint"]').exists()).toBe(true);
expect(shown.find('[data-test="mx-seed-hint"]').text()).toContain("Heads up: override is on.");
expect(modal({ overrideHint: "" }).find('[data-test="mx-seed-hint"]').exists()).toBe(false);
});
it("a row update re-emits update:seedLocks with the merged map", async () => {
const w = modal({ seedLocks: {} });
await w.findAll('[data-test="seedrow-lock"]')[0].trigger("click"); // lock #1 (index 0), sequential derived = 42
expect(lastEmit(w)).toEqual({ "0": 42 });
});
it("threads previousSeeds → each row's lock-previous button (click locks the previous)", async () => {
const w = modal({ seedLocks: {}, previousSeeds: [111, 222, 333, 444] });
const prevBtns = w.findAll('[data-test="seedrow-lockprev"]');
expect(prevBtns).toHaveLength(4);
await prevBtns[2].trigger("click"); // frame #3 → its previous seed 333
expect(lastEmit(w)).toEqual({ "2": 333 });
});
it("greys the lock-previous button for frames with no captured previous", () => {
const w = modal({ seedLocks: {}, previousSeeds: [111] }); // only frame 0 has a previous
const prevBtns = w.findAll('[data-test="seedrow-lockprev"]');
expect(prevBtns[0].attributes("disabled")).toBeUndefined();
expect(prevBtns[1].attributes("disabled")).toBeDefined();
expect(prevBtns[3].attributes("disabled")).toBeDefined();
});
it("Lock all locks every iteration to its derived seed", async () => {
const w = modal({ seedLocks: {} });
await w.find('[data-test="mx-seed-lockall"]').trigger("click");
expect(lastEmit(w)).toEqual({ "0": 42, "1": 43, "2": 44, "3": 45 });
});
it("Unlock all emits an empty map", async () => {
const w = modal({ seedLocks: { "1": 999 } });
await w.find('[data-test="mx-seed-unlockall"]').trigger("click");
expect(lastEmit(w)).toEqual({});
});
it("Copy flashes a 'Copied' indicator on click", async () => {
const w = modal();
const btn = () => w.find('[data-test="mx-seed-copy"]');
expect(btn().classes()).not.toContain("ghost--copied");
await btn().trigger("click");
expect(btn().classes()).toContain("ghost--copied");
expect(btn().text()).toContain("Copied");
w.unmount(); // clears the 1.5s reset timer via onBeforeUnmount
});
it("renders locks beyond count as a dimmed inactive group", () => {
const w = modal({ count: 3, seedLocks: { "1": 11, "5": 55 } });
expect(w.find('[data-test="mx-seed-inactive"]').exists()).toBe(true);
// 3 active rows (#1-#3) + the out-of-range lock shown as #6
expect(w.findAll('[data-test="seedrow-idx"]').map((n) => n.text())).toEqual(["#1", "#2", "#3", "#6"]);
});
it("no inactive group when every lock is within count", () => {
const w = modal({ count: 3, seedLocks: { "1": 11 } });
expect(w.find('[data-test="mx-seed-inactive"]').exists()).toBe(false);
expect(w.findAll('[data-test="seedrow-idx"]')).toHaveLength(3);
});
it("Paste replaces the whole lock map from the copied text", async () => {
stubClipboard("#2: 77\n#3: 88"); // 1-based display → indices 1 and 2
const w = modal({ seedLocks: { "0": 1 } }); // existing lock must be replaced, not merged
await w.find('[data-test="mx-seed-paste"]').trigger("click");
await flushPromises();
expect(lastEmit(w)).toEqual({ "1": 77, "2": 88 });
expect(w.find('[data-test="mx-seed-paste"]').text()).toContain("Pasted");
});
it("Paste with no parseable lines leaves locks untouched", async () => {
stubClipboard("garbage\nnot a seed line");
const w = modal({ seedLocks: { "0": 1 } });
await w.find('[data-test="mx-seed-paste"]').trigger("click");
await flushPromises();
expect(w.emitted("update:seedLocks")).toBeUndefined();
});
it("plain-click Copy writes every seed with locks overlaid", async () => {
const writes = stubClipboard();
const w = modal({ count: 3, seedLocks: { "1": 999 } }); // sequential base 42 → 42,43,44
await w.find('[data-test="mx-seed-copy"]').trigger("click");
expect(writes[writes.length - 1]).toBe("#1: 42\n#2: 999\n#3: 44");
});
it("Alt+click Copy writes only the locked seeds", async () => {
const writes = stubClipboard();
const w = modal({ count: 4, seedLocks: { "1": 999 } });
await w.find('[data-test="mx-seed-copy"]').trigger("click", { altKey: true });
expect(writes[writes.length - 1]).toBe("#2: 999");
});
});
describe("SeedListModal bypass column (#13)", () => {
it("shows no bypass toggles when bypassFrames prop is absent (Seed List parity)", () => {
const w = modal({ seedLocks: {} });
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(0);
});
it("renders a bypass toggle per active row when bypassFrames is passed", () => {
const w = modal({ seedLocks: {}, bypassFrames: [] });
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(4);
});
it("emits update:bypassFrames when a row is bypassed", async () => {
const w = modal({ seedLocks: {}, bypassFrames: [] });
await w.findAll('[data-test="seedrow-bypass"]')[1].trigger("click");
expect(w.emitted("update:bypassFrames")![0]).toEqual([[1]]);
});
it("disables the last active frame's bypass toggle", () => {
const w = modal({ seedLocks: {}, bypassFrames: [0, 1, 2] });
const toggles = w.findAll('[data-test="seedrow-bypass"]');
expect(toggles[3].attributes("disabled")).toBeDefined();
expect(toggles[0].attributes("disabled")).toBeUndefined();
});
it("renders out-of-range bypassed frames as inactive rows", () => {
const w = modal({ seedLocks: {}, bypassFrames: [9] });
expect(w.find('[data-test="mx-bypass-inactive"]').exists()).toBe(true);
});
});
describe("SeedListModal read-only bypass (#13 seed-list mirror)", () => {
it("dims bypassed rows without an interactive toggle + shows the badge", () => {
const w = modal({ seedLocks: {}, bypassFrames: [1], bypassReadonly: true });
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(0); // no toggles
expect(w.findAll(".srow")[1].classes()).toContain("srow--bypassed");
expect(w.find('[data-test="mx-bypass-count"]').text()).toMatch(/1 bypassed/i);
});
});
@@ -0,0 +1,69 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import SeedLockRow from "../SeedLockRow.vue";
function row(props = {}) {
return mount(SeedLockRow, { props: { index: 0, derived: 12345, locked: false, seed: null, ...props } });
}
describe("SeedLockRow", () => {
it("unlocked: shows derived value + a Lock toggle, no input", () => {
const w = row();
expect(w.find('[data-test="seedrow-derived"]').text()).toContain("12345");
expect(w.find('[data-test="seedrow-input"]').exists()).toBe(false);
});
it("locking emits update with the derived value captured", async () => {
const w = row();
await w.find('[data-test="seedrow-lock"]').trigger("click");
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: 12345 });
});
it("unlocking emits update with null", async () => {
const w = row({ locked: true, seed: 999 });
await w.find('[data-test="seedrow-lock"]').trigger("click");
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
});
it("editing the seed input emits update with the typed value", async () => {
const w = row({ locked: true, seed: 999 });
await w.find('[data-test="seedrow-input"]').setValue("4242");
const updates = w.emitted("update")!;
expect(updates[updates.length - 1][0]).toEqual({ index: 0, seed: 4242 });
});
it("displays #N (1-based) from the 0-based index", () => {
expect(row({ index: 2 }).find('[data-test="seedrow-idx"]').text()).toBe("#3");
});
it("inactive dims the row but stays interactive (unlock still emits)", async () => {
const w = row({ locked: true, seed: 999, inactive: true });
expect(w.find(".srow").classes()).toContain("srow--inactive");
await w.find('[data-test="seedrow-lock"]').trigger("click");
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
});
it("lock-previous: fills + locks the captured previous seed", async () => {
const w = row({ previous: 777 });
const btn = w.find('[data-test="seedrow-lockprev"]');
expect(btn.attributes("disabled")).toBeUndefined();
await btn.trigger("click");
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: 777 });
});
it("lock-previous: disabled when the frame has no previous seed", () => {
expect(row({ previous: null }).find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined();
expect(row().find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined(); // absent → disabled
});
});
describe("SeedLockRow bypass (#13)", () => {
it("hides the bypass toggle unless bypassable", () => {
expect(row().find('[data-test="seedrow-bypass"]').exists()).toBe(false);
expect(row({ bypassable: true }).find('[data-test="seedrow-bypass"]').exists()).toBe(true);
});
it("emits bypass with the toggled state", async () => {
const w = row({ bypassable: true, bypassed: false });
await w.get('[data-test="seedrow-bypass"]').trigger("click");
expect(w.emitted("bypass")![0]).toEqual([{ index: 0, bypassed: true }]);
});
it("marks the row bypassed + respects bypassDisabled", () => {
const on = row({ bypassable: true, bypassed: true });
expect(on.get(".srow").classes()).toContain("srow--bypassed");
const dis = row({ bypassable: true, bypassed: false, bypassDisabled: true });
expect(dis.get('[data-test="seedrow-bypass"]').attributes("disabled")).toBeDefined();
});
});
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import corpus from "../../../../tests/fixtures/seed-derive-corpus.json";
import { deriveLoopSeeds, applySeedLocks, parseSeedLocks, effectiveChainSeed } from "../seed-derive";
describe("seed-derive (Py≡TS parity)", () => {
it("matches the engine corpus for every (base, count, strategy)", () => {
for (const c of corpus.derive) {
expect(deriveLoopSeeds(c.base, c.count, c.strategy as never)).toEqual(c.derived);
}
});
it("applySeedLocks matches the engine", () => {
const a = corpus.applyLocks;
const locks: Record<number, number> = {};
for (const [k, v] of Object.entries(a.locks)) locks[Number(k)] = v as number;
expect(applySeedLocks(a.derived, locks)).toEqual(a.locked);
});
});
describe("effectiveChainSeed (Py≡TS parity)", () => {
it("uses an override verbatim — no loop_index XOR (engine no-double-shift rule)", () => {
expect(effectiveChainSeed(42, 999, 0)).toBe(999);
expect(effectiveChainSeed(42, 999, 3)).toBe(999);
});
it("XORs loop_index into a constant widget seed only when there is no override", () => {
expect(effectiveChainSeed(42, null, 0)).toBe(42);
expect(effectiveChainSeed(42, null, 1)).toBe(482072580190692);
expect(effectiveChainSeed(42, null, 5)).toBe(343395897700430);
expect(effectiveChainSeed(2 ** 60, null, 3)).toBe(385286345697257);
});
});
describe("parseSeedLocks (Paste — inverse of the modal copy format)", () => {
it("parses #N: seed lines into a 0-based lock map", () => {
expect(parseSeedLocks("#1: 42\n#2: 43\n#3: 44")).toEqual({ "0": 42, "1": 43, "2": 44 });
});
it("ignores blank, malformed, and #0 lines; tolerates stray whitespace", () => {
expect(parseSeedLocks("\n#2: 7\ngarbage\n #4 : 9 \n#0: 5\n#: x")).toEqual({ "1": 7, "3": 9 });
});
it("returns an empty map when nothing parses", () => {
expect(parseSeedLocks("nope\n123\nfoo: bar")).toEqual({});
});
it("masks values to 50 bits — negatives normalize like the engine", () => {
expect(parseSeedLocks("#1: -1")).toEqual({ "0": (2 ** 50) - 1 });
});
});
+9 -1
View File
@@ -343,7 +343,8 @@
.wp-mod-badge--mod,
.wp-mod-badge--drift,
.wp-mod-badge--missing,
.wp-mod-badge--clash {
.wp-mod-badge--clash,
.wp-mod-badge--on {
flex-shrink: 0;
}
.wp-mod-badge--mod {
@@ -358,6 +359,13 @@
background: color-mix(in oklab, var(--wp-danger) 18%, transparent);
color: var(--wp-danger);
}
/* `--on` variant: per-frame ENABLE override — module is off on base but turned
* ON for this frame. Green (--wp-success) to read as "running here", the
* positive inverse of the `--missing` red used for the off-#k badge. */
.wp-mod-badge--on {
background: color-mix(in oklab, var(--wp-success) 18%, transparent);
color: var(--wp-success);
}
/* `--clash` variant: id reused by a DIFFERENT kind (shared id-space).
* Violet (--wp-accent) — distinct from MOD orange / DRIFT amber / MISSING
* red so a cross-kind collision is unmistakable. */
+117
View File
@@ -0,0 +1,117 @@
/**
* TS mirror of engine/seed_derive.py — lets the seed modal preview the
* per-iteration seed list without running the graph. Parity is guarded
* by tests/fixtures/seed-derive-corpus.json. Values masked to 50 bits.
*/
export type SeedStrategy = "sequential" | "hash_index" | "prime_stride";
const MASK = (1n << 50n) - 1n;
export function deriveLoopSeeds(base: number, count: number, strategy: SeedStrategy): number[] {
if (count <= 1) return [Number(BigInt(base) & MASK)];
if (strategy === "sequential") {
return Array.from({ length: count }, (_, i) => Number((BigInt(base) + BigInt(i)) & MASK));
}
if (strategy === "prime_stride") {
return Array.from({ length: count }, (_, i) => Number((BigInt(base) + BigInt(i) * 1000003n) & MASK));
}
if (strategy === "hash_index") {
return Array.from({ length: count }, (_, i) => {
const digest = sha256Bytes(`${base}:${i}`);
let v = 0n;
for (let b = 0; b < 8; b++) v = (v << 8n) | BigInt(digest[b]);
return Number(v & MASK);
});
}
throw new Error(`unknown loop seed strategy: ${strategy}`);
}
export function applySeedLocks(derived: number[], locks: Record<number, number>): number[] {
return derived.map((s, i) =>
Object.prototype.hasOwnProperty.call(locks, i) ? Number(BigInt(locks[i]) & MASK) : s,
);
}
/**
* TS mirror of engine/seed_derive.py `effective_chain_seed` — the seed a
* WP_Context node rolls at iteration `loopIndex`. An override (the loop's
* already-per-iteration derived seed) is used verbatim; only a constant
* widget seed (no override) gets the loop_index XOR. Mirrors the engine's
* no-double-shift rule so a per-frame seed lock pins the seed the module
* actually rolls at that frame.
*/
export function effectiveChainSeed(
widgetSeed: number, seedOverride: number | null, loopIndex: number,
): number {
const base = seedOverride != null ? BigInt(seedOverride) : BigInt(widgetSeed);
if (loopIndex === 0 || seedOverride != null) return Number(base & MASK);
const digest = sha256Bytes(`loop:${loopIndex}`);
let shift = 0n;
for (let b = 0; b < 8; b++) shift = (shift << 8n) | BigInt(digest[b]);
return Number((base ^ shift) & MASK);
}
const LOCK_LINE = /^\s*#(\d+)\s*:\s*(-?\d+)\s*$/;
/**
* Parse the `#N: seed` lines produced by the seed modal's Copy (1-based
* display index) back into a 0-based seed_locks map. Non-matching lines are
* ignored; values are masked to 50 bits like the engine (negatives normalize
* deterministically). UI-only — no Python mirror, so no parity corpus.
*/
export function parseSeedLocks(text: string): Record<string, number> {
const out: Record<string, number> = {};
for (const line of text.split(/\r?\n/)) {
const m = line.match(LOCK_LINE);
if (!m) continue;
const display = Number(m[1]);
if (display < 1) continue; // #N is 1-based; #0 / #-1 are invalid
out[String(display - 1)] = Number(BigInt(m[2]) & MASK);
}
return out;
}
function sha256Bytes(msg: string): Uint8Array {
const K = [
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
let h0=0x6a09e667,h1=0xbb67ae85,h2=0x3c6ef372,h3=0xa54ff53a,h4=0x510e527f,h5=0x9b05688c,h6=0x1f83d9ab,h7=0x5be0cd19;
const bytes = new TextEncoder().encode(msg);
const bitLen = bytes.length * 8;
const padded = new Uint8Array((Math.ceil((bytes.length + 1 + 8) / 64)) * 64);
padded.set(bytes); padded[bytes.length] = 0x80;
const dv = new DataView(padded.buffer);
dv.setUint32(padded.length - 4, bitLen >>> 0);
dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
const w = new Uint32Array(64);
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15] >>> 3);
const s1 = rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2] >>> 10);
w[i] = (w[i-16] + s0 + w[i-7] + s1) | 0;
}
let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,hh=h7;
for (let i = 0; i < 64; i++) {
const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25);
const ch = (e & f) ^ (~e & g);
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
const S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) | 0;
hh=g; g=f; f=e; e=(d + t1)|0; d=c; c=b; b=a; a=(t1 + t2)|0;
}
h0=(h0+a)|0; h1=(h1+b)|0; h2=(h2+c)|0; h3=(h3+d)|0; h4=(h4+e)|0; h5=(h5+f)|0; h6=(h6+g)|0; h7=(h7+hh)|0;
}
const out = new Uint8Array(32);
const odv = new DataView(out.buffer);
[h0,h1,h2,h3,h4,h5,h6,h7].forEach((hv, i) => odv.setUint32(i*4, hv >>> 0));
return out;
}
@@ -300,6 +300,72 @@ describe("collapse-connections — unified label + resize side effects", () => {
expect(slotLabelStashed.has(node.inputs[1])).toBe(false);
});
it("expandedLabel heals a reloaded node: placeholder baked into .label, empty stash → name on expand", () => {
// Repro of the reload bug: a node saved collapsed serialized the
// placeholders ("inputs ×N" on the first matched pin, " " on the rest)
// into the workflow JSON, so it reloads with them baked into .label and
// an EMPTY in-memory stash. The expandedLabel resolver reconstructs the
// name-fallback from data instead of restoring the dead/placeholder
// stash — no pre-attach repair needed.
const node = makeNode({
inputs: [
{ name: "context_in", label: "context" },
{ name: "input_0", label: "inputs ×3" }, // ← stale placeholder
{ name: "input_1", label: " " },
{ name: "input_2", label: " " },
],
inputPositions: [[0, 0], [0, 20], [0, 40], [0, 60]],
size: [240, 80],
});
node.properties.collapse_connections = true;
attachCollapsableConnections(node, {
matchInput,
collapsedInputLabel: () => "inputs ×3",
expandedLabel: () => undefined,
});
expect(node.inputs[1].label).toBe("inputs ×3"); // unified on first matched
expect(node.inputs[2].label).toBe(" "); // rest blanked
setCollapsed(node, false);
expect(node.inputs[1].label).toBeUndefined(); // healed: name-fallback, NOT placeholder
expect(node.inputs[2].label).toBeUndefined();
expect(node.inputs[3].label).toBeUndefined();
expect(node.inputs[0].label).toBe("context"); // non-matched fixed input untouched
});
it("expandedLabel mode never stashes, so collapse→expand reconstructs cleanly", () => {
const node = makeNode({
inputs: [{ name: "input_0" }, { name: "input_1" }, { name: "input_2" }],
inputPositions: [[0, 0], [0, 20], [0, 40]],
});
attachCollapsableConnections(node, {
matchInput,
collapsedInputLabel: () => "inputs ×3",
expandedLabel: () => undefined,
});
setCollapsed(node, true);
expect(node.inputs[0].label).toBe("inputs ×3");
expect(slotLabelStashed.has(node.inputs[0])).toBe(false); // reconstruction owns the label
setCollapsed(node, false);
expect(node.inputs[0].label).toBeUndefined();
expect(node.inputs[1].label).toBeUndefined();
});
it("expandedLabel returning a string sets that label on expand", () => {
const node = makeNode({
inputs: [{ name: "input_0", label: " " }, { name: "input_1", label: " " }],
inputPositions: [[0, 0], [0, 20]],
});
node.properties.collapse_connections = true;
attachCollapsableConnections(node, {
matchInput,
collapsedInputLabel: () => "inputs",
expandedLabel: (s) => s.name,
});
setCollapsed(node, false);
expect(node.inputs[0].label).toBe("input_0");
expect(node.inputs[1].label).toBe("input_1");
});
it("collapsedInputLabel undefined keeps the first slot's original label", () => {
const node = makeInjectorLike();
attachCollapsableConnections(node, { matchInput });
+44 -6
View File
@@ -64,6 +64,20 @@ export interface CollapseConfig {
collapsedInputLabel?: string | ((node: CollapseTargetNode) => string);
/** Mirror of collapsedInputLabel for outputs. */
collapsedOutputLabel?: string | ((node: CollapseTargetNode) => string);
/** Resolver for a matched slot's EXPANDED label, reconstructed from
* durable data rather than the in-memory stash. When provided, the
* module skips the stash entirely: collapse shows the placeholder,
* and expand calls this to recompute each matched slot's label
* (return `undefined` to clear `.label` and fall back to the slot
* name). This is robust across a reload — the stash is an in-memory
* WeakMap that doesn't survive serialization, so a reloaded collapsed
* node would otherwise restore the serialized placeholder ("inputs
* ×N") instead of the real label. Use for nodes whose expanded label
* is derivable from data they already persist (e.g. the injector's
* `input_N` pins, whose label is just the slot name). Omit to keep
* the default stash-based restore (preserves arbitrary custom labels
* within a session). */
expandedLabel?: (slot: Slot, index: number) => string | undefined;
}
/** Internal — minimal node surface used by the patch. Kept generic
@@ -191,19 +205,43 @@ export function applyCollapsedLabels(node: CollapseTargetNode): void {
// once has its label stashed (even if as `undefined`) —
// re-stashing would overwrite it with our placeholder (" ")
// and corrupt the next restore.
if (!slotLabelStashed.has(slot as object)) {
//
// SKIP the stash entirely when an expandedLabel resolver is
// configured — that mode reconstructs the expanded label from
// durable data, and stashing a reloaded node's live .label
// (which IS the serialized placeholder) would corrupt the very
// restore the resolver exists to prevent.
if (!cfg.expandedLabel && !slotLabelStashed.has(slot as object)) {
slotOrigLabel.set(slot as object, slot.label);
slotLabelStashed.add(slot as object);
}
const stashed = slotOrigLabel.get(slot as object);
if (i === first) {
// Unified label if configured; otherwise restore the
// original (handles cases where the caller doesn't supply
// a label but still wants other slots blanked).
slot.label = label !== undefined ? label : stashed;
// Unified label if configured; otherwise fall back to the
// stashed original (stash mode only — handles callers that
// blank siblings without supplying a unified label).
const fallback = cfg.expandedLabel ? undefined : slotOrigLabel.get(slot as object);
slot.label = label !== undefined ? label : fallback;
} else {
slot.label = " ";
}
} else if (cfg.expandedLabel) {
// EXPANDED, data-reconstruction mode. Recompute the label from
// durable data for EVERY matched slot — stashed or not — so a
// reloaded node (in-memory stash gone, placeholder baked into
// the serialized .label) heals correctly. `undefined` clears
// .label so litegraph's name-fallback shows the slot name.
const canonical = cfg.expandedLabel(slot, i);
if (canonical === undefined) {
delete slot.label;
} else {
slot.label = canonical;
}
// Drop any legacy stash (from a build before expandedLabel) so
// it can't shadow the reconstruction on a later collapse pass.
if (slotLabelStashed.has(slot as object)) {
slotOrigLabel.delete(slot as object);
slotLabelStashed.delete(slot as object);
}
} else if (slotLabelStashed.has(slot as object)) {
// Restore. If the original was undefined, delete the .label
// key so litegraph's name-fallback resumes — assigning
+50
View File
@@ -9,6 +9,7 @@ import {
findDownstreamAssemblers,
findRootGraph,
hasUpstreamLoopOverridingSeed,
resolveUpstreamLoopSeed,
type LiteGraphLike,
type LiteNodeLike,
} from "./graph";
@@ -36,6 +37,55 @@ function fakeContextNode(id: number, vars: string[], upstreamLink?: number): Lit
};
}
describe("resolveUpstreamLoopSeed", () => {
function loopNode(seed: number, count: number, cfg: object): LiteNodeLike {
return {
id: 1,
type: "WP_ContextLoop",
outputs: [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }],
widgets: [
{ name: "seed", value: seed },
{ name: "count", value: count },
{ name: "wp_context_loop_config", value: JSON.stringify(cfg) },
],
} as LiteNodeLike;
}
function mkGraph(loop: LiteNodeLike, ctx: LiteNodeLike): LiteGraphLike {
return {
_nodes: [loop, ctx],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 1: loop, 2: ctx } as Record<number, LiteNodeLike>)[id] ?? null,
};
}
it("reads the Loop's NUMERIC seed + count (regression: widgetValue is string-only)", () => {
const loop = loopNode(175588593219397, 4, { override_seed: true, strategy: "hash_index", seed_locks: {} });
const ctx = fakeContextNode(2, [], 100);
expect(resolveUpstreamLoopSeed(mkGraph(loop, ctx), ctx)).toEqual({
overrideSeed: true,
baseSeed: 175588593219397,
count: 4,
strategy: "hash_index",
seedLocks: {},
});
});
it("returns null when no Loop is upstream", () => {
const ctx = fakeContextNode(2, [], undefined);
const graph: LiteGraphLike = { _nodes: [ctx], links: {}, getNodeById: () => null };
expect(resolveUpstreamLoopSeed(graph, ctx)).toBeNull();
});
it("carries override_seed=false + the Loop's per-iteration seed_locks", () => {
const loop = loopNode(10, 3, { override_seed: false, strategy: "sequential", seed_locks: { "1": 999 } });
const ctx = fakeContextNode(2, [], 100);
const got = resolveUpstreamLoopSeed(mkGraph(loop, ctx), ctx);
expect(got?.overrideSeed).toBe(false);
expect(got?.strategy).toBe("sequential");
expect(got?.seedLocks).toEqual({ "1": 999 });
});
});
describe("collectUpstreamVariables", () => {
it("returns the upstream chain's writes only — never the starting node's own", () => {
const a = fakeContextNode(1, ["style"]);
+66
View File
@@ -13,6 +13,7 @@ import {
type WildcardOption,
} from "../components/context/editors/wildcard/probability";
import { ensure as ensurePreviewLookup, lookup as previewLookup } from "./preview-resolver";
import type { SeedStrategy } from "../components/shared/seed-derive";
// ── Subgraph boundary primer ────────────────────────────────────────────
// ComfyUI subgraphs are nested LGraph instances attached to a parent node
@@ -100,6 +101,14 @@ function widgetValue(node: LiteNodeLike, name: string): string {
return typeof w?.value === "string" ? w.value : "";
}
/** Numeric widget read — `widgetValue` is string-only (returns "" for Int /
* Float / combo widgets), so a numeric widget like the Loop's `seed`/`count`
* must be read here. Coerces the raw value; NaN when absent/unparseable. */
function widgetNumber(node: LiteNodeLike, name: string): number {
const w = node.widgets?.find((x) => x.name === name);
return typeof w?.value === "number" ? w.value : Number(w?.value);
}
function isSubgraphNode(n: LiteNodeLike): boolean {
if (typeof n.isSubgraphNode === "function") {
@@ -430,6 +439,63 @@ export function hasUpstreamLoopOverridingSeed(
return false;
}
export interface UpstreamLoopSeed {
overrideSeed: boolean;
baseSeed: number;
count: number;
strategy: SeedStrategy;
/** 0-based iteration index (stringified) -> pinned seed (the Loop's own
* per-iteration seed-modal locks). Overlaid on the derived series. */
seedLocks: Record<string, number>;
}
/**
* Walk upstream to the first WP_ContextLoop and return its seed-deriving
* config — base seed + count + strategy + override flag + per-iteration locks —
* or null when no Loop drives this node. Lets a downstream WP_Context compute
* the seed each iteration actually rolls (for the per-frame seed lock). Same
* chain walk as `hasUpstreamLoopOverridingSeed`.
*/
export function resolveUpstreamLoopSeed(
rootGraph: LiteGraphLike,
node: LiteNodeLike,
): UpstreamLoopSeed | null {
const parents = buildSubgraphParents(rootGraph);
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
while (cur && !seen.has(locator(cur.graph, cur.node))) {
seen.add(locator(cur.graph, cur.node));
if (cur.node.type === "WP_ContextLoop" && !isSkippedMode(cur.node)) {
const cfg = parseWidgetJson<{
strategy?: string;
override_seed?: boolean;
seed_locks?: Record<string, number>;
}>(widgetValue(cur.node, "wp_context_loop_config"), {});
const strategy: SeedStrategy =
cfg.strategy === "sequential" || cfg.strategy === "prime_stride"
? cfg.strategy
: "hash_index";
const rawSeed = widgetNumber(cur.node, "seed");
const rawCount = widgetNumber(cur.node, "count");
const seedLocks: Record<string, number> = {};
if (cfg.seed_locks && typeof cfg.seed_locks === "object") {
for (const [k, v] of Object.entries(cfg.seed_locks)) {
if (typeof v === "number" && Number.isFinite(v)) seedLocks[k] = v;
}
}
return {
overrideSeed: cfg.override_seed === true,
baseSeed: Number.isFinite(rawSeed) ? rawSeed : 0,
count: Number.isFinite(rawCount) && rawCount >= 1 ? Math.floor(rawCount) : 1,
strategy,
seedLocks,
};
}
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
}
return null;
}
/**
* Variable bindings contributed by every `WP_ContextInjector` upstream
* of `node`. Used by the assembler so the in-template preview shows
+33
View File
@@ -1,10 +1,12 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { createMemoryHistory, createRouter, type Router } from "vue-router";
import AppTopbar from "../layout/AppTopbar.vue";
import { useUiStore } from "../stores/uiStore";
import * as releaseCheck from "../composables/useReleaseCheck";
// Community status pill / sign-in / user-menu surfaces moved to the
// `feat/community-tab` branch alongside the views that need them. Topbar
@@ -109,4 +111,35 @@ describe("AppTopbar.vue", () => {
expect(backSpy).toHaveBeenCalledOnce();
backSpy.mockRestore();
});
it("clicking the update pill opens the dialog instead of navigating", async () => {
// Force an available update so the pill renders, and stub the dialog
// to a marker that only appears while open.
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
current: "2.9.0",
latestVersion: ref("2.10.0"),
hasUpdate: ref(true),
severity: ref("minor"),
releaseBody: ref("- notes"),
releaseUrl: ref("https://x/r"),
lastChecked: ref(null),
checking: ref(false),
checkNow: vi.fn(),
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
const router = makeRouter();
await router.isReady();
const wrap = mount(AppTopbar, {
global: {
plugins: [router],
stubs: { UpdateDialog: { template: '<div data-test="dlg-stub" v-if="open" />', props: ["open"] } },
},
});
await flushPromises();
expect(wrap.find('[data-test="dlg-stub"]').exists()).toBe(false);
await wrap.find('[data-test="topbar-update-indicator"]').trigger("click");
expect(wrap.find('[data-test="dlg-stub"]').exists()).toBe(true);
expect(router.currentRoute.value.path).not.toBe("/dashboard");
});
});
@@ -423,4 +423,49 @@ describe("DerivationRuleCard.vue", () => {
const wrap = mountCard(makeRule(), 0);
expect(wrap.find("datalist").exists()).toBe(false);
});
describe("collapse (#9)", () => {
// v-show toggles an inline `display: none`; assert on that directly since
// isVisible() is unreliable under jsdom for v-show.
const hidden = (w: { attributes: (n: string) => string | undefined }) =>
(w.attributes("style") ?? "").includes("display: none");
it("branches start collapsed; the chevron toggles the body + peek", async () => {
const wrap = mountCard(makeRule(), 0);
const body = () => wrap.find('[data-test="branch-0-0"] .branch-body');
// Collapsed by default → body hidden, peek shown.
expect(hidden(body())).toBe(true);
expect(wrap.find('[data-test="branch-peek-0-0"]').text()).toBe("$x → $out");
// Expand.
await wrap.get('[data-test="toggle-branch-0-0"]').trigger("click");
expect(hidden(body())).toBe(false);
expect(wrap.find('[data-test="branch-peek-0-0"]').exists()).toBe(false);
});
it("per-rule collapse/expand-all-branches buttons toggle every branch", async () => {
const wrap = mountCard(makeRule(), 0);
const body = () => wrap.find('[data-test="branch-0-0"] .branch-body');
// Expand all first (default is collapsed), then collapse all again.
await wrap.get('[data-test="expand-branches-0"]').trigger("click");
expect(hidden(body())).toBe(false);
await wrap.get('[data-test="collapse-branches-0"]').trigger("click");
expect(hidden(body())).toBe(true);
});
it("adopts the collapseCommand broadcast (Collapse all / Expand all)", async () => {
const wrap = mount(DerivationRuleCard, {
props: {
modelValue: makeRule(),
index: 0,
collapseCommand: { nonce: 0, collapsed: false },
},
});
const branches = () => wrap.find(".branches");
expect(hidden(branches())).toBe(false);
await wrap.setProps({ collapseCommand: { nonce: 1, collapsed: true } });
expect(hidden(branches())).toBe(true);
await wrap.setProps({ collapseCommand: { nonce: 2, collapsed: false } });
expect(hidden(branches())).toBe(false);
});
});
});
+17
View File
@@ -1,5 +1,6 @@
import { mount } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Settings from "../views/Settings.vue";
@@ -15,6 +16,22 @@ vi.mock("../api/client", () => ({
ApiError: class ApiError extends Error {},
}));
// The Updates card calls useReleaseCheck, which otherwise fires a real
// GitHub fetch on mount. Stub it — these tests cover the other cards.
vi.mock("../composables/useReleaseCheck", () => ({
useReleaseCheck: () => ({
current: "2.9.0",
latestVersion: ref(null),
hasUpdate: ref(false),
severity: ref(null),
releaseBody: ref(null),
releaseUrl: ref(null),
lastChecked: ref(null),
checking: ref(false),
checkNow: vi.fn(),
}),
}));
const reloadMock = vi.fn();
beforeEach(() => {
@@ -59,4 +59,47 @@ describe("SelectionToolbar.vue", () => {
expect(wrap.emitted("delete-selected")).toBeTruthy();
expect(wrap.emitted("clear")).toBeTruthy();
});
it("Apply menu hides tags already on every selected row (commonTags)", async () => {
const wrap = mount(SelectionToolbar, {
props: { count: 2, tags: ["warm", "cool"], commonTags: ["warm"] },
});
await clickBar(wrap, "Apply sub-category");
const labels = wrap.findAll(".wpc-seltoolbar__menuitem").map((b) => b.text());
expect(labels).toContain("cool");
expect(labels).not.toContain("warm");
});
it("Remove menu lists only tags present on the selection", async () => {
const wrap = mount(SelectionToolbar, {
props: { count: 2, tags: ["warm", "cool", "neon"], presentTags: ["neon"] },
});
await clickBar(wrap, "Remove sub-category");
const labels = wrap.findAll(".wpc-seltoolbar__menuitem").map((b) => b.text());
expect(labels).toEqual(["neon"]);
});
it("Remove menu is empty when the selection carries no tags", async () => {
const wrap = mount(SelectionToolbar, {
props: { count: 1, tags: ["warm", "cool"], presentTags: [] },
});
await clickBar(wrap, "Remove sub-category");
expect(wrap.findAll(".wpc-seltoolbar__menuitem")).toHaveLength(0);
expect(wrap.find(".wpc-seltoolbar__menuempty").exists()).toBe(true);
});
it("menu chips carry their tag's axis hue as --chip-hue", async () => {
const wrap = mount(SelectionToolbar, {
props: {
count: 1,
tags: ["warm"],
tagHues: { warm: "hsl(1 2% 3%)" },
},
});
await clickBar(wrap, "Apply sub-category");
const item = wrap
.findAll(".wpc-seltoolbar__menuitem")
.find((b) => b.text() === "warm");
expect(item!.attributes("style")).toContain("--chip-hue: hsl(1 2% 3%)");
});
});
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { ref } from "vue";
import UpdateDialog from "../../components/UpdateDialog.vue";
import * as managerUpdate from "../../composables/useComfyManagerUpdate";
import * as releaseCheck from "../../composables/useReleaseCheck";
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
function stubRelease(over: Partial<ReturnType<typeof releaseCheck.useReleaseCheck>> = {}) {
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
current: "2.9.0",
latestVersion: ref("2.10.0"),
hasUpdate: ref(true),
severity: ref("minor"),
releaseBody: ref("## What's new\n- Cool thing"),
releaseUrl: ref("https://github.com/x/releases/2.10.0"),
lastChecked: ref(new Date().toISOString()),
checking: ref(false),
checkNow: vi.fn(),
...over,
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
}
function stubManager(over: Partial<ReturnType<typeof managerUpdate.useComfyManagerUpdate>> = {}) {
const base = {
phase: ref<managerUpdate.UpdatePhase>("idle"),
errorKind: ref<managerUpdate.UpdateErrorKind>(null),
errorMessage: ref<string | null>(null),
probe: vi.fn().mockResolvedValue("available" as const),
runUpdate: vi.fn(),
reboot: vi.fn(),
managerUiUrl: "/manager",
};
const merged = { ...base, ...over };
vi.spyOn(managerUpdate, "useComfyManagerUpdate").mockReturnValue(
merged as ReturnType<typeof managerUpdate.useComfyManagerUpdate>,
);
return merged;
}
function mountDialog() {
return mount(UpdateDialog, {
props: { open: true },
global: { stubs: { teleport: true } },
});
}
beforeEach(() => {
setActivePinia(createPinia());
vi.restoreAllMocks();
});
describe("UpdateDialog", () => {
it("shows the version delta and rendered notes", async () => {
stubRelease();
stubManager();
const wrap = mountDialog();
await flushPromises();
expect(wrap.text()).toContain("2.9.0");
expect(wrap.text()).toContain("2.10.0");
expect(wrap.find(".wpc-relnotes").html()).toContain("<li>Cool thing</li>");
});
it("Update Now button calls runUpdate when available + idle", async () => {
stubRelease();
const mgr = stubManager();
const wrap = mountDialog();
await flushPromises();
await wrap.get('[data-test="update-now"]').trigger("click");
expect(mgr.runUpdate).toHaveBeenCalled();
});
it("shows Restart button only in staged phase and calls reboot", async () => {
stubRelease();
const mgr = stubManager({ phase: ref("staged") });
const wrap = mountDialog();
await flushPromises();
expect(wrap.find('[data-test="update-now"]').exists()).toBe(false);
await wrap.get('[data-test="update-restart"]').trigger("click");
expect(mgr.reboot).toHaveBeenCalled();
});
it("shows the fallback card when Manager is absent", async () => {
stubRelease();
stubManager({ probe: vi.fn().mockResolvedValue("absent" as const) });
const wrap = mountDialog();
await flushPromises();
expect(wrap.find('[data-test="update-fallback"]').exists()).toBe(true);
expect(wrap.find('[data-test="update-now"]').exists()).toBe(false);
});
it("shows the fallback card on a forbidden error", async () => {
stubRelease();
stubManager({ phase: ref("error"), errorKind: ref("forbidden") });
const wrap = mountDialog();
await flushPromises();
expect(wrap.find('[data-test="update-fallback"]').exists()).toBe(true);
});
it("emits close from the Later button", async () => {
stubRelease();
stubManager();
const wrap = mountDialog();
await flushPromises();
await wrap.get('[data-test="update-later"]').trigger("click");
expect(wrap.emitted("close")).toBeTruthy();
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { ref } from "vue";
import { useBulkSelection } from "../../composables/useBulkSelection";
describe("useBulkSelection", () => {
it("toggles individual ids", () => {
const rows = ref(["a", "b", "c"]);
const s = useBulkSelection(() => rows.value);
expect(s.count.value).toBe(0);
s.toggle("a");
s.toggle("c");
expect(s.count.value).toBe(2);
expect(s.isSelected("a")).toBe(true);
expect(s.isSelected("b")).toBe(false);
s.toggle("a");
expect(s.isSelected("a")).toBe(false);
expect(s.count.value).toBe(1);
});
it("select-all / clear via toggleAll", () => {
const rows = ref(["a", "b"]);
const s = useBulkSelection(() => rows.value);
expect(s.allSelected.value).toBe(false);
s.toggleAll();
expect(s.allSelected.value).toBe(true);
expect(s.count.value).toBe(2);
s.toggleAll(); // all selected → clears
expect(s.count.value).toBe(0);
});
it("someSelected drives the indeterminate state", () => {
const rows = ref(["a", "b"]);
const s = useBulkSelection(() => rows.value);
expect(s.someSelected.value).toBe(false);
s.toggle("a");
expect(s.someSelected.value).toBe(true);
expect(s.allSelected.value).toBe(false);
});
it("ignores ids no longer present (count + selectedIds prune)", () => {
const rows = ref(["a", "b", "c"]);
const s = useBulkSelection(() => rows.value);
s.toggleAll();
expect(s.count.value).toBe(3);
// A row is removed elsewhere; its stale id must stop counting.
rows.value = ["a", "b"];
expect(s.count.value).toBe(2);
expect(s.selectedIds().sort()).toEqual(["a", "b"]);
expect(s.allSelected.value).toBe(true);
});
it("toggleMode enters/leaves and leaving clears the selection", () => {
const rows = ref(["a"]);
const s = useBulkSelection(() => rows.value);
s.toggleMode();
expect(s.active.value).toBe(true);
s.toggle("a");
expect(s.count.value).toBe(1);
s.toggleMode(); // leave
expect(s.active.value).toBe(false);
expect(s.count.value).toBe(0);
});
it("allSelected is false for an empty list", () => {
const rows = ref<string[]>([]);
const s = useBulkSelection(() => rows.value);
expect(s.allSelected.value).toBe(false);
});
});
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useComfyManagerUpdate } from "../../composables/useComfyManagerUpdate";
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
beforeEach(() => vi.restoreAllMocks());
/** Route a fetch mock by URL + method. `status` route returns `done`
* immediately unless overridden. */
function mockManager(opts: {
install?: { ok: boolean; status?: number; text?: string };
start?: { ok: boolean };
status?: unknown;
reboot?: () => Promise<Response>;
} = {}) {
return vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? "GET";
if (url.includes("/manager/queue/install")) {
const o = opts.install ?? { ok: true };
return { ok: o.ok, status: o.status ?? (o.ok ? 200 : 500), text: async () => o.text ?? "" } as Response;
}
if (url.includes("/manager/queue/start")) {
const o = opts.start ?? { ok: true };
return { ok: o.ok, status: o.ok ? 200 : 500, text: async () => "" } as Response;
}
if (url.includes("/manager/queue/status")) {
const s = opts.status ?? { total_count: 1, done_count: 1, in_progress: false };
return { ok: true, status: 200, json: async () => s } as Response;
}
if (url.includes("/manager/reboot")) {
if (opts.reboot) return opts.reboot();
return { ok: true, status: 200, text: async () => "" } as Response;
}
void method;
throw new Error(`unexpected url ${url}`);
});
}
describe("useComfyManagerUpdate", () => {
it("probe returns available on 200 status", async () => {
vi.stubGlobal("fetch", mockManager());
const u = useComfyManagerUpdate();
expect(await u.probe()).toBe("available");
});
it("probe returns absent on network error", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("no manager")));
const u = useComfyManagerUpdate();
expect(await u.probe()).toBe("absent");
});
it("runUpdate happy path: idle -> installing -> staged", async () => {
const fetchMock = mockManager();
vi.stubGlobal("fetch", fetchMock);
const u = useComfyManagerUpdate();
const p = u.runUpdate("2.10.0"); // newer than __APP_VERSION__ 2.9.0
expect(u.phase.value).toBe("installing");
await p;
expect(u.phase.value).toBe("staged");
// Pins the exact target + uses the fresh (remote) catalog, never "latest"/"cache".
const installCall = fetchMock.mock.calls.find((c) => String(c[0]).includes("/manager/queue/install"));
const body = JSON.parse((installCall![1] as RequestInit).body as string);
expect(body.selected_version).toBe("2.10.0");
expect(body.mode).toBe("remote");
});
it("runUpdate refuses a downgrade / non-newer target (2.10.0 bug guard)", async () => {
const fetchMock = mockManager();
vi.stubGlobal("fetch", fetchMock);
const u = useComfyManagerUpdate();
await u.runUpdate("2.8.0"); // older than installed 2.9.0
expect(u.phase.value).toBe("error");
expect(u.errorKind.value).toBe("failed");
// Never hit the install endpoint.
expect(fetchMock.mock.calls.some((c) => String(c[0]).includes("/manager/queue/install"))).toBe(false);
});
it("runUpdate maps install 403 to forbidden error", async () => {
vi.stubGlobal("fetch", mockManager({ install: { ok: false, status: 403, text: "security" } }));
const u = useComfyManagerUpdate();
await u.runUpdate("2.10.0");
expect(u.phase.value).toBe("error");
expect(u.errorKind.value).toBe("forbidden");
});
it("runUpdate maps a non-403 install failure to failed error", async () => {
vi.stubGlobal("fetch", mockManager({ install: { ok: false, status: 500, text: "boom" } }));
const u = useComfyManagerUpdate();
await u.runUpdate("2.10.0");
expect(u.phase.value).toBe("error");
expect(u.errorKind.value).toBe("failed");
});
it("reboot sets restarting and treats a dropped connection as soft success", async () => {
vi.stubGlobal("fetch", mockManager({ reboot: () => Promise.reject(new Error("socket closed")) }));
const u = useComfyManagerUpdate();
await u.reboot();
expect(u.phase.value).toBe("restarting");
expect(u.errorKind.value).toBeNull();
});
});
@@ -1,20 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { useReleaseCheck, type UpdateSeverity } from "../../composables/useReleaseCheck";
import { useReleaseCheck, resetReleaseCheckSession } from "../../composables/useReleaseCheck";
import { useUiStore } from "../../stores/uiStore";
// __APP_VERSION__ is injected by vite at build time; vitest doesn't
// apply the define plugin, so we wire it onto globalThis here.
// __APP_VERSION__ is injected by vite at build time; vitest doesn't apply
// the define plugin, so we wire it onto globalThis here.
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "1.7.0";
const STORAGE_KEY = "wp.releaseCheck";
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
type CheckResult = ReturnType<typeof useReleaseCheck>;
let lastResult: CheckResult | null = null;
@@ -24,112 +21,144 @@ function host() {
setup() {
const r = useReleaseCheck();
lastResult = r;
return () => h("span", { "data-test": "version" }, r.current);
return () => h("span", r.current);
},
});
}
/** Flush onMounted → await fetch → applyLatest → render. */
async function settle() {
await nextTick();
await Promise.resolve();
await Promise.resolve();
await nextTick();
}
function releaseResponse(tag: string, body = "notes", url = "https://x/releases/v") {
return { ok: true, json: async () => ({ tag_name: tag, body, html_url: url }) };
}
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
setActivePinia(createPinia());
resetReleaseCheckSession();
});
describe("useReleaseCheck", () => {
it("flags hasUpdate when GitHub returns a newer tag", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: "v1.8.0" }),
});
it("fetches on launch and surfaces version, body, url, lastChecked", async () => {
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0", "## New", "https://x/r/1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
// Two ticks: onMounted -> await fetch -> applyLatest mutation -> render.
await nextTick();
await Promise.resolve();
await Promise.resolve();
await nextTick();
expect(fetchMock).toHaveBeenCalled();
// Verify it persisted to localStorage so a re-mount uses the cache.
const cached = JSON.parse(localStorage.getItem(STORAGE_KEY)!) as {
latest_version: string;
};
expect(cached.latest_version).toBe("1.8.0");
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(lastResult!.hasUpdate.value).toBe(true);
expect(lastResult!.latestVersion.value).toBe("1.8.0");
expect(lastResult!.releaseBody.value).toBe("## New");
expect(lastResult!.releaseUrl.value).toBe("https://x/r/1.8.0");
expect(lastResult!.lastChecked.value).not.toBeNull();
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).latest_version).toBe("1.8.0");
wrap.unmount();
});
it("uses cached value within TTL — no fetch", async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
checked_at: new Date().toISOString(),
latest_version: "1.6.5",
}),
);
const fetchMock = vi.fn();
it("does NOT fetch on launch when checkOnLaunch is off", async () => {
setActivePinia(createPinia());
useUiStore().setCheckOnLaunch(false);
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await settle();
expect(fetchMock).not.toHaveBeenCalled();
expect(lastResult!.hasUpdate.value).toBe(false);
wrap.unmount();
});
it("refetches once cache is older than 24h", async () => {
const stale = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ checked_at: stale, latest_version: "1.6.5" }),
);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: "v1.7.0" }),
});
it("paints cached value immediately, then refreshes", async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
checked_at: new Date().toISOString(),
latest_version: "1.6.5",
body: "old",
url: "https://x/r/1.6.5",
}));
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.9.0", "fresh", "https://x/r/1.9.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick(); // cache painted before fetch resolves
expect(lastResult!.latestVersion.value).toBe("1.6.5");
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1); // no 24h suppression
expect(lastResult!.latestVersion.value).toBe("1.9.0");
wrap.unmount();
});
it("session guard: a second mount does not refetch", async () => {
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const a = mount(host());
await settle();
const b = mount(host());
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
a.unmount(); b.unmount();
});
it("checkNow() refetches even within the same session", async () => {
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
await lastResult!.checkNow();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(lastResult!.checking.value).toBe(false);
wrap.unmount();
});
it("a legacy/malformed cache blob (no latest_version) does not throw", async () => {
localStorage.setItem(STORAGE_KEY, '{"v":"1.0"}'); // pre-rework shape
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await Promise.resolve();
expect(fetchMock).toHaveBeenCalled();
// Painting the bad cache must not crash; it degrades to no update.
expect(lastResult!.hasUpdate.value).toBe(false);
expect(lastResult!.latestVersion.value).toBeNull();
await settle();
// The launch refresh still recovers a real version.
expect(lastResult!.latestVersion.value).toBe("1.8.0");
wrap.unmount();
});
it("silently no-ops when GitHub is unreachable", async () => {
it("shares state across consumers: checkNow on one lights the other's pill", async () => {
// Two independent consumers (like the topbar + Settings). checkOnLaunch
// off so only the manual check drives state.
setActivePinia(createPinia());
useUiStore().setCheckOnLaunch(false);
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v2.10.1"));
vi.stubGlobal("fetch", fetchMock);
const topbar = mount(host());
const topbarResult = lastResult!;
const settings = mount(host());
const settingsResult = lastResult!;
await settle();
expect(topbarResult.hasUpdate.value).toBe(false);
// Manual check from the "settings" consumer...
await settingsResult.checkNow();
// ...must reflect in the "topbar" consumer without a remount/reload.
expect(topbarResult.hasUpdate.value).toBe(true);
expect(topbarResult.latestVersion.value).toBe("2.10.1");
topbar.unmount(); settings.unmount();
});
it("network failure leaves hasUpdate false and does not throw", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("offline"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await Promise.resolve();
// No exception bubbled; no cache written.
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
wrap.unmount();
});
it.each<[string, UpdateSeverity]>([
["2.0.0", "major"],
["1.8.0", "minor"],
["1.7.1", "patch"],
])("classifies %s over 1.7.0 as %s", async (latest, expectedSeverity) => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: `v${latest}` }),
});
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await Promise.resolve();
await Promise.resolve();
await nextTick();
expect(lastResult).not.toBeNull();
expect(lastResult!.hasUpdate.value).toBe(true);
expect(lastResult!.severity.value).toBe(expectedSeverity);
wrap.unmount();
});
it("severity is null when no update available", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: "v1.6.0" }),
});
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await Promise.resolve();
await Promise.resolve();
await nextTick();
await settle();
expect(lastResult!.hasUpdate.value).toBe(false);
expect(lastResult!.severity.value).toBeNull();
wrap.unmount();
});
});
@@ -227,3 +227,73 @@ describe("buildModulePublishable — constraint axis-name backfill", () => {
expect(inner).not.toHaveProperty("target_wildcard_name");
});
});
describe("buildModulePublishable — constraint axis-group backfill", () => {
// The grouping (tag_groups) lives on the WILDCARDS, not the constraint. These
// are the source/target wildcards as they sit in the live module catalog.
const moodWc = {
id: "mood0001", type: "wildcard", name: "mood",
payload: { var_binding: "mood", tag_groups: { temperament: ["bold", "serene"] } },
} as unknown as ModuleRow;
const colorWc = {
id: "color001", type: "wildcard", name: "color",
payload: {
var_binding: "color",
tag_groups: { saturation: ["vivid", "muted", "pastel"], tone: ["light", "medium", "dark"] },
},
} as unknown as ModuleRow;
// Single-axis wildcard: no tag_groups → nothing to snapshot.
const flatWc = {
id: "flat0001", type: "wildcard", name: "flat", payload: { var_binding: "flat" },
} as unknown as ModuleRow;
const catalog: ModuleRow[] = [moodWc, colorWc, flatWc];
function constraintRow(payload: Record<string, unknown>): ModuleRow {
return {
id: "cn000002", type: "constraint", name: "Mood x Color",
description: "", category_id: null, tags: [], is_favorite: false,
payload, payload_hash: "h".repeat(64), version: 1,
created_at: "2026-01-01", updated_at: "2026-01-01", content_rating: "safe" as const,
} as unknown as ModuleRow;
}
it("snapshots the source/target wildcards' tag_groups into the published constraint", () => {
const row = constraintRow({
source_wildcard_id: "mood0001", target_wildcard_id: "color001", matrix: {}, exceptions: [],
});
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
expect(inner.source_groups).toEqual({ temperament: ["bold", "serene"] });
expect(inner.target_groups).toEqual({
saturation: ["vivid", "muted", "pastel"], tone: ["light", "medium", "dark"],
});
});
it("does NOT overwrite groups already embedded on the payload", () => {
const row = constraintRow({
source_wildcard_id: "mood0001", target_wildcard_id: "color001",
source_groups: { custom: ["x"] }, matrix: {}, exceptions: [],
});
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
expect(inner.source_groups).toEqual({ custom: ["x"] });
expect(inner.target_groups).toBeDefined(); // target still backfilled
});
it("leaves groups absent for a single-axis wildcard or a dangling ref", () => {
const row = constraintRow({
source_wildcard_id: "flat0001", target_wildcard_id: "gone9999", matrix: {}, exceptions: [],
});
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
expect(inner).not.toHaveProperty("source_groups");
expect(inner).not.toHaveProperty("target_groups");
});
it("a constraint carrying snapshotted groups still passes strict validation", () => {
const row = constraintRow({
source_wildcard_id: "mood0001", target_wildcard_id: "color001", matrix: {}, exceptions: [],
});
const pub = buildModulePublishable(row, catalog);
expect(() =>
buildPublishBody({ payload: pub.payload, name: pub.name, description: pub.description }),
).not.toThrow();
});
});
+20
View File
@@ -28,6 +28,26 @@ describe("Input.vue", () => {
expect(wrap.get("input").attributes("aria-invalid")).toBeUndefined();
});
it("emits native step=any for number inputs (no off-grid validation popup)", () => {
// Regression: `min="0.01" step="0.1"` made the browser reject a default
// weight of 1 ("nearest valid values are 0.91 and 1.01"). The native
// step is validation-only here (arrows/wheel/chevrons use bump()), so it
// must be "any" to never grid-validate. bump() still uses props.step.
const wrap = mount(Input, {
props: { modelValue: 1, type: "number", step: 0.1, min: 0.01 },
});
const el = wrap.get("input").element as HTMLInputElement;
expect(el.getAttribute("step")).toBe("any");
expect(el.validity.stepMismatch).toBe(false);
});
it("leaves step untouched on non-number inputs", () => {
const wrap = mount(Input, { props: { modelValue: "x", step: 5 } });
// Non-number inputs never grid-validate, and `step` is meaningless on
// them, so nativeStep passes props.step straight through.
expect(wrap.get("input").attributes("step")).toBe("5");
});
it("numeric stepper rounds to step precision (no float fuzz)", async () => {
// Regression: clicking the up chevron on value=1 with step=0.1
// historically emitted 1.0000000000000002 (JS float math), which
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { renderReleaseNotes } from "../../utils/releaseNotes";
describe("renderReleaseNotes", () => {
it("escapes raw HTML so scripts cannot execute", () => {
const html = renderReleaseNotes("<script>alert(1)</script>");
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("renders headings", () => {
expect(renderReleaseNotes("## Features")).toContain("<h2>Features</h2>");
});
it("renders unordered lists", () => {
const html = renderReleaseNotes("- one\n- two");
expect(html).toContain("<ul>");
expect(html).toContain("<li>one</li>");
expect(html).toContain("<li>two</li>");
});
it("renders bold and inline code", () => {
const html = renderReleaseNotes("**big** and `code`");
expect(html).toContain("<strong>big</strong>");
expect(html).toContain("<code>code</code>");
});
it("renders links with safe rel/target and escapes the href", () => {
const html = renderReleaseNotes("[docs](https://example.com/a)");
expect(html).toContain('href="https://example.com/a"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener nofollow"');
});
it("neutralizes an href that tries to break out of the attribute", () => {
const html = renderReleaseNotes('[x](https://e.com/"><script>alert(1)</script>)');
expect(html).not.toContain("<script>");
});
it("renders fenced code blocks as escaped preformatted text", () => {
const html = renderReleaseNotes("```\nconst a = 1 < 2;\n```");
expect(html).toContain("<pre><code>");
expect(html).toContain("1 &lt; 2");
});
it("returns an empty-notes marker for blank input", () => {
expect(renderReleaseNotes("")).toContain("No release notes");
});
it("unwraps <details>/<summary>/<b> instead of showing literal tags", () => {
const md = "<details>\n<summary><b>📋 Full changelog</b> — details</summary>\n\n- one\n</details>";
const html = renderReleaseNotes(md);
expect(html).not.toContain("&lt;details&gt;");
expect(html).not.toContain("&lt;summary&gt;");
expect(html).not.toContain("<details>");
// Summary becomes a bold lead line; the list still renders.
expect(html).toContain("<strong>");
expect(html).toContain("Full changelog");
expect(html).toContain("<li>one</li>");
});
it("still neutralizes non-allowlisted raw HTML", () => {
const html = renderReleaseNotes("<img src=x onerror=alert(1)>");
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
});
it("renders a · separated link header as links", () => {
const html = renderReleaseNotes("[Docs](https://e.com/d) · [Discord](https://e.com/x)");
expect(html).toContain('href="https://e.com/d"');
expect(html).toContain('href="https://e.com/x"');
expect(html).toContain("·");
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
validateSubcatName,
validateRefGrammarName,
validateVariableName,
isValidVariableName,
} from "../../validation/names";
describe("validation/names", () => {
describe("validateSubcatName (re-exported engine-parity rule)", () => {
it("accepts a clean tag", () => {
expect(validateSubcatName("warm")).toBeNull();
});
it("rejects whitespace", () => {
expect(validateSubcatName("warm tone")).toMatch(/whitespace/i);
});
it("rejects a reserved word", () => {
expect(validateSubcatName("null")).toBeTruthy();
});
it("rejects a disallowed char", () => {
expect(validateSubcatName("a,b")).toBeTruthy();
});
});
describe("validateRefGrammarName", () => {
it("allows spaces (display names)", () => {
expect(validateRefGrammarName("My Wildcard")).toBeNull();
});
it("rejects each reserved ref-grammar char", () => {
for (const c of ["{", "}", ":", "#", "@", ","]) {
expect(validateRefGrammarName(`a${c}b`)).toContain(c);
}
});
});
describe("variable name", () => {
it("accepts a valid identifier", () => {
expect(isValidVariableName("my_var2")).toBe(true);
expect(validateVariableName("my_var2")).toBeNull();
});
it("rejects spaces / leading digit / empty", () => {
expect(isValidVariableName("my var")).toBe(false);
expect(validateVariableName("my var")).toMatch(/letters, digits/i);
expect(isValidVariableName("2fast")).toBe(false);
expect(isValidVariableName("")).toBe(false);
});
});
});
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { ref } from "vue";
import Settings from "../../views/Settings.vue";
import * as releaseCheck from "../../composables/useReleaseCheck";
import { useUiStore } from "../../stores/uiStore";
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
(globalThis as unknown as { __APP_LICENSE__: string }).__APP_LICENSE__ = "MIT";
const checkNow = vi.fn();
beforeEach(() => {
localStorage.clear();
setActivePinia(createPinia());
vi.restoreAllMocks();
checkNow.mockClear();
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
current: "2.9.0",
latestVersion: ref("2.10.0"),
hasUpdate: ref(true),
severity: ref("minor"),
releaseBody: ref(null),
releaseUrl: ref(null),
lastChecked: ref("2026-07-21T10:00:00.000Z"),
checking: ref(false),
checkNow,
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
});
function mountSettings() {
return mount(Settings, {
global: { stubs: { Icon: true, BrowserPrefsCard: true, DatabaseCard: true } },
});
}
describe("Settings Updates card", () => {
it("toggling check-on-launch calls setCheckOnLaunch", async () => {
const wrap = mountSettings();
const ui = useUiStore();
const spy = vi.spyOn(ui, "setCheckOnLaunch");
// The Toggle's clickable control is an inner <button role="switch">;
// the data-test attribute falls through to the outer <label>.
await wrap.get('[data-test="settings-check-on-launch"]').get("button").trigger("click");
expect(spy).toHaveBeenCalledWith(false);
});
it("Check now button calls checkNow", async () => {
const wrap = mountSettings();
await wrap.get('[data-test="settings-check-now"]').trigger("click");
await flushPromises();
expect(checkNow).toHaveBeenCalled();
});
it("shows an update-available status line", () => {
const wrap = mountSettings();
expect(wrap.get('[data-test="settings-update-status"]').text()).toContain("2.10.0");
});
});
+13 -11
View File
@@ -7,6 +7,7 @@ import Checkbox from "../components/ui/Checkbox.vue";
import Input from "../components/ui/Input.vue";
import Modal from "../components/ui/Modal.vue";
import { useCascadeApply, type CascadeApplyRequest } from "./useCascadeApply";
import { validateSubcatName, validateRefGrammarName, validateVariableName } from "../validation/names";
interface Props {
open: boolean;
@@ -33,18 +34,19 @@ const affected = ref<Array<{ kind: string; id: string; name: string }>>([]);
const loading = ref(true);
const error = ref<string>("");
/** Names that flow into the @{uuid#name:subcat} grammar must avoid
* the structural chars (and comma, since subcats are comma-listed).
* Mirrors `wp_api/_validators.py:REF_GRAMMAR_FORBIDDEN_CHARS`. */
/** Validate the typed new name against the SAME canonical validator its
* matching "add"/"create" surface uses (see `validation/names`), keyed by
* what kind of thing is being renamed. Reusing the shared validators keeps
* rename and create in lockstep — previously rename hand-rolled a weaker
* check that let invalid sub-category names (e.g. with spaces) through
* until save (issue #7), and variable renames weren't validated at all. */
const nameError = computed<string | null>(() => {
if (props.kind !== "wildcard" && props.kind !== "subcategory") return null;
if (props.kind === "subcategory" && newName.value === "null") {
return "\"null\" is reserved by the @{uuid:subcat} filter";
}
const bad = newName.value.match(/[{}:#@,]/);
if (bad) {
return `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`;
}
// Empty is gated by `canConfirm` (Rename button stays disabled) — don't
// surface an error while the field is simply blank.
if (!newName.value) return null;
if (props.kind === "subcategory") return validateSubcatName(newName.value);
if (props.kind === "combine_output_var") return validateVariableName(newName.value);
if (props.kind === "wildcard") return validateRefGrammarName(newName.value);
return null;
});
@@ -139,6 +139,52 @@ describe("CascadeRenameDialog", () => {
wrap.unmount();
});
it("blocks a subcategory rename to a name containing a space (issue #7)", async () => {
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
const wrap = mount(CascadeRenameDialog, {
props: { open: true, kind: "subcategory", id: "11111111", initialName: "warm",
extra: { subcat_name: "warm" } },
attachTo: document.body,
});
await flushPromises();
const input = findNameInput();
input!.value = "warm tone"; // contains a space — invalid subcat name
input!.dispatchEvent(new Event("input"));
await flushPromises();
// Validation error is surfaced.
const err = document.body.querySelector(".wp-cascade-rename__error");
expect(err?.textContent ?? "").toContain("whitespace");
// Confirm is disabled and clicking it does not apply the rename.
const btn = document.body.querySelector("[data-test='cascade-rename-confirm']") as HTMLButtonElement;
expect(btn.disabled || btn.getAttribute("aria-disabled") === "true").toBe(true);
btn.click();
await flushPromises();
expect(mockComposable.apply).not.toHaveBeenCalled();
wrap.unmount();
});
it("blocks a combine output-var rename to a non-identifier name", async () => {
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
const wrap = mount(CascadeRenameDialog, {
props: { open: true, kind: "combine_output_var", id: "22222222", initialName: "outfit",
extra: { old_name: "outfit" } },
attachTo: document.body,
});
await flushPromises();
const input = findNameInput();
input!.value = "out fit"; // space — invalid $identifier
input!.dispatchEvent(new Event("input"));
await flushPromises();
const err = document.body.querySelector(".wp-cascade-rename__error");
expect(err?.textContent ?? "").toContain("letters, digits");
const btn = document.body.querySelector("[data-test='cascade-rename-confirm']") as HTMLButtonElement;
expect(btn.disabled || btn.getAttribute("aria-disabled") === "true").toBe(true);
btn.click();
await flushPromises();
expect(mockComposable.apply).not.toHaveBeenCalled();
wrap.unmount();
});
it("rename button disabled when name empty", async () => {
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
const wrap = mount(CascadeRenameDialog, {
@@ -0,0 +1,59 @@
<script setup lang="ts">
/**
* BulkDeleteToolbar — minimal bulk-action bar for editors whose rows have
* nothing to bulk-set (fixed-values, constraint exceptions, derivation
* rules): just "N selected" + Delete + Clear. The richer wildcard bar
* (apply/remove sub-category, set weight) is SelectionToolbar.
*
* Presentational: emits `delete-selected` / `clear`; the host mutates rows +
* tracks dirty.
*/
import Button from "./ui/Button.vue";
defineProps<{
count: number;
/** Plural noun for the Delete label, e.g. "values" → "Delete 3 values". */
noun?: string;
}>();
const emit = defineEmits<{
(e: "delete-selected"): void;
(e: "clear"): void;
}>();
</script>
<template>
<div class="wpc-deltoolbar" role="toolbar" aria-label="Bulk actions">
<span class="wpc-deltoolbar__count">{{ count }} selected</span>
<div class="wpc-deltoolbar__spacer"></div>
<Button
variant="danger"
size="sm"
icon="pi-trash"
:disabled="count === 0"
data-test="bulk-delete"
@click="emit('delete-selected')"
>Delete{{ noun ? ` ${count} ${noun}` : "" }}</Button>
<Button variant="ghost" size="sm" @click="emit('clear')">Clear</Button>
</div>
</template>
<style scoped>
.wpc-deltoolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid color-mix(in oklab, var(--wp-accent) 40%, transparent);
border-radius: 8px;
background: color-mix(in oklab, var(--wp-accent) 12%, var(--wp-bg-2));
flex-wrap: wrap;
}
.wpc-deltoolbar__count {
font-size: 13px;
font-weight: 600;
color: var(--wp-text);
white-space: nowrap;
}
.wpc-deltoolbar__spacer { flex: 1; }
</style>
+136 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import Card from "./ui/Card.vue";
import Button from "./ui/Button.vue";
import Select from "./ui/Select.vue";
@@ -40,6 +40,10 @@ interface Props {
uuidToTagGroups?: Map<string, Record<string, string[]>>;
/** Default-collapsed when many rules exist (set by editor). */
defaultCollapsed?: boolean;
/** Broadcast from the editor's Collapse-all / Expand-all buttons. When
* `nonce` changes, the card adopts `collapsed` — but the user can still
* toggle any card individually afterwards. */
collapseCommand?: { nonce: number; collapsed: boolean };
}
const props = withDefaults(defineProps<Props>(), {
@@ -57,6 +61,50 @@ const props = withDefaults(defineProps<Props>(), {
const collapsed = ref<boolean>(props.defaultCollapsed);
function toggleCollapsed() { collapsed.value = !collapsed.value; }
// Editor-driven Collapse-all / Expand-all: adopt the broadcast state whenever
// its nonce ticks (individual toggles still work afterwards).
watch(
() => props.collapseCommand?.nonce,
() => {
if (props.collapseCommand) collapsed.value = props.collapseCommand.collapsed;
},
);
// Per-branch collapse. Branch index → collapsed; the ELSE clause is keyed as
// -1. Collapsing hides the condition/action editors and shows a one-line
// summary in the branch head.
/** Every branch key for this rule (IF/ELIF indices + `-1` for ELSE). */
function allBranchKeys(): number[] {
const r = props.modelValue;
const keys = r.branches.map((_, i) => i);
if (r.else) keys.push(-1);
return keys;
}
// Branches start COLLAPSED on open (matches the request for a compact
// editor). Newly-added branches aren't in this set, so they open expanded
// for immediate editing.
const collapsedBranches = ref<Set<number>>(new Set(allBranchKeys()));
function toggleBranch(bi: number): void {
const next = new Set(collapsedBranches.value);
if (next.has(bi)) next.delete(bi);
else next.add(bi);
collapsedBranches.value = next;
}
function isBranchCollapsed(bi: number): boolean {
return collapsedBranches.value.has(bi);
}
/** Collapse / expand every branch in THIS rule (the inline per-rule control). */
function collapseAllBranches(): void {
collapsedBranches.value = new Set(allBranchKeys());
}
function expandAllBranches(): void {
collapsedBranches.value = new Set();
}
/** Compact `$cond → $target` peek for a collapsed branch head. */
function branchPeek(cvar: string, tvar: string): string {
return `${cvar ? "$" + cvar : "$?"} → ${tvar ? "$" + tvar : "$?"}`;
}
const emit = defineEmits<{
"update:modelValue": [value: DerivationRule];
remove: [];
@@ -287,6 +335,26 @@ const branchCount = computed(() => rule.value.branches.length);
<span v-if="rule.else"> + ELSE</span>
</span>
<span class="spacer" />
<!-- Inline per-rule collapse/expand of THIS rule's conditions. Only
useful when the rule card itself is open. -->
<span v-if="!collapsed" class="rule-head__branch-ctrls">
<Button
icon="pi-angle-double-up"
variant="ghost"
size="sm"
:aria-label="`Collapse all conditions in rule ${ruleNumber}`"
:data-test="`collapse-branches-${index}`"
@click.stop="collapseAllBranches"
/>
<Button
icon="pi-angle-double-down"
variant="ghost"
size="sm"
:aria-label="`Expand all conditions in rule ${ruleNumber}`"
:data-test="`expand-branches-${index}`"
@click.stop="expandAllBranches"
/>
</span>
<Button
icon="pi-trash"
variant="ghost"
@@ -306,9 +374,24 @@ const branchCount = computed(() => rule.value.branches.length);
:data-test="`branch-${index}-${bi}`"
>
<div class="branch-head">
<button
type="button"
class="branch-collapse"
:aria-expanded="!isBranchCollapsed(bi)"
:aria-label="`${isBranchCollapsed(bi) ? 'Expand' : 'Collapse'} branch ${bi + 1} of rule ${ruleNumber}`"
:data-test="`toggle-branch-${index}-${bi}`"
@click="toggleBranch(bi)"
>
<i :class="isBranchCollapsed(bi) ? 'pi pi-chevron-right' : 'pi pi-chevron-down'" aria-hidden="true" />
</button>
<span class="branch-tag" :data-kind="bi === 0 ? 'if' : 'elif'">
{{ bi === 0 ? "IF" : "ELIF" }}
</span>
<span
v-if="isBranchCollapsed(bi)"
class="branch-peek"
:data-test="`branch-peek-${index}-${bi}`"
>{{ branchPeek(branch.condition.var, branch.action.target_var) }}</span>
<span class="spacer" />
<Button
v-if="bi > 0"
@@ -321,6 +404,7 @@ const branchCount = computed(() => rule.value.branches.length);
/>
</div>
<div v-show="!isBranchCollapsed(bi)" class="branch-body">
<!-- Compact grid (Proposal B, 2026-05-09 cycle):
row 1 = WHEN var + op on a single line so the prose
reads "When $age equals". Row 2 = condition value below
@@ -476,12 +560,28 @@ const branchCount = computed(() => rule.value.branches.length);
<div class="dvr-hint" :data-test="`act-hint-${index}-${bi}`">
{{ SUPPORTED_SYNTAX_HINT }}
</div>
</div>
</div>
<!-- ELSE branch -->
<div v-if="rule.else" class="branch branch--else" data-kind="else" :data-test="`branch-else-${index}`">
<div class="branch-head">
<button
type="button"
class="branch-collapse"
:aria-expanded="!isBranchCollapsed(-1)"
:aria-label="`${isBranchCollapsed(-1) ? 'Expand' : 'Collapse'} ELSE branch of rule ${ruleNumber}`"
:data-test="`toggle-branch-else-${index}`"
@click="toggleBranch(-1)"
>
<i :class="isBranchCollapsed(-1) ? 'pi pi-chevron-right' : 'pi pi-chevron-down'" aria-hidden="true" />
</button>
<span class="branch-tag" data-kind="else">ELSE</span>
<span
v-if="isBranchCollapsed(-1)"
class="branch-peek"
:data-test="`branch-peek-else-${index}`"
>→ {{ rule.else.action.target_var ? "$" + rule.else.action.target_var : "$?" }}</span>
<span class="spacer" />
<Button
icon="pi-times"
@@ -492,6 +592,7 @@ const branchCount = computed(() => rule.value.branches.length);
@click="removeElse"
/>
</div>
<div v-show="!isBranchCollapsed(-1)" class="branch-body">
<div class="dvr-grid dvr-grid--then">
<span class="dvr-label">Then</span>
<div class="dvr-var-wrap" :data-test="`else-var-wrap-${index}`">
@@ -541,6 +642,7 @@ const branchCount = computed(() => rule.value.branches.length);
<div class="dvr-hint" :data-test="`else-hint-${index}`">
{{ SUPPORTED_SYNTAX_HINT }}
</div>
</div>
</div>
</div>
@@ -600,6 +702,7 @@ const branchCount = computed(() => rule.value.branches.length);
color: var(--wp-text-muted, #9ca3af);
}
.spacer { flex: 1; }
.rule-head__branch-ctrls { display: inline-flex; gap: 2px; }
.branches {
display: flex;
@@ -627,6 +730,38 @@ const branchCount = computed(() => rule.value.branches.length);
align-items: center;
gap: var(--wp-space-4);
}
/* Per-branch collapse chevron — mirrors the rule-card head toggle. */
.branch-collapse {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
background: transparent;
color: var(--wp-text-muted);
cursor: pointer;
border-radius: 4px;
}
.branch-collapse:hover { background: var(--wp-bg-3); color: var(--wp-text); }
.branch-collapse:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
.branch-collapse i { font-size: var(--wp-text-xs); }
/* One-line peek shown in a collapsed branch head. */
.branch-peek {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: var(--wp-text-xs);
color: var(--wp-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.branch-body {
display: flex;
flex-direction: column;
gap: var(--wp-space-4);
margin-top: var(--wp-space-4);
}
.branch-tag {
font-family: var(--wp-font-mono, ui-monospace, monospace);
+77 -11
View File
@@ -8,17 +8,31 @@
* `tags` is a new sub-category — emitted via apply-tag too; the host decides to
* auto-create it (in Ungrouped).
*/
import { computed, ref } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import Button from "./ui/Button.vue";
import Input from "./ui/Input.vue";
import { UNGROUPED_HUE } from "../../components/shared/axis-color";
interface Props {
count: number;
/** Existing sub-category tags, for the apply / remove menus. */
tags: string[];
/** Tags already on EVERY selected row — hidden from the Apply menu since
* applying one there is a no-op. Absent (undefined) = exclude nothing. */
commonTags?: string[];
/** Tags present on ≥1 selected row — the Remove menu's candidate list.
* Absent (undefined) falls back to `tags`; an explicit empty array means
* the selection carries no tags, so Remove is empty. */
presentTags?: string[];
/** tag → axis hue, so menu chips match the pill/chip colours elsewhere. */
tagHues?: Record<string, string>;
}
const props = defineProps<Props>();
function hueOf(tag: string): string {
return props.tagHues?.[tag] ?? UNGROUPED_HUE;
}
const emit = defineEmits<{
(e: "apply-tag", tag: string): void;
(e: "remove-tag", tag: string): void;
@@ -40,11 +54,38 @@ function toggle(menu: Exclude<Menu, null>) {
}
function close() { openMenu.value = null; }
const filteredTags = computed(() => {
// Close the menu when the page scrolls behind it. The toolbar lives in a
// sticky header, so an open menu would otherwise hover in place while the
// option list scrolls under it — the "can't scroll the page" trap. Scrolls
// that originate INSIDE the menu's own list are ignored (that's the list
// scrolling itself; `overscroll-behavior: contain` keeps it from chaining).
function onDocScroll(e: Event): void {
const t = e.target;
if (t instanceof Element && t.closest(".wpc-seltoolbar__menulist")) return;
close();
}
watch(openMenu, (open) => {
if (open) document.addEventListener("scroll", onDocScroll, true);
else document.removeEventListener("scroll", onDocScroll, true);
});
onBeforeUnmount(() => document.removeEventListener("scroll", onDocScroll, true));
// Apply menu: every tag EXCEPT those already on all selected rows, so the
// list only offers tags that would actually change something. Then narrowed
// by the type-to-filter box.
const applyList = computed(() => {
const excluded = new Set(props.commonTags ?? []);
const q = applyFilter.value.trim().toLowerCase();
const list = [...props.tags].sort((a, b) => a.localeCompare(b));
const list = [...props.tags]
.filter((t) => !excluded.has(t))
.sort((a, b) => a.localeCompare(b));
return q ? list.filter((t) => t.toLowerCase().includes(q)) : list;
});
// Remove menu: only tags the selection actually carries. `presentTags`
// absent (undefined) falls back to all tags; an explicit [] means none.
const removeList = computed(() =>
[...(props.presentTags ?? props.tags)].sort((a, b) => a.localeCompare(b)),
);
// "new tag" = typed name not matching any existing tag (case-insensitive).
const newTagIsNovel = computed(() => {
const n = newTag.value.trim();
@@ -96,13 +137,14 @@ function applyWeight() {
</button>
<div class="wpc-seltoolbar__menulist">
<button
v-for="t in filteredTags"
v-for="t in applyList"
:key="t"
type="button"
class="wpc-seltoolbar__menuitem"
:style="{ '--chip-hue': hueOf(t) }"
@click="applyTag(t)"
>{{ t }}</button>
<div v-if="!filteredTags.length && !newTagIsNovel" class="wpc-seltoolbar__menuempty">No sub-categories yet</div>
><span class="wpc-seltoolbar__swatch" aria-hidden="true"></span>{{ t }}</button>
<div v-if="!applyList.length && !newTagIsNovel" class="wpc-seltoolbar__menuempty">Every selected row already has all sub-categories</div>
</div>
</div>
</div>
@@ -115,13 +157,14 @@ function applyWeight() {
<div v-if="openMenu === 'remove'" class="wpc-seltoolbar__menu" role="menu">
<div class="wpc-seltoolbar__menulist">
<button
v-for="t in filteredTags"
v-for="t in removeList"
:key="t"
type="button"
class="wpc-seltoolbar__menuitem"
:style="{ '--chip-hue': hueOf(t) }"
@click="removeTag(t)"
>{{ t }}</button>
<div v-if="!filteredTags.length" class="wpc-seltoolbar__menuempty">No sub-categories</div>
><span class="wpc-seltoolbar__swatch" aria-hidden="true"></span>{{ t }}</button>
<div v-if="!removeList.length" class="wpc-seltoolbar__menuempty">Selection has no sub-categories</div>
</div>
</div>
</div>
@@ -208,8 +251,16 @@ function applyWeight() {
flex-direction: column;
max-height: 220px;
overflow-y: auto;
/* Keep wheel/trackpad scrolling inside the list — at the top/bottom edge
* it must NOT chain to the editor body behind (that "nudged the page and
* you couldn't recenter"). */
overscroll-behavior: contain;
}
.wpc-seltoolbar__menuitem {
--chip-hue: var(--wp-text-dim, var(--wp-text3));
display: flex;
align-items: center;
gap: 8px;
text-align: left;
padding: 6px 8px;
border: none;
@@ -219,8 +270,23 @@ function applyWeight() {
font-size: 13px;
cursor: pointer;
}
.wpc-seltoolbar__menuitem:hover { background: var(--wp-bg-3); }
.wpc-seltoolbar__menuitem--new { color: var(--wp-accent); font-weight: 600; }
/* Colour swatch mirroring the axis hue of the pills / option-row chips so a
* sub-category reads with the same colour in the menu as everywhere else. */
.wpc-seltoolbar__swatch {
flex: 0 0 auto;
width: 10px;
height: 10px;
border-radius: 3px;
border: 1px solid color-mix(in srgb, var(--chip-hue) 55%, var(--wp-border));
background: color-mix(in srgb, var(--chip-hue) 40%, transparent);
}
.wpc-seltoolbar__menuitem:hover {
background: color-mix(in srgb, var(--chip-hue) 14%, var(--wp-bg-3));
}
.wpc-seltoolbar__menuitem--new {
color: var(--wp-accent);
font-weight: 600;
}
.wpc-seltoolbar__menuempty { padding: 6px 8px; font-size: 12px; color: var(--wp-text-muted); }
.wpc-seltoolbar__backdrop {
position: fixed;
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
/**
* Update-available dialog. Opened from the topbar update pill. Shows the
* current → latest version, a rendered changelog, and drives the update
* through ComfyUI Manager (Update Now → installing → staged → explicit
* Restart). Falls back to guidance when Manager is absent or blocks the
* call (403). Reboot is always a separate, explicit user click.
*/
import { computed, ref, watch } from "vue";
import Modal from "./ui/Modal.vue";
import Button from "./ui/Button.vue";
import { useReleaseCheck } from "../composables/useReleaseCheck";
import { useComfyManagerUpdate, type ManagerAvailability } from "../composables/useComfyManagerUpdate";
import { renderReleaseNotes } from "../utils/releaseNotes";
import { GITHUB_REPO } from "../config/links";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: "close"): void }>();
const { current, latestVersion, severity, releaseBody, releaseUrl } = useReleaseCheck();
const { phase, errorKind, runUpdate, reboot, probe, managerUiUrl } = useComfyManagerUpdate();
const availability = ref<ManagerAvailability | null>(null);
// Probe Manager once each time the dialog opens so we know whether to
// show Update Now or the guidance fallback.
watch(
() => props.open,
async (isOpen) => {
if (isOpen && availability.value === null) {
availability.value = await probe();
}
},
{ immediate: true },
);
const notesHtml = computed(() => renderReleaseNotes(releaseBody.value ?? ""));
const fullChangelogUrl = computed(() => releaseUrl.value ?? `${GITHUB_REPO}/releases`);
const severityLabel = computed(() =>
severity.value === "major" ? "Major update"
: severity.value === "minor" ? "Minor update"
: "Patch update",
);
const severityClass = computed(() =>
severity.value === "major" ? "wpc-upd__badge--warn" : "wpc-upd__badge--accent",
);
// Fallback whenever Manager is missing OR the update errored with a
// forbidden/failed kind.
const showFallback = computed(
() => availability.value === "absent" || phase.value === "error",
);
function onClose(): void { emit("close"); }
function onModalOpen(v: boolean): void { if (!v) onClose(); }
/** Kick off the update to the exact version the check found. Passing the
* concrete target (not "latest") lets the composable pin it + guard against
* a downgrade. */
function onUpdateNow(): void {
if (latestVersion.value) runUpdate(latestVersion.value);
}
</script>
<template>
<Modal
:open="open"
title="Update available"
size="md"
:close-on-backdrop="phase !== 'installing'"
@update:open="onModalOpen"
>
<div class="wpc-upd">
<div class="wpc-upd__head">
<span class="wpc-upd__ver">v{{ current }}</span>
<span class="wpc-upd__arrow" aria-hidden="true">→</span>
<span class="wpc-upd__ver wpc-upd__ver--new">v{{ latestVersion }}</span>
<span class="wpc-upd__badge" :class="severityClass">{{ severityLabel }}</span>
</div>
<!-- Rendered release notes. `notesHtml` is produced by
renderReleaseNotes, which escapes before transforming, so this
is the one sanctioned v-html. -->
<div class="wpc-relnotes" data-test="update-notes" v-html="notesHtml" />
<a
class="wpc-upd__full"
:href="fullChangelogUrl"
target="_blank"
rel="noopener"
data-test="update-full-changelog"
>Full changelog →</a>
<!-- Guidance fallback: Manager missing or blocked. -->
<div v-if="showFallback" class="wpc-upd__fallback" data-test="update-fallback">
<p class="wpc-upd__fallback-title">Update via ComfyUI Manager</p>
<p class="wpc-upd__fallback-body">
{{ errorKind === "forbidden"
? "ComfyUI Manager blocked the in-app update (its security level). Update it from the Manager UI, or pull manually:"
: "Automatic update isn't available here. Update from the ComfyUI Manager UI, or pull manually:" }}
</p>
<a class="wpc-upd__manager-link" :href="managerUiUrl" target="_blank" rel="noopener">Open ComfyUI Manager</a>
<pre class="wpc-upd__cmd"><code>cd custom_nodes/ComfyUI-Wildcard-Pipeline
git pull
# then restart ComfyUI</code></pre>
</div>
<p v-else-if="phase === 'staged'" class="wpc-upd__staged" data-test="update-staged">
Update staged. Restart ComfyUI to apply.
</p>
<p v-else-if="phase === 'restarting'" class="wpc-upd__staged" data-test="update-restarting">
Restarting ComfyUI… reopen the browser tab in a moment.
</p>
</div>
<template #footer>
<template v-if="showFallback">
<Button variant="secondary" data-test="update-later" @click="onClose">Close</Button>
</template>
<template v-else-if="phase === 'idle'">
<Button variant="secondary" data-test="update-later" @click="onClose">Later</Button>
<Button variant="primary" data-test="update-now" @click="onUpdateNow">Update Now</Button>
</template>
<template v-else-if="phase === 'installing'">
<Button variant="primary" :loading="true" :disabled="true" data-test="update-installing">Updating…</Button>
</template>
<template v-else-if="phase === 'staged'">
<Button variant="secondary" data-test="update-later" @click="onClose">Later</Button>
<Button variant="primary" data-test="update-restart" @click="reboot">Restart ComfyUI</Button>
</template>
<template v-else-if="phase === 'restarting'">
<Button variant="secondary" data-test="update-later" @click="onClose">Close</Button>
</template>
</template>
</Modal>
</template>
<style scoped>
.wpc-upd { display: flex; flex-direction: column; gap: var(--wp-space-4); }
.wpc-upd__head { display: flex; align-items: center; gap: var(--wp-space-3); flex-wrap: wrap; }
.wpc-upd__ver { font: 600 var(--wp-text-sm)/1 var(--wp-font-mono, monospace); color: var(--wp-text-muted); }
.wpc-upd__ver--new { color: var(--wp-accent-text); }
.wpc-upd__arrow { color: var(--wp-text-dim); }
.wpc-upd__badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; padding: 2px 7px; border-radius: 999px; }
.wpc-upd__badge--accent { color: var(--wp-accent-text); background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent); }
.wpc-upd__badge--warn { color: var(--wp-warn, #f59e0b); background: color-mix(in oklab, var(--wp-warn, #f59e0b) 18%, transparent); }
.wpc-relnotes { max-height: 320px; overflow-y: auto; font-size: var(--wp-text-sm); line-height: 1.55; color: var(--wp-text-muted); background: var(--wp-bg-2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-4); }
.wpc-relnotes :deep(h1), .wpc-relnotes :deep(h2), .wpc-relnotes :deep(h3) { color: var(--wp-text); margin: var(--wp-space-3) 0 var(--wp-space-2); font-size: var(--wp-text-sm); font-weight: 700; }
.wpc-relnotes :deep(ul) { margin: var(--wp-space-2) 0; padding-left: var(--wp-space-6); }
.wpc-relnotes :deep(li) { margin: 2px 0; }
.wpc-relnotes :deep(code) { font-family: var(--wp-font-mono, monospace); background: var(--wp-bg-1); padding: 1px 4px; border-radius: 3px; }
.wpc-relnotes :deep(pre) { background: var(--wp-bg-1); padding: var(--wp-space-3); border-radius: var(--wp-radius); overflow-x: auto; }
.wpc-relnotes :deep(a) { color: var(--wp-accent-text); }
.wpc-upd__full { font-size: var(--wp-text-sm); color: var(--wp-accent-text); text-decoration: none; }
.wpc-upd__full:hover { color: var(--wp-text); }
.wpc-upd__fallback { border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-4); display: flex; flex-direction: column; gap: var(--wp-space-3); }
.wpc-upd__fallback-title { font-weight: 700; color: var(--wp-text); margin: 0; }
.wpc-upd__fallback-body { font-size: var(--wp-text-sm); color: var(--wp-text-muted); margin: 0; }
.wpc-upd__manager-link { font-size: var(--wp-text-sm); color: var(--wp-accent-text); text-decoration: none; }
.wpc-upd__cmd { background: var(--wp-bg-1); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-3); font-size: var(--wp-text-sm); overflow-x: auto; margin: 0; }
.wpc-upd__staged { font-size: var(--wp-text-sm); color: var(--wp-accent-text); margin: 0; }
</style>
+41 -11
View File
@@ -1,26 +1,56 @@
<script setup lang="ts">
import { useSlots } from "vue";
import { useSlots, ref, onMounted, onBeforeUnmount } from "vue";
interface Props {
title?: string;
subtitle?: string;
padding?: boolean;
/** Stick the card header (and its `subheader` slot, e.g. a bulk-edit
* toolbar) to the top of the nearest scroll container so its actions stay
* reachable when scrolling a long list — new rows always land at the
* bottom, so the toolbar must follow. Publishes the measured height of the
* whole stuck region as `--wp-card-header-h` so a sticky `<thead>` inside
* the body can offset itself right below it (updates as the subheader
* toolbar appears/disappears or density changes). */
stickyHeader?: boolean;
}
withDefaults(defineProps<Props>(), { padding: true });
const props = withDefaults(defineProps<Props>(), { padding: true, stickyHeader: false });
const slots = useSlots();
const root = ref<HTMLElement | null>(null);
const stickyEl = ref<HTMLElement | null>(null);
let ro: ResizeObserver | null = null;
function syncStickyHeight(): void {
if (!root.value || !stickyEl.value) return;
const h = Math.round(stickyEl.value.getBoundingClientRect().height);
root.value.style.setProperty("--wp-card-header-h", `${h}px`);
}
onMounted(() => {
if (!props.stickyHeader || !stickyEl.value) return;
syncStickyHeight();
if (typeof ResizeObserver !== "undefined") {
ro = new ResizeObserver(syncStickyHeight);
ro.observe(stickyEl.value);
}
});
onBeforeUnmount(() => { ro?.disconnect(); ro = null; });
</script>
<template>
<section class="wp-card">
<header v-if="title || slots.actions" class="wp-card__header">
<div class="wp-card__title-wrap">
<h2 v-if="title" class="wp-card__title">{{ title }}</h2>
<p v-if="subtitle" class="wp-card__subtitle">{{ subtitle }}</p>
</div>
<span class="wp-spacer" />
<slot name="actions" />
</header>
<section ref="root" class="wp-card" :class="{ 'wp-card--sticky-header': stickyHeader }">
<div ref="stickyEl" class="wp-card__sticky">
<header v-if="title || slots.actions" class="wp-card__header">
<div class="wp-card__title-wrap">
<h2 v-if="title" class="wp-card__title">{{ title }}</h2>
<p v-if="subtitle" class="wp-card__subtitle">{{ subtitle }}</p>
</div>
<span class="wp-spacer" />
<slot name="actions" />
</header>
<slot name="subheader" />
</div>
<div :class="padding ? 'wp-card__body' : ''">
<slot />
</div>
+12 -1
View File
@@ -35,6 +35,17 @@ const inputClasses = computed(() => [
]);
const isNumber = computed(() => props.type === "number");
/** Native `step` here is ONLY used by the browser for HTML validation —
* arrow keys, the wheel, and the custom chevrons all route through `bump()`
* (native spin buttons are hidden via CSS), and `bump()` reads `props.step`
* itself. A concrete step (e.g. `0.1`) combined with an off-grid `min` or
* value makes the browser reject legitimate input: a default weight of `1`
* with `min="0.01" step="0.1"` popped up "Please select a valid value. The
* two nearest valid values are 0.91 and 1.01." So emit `step="any"` for
* number inputs — the browser never grid-validates, while `bump()` keeps
* stepping by `props.step`. */
const nativeStep = computed(() => (isNumber.value ? "any" : props.step));
const wrapped = computed(() => Boolean(props.icon || props.addon) || isNumber.value);
function onInput(e: Event) {
@@ -122,7 +133,7 @@ function bump(direction: 1 | -1) {
:aria-invalid="error || undefined"
:min="min"
:max="max"
:step="step"
:step="nativeStep"
@input="onInput"
@keydown="onKeydown"
@wheel="onWheel"
@@ -0,0 +1,93 @@
import { computed, ref, type ComputedRef, type Ref } from "vue";
/**
* Generic multi-row selection for an editor's bulk mode (select + delete).
*
* The editor owns its rows; this owns only the "which ids are picked" state
* plus the on/off `active` flag. Pass a `getIds` that returns the CURRENT
* selectable row ids — every derived value reads through it, so selection
* stays correct even as rows are added or removed (a deleted id simply stops
* counting; no stale-id bookkeeping needed).
*
* Mirrors the hand-rolled selection in WildcardEditor so the three simpler
* editors (fixed-values, constraint exceptions, derivation rules) share one
* implementation instead of copying it three times.
*/
export interface BulkSelection {
/** Bulk mode on/off. */
active: Ref<boolean>;
/** How many currently-present rows are selected. */
count: ComputedRef<number>;
/** True when every selectable row is selected (drives the header check). */
allSelected: ComputedRef<boolean>;
/** True when ≥1 (but maybe not all) rows are selected (indeterminate dash). */
someSelected: ComputedRef<boolean>;
isSelected: (id: string) => boolean;
toggle: (id: string) => void;
/** Select all when not all selected; otherwise clear. */
toggleAll: () => void;
clear: () => void;
/** Enter/leave bulk mode; leaving clears the selection. */
toggleMode: () => void;
exit: () => void;
/** The selected ids that still exist, for the delete handler. */
selectedIds: () => string[];
}
export function useBulkSelection(getIds: () => string[]): BulkSelection {
const active = ref(false);
const selected = ref<Set<string>>(new Set());
const count = computed(
() => getIds().filter((id) => selected.value.has(id)).length,
);
const allSelected = computed(() => {
const ids = getIds();
return ids.length > 0 && ids.every((id) => selected.value.has(id));
});
const someSelected = computed(() =>
getIds().some((id) => selected.value.has(id)),
);
function isSelected(id: string): boolean {
return selected.value.has(id);
}
function toggle(id: string): void {
const next = new Set(selected.value);
if (next.has(id)) next.delete(id);
else next.add(id);
selected.value = next;
}
function toggleAll(): void {
if (allSelected.value) selected.value = new Set();
else selected.value = new Set(getIds());
}
function clear(): void {
selected.value = new Set();
}
function exit(): void {
active.value = false;
clear();
}
function toggleMode(): void {
if (active.value) exit();
else active.value = true;
}
function selectedIds(): string[] {
return getIds().filter((id) => selected.value.has(id));
}
return {
active,
count,
allSelected,
someSelected,
isSelected,
toggle,
toggleAll,
clear,
toggleMode,
exit,
selectedIds,
};
}
@@ -0,0 +1,164 @@
/**
* Drives an in-place update through ComfyUI Manager's same-origin HTTP
* API instead of reimplementing git. Flow: queue an install-to-latest
* for our registry id, start the queue, poll status to completion, then
* (on an explicit user click) reboot ComfyUI.
*
* Security note: `/manager/queue/install` and `/manager/reboot` are gated
* by ComfyUI Manager's `security_level` (default `normal` → allowed;
* `strong` → 403). A GET probe can't see that gate, so a 403 surfaces as
* `errorKind: "forbidden"` from `runUpdate`; the dialog shows the same
* guidance fallback it shows when Manager is absent.
*/
import { ref } from "vue";
import { COMFY_REGISTRY_ID } from "../config/links";
export type ManagerAvailability = "available" | "absent";
export type UpdatePhase = "idle" | "installing" | "staged" | "restarting" | "error";
export type UpdateErrorKind = "forbidden" | "failed" | null;
const STATUS_POLL_MS = 1000;
const STATUS_MAX_POLLS = 120; // ~2 min ceiling
interface QueueStatus {
total_count?: number;
done_count?: number;
in_progress?: boolean;
}
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** Parse a `1.2.3` version into numeric segments (prerelease ignored). */
function parseSemver(v: string): number[] {
return v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
}
/** True when `target` is a strictly newer version than `current`. Used as a
* downgrade guard so a mis-resolved target can never install an older
* build over a newer one. */
function isStrictlyNewer(target: string, current: string): boolean {
const t = parseSemver(target);
const c = parseSemver(current);
for (let i = 0; i < 3; i++) {
const diff = (t[i] ?? 0) - (c[i] ?? 0);
if (diff !== 0) return diff > 0;
}
return false;
}
export function useComfyManagerUpdate(): {
phase: ReturnType<typeof ref<UpdatePhase>>;
errorKind: ReturnType<typeof ref<UpdateErrorKind>>;
errorMessage: ReturnType<typeof ref<string | null>>;
probe: () => Promise<ManagerAvailability>;
runUpdate: (targetVersion: string) => Promise<void>;
reboot: () => Promise<void>;
managerUiUrl: string;
} {
const phase = ref<UpdatePhase>("idle");
const errorKind = ref<UpdateErrorKind>(null);
const errorMessage = ref<string | null>(null);
async function probe(): Promise<ManagerAvailability> {
try {
const res = await fetch("/manager/queue/status", { method: "GET" });
return res.ok ? "available" : "absent";
} catch {
return "absent";
}
}
function fail(kind: Exclude<UpdateErrorKind, null>, message: string): void {
phase.value = "error";
errorKind.value = kind;
errorMessage.value = message;
}
async function pollUntilDone(): Promise<boolean> {
for (let i = 0; i < STATUS_MAX_POLLS; i++) {
try {
const res = await fetch("/manager/queue/status", { method: "GET" });
if (res.ok) {
const s = (await res.json()) as QueueStatus;
const total = s.total_count ?? 0;
const done = s.done_count ?? 0;
const running = s.in_progress ?? false;
if (!running && (total === 0 || done >= total)) return true;
}
} catch {
/* transient — keep polling until the ceiling */
}
await delay(STATUS_POLL_MS);
}
return false;
}
async function runUpdate(targetVersion: string): Promise<void> {
phase.value = "installing";
errorKind.value = null;
errorMessage.value = null;
// Downgrade guard. `targetVersion` is the exact release the update check
// found (e.g. "2.10.1"). Never proceed unless it is strictly newer than
// what's installed. This is the fix for the 2.10.0 downgrade bug, where
// `selected_version:"latest"` + `mode:"cache"` let ComfyUI Manager
// resolve "latest" against its STALE cached catalog and install an older
// version over a newer one.
if (!targetVersion || !isStrictlyNewer(targetVersion, __APP_VERSION__)) {
fail("failed", `Refusing to install "${targetVersion || "unknown"}" — it is not newer than the installed ${__APP_VERSION__}.`);
return;
}
try {
const installRes = await fetch("/manager/queue/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: COMFY_REGISTRY_ID,
version: __APP_VERSION__,
// Pin the EXACT target version, not "latest" — we already know it.
selected_version: targetVersion,
channel: "default",
// "remote" fetches fresh registry data so the pinned version
// resolves against the live catalog, not Manager's stale cache.
mode: "remote",
}),
});
if (!installRes.ok) {
const text = await installRes.text().catch(() => "");
if (installRes.status === 403) {
fail("forbidden", text || "ComfyUI Manager blocked the update (security level).");
} else {
fail("failed", text || `Install request failed (${installRes.status}).`);
}
return;
}
const startRes = await fetch("/manager/queue/start", { method: "POST" });
if (!startRes.ok) {
fail("failed", `Could not start the update queue (${startRes.status}).`);
return;
}
const done = await pollUntilDone();
if (!done) {
fail("failed", "The update did not finish in time. Check ComfyUI's console.");
return;
}
phase.value = "staged";
} catch (e) {
fail("failed", e instanceof Error ? e.message : "Unexpected update error.");
}
}
async function reboot(): Promise<void> {
phase.value = "restarting";
try {
await fetch("/manager/reboot", { method: "POST" });
} catch {
// The server drops the socket while restarting — expected. Stay in
// `restarting`; the dialog tells the user to reopen ComfyUI.
}
}
const managerUiUrl = "/manager";
return { phase, errorKind, errorMessage, probe, runUpdate, reboot, managerUiUrl };
}
+120 -41
View File
@@ -1,29 +1,101 @@
/**
* Release check - once-per-day GitHub Releases poll for "update available".
* Release check - GitHub Releases lookup for "update available".
*
* Hits `https://api.github.com/repos/<owner>/<repo>/releases/latest`
* anonymously (unauthenticated requests get 60/hr per IP, plenty for a
* single-user dev tool). The result + a timestamp are cached in
* localStorage; subsequent calls return the cached value until the TTL
* expires, so a topbar re-mount doesn't burn a request per page nav.
* single-user dev tool). Cadence is launch-once + manual: on mount we
* paint any cached result immediately, then refresh once per app session
* IF the user opted into launch checks (`uiStore.checkOnLaunch`). There is
* no timed re-poll; `checkNow()` triggers an on-demand refresh. The
* localStorage cache exists only to paint instantly before the network
* call resolves — it is not a re-check suppressor.
*
* The composable returns a reactive `hasUpdate` flag + `latestVersion`
* so AppTopbar can paint an accent dot without owning the polling
* machinery. Network failure (offline, rate limit, 404 on private repo)
* fails silently - `hasUpdate` stays false; we never block the UI.
* The composable returns reactive `hasUpdate` / `latestVersion` /
* `severity` so AppTopbar can paint an accent pill, plus the release
* `body` / `html_url` / `lastChecked` for the Update dialog + Settings.
* Network failure (offline, rate limit, 404 on private repo) fails
* silently - `hasUpdate` stays false; we never block the UI.
*/
import { onMounted, ref } from "vue";
import { GITHUB_REPO } from "../config/links";
import { useUiStore } from "../stores/uiStore";
const STORAGE_KEY = "wp.releaseCheck";
const TTL_MS = 24 * 60 * 60 * 1000;
interface CachedRelease {
/** ISO timestamp of when the check ran. */
checked_at: string;
/** GitHub release `tag_name` minus a leading `v`. */
latest_version: string;
body?: string | null;
url?: string | null;
}
/** Module-level singleton state so EVERY consumer (topbar pill + Settings
* card) shares ONE reactive source. Without this, each `useReleaseCheck()`
* call got its own refs — so a manual "Check now" in Settings updated only
* Settings' copy and the topbar pill stayed dark until a page reload. */
const latestVersion = ref<string | null>(null);
const hasUpdate = ref<boolean>(false);
const severity = ref<UpdateSeverity | null>(null);
const releaseBody = ref<string | null>(null);
const releaseUrl = ref<string | null>(null);
const lastChecked = ref<string | null>(null);
const checking = ref<boolean>(false);
/** Guard so the once-per-session launch fetch fires at most once across all
* consumers. `checkNow` ignores it. */
let sessionFetched = false;
/** Test-only: reset the shared state + session guard between mounts. */
export function resetReleaseCheckSession(): void {
sessionFetched = false;
latestVersion.value = null;
hasUpdate.value = false;
severity.value = null;
releaseBody.value = null;
releaseUrl.value = null;
lastChecked.value = null;
checking.value = false;
}
/** Apply a candidate latest version to the shared refs. Reads
* `__APP_VERSION__` lazily (not a cached const) so tests that assign it
* after import see the right value. Guards a missing/malformed version so a
* bad cache degrades to "no update" instead of throwing in semverCompare. */
function applyLatest(v: string | null): void {
const current = __APP_VERSION__;
const valid = typeof v === "string" && v.length > 0 && typeof current === "string";
latestVersion.value = valid ? v : null;
const newer = valid && semverCompare(v as string, current) > 0;
hasUpdate.value = newer;
severity.value = newer ? classifyBump(current, v as string) : null;
}
/** Fetch fresh, persist, apply to the shared refs. Shared by the launch
* fetch + `checkNow`. */
async function refresh(): Promise<void> {
checking.value = true;
try {
const fresh = await fetchLatestRelease();
if (fresh) {
sessionFetched = true;
writeCache(fresh);
releaseBody.value = fresh.body;
releaseUrl.value = fresh.url;
lastChecked.value = new Date().toISOString();
applyLatest(fresh.version);
}
} finally {
checking.value = false;
}
}
/** Manual check — always refetches, ignores the session guard. Because the
* refs are shared, this lights the topbar pill + Settings simultaneously. */
async function checkNow(): Promise<void> {
await refresh();
}
/**
@@ -84,28 +156,28 @@ function classifyBump(current: string, latest: string): UpdateSeverity | null {
}
/**
* Parse the cached blob if it's still fresh. Returns null on cache
* miss, parse failure, or TTL expiry - caller refetches on null.
* Parse the cached blob. Returns null on cache miss or parse failure.
* There is no TTL expiry — the cache is paint-first, never a re-check
* suppressor.
*/
function readCache(): CachedRelease | null {
if (typeof localStorage === "undefined") return null;
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as CachedRelease;
const age = Date.now() - new Date(parsed.checked_at).getTime();
if (Number.isNaN(age) || age > TTL_MS) return null;
return parsed;
return JSON.parse(raw) as CachedRelease;
} catch {
return null;
}
}
function writeCache(latestVersion: string): void {
function writeCache(rel: { version: string; body: string | null; url: string | null }): void {
if (typeof localStorage === "undefined") return;
const payload: CachedRelease = {
checked_at: new Date().toISOString(),
latest_version: latestVersion,
latest_version: rel.version,
body: rel.body,
url: rel.url,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
@@ -124,7 +196,7 @@ function repoSlug(): string | null {
return `${match[1]}/${match[2]}`;
}
async function fetchLatestRelease(): Promise<string | null> {
async function fetchLatestRelease(): Promise<{ version: string; body: string | null; url: string | null } | null> {
const slug = repoSlug();
if (!slug) return null;
try {
@@ -132,50 +204,57 @@ async function fetchLatestRelease(): Promise<string | null> {
headers: { Accept: "application/vnd.github+json" },
});
if (!res.ok) return null;
const body = (await res.json()) as { tag_name?: unknown };
const body = (await res.json()) as { tag_name?: unknown; body?: unknown; html_url?: unknown };
if (typeof body.tag_name !== "string") return null;
return normalizeTag(body.tag_name);
return {
version: normalizeTag(body.tag_name),
body: typeof body.body === "string" ? body.body : null,
url: typeof body.html_url === "string" ? body.html_url : null,
};
} catch {
return null;
}
}
/**
* Vue composable. Call from a setup() block - the underlying GitHub
* fetch runs on mount, once per 24h. The returned `current` /
* `latestVersion` / `hasUpdate` refs stay reactive across the lifetime
* of the consumer.
* Vue composable. Call from a setup() block — the underlying GitHub fetch
* runs once on mount (when `checkOnLaunch` is on and no other consumer has
* fetched this session). The returned refs stay reactive across the
* lifetime of the consumer. Requires an active Pinia (reads uiStore).
*/
export function useReleaseCheck(): {
current: string;
latestVersion: ReturnType<typeof ref<string | null>>;
hasUpdate: ReturnType<typeof ref<boolean>>;
severity: ReturnType<typeof ref<UpdateSeverity | null>>;
releaseBody: ReturnType<typeof ref<string | null>>;
releaseUrl: ReturnType<typeof ref<string | null>>;
lastChecked: ReturnType<typeof ref<string | null>>;
checking: ReturnType<typeof ref<boolean>>;
checkNow: () => Promise<void>;
} {
const current = __APP_VERSION__;
const latestVersion = ref<string | null>(null);
const hasUpdate = ref<boolean>(false);
const severity = ref<UpdateSeverity | null>(null);
const ui = useUiStore();
function applyLatest(v: string | null): void {
latestVersion.value = v;
const newer = v !== null && semverCompare(v, current) > 0;
hasUpdate.value = newer;
severity.value = newer && v ? classifyBump(current, v) : null;
}
onMounted(async () => {
onMounted(() => {
// Paint the cached result immediately (if any) so the pill/dialog
// aren't blank while the network call is in flight.
const cached = readCache();
if (cached) {
releaseBody.value = cached.body ?? null;
releaseUrl.value = cached.url ?? null;
lastChecked.value = cached.checked_at ?? null;
applyLatest(cached.latest_version);
return;
}
const fresh = await fetchLatestRelease();
if (fresh) {
writeCache(fresh);
applyLatest(fresh);
// Then refresh once per session, only when the user opted into launch
// checks. Fire-and-forget; failures are swallowed in refresh.
if (ui.checkOnLaunch && !sessionFetched) {
void refresh();
}
});
return { current, latestVersion, hasUpdate, severity };
return {
current, latestVersion, hasUpdate, severity,
releaseBody, releaseUrl, lastChecked, checking, checkNow,
};
}

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