Compare commits

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

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

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

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

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

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

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

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

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

Tradeoff: a custom pin label set via the slot context menu no longer
survives a collapse cycle (reverts to input_N) -- it was never persisted
durably anyway, so it was already lost on reload.
2026-07-01 03:17:58 +03:00
DumiFlex a738618dd3 docs(spa): mention the right-click Hold toggle shortcut
The Hold sections described only the Runtime-settings path; add the
two-click row context-menu shortcut alongside it on the WP Context node
page and the Seeds & loops concept page.
2026-07-01 03:17:58 +03:00
DumiFlex 8dcebdcc55 feat(context): quick Hold toggle in the module row context menu
Right-click a wildcard / combine / fixed-values row and "Hold across
run" now flips instance.seed_scope without opening the editor (4 clicks
to 2). The item swaps to "Release hold" when held, mirrors the editor's
base-only rule (disabled with a hint while a loop edit-frame is active),
and is gated to HOLD_KINDS so derivation/constraint rows -- whose
handlers ignore seed_scope -- never see it.

Bumps the extension bundle ceiling 449 -> 450 KB (measured 133 B over).
2026-07-01 03:17:58 +03:00
DumiFlex 1b3e4aa61f docs(spa): describe hold as a value freeze (constrained picks + nested refs) 2026-07-01 03:17:58 +03:00
DumiFlex 0281b8e321 fix(injector): repair collapse-wire labels corrupted across a reload
An older build serialized the collapse PLACEHOLDER labels ('inputs xN' on the
first input pin, ' ' on the rest) into the workflow JSON. On reload the slots
are fresh objects, so applyCollapsedLabels stashed those placeholders as the
'original' label — and expanding then restored the placeholder instead of the
real name (input_0 -> 'inputs xN', siblings blank, input_3 dangling).

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

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

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

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

Live-diagnosed on the SPA editor: exaggerating the padding to 30px grew the
bleed strip to 30px; setting it to 0 removed it entirely. Set padding:0 on both
scroll containers (SPA ConstraintMatrix + canvas MatrixSection); the frame's
1px border + border-radius give the visual edge, content sits flush, and the
4px inter-cell gap (th/td padding) is unaffected.
2026-06-22 17:51:49 +03:00
DumiFlex 40980093e8 fix(constraint): stop scrolled cells bleeding through the frozen matrix headers
position:sticky header cells under border-collapse:collapse hit a Chromium
paint bug: scrolled data cells show through the frozen row/column headers
instead of being covered. Switch both matrices (SPA ConstraintMatrix + canvas
MatrixSection) to border-collapse:separate with border-spacing:0; the 4px
inter-cell gap still comes from the th/td padding, so the layout is unchanged
while the sticky backings now paint opaquely.
2026-06-22 17:10:38 +03:00
DumiFlex 538db06efc feat(constraint): group the canvas matrix axes like the SPA
The ComfyUI instance-modal matrix (MatrixSection) was still flat while the SPA
ConstraintMatrix had grown grouped axes. Bring it to parity:

- Extract the pure grouping logic (order-by-axis, contiguous groups, bucket
  detection, labels, per-axis hue) into a shared components/shared/matrix-axis
  module so the two grids stay identical; refactor the SPA matrix to import it.
- Port MatrixSection: column bands name each target axis, row header chips name
  each multi-tag source axis, a solo axis folds its name into an eyebrow, and
  the leftover run is labelled "uncategorized" (neutral, dashed) — not blank.
  Headers + tag column + group chips + corner freeze while the grid scrolls on
  both axes. All per-instance cell logic (overrides, popover, stranded) is
  unchanged; cells stay keyed by tag name so the reorder is safe.
- ConstraintInstanceModal threads each wildcard's tag_groups into the matrix.
  Cross-context refs resolved only via the preview-resolver cache (which has no
  tag_groups) degrade to flat — the common sibling/chain case groups.
- Bump the extension bundle budget 432 -> 435 KB for the ~1.1 KB net growth.
2026-06-22 15:27:08 +03:00
DumiFlex eac852fbda feat(manager): label the matrix ungrouped axis run as "uncategorized"
The leftover run of sub-categories with no axis (or an empty axis name)
rendered as a blank band over its columns and no chip above its rows — it
read as a layout gap. It now gets the same treatment as a real axis: a
band ("uncategorized") over the columns and a header chip over the rows,
tinted neutral grey (distinct from the per-axis palette) with a dashed
edge so it still reads as the catch-all bucket rather than a named axis.

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

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

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

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

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

Sub-category group colors repeated: the per-component AXIS_HUES palette held two near-identical purples and wrapped with % length. Extracted one shared components/shared/axis-color.ts (axisHueAt) with a de-duplicated, order-separated palette plus a golden-angle fallback so every group is distinct; OptionRow, PoolSection, and WildcardEditor now share it.
2026-06-22 02:04:04 +03:00
semantic-release-bot d628a0687d chore(release): 2.5.0 [skip ci] 2026-06-18 04:36:29 +00:00
DumiFlex 02cc9f766f feat(manager): one-time library repair for non-hex module/bundle ids
Pre-existing ids that aren't 8-hex (e.g. an imported 'coloruni') can't be nested-referenced. A version-gated SPA-boot repair (runLibraryRepair, fired fire-and-forget from AppLayout) rekeys them. create() server-mints ids, so it creates copies -> builds the old->new map -> remaps every referrer (nested @{} via a literal pass since walkRemap's REF_RE is 8-hex only, plus whole-string constraint axes) -> updates -> deletes the old rows, ordered create->update->delete so a crash never leaves a dangling ref. Pure planning (findInvalidIds / planRemapUpdates / remapAll) unit-tested; orchestrator tested against a mock store.
2026-06-18 07:31:07 +03:00
DumiFlex a1d051172d fix(import): normalize non-hex module/bundle ids on import + download
A module/bundle id that isn't a canonical 8-hex uuid (e.g. a hand-authored 'coloruni' that round-tripped through import -> community -> download) silently can't be nested-referenced: @{coloruni} matches neither the editor chip tokenizer nor the engine resolver (both require [0-9a-f]{8}), so the ref renders as plain text and never resolves. install.ts now mints a fresh valid id for any invalid module/bundle id (new isValidId in utils/ids.ts) and folds it into the reattach rename map, so walkRemap rewrites every @{oldid} ref + constraint source/target axis to the new id. Covers both import and community download (same install path).
2026-06-18 07:00:09 +03:00
semantic-release-bot 5b5c635be6 chore(release): 2.4.0 [skip ci] 2026-06-18 02:53:08 +00:00
Dumitru Picalau ccb8392219 Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug
fix: inline branch weights + wildcard pool UI + debug warnings
2026-06-18 05:52:28 +03:00
DumiFlex d8c20728cb fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs
Three pool/UI fixes. (B) The MOD dot now lights on filter / pick-range / exclude-null changes — the category_filter check ran nonEmptyArr() on a string and silently missed, so toggling the pool filter never marked the instance modified. (C/D) The wildcard card subtitle shows 'X of Y options' when a filter narrows the pool, and '· picks N[-M]' when the instance multi-picks. (F) A nested @{uuid} ref to a live-library wildcard not in the chain no longer shows a false BROKEN REF — the Context node DB-expands its catalog at run, so it resolves; only refs absent from BOTH chain and library still flag (and those already emit unknown_ref in the debug panel). Bumps the extension bundle budget 431->432 KB.
2026-06-18 05:42:34 +03:00
DumiFlex 0e9e5db486 feat(engine): emit a constraint source-missing debug warning
A constraint with both ends absent from the chain emitted only the target-focused constraint_never_applied warning, so WP Debug showed one issue while the editor row flagged both source AND target missing. The pipeline now also emits constraint_source_missing (with a matching DebugViewer label) when the constraint's source wildcard isn't in the chain, so both ends surface in the debug panel.
2026-06-18 05:37:55 +03:00
DumiFlex a9516996b4 fix(engine): apply N:: branch weights in simple inline {a|b|c} choices
Simple inline picks resolved a branch uniformly and never stripped the N:: weight prefix, so {2::a|b} could emit the literal 2::a and {2::|, long shadows} leaked 2::. _resolve_inline_pick now strips the prefix and weights the pick when any branch carries one, staying byte-for-byte seed-stable for unweighted choices. Mirrored in the TS preview resolver (expandInlineChoices); multi-pick blocks already handled weights.
2026-06-18 05:33:24 +03:00
semantic-release-bot 02a046321f chore(release): 2.3.1 [skip ci] 2026-06-18 01:06:25 +00:00
Dumitru Picalau 6a5a8d318e Merge pull request #3 from DumiFlex/fix/instance-modal-scroll
fix(extension): scroll instance edit modals with pinned header + footer
2026-06-18 04:05:45 +03:00
DumiFlex a19e5f4500 Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll
# Conflicts:
#	scripts/check-bundle-size.mjs
2026-06-18 03:44:54 +03:00
DumiFlex c43bff0488 fix(extension): scroll instance edit modals with pinned header + footer
Every per-instance edit modal (wildcard, fixed-values, derivation, combine, constraint, bundle) now scrolls its body when content overflows, with the header pinned to the top and a sticky dock pinned to the bottom (runtime + footer, or target-reach + footer for constraints, which have no runtime) so Save is always reachable. The long inner lists — options, values, rules, exceptions — each scroll independently with sticky column headers. Constraint's target-reach moves to last. Compact + right-padded the exceptions delete button. Bumps the extension bundle budget 430->431 KB for the added modal CSS.
2026-06-18 03:23:24 +03:00
145 changed files with 7164 additions and 379 deletions
+168
View File
@@ -1,3 +1,171 @@
## 🎉 Wildcard Pipeline 2.9.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* fix(derivation-modal): absolute max-height for the rules scroll (revert flex) ([5d715fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5d715fb))
* fix(derivation-modal): pin rule cards so the rule list actually scrolls ([fc9d8a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fc9d8a8))
* fix(derivation-modal): scroll the rule LIST, not the whole modal ([6f664ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f664ce))
* fix(derivation-modal): scroll the rules region, pin header + footer ([e98aca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e98aca3))
* fix(engine): derivation hold freezes each branch roll, not the branch choice ([df76abd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df76abd))
* fix(engine): hold the resolved VALUE across iterations, not just the seed ([58c5b27](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/58c5b27))
* fix(injector): keep custom pin labels with the socket on drag-reorder ([41fae1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/41fae1e))
* fix(injector): persist custom pin labels durably across collapse + reload ([8568aa2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8568aa2))
* fix(injector): reconstruct collapsed-wire labels from data, not the dead stash ([0ede205](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0ede205))
* fix(injector): repair collapse-wire labels corrupted across a reload ([0281b8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0281b8e))
* fix(injector): right-align the type pill on label-less ref/slot rows ([bf61f64](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf61f64))
* docs(spa): describe hold as a value freeze (constrained picks + nested refs) ([1b3e4aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b3e4aa))
* docs(spa): document per-iteration overrides, seed scope + lock-previous ([4a34c9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4a34c9b))
* docs(spa): mention the right-click Hold toggle shortcut ([a738618](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a738618))
* docs(spa): note derivation supports Hold across run ([aba4e8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/aba4e8c))
* feat(context): quick Hold toggle in the module row context menu ([8dcebdc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcebdc))
* feat(engine): derivation honors seed_scope=hold (freeze frame-0 output) ([ff6825c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff6825c))
</details>
## 🎉 Wildcard Pipeline 2.8.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* fix(context): add hover states to the per-kind edit modal footer buttons ([b292699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b292699))
* fix(context): capture the loop's per-frame seeds so the lock pins the previous roll ([b33afd8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b33afd8))
* fix(context): clear redundant frame lock-null override when the base unlocks ([f38a316](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f38a316))
* fix(context): coerce disabled_frames to int set and guard frame toggle ([65683ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/65683ae))
* fix(context): modal lock button pins the rolled seed via injected resolver ([e56fd22](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e56fd22))
* fix(context): name the disabled toggles in the per-frame runtime hint + wrap loop frame chips ([2fb853f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2fb853f))
* fix(context): per-frame enable overrides base enabled (frame_enabled) ([6eb4b73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6eb4b73))
* fix(context): per-frame seed lock captures that frame's own rolled seed ([25388b4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/25388b4))
* fix(context): per-frame seed lock computes the frame's real seed from the loop ([1f164df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1f164df))
* fix(context): restrict per-frame overrides to an allowlist of meaningful fields ([bb5e84d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5e84d))
* fix(context): resync editor draft on frame revert + restyle the banner for readability ([39fdb6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39fdb6f))
* fix(context): scope the wrapping grid to the frame chips only ([c48aafb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c48aafb))
* fix(context): stack the frame banner above the editor card instead of beside it ([3c492f1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3c492f1))
* fix(context): sweep redundant frame lock-null when base unlocks via the modal ([e1885a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e1885a3))
* fix(engine): apply iteration_overrides for generic-object modules ([942834d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/942834d))
* fix(engine): include disabled_frames in the generic-object snapshot keys ([03796c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/03796c5))
* fix(engine): stop double-shifting the per-iteration override seed ([3527ada](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3527ada))
* fix(extension): read loop seed and count as numbers for per-frame seed lock ([67a09df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67a09df))
* feat(context-loop): add edit-frame selector chip row ([ece6d49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ece6d49))
* feat(context-loop): add session frame-cursor singleton ([6dc033d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6dc033d))
* feat(context): add hold-across-run toggle to the runtime sections ([9663636](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9663636))
* feat(context): add lock-previous-seed to the iteration-seed modals ([c7c867e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c7c867e))
* feat(context): add pure frame-override helpers ([7b416ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b416ad))
* feat(context): disable run-level controls while editing a frame ([1822297](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1822297))
* feat(context): frame banner with revert-to-base in the module editor ([79cf385](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79cf385))
* feat(context): held and per-frame override row badges ([e93f7ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e93f7ba))
* feat(context): make the row quick-lock act on the active frame ([21f2850](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/21f2850))
* feat(context): per-frame enable/disable via disabled_frames ([8a9dc9e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9dc9e))
* feat(context): redirect edits into iteration_overrides when a frame is active ([4e71e04](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4e71e04))
* feat(engine): apply per-frame iteration_overrides onto module instance ([10dcac4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/10dcac4))
* feat(engine): combine honors hold seed_scope ([8871a28](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8871a28))
* feat(engine): fixed_values honors hold seed_scope ([15c1ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/15c1ade))
* feat(engine): thread a hold seed and loop index into the pipeline run ([0f7aaab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f7aaab))
* feat(engine): wildcard honors hold seed_scope, locked_seed still wins ([072e4e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/072e4e7))
* feat(manager): snapshot source/target tag_groups into published constraints ([ff4d8fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff4d8fa))
* feat(types): add seed_scope and iteration_overrides to module config ([8910b98](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8910b98))
* revert(context): drop per-frame seed capture, superseded by double-shift fix ([91b5f75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/91b5f75))
* refactor(context): unify frame-hint markup and add false-case coverage ([8f696d6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8f696d6))
</details>
## 🎉 Wildcard Pipeline 2.7.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* feat(engine): add apply_seed_locks for per-iteration seed overrides ([5da64f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5da64f8))
* feat(loop): apply per-iteration seed locks in the context loop node ([4884598](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4884598))
* feat(loop): per-iteration seeds button + modal; shrink iteration-var buttons ([86eb15c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/86eb15c))
* feat(seedlist): add paste-to-replace locks from copied text ([fa73750](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa73750))
* feat(seedlist): alt-click copy to grab only the locked seeds ([589f45e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/589f45e))
* feat(seedlist): apply per-iteration seed locks to the output series ([fd2cdcb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd2cdcb))
* feat(seedlist): flash a copied indicator on the modal copy button ([34ec18f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/34ec18f))
* feat(seedlist): per-iteration seeds button + modal ([43d775e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43d775e))
* feat(seedlist): preview effective base/count/strategy from the wired loop ([68910bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/68910bf))
* feat(seedlist): show out-of-range locks dimmed but still editable ([b49a579](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b49a579))
* feat(shared): add per-iteration view/set/lock row (SeedLockRow) ([1ad7e8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ad7e8e))
* feat(shared): ts mirror of loop seed derivation with py-ts parity corpus ([cc64ca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cc64ca3))
* feat(shared): view/set/lock per-iteration seeds modal ([0f0cd47](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f0cd47))
* feat(widgets): add seed_locks field to loop + seed-list configs ([5a3e834](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a3e834))
* feat(widgets): feed stock base-seed + count into the seed modal ([fb19e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fb19e3c))
* feat(widgets): seed-list override hint + capitalize loop bypass label ([44d8ab9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d8ab9))
* test(widgets): restore parse-recovery coverage dropped alongside seed_locks tests ([b5262c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b5262c2))
</details>
## 🎉 Wildcard Pipeline 2.6.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* fix(api): keep duplicate children when creating or updating bundles ([c1f45a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c1f45a3))
* fix(constraint): drop matrix scroll-container padding that leaked scrolled cells ([d3e25b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3e25b7))
* fix(constraint): stop scrolled cells bleeding through the frozen matrix headers ([4098009](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4098009))
* fix(manager): decimal option probabilities and distinct sub-category group colors ([11d205b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11d205b))
* feat(constraint): group the canvas matrix axes like the SPA ([538db06](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/538db06))
* feat(manager): author and save bundles directly in the manager ([a77e60b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a77e60b))
* feat(manager): group constraint matrix axes with header chips and column bands ([c0f354c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0f354c))
* feat(manager): label the matrix ungrouped axis run as "uncategorized" ([eac852f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eac852f))
* feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart ([0bbf150](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bbf150))
* feat(manager): type-to-filter all dropdowns ([b3537ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b3537ad))
</details>
## 🎉 Wildcard Pipeline 2.5.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* feat(manager): one-time library repair for non-hex module/bundle ids ([02cc9f7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02cc9f7))
* fix(import): normalize non-hex module/bundle ids on import + download ([a1d0511](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a1d0511))
</details>
## 🎉 Wildcard Pipeline 2.4.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
</details>
## 🎉 Wildcard Pipeline 2.3.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
</details>
## 🎉 Wildcard Pipeline 2.3.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
+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:
+143 -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
@@ -613,6 +710,44 @@ class PipelineEngine:
# otherwise render as plain text).
"message": message,
})
# Source-side companion warning. The `never_applied` message
# above is target-focused; when the constraint's SOURCE
# wildcard is ALSO absent from the chain, surface that
# distinctly so the Debug viewer shows BOTH ends (mirrors the
# editor's separate "source missing" / "target missing" row
# badges). A constraint registers by its own module position,
# so it can sit in the bucket with neither end present.
source_uuid = c.get("source_wildcard_id", "")
if source_uuid and not _target_present_in_chain(
source_uuid, modules, catalog,
):
src_row = (
catalog.get(source_uuid) if isinstance(catalog, dict) else None
)
source_name = (
(src_row.get("name", "") or "")
if isinstance(src_row, dict) else ""
)
src_suffix = _build_ref_name_suffix(source_name)
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
if isinstance(warnings_bucket, list):
warnings_bucket.append({
"type": "constraint_source_missing",
"severity": "warn",
"module_id": cid,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {
"constraint_id": cid,
"source_wildcard_id": source_uuid,
},
"message": (
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
f"this chain, so it never registers a source pick."
),
})
# SP3 partial-reach finalisation: a constraint that DID fire but
# whose `next N` / `pick` selector matched FEWER occurrences than
+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")
+13 -2
View File
@@ -169,8 +169,19 @@ def _resolve_inline_pick(
branches: list[str] = tok.meta.get("branches", [])
if not branches:
return ""
chosen_idx = ctx.rng.randrange(len(branches))
chosen = branches[chosen_idx]
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
# honors. Strip the prefix off every branch so it never leaks into output;
# only switch to weighted selection when a branch actually carries a weight,
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
# randrange path — changing the draw would shift every existing seed).
values = [_strip_branch_weight(b) for b in branches]
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
if has_weights:
weights = [_parse_branch_weight(b) for b in branches]
chosen_idx = _weighted_pick_index(weights, ctx.rng)
else:
chosen_idx = ctx.rng.randrange(len(branches))
chosen = values[chosen_idx]
if not chosen:
return ""
nested_tokens = tokenize_text(chosen)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.3.0",
"version": "2.9.0",
"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.3.0"
version = "2.9.0"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+65 -8
View File
@@ -157,14 +157,71 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
// the SP3 constraint feature.
// Bumped 430 -> 431 KB on 2026-06-18 for the editor robustness pass:
// RichTextInput's scoped renderEpoch rebuild (nested-ref picker crash fix)
// + useEditorDraft flush-on-unmount/unload + ErrorBoundary copy, plus the
// clipboard-shield wired into _shared.ts + main.ts (Ctrl-A/C/V/X guard on
// editable widget inputs). ~0.3 KB net gzip across the extension entry +
// shared chunks. Landed on the bulk-editor branch (the bulk UI itself is
// manager-only and adds nothing to js/).
const TOTAL_LIMIT = 431 * 1024; // 431 KB
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
// exceptions trash, across all 6 edit modals.
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
// across the ContextWidget + conflicts chunks; entry stays put.
// Bumped 432 -> 435 KB on 2026-06-22 for the canvas constraint-matrix grouping
// parity. MatrixSection (the ComfyUI instance-modal grid) gains the SPA's
// grouped-axis layout — column bands, row header chips, solo eyebrow, the
// labelled "uncategorized" bucket, and frozen headers + both-axis scroll —
// plus the shared components/shared/matrix-axis module the SPA matrix now
// imports too. ~1.1 KB net gzip in the lazy ContextWidget chunk; entry stays
// put. Approved with the matrix-parity feature request.
// Bumped 435 -> 442 KB on 2026-06-23 for per-iteration seeds modal
// (T8+T9). ContextLoopWidget gains the SeedListModal + SeedLockRow lazy
// body (v-if gated) — adds ~5.5 KB net gzip to the ContextLoopWidget
// lazy chunk. SeedListWidget gains a matching button in T9. Headroom
// covers both T8 and T9 landings without per-task bump friction.
// Bumped 442 -> 443 KB on 2026-06-23 for T9 (SeedListWidget seeds
// button). The SeedListWidget chunk grew ~0.7 KB gzip adding the button
// template + ref/computed script + seedbtn CSS; T8's headroom estimate
// was tight. Minimal bump to the actual measured ceiling.
// Bumped 443 -> 444 KB on 2026-06-23 for T12 (seed-list preview
// effective base/count/strategy from wired loop). Adds preview.ts pure
// resolver + graph-walk glue in seed_list.ts + previewStrategy prop in
// SeedListWidget.vue. ~0.4 KB net gzip in the seed_list widget chunk.
// Bumped 444 -> 445 KB on 2026-06-23 for T16 (seed modal paste-to-replace).
// Adds parseSeedLocks in seed-derive + pasteAll/Paste button in SeedListModal.
// Measured 454900 bytes (244 over the 444 ceiling); minimal bump.
// Bumped 445 -> 446 KB on 2026-06-24 for UI-C (wire ContextWidget frame edits).
// Adds imports of frame-cursor / frame-overrides / INSTANCE_FIELDS_PER_KIND into
// the ContextWidget lazy chunk + the merge-on-open / write-redirect logic.
// Measured 455772 bytes (92 over the 445 ceiling); minimal bump.
// Bumped 446 -> 447 KB on 2026-06-24 for RF2 (frame-aware row quick-lock).
// Adds toggleFrameLock helper in frame-overrides.ts + effectiveLockedSeed +
// frame branch in toggleLockOnCard + frame-aware isLocked. Net ~168 bytes
// gzip in the lazy ContextWidget chunk; entry stays put.
// Bumped 447 -> 448 KB on 2026-06-24 for the symmetric per-frame enable
// redesign (a `frame_enabled` override map replaces the `disabled_frames`
// blocklist so a base-off module can be turned ON for select frames). Adds
// frameEnabledAt / effectiveEnabled / frameEnableOverride / toggleFrameEnabled
// to frame-overrides.ts, the effective-enabled checkbox + bidirectional
// on-#k / off-#k badge in ModuleRow, and the .wp-mod-badge--on CSS. Measured
// 457868 bytes (140 over the 447 ceiling); minimal bump.
// Bumped 448 -> 449 KB on 2026-06-25 for the per-frame seed-lock correctness
// work: the Loop's derived-series capture (loop_seeds UI payload + onExecuted
// store + frameSeedReader preference, so the lock pins the PREVIOUS run's
// per-frame seed under control_after_generate=randomize) plus the modal
// Lock-button resolver (lock-seed-ctx provide/inject into SeedLockControls).
// Landed exactly at the 448 ceiling (458752/458752); bump for headroom.
// Bumped 449 -> 450 KB on 2026-06-28 for the row context-menu Hold toggle.
// Adds HOLD_KINDS + isHoldable + toggleHold to ContextWidget plus the quick
// "Hold across run" / "Release hold" menu item (2-click seed_scope toggle,
// base-only with a frame-active disabled hint) in the lazy ContextWidget
// chunk; entry stays put. Measured 459909 bytes (133 over the 449 ceiling);
// minimal bump.
const TOTAL_LIMIT = 450 * 1024; // 450 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,60 @@ 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);
});
});
@@ -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,16 @@ 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 });
}
function pickStrategy(s: LoopStrategy): void {
if (props.modelValue.strategy === s) return;
@@ -108,6 +128,25 @@ 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>
<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 }"
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i" @click="setFrame(i)">#{{ i + 1 }}</button>
</div>
</div>
<div
class="wp-loop__row"
:title="OVERRIDE_SEED_TOOLTIP"
@@ -169,7 +208,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 +220,12 @@ 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"
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
</div>
</template>
@@ -204,9 +249,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;
@@ -289,8 +343,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 +360,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;
}
+19
View File
@@ -30,6 +30,7 @@ describe("parseContextLoopConfig", () => {
bypass: true,
iteration_internal: false,
total_internal: false,
seed_locks: {},
};
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -42,6 +43,7 @@ describe("parseContextLoopConfig", () => {
bypass: false,
iteration_internal: true,
total_internal: true,
seed_locks: {},
};
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -65,8 +67,25 @@ describe("parseContextLoopConfig", () => {
bypass: true,
iteration_internal: false,
total_internal: true,
seed_locks: {},
};
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 });
});
});
+11
View File
@@ -20,6 +20,9 @@ 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>;
}
const STRATEGIES = new Set<LoopStrategy>(["sequential", "hash_index", "prime_stride"]);
@@ -32,6 +35,7 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
bypass: false,
iteration_internal: true,
total_internal: true,
seed_locks: {},
};
}
@@ -60,6 +64,13 @@ 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;
}
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();
+208 -14
View File
@@ -28,7 +28,9 @@ 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";
import {
cascadeRestoreForBundle,
@@ -73,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 {
@@ -150,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
@@ -678,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
@@ -2027,6 +2037,9 @@ const conflicts = computed<Conflict[]>(() => {
props.downstreamWildcardUuids,
props.downstreamNestedReachUuids,
pairOrphanByUid,
// Live-library uuids — a nested @{} ref to any of these resolves at run
// time (DB-expanded catalog), so it must not read as a broken ref.
new Set(Object.keys(libraryHashes.value ?? {})),
);
// Filter by user's validation-strictness preference. The accessor
// reads from the same module-level state map the panel onChange
@@ -2806,7 +2819,16 @@ function isModified(m: ModuleEntry): boolean {
if (nonEmptyObj(inst.option_weights)) return true;
if (inst.mode && inst.mode !== "random") return true;
if (inst.pinned_option_id) return true;
if (nonEmptyArr(inst.category_filter)) return true;
// `category_filter` is a STRING expr — the old `nonEmptyArr()` test
// never matched it, so toggling the pool filter never lit the dot.
if (typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") return true;
if (inst.exclude_null) return true;
// Pick-range override (multi-pick / narrowed). Sparse — absent or 1..1
// is the library default; anything else is a behavioural override.
if (inst.pick_min != null && inst.pick_min !== 1) return true;
if (inst.pick_max != null && inst.pick_max !== 1) return true;
if (inst.pick_independent) return true;
if (typeof inst.pick_separator === "string" && inst.pick_separator !== "") return true;
return false;
case "fixed_values":
// Library-tracked: `values_overrides` non-empty = user edited entries,
@@ -2867,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
@@ -2925,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") {
@@ -2967,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);
}
@@ -3181,14 +3245,37 @@ function summaryTokens(m: ModuleEntry): SummaryToken[] {
// Card summary respects the per-instance binding override so a
// renamed wildcard reads as `$<override>` on the canvas. Engine
// applies the same precedence at run time.
const inst = m.instance?.variable_binding;
const instBind = m.instance?.variable_binding;
const payloadBinding = (p.var_binding as string | undefined)?.trim();
const binding = (typeof inst === "string" && inst.trim()) || payloadBinding;
const opts = Array.isArray(p.options) ? p.options.length : 0;
const binding = (typeof instBind === "string" && instBind.trim()) || payloadBinding;
const head: SummaryToken = binding ? v(binding) : lit("wildcard");
return opts
? [head, lit(` · ${opts} option${opts === 1 ? "" : "s"}`)]
: [head];
const allOpts = (Array.isArray(p.options) ? p.options : []) as WildcardOption[];
const total = allOpts.length;
if (!total) return [head];
const inst = (m.instance ?? {}) as InstanceLike & { pick_min?: number; pick_max?: number };
const pmin = typeof inst.pick_min === "number" ? inst.pick_min : 1;
const pmax = typeof inst.pick_max === "number" ? Math.max(pmin, inst.pick_max) : pmin;
const multiActive = pmax > 1;
// "X of Y options" only when a filter / enabled-subset actually narrows
// the pool — a plain count when nothing is filtering (keeps the common
// case clean). The null-option drop in multi mode alone never triggers it.
const hasFilter =
(typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") ||
Array.isArray(inst.enabled_options);
const eligible = allOpts.filter((o) => isEnabled(o, inst, multiActive)).length;
const tokens: SummaryToken[] = [
head,
lit(
hasFilter && eligible < total
? ` · ${eligible} of ${total} options`
: ` · ${total} option${total === 1 ? "" : "s"}`,
),
];
// Multi-pick hint — surface when the instance resolves more than one.
if (pmax > 1) {
tokens.push(lit(pmin === pmax ? ` · picks ${pmin}` : ` · picks ${pmin}-${pmax}`));
}
return tokens;
}
case "combine": {
// Same per-instance precedence as wildcard: rebinding via the v2
@@ -3661,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;
@@ -3682,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
@@ -3779,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(() => {
@@ -3795,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
@@ -3998,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
@@ -4684,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,
@@ -4950,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'"
@@ -20,6 +20,12 @@
.wp-wcm__head { --modal-accent: var(--wp-kind-wildcard); }
.wp-cbm__head, .wp-cnm__head, .wp-dvm__head, .wp-fvm__head, .wp-wcm__head {
/* Pinned to the top of the scrolling modal body so the title + close
* stay visible while the content scrolls. Opaque (gradient bottoms out
* at --wp-bg2) so scrolled content doesn't bleed through. */
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 10px;
@@ -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"],
};
@@ -102,6 +102,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
:library-color="libraryColor"
@update="(patch) => emit('update', patch)"
/>
<div class="wp-bdm__dock">
<RuntimeSection
:lock-state="lockState"
:internal-state="internalState"
@@ -157,12 +158,13 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
Save
</button>
</footer>
</div>
</div>
</template>
<style scoped>
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
.wp-bdm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow-y: auto; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
.wp-bdm__head { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
.wp-bdm__head-icon { color: var(--modal-accent, var(--wp-accent)); font-size: 18px; width: 24px; text-align: center; }
.wp-bdm__title-block { flex: 1; min-width: 0; }
.wp-bdm__title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -175,6 +177,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
.wp-bdm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; }
.wp-bdm__close:hover { color: var(--wp-text); }
.wp-bdm__close .pi { font-size: 12px; }
.wp-bdm__dock { position: sticky; bottom: 0; z-index: 2; background: var(--wp-bg2); }
.wp-bdm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
.wp-bdm__spa-link { display: inline-flex; align-items: center; gap: 5px; font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); text-decoration: none; }
.wp-bdm__spa-link:hover { color: var(--wp-accent-text, var(--wp-text)); }
@@ -183,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
@@ -109,7 +113,8 @@ function onSpaClick(): void {
:sibling-vars="siblingVars"
@update="onUpdate"
/>
<RuntimeSection :module="module" @update="onUpdate" />
<div class="wp-cbm__dock">
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-cbm__foot">
<a
@@ -147,6 +152,7 @@ function onSpaClick(): void {
<button type="button" class="wp-cbm__btn" data-test="cbm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-cbm__btn wp-cbm__btn--primary" data-test="cbm-save" @click="emit('save')">Save</button>
</footer>
</div>
</div>
</template>
@@ -160,7 +166,7 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
@@ -168,6 +174,12 @@ function onSpaClick(): void {
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). Every modal in this family shares
* the same accent-tinted gradient + chip + close button. */
.wp-cbm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-cbm__foot {
display: flex;
align-items: center;
@@ -212,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: () => [] },
);
@@ -90,6 +95,7 @@ function spaUrl(): string {
interface WildcardPayload {
sub_categories?: string[];
options?: Array<{ value?: string }>;
tag_groups?: Record<string, string[]>;
}
/** Normalised module shape the resolution lookups consume. Unifies the
@@ -284,6 +290,19 @@ const targetValues = computed<string[]>(() => {
return Array.from(set);
});
/** Per-axis grouping (tag_groups) for the matrix headers — orders + colours
* the row/col tags by sub-category axis. Sibling/chain wildcard payloads
* carry it; the preview-resolver cache does not, so a cross-context ref
* resolved only via cache groups flat (acceptable degradation). */
const sourceGroups = computed<Record<string, string[]>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return findWildcard(pl.source_wildcard_id)?.tag_groups ?? {};
});
const targetGroups = computed<Record<string, string[]>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return findWildcard(pl.target_wildcard_id)?.tag_groups ?? {};
});
const sourceName = computed(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return wildcardName(pl.source_wildcard_id);
@@ -688,17 +707,11 @@ function onSpaClick(): void {
:target-subs="targetSubs"
:source-name="sourceName"
:target-name="targetName"
:source-groups="sourceGroups"
:target-groups="targetGroups"
:stranded="hasDangling"
@update="onUpdate"
/>
<TargetReachSection
:model-value="targetSelect"
:chain-modules="chainModules"
:constraint-uid="constraintUid"
:target-wildcard-id="targetWildcardId"
:target-name="targetName"
@update:model-value="onTargetSelect"
/>
<ExceptionsSection
:module="module"
:source-values="sourceValues"
@@ -712,6 +725,15 @@ function onSpaClick(): void {
@update="onUpdate"
/>
<div class="wp-cnm__dock">
<TargetReachSection
:model-value="targetSelect"
:chain-modules="chainModules"
:constraint-uid="constraintUid"
:target-wildcard-id="targetWildcardId"
:target-name="targetName"
@update:model-value="onTargetSelect"
/>
<footer class="wp-cnm__foot">
<a
v-if="isLibraryTracked"
@@ -748,6 +770,7 @@ function onSpaClick(): void {
<button type="button" class="wp-cnm__btn" data-test="cnm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-cnm__btn wp-cnm__btn--primary" data-test="cnm-save" @click="emit('save')">Save</button>
</footer>
</div>
<!-- Feature 2: confirm pulling the missing dependency (+ its closure)
from the community before any network/install side effect. -->
@@ -772,13 +795,19 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-cnm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-cnm__foot {
display: flex;
align-items: center;
@@ -823,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));
@@ -80,7 +80,28 @@ describe("ConstraintReattachSection", () => {
const w = mountSection({ sourceCachedName: "colour", refData: rd });
await w.find("[data-test='reattach-btn-source']").trigger("click");
const first = w.findAll("[data-test='reattach-candidate']")[0];
expect(first.text()).toBe("colour");
expect(first.find("[data-test='reattach-cand-name']").text()).toBe("colour");
});
it("shows each candidate's uuid + option/sub-category meta so duplicates are distinguishable", async () => {
const rd = refData();
// Two library wildcards share the name "colour" — only the uuid + content
// tell them apart. Without these the picker is a coin-flip.
rd.uuidToName = new Map([["beef0001", "colour"], ["beef0002", "colour"]]);
rd.uuidToSubCategories = new Map([["beef0001", ["warm", "cold"]], ["beef0002", ["hue"]]]);
rd.uuidToOptionsCount = new Map([["beef0001", 2], ["beef0002", 9]]);
const w = mountSection({ refData: rd });
await w.find("[data-test='reattach-btn-source']").trigger("click");
const c1 = w.find("[data-test-id='reattach-candidate-beef0001']");
const c2 = w.find("[data-test-id='reattach-candidate-beef0002']");
// The 8-hex uuid is the definitive disambiguator.
expect(c1.text()).toContain("beef0001");
expect(c2.text()).toContain("beef0002");
// Option count + sub-categories give a human-readable tiebreaker.
expect(c1.text()).toContain("2 opts");
expect(c1.text()).toContain("warm");
expect(c2.text()).toContain("9 opts");
expect(c2.text()).toContain("hue");
});
it("renders the community-download button only on a downloadable side", () => {
@@ -66,7 +66,7 @@ const openSide = ref<Side | null>(null);
const query = ref("");
const picked = ref<{ uuid: string; name: string } | null>(null);
interface Candidate { uuid: string; name: string }
interface Candidate { uuid: string; name: string; count: number; subCats: string[] }
function candidates(): Candidate[] {
const q = query.value.trim().toLowerCase();
@@ -78,7 +78,12 @@ function candidates(): Candidate[] {
for (const [uuid, name] of props.refData.uuidToName) {
if (dead.has(uuid)) continue; // can't re-pick the dangling uuid
if (q && !name.toLowerCase().includes(q)) continue;
out.push({ uuid, name });
out.push({
uuid,
name,
count: props.refData.uuidToOptionsCount.get(uuid) ?? 0,
subCats: props.refData.uuidToSubCategories.get(uuid) ?? [],
});
}
return out.sort((a, b) => {
const ax = a.name.toLowerCase() === cached ? 0 : 1;
@@ -87,6 +92,14 @@ function candidates(): Candidate[] {
});
}
/** One-line disambiguator under the name: option count + declared
* sub-categories. Same-named wildcards differ here (or, failing that, by the
* always-shown uuid). */
function candMeta(c: Candidate): string {
const opts = `${c.count} opt${c.count === 1 ? "" : "s"}`;
return c.subCats.length ? `${opts} · ${c.subCats.join(", ")}` : opts;
}
function openDropdown(side: Side): void {
openSide.value = side;
// Empty query keeps every live candidate visible; the cached name is the
@@ -153,7 +166,13 @@ function confirm(side: Side): void {
:data-test-id="`reattach-candidate-${c.uuid}`"
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
@click="pick(c)"
>{{ c.name }}</li>
>
<span class="rb__cand-main">
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
<code class="rb__cand-uuid">{{ c.uuid }}</code>
</span>
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
</li>
</ul>
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-source">
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
@@ -194,7 +213,13 @@ function confirm(side: Side): void {
:data-test-id="`reattach-candidate-${c.uuid}`"
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
@click="pick(c)"
>{{ c.name }}</li>
>
<span class="rb__cand-main">
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
<code class="rb__cand-uuid">{{ c.uuid }}</code>
</span>
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
</li>
</ul>
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-target">
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
@@ -273,9 +298,13 @@ function confirm(side: Side): void {
outline: none;
}
.rb__list { list-style: none; margin: 6px 0; padding: 0; max-height: 150px; overflow-y: auto; }
.rb__cand { padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
.rb__cand { display: flex; flex-direction: column; gap: 2px; padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
.rb__cand:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
.rb__cand--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
.rb__cand-main { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.rb__cand-name { font: 11px var(--wp-font-sans); }
.rb__cand-uuid { flex-shrink: 0; font: 10px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #6e6e7c); }
.rb__cand-meta { font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text-dim, #6e6e7c)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rb__dropped { margin: 6px 0 0; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
.rb__blast { margin: 6px 0 0; display: flex; align-items: center; gap: 5px; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
.rb__note { margin: 6px 0 0; font: 10px var(--wp-font-sans); color: var(--wp-text-dim, #6e6e7c); }
@@ -304,6 +304,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
<section class="ex" data-test="ex-section">
<div class="ex__label">Exceptions</div>
<div class="ex__list">
<div
v-for="(exc, i) in libraryExceptions"
:key="libKey(exc)"
@@ -551,6 +552,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
><i class="pi pi-trash" /></button>
</template>
</div>
</div>
<button
v-if="!stranded"
@@ -581,11 +583,18 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
color: var(--wp-text-dim, var(--wp-text3));
margin-bottom: 8px;
}
.ex__list {
/* Long exception lists scroll in place; the label + add button stay put. */
max-height: 34vh;
overflow-y: auto;
}
.ex__row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
/* right padding keeps the trash off the section edge + the list
* scrollbar when the exceptions list scrolls. */
padding: 4px 8px 4px 0;
font: 11px var(--wp-font-mono);
border-bottom: 1px dashed var(--wp-border-soft, var(--wp-border));
}
@@ -801,17 +810,17 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: 20px;
height: 20px;
padding: 0;
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
border: 1px solid var(--wp-border);
background: transparent;
border: 0;
border-radius: 4px;
color: var(--wp-text-dim, var(--wp-text3));
cursor: pointer;
font-size: 11px;
flex-shrink: 0;
transition: background 0.12s, border-color 0.12s, color 0.12s;
transition: background 0.12s, color 0.12s;
}
.ex__trash:hover {
background: color-mix(in srgb, var(--wp-danger, #e05252) 18%, transparent);
@@ -93,6 +93,64 @@ describe("constraint MatrixSection — layout", () => {
});
});
describe("constraint MatrixSection — grouped axes", () => {
it("renders a column band per target axis + a row header chip per source axis", () => {
const w = mount(MatrixSection, {
props: {
module: makeModule(),
sourceSubs: SOURCE_SUBS,
targetSubs: TARGET_SUBS,
sourceGroups: { warmth: ["red", "blue"] },
targetGroups: { fabric: ["cotton", "silk"] },
},
});
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length)).toEqual(["fabric"]);
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]);
// Cells stay keyed by tag name, so the grouped layout is reorder-safe.
expect(w.find('[data-test="mx-cell-red-silk"]').exists()).toBe(true);
expect(w.find('[data-test="mx-cell-blue-cotton"]').exists()).toBe(true);
});
it("a solo source axis folds its name into the tag eyebrow (no header chip)", () => {
const w = mount(MatrixSection, {
props: {
module: makeModule(),
sourceSubs: ["red", "blue", "green"],
targetSubs: TARGET_SUBS,
sourceGroups: { warmth: ["red", "blue"], shade: ["green"] },
},
});
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]); // shade is solo
const solo = w.find(".mx-th-row.solo");
expect(solo.exists()).toBe(true);
expect(solo.text()).toContain("shade");
expect(solo.text()).toContain("green");
});
it("labels the leftover ungrouped run 'uncategorized' instead of leaving it blank", () => {
const w = mount(MatrixSection, {
props: {
module: makeModule(),
sourceSubs: ["red", "blue", "extra"],
targetSubs: ["cotton", "silk", "wool"],
sourceGroups: { warmth: ["red", "blue"] },
targetGroups: { fabric: ["cotton", "silk"] },
},
});
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth", "uncategorized"]);
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length))
.toEqual(["fabric", "uncategorized"]);
});
it("stays flat (no bands / chips) when no groups are passed", () => {
const w = mount(MatrixSection, {
props: { module: makeModule(), sourceSubs: ["red"], targetSubs: ["cotton"] },
});
expect(w.findAll(".mx-th-band").length).toBe(0);
expect(w.findAll(".mx-grp-head").length).toBe(0);
});
});
describe("constraint MatrixSection — override marker", () => {
it("renders override marker when mode differs from library", () => {
const w = mount(MatrixSection, {
@@ -15,12 +15,19 @@
* three (`allow` / `disabled` / missing) as runtime passthrough,
* so the collapse is engine-equivalent.
*/
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type { ModuleEntry } from "../../../../../widgets/_shared";
import { encodeKey } from "../../instance/keys";
import { patchInstance } from "../../instance/patch";
import CellRulePopover from "../CellRulePopover.vue";
import MatrixLegend from "../MatrixLegend.vue";
import {
groupHue as tagHue,
groupLabel,
isBucket,
orderByGroups,
toGroups,
} from "../../../../shared/matrix-axis";
type Mode = "neutral" | "exclude" | "boost" | "reduce";
@@ -33,6 +40,12 @@ const props = withDefaults(
targetSubs: readonly string[];
sourceName?: string;
targetName?: string;
/** Sub-category axes (tag_groups) of the source/target wildcards. When
* present, the matrix orders + colours its rows / cols by axis (bands on
* the columns, header chips on the rows) — same treatment as the SPA
* ConstraintMatrix. Empty → flat source/target-tinted headers. */
sourceGroups?: Record<string, string[]>;
targetGroups?: Record<string, string[]>;
/** Read-only recovery view: the source/target wildcard was deleted, so
* the configured rules are shown for understanding only. Cells don't
* open the rule popover; the grid frame + cells mute to a snapshot.
@@ -40,7 +53,7 @@ const props = withDefaults(
* ConstraintMatrix `readonly` prop / `wp-mx--readonly` treatment. */
stranded?: boolean;
}>(),
{ sourceName: "", targetName: "", stranded: false },
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}), stranded: false },
);
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
@@ -61,6 +74,26 @@ const cellFactorOverrides = computed<Record<string, number>>(
() => (instance.value.cell_factor_overrides as Record<string, number> | null) ?? {},
);
// ── Axis grouping — order + group + colour rows/cols by sub-category axis,
// shared with the SPA ConstraintMatrix via the matrix-axis module. ──
const orderedSources = computed(() => orderByGroups([...props.sourceSubs], props.sourceGroups));
const orderedTargets = computed(() => orderByGroups([...props.targetSubs], props.targetGroups));
const rowGroups = computed(() => toGroups(orderedSources.value));
const colGroups = computed(() => toGroups(orderedTargets.value));
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
// Measure the column band row's height so the tag row beneath it sticks at
// the right `top` offset, independent of font sizing.
const gridEl = ref<HTMLElement | null>(null);
function measureBands(): void {
const grid = gridEl.value;
if (!grid) return;
const band = grid.querySelector<HTMLElement>(".mx-th-band");
grid.style.setProperty("--mx-band-h", `${band?.offsetHeight ?? 0}px`);
}
watch(colGroups, () => void nextTick(measureBands));
/** Reads `payload.matrix[src][tgt]`. Returns null when no library rule. */
function libCell(src: string, tgt: string): Cell | null {
const cell = matrix.value[src]?.[tgt];
@@ -303,12 +336,15 @@ onMounted(() => {
document.addEventListener("keydown", onDocKeydown);
window.addEventListener("scroll", reanchorPopover, true);
window.addEventListener("resize", reanchorPopover);
window.addEventListener("resize", measureBands);
void nextTick(measureBands);
});
onBeforeUnmount(() => {
document.removeEventListener("mousedown", onDocPointerDown, true);
document.removeEventListener("keydown", onDocKeydown);
window.removeEventListener("scroll", reanchorPopover, true);
window.removeEventListener("resize", reanchorPopover);
window.removeEventListener("resize", measureBands);
});
</script>
@@ -335,40 +371,101 @@ onBeforeUnmount(() => {
</span>
</div>
<div class="mx-grid">
<div ref="gridEl" class="mx-grid">
<table>
<thead>
<tr>
<th class="mx-corner" data-test="mx-corner">
<th class="mx-corner" :rowspan="hasColBands ? 2 : 1" data-test="mx-corner">
<span class="row-1">↓ source</span>
<span class="row-2">→ target</span>
</th>
<th v-for="t in targetSubs" :key="t" class="mx-th-col">{{ t }}</th>
<!-- Grouped cols: a band row names each axis, spanning its tags. -->
<template v-if="hasColBands">
<th
v-for="(grp, gi) in colGroups"
:key="`band-${gi}`"
class="mx-th-band"
:class="{ 'mx-th-band--bucket': isBucket(grp) }"
:colspan="grp.tags.length"
:style="{ '--ax': tagHue(grp, 'target', true) }"
:title="groupLabel(grp)"
><span class="chip">{{ groupLabel(grp) }}</span></th>
</template>
<!-- Flat cols: tag headers sit directly in the single header row. -->
<template v-else>
<th
v-for="t in targetSubs"
:key="t"
class="mx-th-col"
:style="{ '--ax': 'var(--wp-constraint-target)' }"
><span class="chip">{{ t }}</span></th>
</template>
</tr>
<tr v-if="hasColBands">
<template v-for="(grp, gi) in colGroups" :key="`coltags-${gi}`">
<th
v-for="(t, ti) in grp.tags"
:key="t"
class="mx-th-col"
:class="{ 'group-start': !isBucket(grp) && ti === 0 }"
:style="{ '--ax': tagHue(grp, 'target', hasColBands) }"
><span class="chip">{{ t }}</span></th>
</template>
</tr>
</thead>
<tbody>
<tr v-for="s in sourceSubs" :key="s">
<th class="mx-th-row">{{ s }}</th>
<td v-for="t in targetSubs" :key="`${s}-${t}`" class="mx-td">
<template v-for="(grp, gi) in rowGroups" :key="`rowgrp-${gi}`">
<!-- Header chip: a named multi-tag axis, OR the labelled
uncategorized bucket (when the matrix has any named axis). A
named solo axis folds into an eyebrow instead (below). -->
<tr
v-if="(!isBucket(grp) && grp.tags.length > 1) || (isBucket(grp) && hasRowGroups)"
class="mx-grp-row"
>
<th
class="mx-grp-head"
:class="{ 'mx-grp-head--bucket': isBucket(grp) }"
:colspan="orderedTargets.length + 1"
:style="{ '--ax': tagHue(grp, 'source', true) }"
:title="groupLabel(grp)"
><span class="chip">{{ groupLabel(grp) }}</span></th>
</tr>
<tr v-for="s in grp.tags" :key="s">
<th
class="mx-th-row"
:class="{
'group-start': !isBucket(grp) && s === grp.tags[0],
solo: !isBucket(grp) && grp.tags.length === 1,
}"
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
>
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
<span class="eye">{{ grp.axisName }}</span>
<span class="v">{{ s }}</span>
</span>
<span v-else class="chip">{{ s }}</span>
</th>
<!-- role/tabindex stay static even when stranded (mirrors the
SPA ConstraintMatrix): the cell is still a labelled
snapshot a screen-reader can land on; `onCellClick` early-
returns so it's inert. -->
<div
:class="cellClasses(s, t)"
:data-test="`mx-cell-${s}-${t}`"
:aria-label="cellAriaLabel(s, t)"
role="button"
tabindex="0"
@click="onCellClick(s, t)"
@keydown.enter.prevent="onCellClick(s, t)"
@keydown.space.prevent="onCellClick(s, t)"
>
<span class="glyph">{{ cellGlyph(s, t) }}</span>
<span v-if="cellShowsFactor(s, t)" class="factor">{{ cellFactorText(s, t) }}</span>
</div>
</td>
</tr>
<td v-for="t in orderedTargets" :key="`${s}-${t.tag}`" class="mx-td">
<div
:class="cellClasses(s, t.tag)"
:data-test="`mx-cell-${s}-${t.tag}`"
:aria-label="cellAriaLabel(s, t.tag)"
role="button"
tabindex="0"
@click="onCellClick(s, t.tag)"
@keydown.enter.prevent="onCellClick(s, t.tag)"
@keydown.space.prevent="onCellClick(s, t.tag)"
>
<span class="glyph">{{ cellGlyph(s, t.tag) }}</span>
<span v-if="cellShowsFactor(s, t.tag)" class="factor">{{ cellFactorText(s, t.tag) }}</span>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -475,48 +572,125 @@ onBeforeUnmount(() => {
}
.mx-axis .arrow { font: 14px var(--wp-font-mono); line-height: 1; }
/* ── Grid frame ────────────────────────────────────────────── */
/* ── Grid frame — scrolls on BOTH axes; headers + corner freeze ── */
.mx-grid {
display: inline-block;
max-width: 100%;
max-height: 52vh;
overflow: auto;
background: var(--wp-bg-deep, #0e1015);
border: 1px solid var(--wp-border);
border-radius: 6px;
padding: 6px;
/* No padding on the SCROLL container — see ConstraintMatrix: sticky pins
* headers at the padding edge while overflow still renders scrolling cells
* inside the padding, so padding leaves a scrolled-cell bleed strip on the
* top/left. Content sits flush; the border + radius give the frame. */
padding: 0;
vertical-align: top;
}
.mx-grid table { border-collapse: separate; border-spacing: 4px; }
/* `separate` + zero spacing (NOT `collapse`) — sticky header cells with
* border-collapse have a Chromium paint bug where scrolled cells bleed through
* the frozen row/column. The 4px inter-cell gap still comes from th/td padding,
* so the layout is unchanged. */
.mx-grid table { border-collapse: separate; border-spacing: 0; }
/* 2px cell padding = the 4px inter-cell gap. Sticky headers fill with the
* OPAQUE grid bg so scrolled cells can't bleed through them. */
.mx-grid th,
.mx-grid td { padding: 0; vertical-align: middle; }
.mx-grid td { padding: 2px; vertical-align: middle; }
.mx-grid thead th,
.mx-grid tbody th {
position: sticky;
background: var(--wp-bg-deep, #0e1015);
}
.mx-grid thead th { top: 0; z-index: 2; }
.mx-grid tbody th { left: 0; z-index: 1; }
/* Band row pins to the top; the tag row sticks just beneath it (offset = the
* measured band height). Corner outranks both so it stays in the angle. */
.mx-grid thead th.mx-th-band { top: 0; z-index: 3; }
.mx-grid thead th.mx-th-col { top: var(--mx-band-h, 0px); z-index: 2; }
.mx-grid thead th.mx-corner { top: 0; left: 0; z-index: 5; padding: 5px 8px; }
.mx-corner {
width: 96px;
height: 32px;
font: 9px var(--wp-font-mono);
color: var(--wp-text-dim, var(--wp-text3));
border-radius: 4px;
text-align: left;
padding: 0 8px !important;
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
}
.mx-corner .row-1 { display: block; color: var(--wp-constraint-source-text); line-height: 1.3; }
.mx-corner .row-2 { display: block; color: var(--wp-constraint-target-text); line-height: 1.3; }
.mx-th-col {
width: 64px;
height: 28px;
font: 700 10px var(--wp-font-sans);
color: var(--wp-constraint-target-text);
background: color-mix(in oklab, var(--wp-constraint-target) 10%, transparent);
border-radius: 4px;
text-align: center;
/* Column band — axis name spanning its tags, atop the tag row. */
.mx-th-band .chip {
display: flex; align-items: center; justify-content: center;
height: 22px; padding: 0 8px;
font: 700 9px var(--wp-font-sans);
letter-spacing: 0.06em; text-transform: uppercase;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
border-radius: 4px 4px 0 0;
color: color-mix(in srgb, var(--ax) 82%, var(--wp-text));
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-deep, #0e1015));
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
}
.mx-th-row {
width: 96px;
height: 36px;
.mx-th-band--bucket .chip { border-bottom-style: dashed; }
/* Column tag chip — per-tag header below the band (or the only header row
* when columns are ungrouped). */
.mx-th-col .chip {
display: flex; align-items: center; justify-content: center;
width: 64px; min-height: 28px; padding: 3px 2px;
font: 700 10px var(--wp-font-sans);
color: var(--wp-constraint-source-text);
background: color-mix(in oklab, var(--wp-constraint-source) 10%, transparent);
border-radius: 4px;
text-align: right;
padding: 0 12px !important;
border-radius: 0 0 4px 4px;
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text));
background: color-mix(in srgb, var(--ax) 9%, var(--wp-bg-deep, #0e1015));
}
.mx-th-col.group-start .chip { border-left: 2px solid color-mix(in srgb, var(--ax) 45%, transparent); }
/* Row group header chip — names the axis above its tags, pinned to the left
* edge so it stays visible during horizontal scroll. The <th> spans the row
* but stays transparent; only the chip is visible + sticky. */
.mx-grp-row .mx-grp-head {
position: relative; z-index: 1; background: transparent;
padding: 4px 0 1px; text-align: left;
}
.mx-grp-head .chip {
position: sticky; left: 0;
display: inline-flex; align-items: center;
max-width: 220px; height: 22px; padding: 0 11px;
font: 700 9px var(--wp-font-sans);
letter-spacing: 0.1em; text-transform: uppercase;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
border-radius: 0 4px 4px 0;
color: color-mix(in srgb, var(--ax) 80%, var(--wp-text));
background: color-mix(in srgb, var(--ax) 16%, var(--wp-bg-deep, #0e1015));
border-left: 3px solid var(--ax);
box-shadow: 0 1px 4px color-mix(in srgb, var(--wp-bg, #000) 55%, transparent);
}
.mx-grp-head--bucket .chip { border-left-style: dashed; }
/* Row tag chip — one per source sub-category, left accent in the axis hue. */
.mx-th-row .chip {
display: flex; align-items: center; justify-content: flex-start;
width: 96px; height: 36px; padding: 0 10px;
font: 700 10px var(--wp-font-sans);
text-align: left;
border-radius: 0 4px 4px 0;
color: color-mix(in srgb, var(--ax) 76%, var(--wp-text));
background: color-mix(in srgb, var(--ax) 10%, var(--wp-bg-deep, #0e1015));
border-left: 2px solid color-mix(in srgb, var(--ax) 48%, transparent);
}
.mx-th-row.solo .chip {
flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px;
border-left-width: 3px; border-left-color: var(--ax);
}
.mx-th-row.solo .eye {
max-width: 78px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font: 700 7px var(--wp-font-sans);
letter-spacing: 0.08em; text-transform: uppercase;
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text-dim));
}
.mx-th-row.solo .v { font: 600 11px var(--wp-font-sans); color: var(--wp-text); }
.mx-td { position: relative; }
/* ── Body cells ────────────────────────────────────────────── */
@@ -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,
@@ -160,7 +163,8 @@ function onSpaClick(): void {
:via-option-pairs="viaOptionPairs"
@update="onUpdate"
/>
<RuntimeSection :module="module" @update="onUpdate" />
<div class="wp-dvm__dock">
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-dvm__foot">
<a
@@ -198,6 +202,7 @@ function onSpaClick(): void {
<button type="button" class="wp-dvm__btn" data-test="dvm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-dvm__btn wp-dvm__btn--primary" data-test="dvm-save" @click="emit('save')">Save</button>
</footer>
</div>
</div>
</template>
@@ -211,6 +216,16 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
/* 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;
@@ -218,6 +233,13 @@ function onSpaClick(): void {
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-dvm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
flex-shrink: 0;
}
.wp-dvm__foot {
display: flex;
align-items: center;
@@ -262,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,7 +693,34 @@ function opUsesValue(op: string | undefined): boolean {
display: flex;
flex-direction: column;
gap: 6px;
/* 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 {
@@ -701,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 },
);
@@ -66,7 +69,8 @@ function onSpaClick(): void {
<IdentitySection :module="module" @update="onUpdate" />
<ValuesSection :module="module" @update="onUpdate" />
<RuntimeSection :module="module" @update="onUpdate" />
<div class="wp-fvm__dock">
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-fvm__foot">
<a
@@ -105,6 +109,7 @@ function onSpaClick(): void {
<button type="button" class="wp-fvm__btn" data-test="fvm-cancel" @click="emit('cancel')">Cancel</button>
<button type="button" class="wp-fvm__btn wp-fvm__btn--primary" data-test="fvm-save" @click="emit('save')">Save</button>
</footer>
</div>
</div>
</template>
@@ -118,13 +123,19 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-fvm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-fvm__foot {
display: flex;
align-items: center;
@@ -169,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>
@@ -183,9 +183,14 @@ const addedCount = computed(() => draftRows.value.filter((r) => r.libraryId ===
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: 3px;
overflow: hidden;
/* Long value lists scroll in place; the header row stays pinned. */
max-height: 42vh;
overflow-y: auto;
}
.vals__head {
position: sticky;
top: 0;
z-index: 1;
display: grid;
grid-template-columns: 22px 180px 1fr 28px 28px;
gap: 10px;
@@ -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,10 +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" />
<RuntimeSection :module="module" @update="onUpdate" />
<div class="wp-wcm__dock">
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-wcm__foot">
<a
@@ -150,6 +155,7 @@ function onSpaClick(): void {
Save
</button>
</footer>
</div>
</div>
</template>
@@ -163,7 +169,7 @@ function onSpaClick(): void {
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
@@ -171,6 +177,12 @@ function onSpaClick(): void {
/* Head styling lives in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). Adopted from InjectorBindingModal
* so every per-instance edit modal reads as one design family. */
.wp-wcm__dock {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--wp-bg2);
}
.wp-wcm__foot {
display: flex;
align-items: center;
@@ -215,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>
@@ -12,6 +12,7 @@ import { cacheVersion, ensure, lookup } from "../../../../../extension/preview-r
import type { PairingBadge } from "../../../../../extension/constraint-pairs";
import { matches, parse, readsAs } from "@/manager/parsing/subcatFilter";
import PairBadge from "../../../PairBadge.vue";
import { axisHueAt } from "../../../../shared/axis-color";
interface OptionFull extends WildcardOption {
value: string;
@@ -208,20 +209,11 @@ const filteredByCategory = computed(() => {
});
/**
* Per-axis chip colour. Each `tag_groups` axis gets a stable hue from a
* small palette of graph-theme tokens so a tag reads with the same colour
* here as in the pool pills. Ungrouped tags fall back to a neutral hue.
* Returned as inline custom properties the scoped CSS consumes — keeps the
* palette out of per-tag class explosions.
* Per-axis chip colour via the shared `axisHueAt` palette so a tag reads
* with the same colour here as in the pool pills. Ungrouped tags fall back
* to a neutral hue. Returned as an inline custom property the scoped CSS
* consumes — keeps the palette out of per-tag class explosions.
*/
const AXIS_HUES = [
"var(--wp-kind-wildcard, #a78bfa)",
"var(--wp-teal, #33d6c6)",
"var(--wp-status-modified, #fb923c)",
"var(--wp-accent2, #a970ff)",
"var(--wp-success, #22c55e)",
];
function axisOf(tag: string): number {
// Index of the axis whose member list contains `tag`; -1 when ungrouped.
const axes = Object.keys(props.tagGroups);
@@ -232,9 +224,7 @@ function axisOf(tag: string): number {
}
function chipStyle(tag: string): Record<string, string> {
const idx = axisOf(tag);
if (idx < 0) return { "--chip-hue": "var(--wp-text-dim, var(--wp-text3))" };
return { "--chip-hue": AXIS_HUES[idx % AXIS_HUES.length] };
return { "--chip-hue": axisHueAt(axisOf(tag)) };
}
const probability = computed(() =>
probabilityFor(props.option, props.allOptions, props.instance, props.multiActive === true),
@@ -13,6 +13,7 @@ import {
} from "@/manager/parsing/subcatFilter";
import OptionRow from "./OptionRow.vue";
import WpCheck from "../../../../shared/WpCheck.vue";
import { axisHueAt, UNGROUPED_HUE } from "../../../../shared/axis-color";
interface OptionFull extends WildcardOption {
value: string;
@@ -137,19 +138,11 @@ function onPillClick(tag: string): void {
emit("update", patchInstance(props.module, "category_filter", expr));
}
/** Per-axis pill colour — mirrors OptionRow's CATEGORY chip hues so a tag
* reads with the same colour in both surfaces. */
const AXIS_HUES = [
"var(--wp-kind-wildcard, #a78bfa)",
"var(--wp-teal, #33d6c6)",
"var(--wp-status-modified, #fb923c)",
"var(--wp-accent2, #a970ff)",
"var(--wp-success, #22c55e)",
];
/** Per-axis pill colour via the shared `axisHueAt` palette — mirrors
* OptionRow's chip hues so a tag reads the same in both surfaces. */
function axisHue(axis: string): string {
if (axis === OTHER) return "var(--wp-text-dim, var(--wp-text3))";
const idx = pillGroups.value.findIndex((g) => g.axis === axis);
return AXIS_HUES[(idx < 0 ? 0 : idx) % AXIS_HUES.length];
if (axis === OTHER) return UNGROUPED_HUE;
return axisHueAt(pillGroups.value.findIndex((g) => g.axis === axis));
}
function categoryOptionCount(cat: string): number {
@@ -903,9 +896,15 @@ const skewedTowards = computed(() => {
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: 3px;
overflow: hidden;
/* Long option lists scroll in place so the axes / pick / footer stay
* visible; the column-header row below stays pinned via sticky. */
max-height: 40vh;
overflow-y: auto;
}
.pool__opt-head {
position: sticky;
top: 0;
z-index: 1;
display: grid;
/* Match OptionRow column widths so cell alignment stays in lockstep. */
grid-template-columns: 22px 1fr 110px 64px 96px;
@@ -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");
+1
View File
@@ -182,6 +182,7 @@ interface WarningEntry {
const WARNING_LABELS: Record<string, string> = {
constraint_never_applied: "Constraint never applied",
constraint_partial_reach: "Constraint partial reach",
constraint_source_missing: "Constraint source missing",
constraint_register_failed: "Constraint failed to register",
constraint_factor_ignored_on_allow: "Constraint factor ignored (allow)",
unknown_constraint_mode: "Unknown constraint mode",
@@ -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,46 @@ 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();
});
});
+45 -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,23 @@ 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;
}>(),
{ nodeMode: 0 },
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null },
);
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
@@ -55,6 +71,13 @@ 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);
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 +148,14 @@ 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>
<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 +203,11 @@ 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"
: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 +299,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;
}
+193
View File
@@ -0,0 +1,193 @@
<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;
}>();
const emit = defineEmits<{ "update:seedLocks": [next: Record<string, 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);
});
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 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" @update="onRow" />
<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>
+121
View File
@@ -0,0 +1,121 @@
<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;
}>();
const emit = defineEmits<{ update: [payload: { index: number; seed: number | null }] }>();
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 }">
<span class="srow__idx" data-test="seedrow-idx">#{{ index + 1 }}</span>
<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; }
.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,125 @@
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");
});
});
@@ -0,0 +1,51 @@
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
});
});
@@ -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 });
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { axisHueAt, UNGROUPED_HUE } from "./axis-color";
describe("axisHueAt", () => {
it("returns the neutral hue for ungrouped tags", () => {
expect(axisHueAt(-1)).toBe(UNGROUPED_HUE);
});
it("gives every group a distinct colour — no two axes share a hue", () => {
const hues = Array.from({ length: 24 }, (_, i) => axisHueAt(i));
expect(new Set(hues).size).toBe(24);
});
it("has no duplicate in the curated palette (the old two-purples bug)", () => {
const curated = Array.from({ length: 8 }, (_, i) => axisHueAt(i));
expect(new Set(curated).size).toBe(curated.length);
// The legacy violet that collided with purple must be gone.
expect(curated).not.toContain("var(--wp-accent2, #a970ff)");
});
it("generates an hsl hue past the curated palette", () => {
expect(axisHueAt(8)).toMatch(/^hsl\(/);
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Per-axis sub-category colours — one source of truth for the wildcard
* editor pills, the option-row tag chips, and the pool palette, so a tag
* reads with the same colour on every surface.
*
* Each sub-category GROUP (axis) gets a distinct hue by its position among
* its siblings. The old per-component copies shared TWO failings that both
* produced "two groups, same colour": the palette held two near-identical
* purples (`#a78bfa` and `#a970ff`), and assignment wrapped with
* `% length`, repeating once there were more groups than hues. This palette
* is de-duplicated and ordered for maximum separation between consecutive
* axes; past it, a golden-angle hue keeps every additional group distinct.
*/
/** Neutral hue for ungrouped / "other" tags. */
export const UNGROUPED_HUE = "var(--wp-text-dim, var(--wp-text3))";
/** Curated distinct hues (no two similar), ordered so consecutive axes land
* far apart on the wheel. Theme tokens where they exist, hex otherwise. */
const AXIS_HUES = [
"var(--wp-kind-wildcard, #a78bfa)", // purple
"var(--wp-teal, #33d6c6)", // teal
"var(--wp-status-modified, #fb923c)", // orange
"#60a5fa", // blue
"var(--wp-success, #22c55e)", // green
"#fb7185", // rose
"#fbbf24", // amber
"#e879f9", // magenta
];
/**
* Distinct, non-repeating hue for the sub-category group (axis) at `index`
* among its siblings. `index < 0` (ungrouped) → the neutral token. Past the
* curated palette, a golden-angle hue keeps additional groups distinct
* rather than wrapping back onto an earlier colour.
*/
export function axisHueAt(index: number): string {
if (index < 0) return UNGROUPED_HUE;
if (index < AXIS_HUES.length) return AXIS_HUES[index];
const hue = ((index * 137.508) % 360).toFixed(1);
return `hsl(${hue} 68% 66%)`;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Constraint-matrix axis grouping — the pure logic shared by both matrix
* surfaces (the SPA `ConstraintMatrix.vue` library editor and the canvas
* `MatrixSection.vue` instance modal). Keeping it here means the two grids
* order, group, label and colour their axes identically; only their template
* + CSS differ.
*
* A wildcard's `tag_groups` (axis name → member sub-categories) drives the
* layout: tags are ordered by axis, each axis becomes a coloured band
* (columns) or pinned header chip (rows), and the leftover run of tags with
* no axis becomes the labelled "uncategorized" bucket instead of a blank.
*/
import { axisHueAt } from "./axis-color";
/** One tag stamped with its axis + whether it opens a new group. Cell lookups
* stay keyed by tag name, so reordering for display is safe. */
export interface TagMeta {
tag: string;
axisIndex: number; // -1 = ungrouped
axisName: string;
groupStart: boolean; // first tag of its axis
}
/** A contiguous run of tags belonging to one axis (or the trailing ungrouped
* run). Columns render one band per group; row groups with >1 tag get a
* header chip, solo named groups fold the name into the tag. */
export interface AxisGroup {
axisIndex: number; // -1 = ungrouped
axisName: string;
tags: string[];
grouped: boolean;
}
/** Order `tags` by axis (grouped first, in axis order; ungrouped last) and
* stamp each with its axis index + whether it opens a new group. */
export function orderByGroups(tags: string[], groups: Record<string, string[]>): TagMeta[] {
const out: TagMeta[] = [];
const seen = new Set<string>();
Object.keys(groups).forEach((axis, ai) => {
let first = true;
for (const t of groups[axis] ?? []) {
if (seen.has(t) || !tags.includes(t)) continue;
seen.add(t);
out.push({ tag: t, axisIndex: ai, axisName: axis, groupStart: first });
first = false;
}
});
let firstUngrouped = true;
for (const t of tags) {
if (seen.has(t)) continue;
out.push({ tag: t, axisIndex: -1, axisName: "", groupStart: firstUngrouped });
firstUngrouped = false;
}
return out;
}
/** Collapse an ordered `TagMeta[]` into contiguous axis groups. */
export function toGroups(ordered: TagMeta[]): AxisGroup[] {
const groups: AxisGroup[] = [];
for (const m of ordered) {
if (m.groupStart || groups.length === 0) {
groups.push({ axisIndex: m.axisIndex, axisName: m.axisName, tags: [], grouped: m.axisIndex >= 0 });
}
groups[groups.length - 1].tags.push(m.tag);
}
return groups;
}
/** A "bucket" is the catch-all uncategorized run: no axis, or a blank axis
* name. Buckets are labelled "uncategorized" + tinted neutral rather than
* rendered blank. */
export function isBucket(grp: AxisGroup): boolean {
return !grp.grouped || grp.axisName.trim().length === 0;
}
/** Display label for a group's band / chip. */
export function groupLabel(grp: AxisGroup): string {
return isBucket(grp) ? "uncategorized" : grp.axisName;
}
/** Neutral grey for the uncategorized bucket — distinct from the per-axis
* palette so it doesn't read as a real named axis. */
export const UNGROUPED_HUE = "var(--wp-text-muted, #8a8a9a)";
/** Hue for a group's chips / band / tags. Named axes take the shared palette;
* the uncategorized bucket goes neutral grey when it's labelled (mixed
* layout), else keeps the legacy source/target tint (fully-flat matrix). */
export function groupHue(grp: AxisGroup, role: "source" | "target", labeled: boolean): string {
if (!isBucket(grp)) return axisHueAt(grp.axisIndex);
return labeled ? UNGROUPED_HUE : `var(--wp-constraint-${role})`;
}
+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
+26
View File
@@ -1294,6 +1294,32 @@ describe("scanConflicts — wildcard_broken_nested_ref", () => {
});
});
it("does NOT flag when the ref resolves to a LIBRARY wildcard not in the chain", () => {
// `deadbeef` isn't in the chain, but it's a live-library wildcard — the
// Context node fills it from the DB at run (`_expand_catalog_via_live_db`),
// so the ref resolves and must NOT read as broken.
const value: ContextWidgetValue = {
version: 1,
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
};
const out = scanConflicts(value, [], [], [], [], null, new Set(["deadbeef"]));
expect(out.find((c) => c.type === "wildcard_broken_nested_ref")).toBeUndefined();
});
it("STILL flags a ref absent from BOTH the chain and the library", () => {
const value: ContextWidgetValue = {
version: 1,
modules: [wildcardOpt("w1", "phrase", "see @{deadbeef}")],
};
const out = scanConflicts(value, [], [], [], [], null, new Set(["other999"]));
expect(out).toContainEqual({
moduleId: "w1",
variable: "deadbeef",
type: "wildcard_broken_nested_ref",
severity: "warning",
});
});
it("keys the conflict to the module's _uid when present", () => {
const value: ContextWidgetValue = {
version: 1,
+13 -1
View File
@@ -451,6 +451,13 @@ export function scanConflicts(
// handed and reads `isOrphan` off that. Same reach math, narrower
// visibility.
constraintPairOrphanByUid: Map<string, boolean> | null = null,
// Uuids of every wildcard in the live library. A nested `@{uuid}` ref to a
// library wildcard that ISN'T in this chain still resolves at run time — the
// Context node expands its catalog from the live DB (`_expand_catalog_via_live_db`),
// so the ref is NOT broken. Only flag refs absent from BOTH chain + library.
// Constraint TARGET reachability deliberately stays chain-only (a constraint
// needs a downstream instance to re-weight — library presence isn't enough).
knownLibraryUuids: ReadonlySet<string> = new Set(),
): Conflict[] {
const upstream = new Set(upstreamVars);
const upstreamUuids = new Set(upstreamWildcardUuids);
@@ -618,6 +625,11 @@ export function scanConflicts(
const inDownstreamNested = downstreamNestedReach.has(uuid);
return inLocalDirect || inUpstream || inDownstream || inLocalNestedAfter || inDownstreamNested;
};
// Nested `@{uuid}` refs additionally resolve from the live library catalog
// (the engine pulls them from the DB at run time even when they're not in
// the chain), so a ref to any known library wildcard is NOT a broken ref.
const isNestedRefResolvable = (uuid: string, i: number): boolean =>
isUuidResolvable(uuid, i) || knownLibraryUuids.has(uuid);
for (let i = 0; i < value.modules.length; i++) {
const m = value.modules[i];
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
@@ -828,7 +840,7 @@ export function scanConflicts(
const uuid = match[1];
if (seenRef.has(uuid)) continue;
seenRef.add(uuid);
if (!isUuidResolvable(uuid, i)) {
if (!isNestedRefResolvable(uuid, i)) {
out.push({ moduleId: m._uid ?? m.id, variable: uuid, type, severity: "warning" });
}
}
+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
@@ -38,6 +38,7 @@ function makeRouter() {
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/bundles", name: "bundles", component: { template: "<div/>" } },
{ path: "/bundles/:id/edit", name: "bundles-edit", component: { template: "<div/>" } },
],
});
}
@@ -582,4 +583,22 @@ describe("BundleEditor.vue", () => {
expect(payload.children![0].id).toBe("wc_a");
expect(payload.children![1].id).toBe("wc_a");
});
it("create-mode saves a new bundle directly (no Context wrap needed)", async () => {
apiBundles.create.mockResolvedValue({
id: "bn_new", name: "My Kit", description: "", color: null,
category_id: null, tags: [], is_favorite: false, children: [],
payload_hash: "h", version: 1, created_at: "", updated_at: "",
});
const wrap = mountEditor(); // no id → create mode
await flushPromises();
await wrap.find('[data-test="identity-name"]').setValue("My Kit");
await flushPromises();
const saveBtn = wrap.findAll("button").find((b) => b.text().includes("Save"));
await saveBtn!.trigger("click");
await flushPromises();
expect(apiBundles.create).toHaveBeenCalledTimes(1);
expect(apiBundles.create.mock.calls[0][0].name).toBe("My Kit");
expect(apiBundles.update).not.toHaveBeenCalled();
});
});
@@ -204,4 +204,89 @@ describe("ConstraintMatrix.vue", () => {
expect(ths.every((th) => th.text() !== "" && th.text().toLowerCase() !== "null")).toBe(true);
wrap.unmount();
});
it("orders cols into axis bands and rows into grouped sections, colored by axis", () => {
const wrap = mount(ConstraintMatrix, {
props: {
rows: ["tempting", "man", "woman", "clothed"], // deliberately unordered
cols: ["vivid", "warm", "cool"],
modelValue: {},
sourceGroups: { gender: ["man", "woman"], rating: ["clothed", "tempting"] },
targetGroups: { temperature: ["warm", "cool"], saturation: ["vivid"] },
},
attachTo: document.body,
});
// Column TAG cells re-ordered by axis order: temperature(warm, cool) then
// saturation(vivid). The axis NAME no longer lives in the tag cell.
const cols = wrap.findAll(".wp-mx-th-col");
expect(cols.map((c) => c.text())).toEqual(["warm", "cool", "vivid"]);
// Axis names live in a band row, each spanning its group's tags.
const bands = wrap.findAll(".wp-mx-th-band");
expect(bands.map((b) => b.text()).filter((t) => t.length > 0))
.toEqual(["temperature", "saturation"]);
const temperature = bands.find((b) => b.text() === "temperature");
expect(temperature?.attributes("colspan")).toBe("2"); // spans warm + cool
expect(temperature?.attributes("style") ?? "").toContain("--ax"); // carries the hue
// Rows re-ordered: gender(man, woman) then rating(clothed, tempting).
const rows = wrap.findAll(".wp-mx-th-row");
expect(rows.map((r) => r.text())).toEqual(["man", "woman", "clothed", "tempting"]);
// Each multi-tag row group gets a pinned header chip naming the axis.
const heads = wrap.findAll(".wp-mx-grp-head");
expect(heads.map((h) => h.text())).toEqual(["gender", "rating"]);
expect(heads[0].attributes("style") ?? "").toContain("--ax");
wrap.unmount();
});
it("a solo grouped row axis folds its name into the tag (no header chip row)", () => {
const wrap = mount(ConstraintMatrix, {
props: {
rows: ["lean", "man", "woman"],
cols: ["warm"],
modelValue: {},
sourceGroups: { gender: ["man", "woman"], build: ["lean"] },
targetGroups: {},
},
attachTo: document.body,
});
// gender (2 tags) gets a header chip; build (1 tag) does NOT.
const heads = wrap.findAll(".wp-mx-grp-head");
expect(heads.map((h) => h.text())).toEqual(["gender"]);
// The solo "build" axis row carries the axis name as an eyebrow over "lean".
const solo = wrap.find(".wp-mx-th-row.solo");
expect(solo.exists()).toBe(true);
expect(solo.text()).toContain("build");
expect(solo.text()).toContain("lean");
wrap.unmount();
});
it("labels the leftover ungrouped run 'uncategorized' instead of leaving it blank", () => {
const wrap = mount(ConstraintMatrix, {
props: {
rows: ["romantic", "elegant", "bold"], // mood axis + ungrouped "bold"
cols: ["vivid", "muted", "yellow"], // saturation axis + ungrouped "yellow"
modelValue: {},
sourceGroups: { mood: ["romantic", "elegant"] },
targetGroups: { saturation: ["vivid", "muted"] },
},
attachTo: document.body,
});
// Row groups: the real axis then a labelled "uncategorized" bucket (not blank).
const heads = wrap.findAll(".wp-mx-grp-head").map((h) => h.text());
expect(heads).toEqual(["mood", "uncategorized"]);
// Column bands: the real axis then a labelled "uncategorized" band.
const bands = wrap.findAll(".wp-mx-th-band").map((b) => b.text()).filter((t) => t.length > 0);
expect(bands).toEqual(["saturation", "uncategorized"]);
// The bucket carries a non-axis (neutral) hue, not a palette colour.
const bucketBand = wrap.findAll(".wp-mx-th-band").find((b) => b.text() === "uncategorized");
expect(bucketBand?.classes()).toContain("wp-mx-th-band--bucket");
wrap.unmount();
});
it("stays flat with no 'uncategorized' label when nothing is grouped", () => {
const wrap = mountGrid({}, ["red", "blue"], ["warm", "cool"]);
expect(wrap.findAll(".wp-mx-grp-head").length).toBe(0);
expect(wrap.findAll(".wp-mx-th-band").length).toBe(0);
expect(wrap.text().toLowerCase()).not.toContain("uncategorized");
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();
});
});
+42
View File
@@ -66,4 +66,46 @@ describe("Select.vue", () => {
expect(wrap.get("[data-test='select-trigger']").attributes("aria-invalid")).toBe("true");
wrap.unmount();
});
it("type-to-filter narrows the options", async () => {
const wrap = mount(Select, {
props: { modelValue: null, options: opts },
attachTo: document.body,
});
const trigger = wrap.get("[data-test='select-trigger']");
await trigger.trigger("click");
expect(document.querySelectorAll(".wp-select__option").length).toBe(3);
await trigger.trigger("keydown", { key: "a" });
await trigger.trigger("keydown", { key: "l" }); // "al" → only "Alpha"
expect(document.querySelectorAll(".wp-select__option").length).toBe(1);
expect(document.querySelector(".wp-select__option")?.textContent).toContain("Alpha");
expect(document.querySelector("[data-test='select-filter']")?.textContent).toContain("al");
wrap.unmount();
});
it("Enter selects the single filtered match", async () => {
const wrap = mount(Select, {
props: { modelValue: null, options: opts },
attachTo: document.body,
});
const trigger = wrap.get("[data-test='select-trigger']");
await trigger.trigger("click");
await trigger.trigger("keydown", { key: "g" }); // → only "Gamma"
await trigger.trigger("keydown", { key: "Enter" });
expect(wrap.emitted("update:modelValue")![0]).toEqual(["c"]);
wrap.unmount();
});
it("shows No matches when the query matches nothing", async () => {
const wrap = mount(Select, {
props: { modelValue: null, options: opts },
attachTo: document.body,
});
const trigger = wrap.get("[data-test='select-trigger']");
await trigger.trigger("click");
await trigger.trigger("keydown", { key: "z" });
expect(document.querySelectorAll(".wp-select__option").length).toBe(0);
expect(document.querySelector("[data-test='select-empty']")).not.toBeNull();
wrap.unmount();
});
});
@@ -44,18 +44,30 @@ describe("BundleReattachSection", () => {
const w = mountSection();
// Leaf (wildcard) child → module candidates.
await w.find("[data-test='bundle-reattach-btn-aaa']").trigger("click");
const leafCands = w.findAll("[data-test='bundle-reattach-candidate']").map((c) => c.text());
const leafCands = w.findAll("[data-test='bundle-reattach-cand-name']").map((c) => c.text());
expect(leafCands).toContain("Live module");
expect(leafCands).toContain("Other module");
expect(leafCands).not.toContain("Live bundle");
// Bundle child → bundle candidates.
await w.find("[data-test='bundle-reattach-btn-bbb']").trigger("click");
const bundleCands = w.findAll("[data-test='bundle-reattach-candidate']").map((c) => c.text());
const bundleCands = w.findAll("[data-test='bundle-reattach-cand-name']").map((c) => c.text());
expect(bundleCands).toContain("Live bundle");
expect(bundleCands).not.toContain("Live module");
});
it("shows each candidate's id so same-named modules are distinguishable", async () => {
const w = mountSection({
moduleCandidates: [
{ id: "mod1", name: "color" },
{ id: "mod2", name: "color" },
],
});
await w.find("[data-test='bundle-reattach-btn-aaa']").trigger("click");
expect(w.find("[data-test-id='bundle-reattach-candidate-mod1']").text()).toContain("mod1");
expect(w.find("[data-test-id='bundle-reattach-candidate-mod2']").text()).toContain("mod2");
});
it("picking + confirming emits reattach with childId + newId + newName", async () => {
const w = mountSection();
await w.find("[data-test='bundle-reattach-btn-aaa']").trigger("click");
@@ -143,7 +143,10 @@ function confirm(childId: string): void {
:data-test-id="`bundle-reattach-candidate-${c.id}`"
:class="{ 'rb__cand--picked': c.id === picked?.id }"
@click="pick(c)"
>{{ c.name }}</li>
>
<span class="rb__cand-name" data-test="bundle-reattach-cand-name">{{ c.name }}</span>
<code class="rb__cand-id">{{ c.id }}</code>
</li>
<li v-if="candidatesFor(child).length === 0" class="rb__empty">
No local {{ child.type === "bundle" ? "bundles" : "modules" }} to reattach.
</li>
@@ -226,7 +229,9 @@ function confirm(childId: string): void {
outline: none;
}
.rb__list { list-style: none; margin: 6px 0; padding: 0; max-height: 150px; overflow-y: auto; }
.rb__cand { padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
.rb__cand { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
.rb__cand-name { font: 11px var(--wp-font-sans); }
.rb__cand-id { flex-shrink: 0; font: 10px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #6e6e7c); }
.rb__cand:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
.rb__cand--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
.rb__empty { padding: 5px 8px; font: 11px var(--wp-font-sans); color: var(--wp-text-dim, #6e6e7c); }
+290 -64
View File
@@ -15,7 +15,7 @@
* Single popover lives in a body portal so it never picks up the cell's
* hover / clipping context — same fix the canvas grid applies.
*/
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import type {
ConstraintCell,
ConstraintMatrix,
@@ -23,6 +23,13 @@ import type {
} from "../api/types";
import CellRulePopover from "../../components/context/editors/constraint/CellRulePopover.vue";
import MatrixLegend from "../../components/context/editors/constraint/MatrixLegend.vue";
import {
groupHue as tagHue,
groupLabel,
isBucket,
orderByGroups,
toGroups,
} from "../../components/shared/matrix-axis";
type RuleState = "neutral" | "exclude" | "boost" | "reduce";
@@ -32,6 +39,13 @@ interface Props {
modelValue: ConstraintMatrix;
sourceName?: string; // wildcard name shown in the source axis tag
targetName?: string; // wildcard name shown in the target axis tag
/** Sub-category axes (tag_groups) of the source/target wildcards:
* axis name → member tags. When present, the matrix orders its rows /
* cols by axis and colors each group via `axisHueAt` (mirrors the
* wildcard editor's per-axis chip hues). Empty → flat, source/target-
* tinted headers as before. */
sourceGroups?: Record<string, string[]>;
targetGroups?: Record<string, string[]>;
// Read-only recovery view: the source/target wildcard was deleted, so the
// configured rules are shown for understanding only. Cells don't open the
// rule popover; reattach a live wildcard to edit.
@@ -40,6 +54,8 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
sourceName: "",
targetName: "",
sourceGroups: () => ({}),
targetGroups: () => ({}),
readonly: false,
});
const emit = defineEmits<{ "update:modelValue": [value: ConstraintMatrix] }>();
@@ -116,6 +132,30 @@ function fmtFactor(f: number): string {
return f.toFixed(2).replace(/\.?0+$/, "");
}
// ── Axis grouping (target = columns, source = rows). Ordering, grouping,
// bucket detection + hues come from the shared matrix-axis module so this grid
// and the canvas MatrixSection stay identical. ──────────────────
const orderedRows = computed(() => orderByGroups(props.rows, props.sourceGroups));
const orderedCols = computed(() => orderByGroups(props.cols, props.targetGroups));
const rowGroups = computed(() => toGroups(orderedRows.value));
const colGroups = computed(() => toGroups(orderedCols.value));
/** True when at least one column / row axis is a NAMED group — then the header
* grows a band/chip and the leftover bucket is worth labelling. A fully-flat
* matrix keeps the legacy source/target tint and grows no band/chip. */
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
// The column band row's height is measured at runtime so the tag row beneath
// it can stick at the right `top` offset, independent of font sizing.
const gridEl = ref<HTMLElement | null>(null);
function measureBands(): void {
const grid = gridEl.value;
if (!grid) return;
const band = grid.querySelector<HTMLElement>(".wp-mx-th-band");
grid.style.setProperty("--wp-mx-band-h", `${band?.offsetHeight ?? 0}px`);
}
watch(colGroups, () => void nextTick(measureBands));
function cellAriaLabel(row: string, col: string): string {
const c = cellAt(row, col);
const factor = (c.mode === "boost" || c.mode === "reduce") ? ` ×${fmtFactor(c.factor)}` : "";
@@ -228,12 +268,15 @@ onMounted(() => {
window.addEventListener("keydown", onDocumentKeydown);
window.addEventListener("scroll", reanchorPopover, true);
window.addEventListener("resize", reanchorPopover);
window.addEventListener("resize", measureBands);
void nextTick(measureBands);
});
onBeforeUnmount(() => {
window.removeEventListener("mousedown", onDocumentMousedown);
window.removeEventListener("keydown", onDocumentKeydown);
window.removeEventListener("scroll", reanchorPopover, true);
window.removeEventListener("resize", reanchorPopover);
window.removeEventListener("resize", measureBands);
});
defineExpose({ cellAt });
@@ -256,50 +299,111 @@ defineExpose({ cellAt });
</span>
</div>
<div class="wp-mx-grid">
<div ref="gridEl" class="wp-mx-grid">
<table>
<thead>
<tr>
<th class="wp-mx-corner" data-test="mx-corner">
<span class="row-1">↓ source</span>
<span class="row-2">→ target</span>
<th class="wp-mx-corner" :rowspan="hasColBands ? 2 : 1" data-test="mx-corner">
<span class="chip">
<span class="row-1">↓ source</span>
<span class="row-2">→ target</span>
</span>
</th>
<th
v-for="col in cols"
:key="col"
class="wp-mx-th-col"
>{{ col }}</th>
<!-- Grouped cols: a band row names each axis, spanning its tags. -->
<template v-if="hasColBands">
<th
v-for="(grp, gi) in colGroups"
:key="`band-${gi}`"
class="wp-mx-th-band"
:class="{ 'wp-mx-th-band--bucket': isBucket(grp) }"
:colspan="grp.tags.length"
:style="{ '--ax': tagHue(grp, 'target', true) }"
:title="groupLabel(grp)"
>
<span class="chip">{{ groupLabel(grp) }}</span>
</th>
</template>
<!-- Flat cols: tag headers sit directly in the single header row. -->
<template v-else>
<th
v-for="c in orderedCols"
:key="c.tag"
class="wp-mx-th-col"
:style="{ '--ax': 'var(--wp-constraint-target)' }"
><span class="chip">{{ c.tag }}</span></th>
</template>
</tr>
<tr v-if="hasColBands">
<template v-for="(grp, gi) in colGroups" :key="`coltags-${gi}`">
<th
v-for="(tag, ti) in grp.tags"
:key="tag"
class="wp-mx-th-col"
:class="{ 'group-start': !isBucket(grp) && ti === 0 }"
:style="{ '--ax': tagHue(grp, 'target', hasColBands) }"
><span class="chip">{{ tag }}</span></th>
</template>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row">
<th class="wp-mx-th-row">{{ row }}</th>
<td v-for="col in cols" :key="col" class="wp-mx-td">
<div
class="wp-mx-cell"
:class="[
`s-${toState(cellAt(row, col).mode)}`,
{ open: isOpenAt(row, col) },
]"
:data-mode="cellAt(row, col).mode"
:data-test="`cell-${row}-${col}`"
:aria-label="cellAriaLabel(row, col)"
role="button"
tabindex="0"
@click="openPopover(row, col, $event)"
@keydown.enter.prevent="openPopover(row, col, $event as unknown as MouseEvent)"
@keydown.space.prevent="openPopover(row, col, $event as unknown as MouseEvent)"
<template v-for="(grp, gi) in rowGroups" :key="`rowgrp-${gi}`">
<!-- Header chip: a named multi-tag axis, OR the labelled
uncategorized bucket (when the matrix has any named axis). A
named solo axis folds into an eyebrow instead (below). -->
<tr
v-if="(!isBucket(grp) && grp.tags.length > 1) || (isBucket(grp) && hasRowGroups)"
class="wp-mx-grp-row"
>
<th
class="wp-mx-grp-head"
:class="{ 'wp-mx-grp-head--bucket': isBucket(grp) }"
:colspan="orderedCols.length + 1"
:style="{ '--ax': tagHue(grp, 'source', true) }"
:title="groupLabel(grp)"
><span class="chip">{{ groupLabel(grp) }}</span></th>
</tr>
<tr v-for="tag in grp.tags" :key="tag">
<th
class="wp-mx-th-row"
:class="{
'group-start': !isBucket(grp) && tag === grp.tags[0],
solo: !isBucket(grp) && grp.tags.length === 1,
}"
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
>
<span class="glyph">{{ MODE_ICON[cellAt(row, col).mode] }}</span>
<span
v-if="cellAt(row, col).mode === 'boost' || cellAt(row, col).mode === 'reduce'"
class="factor"
>×{{ fmtFactor(cellAt(row, col).factor) }}</span>
</div>
</td>
</tr>
<tr v-if="rows.length === 0">
<td :colspan="cols.length + 1" class="wp-mx-empty">
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
<span class="eye">{{ grp.axisName }}</span>
<span class="v">{{ tag }}</span>
</span>
<span v-else class="chip">{{ tag }}</span>
</th>
<td v-for="c in orderedCols" :key="c.tag" class="wp-mx-td">
<div
class="wp-mx-cell"
:class="[
`s-${toState(cellAt(tag, c.tag).mode)}`,
{ open: isOpenAt(tag, c.tag) },
]"
:data-mode="cellAt(tag, c.tag).mode"
:data-test="`cell-${tag}-${c.tag}`"
:aria-label="cellAriaLabel(tag, c.tag)"
role="button"
tabindex="0"
@click="openPopover(tag, c.tag, $event)"
@keydown.enter.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
@keydown.space.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
>
<span class="glyph">{{ MODE_ICON[cellAt(tag, c.tag).mode] }}</span>
<span
v-if="cellAt(tag, c.tag).mode === 'boost' || cellAt(tag, c.tag).mode === 'reduce'"
class="factor"
>×{{ fmtFactor(cellAt(tag, c.tag).factor) }}</span>
</div>
</td>
</tr>
</template>
<tr v-if="orderedRows.length === 0">
<td :colspan="orderedCols.length + 1" class="wp-mx-empty">
No source values yet — add options to the source wildcard first.
</td>
</tr>
@@ -386,52 +490,174 @@ defineExpose({ cellAt });
}
.wp-mx-axis .arrow { font: 14px var(--wp-font-mono, monospace); line-height: 1; }
/* ── Grid frame ────────────────────────────────────────────── */
/* ── Grid frame — scrolls on BOTH axes; headers + corner freeze ── */
.wp-mx-grid {
display: inline-block;
background: var(--wp-bg-deep, var(--wp-bg-1, #0e1015));
border: 1px solid var(--wp-border);
border-radius: 6px;
padding: 6px;
overflow-x: auto;
/* No padding on the SCROLL container: position:sticky pins headers at the
* padding edge while overflow still renders scrolling cells INSIDE the
* padding, so any padding leaves a bleed strip of scrolled cells on the
* top/left scroll-start sides. Content sits flush; the border + radius give
* the frame. */
padding: 0;
overflow: auto;
max-width: 100%;
max-height: 60vh;
}
.wp-mx-grid table { border-collapse: separate; border-spacing: 4px; }
/* `separate` + zero spacing (NOT `collapse`) — sticky header cells with
* border-collapse have a Chromium paint bug where scrolled cells bleed through
* the frozen row/column. The 4px inter-cell gap still comes from th/td padding,
* so the layout is unchanged. */
.wp-mx-grid table { border-collapse: separate; border-spacing: 0; }
/* 2px cell padding = the 4px inter-cell gap. Sticky headers fill with the
* OPAQUE grid bg so scrolled cells can't bleed through them; that fill also
* paints the padding strip, which is what reads as the gap. */
.wp-mx-grid th,
.wp-mx-grid td { padding: 0; vertical-align: middle; }
.wp-mx-grid td { padding: 2px; vertical-align: middle; }
.wp-mx-grid thead th,
.wp-mx-grid tbody th {
position: sticky;
background: var(--wp-bg-deep, var(--wp-bg-1, #0e1015));
}
.wp-mx-grid thead th { top: 0; z-index: 2; }
.wp-mx-grid tbody th { left: 0; z-index: 1; }
/* Band row pins to the very top; the tag row sticks just beneath it (offset =
* the measured band height). Corner outranks both so it stays in the angle. */
.wp-mx-grid thead th.wp-mx-th-band { top: 0; z-index: 3; }
.wp-mx-grid thead th.wp-mx-th-col { top: var(--wp-mx-band-h, 0px); z-index: 2; }
.wp-mx-grid thead th.wp-mx-corner { top: 0; left: 0; z-index: 5; } /* both axes */
.wp-mx-corner {
/* Inner chips carry the rounded visual + per-axis tint (--ax, set inline);
* the <th> itself stays an opaque grid-bg backing for the sticky fill. */
.wp-mx-corner .chip {
display: flex;
flex-direction: column;
justify-content: center;
width: 96px;
height: 32px;
min-height: 36px;
height: 100%;
padding: 5px 8px;
border-radius: 4px;
font-family: var(--wp-font-mono, monospace);
font-size: 9px;
color: var(--wp-text-dim, var(--wp-text-muted));
border-radius: 4px;
line-height: 1.3;
text-align: left;
padding: 0 8px !important;
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
background: color-mix(in srgb, var(--wp-text) 4%, var(--wp-bg-1));
}
.wp-mx-corner .row-1 { display: block; color: var(--wp-constraint-source-text); line-height: 1.3; }
.wp-mx-corner .row-2 { display: block; color: var(--wp-constraint-target-text); line-height: 1.3; }
.wp-mx-corner .row-1 { color: var(--wp-constraint-source-text); }
.wp-mx-corner .row-2 { color: var(--wp-constraint-target-text); }
.wp-mx-th-col {
width: 64px;
height: 28px;
font: 700 10px var(--wp-font-sans, sans-serif);
color: var(--wp-constraint-target-text);
background: color-mix(in oklab, var(--wp-constraint-target) 10%, transparent);
border-radius: 4px;
text-align: center;
/* Column band — axis name spanning its tags, sitting atop the tag row. */
.wp-mx-th-band .chip {
display: flex;
align-items: center;
justify-content: center;
height: 22px;
padding: 0 8px;
font: 700 9px var(--wp-font-sans, sans-serif);
letter-spacing: 0.06em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 4px 4px 0 0;
color: color-mix(in srgb, var(--ax) 82%, var(--wp-text));
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-1));
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
}
.wp-mx-th-row {
/* The uncategorized bucket reads as a catch-all, not a real axis: same neutral
* tint (via --ax) but a dashed edge instead of the solid axis accent. */
.wp-mx-th-band--bucket .chip { border-bottom-style: dashed; }
.wp-mx-grp-head--bucket .chip { border-left-style: dashed; }
/* Column tag chip — the per-tag header below the band (or the only header row
* when columns are ungrouped). */
.wp-mx-th-col .chip {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
min-height: 28px;
padding: 3px 2px;
font: 700 10px var(--wp-font-sans, sans-serif);
border-radius: 0 0 4px 4px;
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text));
background: color-mix(in srgb, var(--ax) 9%, var(--wp-bg-1));
}
.wp-mx-th-col.group-start .chip {
border-left: 2px solid color-mix(in srgb, var(--ax) 45%, transparent);
}
/* Row group header chip — names the axis above its tags, pinned to the left
* edge so it stays visible while cells scroll horizontally. The <th> spans the
* row but stays transparent; only the chip is visible + sticky. */
.wp-mx-grp-row .wp-mx-grp-head {
position: relative; /* override the sticky tbody th; the chip pins */
z-index: 1;
background: transparent;
padding: 4px 0 1px;
text-align: left;
}
.wp-mx-grp-head .chip {
position: sticky;
left: 0;
display: inline-flex;
align-items: center;
max-width: 220px;
height: 22px;
padding: 0 11px;
font: 700 9px var(--wp-font-sans, sans-serif);
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-radius: 0 4px 4px 0;
color: color-mix(in srgb, var(--ax) 80%, var(--wp-text));
background: color-mix(in srgb, var(--ax) 16%, var(--wp-bg-1));
border-left: 3px solid var(--ax);
box-shadow: 0 1px 4px color-mix(in srgb, var(--wp-bg) 55%, transparent);
}
/* Row tag chip — one per source sub-category, left accent in the axis hue. */
.wp-mx-th-row .chip {
display: flex;
align-items: center;
justify-content: flex-start;
width: 96px;
height: 36px;
padding: 0 10px;
font: 700 10px var(--wp-font-sans, sans-serif);
color: var(--wp-constraint-source-text);
background: color-mix(in oklab, var(--wp-constraint-source) 10%, transparent);
border-radius: 4px;
text-align: right;
padding: 0 12px !important;
text-align: left;
border-radius: 0 4px 4px 0;
color: color-mix(in srgb, var(--ax) 76%, var(--wp-text));
background: color-mix(in srgb, var(--ax) 10%, var(--wp-bg-1));
border-left: 2px solid color-mix(in srgb, var(--ax) 48%, transparent);
}
/* Solo grouped axis — no header chip row; fold the axis name in as an eyebrow. */
.wp-mx-th-row.solo .chip {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
border-left-width: 3px;
border-left-color: var(--ax);
}
.wp-mx-th-row.solo .eye {
max-width: 78px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: 700 7px var(--wp-font-sans, sans-serif);
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text-dim));
}
.wp-mx-th-row.solo .v {
font: 600 11px var(--wp-font-sans, sans-serif);
color: var(--wp-text);
}
.wp-mx-td { position: relative; }
@@ -48,6 +48,14 @@ describe("RemapRefPopup", () => {
expect(rows[0].text()).toContain("texture");
});
it("shows each candidate's uuid so same-named wildcards are distinguishable", () => {
const rd = refData();
rd.uuidToName = new Map([["beef0001", "colour"], ["beef0002", "colour"]]);
const w = mountPopup({ cachedName: "", refData: rd });
expect(w.find("[data-test-id='remap-candidate-beef0001']").text()).toContain("beef0001");
expect(w.find("[data-test-id='remap-candidate-beef0002']").text()).toContain("beef0002");
});
it("after picking a wildcard, shows the dropped-token preview struck through", async () => {
const w = mountPopup();
await w.find("[data-test-id='remap-candidate-beef0001']").trigger("click");
+2 -1
View File
@@ -141,7 +141,7 @@ function doConfirm(): void {
@click="pick(c.uuid)"
>
<span class="wpc-remap__cand-name">{{ c.name }}</span>
<span class="wpc-remap__cand-meta">{{ c.count }} opts</span>
<span class="wpc-remap__cand-meta"><code class="wpc-remap__cand-uuid" data-test="remap-cand-uuid">{{ c.uuid }}</code> · {{ c.count }} opts</span>
</li>
</ul>
@@ -249,6 +249,7 @@ function doConfirm(): void {
.wpc-remap__candidate:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
.wpc-remap__candidate--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
.wpc-remap__cand-meta { font: 10px var(--wp-font-mono); color: var(--wp-text-dim, #8a8a93); }
.wpc-remap__cand-uuid { font: inherit; color: var(--wp-text-muted, var(--wp-text-dim, #8a8a93)); }
.wpc-remap__reconcile {
border-top: 1px dashed var(--wp-border, rgba(255,255,255,0.12));
padding-top: 8px;
+41 -4
View File
@@ -35,12 +35,23 @@ const btnRef = ref<HTMLButtonElement | null>(null);
const open = ref(false);
const flip = ref(false);
const active = ref(0);
/** Type-to-filter query, built from printable keystrokes while the menu
* is open. Reset every time the menu opens or closes. */
const query = ref("");
/** Fixed-position coordinates for the teleported menu. */
const menuStyle = ref<Record<string, string>>({});
const selected = computed(() => props.options.find((o) => o.value === props.modelValue) ?? null);
/** Options narrowed by `query` (case-insensitive substring on the label).
* Empty query → all options, so every dropdown is type-to-filter. */
const filtered = computed<SelectOption[]>(() => {
const q = query.value.trim().toLowerCase();
if (!q) return props.options;
return props.options.filter((o) => o.label.toLowerCase().includes(q));
});
const btnClasses = computed(() => [
"wp-select",
props.size === "sm" && "wp-select--sm",
@@ -137,11 +148,13 @@ function computeMenuStyle() {
watch(open, (v) => {
if (!v) {
query.value = "";
window.removeEventListener("scroll", closeOnScroll, true);
window.removeEventListener("resize", reposition);
return;
}
if (!btnRef.value) return;
query.value = "";
computeMenuStyle();
// Highlight currently-selected option (or first).
const idx = props.options.findIndex((o) => o.value === props.modelValue);
@@ -177,19 +190,29 @@ function onKeydown(e: KeyboardEvent) {
}
return;
}
const list = filtered.value;
if (e.key === "Escape") {
e.preventDefault();
open.value = false;
} else if (e.key === "ArrowDown") {
e.preventDefault();
active.value = (active.value + 1) % props.options.length;
if (list.length) active.value = (active.value + 1) % list.length;
} else if (e.key === "ArrowUp") {
e.preventDefault();
active.value = (active.value - 1 + props.options.length) % props.options.length;
if (list.length) active.value = (active.value - 1 + list.length) % list.length;
} else if (e.key === "Enter") {
e.preventDefault();
const opt = props.options[active.value];
const opt = list[active.value];
if (opt) pick(opt);
} else if (e.key === "Backspace") {
e.preventDefault();
query.value = query.value.slice(0, -1);
active.value = 0;
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Printable key → type-to-filter; jump the highlight back to the top.
e.preventDefault();
query.value += e.key;
active.value = 0;
}
}
</script>
@@ -242,7 +265,14 @@ function onKeydown(e: KeyboardEvent) {
data-test="select-menu"
>
<li
v-for="(opt, i) in options"
v-if="query"
class="wp-select__filter"
aria-hidden="true"
data-test="select-filter"
style="display:flex;align-items:center;padding:4px 12px;font-size:11px;opacity:0.6;"
>Filtering: {{ query }}</li>
<li
v-for="(opt, i) in filtered"
:key="String(opt.value)"
class="wp-select__option"
role="option"
@@ -262,6 +292,13 @@ function onKeydown(e: KeyboardEvent) {
<span class="wp-spacer" />
<Icon v-if="opt.value === modelValue" name="check" :size="ICON_SM" />
</li>
<li
v-if="!filtered.length"
class="wp-select__empty"
aria-disabled="true"
data-test="select-empty"
style="padding:6px 12px;opacity:0.55;"
>No matches</li>
</ul>
</Teleport>
</div>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import DocPage from "../../../components/docs/DocPage.vue";
import DocSection from "../../../components/docs/DocSection.vue";
import DocCallout from "../../../components/docs/DocCallout.vue";
import DocFlow from "../../../components/docs/DocFlow.vue";
import DocKeyList from "../../../components/docs/DocKeyList.vue";
import CrossLinks from "../../../components/docs/CrossLinks.vue";
const overridable = [
{
term: "Wildcard pick",
desc: "Pin a specific option for one frame while the rest re-roll. Frame #3 always lands on \"red\"; the others stay random.",
},
{
term: "Combine template / Fixed value",
desc: "Give a frame its own template string or fixed value — e.g. a different caption on the last frame.",
},
{
term: "Constraint reach + matrix tweaks",
desc: "Adjust a constraint's instance settings for one frame.",
},
{
term: "Seed lock",
desc: "Lock a module's seed on one frame only, so just that frame reproduces while the others vary. See the per-frame seed lock below.",
},
{
term: "Enable / disable",
desc: "Run a module only on some frames, or skip it on others. See per-frame enable/disable below.",
},
];
</script>
<template>
<DocPage
group="How it connects"
title="Per-iteration overrides"
icon="pi pi-images"
tone="neutral"
blurb="Give individual frames of a WP Context Loop run their own settings — a pinned wildcard pick, a tweaked template, a locked seed, or a skipped module — without disturbing the rest of the batch."
>
<DocSection title="Base vs. per-frame">
<p>
A <b>WP Context Loop</b> fans your chain out into N passes (“frames”). By default every
frame resolves from the same module settings — the <b>base</b>. An <b>override</b> is a
per-frame exception: a single frame gets a different value while all the others keep using
the base. Think of it as a keyframe — most of the run follows the base, and you pin only the
spots that need to differ.
</p>
<DocFlow
:stages="[
{ icon: 'pi pi-sitemap', name: 'Base settings', sub: 'apply to all frames', tone: 'node' },
{ icon: 'pi pi-images', name: 'Frame #k override', sub: 'patches just frame k', tone: 'neutral' },
{ icon: 'pi pi-replay', name: 'Resolved frames', sub: 'base, except where pinned', tone: 'node' },
]"
:arrows="['default', 'per-frame patch']"
caption="Each frame resolves from the base, then any override recorded for that frame is layered on top."
/>
</DocSection>
<DocSection title="Editing a frame">
<p>
The loop widget carries an <b>edit frame</b> row of chips: <code>base</code>, then
<code>#1 #2 #3 …</code> up to the count. Pick a frame chip and the module editors switch to
“editing frame #k”: open any module, change it, and the change is recorded as that frame's
override — not the base. Switch back to <code>base</code> and you're editing the default
that every un-pinned frame uses.
</p>
<p>
Overrides are sparse: a frame stores only the fields you actually changed, so a four-frame
run with one pinned wildcard carries one tiny patch, not four full copies. Each pinned field
shows a small badge on its module row so you can see at a glance which frames diverge, and a
<b>revert to base</b> control clears a frame's override and returns it to the default.
</p>
<DocCallout variant="tip">
Overrides are <b>instance-level</b> — they patch how a module resolves for a frame (the
pick, the lock, the template text, enable/disable), not its underlying structure. Adding or
removing a wildcard option, or changing a module's identity, always edits the base and
applies to every frame.
</DocCallout>
</DocSection>
<DocSection title="What you can override">
<DocKeyList :items="overridable" />
</DocSection>
<DocSection title="Per-frame enable / disable">
<p>
Every module row has an enable checkbox that's <b>frame-aware</b>. The effective state for a
frame is the per-frame setting if you've set one, otherwise the module's base on/off. That
makes both directions work:
</p>
<ul>
<li><b>Base on, frame off</b> — the module runs everywhere except the frames you switch off.</li>
<li><b>Base off, frame on</b> — the module is dormant except on the frames you switch on, so a run can introduce an extra element on just a couple of frames.</li>
</ul>
<p>
A skipped frame still produces an image — the module simply contributes nothing to that
pass. In <b>WP Debug</b> a frame turned off by a per-frame switch reads differently from a
module that's off at the base, so you can tell intentional per-frame skips from a
globally-disabled module.
</p>
</DocSection>
<DocSection title="Per-frame seed lock">
<p>
Seed locks are frame-aware too. Locking a module's seed while a frame chip is active pins
the seed for <em>that frame only</em> — the others keep varying. This is the difference
between “hold this one element steady across the whole batch” (lock at the base) and “make
frame #4 reproducible but let the rest roam” (lock on frame #4). Unlocking a frame whose
base is locked records an explicit per-frame unlock, so a single frame can break free of a
base lock.
</p>
<DocCallout variant="warn">
While a frame chip is active, the run-level toggles (<b>Hide from prompt</b>,
<b>Hold across run</b>) are disabled — those apply to every frame, so you switch back to
<code>base</code> to change them. Only the per-frame things (pick, seed lock, enable) are
editable on a frame.
</DocCallout>
</DocSection>
<DocSection title="Works with">
<CrossLinks
:links="[
{ id: 'wp-context-loop', label: 'WP Context Loop', icon: 'pi pi-replay', tone: 'node' },
{ id: 'seeds-and-loops', label: 'Seeds &amp; loops', icon: 'pi pi-share-alt', tone: 'neutral' },
{ id: 'wp-context', label: 'WP Context', icon: 'pi pi-sitemap', tone: 'node' },
]"
/>
</DocSection>
</DocPage>
</template>

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