Compare commits

...
Author SHA1 Message Date
DumiFlex 4b1bd1b24a chore(tutorials): AI3 comment no longer looks like a line 2026-11-13 01:04:37 +00:00
DumiFlex c262321ea8 feat(tutorials): N02 Prompt Assembler in depth 2026-11-13 01:04:29 +00:00
DumiFlex 439e316631 feat(tutorials): N07 WP Toggles in depth 2026-11-13 01:04:29 +00:00
DumiFlex 55cebea0b6 feat(tutorials): N01 WP Context in depth 2026-11-13 01:04:29 +00:00
DumiFlex 3d1cdebd70 fix(tutorials): voice AI3's warning-path lines (#392)
Literal say lines for both Install branches, a line for leftover warnings, and speak.py flags unvoiced say() calls.
2026-11-13 01:59:09 +01:00
DumiFlex 64f6f58844 fix(context): keep bundle headers inside narrow frames and the settings preview (#391)
The bundle frame keeps its width; the state badge and summary shorten before the name, and a narrow header shows the badge's dot instead of its word.
2026-11-13 00:32:06 +01:00
DumiFlex a16129a973 feat(assembler): mark unused variables internal from the toolbar (#390)
A new eye button beside Load, Save and Clear marks every upstream variable that neither the template nor the negative template reads as internal. Assemblers below each writer are checked first and keep what they read; a Context module is flipped only when none of its variables is in use. Undo in the toast puts every widget back unless it was edited since.
2026-11-12 17:39:27 +00:00
DumiFlex 4476cebf57 fix(debug): Show node opens folded bundles, groups and rows before it blinks (#389)
Widgets marked data-wp-reveal get a wp-reveal event with the target before the blink looks: Context unfolds the bundles around the module, Toggles its label groups, the Injector its folded row list. Subgraphs are entered, never unpacked. The trace link underlines only its word.
2026-11-12 18:28:59 +01:00
DumiFlex 2f364f87e6 fix(debug): centre the tag and axis chips on whole pixels (#388)
The WP Debug variables view squeezed the kit Chip to 17px. Inside its 1px border that left 15px for a 10px line, so the icon and text sat on half pixels and Firefox rounded the icon up and the text down. 18px leaves an even 16px.
2026-11-12 18:23:26 +01:00
DumiFlex 257be0bfc7 fix(context): open the drop gap beside a nested bundle (#387)
The nested frame's own margins outranked the kit's wp-rowdrag gap; matching three-class rules let the gap and line open beside it.
2026-11-12 17:49:09 +01:00
DumiFlex 3caa30e410 feat(context): drag with the kit's row-drag look; drop into closed bundles (#386)
WP Context uses the kit's row-drag gap and line (bundle-coloured inside a bundle); a dragged bundle dims; a closed bundle takes drops on the middle of its header and opens.
2026-11-12 17:32:50 +01:00
DumiFlex cc2cea239a feat(toggles): shared row drag from wp-kit 0.19.0 (Toggles + Injector) (#385)
Bump @dumiflex/wp-kit to 0.19.0. Toggles and the Context Injector drag on the kit's useRowDrag; Toggles groups reorder and toggles can drop below a group; grid tiles size to their content.
2026-11-12 16:15:36 +01:00
DumiFlex e8d17999c2 test(tutorials): A17 toggle values and negatives, Modal footer fixes, addNode avoids groups (#384)
Script changes from the 12/11 retake round; every touched video retaken on dev be71b639.
2026-11-12 07:25:41 +01:00
DumiFlex be71b6391b fix(toggles): keep toggle negatives in the Assembler's preview layering (#383)
The Assembler's injector layer no longer clears a toggle's own negative from the preview.
2026-11-12 07:00:50 +01:00
DumiFlex 54d48f88f5 fix(toggles): show toggle negatives in the Assembler's negative preview (#382)
A toggle's Negative (or Off negative), and the picked toggle's negative in Pick one, now show in the Assembler's negative preview.
2026-11-12 06:32:07 +01:00
DumiFlex 3b5e557685 chore(deps): bump @dumiflex/wp-kit to 0.18.1 (#381) 2026-11-12 04:25:45 +01:00
DumiFlex ed21697b3a fix(toggles): grid and socket polish (#380)
- Group layout is a grid button beside the icon and colour buttons.
- Control and output dots' tooltips say the toggle's name.
- A merged socket line says how many go in and out, names on hover.
- A Buttons toggle takes two tiles once two fit.
- A folded group keeps its toggles' input dots on its title.
- Socket carets line up with the group carets; control icons use the
  rows' colours.
- Dots are placed from the layout, so a row being dragged doesn't drag
  them along.
2026-11-12 04:06:35 +01:00
DumiFlex 53a176c884 chore(deps): bump zod 4, typescript 6, jsdom 31, @vitejs/plugin-vue 6, vue 3.5.45 (#379)
Folds Dependabot #372-#376 into one change.

- zod 4: z.record takes a key schema, SafeParseReturnType is now
  ZodSafeParseResult, and a missing derivation else stays allowed.
- jsdom 31: visibility tests mount attached; FLIP and clip-path tests
  accept jsdom's new CSS serialisation.
- Bundle budget raised to 685 KB: zod 4 doubles the lazy shape-check chunk.
2026-11-12 03:51:37 +01:00
DumiFlex b890660980 feat(toggles): a Grid layout for WP Toggles (#378)
Rows or Grid for the node and for each group, in both modes and every style. Grid tiles wrap to the node's width; wired toggles' dots sit on thin socket lines under a group's tiles, control dots packed on the left and outputs on the right, and a group's lines merge into one like the Context Injector's merged wires.
2026-11-12 03:24:03 +01:00
DumiFlex 16af4980b5 refactor: move the 12 canvas dialogs onto the kit Modal, keeping their coloured headers (#377) 2026-11-12 02:55:39 +01:00
DumiFlex 108c2b3e53 feat(toggles): an Off value and Off negative for Any on toggles (#371)
A toggle can say what its variable writes while off, with its own negative. Either a Value or an Off value makes the variable hold text; with neither it writes true/false. $name.bool reads the switch either way.
2026-11-12 02:46:22 +01:00
DumiFlex e41ae4ded0 feat(debug): trace steps say why they came out that way, per kind (#370)
Wildcard steps list their options with weights and why each was cut, the pool chain, locked seed, separator and matched value; fixed values, combines and node templates show what they read; constraint steps link the steps they re-weighted; Model Info, Toggles, Injector and Context Loop rows carry their own facts.
2026-11-12 02:33:22 +01:00
DumiFlex 3a7671b467 fix(examples): Var nodes read $name.bool, so Quick preview picks the small size again (#369)
lookup_var reads $name.bool from the toggles' on/off table; example 2's Var → Bool reads $quick_preview.bool since the Quick toggle's Value (12) now fills $quick_preview. Tests, docs note and release-notes line.
2026-11-12 02:28:10 +01:00
DumiFlex 6818abdc25 fix(tutorials): A17 fills a toggle's settings in its new window (#368)
A17 Switches fills name, variable, value and When off in the toggle window (#361) and saves with its Done; the Pick one line says Show in Assembler. Retaken on the PC against dev f30185d3.
2026-11-12 02:15:57 +01:00
DumiFlex 4005954615 feat(debug): give WP Debug a view of its own for the Loop, Model Info, Toggles and Injector (#360)
Variables grouped into per-node cards with kind squares, module facts, toggle true/false and value + .bool, Injector type chips, Loop frame line; long values fold until Show all; .axis/.bool/tags as kit chips. Trace stays numbered run order. Show node blinks the module or field on the canvas. Grouped right-click menus with a kind header. Loop trace rows, node warning labels and links.
2026-11-12 02:12:23 +01:00
DumiFlex 4ff45d2492 feat(toggles): every toggle can have a Value and a Negative, plus $name.bool (#367)
A toggle with a Value writes it to its variable while on and nothing while off; its Negative goes with the variable. $name.bool reads the switch (true/false) across nodes, in text and in conditions, through __wp_toggle_bools__. Payloads reading $name.bool stamp schema 12; bool is reserved as a tag group.
2026-11-12 02:12:08 +01:00
DumiFlex f30185d36a feat(toggles): a toggle's settings in a window, icons, colours and a Pick one negative (#361)
Each toggle's settings open in a kit Modal: name, variable, looks (icon, icon/name/control colours on kit IconPicker and ColorField), Pick one value and negative, style, needs, when off. The header and the row wear the toggle's looks; the icon is a column beside the name and $var. Labels take an icon and colour. Window warnings use the kit Banner. Bundle guide raised to 670 KB.
2026-11-12 01:51:09 +01:00
DumiFlex 802fa2b36c refactor(canvas): module, bundle, seed list and note edit windows on the kit Modal (#366) 2026-11-12 00:07:42 +00:00
DumiFlex eb9d8e483d feat(note): pick any extension picture, and keep the Pictures panel open after adding (#365)
* feat(note): pick any extension picture, and keep the Pictures panel open after adding

* feat(note): show the extension's pictures only once you search

* feat(note): preview the note as the node draws it, and let the icon picker fill the editor
2026-11-11 23:54:14 +00:00
DumiFlex 4d0cf610a9 chore(deps): bump wp-kit to 0.17 and use Stat on the Database card (#364) 2026-11-12 00:42:57 +01:00
DumiFlex fecc3f45b7 fix(ui): show notices and error lines as kit Banners (#363)
* fix(ui): show notices and error lines as kit Banners

Hand-drawn red error lines, warning notes and info notes in the manager and a few canvas editors become wp-kit Banners (size sm), so they share the kit's tone, icon and role. Their old class rules keep only their margins.

* fix(ui): turn the remaining settings and chat notes into kit Banners
2026-11-12 00:28:41 +01:00
DumiFlex 46b8690e19 refactor(note): open the note's text in the kit Modal instead of a one-off window shell (#362)
* refactor(note): open the note's text in the kit Modal instead of a one-off window shell

NodeEditWindow duplicated the kit's Modal (header, body, footer, × with Esc)
and lacked its focus handling. The note text window now uses Modal at size lg
with a ConfirmModal before discarding changes; NodeEditWindow and its kit
page entry are gone.

* chore(docs): regenerate docs index after dropping the NodeEditWindow kit entry
2026-11-11 23:09:27 +00:00
DumiFlex 18e9243542 feat(tutorials): review round 3 (callout hold rule, A17 rewrite, AI3 image ending, starter library) (#357)
Callout hold rule, dead-pause fix after runs, A17 rewrite (toggle variables first), AI3 ends by rendering with the built pack's top bundle, AI1 line, box lines in A16/T10/T12, N13 at 20x, and three starter-library items (Daydream, Lights on or off, Shot per frame).
2026-11-11 22:58:26 +01:00
DumiFlex 089b803497 feat(note): write the note's text in its own window, and drag Note and Bookmark while editing (#359)
The text and a live preview move into a window like the module editors
(NodeEditWindow, shared with the Toggles editor). The node keeps tone,
look, colour, title, pin and the bookmark settings. An open Note or
Bookmark editor now lets presses on its background through, so the node
drags.
2026-11-11 21:57:47 +00:00
DumiFlex 51f9f3fc3a fix(toggles): toggle variables reach the Assembler, internal globe, Pick one styles (#358)
Graph walkers marked every WP Toggles variable internal, so the Assembler
showed none. Each toggle now has a globe (internal by default), Pick one
gets a node-wide style, the Value field is a rich input that warns about
inline picks and missing variables, and toggles drag into and out of groups.
2026-11-11 22:36:58 +01:00
DumiFlex 8c99088dca fix(ui): give manager pop-up panels their padding back under Tailwind 4 (#356)
Tailwind 4's preflight resets padding, margin and border on every element in @layer base, which ranked above the kit's @layer wp-extension, so the Popover lost its padding (Test Runner picker flush against its edges). layers.css, imported first, ranks wp-extension above base.
2026-11-11 21:11:09 +01:00
DumiFlex 25dac0aa40 fix(tutorials): A07 cut-off line, A09 nested reach scene, A14 callout pacing (#355)
A07's line no longer ends on a clipped "or"; A09 shows a constraint reaching inside a nested wildcard; A14 holds each Var node box for its own line.
2026-11-11 19:25:16 +01:00
DumiFlex 59371343d7 fix(tutorials): AI4 tag lookup from the local tag list, and review retakes (#353)
AI4 asks the chat for a tag from the local tag list instead of the live Danbooru lookup. Retake fixes: template tooltips left open (A04/A18/A19), A18 convert box timing, N12 grid label, T10 reset line split, T12 callouts held for their lines.
2026-11-11 18:36:58 +01:00
DumiFlex 89b5b0877d fix(wildcard-editor): narrow the option weight input (#354) 2026-11-11 17:06:29 +00:00
DumiFlex d34fc8f8cf feat(tutorials): in-depth Context Loop, Context Injector and Image Filter videos (#352)
* feat(tutorials): in-depth Context Loop, Context Injector and Image Filter videos

N05 walks the whole Context Loop (frames vs batch size vs Run count, Edit
frame chips, Override Context seed, strategies, the seeds panel, Ctrl/Alt
click, values, a rule on $iteration, Seed List, Hold, Bypass loop). N04
takes the Context Injector apart and N12 the Image Filter (labels, prompt
edits with clip, the mask painter, Compare, Zoom, Send to Loop, the node's
settings). The old Nodes videos move to their new numbers (Debug N03,
Label & Grid N13, Note N14, Bookmark N15); N12 replaces the old N01.

* fix(tutorials): tick both rows in T03's bulk edit

The selection bar slides in after the first tick and moves the rows, so the
second click could land where its row used to be. Tick one row at a time and
check each one took.

* fix(tutorials): scroll T05's rules into view before boxing them

scrollTo now falls back to scrolling the part's own scrolling box when the
wheel doesn't move it, and T05 scrolls each branch (and the second action)
to the upper screen before its callout, so the boxes are on screen.

* fix(tutorials): narrate T06's canvas build so it plays at normal speed

The two builds had no line, so the render squeezed them to 4.5x and the
hand looked rushed. A line over each keeps them at normal speed.

* fix(tutorials): a hand at work plays at most 2x in the render

The Tutor now records when the hand clicks, drags, types or presses a key
(cues.json acts), and render.mjs speeds a silent stretch with hand work up
by at most 2x instead of up to 8x, so builds don't look rushed. The ten
longest silent builds (T04, A04, A05, A06, A09, A10, A12, L03 and two in
N14) get a line, so they play at normal speed.

* fix(tutorials): give N04's Injector room beside the stand-in nodes

The Injector landed on the first text box and covered its output, so the
first wire never connected. The stand-ins sit further right, the Injector
further right still, and the canvas slides left before the Context step.

* fix(tutorials): keep both ends of N12's new wires on screen

The mask wire was dragged from the filter to a node that had scrolled off
the top, so it dropped on empty canvas. The canvas now fits the nodes of
each wire into the free part of the screen first, and the new Set Latent
Noise Mask goes under the refine sampler.

* fix(tutorials): N05 and N04 fixes from the first takes

N05: zoom in on the Seed List while it's talked about (it sat under the
caption), and drop the callout labels that covered the frame chips and the
template. N04: narrate the Context / Assembler / preview build (36 s of
silence) and move the hand off the rows so the globe tooltip closes.

* fix(tutorials): paint N12's mask on the painter's canvas

In paint mode the mask painter replaces the detail image, so the strokes
aimed at an image that was no longer there.

* fix(tutorials): a single click doesn't hold a long wait at 2x

A Run click followed by a long wait counted as hand work, so the wait played
at 2x instead of being squeezed. A stretch now needs two or more hand
actions to be capped.

* fix(tutorials): tuck N12's picker away with its minus button

Escape in Detail goes back to the grid instead of tucking the picker away,
so the waiting pill never showed.

* fix(tutorials): box N12's two extra slots as a pair

The callout took one locator matching both slot dots, which strict mode
refuses.

* fix(image-filter): keep the picker's keys after a view change

Switching mode or leaving the painter removed the focused control and dropped
focus to body, so the picker's keys went to the canvas bookmarks. Focus goes
back to the picker. N12 hands the keys back before Compare and Zoom.

* fix(tutorials): L05 waits for Role's save to store a history entry

* fix(tutorials): a few clicks across a long wait don't cap the squeeze

* fix(tutorials): review 2026-11-11 first half (lines over empty canvas, late boxes, N12 framing)

* fix(tutorials): review 2026-11-11 A09-A19 findings

* fix(tutorials): review 2026-11-11 L03, L05, N14, E4 findings; jumpTo keeps tall notes above the caption

* fix(tutorials): A06 and T05 build their Context before the line

* fix(tutorials): N14 keeps the label's peek open through its line

* fix(tutorials): N12 boxes the extra slot rows; callouts fall back to the raw box

* fix(tutorials): N14 hovers the label before its line

* fix(tutorials): N14 keeps the hover card up while its line plays
2026-11-11 03:33:14 +00:00
DumiFlex 9a7637e2b2 fix(tutorials): keep a callout centred when it meets a window edge (#351) 2026-11-10 19:48:25 +00:00
DumiFlex 0a64577b87 fix(tutorials): AI1 narration fits a cloud model (#350) 2026-11-10 18:38:53 +00:00
DumiFlex 09a8630444 fix(tutorials): apply the 2026-11-10 video review (#348)
Render: squeeze silent stretches over 12 s and cut a silent opening.
Tutor: clear tooltips after each line, close suggestion lists in DOM-widget
fields, deselect(), zoomOn(top), jumpTo() zooms example steps and boxes
their notes; the caption moves up over open lists.
Tests: AI4 fails on a refused lookup, AI3 fixes until clean, Assembler
preview out of frame (A05/A12/A13), actions before their lines, late and
oversized callouts, parts under the caption, Done before the outros.
2026-11-10 16:29:23 +00:00
DumiFlex 1439529b04 fix(tutorials): even callout padding on every side near an edge (#347) 2026-11-10 15:21:34 +00:00
DumiFlex 60347372f4 fix(tutorials): keep A14's variable list open to pick from it (#346) 2026-11-10 15:18:41 +00:00
DumiFlex 7bb128b5a8 feat(tutorials): clean each voice line in the render (fizz, breaths) (#345)
* feat(tutorials): clean each voice line in the render (fizz, breaths)

* fix(tutorials): keep the rendered audio at 48 kHz
2026-11-10 13:02:24 +00:00
DumiFlex 69dd1f2038 fix(tutorials): wait for Watchwright's raw capture before rendering (#344) 2026-11-10 11:35:45 +00:00
DumiFlex 8c3b719b62 fix(tutorials): leave numbers out of the voice word check (#343) 2026-11-10 09:13:02 +00:00
DumiFlex af69d5fc0c feat(tutorials): narrate with Qwen3-TTS, checked takes and a Kokoro stand-in (#342)
* feat(tutorials): narrate with Qwen3-TTS, checked takes and a Kokoro stand-in

* fix(tutorials): load faster-whisper with a test transcribe, CPU when CUDA 12 is missing
2026-11-10 06:35:22 +00:00
DumiFlex 933c2a5d08 feat(tutorials): voice short lines like statements (#341)
* feat(tutorials): spoken text apart from captions

* feat(tutorials): voice short lines like statements

* feat(tutorials): render the voice at -19 LUFS
2026-11-10 05:46:36 +00:00
DumiFlex 55297b3e92 feat(tutorials): spoken text apart from captions (#340) 2026-11-10 05:25:10 +00:00
DumiFlex 7dd3eeaf61 fix(tutorials): paint sync marks out with the pixels below them and centre callouts at the edge (#339) 2026-11-10 05:12:22 +00:00
DumiFlex ed3b3c37aa fix(tutorials): recording fixes from the video review (#325)
Fixes every recording issue from the tutorial video review; T10 shows ComfyUI palettes; records on Watchwright 0.16 high quality at 60 fps with correct colours.
2026-11-10 05:37:49 +01:00
DumiFlex 40422f1a72 build(deps): bump @dumiflex/wp-kit to 0.16.0 (#338)
Brings back the plain text field box (kit #131) and adds TreePicker (kit #132).
2026-11-10 04:30:43 +00:00
DumiFlex b4587a8001 build(deps): move to Tailwind CSS 4 (#337)
The manager's Tailwind setup moves into CSS: tailwind.css imports
tailwindcss with source(none) plus @source for the manager files, the wp
colours and fonts are an @theme inline block, and postcss uses
@tailwindcss/postcss. Two compat rules keep v3's default border colour
and the pointer cursor on buttons. tailwind.config.js is gone.
2026-11-10 04:07:15 +00:00
dependabot[bot] 0ffb527ff9 build(deps-dev): bump playwright (#335)
Bumps the development group with 1 update in the / directory: [playwright](https://github.com/microsoft/playwright).


Updates `playwright` from 1.64.1 to 1.65.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.64.1...v1.65.0)

---
updated-dependencies:
- dependency-name: playwright
  dependency-version: 1.65.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-10 04:51:56 +01:00
dependabot[bot] 2177c189e3 ci: bump pnpm/action-setup (#334)
Bumps the actions group with 1 update in the / directory: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 5 to 6
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v5...v6)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-10 04:51:48 +01:00
DumiFlex 79fd31ccf5 build(deps): move to vue-tsc 3 (#336) 2026-11-10 03:49:03 +00:00
DumiFlexanddependabot[bot] d63244209c build(deps): take the open Dependabot updates (#333)
* ci: bump the actions group across 1 directory with 7 updates

Bumps the actions group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `5` | `7` |
| [pnpm/action-setup](https://github.com/pnpm/action-setup) | `4` | `5` |
| [actions/setup-node](https://github.com/actions/setup-node) | `5` | `7` |
| [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) | `2` | `3` |
| [actions/setup-python](https://github.com/actions/setup-python) | `5` | `7` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |



Updates `actions/checkout` from 5 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

Updates `pnpm/action-setup` from 4 to 5
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4...v5)

Updates `actions/setup-node` from 5 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v7)

Updates `marocchino/sticky-pull-request-comment` from 2 to 3
- [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases)
- [Commits](https://github.com/marocchino/sticky-pull-request-comment/compare/v2...v3)

Updates `actions/setup-python` from 5 to 7
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v7)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: marocchino/sticky-pull-request-comment
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: pnpm/action-setup
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): take the open Dependabot updates

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-10 03:31:46 +00:00
DumiFlex 4bdacc8506 fix(note): open the editor wide enough for its tools (#330)
The editor's tools (text toolbar, bookmark zoom box) need about 358 px and
the editor opened at 340, so they spilled past the edge. FORM_WIDTH is
360, the toolbar wraps on a narrower note, and the fitted size goes
through setSize so Nodes 2.0 hears it.
2026-11-10 03:20:03 +00:00
DumiFlex 0e822858f1 fix(note): buttons, links and text box scrolling in Nodes 2.0 (#331)
Restores clicks on a note's own buttons and links in Nodes 2.0 (specificity regression from #328), and lets the wheel scroll a long textarea under the wheel shield even when Firefox reports its overflow as visible.
2026-11-10 04:06:41 +01:00
DumiFlex fb00b7ca94 fix(context): collapsed bundles show trouble inside and don't widen the node (#332)
Hidden rows inside a collapsed bundle no longer raise the WP Context width floor, and a collapsed bundle's header shows a dot for the worst conflict and library state among the rows it hides.
2026-11-10 04:00:51 +01:00
DumiFlex a08b763036 chore(kit): bump @dumiflex/wp-kit to 0.15.4 (#329)
Bar room only while a box actually scrolls, so the category icon picker has no empty strip. The module list's scrolling table wrap takes v-bar-room.
2026-11-10 03:28:30 +01:00
DumiFlex 6d1d0591a2 fix(note): let the editor keep the pointer so its scrollbars work in Firefox (#328)
While a WP Note is being edited, the frameless pass-through gives the pointer back to the editor (legacy and Nodes 2.0), so Firefox's overlay scrollbars respond on hover. It takes it again when editing ends.
2026-11-10 02:38:39 +01:00
DumiFlex aadc550207 chore(deps): bump @dumiflex/wp-kit to 0.15.3, leave room for overlay scrollbars (#326) 2026-11-10 02:26:16 +01:00
DumiFlex 1f3e610c59 fix(note): frame pictures so they don't read as a hole in the node (#324) 2026-11-10 02:09:05 +01:00
DumiFlex 272a761e11 fix(categories): draw icons saved with a pi- prefix (#323) 2026-11-10 01:44:35 +01:00
DumiFlex 2b50da3d22 feat(manager): ComfyUI palette colour theme (#327) 2026-11-10 01:37:18 +01:00
DumiFlex 24ab53e531 feat(canvas): follow ComfyUI color palettes beyond Dark and Light (#322) 2026-11-10 01:23:45 +01:00
DumiFlex 2a9336bee5 fix(ui): uncut g/p/q/y on seed buttons and health names, roomier unset chip (#320) 2026-11-10 01:01:01 +01:00
DumiFlex 1a807ce976 feat(tutorials): L05 shows an item's own version history (last three saves) (#321) 2026-11-09 23:48:38 +00:00
DumiFlex 4f8e1dcf0e fix(assembler): plain-text status, empty variables note and uncut negative row (#319)
Without a Context the preview said '(template empty or unresolved)' about
text with nothing to resolve. It now reads 'plain text', counts $names
nothing sets as unresolved, and the empty variables row explains itself.
The negative row's line height no longer clips descenders.
2026-11-09 23:24:44 +00:00
DumiFlex 78d2d4c395 fix(var-nodes): show the default after a run whose variable was missing (#317)
The last-execute strip decided 'has run' from a non-empty source text or
a choice note, so Var → Int / Float / Bool reading an unset variable kept
saying 'run workflow to see result' instead of the amber default. The
host now passes an executed flag, and the strip names the unset variable.
2026-11-09 23:14:55 +00:00
DumiFlex da637c451a fix(canvas): hide the part of a WP widget a node on top covers (legacy look) (#318)
In the legacy renderer every DOM widget sits in one layer over the
canvas, and ComfyUI's own clipping only cuts out the selected node, so a
WP panel showed through any node dropped on top of it. On each canvas
draw, each WP widget now gets a clip-path that leaves out the nodes
drawn after its own. Cleared under Nodes 2.0.
2026-11-09 23:07:02 +00:00
DumiFlex d2042ecb3a fix(bundles): fill a child's missing payload hash so a new bundle isn't drifted (#316)
* fix(bundles): fill a child's missing payload hash so a new bundle isn't drifted

A bundle child saved without payload_hash (hand-made or imported packs,
like the tutorial library) came onto the canvas with no hash, and the
canvas reads a hashless row whose id is in the library as drifted. GET
now gives such a child the hash of its own payload.

* docs(release-notes): bundle children no longer drifted on insert
2026-11-09 22:45:35 +00:00
DumiFlex 7bb834a6b1 fix(tutorials): T02 ticks Setting too, through pickModules (#315) 2026-11-09 22:24:27 +00:00
DumiFlex 50223ab0ea fix(tutorials): T00 on 2.22, callouts kept inside the frame (#314)
* fix(tutorials): T00 says the videos are recorded on 2.22

* fix(tutorials): callouts stay inside the frame; T00's tags and library highlights
2026-11-09 21:02:18 +00:00
DumiFlex b7ea6582ed fix(tutorials): fixes found while re-recording the videos on Klein (#313)
Clipped callouts, addNode blur, A04/A15/A16 test fixes, L06 tidy library, example workflows on an installed SDXL checkpoint without the missing-model toast or old tabs.
2026-11-09 20:35:53 +01:00
DumiFlex 791b3d8a06 fix(extension): toast Focus opens the subgraph the node lives in (#312)
The Focus button on an unresolved-Assembler warning now opens the graph the node lives in (into a subgraph or back out to the root) before centring, selecting and flashing it, through a shared showGraph helper the Debug viewer's Show node uses too. The pre-run scan starts from the root graph, and the flash re-finds the Nodes 2.0 element once it is drawn.
2026-11-09 17:37:21 +01:00
DumiFlex 069ac06835 feat(tutorials): a clearer tutorial pack and rewritten videos (#311)
Reworks the video tutorials from the review notes: a new precise tutorial library, every video moved to it, a new pool video (A03b), pool filter by variable in A05/A12/A15, and presenter fixes.
2026-11-09 16:09:24 +01:00
DumiFlex e891e4f6af feat: export library items as plain wildcard files (#310)
Import / Export gets a Wildcard files (.zip) button: picks become Dynamic Prompts / Impact Pack .txt files (engine/wildcard_export.py, POST /wp/api/export/plain), with a README listing what has no plain form. The community site vendors the exporter.
2026-11-09 14:36:15 +01:00
DumiFlex 6f089755fd feat(wildcards): filter a wildcard's pool by a run-time variable (#309)
* fix(extension): explain an empty wildcard pool when no option has tags

* feat(wildcards): filter a wildcard's pool by a run-time variable

A $name term in a Pool filter or a reference filter reads the variable
when the prompt runs: options tagged with its value stay, options with no
tag in that value's group stay too. An unset variable or a value that is
no tag on the wildcard drops that term and files filter_variable_no_tag.
Constraints still apply on top. Stamps schema 11 (MAX_KNOWN 11).

* test(syntax): cover the filter variable helpers in the syntax suite
2026-11-09 14:11:10 +01:00
DumiFlex 43e94b1a51 fix(extension): explain an empty wildcard pool when no option has tags (#305) 2026-11-09 13:57:56 +01:00
DumiFlex 1b6865a8ef fix(engine): booleans read as true / false so a Boolean node can drive a rule (#308) 2026-11-09 12:39:01 +00:00
DumiFlex e0fdbb3036 fix(extension): subgraph variables and Nodes 2.0 badges, centred loop chevrons (#307)
Current frontends give nodes text ids ("-10") and the root graph a UUID id, so walkers never crossed a subgraph's edge: variables set outside showed as missing inside. Nodes 2.0 only read the subgraph badge when a node rendered; each SubgraphNode now gets one badge getter up front and a change bumps a revision on ComfyUI's own reactive store. The Context Loop and Seed List chevrons sit at the right edge, level with the label.
2026-11-09 13:24:30 +01:00
DumiFlex 931e4733ad feat(tutorials): presenter fixes and Flux Klein showcase (#306)
* feat(tutorials): smaller straight-moving pointer, callouts follow their target, Flux Klein showcase

- the pointer is smaller and scrolls along a straight line to its target
- a callout follows its target and leaves when the target is hidden or covered
- the showcase loads a UNET + CLIP + VAE (Flux 3 Klein by default) or a checkpoint
- the voice reads LoRA, ComfyUI and $var_names the way they are said
- T01 boxes the whole Import committed toast

* style(tutorials): shorten speak.py docstring
2026-11-09 12:16:07 +00:00
DumiFlex e12effa069 Merge pull request #303 from DumiFlex/main
chore: sync v2.21.0 into dev
2026-11-05 20:39:34 +01:00
DumiFlexandgithub-actions[bot] df769e5d10 chore: clear the release notes shipped in v2.21.0 (#304)
Co-authored-by: github-actions[bot] <41898283+github-actions[bot]@users.noreply.github.com>
2026-11-05 19:39:22 +00:00
369 changed files with 21927 additions and 5880 deletions
+5 -5
View File
@@ -29,22 +29,22 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR head
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
path: head
- name: Checkout base
uses: actions/checkout@v5
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
# Explicit version because both checkouts land in subdirs
# (head/, base/) so there's no package.json at the runner's CWD
# root for the action to autodetect "packageManager" from.
version: 9
- uses: actions/setup-node@v5
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
@@ -71,7 +71,7 @@ jobs:
- name: Diff manifests
run: node head/scripts/bundle-size-diff.mjs sizes/base.json sizes/head.json > report.md
- name: Upsert PR comment
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
with:
header: bundle-size
path: report.md
+17 -17
View File
@@ -23,8 +23,8 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]" pytest-cov
@@ -50,9 +50,9 @@ jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
@@ -68,7 +68,7 @@ jobs:
- run: pnpm build:manager
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: coverage
path: coverage/
@@ -82,13 +82,13 @@ jobs:
theme-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
@@ -101,7 +101,7 @@ jobs:
for i in $(seq 1 60); do curl -sf http://127.0.0.1:8199/wp/ > /dev/null && break; sleep 1; done
node scripts/theme-audit/audit.mjs --out theme-audit-out --shots
- if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: theme-audit
path: |
@@ -119,8 +119,8 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
@@ -138,15 +138,15 @@ jobs:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- name: Python fuzz
run: pytest tests/syntax/test_fuzz.py -m fuzz -v --strict-markers
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
+2 -2
View File
@@ -39,8 +39,8 @@ jobs:
announce:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "22"
# setup-node sniffs the lockfile and tries to prime a pnpm cache,
+4 -4
View File
@@ -33,15 +33,15 @@ jobs:
if: ${{ !github.event.release.prerelease }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
fetch-depth: 1
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.10"
- run: pnpm install --frozen-lockfile
+10 -10
View File
@@ -72,7 +72,7 @@ jobs:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- uses: actions/checkout@v5
- uses: actions/checkout@v7
if: steps.release.outputs.prs_created == 'true'
- name: Auto-merge the release PR once it has notes
@@ -115,12 +115,12 @@ jobs:
run: echo "VERSION=${TAG#v}" >> "$GITHUB_ENV"
# The release PR's merge commit, which is what the tag points at (main's
# head on a hand run).
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
@@ -138,7 +138,7 @@ jobs:
fi
node scripts/release/release-notes.mjs body "$VERSION" "$TAG" > dist/release-body.md
cp docs/release-notes/next.md dist/shipped-notes.md
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: release-notes
path: |
@@ -189,11 +189,11 @@ jobs:
env:
TAG: ${{ needs.release.outputs.tag_name || inputs.tag }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
ref: dev
token: ${{ secrets.GH_TOKEN }}
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
name: release-notes
path: ${{ runner.temp }}/release-notes
@@ -228,12 +228,12 @@ jobs:
if: github.ref == 'refs/heads/beta'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: pnpm
+3 -3
View File
@@ -48,8 +48,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
@@ -77,7 +77,7 @@ jobs:
# Always kept, so a build can be inspected before it replaces the asset
# everyone downloads.
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: wildcard-pipeline-tags
path: wildcard-pipeline-tags.csv
+1
View File
@@ -83,4 +83,5 @@ theme-audit-out/
# Tutorial videos: the voice model, spoken lines and rendered videos are made locally
tutorials/voice/cache/
tutorials/voice/models/
tutorials/voice/ref/
tutorials/out/
+183
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -116,7 +116,7 @@ doesn't exist yet, pick **Placeholder** from the `@` list and connect it later.
| Node | What it does |
|---|---|
| **WP Context** | Holds your module stack and outputs the picked variables. Chain several together. |
| **WP Prompt Assembler** | Fills `$variables` in a template and collects their negative words into a second output. |
| **WP Prompt Assembler** | Fills `$variables` in a template and collects their negative words into a second output. One button hides the variables no Assembler prints. |
| **WP Context Loop** + **WP Seed List** | Run the whole chain N times from one Generate, each with its own prompt and seed, or sweep every combination of chosen wildcard options. |
| **WP Context Injector** | Turns any ComfyUI output (text, a number) into a `$variable`. |
| **WP Model Info** | Reads the loaded checkpoint into `$model_family` / `$model_variant` (Pony, Illustrious, …) so rules can pick LoRAs and tags per model. |
+5 -5
View File
@@ -2215,35 +2215,35 @@
"id": "bench_characters",
"name": "Bench: Characters",
"color": "#a78bfa",
"icon": "pi-user",
"icon": "user",
"sort_order": 0
},
{
"id": "bench_clothing",
"name": "Bench: Clothing",
"color": "#f472b6",
"icon": "pi-shopping-bag",
"icon": "shopping-bag",
"sort_order": 0
},
{
"id": "bench_scene",
"name": "Bench: Scene",
"color": "#34d399",
"icon": "pi-image",
"icon": "image",
"sort_order": 0
},
{
"id": "bench_style",
"name": "Bench: Style",
"color": "#fbbf24",
"icon": "pi-palette",
"icon": "palette",
"sort_order": 0
},
{
"id": "old_stuff",
"name": "Old stuff",
"color": "#9ca3af",
"icon": "pi-folder",
"icon": "folder",
"sort_order": 0
}
],
+5 -5
View File
@@ -1,10 +1,10 @@
// Path is relative to the directory PostCSS runs from. Vite always
// invokes PostCSS with `cwd` = repo root (where `vite.config.mts`
// lives), so this string resolves against the root regardless of
// where this config file itself lives.
// Tailwind v4 runs as its PostCSS plugin. Its config lives in CSS now
// (`src/manager/styles/tailwind.css`: `@theme` + `@source`), so there is no
// tailwind.config.js. Autoprefixer stays for the canvas extension's own CSS,
// which Tailwind never touches.
export default {
plugins: {
tailwindcss: { config: "config/tailwind.config.js" },
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};
-36
View File
@@ -1,36 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
// Scope content scanning to the SPA only — extension code never sees Tailwind.
content: ["./src/manager/**/*.{vue,ts}", "./src/manager.html"],
theme: {
extend: {
colors: {
wp: {
bg: "var(--wp-bg)",
bg2: "var(--wp-bg2)",
bg3: "var(--wp-bg3)",
bg4: "var(--wp-bg4)",
border: "var(--wp-border)",
border2: "var(--wp-border2)",
text: "var(--wp-text)",
text2: "var(--wp-text2)",
text3: "var(--wp-text3)",
text4: "var(--wp-text4)",
accent: "var(--wp-accent)",
accent2: "var(--wp-accent2)",
violet: "var(--wp-violet)",
teal: "var(--wp-teal)",
green: "var(--wp-green)",
amber: "var(--wp-amber)",
red: "var(--wp-red)",
rose: "var(--wp-rose)",
},
},
fontFamily: {
sans: "var(--wp-font-sans)",
mono: "var(--wp-font-mono)",
},
},
},
plugins: [],
};
+1 -1
View File
@@ -12,7 +12,7 @@ Turns any ComfyUI output (text, a LoRA name, a number) into a `$variable` the re
## Row options
- **Name**: The $variable the value becomes. Letters, digits and underscores, starting with a letter.
- **Template**: Optional. Wrap the value in text, e.g. wearing $input_0. Leave empty to use the value as it is. $$ prints a dollar sign.
- **Template**: Optional. Wrap the value in text, e.g. wearing $input_0. Leave empty to use the value as it is. $$ prints a dollar sign. Inline {a|b} picks need a seed, so they print as written (the editor warns).
- **Negative**: Optional negative words for this variable (it can use $input_0 too, so another node can write it). Only reaches an Assembler that prints the variable. Rows with one show a NEG badge.
- **Hide from prompt (globe)**: The variable still reaches combines and derivations, but prints nothing in the prompt.
<!-- fields:end -->
+2 -2
View File
@@ -9,8 +9,8 @@ See what happened at this point of the chain: every variable, which module set i
## Tabs
- **Variables**: Every $variable and its value at this point, with the module that set it. The quickest way to check what your wildcards picked. Negative words show under the value in red, with the module they came from when it isn't the variable's own.
- **Trace**: Every module in the order it ran, grouped by Context. Open one to see why it did what it did: a wildcard's odds and the constraints that changed them, which derivation branch ran, and the negative each one left.
- **Variables**: Every $variable and its value at this point, with the module or node that set it (its icon and colour on the left edge say which kind: wildcard, derivation, Toggles, Model Info, Context Loop, Injector …). The quickest way to check what your wildcards picked. Negative words show under the value in red, with the module they came from when it isn't the variable's own.
- **Trace**: Every module and node row in the order it ran, grouped by the node it ran in. Open one to see why it did what it did: a wildcard's odds and the constraints that changed them, which derivation branch ran, the negative each one left, and for rows from other nodes where the value came from (a Loop frame and its seed, a Model Info detection, a Toggles switch set upstream).
- **Warnings**: What came up during the run, such as missing variables or a constraint that changed nothing. Look here first when something seems off.
- **Raw**: Everything as JSON, with a download button. Handy for bug reports.
<!-- fields:end -->
+3 -4
View File
@@ -20,18 +20,17 @@ A note that explains a workflow. It looks like a panel, not a node: no title bar
- **Look**: Card, Sticky or Label (above).
- **Pinned**: ComfyUI's own pin: a pinned note can't be moved or resized by accident. New notes start pinned.
- **Title**: Shown in bold at the top, or on the pill of a Label.
- **Text**: Markdown, with the formatting bar below over it: headings (# to ####), bold, italic, strikethrough, code, bulleted, numbered and nested lists, task lists (- [x] / - [ ]), quotes and callouts (> [!TIP], > [!WARNING]), tables with column alignment (:--, :-:, --:), rules (---) and code blocks.
- **Text**: Its first line and length on the node; Edit text opens the text window, with the editor beside a live preview. Done (Ctrl+Enter) puts it on the note, Cancel or Escape leaves the note as it was. Markdown, with the formatting bar below over it: headings (# to ####), bold, italic, strikethrough, code, bulleted, numbered and nested lists, task lists (- [x] / - [ ]), quotes and callouts (> [!TIP], > [!WARNING]), tables with column alignment (:--, :-:, --:), rules (---) and code blocks.
## The formatting bar
- **Undo and redo** (Ctrl+Z / Ctrl+Y): Step back or forward through your changes, typing and the bar's own edits alike (Ctrl+Shift+Z and the right-click Undo and Redo work too). Typing counts as one step until you pause, move the caret or start a new line. The steps last while the editor is open.
- **Undo and redo** (Ctrl+Z / Ctrl+Y): Step back or forward through your changes, typing and the bar's own edits alike (Ctrl+Shift+Z and the right-click Undo and Redo work too). Typing counts as one step until you pause, move the caret or start a new line. The steps last while the text window is open.
- **Bold, italic, strikethrough** (Ctrl+B / Ctrl+I): Wraps the selection in **, * or ~~.
- **Code**: Wraps it in backticks, for a $variable.
- **Lists and quote**: Puts - , 1. or > before each selected line (press again to take it off).
- **Link** (Ctrl+K): A Docs page (type to find any page of these docs, then pick a section or the whole page), a Web address, a Library button (below) or a Community pack (owner/pack, or paste the pack's address), drawn as a Button with an icon or as a Text link, in its own colour or the note's. The selected text becomes the link's text. It goes where the caret is when you press Insert link, so you can click in the text after opening the panel.
- **Icon**: Any PrimeIcons icon, searchable, in a colour or the text's own, inserted where the caret is.
- **Pictures**: Add a picture from a file, or paste or drop one into the text; it goes on a line of its own where the caret is. Or pick one the extension ships (the node toolbar with its ? help button). The panel lists the note's pictures with a Width slider (a share of the note), Own size and Remove.
- **Preview (eye)**: Shows the note as it will read.
- **Pictures**: Add a picture from a file, or paste or drop one into the text; it goes on a line of its own where the caret is. Or pick one the extension ships (the node toolbar with its ? help button, or any docs screenshot), with a search box to find it. The panel stays open after adding and lists the note's pictures with a Width slider (a share of the note), Own size and Remove.
<!-- fields:end -->
Keys you type in the editor stay in the editor: Delete, Ctrl+Z and the other ComfyUI shortcuts don't reach the canvas.
+6
View File
@@ -10,6 +10,12 @@ Write a prompt with `$variables` in it and get the finished prompt, plus a match
- **negative_template** `STRING`: Optional, in the collapsible negative section. Your standing negative, with $negatives where the collected negative words go. New Assemblers start with $negatives; without it the negative is only your own text (empty = no negative).
- **prompt (out)** `STRING`: The finished prompt. Wire it into CLIP Text Encode or a WP Prompt Cleaner.
- **negative (out)** `STRING`: The negative words of the variables this template printed, plus your negative template. Wire it into your negative CLIP Text Encode.
## On the node
- **Load / Save (toolbar)**: Load a template from your library into both boxes, or save this one (prompt and negative) to it.
- **Mark unused internal (toolbar)**: The eye button. Marks every upstream variable that neither box reads as internal, so it stops showing here; combines and rules still read it. Variables another Assembler on the same context reads are kept, and so is a module that also writes a variable in use. Undo in the message puts it back.
- **Clear (toolbar)**: Empties the template and the negative template, after asking.
<!-- fields:end -->
## How to use
+19 -12
View File
@@ -1,39 +1,46 @@
# WP Toggles
Several on/off switches in one node. Each toggle writes `$its_variable` as `true` / `false` (always internal) and can have a `BOOLEAN` output.
Several on/off switches in one node. Each toggle writes `$its_variable` as `true` / `false` (internal unless you turn off its globe) and can have a `BOOLEAN` output.
<!-- fields:start -->
## On the node
- **upstream (in)** `context`: Optional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).
- **Pencil (toolbar)**: Select the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the grips to reorder, a pencil per row, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.
- **Pencil (toolbar)**: Select the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.
- **Any on / Pick one**: Any on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.
- **writes (Pick one)**: Optional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.
- **Pencil (row)**: Opens the toggle's edit panel: name, variable, style, needs, what off mutes or bypasses, group and its output.
- **Add toggle / Add label**: A new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it.
- **Globe (row)**: Edit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.
- **Show in Assembler (Pick one)**: The picked variable shows in the Assembler and can print in the prompt. Off, it is internal.
- **Style (Pick one)**: How every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.
- **Layout**: Edit view: Rows (one row per toggle, the default) or Grid (each toggle in a tile, the tiles wrapping to the node's width), in both modes and every style. Each label has a grid button for its own group; setting it back to the node's layout makes the group follow the node again. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name, BOOLEAN outputs on the right with theirs, each side packed on its own (so Dawn | Dawn, Sunset | Night). The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), and the arrow on the merged line opens them again.
- **Pencil, globe, trash (row)**: Each row in the edit view has three buttons, like a module on a Context node: the pencil opens the toggle's own settings in a window (name, variable, looks, style, needs, what off mutes or bypasses, group and its output) with the row drawn on top as the node will show it; the globe keeps its variable internal; the trash removes it.
- **Add toggle / Add label**: A new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it; its icon and colour buttons pick any icon and any colour. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.
- **control dots (in)**: A toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.
- **context (out)** `context`: The Context with every toggle variable added, all internal.
- **context (out)** `context`: The Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler.
- **toggle outputs** `BOOLEAN`: One BOOLEAN per toggle that has an output, at the right of its row. Up to 16.
## Editing
- **Name**: Shown on the row and on its output.
- **Variable**: Gets true or false. It follows the name until you change it. Always internal, so it never prints in the prompt. Show puts it under the name in the clean view.
- **Style**: Switch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name).
- **Value (Pick one)**: What the picked variable gets when this toggle is picked, instead of its name. It can read $variables from upstream.
- **Needs**: Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.
- **Name**: Shown on the row and on its output. A Chip shows Name · $variable (the variable while Show is on). A name colour also colours its variable.
- **Variable**: Gets true or false, or the toggle's Value while on and nothing while off. $name.bool is always true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name, or after it on a Chip.
- **Looks**: An icon before the name, and colours for the icon, the name and the control (switch, check, radio, chip or buttons), so Day can be yellow and Night blue. None keeps the theme's colour.
- **Style**: Switch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Any on only: in Pick one the Style in the top bar sets every row's look.
- **Value**: What the toggle's variable gets while it is on, instead of true; off, it gets its Off value (nothing when that is empty), so $rain can print "heavy rain" or nothing. $rain.bool still reads true or false, for rules. In Pick one the picked variable gets it too, instead of the toggle's name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller.
- **Negative**: Words to keep out of the image while this toggle is on. They go with its variable (and, in Pick one, with the picked variable), so an Assembler that prints it sends them to its negative output, like a wildcard option's negative. It can read $variables; off, or without one, the variable carries none.
- **Off value (Any on)**: What the variable gets while the toggle is off, with its own Off negative. Empty prints nothing. With both, one toggle writes either side: a Buttons toggle Anime / Real can write "anime style" or "photorealistic", each keeping the other's look out of the image. Either one makes the variable hold text, not true or false; $name.bool still reads the switch.
- **Needs**: Any on only. Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.
- **When off**: Nothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).
- **Invert**: Mute or bypass while the toggle is ON instead, for a node used only as a switchboard.
- **Canvas groups**: Groups on the canvas whose nodes When off also covers, by title. No wires needed.
- **Upstream can set it**: When the upstream Context holds this toggle's variable as a yes/no word (true, yes, on, 1, false, no, off, 0), that value sets the switch for the run.
- **Group**: Which label it sits under.
- **Group**: Which label it sits under. You can also drag the toggle's grip under another label, or above the first one.
- **BOOLEAN output**: Show an output for switch nodes. Off for a toggle only rules read.
<!-- fields:end -->
## Toggles
- **Any on**: each toggle is on or off by itself. A toggle that **needs** another is off while that one is off, and keeps its own choice for when it is on again.
- **Pick one**: only one toggle is on: the one the upstream picked variable names (with **Upstream can pick**), else the first one upstream set on, else the first one you switched on. Optional **writes** variable gets the picked toggle's **Value**, or its name; internal unless **Show in prompt**.
- **Pick one**: only one toggle is on: the one the upstream picked variable names (with **Upstream can pick**), else the first one upstream set on, else the first one you switched on. Optional **writes** variable gets the picked toggle's **Value**, or its name; internal unless **Show in Assembler**. Its **Style** (Radio, Chip, Switch, Check) sets every row's look.
- **When off**: Nothing, **Mute** or **Bypass** the nodes wired into the row's dot and any **Canvas groups** you pick, while the toggle is off (needs and Pick one count). Done on the canvas when the toggle flips.
- **Invert**: mute / bypass while the toggle is ON instead. An icon by the name shows a control is wired: coloured while it acts, dim while not; hover for the nodes.
- **Show** (by the variable): show the `$variable` under the name in the clean view.
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -66,7 +66,8 @@ CARDS: tuple[Card, ...] = (
"stack item the same filter is the use setting \"filter warm and not wet\". Tag the "
"source wildcard's options (\"tags\": [\"warm\"]) so the filter has something to "
"match. \"Options using only warm colours from Palette\" means options like "
"\"a @{Palette:warm} sky\", never the colours copied in.",
"\"a @{Palette:warm} sky\", never the colours copied in. A $var term reads the run: "
"@{Setting:$model_variant} keeps options tagged pony on Pony.",
_w(r"\bonly (?:the )?\w+ (?:ones|options|colou?rs|items)\b|\btagged\b|\bby tags?\b|"
r"\bwith the tag\b|\bfilter\w*|\bonly uses?\b|@\{\w+:"),
offers=("add_options", "edit_options")),
+65 -25
View File
@@ -185,7 +185,7 @@
"sections": [
{
"title": "Colours",
"text": "- Orbit: Logo indigo on cool ink. The default.\n- Native: ComfyUI's own greys.\n- Nebula: Violet-tinted night with a violet accent.\n- Tide: Deep slate blue with a sky accent.\n- Ember: Warm charcoal and paper with a copper accent.\n- High contrast: Black and white, solid borders, a yellow focus ring.\nModule kind colours (wildcard, fixed value, combine and the rest), variable colours and the teal of a picked option stay the same in every theme, so they always mean the same thing."
"text": "- Orbit: Logo indigo on cool ink. The default.\n- Native: ComfyUI's own greys.\n- Nebula: Violet-tinted night with a violet accent.\n- Tide: Deep slate blue with a sky accent.\n- Ember: Warm charcoal and paper with a copper accent.\n- High contrast: Black and white, solid borders, a yellow focus ring.\n- ComfyUI palette: Your ComfyUI color palette, imported ones too. Switches when it does.\nModule kind colours (wildcard, fixed value, combine and the rest), variable colours and the teal of a picked option stay the same in every theme, so they always mean the same thing.\nComfyUI palette dresses the manager in the colour palette ComfyUI is using (ComfyUI Settings › Appearance › Color Palette), imported palettes and ones other extensions add included. On Dark and Light it wears Native, ComfyUI's own greys; on any other palette the pages, cards, borders and text come from the palette's node and text colours, with the text kept readable. The palette also decides dark or light, so the Mode setting and the top bar's theme button rest while it is the colour theme. Switch the palette in ComfyUI and an open manager tab follows at once; with no canvas open, it catches up when you come back to the tab. On Theme's own the accent is the palette's own when it has one that reads well, else indigo."
},
{
"title": "Look",
@@ -193,7 +193,11 @@
},
{
"title": "Accent colour",
"text": "Accent color starts on Theme's own, which follows the colour theme. The presets are Indigo, Violet, Teal, Rose and Amber, and Custom opens a picker for any colour. A preset or custom accent you pick is kept when you change the colour theme. High contrast keeps its own accent so its text stays readable; your pick comes back when you leave it.\nThemes change the manager, and the Community tab follows them: it takes the colour theme, the look and light or dark, and changes with them. The canvas nodes keep ComfyUI's colours, so they sit well next to other nodes."
"text": "Accent color starts on Theme's own, which follows the colour theme. The presets are Indigo, Violet, Teal, Rose and Amber, and Custom opens a picker for any colour. A preset or custom accent you pick is kept when you change the colour theme. High contrast keeps its own accent so its text stays readable; your pick comes back when you leave it.\nThemes change the manager, and the Community tab follows them: it takes the colour theme, the look and light or dark, and changes with them. The canvas nodes follow ComfyUI's colour palette instead (below), so they sit well next to other nodes."
},
{
"title": "On the canvas",
"text": "WP nodes on the canvas follow ComfyUI's Color Palette (ComfyUI Settings › Appearance). On Dark and Light they use their own dark and light colours. On any other palette (Solarized, Arc, Nord, Github, a palette another extension adds such as Obsidian or Milk White, or one you imported) their rows, fields, borders and text take the palette's node, widget and text colours, so a WP node looks like the nodes around it. A palette that is light counts as light even when it doesn't say so.\nText always stays readable: when a palette's text is too faint on its own widget colour, WP nodes use a stronger one. The accent, module kind colours, variable colours and warnings keep their meaning on every palette, in their dark or light shades. High contrast still wins over the palette.\nTo keep the WP colours on every palette, set Wildcard Pipeline › Sizing › Canvas colors (also in the Display playground and in the manager's Settings › Canvas) to Wildcard Pipeline colors."
}
]
},
@@ -378,6 +382,10 @@
"title": "Share modules as a file",
"text": "- In the manager open Import / Export → Export.\n- Tick what to share: the whole library, a kind, or single modules and bundles. Select with dependencies adds the wildcards they reference.\n- Download the file and send it.\nTo bring one in, use the Import tab: pick the file or paste its text, choose what to add, and decide what happens when a name already exists. An import can be undone from the message that follows."
},
{
"title": "Share with people who don't use Wildcard Pipeline",
"text": "Wildcard files (.zip), next to Export, writes the same picks as plain .txt wildcard files that Dynamic Prompts, Impact Pack, Prompt-PostProcessor, Easy-Use and A1111/Forge read: one file per wildcard, one option per line, in a folder named after what you picked. Use them as __folder/name__.\n- References to other wildcards become __folder/name__, and $hair becomes __folder/hair__. In those tools that is a new pick, not the same one.\n- Uneven weights become repeated lines, since those tools pick lines evenly.\n- Fixed values and combines get a one-line file each, templates go in templates/.\n- Rules (derivations, constraints), tags, the chance of nothing, fallbacks and negatives on options have no plain form. The zip's README.md lists what changed in each file.\nThe community site offers the same download on every pack."
},
{
"title": "Publish to the community",
"text": "On a module or bundle, the Publish to community button opens the publish form with everything filled in; the Export tab has the same button for a selection. Others find it in the Community tab and install it in one click, with the wildcards it needs.\nA pack that uses a newer feature asks people on an older version to update before it installs, so nothing gets dropped on the way."
@@ -1175,7 +1183,7 @@
},
{
"title": "What you can write",
"text": "Write | What it does\n$name | The variable's value. If the wildcard picked several options, all of them, joined by its separator.\n$name.0 | One of several picks, counting from 0: $colors.0 is the first. A number past the end prints nothing.\n$name.GROUP | The tag rolled from an accepts tag group: $outfit.SHOES is the shoe that goes with this run's outfit. It's rolled once, so it's the same everywhere. With several picks, $outfit.0.SHOES is the first one's. A group that isn't accepts prints nothing and gets a warning.\n$name.neg | The variable's negative words. See Negatives.\n@{…} | A fresh pick from another wildcard. Type @ to insert one; it shows as a chip. It can narrow the pick to some tags (warm or cool) or leave out the null option. Written out it looks like @{abcd1234#Mood:calm or intense!null}: the 8-letter id, then an optional name, filter and !null. More in Chips & references.\n{a|b|c} | Picks one part each run. {3::cat|dog} makes cat three times as likely.\n{2$$a|b|c} | Picks two different parts, joined by a comma. {2$$ and $$a|b|c} joins them with \" and \"; {1-3$$a|b|c} picks between one and three. Add ~ ({2~$$a|b|c}) to allow the same part twice.\n$$ · @@ · {{ | Print a plain $, @ or {.\nThese nest: a part of {…} can hold a reference or another choice, such as {a @{abcd1234} cat|{2$$, $$red|blue|green} sky}.\nNaming a variable: letters, digits and underscores, starting with a letter or underscore, up to 64 characters. Names starting with two underscores are kept for the engine."
"text": "Write | What it does\n$name | The variable's value. If the wildcard picked several options, all of them, joined by its separator.\n$name.0 | One of several picks, counting from 0: $colors.0 is the first. A number past the end prints nothing.\n$name.GROUP | The tag rolled from an accepts tag group: $outfit.SHOES is the shoe that goes with this run's outfit. It's rolled once, so it's the same everywhere. With several picks, $outfit.0.SHOES is the first one's. A group that isn't accepts prints nothing and gets a warning.\n$name.neg | The variable's negative words. See Negatives.\n$name.bool | A WP Toggles variable's switch, true or false, even when the toggle writes its own Value.\n@{…} | A fresh pick from another wildcard. Type @ to insert one; it shows as a chip. It can narrow the pick to some tags (warm or cool) or leave out the null option. Written out it looks like @{abcd1234#Mood:calm or intense!null}: the 8-letter id, then an optional name, filter and !null. More in Chips & references.\n{a|b|c} | Picks one part each run. {3::cat|dog} makes cat three times as likely.\n{2$$a|b|c} | Picks two different parts, joined by a comma. {2$$ and $$a|b|c} joins them with \" and \"; {1-3$$a|b|c} picks between one and three. Add ~ ({2~$$a|b|c}) to allow the same part twice.\n$$ · @@ · {{ | Print a plain $, @ or {.\nThese nest: a part of {…} can hold a reference or another choice, such as {a @{abcd1234} cat|{2$$, $$red|blue|green} sky}.\nNaming a variable: letters, digits and underscores, starting with a letter or underscore, up to 64 characters. Names starting with two underscores are kept for the engine."
},
{
"title": "Which syntax works where",
@@ -1232,7 +1240,7 @@
},
{
"title": "Writing a filter",
"text": "A filter is a short sentence of tag names joined by not, and and or. Each option is checked against its own tags and stays in when the sentence is true.\nWrite | Keeps options that\nwarm | carry the warm tag\nnot warm | don't carry warm (options with no tags at all count)\nwarm and bright | carry both\nwarm or cool · warm, cool | carry either (a comma is short for or)\nnot warm and (low or high) | don't carry warm, and carry low or high\nnot (warm or cool) | carry neither\n- Order: not goes first, then and, then or. So a or b and c means a or (b and c). Use parentheses whenever you mean something else; they nest to any depth.\n- Operators are lowercase words. Symbols like !, & or | are not operators, and ! is reserved for the !null flag. Write not warm, never !warm.\n- Tag names match exactly, including case. A name can't contain spaces or any of ( ) ! , # : { } @ $, and and, or, not and null are reserved.\n- Null is a flag, not a term. To keep the null option out, tick Exclude null (the !null segment); writing not null in the expression is an error.\nThe filter picker checks the expression as you type. It shows how the expression reads as (fully parenthesised where it matters), how many options match, and flags an unknown sub-category with a did you mean fix. If a valid filter matches no option, the reference prints nothing when you run and raises a Filter matched no options warning that lists the tags the wildcard actually has (see Warning & conflict types)."
"text": "A filter is a short sentence of tag names joined by not, and and or. Each option is checked against its own tags and stays in when the sentence is true.\nWrite | Keeps options that\nwarm | carry the warm tag\nnot warm | don't carry warm (options with no tags at all count)\nwarm and bright | carry both\nwarm or cool · warm, cool | carry either (a comma is short for or)\nnot warm and (low or high) | don't carry warm, and carry low or high\nnot (warm or cool) | carry neither\n$model_variant | carry the tag the variable holds when the prompt runs (e.g. pony), plus options with no tag from that tag's group\n- Order: not goes first, then and, then or. So a or b and c means a or (b and c). Use parentheses whenever you mean something else; they nest to any depth.\n- Operators are lowercase words. Symbols like !, & or | are not operators, and ! is reserved for the !null flag. Write not warm, never !warm.\n- Tag names match exactly, including case. A name can't contain spaces or any of ( ) ! , # : { } @ $, and and, or, not and null are reserved.\n- A $variable is read when the prompt runs. Its value is compared with the tags like a tag name, ignoring case and with spaces as _ (\"Light rain\" finds light_rain). Options with no tag from the value's group stay, so untagged options work for every value. When the variable isn't set, or its value is no tag here, that part of the filter is left out and the run notes it. A multi-pick list keeps the options tagged with any of its picks. The match count is worked out before the variables.\n- Null is a flag, not a term. To keep the null option out, tick Exclude null (the !null segment); writing not null in the expression is an error.\nThe filter picker checks the expression as you type. It shows how the expression reads as (fully parenthesised where it matters), how many options match, and flags an unknown sub-category with a did you mean fix. If a valid filter matches no option, the reference prints nothing when you run and raises a Filter matched no options warning that lists the tags the wildcard actually has (see Warning & conflict types)."
},
{
"title": "Works with",
@@ -1652,7 +1660,7 @@
},
{
"title": "Row options",
"text": "Right-click a row → Edit for its template and negative.\nName\nThe $variable the value becomes. Letters, digits and underscores, starting with a letter.\nTemplate\nOptional. Wrap the value in text, e.g. wearing $input_0. Leave empty to use the value as it is. $$ prints a dollar sign.\nNegative\nOptional negative words for this variable (it can use $input_0 too, so another node can write it). Only reaches an Assembler that prints the variable. Rows with one show a NEG badge.\nHide from prompt (globe)\nThe variable still reaches combines and derivations, but prints nothing in the prompt."
"text": "Right-click a row → Edit for its template and negative.\nName\nThe $variable the value becomes. Letters, digits and underscores, starting with a letter.\nTemplate\nOptional. Wrap the value in text, e.g. wearing $input_0. Leave empty to use the value as it is. $$ prints a dollar sign. Inline {a|b} picks need a seed, so they print as written (the editor warns).\nNegative\nOptional negative words for this variable (it can use $input_0 too, so another node can write it). Only reaches an Assembler that prints the variable. Rows with one show a NEG badge.\nHide from prompt (globe)\nThe variable still reaches combines and derivations, but prints nothing in the prompt."
},
{
"title": "Join several inputs",
@@ -1694,6 +1702,10 @@
"title": "LoRAs per model",
"text": "In the derivation editor, LoRA by model adds a ready-made rule: if $model_variant is pony, set $loras to a Pony LoRA tag; if illustrious, an Illustrious one; otherwise leave it empty. Swap the placeholder LoRA names for yours, add branches for other variants, and put $loras in your Assembler template. The same pattern works for quality tags, negatives or anything else a derivation can write."
},
{
"title": "Options per model in one wildcard",
"text": "When one wildcard should offer different options per model, you don't need a constraint. Tag its options with the variants (pony, illustrious) and pick $model_variant under By variable in its Pool. Each run keeps the options tagged with the loaded model, plus options with no model tag. Constraints on that wildcard still apply on top."
},
{
"title": "Constraints from the model",
"text": "Constraints react to a wildcard's pick, not to a variable, so the model reaches them through a wildcard. Make a wildcard (say Model) with options pony and illustrious, tag them, add it to your Context, and in its node settings set Match variable to model_variant. Instead of rolling, it picks the option whose text equals the detected variant, and any constraint can use it as its source, for example “if Model is pony, exclude photo styles”.\nMark one option of that wildcard as the fallback (for example “other”), so checkpoints your rules don't recognise still get a predictable pick. Without a fallback the wildcard rolls as usual and WP Debug notes that nothing matched."
@@ -1728,7 +1740,7 @@
"sections": [
{
"title": "What it's for",
"text": "WP Toggles replaces a stack of Boolean nodes. Each toggle writes $its_variable as true or false, so a derivation can branch on it without a Context Injector, and can show a BOOLEAN output at the right of its row for a switch node.\nFigure: Hires fix is on and Quick preview off: their BOOLEAN outputs feed ComfyUI's If/Else Switch and two Preview as Text nodes, which print what the run sent (True, False, and the switch's pick). Detailer is off, so the text node wired into its dot is muted. Screenshot after a run. A WP Toggles node in its clean view with a Render label and three toggles: Hires fix (a switch, on, $hires), Quick preview (off, $quick_preview) and Detailer (off, $detailer, with a mute icon by its name). A Detailer prompt text node is wired into the dot at the left of the Detailer row and shows Muted. Hires fix and Quick preview each have a BOOLEAN output at the right of their row. Hires fix feeds the switch input of an If/Else Switch whose on_true is 'upscaled 2x, then refined' and on_false 'base size only'; the switch's output goes to a Preview as Text that prints 'upscaled 2x, then refined'. Two more Preview as Text nodes print True (Hires fix) and False (Quick preview).\n- upstream (in)Optional Context to extend.\n- context (out)The Context with every toggle variable added, all internal.\n- toggle outputsOne BOOLEAN per toggle that has an output, at the right of its row.\n- control dots (in)A toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it."
"text": "WP Toggles replaces a stack of Boolean nodes. Each toggle writes $its_variable as true or false, so a derivation can branch on it without a Context Injector, and can show a BOOLEAN output at the right of its row for a switch node.\nFigure: Hires fix is on and Quick preview off: their BOOLEAN outputs feed ComfyUI's If/Else Switch and two Preview as Text nodes, which print what the run sent (True, False, and the switch's pick). Detailer is off, so the text node wired into its dot is muted. Screenshot after a run. A WP Toggles node in its clean view with a Render label and three toggles: Hires fix (a switch, on, $hires), Quick preview (off, $quick_preview) and Detailer (off, $detailer, with a mute icon by its name). A Detailer prompt text node is wired into the dot at the left of the Detailer row and shows Muted. Hires fix and Quick preview each have a BOOLEAN output at the right of their row. Hires fix feeds the switch input of an If/Else Switch whose on_true is 'upscaled 2x, then refined' and on_false 'base size only'; the switch's output goes to a Preview as Text that prints 'upscaled 2x, then refined'. Two more Preview as Text nodes print True (Hires fix) and False (Quick preview).\n- upstream (in)Optional Context to extend.\n- context (out)The Context with every toggle variable added.\n- toggle outputsOne BOOLEAN per toggle that has an output, at the right of its row.\n- control dots (in)A toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it."
},
{
"title": "When to use it",
@@ -1736,11 +1748,19 @@
},
{
"title": "Controls",
"text": "upstream (in)\nOptional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).\nPencil (toolbar)\nSelect the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the grips to reorder, a pencil per row, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.\nAny on / Pick one\nAny on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.\nwrites (Pick one)\nOptional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.\nPencil (row)\nOpens the toggle's edit panel: name, variable, style, needs, what off mutes or bypasses, group and its output.\nAdd toggle / Add label\nA new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it.\ncontrol dots (in)\nA toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.\ncontext (out)\nThe Context with every toggle variable added, all internal.\ntoggle outputs\nOne BOOLEAN per toggle that has an output, at the right of its row. Up to 16."
"text": "upstream (in)\nOptional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below).\nPencil (toolbar)\nSelect the node and the indigo pencil in ComfyUI's selection toolbar opens the edit view: the mode, the Pick one options, the grips to reorder, the row buttons, Add toggle and Add label. Done goes back to the clean view, which shows only the switches.\nAny on / Pick one\nAny on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons.\nwrites (Pick one)\nOptional variable that also gets the picked toggle's value (or its name), so one rule can branch on it.\nGlobe (row)\nEdit view only. On (the default) keeps the toggle's variable internal: rules read it, the Assembler doesn't offer it and it never prints. Turn it off to use the variable in a prompt. Same in Any on and Pick one.\nShow in Assembler (Pick one)\nThe picked variable shows in the Assembler and can print in the prompt. Off, it is internal.\nStyle (Pick one)\nHow every row looks in Pick one: Radio (a round dot, the default), Chip (the name across the row, lit when picked), Switch or Check. Turning one on turns the others off.\nLayout\nEdit view: Rows (one row per toggle, the default) or Grid (each toggle in a tile, the tiles wrapping to the node's width), in both modes and every style. Each label has a grid button for its own group; setting it back to the node's layout makes the group follow the node again. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name, BOOLEAN outputs on the right with theirs, each side packed on its own (so Dawn | Dawn, Sunset | Night). The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), and the arrow on the merged line opens them again.\nPencil, globe, trash (row)\nEach row in the edit view has three buttons, like a module on a Context node: the pencil opens the toggle's own settings in a window (name, variable, looks, style, needs, what off mutes or bypasses, group and its output) with the row drawn on top as the node will show it; the globe keeps its variable internal; the trash removes it.\nAdd toggle / Add label\nA new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it; its icon and colour buttons pick any icon and any colour. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.\ncontrol dots (in)\nA toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it.\ncontext (out)\nThe Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler.\ntoggle outputs\nOne BOOLEAN per toggle that has an output, at the right of its row. Up to 16."
},
{
"title": "Editing a toggle",
"text": "Name\nShown on the row and on its output.\nVariable\nGets true or false. It follows the name until you change it. Always internal, so it never prints in the prompt. Show puts it under the name in the clean view.\nStyle\nSwitch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name).\nValue (Pick one)\nWhat the picked variable gets when this toggle is picked, instead of its name. It can read $variables from upstream.\nNeeds\nAnother toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.\nWhen off\nNothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).\nInvert\nMute or bypass while the toggle is ON instead, for a node used only as a switchboard.\nCanvas groups\nGroups on the canvas whose nodes When off also covers, by title. No wires needed.\nUpstream can set it\nWhen the upstream Context holds this toggle's variable as a yes/no word (true, yes, on, 1, false, no, off, 0), that value sets the switch for the run.\nGroup\nWhich label it sits under.\nBOOLEAN output\nShow an output for switch nodes. Off for a toggle only rules read.\nDrag the grip to reorder. A label moves with its toggles. Moving a row never re-points a wire: each output stays with its toggle."
"text": "Name\nShown on the row and on its output. A Chip shows Name · $variable (the variable while Show is on). A name colour also colours its variable.\nVariable\nGets true or false, or the toggle's Value while on and nothing while off. $name.bool is always true or false. It follows the name until you change it. Internal until you turn off the row's globe. Show puts it under the name, or after it on a Chip.\nLooks\nAn icon before the name, and colours for the icon, the name and the control (switch, check, radio, chip or buttons), so Day can be yellow and Night blue. None keeps the theme's colour.\nStyle\nSwitch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Any on only: in Pick one the Style in the top bar sets every row's look.\nValue\nWhat the toggle's variable gets while it is on, instead of true; off, it gets its Off value (nothing when that is empty), so $rain can print \"heavy rain\" or nothing. $rain.bool still reads true or false, for rules. In Pick one the picked variable gets it too, instead of the toggle's name. A text box like the Assembler's: $ lists every variable upstream (Context Injector ones included) and the toggles' own, and tag, LoRA and embedding autocomplete work when they are on. A $name nothing upstream sets is flagged under the box and in WP Debug. Inline {a|b} picks need a seed, so they print as written; roll them in a wildcard instead. Drag the corner to make it taller.\nNegative\nWords to keep out of the image while this toggle is on. They go with its variable (and, in Pick one, with the picked variable), so an Assembler that prints it sends them to its negative output, like a wildcard option's negative. It can read $variables; off, or without one, the variable carries none.\nOff value (Any on)\nWhat the variable gets while the toggle is off, with its own Off negative. Empty prints nothing. With both, one toggle writes either side: a Buttons toggle Anime / Real can write \"anime style\" or \"photorealistic\", each keeping the other's look out of the image. Either one makes the variable hold text, not true or false; $name.bool still reads the switch.\nNeeds\nAny on only. Another toggle this one needs. While that one is off, this one is off too; it keeps your choice for when it comes back on.\nWhen off\nNothing, Mute or Bypass: what happens to the nodes wired into the row's dot (and to the canvas groups you pick) while the toggle is off. On, they run. When several toggles drive one node, off wins: it runs only while all of them let it (Mute beats Bypass).\nInvert\nMute or bypass while the toggle is ON instead, for a node used only as a switchboard.\nCanvas groups\nGroups on the canvas whose nodes When off also covers, by title. No wires needed.\nUpstream can set it\nWhen the upstream Context holds this toggle's variable as a yes/no word (true, yes, on, 1, false, no, off, 0), that value sets the switch for the run.\nGroup\nWhich label it sits under. You can also drag the toggle's grip under another label, or above the first one.\nBOOLEAN output\nShow an output for switch nodes. Off for a toggle only rules read.\nDrag the grip to reorder: the row you drop beside opens a gap with a line. An indented line in a group's colour puts the toggle in that group (a folded one opens); a full-width line, just above a label or below the last row, puts it below the group, in no group. A label moves with its toggles and lands only between groups. Moving a row never re-points a wire: each output stays with its toggle."
},
{
"title": "Icons and colours",
"text": "Give a toggle an icon and its own colours under Looks in its window: one for the icon, one for the name and one for the control itself, so Day can glow yellow and Night blue. A label's icon and colour buttons do the same for the group, and its toggles wear that colour on their left edge. The row at the top of the toggle's window shows each change before you press Done."
},
{
"title": "Use a toggle in the prompt",
"text": "A toggle's variable is internal by default: rules read it, but the Assembler doesn't offer it, since true or false rarely belongs in a prompt. To print it, open the edit view and turn off the globe on its row. In Pick one, Show in Assembler does the same for the picked variable, so $time can print the picked toggle's Value.\nGive a toggle a Value and its variable holds that text while it is on and nothing while it is off: a Rain toggle with the Value \"heavy rain\" makes $rain print \"heavy rain\" or nothing. The switch itself is still there as $rain.bool, true or false, for rules and conditions. Its Negative goes with the variable while it is on, so an Assembler that prints $rain also sends \"sunny, dry\" to its negative output.\nIn Any on a toggle can also say what to write while it is off: its Off value and Off negative. A Buttons toggle Anime / Real with the Value \"anime style\" and the Off value \"photorealistic\" writes one or the other, and each side's negative keeps the other look out. Leave them empty and off prints nothing, as before."
},
{
"title": "Mute or bypass nodes",
@@ -1748,7 +1768,11 @@
},
{
"title": "Pick one",
"text": "One toggle is on. Which one, first match wins: the toggle the upstream picked variable names (with Upstream can pick), then the first toggle upstream set to a yes word, then the first one you switched on. The picked variable gets the toggle's Value, or its name; it is internal unless Show in prompt is on."
"text": "One toggle is on. Which one, first match wins: the toggle the upstream picked variable names (with Upstream can pick), then the first toggle upstream set to a yes word, then the first one you switched on. The picked variable gets the toggle's Value, or its name; it is internal unless Show in Assembler is on. A toggle's Negative, under its Value, goes with the picked variable: an Assembler that prints it sends those words to its negative output. All rows share one look here: the Style next to those boxes picks Radio (the default), Chip, Switch or Check, so a toggle's own Style and Needs are hidden."
},
{
"title": "Rows or a grid",
"text": "The Layout switch in the edit bar draws the node as Rows (one row per toggle, the default) or as a Grid: each toggle in a tile, the tiles wrapping to the node's width, in both modes and every style. Each label has a grid button for its own group, so one group can be a grid while another stays rows. Under a grid's tiles, thin lines carry the dots: mute/bypass dots stack on the left with their toggle's name and BOOLEAN outputs on the right with theirs, each side packed on its own, so one line can hold two toggles. The arrow on the first line merges the group's lines into one that carries every dot and says how many go in and out (the names on hover), like the Context Injector's merged wires. Rows keep their dots on the row, and the edit view always shows rows so you can reorder them."
},
{
"title": "Merge the Boolean nodes you have",
@@ -1855,7 +1879,7 @@
},
{
"title": "On the canvas",
"text": "A bookmark has no node frame and can't be resized: it is a small pill with its icon, name and shortcut (a warning mark when something else uses the key, a pin when it is pinned). Hover it for where it lands. A double-click, or the indigo pencil in ComfyUI's selection toolbar while it is selected, opens the editor, and Done closes it. A new bookmark opens in the editor."
"text": "A bookmark has no node frame and can't be resized: it is a small pill with its icon, name and shortcut (a warning mark when something else uses the key, a pin when it is pinned). Hover it for where it lands. A double-click, or the indigo pencil in ComfyUI's selection toolbar while it is selected, opens the editor, and Done closes it. A new bookmark opens in the editor. Drag the editor's background (anywhere that isn't a control) to move it while it is open."
},
{
"title": "When to use it",
@@ -1898,6 +1922,10 @@
"title": "Write a template",
"text": "- Type $ and pick a variable, or click a chip in the variables strip to insert it at the cursor.\n- A variable nothing sets is underlined in amber and shows as a dashed chip, and prints nothing when you run. Click the chip to remove it from the template.\n- $$ prints a single dollar sign.\n- Save and Load in the toolbar keep templates in your library (see Templates).\nThe Assembler doesn't roll. {a|b|c} prints exactly as typed and @{…} prints nothing. Put choices in a wildcard or combine and print its variable.\nHow empty variables are tidied\nWhen a variable comes out empty, the commas around it and a joining word left with nothing after it (\"with\", \"and\") are removed. Your own words stay: with $hat, $scarf and $place empty, a girl wearing $hat with $scarf, $pose, in the $place gives \"a girl wearing, sitting\". Write templates that still read well when something is empty (put the verb inside a combine), and use WP Prompt Cleaner to remove duplicates. Emoticon tags such as :d are kept."
},
{
"title": "Hide the variables you don't use",
"text": "A big Context offers many variables, and most of them feed other variables rather than the prompt. The eye button in the variables toolbar (beside Load, Save and Clear) marks every variable that neither this template nor the negative template reads as internal, so the chip strip and the $ list only offer what you print.\n- Internal only hides a variable from Assemblers. Combines, rules, constraints and the Var nodes still read it, so $hero used inside $scene keeps working.\n- Every other Assembler on the same context is checked first: a variable one of them prints ($x.0, $x.AXIS and $x.neg count) is kept.\n- A Context module has one internal switch for all the variables it writes, so a fixed values module that also writes a variable in use is left as it is.\n- It flips the same switches you would: the globe on a module, an Injector row, a toggle, a Model Info row or a Loop variable. The message says what it marked and what it kept and why; Undo in it puts everything back.\n- An Assembler whose template comes from a wire can't tell what it uses, so nothing it can see is marked."
},
{
"title": "The negative output",
"text": "Wildcard options, fixed values, combines and derivations can carry negative words. The negative output collects them, but only for variables this template printed: an Assembler for a face detailer that prints just $face never gets the hair negatives.\nFigure: Hair, Weather and Quality feed an Assembler with both outputs wired. The negative preview shows the words collected from the variables the template printed. Screenshot of a WP Context (seed 3) holding Hair, Weather and Quality, wired into a WP Prompt Assembler whose prompt and negative outputs each feed a Preview as Text node. The template is '$quality, a woman with $hair, $weather'; the preview reads 'masterpiece, best quality, a woman with black hair, clear sky'. The negative template is empty ('just the variables' negatives'), and the negative preview, marked '1 from variables', reads 'lowres, bad anatomy', which came from the Quality fixed value. The negative Preview as Text prints 'lowres, bad anatomy'.\n- A new Assembler starts with $negatives: just the collected words. Add your standing negative around it, e.g. lowres, bad anatomy, $negatives.\n- Without $negatives the negative is only what you write, and an empty box is an empty negative. Workflows saved before this get $negatives added when they load, so they print the same.\n- Repeated tags are dropped, also when written with a weight like (blurry:1.2).\n- Clear template empties the negative template too.\nMore in Negatives."
@@ -2073,7 +2101,7 @@
"sections": [
{
"title": "What it's for",
"text": "Wire a Context's output into a WP Debug. After each Generate it shows everything that reached that point. It has no output and changes nothing.\nFigure: The Scene Context feeding WP Debug after a run. The Variables tab lists each variable, its value, its tags and which module set it. Screenshot after a run. A folded WP Context · Character feeds WP Context · Scene (seed 42, its Scene bundle open: constraint, Setting, Time of day, Lighting from time, Scene line, Style), whose context goes into a WP Debug. The Debug header reads seed 42, 11 steps, 2 nodes, clean run, with tabs Variables (9), Trace (11), Warnings and Raw, a negatives count and a Filter variables box. The Variables tab lists $hero, $hair, $eyes, $outfit, $setting and $time with their values, tags and the module that set each.\n- context (in)Any WP node's context output.\n- VariablesEvery $variable and its value at this point, with the module that set it.\n- TraceEvery module in the order it ran, grouped by Context.\n- WarningsWhat came up during the run, such as missing variables or a constraint that changed nothing.\n- RawEverything as JSON, with a download button."
"text": "Wire a Context's output into a WP Debug. After each Generate it shows everything that reached that point. It has no output and changes nothing.\nFigure: The Scene Context feeding WP Debug after a run. The Variables tab lists each variable, its value, its tags and which module set it. Screenshot after a run. A folded WP Context · Character feeds WP Context · Scene (seed 42, its Scene bundle open: constraint, Setting, Time of day, Lighting from time, Scene line, Style), whose context goes into a WP Debug. The Debug header reads seed 42, 11 steps, 2 nodes, clean run, with tabs Variables (9), Trace (11), Warnings and Raw, a negatives count and a Filter variables box. The Variables tab lists $hero, $hair, $eyes, $outfit, $setting and $time with their values, tags and the module that set each.\n- context (in)Any WP node's context output.\n- VariablesEvery $variable and its value at this point, with the module or node that set it (its icon and colour on the left edge say which kind: wildcard, derivation, Toggles, Model Info, Context Loop, Injector …).\n- TraceEvery module and node row in the order it ran, grouped by the node it ran in.\n- WarningsWhat came up during the run, such as missing variables or a constraint that changed nothing.\n- RawEverything as JSON, with a download button."
},
{
"title": "When to use it",
@@ -2085,7 +2113,15 @@
},
{
"title": "The tabs",
"text": "Variables\nEvery $variable and its value at this point, with the module that set it. The quickest way to check what your wildcards picked. Negative words show under the value in red, with the module they came from when it isn't the variable's own.\nTrace\nEvery module in the order it ran, grouped by Context. Open one to see why it did what it did: a wildcard's odds and the constraints that changed them, which derivation branch ran, and the negative each one left.\nWarnings\nWhat came up during the run, such as missing variables or a constraint that changed nothing. Look here first when something seems off.\nRaw\nEverything as JSON, with a download button. Handy for bug reports.\nThe filter box at the top of each tab searches by variable name or warning."
"text": "Variables\nEvery $variable and its value at this point, with the module or node that set it (its icon and colour on the left edge say which kind: wildcard, derivation, Toggles, Model Info, Context Loop, Injector …). The quickest way to check what your wildcards picked. Negative words show under the value in red, with the module they came from when it isn't the variable's own.\nTrace\nEvery module and node row in the order it ran, grouped by the node it ran in. Open one to see why it did what it did: a wildcard's odds and the constraints that changed them, which derivation branch ran, the negative each one left, and for rows from other nodes where the value came from (a Loop frame and its seed, a Model Info detection, a Toggles switch set upstream).\nWarnings\nWhat came up during the run, such as missing variables or a constraint that changed nothing. Look here first when something seems off.\nRaw\nEverything as JSON, with a download button. Handy for bug reports.\nThe filter box at the top of each tab searches by variable name or warning."
},
{
"title": "What a trace step tells you",
"text": "Click a step in the Trace tab to see why it came out the way it did. Each kind says what matters for it:\n- Wildcard: the odds of the pick, the pool after each step (options → after the pool filter → after the node's option toggles → drawable), a locked or held seed, a multi-pick's count, whether repeats were allowed and what joins the picks, the value Match variable read, and the constraints that re-weighted it. Options lists every option with its weight (the node's override beside the library's), its chance, and why an option can't come up: filtered out, toggled off, excluded by a constraint, or weight 0. It opens by itself when nothing could be drawn.\n- Derivation: each rule, which branch fired, every test with the value it read, and each action.\n- Fixed values: whether the node's own rows or the library's were used, rows turned off, and a value's text before it was filled in.\n- Combine: the template and what each $variable in it read, with one nothing set flagged.\n- Constraint: its reach, how often it applied and which steps it re-weighted (click one to go there).\n- WP Context Loop: the frame, its seed and seed strategy, skipped frames and the sweep pins.\n- WP Model Info: the model class the family came from, the rule that matched the variant (or that none did) and the file name.\n- WP Toggles: the switch on the node, what an upstream variable said, a parent it needs that is off, and for Pick one which row was picked and why.\n- WP Context Injector: the input a value came from, and a template with what each $name filled with."
},
{
"title": "Rows from other nodes",
"text": "Besides the modules in your Context nodes, WP Debug shows what the other context nodes wrote. The Variables tab groups the variables by the node that set them, under the node's title (and a Context's codename), and each node's rows show what matters for that node. The Trace tab stays the run itself: every row is a numbered step in the order it ran, and these nodes differ only by their icon, colour and a short note on where the value came from:\n- WP Context Loop (orange): $iteration, $iteration_total and $iteration_value, under a line saying which frame this is, the frame's seed when the Loop sets seeds (click to copy), the sweep combination and the value line.\n- WP Model Info (lime): Family, Variant and Name, each saying where it came from: the model, a file-name rule, a pin, a wire or the loader's file.\n- WP Toggles (indigo): each toggle's variable as true / false, or its value text when the toggle has one (empty while off), with its .bool state and negative under it. In Pick one, each pick still has its own on/off variable (true is the picked one) and the Pick one variable holds the picked value. The source says when an upstream variable set a toggle.\n- WP Context Injector (blue): each wired value with its type (STR, INT, FLOAT, BOOL, or TPL for a template).\nShow node (in a step's details, a row's right-click menu, or the arrow on a node row) selects the node on the canvas and makes the module or field that row came from blink: a Context's module, a Model Info field, a toggle, an Injector row, or the Loop's iteration field. A collapsed node, a folded bundle, a folded Toggles group or the Injector's folded row list opens first so you can see it. A node inside a subgraph opens that subgraph; the subgraph itself is left as it is.\nA long value shows its first three lines (a long list its first three items, a long negative one line) until you press Show all under it; Show less folds it again.\nTheir warnings (an unknown $variable in a Toggles value, a Model Info rule that couldn't be read, a skipped Injector name) show in the Warnings tab and on that node's row. A wildcard pinned by a Loop sweep says swept, one set to Match variable says which variable it matched, and a pool filter that reads a $variable shows the value it read. With a Loop that skips frames, the frame picker shows the Loop's own frame number."
},
{
"title": "Where to put it",
@@ -2188,7 +2224,7 @@
},
{
"title": "How it reads the text",
"text": "The node splits the text at spaces, commas, semicolons, pipes and slashes and checks each word (capitals don't matter). Other words are skipped and don't count for the index:\n- True: true, yes, on, 1\n- False: false, no, off, 0\n- Everything else (e.g. maybe, 1.5, hello) is ignored\nSo true maybe false has two: index 0 is true, index 1 is false.\nA wildcard with options on and off through WP Var → Bool switches any true/false input at random. Weights set how often."
"text": "The node splits the text at spaces, commas, semicolons, pipes and slashes and checks each word (capitals don't matter). Other words are skipped and don't count for the index:\n- True: true, yes, on, 1\n- False: false, no, off, 0\n- Everything else (e.g. maybe, 1.5, hello) is ignored\nSo true maybe false has two: index 0 is true, index 1 is false.\nA WP Toggles toggle with a Value holds that value in its variable, not true or false. Read its switch with .bool: $rain.bool is true while the toggle is on and false while it's off.\nA wildcard with options on and off through WP Var → Bool switches any true/false input at random. Weights set how often."
},
{
"title": "Works with",
@@ -2263,19 +2299,19 @@
},
{
"title": "Moving, sizing and editing",
"text": "Unpin a note to drag it like any node. A Card or Sticky resizes from its corner: the width is yours and the height follows the text. A Label sizes itself to its title. Double-click a note, press the indigo pencil in ComfyUI's selection toolbar while it is selected, or press Edit on a label's hover card to open the editor; Done goes back to reading.\nTone\nStep (a numbered circle), Icon (pick any icon from the list next to it) or Plain (no badge).\nColour\nPresets that follow the theme (Accent, Tip, Info, Warning, Danger, Teal, Violet, Neutral), or any colour from the picker.\nLook\nCard, Sticky or Label (above).\nPinned\nComfyUI's own pin: a pinned note can't be moved or resized by accident. New notes start pinned.\nTitle\nShown in bold at the top, or on the pill of a Label.\nText\nMarkdown, with the formatting bar below over it: headings (# to ####), bold, italic, strikethrough, code, bulleted, numbered and nested lists, task lists (- [x] / - [ ]), quotes and callouts (> [!TIP], > [!WARNING]), tables with column alignment (:--, :-:, --:), rules (---) and code blocks.\nKeys you type in the editor stay in the editor: Delete, Ctrl+Z and ComfyUI's other shortcuts don't reach the canvas."
"text": "Unpin a note to drag it like any node. A Card or Sticky resizes from its corner: the width is yours and the height follows the text. A Label sizes itself to its title. Double-click a note, press the indigo pencil in ComfyUI's selection toolbar while it is selected, or press Edit on a label's hover card to open the editor; Done goes back to reading. The editor on the node holds the note's settings: tone, look, colour, title and the bookmark settings. Drag the editor's background (anywhere that isn't a control) to move the note while it is open.\nThe text has its own window: press Edit text (Write text on an empty note). The Markdown editor is on the left and the note as it will read on the right, following what you type: the node's own card, with its look, tone, colour, title and width. A label shows its tag and, under it, the card that opens when you point at it. Done (or Ctrl+Enter) puts the text on the note; Cancel, Escape or × leaves the note as it was, and asks first when you changed something. The node shows the text's first line and how long it is.\nTone\nStep (a numbered circle), Icon (pick any icon from the list next to it) or Plain (no badge).\nColour\nPresets that follow the theme (Accent, Tip, Info, Warning, Danger, Teal, Violet, Neutral), or any colour from the picker.\nLook\nCard, Sticky or Label (above).\nPinned\nComfyUI's own pin: a pinned note can't be moved or resized by accident. New notes start pinned.\nTitle\nShown in bold at the top, or on the pill of a Label.\nText\nIts first line and length on the node; Edit text opens the text window, with the editor beside a live preview. Done (Ctrl+Enter) puts it on the note, Cancel or Escape leaves the note as it was. Markdown, with the formatting bar below over it: headings (# to ####), bold, italic, strikethrough, code, bulleted, numbered and nested lists, task lists (- [x] / - [ ]), quotes and callouts (> [!TIP], > [!WARNING]), tables with column alignment (:--, :-:, --:), rules (---) and code blocks.\nKeys you type in the editor stay in the editor: Delete, Ctrl+Z and ComfyUI's other shortcuts don't reach the canvas."
},
{
"title": "The formatting bar",
"text": "Figure: The editor with the link panel open: a docs page found by typing, one of its sections, and a button with an icon. A WP Note open in its editor next to a finished Step card. The editor has Tone (Step, Icon, Plain) with the step number 2, Look (Card, Sticky, Label) with the Pinned switch, a row of colour presets with the custom colour #ef4444, the Title Label each frame, and the Text with its formatting bar: undo and redo (both lit), bold, italic, strikethrough, code, bulleted and numbered list, quote, link (pressed), icon and a preview eye. The link panel is open: Docs page is picked, the page is WP Label & Grid and the section Set it up, the text is Set up the grid, the style is Button with the Th Large icon, and Colour is #22c55e (None follows the note), with Cancel and Insert link. Under it is the note's Markdown and a Done button. The card on the right, step 1 Load your model, reads Pick the checkpoint in the loader. $model_family follows it, so the prompt fits the model, with two link buttons: WP Model Info in the note's colour and Writing prompts for your model in green.\nUndo and redo Ctrl+Z / Ctrl+Y\nStep back or forward through your changes, typing and the bar's own edits alike (Ctrl+Shift+Z and the right-click Undo and Redo work too). Typing counts as one step until you pause, move the caret or start a new line. The steps last while the editor is open.\nBold, italic, strikethrough Ctrl+B / Ctrl+I\nWraps the selection in **, * or ~~.\nCode\nWraps it in backticks, for a $variable.\nLists and quote\nPuts - , 1. or > before each selected line (press again to take it off).\nLink Ctrl+K\nA Docs page (type to find any page of these docs, then pick a section or the whole page), a Web address, a Library button (below) or a Community pack (owner/pack, or paste the pack's address), drawn as a Button with an icon or as a Text link, in its own colour or the note's. The selected text becomes the link's text. It goes where the caret is when you press Insert link, so you can click in the text after opening the panel.\nIcon\nAny PrimeIcons icon, searchable, in a colour or the text's own, inserted where the caret is.\nPictures\nAdd a picture from a file, or paste or drop one into the text; it goes on a line of its own where the caret is. Or pick one the extension ships (the node toolbar with its ? help button). The panel lists the note's pictures with a Width slider (a share of the note), Own size and Remove.\nPreview (eye)\nShows the note as it will read."
"title": "The text window and its formatting bar",
"text": "Figure: The text window: the Markdown on the left with the link panel open (a docs page found by typing), the note as it will read on the right. Screenshot of a WP Note's text window, titled Load your model with a NOTE TEXT chip. On the left, under Write, the formatting bar has its link button pressed and the link panel open: Docs is picked, the page is Seeds & loops (Whole page), with an optional Text box, the Button style with the Book icon, Colour none (None follows the note), Cancel and Insert link; under it is the note's Markdown. On the right, under Preview, is the note as the node draws it: a Card with the accent edge, the step badge 1 and the title Load your model, then Pick the checkpoint in the loader. $model_family follows it, so the prompt fits the model, a bullet, a ticked and an open task, a tip callout Lock the seed before you compare two settings, and a WP Model Info button. At the bottom: Ctrl+Enter Done, Cancel and Done.\nUndo and redo Ctrl+Z / Ctrl+Y\nStep back or forward through your changes, typing and the bar's own edits alike (Ctrl+Shift+Z and the right-click Undo and Redo work too). Typing counts as one step until you pause, move the caret or start a new line. The steps last while the text window is open.\nBold, italic, strikethrough Ctrl+B / Ctrl+I\nWraps the selection in **, * or ~~.\nCode\nWraps it in backticks, for a $variable.\nLists and quote\nPuts - , 1. or > before each selected line (press again to take it off).\nLink Ctrl+K\nA Docs page (type to find any page of these docs, then pick a section or the whole page), a Web address, a Library button (below) or a Community pack (owner/pack, or paste the pack's address), drawn as a Button with an icon or as a Text link, in its own colour or the note's. The selected text becomes the link's text. It goes where the caret is when you press Insert link, so you can click in the text after opening the panel.\nIcon\nAny PrimeIcons icon, searchable, in a colour or the text's own, inserted where the caret is.\nPictures\nAdd a picture from a file, or paste or drop one into the text; it goes on a line of its own where the caret is. Or pick one the extension ships (the node toolbar with its ? help button, or any docs screenshot), with a search box to find it. The panel stays open after adding and lists the note's pictures with a Width slider (a share of the note), Own size and Remove."
},
{
"title": "Links and icons in the text",
"text": "The bar writes these for you; you can also type them. A note can hold any number of links, anywhere in the text. Any other kind of link shows as plain text.\n[Read more](https://example.com)\nA link that opens in a new tab.\n[Seeds](docs:seeds-and-loops)\nA link to that page of these docs.\n[The grid](docs:wp-label-grid#s-the-grid)\nA link to one section of it.\n[Start here](docs:quick-start){button pi-play}\nThe link drawn as a button with an icon. {button} alone has no icon.\n{button pi-play #rrggbb} [Read more](https://example.com){#rrggbb}\nA button or a text link in its own colour, a hex code like the colour field shows. Without one it takes the note's.\n[Add the pack](wp:library){button pi-download}\nA button that adds this workflow's bundles and modules to your library, after a review. Always a button.\n[Add the Scene lists](wp:library#node-12){button}\nThe same for one WP Context only (its node id; inside a subgraph the editor writes the subgraph's id too).\n[Get the pack](wp:community/owner/pack)\nOpens that published pack in the manager's Community tab, where it installs.\n:pi-heart: :pi-heart:{#rrggbb}\nAn icon, optionally in a colour: a hex code like the custom colour field shows.\n![What it shows](wp:image/1a2b3c4d)\nA picture saved in the note: the bar's Pictures panel, a paste or a drop writes it and its id.\n![The ? button](wp:asset/toolbar-help)\nA picture the extension ships.\n![What it shows](wp:image/1a2b3c4d){width=60%}\nThe picture as wide as 60% of the note (10 to 100). Without it, its own size, never wider than the note."
"text": "The bar writes these for you; you can also type them. A note can hold any number of links, anywhere in the text. Any other kind of link shows as plain text.\n[Read more](https://example.com)\nA link that opens in a new tab.\n[Seeds](docs:seeds-and-loops)\nA link to that page of these docs.\n[The grid](docs:wp-label-grid#s-the-grid)\nA link to one section of it.\n[Start here](docs:quick-start){button pi-play}\nThe link drawn as a button with an icon. {button} alone has no icon.\n{button pi-play #rrggbb} [Read more](https://example.com){#rrggbb}\nA button or a text link in its own colour, a hex code like the colour field shows. Without one it takes the note's.\n[Add the pack](wp:library){button pi-download}\nA button that adds this workflow's bundles and modules to your library, after a review. Always a button.\n[Add the Scene lists](wp:library#node-12){button}\nThe same for one WP Context only (its node id; inside a subgraph the editor writes the subgraph's id too).\n[Get the pack](wp:community/owner/pack)\nOpens that published pack in the manager's Community tab, where it installs.\n:pi-heart: :pi-heart:{#rrggbb}\nAn icon, optionally in a colour: a hex code like the custom colour field shows.\n![What it shows](wp:image/1a2b3c4d)\nA picture saved in the note: the bar's Pictures panel, a paste or a drop writes it and its id.\n![The ? button](wp:asset/toolbar-help)\nA picture the extension ships: the toolbar one, or any docs screenshot, named by its file.\n![What it shows](wp:image/1a2b3c4d){width=60%}\nThe picture as wide as 60% of the note (10 to 100). Without it, its own size, never wider than the note."
},
{
"title": "Pictures",
"text": "A note can show pictures: a screenshot of the step it explains, or the picture the extension ships of the node toolbar with its ? button, which opens any node's help page. Open Pictures in the bar and press Add a picture…, or paste or drop a picture straight into the text. Each one goes on a line of its own where the caret is, named by what you type in the box (or the file's name).\nFigure: A note with the shipped toolbar picture, and its editor with the Pictures panel: the note's pictures, each with a Width slider. Screenshot of a WP Note with pictures, and the same note open in its editor. The card on the left, Find the help page with a question-mark icon, reads Every WP node has a help page. Select the node and press the ? in its toolbar, then shows the picture the extension ships: ComfyUI's node toolbar over a WP Context · Character node, with the indigo ? button lit. Under it, The page opens next to the canvas, and a pasted screenshot of two chained Contexts at 70% of the note's width. On the right, the editor's formatting bar has its Pictures button pressed; the panel has a box for what the picture shows, Add a picture… (or paste or drop one into the text), From the extension with the toolbar picture, and In this note: Node toolbar with the ? help button at Own size and Two Contexts chained at 70%, each with a Width slider and a remove button, then a hint that pictures are saved in the workflow, made smaller, and never make the note bigger. Below is the note's Markdown with ![Two Contexts chained](wp:image/…){width=70%}.\nA picture is never wider than the note. Its width is a share of the note's, so it grows and shrinks with the note and never makes the note bigger; set it with the panel's Width slider, or Own size for its own width up to the note's. Remove takes it out (Undo brings it back while the editor is open).\nA pasted or dropped picture is saved in the workflow, so a shared workflow shows it too. It is made smaller first: at most 1280 px on its long side and about 400 KB, which also drops camera and location data. A note holds up to six pictures and 2 MB. Pictures never load from a web address, so opening a shared workflow fetches nothing."
"text": "A note can show pictures: a screenshot of the step it explains, or one the extension ships, such as the node toolbar with its ? button or any screenshot from these docs. Open Pictures in the bar and press Add a picture…, paste or drop a picture straight into the text, or pick one under From the extension: type part of its name in the search box and click it in the grid that shows up. Each one goes on a line of its own where the caret is, named by what you type in the box (or the picture's name). The panel stays open after you add one, so you can set its width right away.\nFigure: The text window with the Pictures panel: the pictures the extension ships, the note's own pictures with a Width slider, and the preview showing it. Screenshot of a WP Note with pictures, and the same note open in its editor. The card on the left, Find the help page with a question-mark icon, reads Every WP node has a help page. Select the node and press the ? in its toolbar, then shows the picture the extension ships: ComfyUI's node toolbar over a WP Context · Character node, with the indigo ? button lit. Under it, The page opens next to the canvas, and a pasted screenshot of two chained Contexts at 70% of the note's width. On the right, the editor's formatting bar has its Pictures button pressed; the panel has a box for what the picture shows, Add a picture… (or paste or drop one into the text), From the extension with a search box for the 53 pictures it ships (its grid shows only what matches), and In this note: Node toolbar with the ? help button at Own size and Two Contexts chained at 70%, each with a Width slider and a remove button, then a hint that pictures are saved in the workflow, made smaller, and never make the note bigger. Below is the note's Markdown with ![Two Contexts chained](wp:image/…){width=70%}.\nA picture is never wider than the note. Its width is a share of the note's, so it grows and shrinks with the note and never makes the note bigger; set it with the panel's Width slider, or Own size for its own width up to the note's. Remove takes it out (Undo brings it back while the text window is open).\nA pasted or dropped picture is saved in the workflow, so a shared workflow shows it too. It is made smaller first: at most 1280 px on its long side and about 400 KB, which also drops camera and location data. A note holds up to six pictures and 2 MB. Pictures never load from a web address, so opening a shared workflow fetches nothing."
},
{
"title": "An add-to-library button",
@@ -2283,7 +2319,7 @@
},
{
"title": "A note that is also a bookmark",
"text": "Turn on Also a bookmark at the bottom of the editor and the note joins the Bookmarks dock: pick where it lands on screen, its zoom and a shortcut, the same settings as a WP Bookmark, with the same clash check. A new one gets the next free shortcut. The dock shows it with the note's colour and icon, and a small bookmark mark on the note (its tooltip names the key) says it is one.\nFigure: A note open in its editor with Also a bookmark on, a note card with its bookmark mark, and the dock listing both next to a WP Bookmark. Screenshot of WP Notes used as bookmarks. On the left, the Bookmarks dock lists three rows: 1 Load your model (its step number 1 in a blue circle, Alt + 1, highlighted after a jump), 2 Sampler (an amber lightning icon, Alt + 3) and 3 Label each frame (its step number 2 in a red circle, Alt + 2). In the middle, the note Label each frame is open in its editor (Step 2, Card, colour #ef4444, its title and Markdown text); at the bottom the Also a bookmark switch is on, with the hint Jump here from the Bookmarks dock or a shortcut, and under it Lands at with the centre of a 3 × 3 grid picked (Centre of the screen, 24 px in) and a Use this view button, Zoom set to 1 ×, and the shortcut Alt + 2, then Done. On the right, the note card Load your model (step 1, blue) shows a small bookmark mark in its head next to the pencil, and below it a WP Bookmark, Sampler, as a pill: a lightning icon, its name and Alt + 3.\nUnlike a WP Bookmark, a note can't be hidden on the canvas (it is there to be read), and muting it turns its shortcut off without fading it."
"text": "Turn on Also a bookmark at the bottom of the editor and the note joins the Bookmarks dock: pick where it lands on screen, its zoom and a shortcut, the same settings as a WP Bookmark, with the same clash check. A new one gets the next free shortcut. The dock shows it with the note's colour and icon, and a small bookmark mark on the note (its tooltip names the key) says it is one.\nFigure: A note open in its editor on the node with Also a bookmark on, a note card with its bookmark mark, and the dock listing both. Screenshot of WP Notes used as bookmarks. On the left, the Bookmarks dock lists three rows: 1 Load your model (its step number 1 in a blue circle, Alt + 1, highlighted after a jump), 2 Sampler (an amber lightning icon, Alt + 3) and 3 Label each frame (its step number 2 in a red circle, Alt + 2). In the middle, the note Label each frame is open in its editor (Step 2, Card, colour #ef4444, its title and Markdown text); at the bottom the Also a bookmark switch is on, with the hint Jump here from the Bookmarks dock or a shortcut, and under it Lands at with the centre of a 3 × 3 grid picked (Centre of the screen, 24 px in) and a Use this view button, Zoom set to 1 ×, and the shortcut Alt + 2, then Done. On the right, the note card Load your model (step 1, blue) shows a small bookmark mark in its head next to the pencil, and below it a WP Bookmark, Sampler, as a pill: a lightning icon, its name and Alt + 3.\nUnlike a WP Bookmark, a note can't be hidden on the canvas (it is there to be read), and muting it turns its shortcut off without fading it."
},
{
"title": "Works with",
@@ -2320,7 +2356,7 @@
},
{
"title": "Change it for one node",
"text": "Open the row's editor on the node to change how this one use behaves. The library entry stays as it is.\nVariable binding\nThe variable this use writes. It starts as the library's; change it to use the same wildcard twice ($hair and $hair_b) without one overwriting the other.\nOption toggles\nUntick options this use may not pick. Leaving one ticked fixes the value, though a constraint can still rule it out (keep a fallback for that).\nPer-use weights\nChange an option's weight for this use only. The library editor doesn't show them.\nCategory filter\nOnly pick options with certain tags, e.g. \"feline and warm, not lynx\" (a comma means or). Click the tag pills or type it under Advanced.\nPick, separator, Allow repeats\nPick several options at once. See below.\nExclude null\nLeaves the null option out for this use, so something always prints.\nFallback\nUntick the fallback to switch it off for this use; then nothing prints when nothing is left.\nMatch variable\nInstead of rolling, pick the option whose text equals a variable's value (case doesn't matter), e.g. $model_variant from WP Model Info. No match uses the fallback, or rolls as usual. This is how a variable can drive a constraint.\nLock pick\nKeeps the same pick on every Generate and loop frame until you unlock it. Good for an art style you've settled on.\nHold across run\nThe same pick on every frame of one loop run, a new one next Generate. Good for a character who stays the same through a story.\nHide from prompt\nThe variable prints nothing, but constraints, combines and derivations can still use it.\nTo fix one value on a node, untick every other option or use Lock pick."
"text": "Open the row's editor on the node to change how this one use behaves. The library entry stays as it is.\nVariable binding\nThe variable this use writes. It starts as the library's; change it to use the same wildcard twice ($hair and $hair_b) without one overwriting the other.\nOption toggles\nUntick options this use may not pick. Leaving one ticked fixes the value, though a constraint can still rule it out (keep a fallback for that).\nPer-use weights\nChange an option's weight for this use only. The library editor doesn't show them.\nCategory filter\nOnly pick options with certain tags, e.g. \"feline and warm, not lynx\" (a comma means or). Click the tag pills or type it under Advanced. Pick a variable under By variable (or type $model_variant) to filter by its value when the prompt runs. A wildcard whose options have no tags says so in the Pool, since there is nothing to filter by.\nPick, separator, Allow repeats\nPick several options at once. See below.\nExclude null\nLeaves the null option out for this use, so something always prints.\nFallback\nUntick the fallback to switch it off for this use; then nothing prints when nothing is left.\nMatch variable\nInstead of rolling, pick the option whose text equals a variable's value (case doesn't matter), e.g. $model_variant from WP Model Info. No match uses the fallback, or rolls as usual. This is how a variable can drive a constraint.\nLock pick\nKeeps the same pick on every Generate and loop frame until you unlock it. Good for an art style you've settled on.\nHold across run\nThe same pick on every frame of one loop run, a new one next Generate. Good for a character who stays the same through a story.\nHide from prompt\nThe variable prints nothing, but constraints, combines and derivations can still use it.\nTo fix one value on a node, untick every other option or use Lock pick."
},
{
"title": "Pick several at once",
@@ -2338,6 +2374,10 @@
"title": "With constraints",
"text": "A Constraint can change this wildcard's odds based on another pick, such as making \"stormy\" likelier when the subject is \"ocean\". It must sit after the source wildcard and before this one.\nFigure: Source wildcard, then the constraint, then the target wildcard. The #1 badges link the constraint to its target. Screenshot of a WP Context (modules 3 / 3) wired into a WP Prompt Assembler. The Context rows, top to bottom: Starter subject ('$subject · 4 options'), Starter pairing, a constraint marked #1-all ('$subject → $mood · 2×2'), and Starter mood marked #1 ('$mood · 4 options'). The Assembler's template is 'a $mood $subject, masterpiece' and its preview reads 'a sleepy wolf, masterpiece'. It shows the order: source wildcard, then the constraint, then the target wildcard it changes.\nConstraints react to picks, not variables. To steer one from a variable (such as the loaded model), give a wildcard options that spell the variable's values and set Match variable on it. WP Model Info has an example."
},
{
"title": "One wildcard, different options per model",
"text": "To let a variable narrow a wildcard directly, tag its options with the variable's values and add the variable to the Pool's filter. For example, tag a Setting wildcard's options pony or illustrious (in a model tag group), then pick $model_variant under By variable in the Pool. Each run keeps the options tagged with the loaded model; options with no model tag stay for every model. Constraints on the wildcard still apply to what is left, so a Hero → Setting constraint keeps working.\nThe same works in a reference: @{Setting:$model_variant}. If the variable isn't set, or its value is no tag on the wildcard, that part of the filter is left out and WP Debug notes it. Match variable is still the way to make a variable the source of a constraint. See Chips & references for the filter rules."
},
{
"title": "Coming from A1111 or Forge?",
"text": "Your wildcard packs can be imported: see Import wildcard packs."
@@ -2566,11 +2606,11 @@
"sections": [
{
"title": "What a bundle is",
"text": "A bundle is a saved group of modules: a character, a wardrobe, a whole pack. Drop it into a WP Context and all its modules arrive at once, in order. In the stack it shows as one frame you can collapse to keep long setups tidy.\nWhen the pipeline runs, a bundle's modules run exactly as if they sat in the stack on their own. The bundle itself adds nothing; it only groups.\nFigure: A WP Context with the Starter set bundle expanded and a loose Pose module below it. Screenshot of a WP Context (seed 42, modules 7 / 7) wired into a WP Prompt Assembler. The Context holds the Starter set bundle (6 mods), expanded inside a frame: Starter subject, Starter pairing, Starter mood, Starter style, Starter scene and Starter accent. Below the bundle is a loose Pose wildcard. The Assembler's template is '$scene, $pose, $style, $accent' with 4 upstream chips, and the preview reads 'sleepy wolf, standing, oil painting, soft lighting'.\nGood for\n- A set of modules you reuse together: a character, a wardrobe, a whole pack\n- Sub-bundles for the sections of a pack\nBetter with something else\n- Keeping two copies apart: a bundle doesn't scope its variables. Insert it twice and rename the second copy's variables"
"text": "A bundle is a saved group of modules: a character, a wardrobe, a whole pack. Drop it into a WP Context and all its modules arrive at once, in order. In the stack it shows as one frame you can collapse to keep long setups tidy. A collapsed bundle still warns you: when a row inside has a problem (a missing variable, a conflict, a module gone from the library), its header shows that row's coloured dot, and hovering the dot names the rows. Hidden rows never make the node wider.\nWhen the pipeline runs, a bundle's modules run exactly as if they sat in the stack on their own. The bundle itself adds nothing; it only groups.\nFigure: A WP Context with the Starter set bundle expanded and a loose Pose module below it. Screenshot of a WP Context (seed 42, modules 7 / 7) wired into a WP Prompt Assembler. The Context holds the Starter set bundle (6 mods), expanded inside a frame: Starter subject, Starter pairing, Starter mood, Starter style, Starter scene and Starter accent. Below the bundle is a loose Pose wildcard. The Assembler's template is '$scene, $pose, $style, $accent' with 4 upstream chips, and the preview reads 'sleepy wolf, standing, oil painting, soft lighting'.\nGood for\n- A set of modules you reuse together: a character, a wardrobe, a whole pack\n- Sub-bundles for the sections of a pack\nBetter with something else\n- Keeping two copies apart: a bundle doesn't scope its variables. Insert it twice and rename the second copy's variables"
},
{
"title": "Make a bundle",
"text": "- On the canvas, select rows in a WP Context, right-click and choose Save as bundle.\n- In the manager, create one on the Bundles page and add modules to it.\n- Drop a saved bundle into a Context with Add Bundle."
"text": "- On the canvas, select rows in a WP Context, right-click and choose Save as bundle.\n- In the manager, create one on the Bundles page and add modules to it.\n- Drop a saved bundle into a Context with Add Bundle.\n- Drag a row into a bundle on the canvas: a line in the bundle's colour shows where it lands. On a closed bundle, drop on the middle of its header to put the row inside (the bundle opens); its top and bottom edges drop the row beside it."
},
{
"title": "When you edit a module later",
@@ -2646,7 +2686,7 @@
},
{
"title": "After a run (WP Debug → Warnings)",
"text": "These show up once you run. The labels match what WP Debug shows.\nWhat it flags | Severity | What it means\nUnknown variable | Warning | A template or combine used a $variable nothing set, so it printed nothing.\nReference not found | Warning | An @{…} reference points at a wildcard that wasn't found on the node or in the library.\nFilter matched no options | Warning | A reference's tag filter left no options, so it printed nothing.\nReference not allowed here | Warning | An @{…} reference sits somewhere that can't use one, such as a combine or the Assembler, so it printed nothing.\nVariable not allowed here | Warning | A $variable was written somewhere that can't read one, such as a wildcard option. It printed as typed.\nUnknown tag axis | Warning | A $name.GROUP read names a group that isn't an accepts group on that wildcard, so it printed nothing.\nPick has no tag on this axis | Info | The picked option has no tag from the accepts group being read, so $name.GROUP printed nothing.\nReferences nested too deep | Warning | References inside references went too many levels deep, so the deepest ones were left out.\nReference cycle | Warning | References pointed back at each other in a circle; the circle was cut so the run could finish.\nConstraint never applied | Info | The constraint's target never ran after it (or wasn't drawn this run), so it changed nothing.\nConstraint partial reach | Info | A constraint set to reach the next N (or picked) uses of its target found fewer than that.\nConstraint source missing | Warning | The constraint's source wildcard hadn't run yet when the constraint was reached.\nConstraint excluded every option | Warning | The constraint ruled out every option of the target and there was no fallback, so it printed nothing.\nFallback used | Info | Nothing was left to pick, so the wildcard's fallback option stood in.\nNo option matches the variable | Info | A wildcard set to Match variable found no option equal to the variable's value and has no fallback, so it rolled as usual.\nPinned option missing | Warning | A sweep frame (or other pin) points at an option the wildcard no longer has, so that frame rolled instead.\nConstraint factor ignored (allow) | Info | A rule set to Allow also has a number; Allow ignores it.\nUnknown constraint mode | Warning | A constraint rule has a mode this version doesn't know (probably from a newer pack), so the rule was skipped.\nFixed-values overrides malformed | Warning | The node-only changes on a Fixed Values row couldn't be read, so the library values were used.\nConstraint failed to register | Warning | A constraint couldn't be set up, usually because its data is damaged. It was skipped.\nModule failed | Warning | A module hit an error and was skipped. The message says which.\nInvalid injector name | Warning | A WP Context Injector row used a variable name that isn't valid.\nReserved injector name | Warning | A WP Context Injector row name starts with two underscores, which are kept for the engine."
"text": "These show up once you run. The labels match what WP Debug shows.\nWhat it flags | Severity | What it means\nUnknown variable | Warning | A template or combine used a $variable nothing set, so it printed nothing.\nReference not found | Warning | An @{…} reference points at a wildcard that wasn't found on the node or in the library.\nFilter matched no options | Warning | A reference's tag filter left no options, so it printed nothing.\nReference not allowed here | Warning | An @{…} reference sits somewhere that can't use one, such as a combine or the Assembler, so it printed nothing.\nVariable not allowed here | Warning | A $variable was written somewhere that can't read one, such as a wildcard option. It printed as typed.\nUnknown tag axis | Warning | A $name.GROUP read names a group that isn't an accepts group on that wildcard, so it printed nothing.\nPick has no tag on this axis | Info | The picked option has no tag from the accepts group being read, so $name.GROUP printed nothing.\nReferences nested too deep | Warning | References inside references went too many levels deep, so the deepest ones were left out.\nReference cycle | Warning | References pointed back at each other in a circle; the circle was cut so the run could finish.\nConstraint never applied | Info | The constraint's target never ran after it (or wasn't drawn this run), so it changed nothing.\nConstraint partial reach | Info | A constraint set to reach the next N (or picked) uses of its target found fewer than that.\nConstraint source missing | Warning | The constraint's source wildcard hadn't run yet when the constraint was reached.\nConstraint excluded every option | Warning | The constraint ruled out every option of the target and there was no fallback, so it printed nothing.\nFallback used | Info | Nothing was left to pick, so the wildcard's fallback option stood in.\nFilter variable left out | Info | A $variable in a pool or reference filter was unset, or its value is no tag on that wildcard, so that part of the filter narrowed nothing.\nNo option matches the variable | Info | A wildcard set to Match variable found no option equal to the variable's value and has no fallback, so it rolled as usual.\nPinned option missing | Warning | A sweep frame (or other pin) points at an option the wildcard no longer has, so that frame rolled instead.\nConstraint factor ignored (allow) | Info | A rule set to Allow also has a number; Allow ignores it.\nUnknown constraint mode | Warning | A constraint rule has a mode this version doesn't know (probably from a newer pack), so the rule was skipped.\nFixed-values overrides malformed | Warning | The node-only changes on a Fixed Values row couldn't be read, so the library values were used.\nConstraint failed to register | Warning | A constraint couldn't be set up, usually because its data is damaged. It was skipped.\nModule failed | Warning | A module hit an error and was skipped. The message says which.\nInvalid injector name | Warning | A WP Context Injector row used a variable name that isn't valid.\nReserved injector name | Warning | A WP Context Injector row name starts with _, which is kept for the engine.\nModel Info found nothing | Warning | WP Model Info has no model wired and nothing pinned, so $model_family, $model_variant and $model_name are empty.\nModel Info rule skipped | Warning | One of WP Model Info's variant rules couldn't be read (often a broken pattern), so it was skipped.\nToggles setup problem | Warning | Something in a WP Toggles node's setup doesn't add up, such as two rows writing the same variable. The message says what.\nUpstream turned on several | Warning | In Pick one with Upstream can pick, upstream variables turned on more than one row, so the first of them was picked."
},
{
"title": "See also",
@@ -2695,7 +2735,7 @@
},
{
"title": "Appearance",
"text": "How the manager looks on this device.\n- Colours: The colour theme: Orbit, Native, Nebula, Tide, Ember or High contrast.\n- Look: Corners, type, borders and selection: Orbit, Crisp, Soft, Terminal, Editorial, Compact or TUI.\n- Mode: Dark, light, or follow the system.\n- Accent color: The highlight color used for buttons, links and selection. Theme's own follows the colour theme.\n- Density: Spacing and control height across the manager.\n- Sidebar: Show the navigation with labels, or as icons only.\n- Start page: What the manager opens to.\n- Reduce motion: Turn animations and transitions off in the manager."
"text": "How the manager looks on this device.\n- Colours: The colour theme: Orbit, Native, Nebula, Tide, Ember, High contrast or your ComfyUI palette.\n- Look: Corners, type, borders and selection: Orbit, Crisp, Soft, Terminal, Editorial, Compact or TUI.\n- Mode: Dark, light, or follow the system.\n- Accent color: The highlight color used for buttons, links and selection. Theme's own follows the colour theme.\n- Density: Spacing and control height across the manager.\n- Sidebar: Show the navigation with labels, or as icons only.\n- Start page: What the manager opens to.\n- Reduce motion: Turn animations and transitions off in the manager."
},
{
"title": "Editing",
@@ -2707,7 +2747,7 @@
},
{
"title": "Canvas",
"text": "The nodes on the ComfyUI canvas, and how runs resolve.\n- Ref recursion limit: How deep nested @{uuid} references resolve when a workflow runs.\n- Module density: Module spacing and chip sizes.\n- Decoration: Gradients & shadows. Off = flat (weak GPU / remote desktop).\n- Color intensity: How saturated accent / kind / status colors render. Muted reduces chroma for a calmer palette; vivid bumps it for pop.\n- Type style: How the kind indicator shows on module rows AND bundle headers: chip text, icon glyph, or both.\n- State indicator style: How mod / missing / drift / conflict markers appear.\n- State border highlights: Color the module border by its state.\n- Collapse new modules by default: Modules added via the picker render with body hidden (header only). Bundles have their own setting below.\n- Collapse new bundles by default: Bundles inserted from the library render with the frame collapsed (header only). Independent from the modules collapse-default since bundles usually carry 3-8 children and users often want a different default there.\n- Collapse stack mode: Independent: each module collapses on its own. Accordion: expanding a module collapses all others.\n- Focus mode: Hover a module to dim the others.\n- Row buttons: When a module row shows its lock, internal and remove buttons. On hover keeps rows calmer; a pressed lock or internal button always shows.\n- New modules start disabled: When on, modules added from the picker start with their toggle off. Useful when configuring before letting them run.\n- Confirm destructive bundle actions: Show a confirm dialog before remove / reset-to-library / save-to-library on bundles. Turn off if you trust the Undo toast as the only safety net.\n- Bundle master toggle: clear behavior: What the bundle master ON->OFF click clears. Preserve manual: only revert rows the master itself turned on; rows the user marked internal / locked individually stay put. Cascade: clear every applicable row regardless of how it got set. Applicability (skip constraint for internal, skip non-lockable for lock) is hardcoded — the engine ignores the flag on those kinds, so writing it would be dead data.\n- Validation strictness: How aggressively the conflict scanner surfaces issues. Permissive turns it off — use only if you know what you're doing.\n- Toast lifetime: How long status toasts stay on screen before auto-dismissing.\n- Suppress info-severity toasts: When on, info toasts (status confirmations) are filtered out. Warnings + errors still show.\n- Image Filter sound: Play a short chime when WP Image Filter is waiting for your pick, so you hear it from another tab.\n- Image Filter desktop notification: Show a system notification when WP Image Filter is waiting and this tab is in the background. Your browser asks for permission the first time.\n- Reduce motion: Disables Wildcard Pipeline animations. Match system honors prefers-reduced-motion.\n- Contrast: Bumps borders + text contrast. Match system honors prefers-contrast."
"text": "The nodes on the ComfyUI canvas, and how runs resolve.\n- Ref recursion limit: How deep nested @{uuid} references resolve when a workflow runs.\n- Module density: Module spacing and chip sizes.\n- Decoration: Gradients & shadows. Off = flat (weak GPU / remote desktop).\n- Color intensity: How saturated accent / kind / status colors render. Muted reduces chroma for a calmer palette; vivid bumps it for pop.\n- Canvas colors: Which colors WP widgets use on a ComfyUI color palette other than Dark or Light (Nord, Solarized, an imported one…). Follow takes the palette's node, widget and text colors; Wildcard Pipeline keeps our own dark or light colors.\n- Type style: How the kind indicator shows on module rows AND bundle headers: chip text, icon glyph, or both.\n- State indicator style: How mod / missing / drift / conflict markers appear.\n- State border highlights: Color the module border by its state.\n- Collapse new modules by default: Modules added via the picker render with body hidden (header only). Bundles have their own setting below.\n- Collapse new bundles by default: Bundles inserted from the library render with the frame collapsed (header only). Independent from the modules collapse-default since bundles usually carry 3-8 children and users often want a different default there.\n- Collapse stack mode: Independent: each module collapses on its own. Accordion: expanding a module collapses all others.\n- Focus mode: Hover a module to dim the others.\n- Row buttons: When a module row shows its lock, internal and remove buttons. On hover keeps rows calmer; a pressed lock or internal button always shows.\n- New modules start disabled: When on, modules added from the picker start with their toggle off. Useful when configuring before letting them run.\n- Confirm destructive bundle actions: Show a confirm dialog before remove / reset-to-library / save-to-library on bundles. Turn off if you trust the Undo toast as the only safety net.\n- Bundle master toggle: clear behavior: What the bundle master ON->OFF click clears. Preserve manual: only revert rows the master itself turned on; rows the user marked internal / locked individually stay put. Cascade: clear every applicable row regardless of how it got set. Applicability (skip constraint for internal, skip non-lockable for lock) is hardcoded — the engine ignores the flag on those kinds, so writing it would be dead data.\n- Validation strictness: How aggressively the conflict scanner surfaces issues. Permissive turns it off — use only if you know what you're doing.\n- Toast lifetime: How long status toasts stay on screen before auto-dismissing.\n- Suppress info-severity toasts: When on, info toasts (status confirmations) are filtered out. Warnings + errors still show.\n- Image Filter sound: Play a short chime when WP Image Filter is waiting for your pick, so you hear it from another tab.\n- Image Filter desktop notification: Show a system notification when WP Image Filter is waiting and this tab is in the background. Your browser asks for permission the first time.\n- Reduce motion: Disables Wildcard Pipeline animations. Match system honors prefers-reduced-motion.\n- Contrast: Bumps borders + text contrast. Match system honors prefers-contrast."
},
{
"title": "Test Runner",
+1 -1
View File
@@ -174,7 +174,7 @@ GRAPH_SCHEMA: dict[str, Any] = _obj({
def _axis_name(raw: Any) -> str:
a = _clean(raw, 40).lower().replace(" ", "_").replace("-", "_").lstrip("$.")
return a if _AXIS_NAME.match(a) and a != "neg" else ""
return a if _AXIS_NAME.match(a) and a not in ("neg", "bool") else ""
def _number(raw: Any, default: float) -> float:
+5 -1
View File
@@ -231,6 +231,8 @@ def _check_filter(expr: str, row: dict[str, Any]) -> str:
terms: list[str] = []
def walk(n: dict[str, Any]) -> None:
if "var" in n:
return
if "tag" in n:
terms.append(n["tag"])
elif n["op"] == "not":
@@ -389,4 +391,6 @@ def has_pick(text: Any) -> bool:
def valid_axis_name(name: str) -> bool:
return bool(_IDENT.fullmatch(name)) and name != "neg" and not validate_subcat_name(name)
if name in ("neg", "bool"):
return False
return bool(_IDENT.fullmatch(name)) and not validate_subcat_name(name)
+5 -1
View File
@@ -10,6 +10,9 @@ line of ``;``-separated clauses, which this module turns into instance keys:
pick 1-3 repeats … allowing the same option twice
sep " and " pick_separator (default ", ")
filter warm and not wet category_filter, checked against its tags
filter $model_variant … a $variable term keeps the options tagged
with its value at run time (untagged-for-that-axis
options stay)
no empty exclude_null (never the empty option)
match $model_variant match_variable (take the option equal to it)
hold seed_scope "hold" (same pick for a whole Loop run)
@@ -168,7 +171,8 @@ def _apply(word: str, rest: str, out: dict[str, Any], tags: list[str] | None) ->
if tags is not None:
known = set(tags)
terms = re.findall(r"[^\s()!,]+", expr)
bad = [t for t in terms if t.lower() not in ("and", "or", "not") and t not in known]
bad = [t for t in terms if t.lower() not in ("and", "or", "not")
and not t.startswith("$") and t not in known]
if bad:
raise ValueError(f"no tag {bad[0]!r} on this wildcard"
+ (f" (its tags: {', '.join(tags[:15])})" if tags else ""))
+2 -2
View File
@@ -794,8 +794,8 @@ def edit_groups(payload: dict[str, Any], raw: Any) -> dict[str, Any]:
if new.lower() in by_lower and by_lower[new.lower()] != name:
warnings.append(f"a group named {new} exists; move the tags with axes instead")
continue
if new.lower() == "neg":
warnings.append("neg is reserved and can't name a group")
if new.lower() in ("neg", "bool"):
warnings.append(f"{new.lower()} is reserved and can't name a group")
continue
groups = {(new if k == name else k): v for k, v in groups.items()}
kinds = {(new if k == name else k): v for k, v in kinds.items()}
+3 -1
View File
@@ -97,6 +97,8 @@ def _map_tags(ast: dict[str, Any] | None, fn) -> dict[str, Any] | None:
(src/manager/cascade/subcatExprCascade.ts)."""
if ast is None:
return None
if "var" in ast:
return ast
if "tag" in ast:
v = fn(ast["tag"])
return None if v is None else {"tag": v}
@@ -113,7 +115,7 @@ def _expr_tags(ast: dict[str, Any] | None) -> set[str]:
out: set[str] = set()
def walk(n: dict[str, Any] | None) -> None:
if n is None:
if n is None or "var" in n:
return
if "tag" in n:
out.add(n["tag"])
+1 -1
View File
@@ -26,7 +26,7 @@ Context = dict[str, Any]
#: anything enumerating it (the assembler's variables panel, debug views) would
#: list `$__wp_axes__` as a user variable. Callers that need accessor syntax
#: re-attach them to their own local dict via `with_resolver_tables`.
RESOLVER_TABLES = ("__wp_axes__", "__wp_picks__", "__wp_negatives__")
RESOLVER_TABLES = ("__wp_axes__", "__wp_picks__", "__wp_negatives__", "__wp_toggle_bools__")
def with_resolver_tables(
+20
View File
@@ -11,12 +11,15 @@ import re
from typing import Any, Protocol
from engine.syntax.types import deref_var_value, split_var_accessor
from engine.toggles import BOOLS_KEY
class _CtxLike(Protocol):
context: dict[str, object]
_INT_RE = re.compile(r"-?\d+")
_FLOAT_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
_BOOL_SPLIT_RE = re.compile(r"[\s,;|/]+")
@@ -82,6 +85,23 @@ def lookup_var(context: _CtxLike, name: str) -> str:
bare = (name or "").lstrip("$").strip()
if not bare:
return ""
if bare.endswith(".bool"):
return _lookup_bool(context, bare[: -len(".bool")])
base, index = split_var_accessor(bare)
raw: Any = context.context.get(base) if context and getattr(context, "context", None) else None
return deref_var_value(raw, index)
def _lookup_bool(context: _CtxLike, base: str) -> str:
"""`$name.bool`: a WP Toggles switch as "true" / "false", from the table
the payload's `internals` carry (a toggle with a Value writes the value to
its variable, not the switch). Falls back to the variable itself when it
already is "true" / "false"; else ""."""
internals = getattr(context, "internals", None) if context else None
table = internals.get(BOOLS_KEY) if isinstance(internals, dict) else None
if isinstance(table, dict) and base in table:
return "true" if table[base] else "false"
raw: Any = context.context.get(base) if context and getattr(context, "context", None) else None
if isinstance(raw, str) and raw.strip().lower() in ("true", "false"):
return raw.strip().lower()
return ""
+6 -1
View File
@@ -30,7 +30,7 @@ CURRENT_SCHEMA_VERSION = 2
# MAINTENANCE CONTRACT: bump this whenever the TS `schemaVersionForPayload()`
# learns to stamp a new (higher) version — otherwise this commit-side
# re-validate will reject the very shapes the runtime just learned to produce.
MAX_KNOWN_SCHEMA_VERSION = 10
MAX_KNOWN_SCHEMA_VERSION = 12
# Feature stamps (mirror of the TS constants beside MAX_KNOWN). Each is the
# catalog version a payload needs once it uses that feature; see
@@ -48,6 +48,11 @@ NEGATIVES_SCHEMA_VERSION = 8
FALLBACK_SCHEMA_VERSION = 9
# derivation NOT toggle: a test using `not_contains` or `not_matches`
NEGATED_TESTS_SCHEMA_VERSION = 10
# pool filter variables: a `$name` term in a use's `category_filter` or a
# ref's `@{uuid:…}` filter
FILTER_VARIABLES_SCHEMA_VERSION = 11
# WP Toggles booleans: text or a condition reading `$name.bool`
BOOL_ACCESSOR_SCHEMA_VERSION = 12
_CHAIN = {
0: migrate_v0_to_v1,
+53
View File
@@ -5,6 +5,10 @@ A payload is stamped with the LOWEST catalog version its features need, so
an older consumer can still install everything that doesn't use a newer
feature:
- ``BOOL_ACCESSOR_SCHEMA_VERSION`` (12): text reads ``$name.bool`` or a
derivation test's ``var`` is ``name.bool`` (WP Toggles' on/off).
- ``FILTER_VARIABLES_SCHEMA_VERSION`` (11): a wildcard use's pool filter
(``category_filter``) or a ref's ``@{uuid:…}`` filter has a ``$name`` term.
- ``NEGATED_TESTS_SCHEMA_VERSION`` (10): a derivation test uses
``not_contains`` or ``not_matches`` (the editor's NOT toggle).
- ``FALLBACK_SCHEMA_VERSION`` (9): a wildcard option is flagged
@@ -37,10 +41,12 @@ import re
from typing import Any
from engine.migrations import (
BOOL_ACCESSOR_SCHEMA_VERSION,
CONSTRAINT_ONLY_SCHEMA_VERSION,
CURRENT_SCHEMA_VERSION,
DERIVATION_CONDITIONS_SCHEMA_VERSION,
FALLBACK_SCHEMA_VERSION,
FILTER_VARIABLES_SCHEMA_VERSION,
NEGATED_TESTS_SCHEMA_VERSION,
NEGATIVES_SCHEMA_VERSION,
SP2B_SCHEMA_VERSION,
@@ -56,6 +62,18 @@ _NEG_ACCESSOR_RE = re.compile(
r"\$[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?\.neg(?![A-Za-z0-9_])"
)
# `$name.bool` (and `$name.K.bool`): reads a WP Toggles variable's on/off
# (schema v12). A condition's `var` holds the same reference without `$`.
_BOOL_ACCESSOR_RE = re.compile(
r"\$[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?\.bool(?![A-Za-z0-9_])"
)
_BOOL_VAR_RE = re.compile(r"^\$?[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?\.bool$")
# A ref whose `:filter` holds a `$name` term (schema v11).
_REF_FILTER_VAR_RE = re.compile(
r"@\{[0-9a-f]{8}(?:#[^#:}@{!]*)?:[^}]*\$[A-Za-z_]"
)
def _is_number(value: Any) -> bool:
# JSON booleans come back as Python bools, which are ints; TS
@@ -239,6 +257,37 @@ def uses_negated_tests(node: Any) -> bool:
)
def uses_filter_variables(node: Any) -> bool:
"""A ``$name`` term in any ``category_filter`` (a use's pool filter, at
any depth) or in any ref's ``@{uuid:…}`` filter text."""
from engine.syntax.subcat_filter import expr_variables
if isinstance(node, list):
return any(uses_filter_variables(child) for child in node)
if isinstance(node, str):
return _REF_FILTER_VAR_RE.search(node) is not None
if not isinstance(node, dict):
return False
if expr_variables(node.get("category_filter")):
return True
return any(uses_filter_variables(value) for value in node.values())
def uses_bool_accessor(node: Any) -> bool:
"""Any text reading ``$name.bool``, or a derivation test whose ``var``
is ``name.bool``, at any depth."""
if isinstance(node, list):
return any(uses_bool_accessor(child) for child in node)
if isinstance(node, str):
return _BOOL_ACCESSOR_RE.search(node) is not None
if not isinstance(node, dict):
return False
var = node.get("var")
if isinstance(var, str) and _BOOL_VAR_RE.match(var.strip()):
return True
return any(uses_bool_accessor(value) for value in node.values())
def uses_sp2b_grammar(node: Any) -> bool:
"""Range-count or ``~`` multi-pick anywhere in the serialised payload."""
text = json.dumps(node, ensure_ascii=False, default=str)
@@ -247,6 +296,10 @@ def uses_sp2b_grammar(node: Any) -> bool:
def schema_version_for_payload(payload: Any) -> int:
"""The lowest catalog version that covers every feature in ``payload``."""
if uses_bool_accessor(payload):
return BOOL_ACCESSOR_SCHEMA_VERSION
if uses_filter_variables(payload):
return FILTER_VARIABLES_SCHEMA_VERSION
if uses_negated_tests(payload):
return NEGATED_TESTS_SCHEMA_VERSION
if uses_fallback_option(payload):
+16
View File
@@ -76,6 +76,20 @@ class _RuntimeResolveContext:
# The run's negatives table (`ctx["__wp_negatives__"]`), by reference so
# a `$name.neg` read sees negatives filed earlier in the same module.
_negs: dict[str, Any] | None = None
# WP Toggles' on/off per variable (`ctx["__wp_toggle_bools__"]`), what
# `$name.bool` reads.
_bools: dict[str, Any] | None = None
def get_bool(self, name: str) -> str | None:
"""`$name.bool`: "true" / "false" from WP Toggles' table, else the
variable's own value when it already is one, else None."""
got = self._bools.get(name) if isinstance(self._bools, dict) else None
if isinstance(got, bool):
return "true" if got else "false"
own = self._vars.get(name)
if isinstance(own, str) and own.strip().lower() in ("true", "false"):
return own.strip().lower()
return None
def get_negative(self, name: str, index: int | None) -> str | None:
"""`name`'s negatives as one line (tag-deduped), or None when it has
@@ -237,6 +251,7 @@ def build_resolve_ctx(
hits = ctx.setdefault("__wp_constraint_hits__", {})
ref_log = ctx.get("__wp_ref_log__")
negs = ctx.get("__wp_negatives__")
bools = ctx.get("__wp_toggle_bools__")
return _RuntimeResolveContext( # type: ignore[return-value]
rng=ctx["__wp_rng__"],
max_ref_depth=int(ctx.get("__wp_max_ref_depth__", 8)),
@@ -265,6 +280,7 @@ def build_resolve_ctx(
_ref_log=ref_log if isinstance(ref_log, list) else None,
_ref_owner=ctx.get("__wp_current_module_uid__"),
_negs=negs if isinstance(negs, dict) else None,
_bools=bools if isinstance(bools, dict) else None,
stream_key=stream,
)
+25
View File
@@ -19,10 +19,14 @@ from typing import Any
from engine import negatives
from engine.modules import build_resolve_ctx
from engine.modules._detail import module_detail
from engine.modules._seed import derive_module_rng, instance_key, stream_key
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
#: `$name` (not `$$`) in a template, for WP Debug's list of what it read.
_TEMPLATE_VAR_RE = re.compile(r"(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)")
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
_MAX_IDENT_LEN = 64
_MAX_TEMPLATE_LEN = 8000
@@ -112,6 +116,9 @@ class CombineHandler(ModuleHandler):
# frozen across the whole run, nested refs included.
_hb = ctx.get("__wp_hold_base_ctx__")
if isinstance(_hb, dict) and output_var in _hb:
held_detail = module_detail(ctx)
if held_detail is not None:
held_detail["held"] = True
negatives.copy_from(ctx, _hb, [output_var])
return {output_var: _hb[output_var]}
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
@@ -147,4 +154,22 @@ class CombineHandler(ModuleHandler):
pick=None, source=output_var,
)
negatives.set_entries(ctx, output_var, entries)
detail = module_detail(ctx)
if detail is not None:
# WP Debug: the template it filled and what each `$var` read.
detail["template"] = template[:400]
detail["template_source"] = "override" if isinstance(override, str) else "library"
reads: dict[str, str | None] = {}
names = [name for name, _index in col.reads]
# A `$var` nothing set records no read; list it from the template.
names += _TEMPLATE_VAR_RE.findall(template)
for name in names:
if name in reads or name.startswith("__"):
continue
val = ctx.get(name)
reads[name] = None if val is None else str(val)[:160]
if reads:
detail["reads"] = reads
if instance.get("locked_seed") is not None:
detail["locked_seed"] = True
return {output_var: result}
+17 -1
View File
@@ -26,7 +26,7 @@ from engine.modules._detail import module_detail
from engine.modules._seed import derive_module_rng, instance_key, stream_key
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
from engine.syntax.resolve import NEG_ACCESSOR
from engine.syntax.resolve import BOOL_ACCESSOR, NEG_ACCESSOR
from engine.syntax.types import (
deref_var_value,
parse_var_reference,
@@ -125,6 +125,8 @@ def _ctx_get(ctx: Any, name: str) -> str:
base, index, axis = parse_var_reference(name)
if axis == NEG_ACCESSOR:
return _ctx_negative(ctx, base, index)
if axis == BOOL_ACCESSOR:
return _ctx_bool(ctx, base)
if axis:
return _ctx_axis(ctx, base, axis, index)
return deref_var_value(_ctx_get_raw(ctx, base), index)
@@ -140,6 +142,18 @@ def _ctx_negative(ctx: Any, base: str, index: int | None) -> str:
return negatives.join_unique(texts)
def _ctx_bool(ctx: Any, base: str) -> str:
"""`$name.bool` in a condition: WP Toggles' on/off for the variable, else
its own value when that already is "true" / "false", else ""."""
table = ctx.get("__wp_toggle_bools__") if isinstance(ctx, dict) else None
got = table.get(base) if isinstance(table, dict) else None
if isinstance(got, bool):
return "true" if got else "false"
own = _ctx_get_raw(ctx, base)
word = own.strip().lower() if isinstance(own, str) else ""
return word if word in ("true", "false") else ""
def _ctx_has(ctx: Any, name: str) -> bool:
"""Whether `name` is a known key in `ctx`, regardless of value.
@@ -155,6 +169,8 @@ def _ctx_has(ctx: Any, name: str) -> bool:
base, _index, axis = parse_var_reference(name)
if axis == NEG_ACCESSOR:
return bool(_ctx_negative(ctx, base, _index))
if axis == BOOL_ACCESSOR:
return bool(_ctx_bool(ctx, base))
if axis:
# An axis is present when the binding actually rolled it. Checked
# against the axes bucket, not ctx: `$outfit.SHOES` can exist while
+26
View File
@@ -25,6 +25,7 @@ from typing import Any, cast
from engine import negatives
from engine.modules import build_resolve_ctx
from engine.modules._detail import module_detail
from engine.modules._seed import derive_module_rng, instance_key, stream_key
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
@@ -103,6 +104,8 @@ class FixedValuesHandler(ModuleHandler):
# set + non-empty, else payload.values. Existing two-tier model
# preserved.
overrides = instance.get("values_overrides")
detail = module_detail(ctx)
from_override = isinstance(overrides, list) and bool(overrides)
if isinstance(overrides, list) and overrides:
values: list[dict[str, Any]] = [v for v in overrides if isinstance(v, dict)]
else:
@@ -139,10 +142,22 @@ class FixedValuesHandler(ModuleHandler):
values = list(payload.get("values", []))
enabled = instance.get("enabled_options")
toggled_off: list[str] = []
if enabled is not None:
allowed = set(enabled)
keys = _row_keys(values)
toggled_off = [
(v.get("name") or "").strip()
for v, k in zip(values, keys, strict=True) if k not in allowed
]
values = [v for v, k in zip(values, keys, strict=True) if k in allowed]
if detail is not None:
# WP Debug: where the rows came from and which the node turned off.
detail["source"] = "override" if from_override else "library"
if toggled_off:
detail["toggled_off"] = [n for n in toggled_off if n]
if instance.get("locked_seed") is not None:
detail["locked_seed"] = True
# Syntax-aware emission path. When ctx is missing required keys
# for build_resolve_ctx (legacy callers passing None / minimal
@@ -166,6 +181,8 @@ class FixedValuesHandler(ModuleHandler):
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):
if detail is not None:
detail["held"] = True
negatives.copy_from(ctx, _hb, _names)
return {n: _hb[n] for n in _names}
chain_seed = int(
@@ -197,6 +214,7 @@ class FixedValuesHandler(ModuleHandler):
n_rng = negatives.neg_rng(effective_seed, module_key)
out_resolved: dict[str, str] = {}
rows_detail: list[dict[str, Any]] = []
for v in values:
name = (v.get("name") or "").strip()
if not name:
@@ -206,6 +224,12 @@ class FixedValuesHandler(ModuleHandler):
ctx_local, surface="fixed_values",
stream=stream_key(effective_seed, f"{module_key}/{name}"),
))
if detail is not None:
# The text as written, when resolving changed it ($vars, {a|b}).
row: dict[str, Any] = {"name": name}
if raw != out_resolved[name]:
row["raw"] = raw[:240]
rows_detail.append(row)
if "negative" in v:
own = negatives.clean_negative(v.get("negative"))
else:
@@ -214,6 +238,8 @@ class FixedValuesHandler(ModuleHandler):
negatives.set_entries(ctx, name, negatives.own_entries(
[own] if own else [], resolve_ctx, n_rng, pick=None, source=name,
))
if detail is not None:
detail["rows"] = rows_detail
return out_resolved
+4
View File
@@ -166,6 +166,10 @@ def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
"id": f"val_{i:04x}",
"name": e.get("variable_name", ""),
"value": e.get("value", ""),
# The Toggles preview step sends each variable's
# negative here (send-to-negative).
**({"negative": e["negative"]}
if isinstance(e.get("negative"), str) and e["negative"].strip() else {}),
}
for i, e in enumerate(legacy_entries)
],
+117 -2
View File
@@ -74,6 +74,65 @@ def _explain_pool(detail: dict[str, Any], options: list[dict[str, Any]]) -> None
detail["live"] = sum(1 for o in options if _weight(o) > 0)
#: Options listed in WP Debug's options table; the rest are only counted.
OPTIONS_SHOWN = 40
_TEXT_SHOWN = 120
def _explain_options(
detail: dict[str, Any],
library: list[dict[str, Any]],
after_filter: set[Any],
after_toggles: set[Any],
before_constraints: dict[Any, float],
final: list[dict[str, Any]],
) -> None:
"""Every option of this use with its library weight, its final weight and
why it can't be drawn (``filter`` / ``toggle`` / ``constraint`` /
``weight``), so WP Debug can say why an option never comes up. Also the
pool size after each step (library → filter → toggles → drawable)."""
final_w = {o.get("id"): _weight(o) for o in final}
rows: list[dict[str, Any]] = []
for o in library:
oid = o.get("id")
lib_w = _weight(o)
if oid not in after_filter:
cut, weight = "filter", None
elif oid not in after_toggles:
cut, weight = "toggle", None
else:
weight = final_w.get(oid, 0.0)
pre = before_constraints.get(oid, lib_w)
cut = None
if weight <= 0:
cut = "constraint" if pre > 0 else "weight"
row: dict[str, Any] = {
"id": oid,
"value": str(o.get("value", ""))[:_TEXT_SHOWN],
"base": lib_w,
"weight": weight,
}
if cut:
row["cut"] = cut
pre = before_constraints.get(oid)
if pre is not None and pre != lib_w:
row["override"] = pre
if o.get("is_null"):
row["null"] = True
if o.get("fallback"):
row["fallback"] = True
rows.append(row)
detail["steps"] = {
"library": len(library),
"filter": len(after_filter),
"toggles": len(after_toggles),
"live": sum(1 for o in final if _weight(o) > 0),
}
detail["options"] = rows[:OPTIONS_SHOWN]
if len(rows) > OPTIONS_SHOWN:
detail["options_more"] = len(rows) - OPTIONS_SHOWN
def _explain_constraints(
detail: dict[str, Any], applied: list[tuple[dict[str, Any], dict[str, Any]]],
) -> None:
@@ -666,6 +725,7 @@ class WildcardHandler(ModuleHandler):
expr: str,
*,
exclude_null: bool,
bound: tuple[dict[str, Any] | None] | None = None,
) -> list[dict[str, Any]]:
"""Narrow an option pool by a boolean sub-category expression.
@@ -673,9 +733,13 @@ class WildcardHandler(ModuleHandler):
against each non-null option's tag set (`sub_categories`). Empty
/ whitespace `expr` ⇒ no tag filtering. The null option
(`is_null`) is governed solely by `exclude_null`; the expression
never applies to it (spec §3.4).
never applies to it (spec §3.4). `bound` holds an already-bound
expression (schema v11 `$name` terms, `bind_for_wildcard`).
"""
ast = _parse_subcat(expr) if isinstance(expr, str) else None
if bound is not None:
ast = bound[0]
else:
ast = _parse_subcat(expr) if isinstance(expr, str) else None
out: list[dict[str, Any]] = []
for o in options:
if o.get("is_null"):
@@ -924,16 +988,42 @@ class WildcardHandler(ModuleHandler):
category_filter = instance.get("category_filter")
exclude_null = bool(instance.get("exclude_null", False))
pool = options
filter_vars: dict[str, Any] = {}
if (isinstance(category_filter, str) and category_filter.strip()) or exclude_null:
# Schema v11: `$name` terms read this run's variables, so one
# tagged wildcard narrows per model ($model_variant) and the
# constraints below still apply to what is left.
from engine.syntax.subcat_filter import (
bind_for_wildcard,
filter_variable_warning,
variables,
)
bound_ast, notes = bind_for_wildcard(
category_filter if isinstance(category_filter, str) else "",
lambda n: ctx.get(n),
payload,
)
if isinstance(category_filter, str) and "$" in category_filter:
parsed = _parse_subcat(category_filter)
for name in variables(parsed):
raw = ctx.get(name)
filter_vars[name] = "" if raw is None else str(raw)
if notes and isinstance(ctx.get("__wp_warnings__"), list):
mid = str(ctx.get("__wp_current_module_id__") or "")
for note in notes:
ctx["__wp_warnings__"].append(filter_variable_warning(note, module_id=mid))
pool = cls._apply_pool_filter(
pool,
category_filter if isinstance(category_filter, str) else "",
exclude_null=exclude_null,
bound=(bound_ast,),
)
ids_after_filter = {o.get("id") for o in pool}
enabled = instance.get("enabled_options")
if enabled is not None:
allowed = set(enabled)
pool = [o for o in pool if o.get("id") in allowed]
ids_after_toggles = {o.get("id") for o in pool}
# `pinned` mode short-circuits the RNG: always pick the option
# whose id matches `pinned_option_id`. Falls through to random
@@ -978,8 +1068,18 @@ class WildcardHandler(ModuleHandler):
detail = module_detail(ctx)
if detail is not None:
detail.update({"mode": pin_mode, "option_id": pinned.get("id")})
# A Context Loop sweep pins through this same mode; say so.
sweep_pins = ctx.get("__wp_pin_overrides__")
if (
pin_mode == "pinned"
and isinstance(sweep_pins, dict)
and ctx.get("__wp_current_module_uid__") in sweep_pins
):
detail["swept"] = True
if pin_mode != "pinned":
detail["match_variable"] = match_var.strip()
raw_match = ctx.get(match_var.strip())
detail["match_value"] = "" if raw_match is None else str(raw_match)
value = str(pinned.get("value", ""))
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard", stream=stream_key(
int(ctx.get("__wp_node_seed__", 0) or 0),
@@ -1020,10 +1120,16 @@ class WildcardHandler(ModuleHandler):
if detail is not None:
if isinstance(category_filter, str) and category_filter.strip():
detail["filter"] = category_filter.strip()
if filter_vars:
detail["filter_vars"] = filter_vars
if exclude_null:
detail["exclude_null"] = True
if not options:
detail.update({"pool": 0, "live": 0})
_explain_options(
detail, list(payload.get("options", [])),
ids_after_filter, ids_after_toggles, {}, [],
)
if not options:
return {binding: ""}
@@ -1057,6 +1163,7 @@ class WildcardHandler(ModuleHandler):
apply_constraints_for_target,
warn_excludes_all,
)
weights_before_constraints = {o.get("id"): _weight(o) for o in options}
my_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
any_constraint_applied = False
# (constraint, source_pick) pairs that actually re-weighted this target.
@@ -1100,6 +1207,13 @@ class WildcardHandler(ModuleHandler):
if detail is not None:
_explain_pool(detail, options)
_explain_constraints(detail, applied_constraints)
_explain_options(
detail, list(payload.get("options", [])),
ids_after_filter, ids_after_toggles,
weights_before_constraints, options,
)
if instance.get("locked_seed") is not None:
detail["locked_seed"] = True
use_fallback = fallback is not None and pool_is_dead(options)
if use_fallback:
assert fallback is not None
@@ -1230,6 +1344,7 @@ class WildcardHandler(ModuleHandler):
if detail is not None:
detail["option_ids"] = [o.get("id") for o in picks]
detail["range"] = [lo, hi]
detail["separator"] = sep
if independent:
detail["independent"] = True
_record_axes(ctx, binding, rolled_list, payload)
+49
View File
@@ -187,6 +187,8 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
# variable itself: it carries no negatives along (that would echo
# them) and does not count as rendering `$name`.
return _resolve_negative(tok, ctx)
if axis == BOOL_ACCESSOR:
return _resolve_bool(tok, ctx)
if axis:
out = _resolve_axis(tok, ctx, str(axis))
_note_read(ctx, name, tok.meta.get("index"), out)
@@ -219,6 +221,39 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
#: `$name.K.neg`). Reserved: an `accepts` tag group cannot use this name.
NEG_ACCESSOR = "neg"
#: The accessor that reads a WP Toggles variable's on/off (`$name.bool`),
#: whatever value the toggle writes. Reserved like `neg`.
BOOL_ACCESSOR = "bool"
#: Names an `accepts` tag group may not use: they are accessors.
RESERVED_ACCESSORS = frozenset({NEG_ACCESSOR, BOOL_ACCESSOR})
def _resolve_bool(tok: Token, ctx: ResolveContext) -> str:
"""Render `$name.bool`: "true" / "false". Not a read of `$name` itself,
so it carries no negatives along."""
name = str(tok.meta.get("name", ""))
getter: Any = getattr(ctx, "get_bool", None)
got: Any = getter(name) if callable(getter) else None
if got is None:
own = ctx.get_var(name)
if isinstance(own, str) and own.strip().lower() in ("true", "false"):
return own.strip().lower()
if own is None:
_push_warning(
ctx,
type="unknown_var",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "surface": ctx.surface},
message=f"Unknown variable ${name}",
)
return ""
return str(got)
def _resolve_negative(tok: Token, ctx: ResolveContext) -> str:
"""Render `$name.neg` / `$name.K.neg`: the negatives filed for `name`
@@ -628,6 +663,20 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
if (isinstance(filter_expr, str) and filter_expr.strip()) or exclude_null:
pool_total = len(options)
ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None
if ast is not None and isinstance(filter_expr, str) and "$" in filter_expr:
# Schema v11: `$name` terms read the run's variables (as a use's
# pool filter does). Quiet resolves (negatives) file nothing.
from engine.syntax.subcat_filter import bind, filter_variable_warning, values_of
from engine.syntax.subcat_filter import wildcard_tags as _wildcard_tags
src = payload_dict if isinstance(payload_dict, dict) else module
ast, notes = bind(
ast, lambda n: values_of(ctx.get_var(n)), _wildcard_tags(src),
src.get("tag_groups") if isinstance(src, dict) else None,
)
for note in notes:
ctx.warnings.append(filter_variable_warning(
note, where=f"@{{{uuid}:{filter_expr}}}: ",
))
options = [
o for o in options
if isinstance(o, dict)
+222 -3
View File
@@ -5,7 +5,8 @@ Grammar (precedence not > and > or; or/and are n-ary, flattened):
or := and ( ("or" | ",") and )*
and := unary ( "and" unary )*
unary := ("not" | "!") unary | atom
atom := SUBCAT | "(" or ")"
atom := SUBCAT | VAR | "(" or ")"
VAR := "$" ident (schema v11: reads a variable at run time, `bind`)
Mirror: src/manager/parsing/subcatFilter.ts — keep behavior identical
(shared fixture in tests/fixtures/subcat_filter_cases.json).
@@ -18,7 +19,8 @@ from typing import Any
RESERVED = {"and", "or", "not", "null"}
# A sub-category NAME is one safe token (see validate_subcat_name).
_NAME_RE = re.compile(r"^[^\s()!,#:}@{$]+$")
_TOKEN_RE = re.compile(r"\s*(\(|\)|,|!|[^\s()!,#:}@{$]+)")
_VAR_RE = re.compile(r"^\$[A-Za-z_][A-Za-z0-9_]*$")
_TOKEN_RE = re.compile(r"\s*(\(|\)|,|!|\$[A-Za-z_][A-Za-z0-9_]*|[^\s()!,#:}@{$]+)")
class ParseError(ValueError):
@@ -91,6 +93,8 @@ def parse(s: str) -> dict[str, Any] | None:
if t in ("and", "or", "not", ")", ","):
raise ParseError(f"unexpected token {t!r} (missing an operator/term?)")
eat()
if _VAR_RE.match(t):
return {"var": t[1:]}
return {"tag": t}
ast = p_or()
@@ -100,14 +104,22 @@ def parse(s: str) -> dict[str, Any] | None:
def matches(ast: dict[str, Any] | None, tags: set[str]) -> bool:
"""True iff the option's `tags` satisfy the expression. Empty expr => True."""
"""True iff the option's `tags` satisfy the expression. Empty expr => True.
A `$var` term that was never bound (see `bind`) is left out, as if it
were not written."""
if ast is None:
return True
if "var" in ast:
return True
if "tag" in ast:
return ast["tag"] in tags
op = ast["op"]
if op == "not":
if _has_var(ast["x"]):
return matches(_drop_vars(ast), tags)
return not matches(ast["x"], tags)
if any("var" in k for k in ast["kids"]):
return matches(_drop_vars(ast), tags)
if op == "and":
return all(matches(k, tags) for k in ast["kids"])
return any(matches(k, tags) for k in ast["kids"]) # or
@@ -118,6 +130,8 @@ def reads_as(ast: dict[str, Any] | None) -> str:
child whose operator differs from the parent's."""
if ast is None:
return ""
if "var" in ast:
return "$" + ast["var"]
if "tag" in ast:
return ast["tag"]
op = ast["op"]
@@ -160,6 +174,8 @@ def validate_expression(s: str, known: set[str]) -> str | None:
seen: set[str] = set()
def walk(n: dict[str, Any]) -> None:
if "var" in n:
return
if "tag" in n:
seen.add(n["tag"])
return
@@ -176,3 +192,206 @@ def validate_expression(s: str, known: set[str]) -> str | None:
if t not in known:
return f"Unknown sub-category: {t!r}"
return None
def variables(ast: dict[str, Any] | None) -> list[str]:
"""Distinct `$name` terms of a parsed expression, in order (no `$`)."""
out: list[str] = []
def walk(n: dict[str, Any] | None) -> None:
if n is None:
return
if "var" in n:
if n["var"] not in out:
out.append(n["var"])
elif "tag" in n:
return
elif n["op"] == "not":
walk(n["x"])
else:
for k in n["kids"]:
walk(k)
walk(ast)
return out
def expr_variables(expr: Any) -> list[str]:
"""`variables` of an expression string; [] when empty or malformed."""
if not isinstance(expr, str) or "$" not in expr:
return []
try:
return variables(parse(expr))
except ParseError:
return []
def tag_key(value: str) -> str:
"""How a variable's value is compared with tags: trimmed, lower case,
runs of spaces as one `_`."""
return "_".join(value.strip().lower().split())
def _has_var(n: dict[str, Any]) -> bool:
if "var" in n:
return True
if "tag" in n:
return False
if n["op"] == "not":
return _has_var(n["x"])
return any(_has_var(k) for k in n["kids"])
def _drop_vars(n: dict[str, Any] | None) -> dict[str, Any] | None:
"""The expression without its `$var` terms: a `not` or a group left
empty goes too, a one-child group becomes that child."""
if n is None or "var" in n:
return None
if "tag" in n:
return n
if n["op"] == "not":
x = _drop_vars(n["x"])
return None if x is None else {"op": "not", "x": x}
kids = [k for k in (_drop_vars(c) for c in n["kids"]) if k is not None]
if not kids:
return None
return kids[0] if len(kids) == 1 else {"op": n["op"], "kids": kids}
def drop_vars(ast: dict[str, Any] | None) -> dict[str, Any] | None:
"""Public `_drop_vars`: what an expression narrows before the run."""
return _drop_vars(ast)
def bind(
ast: dict[str, Any] | None,
lookup: Any,
tags: Any,
tag_groups: Any = None,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
"""Replace every `$name` term with a plain tag expression for this run
(schema v11). `lookup(name)` returns the variable's values (a list of
strings; [] or None when unset). `tags` are the wildcard's tags,
`tag_groups` its {group: [tags]}.
A term keeps the options tagged with one of the values. An option with
NO tag of the value's group passes too, so untagged-for-that-axis
options work for every value. When the variable is unset, or none of
its values is one of the wildcard's tags, the term narrows nothing and
a note says why (the caller files it as an info warning), and the term
is left out as if it were not written. Mirror:
`bindVars` in src/manager/parsing/subcatFilter.ts.
"""
notes: list[dict[str, Any]] = []
if ast is None:
return None, notes
by_key: dict[str, str] = {}
for t in tags or []:
if isinstance(t, str):
by_key.setdefault(tag_key(t), t)
groups: list[list[str]] = [
[m for m in members if isinstance(m, str)]
for members in (tag_groups or {}).values()
if isinstance(members, list)
]
cache: dict[str, dict[str, Any]] = {}
def term(name: str) -> dict[str, Any]:
if name in cache:
return cache[name]
raw = lookup(name) or []
values = [str(v) for v in raw if str(v).strip()]
hit = [by_key[tag_key(v)] for v in values if tag_key(v) in by_key]
hit = list(dict.fromkeys(hit))
if not hit:
notes.append({
"variable": name,
"value": ", ".join(values),
"reason": "unset" if not values else "no_tag",
})
node: dict[str, Any] = {"var": name}
else:
kids: list[dict[str, Any]] = [{"tag": t} for t in hit]
axis: list[str] = []
for g in groups:
if any(t in g for t in hit):
axis.extend(m for m in g if m not in axis)
if axis:
kids.append({"op": "not", "x": {"op": "or", "kids": [{"tag": m} for m in axis]}})
node = kids[0] if len(kids) == 1 else {"op": "or", "kids": kids}
cache[name] = node
return node
def walk(n: dict[str, Any]) -> dict[str, Any]:
if "var" in n:
return term(n["var"])
if "tag" in n:
return n
if n["op"] == "not":
return {"op": "not", "x": walk(n["x"])}
return {"op": n["op"], "kids": [walk(k) for k in n["kids"]]}
return _drop_vars(walk(ast)), notes
def values_of(raw: Any) -> list[str]:
"""A context value as the list `bind` compares: a multi-pick list var
gives its items, anything else its text (empty when unset)."""
if raw is None:
return []
items = getattr(raw, "items", None)
if isinstance(items, list):
return [str(i) for i in items]
text = str(raw)
return [text] if text.strip() else []
def wildcard_tags(payload: Any) -> list[str]:
"""Every tag a wildcard declares or puts on an option."""
out: list[str] = []
if not isinstance(payload, dict):
return out
for t in payload.get("sub_categories") or []:
if isinstance(t, str) and t not in out:
out.append(t)
for o in payload.get("options") or []:
if isinstance(o, dict):
for t in o.get("sub_categories") or []:
if isinstance(t, str) and t not in out:
out.append(t)
return out
def bind_for_wildcard(
expr: Any, get: Any, payload: Any,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
"""Parse `expr` and `bind` its `$name` terms against the run's
variables (`get(name)` returns the raw context value) and the
wildcard `payload`'s tags + tag groups."""
ast = parse(expr) if isinstance(expr, str) else None
if ast is None or not variables(ast):
return ast, []
groups = payload.get("tag_groups") if isinstance(payload, dict) else None
return bind(ast, lambda n: values_of(get(n)), wildcard_tags(payload), groups)
def filter_variable_warning(
note: dict[str, Any], *, module_id: str = "", where: str = "",
) -> dict[str, Any]:
"""The info warning for a `$name` term that narrowed nothing."""
name = note.get("variable", "")
value = note.get("value", "")
if note.get("reason") == "unset":
why = f"${name} is not set"
else:
why = f"no tag here is ${name}'s value ({value!r})"
return {
"type": "filter_variable_no_tag",
"severity": "info",
"module_id": module_id,
"source_field": "",
"position": 0,
"token_index": None,
"detail": {"variable": name, "value": value, "reason": note.get("reason", "")},
"message": f"{where}filter term ${name} left out: {why}, so it narrowed nothing",
}
+10 -1
View File
@@ -123,6 +123,12 @@ def parse_var_reference(name: str) -> tuple[str, int | None, str | None]:
return (m.group(1), int(idx) if idx is not None else None, axis)
def bool_text(value: bool) -> str:
"""A boolean as a variable's text: "true" / "false", the words WP Toggles writes and a
derivation's equals test compares against."""
return "true" if value else "false"
def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
"""Render a resolved var value to a string, honoring an optional ``.K``
list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` ->
@@ -138,7 +144,10 @@ def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
if index is not None:
return value.items[index] if 0 <= index < len(value.items) else ""
return value.sep.join(value.items)
s = value if isinstance(value, str) else str(value)
if isinstance(value, bool):
s = bool_text(value)
else:
s = value if isinstance(value, str) else str(value)
if index is not None:
return s if index == 0 else ""
return s
+86 -7
View File
@@ -31,10 +31,13 @@ leaves the switch alone.
``control`` / ``groups`` (mute or bypass other nodes) are frontend-only: the
canvas sets node modes when a toggle flips, and the run never sees them.
Every toggle writes ``"true"`` / ``"false"`` to its variable, always
internal. A pinned toggle (``pin``) also has a BOOLEAN output; ``out`` is its
output slot (1-based, slot 0 is the context). The frontend keeps ``out``
unique; :func:`output_values` falls back to row order when it isn't.
Every toggle writes ``"true"`` / ``"false"`` to its variable. It is
internal (for rules only) unless the row carries ``visible: true`` (stored
only as ``true``; the canvas's globe "internal" button turned off), which
lets the Assembler print it; :func:`internal_vars` lists the hidden ones.
A pinned toggle (``pin``) also has a BOOLEAN output; ``out`` is its output
slot (1-based, slot 0 is the context). The frontend keeps ``out`` unique;
:func:`output_values` falls back to row order when it isn't.
Mirrored in TS at ``src/extension/toggles.ts`` via the shared
``tests/fixtures/toggles-corpus.json``. Keep the two in step.
@@ -72,6 +75,20 @@ class Toggle:
needs: str = ""
upstream: bool = False
value: str = ""
visible: bool = False
#: Negative text, filed under ``var`` while the toggle is on (and under
#: ``group_var`` when it is the Pick one pick).
negative: str = ""
#: What the variable gets, and the negative it carries, while the toggle
#: is OFF (empty: nothing). Either value makes the toggle write text
#: instead of true/false.
off_value: str = ""
off_negative: str = ""
@property
def writes_text(self) -> bool:
"""A Value or an Off value: the variable holds text, not true/false."""
return bool(self.value or self.off_value)
@dataclass
@@ -114,6 +131,9 @@ def parse_config(raw: Any) -> Config:
continue
out = row.get("out")
value = row.get("value")
negative = row.get("negative")
off_value = row.get("off_value")
off_negative = row.get("off_negative")
toggles.append(Toggle(
id=tid,
name=_str(row.get("name")) or tid,
@@ -124,6 +144,10 @@ def parse_config(raw: Any) -> Config:
needs=_str(row.get("needs")),
upstream=row.get("upstream") is True,
value=value if isinstance(value, str) else "",
visible=row.get("visible") is True,
negative=negative.strip() if isinstance(negative, str) else "",
off_value=off_value if isinstance(off_value, str) else "",
off_negative=off_negative.strip() if isinstance(off_negative, str) else "",
))
return Config(
mode=mode,
@@ -240,20 +264,75 @@ def problems(cfg: Config) -> list[str]:
return out
#: Cross-node internal: every toggle variable's on/off, what `$name.bool`
#: reads (a toggle with a value writes the value, not the boolean).
BOOLS_KEY = "__wp_toggle_bools__"
def resolve(cfg: Config) -> dict[str, str]:
"""Variable → ``"true"`` / ``"false"`` (plus the Pick one variable), in
row order. A later row with the same variable wins."""
"""Variable → its value (plus the Pick one variable), in row order. A
toggle with a Value or an Off value writes the Value while on and the Off
value (often ``""``) while off; one with neither writes ``"true"`` /
``"false"``. A later row with the same variable wins."""
eff = effective(cfg)
values: dict[str, str] = {}
for t in cfg.toggles:
if t.var and VAR_RE.match(t.var):
values[t.var] = TRUE if eff[t.id] else FALSE
if t.writes_text:
values[t.var] = t.value if eff[t.id] else t.off_value
else:
values[t.var] = TRUE if eff[t.id] else FALSE
if cfg.mode == "one" and cfg.group_var and VAR_RE.match(cfg.group_var):
picked = next((t for t in cfg.toggles if eff[t.id]), None)
values[cfg.group_var] = (picked.value or picked.name) if picked else ""
return values
def bools(cfg: Config) -> dict[str, bool]:
"""Each toggle variable's on/off (`$name.bool`); a later row wins."""
eff = effective(cfg)
return {t.var: eff[t.id] for t in cfg.toggles if t.var and VAR_RE.match(t.var)}
def own_negatives(cfg: Config) -> dict[str, str]:
"""Toggle variable → its negative: the Negative while on, the Off
negative while off, left out when that one is empty. A later row writing
the same variable decides, as in :func:`resolve`."""
eff = effective(cfg)
out: dict[str, str] = {}
for t in cfg.toggles:
if t.var and VAR_RE.match(t.var):
text = (t.negative if eff[t.id] else t.off_negative).strip()
if text:
out[t.var] = text
else:
out.pop(t.var, None)
return out
def picked_negative(cfg: Config) -> str:
"""Pick one: the picked toggle's negative text ('' when none), the
negative the Pick one variable carries (send-to-negative)."""
if cfg.mode != "one" or not cfg.group_var or not VAR_RE.match(cfg.group_var):
return ""
eff = effective(cfg)
picked = next((t for t in cfg.toggles if eff[t.id]), None)
return picked.negative if picked else ""
def internal_vars(cfg: Config) -> set[str]:
"""Variables the node writes that stay out of the prompt: a toggle
without ``visible`` (the last row writing a variable decides, as in
:func:`resolve`) and the Pick one variable unless ``group_visible``."""
flags: dict[str, bool] = {}
for t in cfg.toggles:
if t.var and VAR_RE.match(t.var):
flags[t.var] = not t.visible
if cfg.mode == "one" and cfg.group_var and VAR_RE.match(cfg.group_var):
flags[cfg.group_var] = not cfg.group_visible
return {k for k, v in flags.items() if v}
def output_slots(cfg: Config) -> list[Toggle]:
"""Pinned toggles in output order (slot 1, 2, …). Uses each row's ``out``
when those are 1..N with no gaps or repeats, else row order."""
+465
View File
@@ -0,0 +1,465 @@
"""Export library content as plain wildcard files (a zip of ``.txt``).
The reverse of :mod:`engine.wildcard_files`: Dynamic Prompts, Impact Pack,
Prompt-PostProcessor, Easy-Use and A1111/Forge all read a folder of
``.txt`` files, one choice per line, and expand ``__folder/name__``. A pack
exported this way works in any of them, without Wildcard Pipeline.
What carries over:
* **wildcards** → ``<pack>/<name>.txt``, one line per option. Uneven
weights become repeated lines (those tools pick lines evenly), scaled
down to at most ``MAX_COPIES`` copies of one option.
* **fixed values** and **combines** → one file per variable, one line.
* **templates** → ``<pack>/templates/<name>.txt`` (+ ``.negative.txt``).
* ``$var`` reads of a variable an exported item writes → ``__<pack>/<file>__``;
``@{id#name}`` refs → the file of that wildcard; ``{a|b}``, ``{2$$, $$a|b}``
and ``N::`` weights inside braces are Dynamic Prompts syntax already.
What can't (each is listed per file in the zip's ``README.md``): the
chance of nothing (null options), tag groups, ref filters, constraints,
derivations, fallbacks, negatives on options, ``.K`` / axis accessors, the
``~`` repeat flag. A ``$var`` read in WP reuses the pick already made;
``__file__`` in those tools makes a new pick.
Pure Python, standard library only, so the community site can vendor this
file as is (``api/app/services/plain_export.py`` there). Its small parser
follows ``engine/syntax/tokenize.py``; ``tests/engine/test_wildcard_export.py``
checks the two agree on the shared syntax corpus.
"""
from __future__ import annotations
import io
import re
import zipfile
from dataclasses import dataclass, field
from fractions import Fraction
from typing import Any
FORMAT_NOTE = "Plain wildcard files (Dynamic Prompts / Impact Pack / PPP)"
MAX_COPIES = 10
MAX_LINES = 5000 # per file, after weight copies
# Same shapes as engine/syntax/tokenize.py (_VAR_RE / _REF_RE / multi prefix).
_VAR_RE = re.compile(
r"\$([A-Za-z_][A-Za-z0-9_]*)"
r"(?:\.(?:(\d+)(?:\.([A-Za-z_][A-Za-z0-9_]*))?"
r"|([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?))?"
)
_REF_RE = re.compile(
r"@\{([0-9a-f]{8})(?:#([^#:}@{!]*))?(?::((?:[^}!]|!(?!null\}))*))?(?:!([^}]*))?\}"
)
_MULTI_PREFIX_RE = re.compile(r"^(\d+(?:-\d+)?)(~?)\$\$(?:(.*?)\$\$)?", re.DOTALL)
_SLUG_RE = re.compile(r"[^a-z0-9]+")
@dataclass
class _File:
path: str # inside the pack folder, no extension
title: str
kind: str
lines: list[str] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
def note(self, text: str) -> None:
if text not in self.notes:
self.notes.append(text)
@dataclass
class PlainExport:
"""The converted pack: files keyed by path, plus what was left out."""
pack: str
files: list[_File]
skipped: list[str]
def file_texts(self) -> dict[str, str]:
out: dict[str, str] = {}
for f in self.files:
out[f"{self.pack}/{f.path}.txt"] = "\n".join(f.lines) + "\n"
return out
def readme(self, *, title: str, source: str | None = None) -> str:
lines = [
title,
"=" * len(title),
"",
"Plain wildcard files exported from Wildcard Pipeline. Put the",
f"`{self.pack}` folder in your wildcards folder (Dynamic Prompts,",
"Impact Pack, Prompt-PostProcessor, Easy-Use, A1111/Forge) and use",
f"`__{self.pack}/<name>__` in a prompt.",
"",
]
if source:
lines += [
"The full pack, with everything listed below, installs in one",
"click in Wildcard Pipeline for ComfyUI:",
f" {source}",
"",
]
lines.append("Files")
lines.append("-----")
for f in self.files:
lines.append(f"__{self.pack}/{f.path}__ ({f.kind}: {f.title})")
for n in f.notes:
lines.append(f" - {n}")
if self.skipped:
lines += ["", "Not exported (no plain-file equivalent)", "-" * 39]
lines += [f"- {s}" for s in self.skipped]
return "\n".join(lines) + "\n"
def to_zip(self, *, title: str, source: str | None = None) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for path, text in sorted(self.file_texts().items()):
zf.writestr(path, text)
zf.writestr(f"{self.pack}/README.md", self.readme(title=title, source=source))
return buf.getvalue()
def slug(text: str, fallback: str = "item") -> str:
s = _SLUG_RE.sub("_", text.lower()).strip("_")
return s[:60] or fallback
def _items_from_envelope(env: dict[str, Any]) -> list[dict[str, Any]]:
"""Flatten an 8-bucket export (bundles' children included) into module
rows ``{id, type, name, payload}`` plus template rows."""
items: list[dict[str, Any]] = []
for bucket, kind in (
("wildcards", "wildcard"),
("fixed_values", "fixed_values"),
("combines", "combine"),
("derivations", "derivation"),
("constraints", "constraint"),
):
for row in env.get(bucket) or []:
if isinstance(row, dict):
items.append({**row, "type": row.get("type") or kind})
for b in env.get("bundles") or []:
if isinstance(b, dict):
items.extend(bundle_children(b))
for t in env.get("templates") or []:
if isinstance(t, dict):
items.append({**t, "type": "template"})
return items
def bundle_children(bundle: dict[str, Any]) -> list[dict[str, Any]]:
"""A bundle's children (frozen widget snapshots) as module rows."""
out: list[dict[str, Any]] = []
for c in bundle.get("children") or []:
if not isinstance(c, dict):
continue
meta = _dict(c.get("meta"))
out.append({
"id": c.get("id"),
"type": c.get("type"),
"name": meta.get("name") or c.get("name") or c.get("type") or "item",
"payload": _dict(c.get("payload")),
})
return out
def items_from_post_payload(payload: dict[str, Any]) -> list[dict[str, Any]]:
"""A community post payload (one module row, or a bundle with
``children``) as module rows."""
if isinstance(payload.get("children"), list):
return bundle_children(payload)
return [payload]
def _copies(weights: list[float]) -> tuple[list[int], bool]:
"""Line copies per option keeping the odds; ``(copies, exact)``."""
if not weights:
return [], True
fr = [Fraction(w).limit_denominator(100) for w in weights]
if len(set(fr)) == 1:
return [1] * len(fr), True
low = min(fr)
ratios = [f / low for f in fr]
# Smallest whole numbers in the same ratio.
den = 1
for r in ratios:
den = den * r.denominator // _gcd(den, r.denominator)
ints = [int(r * den) for r in ratios]
g = 0
for n in ints:
g = _gcd(g, n)
ints = [n // g for n in ints]
if max(ints) <= MAX_COPIES:
return ints, True
top = max(fr)
scaled = [max(1, round(float(f / top) * MAX_COPIES)) for f in fr]
return scaled, False
def _gcd(a: int, b: int) -> int:
while b:
a, b = b, a % b
return a
class _Converter:
def __init__(self, pack: str) -> None:
self.pack = pack
self.by_id: dict[str, str] = {} # module id -> file path
self.by_var: dict[str, str] = {} # variable -> file path
self.used: set[str] = set()
def claim(self, base: str) -> str:
path, n = base, 2
while path in self.used:
path, n = f"{base}_{n}", n + 1
self.used.add(path)
return path
def ref(self, path: str) -> str:
return f"__{self.pack}/{path}__"
# --- text -----------------------------------------------------------
def text(self, raw: str, f: _File) -> str:
out: list[str] = []
i = 0
n = len(raw)
while i < n:
ch = raw[i]
if ch in "$@" and i + 1 < n and raw[i + 1] == ch:
out.append(ch) # `$$` / `@@` are WP's escapes
i += 2
continue
if ch == "$":
m = _VAR_RE.match(raw, i)
if m:
out.append(self.var(m, f))
i = m.end()
continue
if ch == "@":
m = _REF_RE.match(raw, i)
if m:
out.append(self.wp_ref(m, f))
i = m.end()
continue
if ch == "{":
end = _close(raw, i)
if end is not None:
out.append(self.brace(raw[i + 1 : end], f))
i = end + 1
continue
out.append(ch)
i += 1
return "".join(out)
def var(self, m: re.Match[str], f: _File) -> str:
name = m.group(1)
accessor = m.group(0)[len(name) + 1 :]
if accessor == ".neg":
f.note(f"`{m.group(0)}` (a variable's negatives) dropped")
return ""
path = self.by_var.get(name)
if path is None:
f.note(f"`${name}` is set by something not in this pack; kept as text")
return m.group(0)
if accessor:
f.note(f"`{m.group(0)}` reads one part of a pick; exported as a whole new pick")
f.note(f"`${name}` becomes a new pick of {self.ref(path)}, not the same one")
return self.ref(path)
def wp_ref(self, m: re.Match[str], f: _File) -> str:
uid, label, expr = m.group(1), m.group(2), m.group(3)
path = self.by_id.get(uid)
if expr:
f.note(f"the tag filter `{expr}` on a reference was dropped")
if path is None:
name = label or uid
f.note(f"refers to `{name}`, not in this pack; written as __{slug(name)}__")
return f"__{slug(name)}__"
return self.ref(path)
def brace(self, body: str, f: _File) -> str:
prefix = ""
m = _MULTI_PREFIX_RE.match(body)
if m:
count, independent, sep = m.group(1), m.group(2), m.group(3)
if independent:
f.note("the `~` (repeats allowed) flag on a multi-pick was dropped")
prefix = f"{count}$${sep}$$" if sep is not None else f"{count}$$"
body = body[m.end() :]
branches = _split_top(body)
return "{" + prefix + "|".join(self.text(b, f) for b in branches) + "}"
# --- items ----------------------------------------------------------
def convert(self, items: list[dict[str, Any]]) -> PlainExport:
files: list[tuple[_File, dict[str, Any]]] = []
skipped: list[str] = []
seen: set[str] = set()
for it in items:
kind = it.get("type")
name = str(it.get("name") or kind or "item")
key = f"{kind}:{it['id']}" if it.get("id") else ""
if key and key in seen:
continue
if key:
seen.add(key)
payload = _dict(it.get("payload"))
if kind == "wildcard":
var = str(payload.get("var_binding") or "")
path = self.claim(slug(var or name, "wildcard"))
f = _File(path, name, "wildcard")
if it.get("id"):
self.by_id[str(it["id"])] = path
if var:
self.by_var.setdefault(var, path)
files.append((f, it))
elif kind == "fixed_values":
for row in payload.get("values") or []:
if not isinstance(row, dict) or not row.get("name"):
continue
var = str(row["name"])
path = self.claim(slug(var, "value"))
self.by_var.setdefault(var, path)
files.append((_File(path, f"{name} · {var}", "fixed value"), {"row": row}))
elif kind == "combine":
var = str(payload.get("output_var") or "")
path = self.claim(slug(var or name, "combine"))
if var:
self.by_var.setdefault(var, path)
files.append((_File(path, name, "combine"), it))
elif kind == "template":
path = self.claim(f"templates/{slug(name, 'template')}")
files.append((_File(path, name, "template"), it))
elif kind in ("derivation", "constraint"):
skipped.append(f"{name} ({kind}: rules that need Wildcard Pipeline)")
elif kind:
skipped.append(f"{name} ({kind})")
out: list[_File] = []
for f, it in files:
self.fill(f, it)
out.append(f)
if f.kind == "template":
neg = it.get("negative_template")
if isinstance(neg, str) and neg.strip():
nf = _File(
self.claim(f"{f.path}.negative"), f"{f.title} (negative)", "template"
)
nf.lines = [self.text(neg, nf).strip()]
out.append(nf)
return PlainExport(self.pack, out, skipped)
def fill(self, f: _File, it: dict[str, Any]) -> None:
payload = _dict(it.get("payload"))
if f.kind == "fixed value":
row = it["row"]
f.lines = [self.text(str(row.get("value") or ""), f).strip()]
if row.get("negative"):
f.note("its negative prompt was dropped")
elif f.kind == "combine":
f.lines = [self.text(str(payload.get("template") or ""), f).strip()]
if payload.get("negative"):
f.note("its negative prompt was dropped")
elif f.kind == "template":
f.lines = [self.text(str(it.get("template_string") or ""), f).strip()]
else:
self.fill_wildcard(f, payload)
f.lines = [ln for ln in f.lines if ln] or ["# (empty)"]
def fill_wildcard(self, f: _File, payload: dict[str, Any]) -> None:
opts = [o for o in payload.get("options") or [] if isinstance(o, dict)]
live = [o for o in opts if not o.get("is_null")]
nulls = [o for o in opts if o.get("is_null")]
weights = [_weight(o) for o in live]
total = sum(weights) + sum(_weight(o) for o in nulls)
keep = [(o, w) for o, w in zip(live, weights, strict=True) if w > 0]
if nulls and total > 0:
share = round(100 * sum(_weight(o) for o in nulls) / total)
if share:
f.note(f"a {share}% chance of nothing was dropped")
if len(keep) < len(live):
f.note("options with weight 0 were left out")
if payload.get("tag_groups") or any(o.get("sub_categories") for o in live):
f.note("tags on options were dropped")
if any(o.get("negative") for o in live):
f.note("negative prompts on options were dropped")
if any(o.get("fallback") for o in live):
f.note("the fallback option is now an ordinary option")
copies, exact = _copies([w for _, w in keep])
if not exact:
f.note(f"weights rounded to whole line copies (at most {MAX_COPIES})")
elif any(c > 1 for c in copies):
f.note("uneven weights written as repeated lines")
lines: list[str] = []
for (o, _), c in zip(keep, copies, strict=True):
text = self.text(str(o.get("value") or ""), f).replace("\n", " ").strip()
if not text:
continue
if text.startswith("#"):
# A line starting with # is a comment in those tools.
text = text.lstrip("# ").strip()
f.note("a leading # was removed (it starts a comment there)")
if not text:
continue
lines.extend([text] * c)
if len(lines) > MAX_LINES:
f.note(f"cut to the first {MAX_LINES} lines")
lines = lines[:MAX_LINES]
f.lines = lines
def _dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _weight(o: dict[str, Any]) -> float:
w = o.get("weight", 1)
try:
v = float(w)
except (TypeError, ValueError):
return 1.0
return v if v >= 0 else 0.0
def _close(text: str, start: int) -> int | None:
depth = 0
i = start
while i < len(text):
ch = text[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return i
i += 1
return None
def _split_top(body: str) -> list[str]:
parts: list[str] = []
cur: list[str] = []
depth = i = 0
while i < len(body):
ch = body[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if ch == "|" and depth == 0:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
i += 1
parts.append("".join(cur))
return parts
def export_items(items: list[dict[str, Any]], pack: str) -> PlainExport:
"""Convert module / template rows into plain wildcard files."""
return _Converter(slug(pack, "pack")).convert(items)
def export_envelope(env: dict[str, Any], pack: str) -> PlainExport:
"""Convert an 8-bucket export (``engine.exporter``) into plain files."""
return export_items(_items_from_envelope(env), pack)
+5 -5
View File
@@ -528,10 +528,10 @@
"outputs": [],
"properties": {},
"widgets_values": [
"{\"version\": 1, \"tone\": \"step\", \"look\": \"card\", \"step\": 3, \"icon\": \"info-circle\", \"text\": \"**WP Toggles** in *Pick one* mode: pick **Quick** for a fast, rough pass of the whole grid, **Full** for the real thing.\\n\\n- The pick's value goes to `$steps`; **Var → Int** hands it to the sampler.\\n- Each choice also writes its own true / false: **Var → Bool** reads `$quick_preview` and the **If/Else Switch** picks the smaller latent.\\n\\n[WP Toggles](docs:wp-toggles){button pi-sliders-h} [Var → Bool](docs:wp-var-to-bool){button pi-check-square}\", \"color\": \"warning\", \"bookmark\": {\"anchor\": \"custom\", \"view\": {\"x\": 3440, \"y\": 640}, \"zoomMode\": \"fixed\", \"zoom\": 0.6, \"shortcut\": \"3\", \"order\": 3}}"
"{\"version\": 1, \"tone\": \"step\", \"look\": \"card\", \"step\": 3, \"icon\": \"info-circle\", \"text\": \"**WP Toggles** in *Pick one* mode: pick **Quick** for a fast, rough pass of the whole grid, **Full** for the real thing.\\n\\n- The pick's value goes to `$steps`; **Var → Int** hands it to the sampler.\\n- Each choice also has its own switch: **Var → Bool** reads `$quick_preview.bool` (on or off; `$quick_preview` itself holds the value) and the **If/Else Switch** picks the smaller latent.\\n\\n[WP Toggles](docs:wp-toggles){button pi-sliders-h} [Var → Bool](docs:wp-var-to-bool){button pi-check-square}\", \"color\": \"warning\", \"bookmark\": {\"anchor\": \"custom\", \"view\": {\"x\": 3440, \"y\": 640}, \"zoomMode\": \"fixed\", \"zoom\": 0.6, \"shortcut\": \"3\", \"order\": 3}}"
],
"widgets_values_named": {
"wp_note": "{\"version\": 1, \"tone\": \"step\", \"look\": \"card\", \"step\": 3, \"icon\": \"info-circle\", \"text\": \"**WP Toggles** in *Pick one* mode: pick **Quick** for a fast, rough pass of the whole grid, **Full** for the real thing.\\n\\n- The pick's value goes to `$steps`; **Var → Int** hands it to the sampler.\\n- Each choice also writes its own true / false: **Var → Bool** reads `$quick_preview` and the **If/Else Switch** picks the smaller latent.\\n\\n[WP Toggles](docs:wp-toggles){button pi-sliders-h} [Var → Bool](docs:wp-var-to-bool){button pi-check-square}\", \"color\": \"warning\", \"bookmark\": {\"anchor\": \"custom\", \"view\": {\"x\": 3440, \"y\": 640}, \"zoomMode\": \"fixed\", \"zoom\": 0.6, \"shortcut\": \"3\", \"order\": 3}}"
"wp_note": "{\"version\": 1, \"tone\": \"step\", \"look\": \"card\", \"step\": 3, \"icon\": \"info-circle\", \"text\": \"**WP Toggles** in *Pick one* mode: pick **Quick** for a fast, rough pass of the whole grid, **Full** for the real thing.\\n\\n- The pick's value goes to `$steps`; **Var → Int** hands it to the sampler.\\n- Each choice also has its own switch: **Var → Bool** reads `$quick_preview.bool` (on or off; `$quick_preview` itself holds the value) and the **If/Else Switch** picks the smaller latent.\\n\\n[WP Toggles](docs:wp-toggles){button pi-sliders-h} [Var → Bool](docs:wp-var-to-bool){button pi-check-square}\", \"color\": \"warning\", \"bookmark\": {\"anchor\": \"custom\", \"view\": {\"x\": 3440, \"y\": 640}, \"zoomMode\": \"fixed\", \"zoom\": 0.6, \"shortcut\": \"3\", \"order\": 3}}"
},
"id": 11,
"pos": [
@@ -697,16 +697,16 @@
}
],
"properties": {
"var_name": "$quick_preview",
"var_name": "$quick_preview.bool",
"Node name for S&R": "WP_VarToBool"
},
"widgets_values": [
"$quick_preview",
"$quick_preview.bool",
0,
false
],
"widgets_values_named": {
"wp_var_name": "$quick_preview",
"wp_var_name": "$quick_preview.bool",
"index": 0,
"default": false
},
+22 -20
View File
@@ -26,6 +26,7 @@
"kit:baseline": "WP_WRITE_KIT_BASELINE=1 vitest run scripts/kit-guard.test.mjs",
"test": "vitest run --passWithNoTests",
"docs:help": "WP_WRITE_NODE_HELP=1 vitest run src/manager/docs/__tests__/node-help.test.ts",
"notes:assets": "WP_WRITE_NOTE_ASSETS=1 vitest run src/components/note/note-assets.test.ts",
"docs:index": "WP_WRITE_DOCS_INDEX=1 vitest run src/manager/docs/__tests__/docs-index.test.ts",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
@@ -34,37 +35,38 @@
"announce": "node scripts/discord-announce.mjs"
},
"dependencies": {
"@dumiflex/wp-kit": "^0.14.0",
"@dumiflex/wp-kit": "^0.19.0",
"pinia": "^2.2.6",
"primeicons": "^7.0.0",
"vue": "^3.5.44",
"primeicons": "^8.0.2",
"vue": "^3.5.45",
"vue-router": "^4.4.5",
"zod": "^3.25.76"
"zod": "^4.6.7"
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.42.15",
"@comfyorg/comfyui-frontend-types": "^1.57.6",
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-vue": "^5.1.4",
"@tailwindcss/postcss": "^4.3.5",
"@typescript-eslint/eslint-plugin": "^8.72.1",
"@typescript-eslint/parser": "^8.72.1",
"@vitejs/plugin-vue": "^6.0.10",
"@vitest/coverage-v8": "^2.1.9",
"@vue/test-utils": "^2.4.8",
"@vue/tsconfig": "^0.5.1",
"autoprefixer": "^10.4.20",
"@vue/test-utils": "^2.5.1",
"@vue/tsconfig": "^0.9.1",
"autoprefixer": "^10.6.2",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.31.0",
"eslint-plugin-vuejs-accessibility": "^2.5.0",
"eslint-plugin-vuejs-accessibility": "^2.6.3",
"husky": "^9.1.6",
"jsdom": "^29.0.2",
"playwright": "1.63.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"jsdom": "^31.0.0",
"playwright": "1.65.0",
"postcss": "^8.5.32",
"tailwindcss": "^4.3.5",
"typescript": "^6.0.3",
"vite": "^5.4.10",
"vite-plugin-css-injected-by-js": "^3.5.2",
"vite-plugin-css-injected-by-js": "^4.0.1",
"vitest": "^2.1.4",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^2.1.10"
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.14"
}
}
+932 -1056
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 163 KiB

+18 -1
View File
@@ -374,7 +374,24 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// RAISED 630 -> 640 KB on 2026-11-04 for pictures in WP Note: the
// Pictures panel, the canvas re-encode (note-images.ts) and the picture
// syntax ride in the note's lazy editor chunk (dev was already ~636.9 KB).
const TOTAL_LIMIT = 640 * 1024; // 640 KB
// RAISED 640 -> 650 KB on 2026-11-10: kit 0.14 -> 0.15.3 (colour picker
// fixes, PostCard samples, overlay-scrollbar room) put the total at ~640.9 KB.
// RAISED 650 -> 655 KB on 2026-11-11: WP Debug's newer-node support (node
// cards in Variables, Show node blink, toggle values/.bool) put it at ~650.04 KB.
// RAISED 655 -> 665 KB on 2026-11-12: every docs screenshot can go in a WP
// Note (note-assets.json + the kit's ImageGrid/SearchInput in the note's lazy
// editor chunk) put the total at ~652.9 KB.
// RAISED 660 -> 670 KB on 2026-11-12: with both of those, WP Toggles' toggle
// window (Value box, icon picker and colour fields in a lazy chunk) and the
// toggle looks put the total at ~660.0 KB, 9 bytes under the old line.
// RAISED 670 -> 685 KB on 2026-11-12 for zod 3 -> 4 (Dependabot): the lazy
// shape-check chunk (Note add-to-library press, Import tab) grew 17.9 ->
// 33.5 KB gzip, putting the total at ~684.0 KB. Moving the validators to
// `zod/mini` could win most of it back.
// RAISED 685 -> 700 KB on 2026-11-12 for kit 0.19.0: the shared row drag
// (useRowDrag + row-drag.css, now in the Toggles and Injector chunks) and the
// audit's eight kit pieces put the total at ~687.9 KB.
const TOTAL_LIMIT = 700 * 1024; // 700 KB
// The total budget is a guide, not a cap (Dumi, 2026-10-06), so the gate has
// two bands: over TOTAL_LIMIT it warns (CI stays green and shows a
// `::warning` annotation), and only past TOTAL_LIMIT + 5% does it fail. A
+5 -4
View File
@@ -421,8 +421,9 @@ def batches_and_grids() -> None:
"**WP Toggles** in *Pick one* mode: pick **Quick** for a fast, rough pass of the whole "
"grid, **Full** for the real thing.\n\n"
"- The pick's value goes to `$steps`; **Var → Int** hands it to the sampler.\n"
"- Each choice also writes its own true / false: **Var → Bool** reads "
"`$quick_preview` and the **If/Else Switch** picks the smaller latent.\n\n"
"- Each choice also has its own switch: **Var → Bool** reads "
"`$quick_preview.bool` (on or off; `$quick_preview` itself holds the value) and "
"the **If/Else Switch** picks the smaller latent.\n\n"
"[WP Toggles](docs:wp-toggles){button pi-sliders-h} "
"[Var → Bool](docs:wp-var-to-bool){button pi-check-square}",
step=3, color="warning", bookmark=view(3440, 640, 0.6, "3", 3))})
@@ -437,8 +438,8 @@ def batches_and_grids() -> None:
props={"var_name": "$steps"},
widgets={"wp_var_name": "$steps", "index": 0, "default": 30})
quick = wf.add("WP_VarToBool", (2960, ny + 460), (460, 180), title="Quick preview?",
props={"var_name": "$quick_preview"},
widgets={"wp_var_name": "$quick_preview", "index": 0, "default": False})
props={"var_name": "$quick_preview.bool"},
widgets={"wp_var_name": "$quick_preview.bool", "index": 0, "default": False})
small = wf.add("EmptyLatentImage", (3480, ny), (420, 110), title="Quick size",
widgets={"width": 512, "height": 768, "batch_size": 1})
full = wf.add("EmptyLatentImage", (3480, ny + 170), (420, 110), title="Full size",
+2 -2
View File
@@ -42,8 +42,8 @@ from engine.db.repositories import ( # noqa: E402
CATEGORIES = [
{"name": "Subjects", "color": "#a78bfa", "icon": "pi-user", "sort_order": 0},
{"name": "Style", "color": "#f472b6", "icon": "pi-palette", "sort_order": 1},
{"name": "Subjects", "color": "#a78bfa", "icon": "user", "sort_order": 0},
{"name": "Style", "color": "#f472b6", "icon": "palette", "sort_order": 1},
{"name": "Composition", "color": "#34d399", "icon": "pi-objects-column", "sort_order": 2},
{"name": "Edge cases", "color": "#fb7185", "icon": "pi-exclamation-triangle", "sort_order": 3},
{"name": "Quality", "color": "#fbbf24", "icon": "pi-sparkles", "sort_order": 4},
+25 -1
View File
@@ -40,7 +40,27 @@ const ONLY_PALETTES = opt("palettes", "")?.split(",").filter(Boolean);
// Kept in step with LOOK_OPTIONS / PALETTE_OPTIONS (tweaksStore.ts); the
// vitest guard theme-audit-ids.test.ts fails when they drift.
export const LOOKS = ["orbit", "crisp", "soft", "terminal", "editorial", "compact", "tui"];
export const PALETTES = ["orbit", "native", "nebula", "tide", "ember", "contrast"];
export const PALETTES = ["orbit", "native", "nebula", "tide", "ember", "contrast", "comfyui"];
/** serve.py runs no ComfyUI, so the "ComfyUI palette" theme gets its
* `/api/settings` from here: a dark imported palette (teal, like a user's
* own) and Easy-Use's Milk White, light with no `light_theme` flag. */
const COMFY_SETTINGS = {
dark: {
"Comfy.ColorPalette": "aurora_test",
"Comfy.CustomColorPalettes": { aurora_test: { id: "aurora_test", name: "Aurora", colors: { litegraph_base: {
NODE_DEFAULT_BGCOLOR: "#0f3d3e", WIDGET_BGCOLOR: "#0b2e2f", WIDGET_TEXT_COLOR: "#d8f3ef",
WIDGET_SECONDARY_TEXT_COLOR: "#8fbfb8", CLEAR_BACKGROUND_COLOR: "#082222",
} } } },
},
light: {
"Comfy.ColorPalette": "milk_white",
"Comfy.CustomColorPalettes": { milk_white: { id: "milk_white", name: "Milk White", colors: { litegraph_base: {
NODE_DEFAULT_BGCOLOR: "#F5F5F5", WIDGET_BGCOLOR: "#D4D4D4", WIDGET_TEXT_COLOR: "#222",
WIDGET_SECONDARY_TEXT_COLOR: "#555",
} } } },
},
};
/** Ids from bench/bench-library.json, which serve.py seeds. */
const SCREENS = [
@@ -378,8 +398,12 @@ async function run() {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
let current = "";
let comfySettings = COMFY_SETTINGS.dark;
await context.route((url) => url.pathname === "/api/settings", (route) =>
route.fulfill({ contentType: "application/json", body: JSON.stringify(comfySettings) }));
const page = await context.newPage();
for (const r of runs) {
comfySettings = COMFY_SETTINGS[r.mode];
const key = `${r.look}|${r.palette}|${r.mode}`;
if (key !== current) {
current = key;
+1
View File
@@ -50,6 +50,7 @@ export * as nodeToolbarMod from "./extension/node-toolbar";
export { stripControlInputs } from "./extension/toggle-control";
export * as imageFilterMod from "./extension/image-filter";
export * as graphEventsMod from "./extension/graph-events";
export * as badgeSlotMod from "./extension/badge-slot";
export * as graphMod from "./extension/graph";
export * as assemblerVarsMod from "./extension/assembler-vars";
export * as toastStoreMod from "./components/shared/toast-store";
@@ -338,6 +338,24 @@ describe("AssemblerHelper var-color rendering", () => {
expect(btn.element.disabled).toBe(false);
});
it("mark-unused button fires and is off when both boxes use every variable", async () => {
const onMarkUnused = vi.fn();
const wrapper = mount(AssemblerHelper, {
props: { upstreamVars: ["mood", "hero"], template: "$mood", onMarkUnused },
});
const btn = wrapper.find<HTMLButtonElement>('[data-test="asm-mark-unused"]');
expect(btn.element.disabled).toBe(false);
await btn.trigger("click");
expect(onMarkUnused).toHaveBeenCalledTimes(1);
await wrapper.setProps({ negativeTemplate: "$negatives, $hero.neg" });
expect(btn.element.disabled).toBe(true);
});
it("mark-unused button is absent without a handler", () => {
const wrapper = mount(AssemblerHelper, { props: { upstreamVars: ["mood"], template: "" } });
expect(wrapper.find('[data-test="asm-mark-unused"]').exists()).toBe(false);
});
it("preview area shows empty-state ghost when template is empty", () => {
const wrapper = mount(AssemblerHelper, {
props: { upstreamVars: ["mood"], template: "" },
@@ -542,3 +560,28 @@ describe("AssemblerHelper negative preview", () => {
expect(w.find('[data-test="asm-neg-preview"]').exists()).toBe(false);
});
});
describe("AssemblerHelper.vue with no Context", () => {
it("calls a template with no $names plain text and says why the variables row is empty", () => {
const w = mount(AssemblerHelper, {
props: { upstreamVars: [], templateVars: [], template: "1girl blue hair", resolved: "1girl blue hair" },
});
expect(w.find('[data-test="asm-preview-status"]').text()).toBe("plain text");
expect(w.find('[data-test="asm-vars-none"]').exists()).toBe(true);
});
it("counts unresolved $names when nothing sets them", () => {
const w = mount(AssemblerHelper, {
props: { upstreamVars: [], templateVars: ["hair"], template: "a $hair girl", resolved: "" },
});
expect(w.find('[data-test="asm-preview-status"]').text()).toBe("1 unresolved");
expect(w.find('[data-test="asm-vars-none"]').exists()).toBe(false);
});
it("drops the placeholder once a variable comes in", () => {
const w = mount(AssemblerHelper, {
props: { upstreamVars: ["hair"], templateVars: [], template: "x", resolved: "x" },
});
expect(w.find('[data-test="asm-vars-none"]').exists()).toBe(false);
});
});
+54 -3
View File
@@ -69,6 +69,10 @@ const props = defineProps<{
/** Open the load-template-from-library picker. Toolbar Load button.
* Optional. */
onLoadTemplate?: () => void;
/** Mark every upstream variable neither box reads as internal (keeping
* any another Assembler on the same context reads). Toolbar button;
* optional. */
onMarkUnused?: () => void;
/** Per-var module-kind lookup. When known, each chip renders a
* small pi-icon matching the source module's kind (wildcard,
* fixed_values, combine, derivation, constraint,
@@ -126,6 +130,17 @@ const templateVarsInternal = computed(() => {
return [...new Set(matches.map((m) => m.slice(1)).filter((v) => !v.startsWith("__")))];
});
/** Upstream variables neither the prompt nor the negative box reads — what
* the Mark unused button would look at (other Assemblers are checked when
* it is pressed). */
const unusedHere = computed(() => {
const used = new Set<string>();
for (const text of [props.template, props.negativeTemplate ?? ""]) {
for (const m of text.replace(/\$\$/g, ESCAPE_PLACEHOLDER).matchAll(VAR_RE)) used.add(m[1]);
}
return upstreamNames.value.filter((v) => !used.has(v));
});
/**
* Variables referenced in the template but NOT present in the upstream list.
*/
@@ -304,6 +319,17 @@ const isResolved = computed(() => {
return Boolean(props.resolved);
});
/** The preview header's note. A template with no `$names` is plain text: it
* passes through as typed (the node then works as a multiline text box with
* its own negative), so it is neither "empty" nor "unresolved". */
const previewStatus = computed<{ text: string; tone: "ok" | "warn" | "" }>(() => {
if (!props.template.trim()) return { text: "(template empty)", tone: "" };
if (templateVarsInternal.value.length === 0) return { text: "plain text", tone: "ok" };
if (missing.value.length > 0) return { text: `${missing.value.length} unresolved`, tone: "warn" };
if (isResolved.value) return { text: "resolved", tone: "ok" };
return { text: "(not resolved yet)", tone: "" };
});
// ---------------------------------------------------------------------------
// Ripple effect
// ---------------------------------------------------------------------------
@@ -485,6 +511,19 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
aria-label="Save template"
@click="onSaveTemplate"
/>
<Button
v-if="onMarkUnused"
size="sm"
variant="outline"
icon="pi-eye-slash"
:disabled="unusedHere.length === 0"
data-test="asm-mark-unused"
:hint="unusedHere.length
? `Mark the ${unusedHere.length} unused ${unusedHere.length === 1 ? 'variable' : 'variables'} internal (keeps any that another Assembler uses)`
: 'Every variable here is in the prompt or the negative'"
aria-label="Mark unused variables internal"
@click="onMarkUnused"
/>
<Button
v-if="onClearTemplate"
size="sm"
@@ -537,14 +576,20 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
@click="onRemoveVar?.(v)"
@contextmenu="(ev) => openChipMenu(ev, v, true)"
><i class="pi pi-exclamation-triangle" aria-hidden="true" />{{ v }}</span>
<span
v-if="upstreamNames.length === 0 && missing.length === 0"
class="wp-asm-vars__none"
data-test="asm-vars-none"
>No variables here: wire a Context or Injector to use $names. Plain text passes through as typed.</span>
</div>
<!-- preview section -->
<div class="wp-asm-section">
<span>preview</span>
<span :class="['wp-asm-section-stat', isResolved ? 'is-ok' : '']">
{{ isResolved ? "resolved" : "(template empty or unresolved)" }}
</span>
<span
:class="['wp-asm-section-stat', { 'is-ok': previewStatus.tone === 'ok', 'wp-asm-section-stat--warn': previewStatus.tone === 'warn' }]"
data-test="asm-preview-status"
>{{ previewStatus.text }}</span>
</div>
<div
class="wp-asm-preview"
@@ -647,6 +692,12 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
}
.wp-asm-section-stat--warn { color: var(--wp-warn); }
.wp-asm-section-stat.is-ok { color: var(--wp-green); }
.wp-asm-vars__none {
color: var(--wp-text-dim);
font-size: 10px;
line-height: 1.4;
padding: 2px 4px;
}
.wp-asm-section-stat--neg {
padding: 2px 6px;
border-radius: min(3px, var(--wp-radius-cap, 999px));
+21 -52
View File
@@ -13,7 +13,7 @@ import { computed, nextTick, ref, watch } from "vue";
import WpSelect from "@/kit/WpSelect.vue";
import { api } from "../../manager/api/client";
import type { TemplateRow } from "../../manager/api/types";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Skeleton from "@/kit/Skeleton.vue";
import Button from "@/kit/Button.vue";
import Chip from "@/kit/Chip.vue";
@@ -128,19 +128,17 @@ function pick(row: TemplateRow) {
</script>
<template>
<ModalShell :visible="open" labelledby="wp-ltm-title" @close="emit('close')">
<div class="wp-ltm" @click.stop>
<header class="wp-ltm__head">
<div class="wp-ltm__head-text">
<div class="wp-ltm__eyebrow">Load template from library</div>
<div id="wp-ltm-title" class="wp-ltm__title">
Browse Templates
<span class="wp-ltm__title-detail">· {{ rows.length }} {{ rows.length === 1 ? "template" : "templates" }}</span>
</div>
<Modal :open="open" size="lg" flush accent="brand" icon="folder-open" @update:open="(v: boolean) => { if (!v) emit('close'); }">
<template #header="{ titleId }">
<div class="wp-ltm__head-text">
<div class="wp-ltm__eyebrow">Load template from library</div>
<div :id="titleId" class="wp-ltm__title">
Browse Templates
<span class="wp-ltm__title-detail">· {{ rows.length }} {{ rows.length === 1 ? "template" : "templates" }}</span>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="load-tpl-close" @click="emit('close')" />
</header>
</div>
</template>
<div class="wp-ltm">
<div class="wp-ltm__toolbar">
<SearchInput
ref="searchInput"
@@ -269,49 +267,26 @@ function pick(row: TemplateRow) {
</template>
</div>
<footer class="wp-ltm__foot">
<span class="wp-ltm__hint">Click a template to load it into the assembler.</span>
<span class="wp-ltm__foot-spacer" />
<Button size="sm" data-test="load-tpl-cancel" @click="emit('close')">Cancel</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<span class="wp-ltm__hint">Click a template to load it into the assembler.</span>
<span class="wp-ltm__foot-spacer" />
<Button size="sm" data-test="load-tpl-cancel" @click="emit('close')">Cancel</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-ltm {
background: var(--wp-bg-2);
border: 1px solid var(--wp-border-strong, var(--wp-border));
border-radius: var(--wp-radius-lg, 12px);
box-shadow: var(--wp-shadow-lg, 0 20px 60px rgba(0, 0, 0, 0.55));
width: 640px;
max-width: calc(100vw - 32px);
height: 560px;
max-height: calc(100vh - 32px);
height: 480px;
max-height: calc(100vh - 160px);
display: flex;
flex-direction: column;
color: var(--wp-text);
overflow: hidden;
font-family: var(--wp-font-sans, sans-serif);
}
/* Header — brand wash mirrors ModulePickerModal. */
.wp-ltm__head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient, transparent);
position: relative;
}
.wp-ltm__head::after {
content: "";
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
pointer-events: none;
}
.wp-ltm__head > * { position: relative; z-index: 1; }
.wp-ltm__head-text { flex: 1; min-width: 0; }
.wp-ltm__head-text { flex: 1 1 auto; min-width: 0; }
.wp-ltm__eyebrow {
font-size: 11px;
text-transform: uppercase;
@@ -453,12 +428,6 @@ function pick(row: TemplateRow) {
}
/* Footer */
.wp-ltm__foot {
height: 44px; padding: 0 16px;
display: flex; align-items: center; gap: 10px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg-1);
color: var(--wp-text-dim); font-size: 11px;
}
.wp-ltm__foot-spacer { flex: 1; }
.wp-ltm__hint { align-self: center; color: var(--wp-text-dim); font-size: 11px; }
</style>
@@ -102,7 +102,9 @@ const summary = computed(() => {
flex: 0 0 26px;
padding: 0 2px;
color: var(--wp-danger);
font: 500 11px/1 var(--wp-font-sans);
/* Line height above 1: the label and summary clip their overflow, so a
tight 1 cut the tails off g, p and y. */
font: 500 11px/1.4 var(--wp-font-sans);
text-transform: lowercase;
}
.wp-negbox__head:hover:not(:disabled) { color: var(--wp-danger); }
@@ -114,7 +116,7 @@ const summary = computed(() => {
text-overflow: ellipsis;
white-space: nowrap;
color: var(--wp-text-dim);
font: 11px/1 var(--wp-font-mono);
font: 11px/1.4 var(--wp-font-mono);
text-transform: none;
}
.wp-negbox__body {
+23 -65
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { api } from "../../manager/api/client";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Textarea from "@/kit/Textarea.vue";
import Input from "@/kit/Input.vue";
import WpSelect from "@/kit/WpSelect.vue";
@@ -100,12 +100,8 @@ async function submit(mode: "update" | "new") {
</script>
<template>
<ModalShell :visible="open" labelledby="wp-stm-title" @close="emit('close')">
<div class="wp-stm" @click.stop>
<header class="wp-stm__head">
<h3 id="wp-stm-title" class="wp-stm__title">Save template</h3>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="save-tpl-close" @click="emit('close')" />
</header>
<Modal :open="open" title="Save template" size="md" flush accent="brand" icon="save" @update:open="(v: boolean) => { if (!v) emit('close'); }">
<div class="wp-stm">
<div class="wp-stm__body">
<div class="wp-stm-field">
@@ -182,60 +178,30 @@ async function submit(mode: "update" | "new") {
<p v-if="errorMsg" class="wp-stm-error" data-test="save-tpl-error">{{ errorMsg }}</p>
</div>
<footer class="wp-stm__foot">
<Button size="sm" data-test="save-tpl-cancel" @click="emit('close')">Cancel</Button>
<span class="wp-stm-spacer" />
<Button
v-if="updateTargetId"
size="sm"
:disabled="!canSubmit"
data-test="save-tpl-save-new"
@click="submit('new')"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
:disabled="!canSubmit"
data-test="save-tpl-submit"
@click="submit(updateTargetId ? 'update' : 'new')"
>{{ updateTargetId ? "Update existing" : "Save" }}</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<Button size="sm" data-test="save-tpl-cancel" @click="emit('close')">Cancel</Button>
<span class="wp-stm-spacer" />
<Button
v-if="updateTargetId"
size="sm"
:disabled="!canSubmit"
data-test="save-tpl-save-new"
@click="submit('new')"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
:disabled="!canSubmit"
data-test="save-tpl-submit"
@click="submit(updateTargetId ? 'update' : 'new')"
>{{ updateTargetId ? "Update existing" : "Save" }}</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-stm {
width: min(560px, 100%);
max-height: calc(100vh - 40px);
background: var(--wp-bg-1);
border: 1px solid var(--wp-border-strong, var(--wp-border));
border-radius: var(--wp-radius-lg, 12px);
box-shadow: var(--wp-shadow-lg, 0 20px 60px rgba(0, 0, 0, 0.55));
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--wp-text);
font-family: var(--wp-font-sans, sans-serif);
}
.wp-stm__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient, transparent);
position: relative;
}
.wp-stm__head::after {
content: "";
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
pointer-events: none;
}
.wp-stm__head > * { position: relative; z-index: 1; }
.wp-stm__title { margin: 0; font-size: 14px; font-weight: 600; }
.wp-stm { display: flex; flex-direction: column; color: var(--wp-text); font-family: var(--wp-font-sans, sans-serif); }
.wp-stm__body {
flex: 1;
@@ -280,13 +246,5 @@ async function submit(mode: "update" | "new") {
color: var(--wp-danger);
}
.wp-stm__foot {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 16px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg-1);
}
.wp-stm-spacer { flex: 1; }
</style>
+5 -6
View File
@@ -138,8 +138,8 @@ function suggest(): void {
</script>
<template>
<div class="wp-bmf">
<div class="wp-bmf__row wp-bmf__row--top">
<div class="wp-bmf wp-frameless__pass">
<div class="wp-bmf__row wp-bmf__row--top wp-frameless__pass">
<span class="wp-bmf__label">Lands at</span>
<div class="wp-bmf__grid" role="radiogroup" aria-label="Where the bookmark lands on screen">
<WpIconButton
@@ -167,7 +167,7 @@ function suggest(): void {
</div>
</div>
<div class="wp-bmf__row">
<div class="wp-bmf__row wp-frameless__pass">
<span class="wp-bmf__label">Zoom</span>
<WpSegmented
:model-value="value.zoomMode"
@@ -199,7 +199,7 @@ function suggest(): void {
</template>
</div>
<div class="wp-bmf__row">
<div class="wp-bmf__row wp-frameless__pass">
<span class="wp-bmf__label">Shortcut</span>
<Button
size="sm"
@@ -223,7 +223,7 @@ function suggest(): void {
@click="emit('update', { shortcut: '' })"
/>
</div>
<p v-if="refused" class="wp-bmf__refused" role="alert" data-test="bm-refused">{{ refused }}</p>
<Banner v-if="refused" tone="danger" size="sm" data-test="bm-refused">{{ refused }}</Banner>
<Banner
v-else-if="conflicts.length && worst !== 'info'"
:tone="worst"
@@ -276,6 +276,5 @@ function suggest(): void {
.wp-bmf__record { min-width: 86px; }
.wp-bmf__hint { font-size: 10.5px; color: var(--wp-text-dim); }
.wp-bmf__hint--block { margin: 0; padding: 0 6px; }
.wp-bmf__refused { margin: 0; padding: 0 6px; font-size: 11px; color: var(--wp-danger); }
.wp-bmf__conflict { display: block; }
</style>
@@ -118,3 +118,15 @@ describe("BookmarkWidget", () => {
w.unmount();
});
});
describe("BookmarkWidget drag while editing", () => {
it("lets the form's background through: every pass part sits right under another one", () => {
const w = make();
const form = w.get('[data-test="bm-form"]').element;
expect(form.classList.contains("wp-frameless__pass")).toBe(true);
const parts = [...form.querySelectorAll(".wp-frameless__pass")];
expect(parts.length).toBeGreaterThan(3);
for (const p of parts) expect(p.parentElement?.classList.contains("wp-frameless__pass"), p.className).toBe(true);
w.unmount();
});
});
+8 -9
View File
@@ -131,14 +131,14 @@ function done(): void {
<!-- Keys typed here never reach ComfyUI's keybindings. -->
<form
v-else
class="wp-bm__form"
class="wp-bm__form wp-frameless__pass"
data-test="bm-form"
@submit.prevent="done"
@keydown.stop
@keyup.stop
@keypress.stop
>
<div class="wp-bm__head">
<div class="wp-bm__head wp-frameless__pass">
<Button
size="sm"
:icon="`pi-${config.icon}`"
@@ -190,7 +190,7 @@ function done(): void {
@recording="(on: boolean) => emit('recording', on)"
/>
<div class="wp-bm__row">
<div class="wp-bm__row wp-frameless__pass">
<WpSwitch
:model-value="showOnCanvas"
size="sm"
@@ -206,7 +206,7 @@ function done(): void {
@update:model-value="(v: boolean) => emit('update:pinned', v)"
/>
</div>
<div class="wp-bm__actions">
<div class="wp-bm__actions wp-frameless__pass">
<span v-if="nested" class="wp-bm__hint">In a subgraph: the jump opens it.</span>
<span class="wp-bm__spacer"></span>
<Button size="sm" icon="pi-directions" hint="Try the jump" data-test="bm-go" @click="emit('jump')">Go</Button>
@@ -267,8 +267,9 @@ function done(): void {
.wp-bm__offtag { color: var(--wp-text-dim); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.04em; }
/* ── editor card ────────────────────────────────────────────────────── */
/* Presses on its background go through, so it drags while open
* (frameless-node.css: wp-frameless__pass). */
.wp-bm__form {
pointer-events: auto;
display: flex;
flex-direction: column;
gap: 6px;
@@ -304,11 +305,9 @@ function done(): void {
@layer wp-extension {
/* Nodes 2.0: the widget wrappers take pointer events and would eat the
press that drags the node; the canvas handles it instead. The editor
takes events back. */
press that drags the node; the canvas handles it instead. The editor's
controls take events back (frameless-node.css: wp-frameless__pass). */
.lg-node:has(.wp-bm) .lg-node-widgets,
.lg-node:has(.wp-bm) .lg-node-widgets * { pointer-events: none !important; }
.lg-node:has(.wp-bm) .wp-bm__form,
.lg-node:has(.wp-bm) .wp-bm__form * { pointer-events: auto !important; }
}
</style>
+8 -31
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Body-teleported modal for the cleaner's blocklist. Mode toggle:
* Kit Modal for the cleaner's blocklist. Mode toggle:
* - "list" — comma- or newline-separated, exact tag match (case-insensitive)
* - "regex" — one regex per line, case-insensitive
*
@@ -8,7 +8,7 @@
* Cancel discards. Parent gates visibility via `visible`.
*/
import { ref, watch, computed } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Textarea from "@/kit/Textarea.vue";
import type { BlocklistKind } from "./types";
import Button from "@/kit/Button.vue";
@@ -60,11 +60,8 @@ function onCancel(): void {
</script>
<template>
<ModalShell :visible="visible" @close="onCancel">
<Modal :open="visible" title="Blocklist" size="md" flush @update:open="(v: boolean) => { if (!v) onCancel(); }">
<div class="wp-blocklist">
<header class="wp-blocklist__head">
<span class="wp-blocklist__title">Blocklist</span>
</header>
<div class="wp-blocklist__mode-row">
<span class="wp-blocklist__label">MODE</span>
<WpSegmented
@@ -93,28 +90,15 @@ function onCancel(): void {
</span>
</div>
</div>
<footer class="wp-blocklist__foot">
<Button size="sm" data-test="blocklist-cancel" @click="onCancel">Cancel</Button>
<Button size="sm" variant="primary" data-test="blocklist-save" @click="onSave">Save</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<Button size="sm" data-test="blocklist-cancel" @click="onCancel">Cancel</Button>
<Button size="sm" variant="primary" data-test="blocklist-save" @click="onSave">Save</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-blocklist {
width: 480px;
max-width: 90vw;
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius, 6px);
color: var(--wp-text);
}
.wp-blocklist__head {
padding: 12px 16px;
border-bottom: 1px solid var(--wp-border);
}
.wp-blocklist__title { font-size: 14px; font-weight: 600; }
.wp-blocklist__mode-row {
padding: 10px 16px;
border-bottom: 1px solid var(--wp-border);
@@ -151,11 +135,4 @@ function onCancel(): void {
display: flex;
gap: 6px;
}
.wp-blocklist__foot {
padding: 10px 16px 12px;
display: flex;
gap: 6px;
justify-content: flex-end;
border-top: 1px solid var(--wp-border);
}
</style>
@@ -240,7 +240,7 @@ function toggleTotalInternal(): void {
data-test="loop-seeds-btn"
@click="seedsOpen = true"
>
Per-iteration seeds
<span class="wp-loop__seedbtn-text">Per-iteration seeds</span>
<span class="wp-loop__seedbtn-fill" />
<span v-if="lockedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-badge">{{ lockedCount }} locked</span>
<span v-if="bypassedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
@@ -256,7 +256,7 @@ function toggleTotalInternal(): void {
hint="Run every combination of the wildcards you pick, one frame each."
@click="sweepOpen = true"
>
Sweep combinations
<span class="wp-loop__seedbtn-text">Sweep combinations</span>
<span class="wp-loop__seedbtn-fill" />
<span v-if="sweepFrames != null" class="wp-loop__seedbtn-badge" data-test="loop-sweep-badge">{{ sweepFrames }} {{ sweepFrames === 1 ? "frame" : "frames" }}</span>
<span v-else-if="modelValue.sweep.enabled" class="wp-loop__seedbtn-badge wp-loop__seedbtn-badge--dim" data-test="loop-sweep-badge">on</span>
@@ -294,7 +294,10 @@ function toggleTotalInternal(): void {
/>
</div>
<div class="wp-loop__section">
<div
class="wp-loop__section"
:data-wp-vars="`${modelValue.iteration_var_name} ${modelValue.iteration_var_name}_total`"
>
<div class="wp-loop__label">iteration var</div>
<div class="wp-loop__var-input" :data-wp-socket="valuesWired ? undefined : 'values'">
<Input
@@ -333,7 +336,7 @@ function toggleTotalInternal(): void {
</div>
</div>
<div v-if="valuesWired" class="wp-loop__values" data-wp-socket="values" data-test="loop-values-note">
<div v-if="valuesWired" class="wp-loop__values" data-wp-socket="values" data-test="loop-values-note" :data-wp-vars="`${modelValue.iteration_var_name}_value`">
<i class="pi pi-link" aria-hidden="true" />
<span>
One frame per line of the values input<template v-if="valuesCount != null"> ({{ valuesCount }} on the last run)</template>,
@@ -402,9 +405,20 @@ function toggleTotalInternal(): void {
.wp-loop__var-input { display: flex; align-items: center; gap: 4px; }
.wp-loop__var-input .wp-loop__var-text { flex: 1 1 0; min-width: 0; }
/* The kit Button wraps the slot in an inline label box, so the fill span
* can't push the badges and chevron right and the chevron rides the
* text's baseline. Make the label the flex row instead; the text keeps
* the kit's cap-height trim so it stays level with the icons. */
.wp-loop__seedbtn :deep(.wp-btn__label) { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; text-box: normal; }
/* Room for g, p, q and y: the line is trimmed to the baseline and the
clip (for the ellipsis) would cut them. */
.wp-loop__seedbtn-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; padding-block: 0.3em; margin-block: -0.3em; }
@supports (text-box: trim-both cap alphabetic) {
.wp-loop__seedbtn-text { text-box: trim-both cap alphabetic; }
}
.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: min(3px, var(--wp-radius-cap, 999px)); 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)); }
.wp-loop__seedbtn-chev { flex: none; 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. */
+18 -25
View File
@@ -7,12 +7,11 @@
* run, so the grid can be checked before anything is queued.
*/
import { computed } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import SweepPanel from "./SweepPanel.vue";
import { liveAxes, sweepTotal, type SweepConfig } from "./types";
import type { SweepCandidate } from "./sweep-candidates";
import Button from "@/kit/Button.vue";
import Icon from "@/kit/Icon.vue";
const props = withDefaults(
defineProps<{
@@ -63,17 +62,14 @@ const preview = computed(() => {
</script>
<template>
<ModalShell :visible="true" @close="emit('close')">
<div class="wp-swm" data-test="sweep-modal">
<div class="wp-swm__head">
<span class="wp-swm__head-icon"><Icon name="th-large" :size="15" /></span>
<div class="wp-swm__title-block">
<div class="wp-swm__title-row"><span class="wp-swm__name">WP Context Loop</span><span class="wp-swm__chip">Sweep</span></div>
<div class="wp-swm__sub">Run every combination of the wildcards you pick, one frame each.</div>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="sweep-modal-close" @click="emit('close')" />
<Modal :open="true" size="md" flush accent="var(--wp-accent)" icon="th-large" @update:open="(v: boolean) => { if (!v) emit('close'); }">
<template #header="{ titleId }">
<div class="wp-swm__title-block">
<div class="wp-swm__title-row"><span :id="titleId" class="wp-swm__name">WP Context Loop</span><span class="wp-swm__chip">Sweep</span></div>
<div class="wp-swm__sub">Run every combination of the wildcards you pick, one frame each.</div>
</div>
</template>
<div class="wp-swm" data-test="sweep-modal">
<div class="wp-swm__body">
<SweepPanel
:model-value="modelValue"
@@ -94,23 +90,21 @@ const preview = computed(() => {
</div>
</div>
<div class="wp-swm__foot">
<span class="wp-swm__foot-hint">Changes save as you make them.</span>
<span class="wp-swm__spacer" />
<Button size="sm" variant="primary" data-test="sweep-modal-done" @click="emit('close')">Done</Button>
</div>
</div>
</ModalShell>
<template #footer>
<span class="wp-swm__foot-hint">Changes save as you make them.</span>
<span class="wp-swm__spacer" />
<Button size="sm" variant="primary" data-test="sweep-modal-done" @click="emit('close')">Done</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-swm { width: 520px; max-width: 100%; max-height: 86vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: min(6px, var(--wp-radius-cap, 999px)); overflow: hidden; color: var(--wp-text); font-size: 12px; box-shadow: var(--wp-shadow-lg); }
.wp-swm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: color-mix(in srgb, var(--wp-accent) 9%, var(--wp-bg2)); }
.wp-swm__head-icon { color: var(--wp-accent); width: 24px; display: flex; justify-content: center; }
.wp-swm__title-block { flex: 1; min-width: 0; }
.wp-swm { display: flex; flex-direction: column; min-height: 0; color: var(--wp-text); font-size: 12px; }
.wp-swm__title-block { flex: 1 1 auto; min-width: 0; }
.wp-swm__title-row { display: flex; align-items: center; gap: 8px; }
.wp-swm__name { font: 600 13px var(--wp-font-sans); }
.wp-swm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent); }
.wp-swm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-modal-accent) 22%, transparent); color: var(--wp-modal-accent); }
.wp-swm__sub { font: 10.5px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); margin-top: 3px; }
.wp-swm__body { overflow-y: auto; padding: 12px 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; font-size: 11px; }
@@ -122,7 +116,6 @@ const preview = computed(() => {
.wp-swm__frame-vals { color: var(--wp-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-swm__frame-more { padding: 4px 0 0 42px; font: 10.5px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
.wp-swm__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); }
.wp-swm__foot-hint { font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
.wp-swm__foot-hint { align-self: center; font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
.wp-swm__spacer { flex: 1; }
</style>
+38 -73
View File
@@ -2,7 +2,7 @@
import Kbd from "@/kit/Kbd.vue";
import SearchInput from "@/kit/SearchInput.vue";
import { computed, nextTick, ref, watch } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import ConfirmModal from "@/kit/ConfirmModal.vue";
import { api } from "../../manager/api/client";
import type { BundleRow } from "../../manager/api/types";
@@ -178,19 +178,17 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
</script>
<template>
<ModalShell :visible="visible" label="Add bundle from library" @close="emit('close')" @keydown="onKeydown">
<div class="wp-bp">
<header class="wp-bp__head">
<div class="wp-bp__head-text">
<div class="wp-bp__eyebrow">Add bundle from library</div>
<div class="wp-bp__title">
Browse Bundles
<span class="wp-bp__title-detail">· {{ filtered.length }} {{ filtered.length === 1 ? "bundle" : "bundles" }}</span>
</div>
<Modal :open="visible" size="lg" flush accent="brand" icon="box" @update:open="(v: boolean) => { if (!v) emit('close'); }" @keydown="onKeydown">
<template #header="{ titleId }">
<div class="wp-bp__head-text">
<div class="wp-bp__eyebrow">Add bundle from library</div>
<div :id="titleId" class="wp-bp__title">
Browse Bundles
<span class="wp-bp__title-detail">· {{ filtered.length }} {{ filtered.length === 1 ? "bundle" : "bundles" }}</span>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" @click="emit('close')" />
</header>
</div>
</template>
<div class="wp-bp">
<div class="wp-bp__toolbar">
<SearchInput
ref="searchInput"
@@ -263,32 +261,32 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
</div>
</div>
<div class="wp-bp__foot">
<div class="wp-bp__hints">
<Kbd keys="↑" size="sm" />
<Kbd keys="↓" size="sm" />
<span class="wp-bp__hint-label">nav</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="Enter" size="sm" />
<span class="wp-bp__hint-label">insert</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="/" size="sm" />
<span class="wp-bp__hint-label">search</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="Esc" size="sm" />
<span class="wp-bp__hint-label">cancel</span>
</div>
<Button size="sm" @click="emit('close')">Cancel</Button>
<Button
size="sm"
variant="primary"
icon="pi-plus"
data-testid="bundle-picker-create"
@click="emit('create')"
>Create Bundle</Button>
</div>
</div>
</ModalShell>
<template #footer>
<div class="wp-bp__hints">
<Kbd keys="↑" size="sm" />
<Kbd keys="↓" size="sm" />
<span class="wp-bp__hint-label">nav</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="Enter" size="sm" />
<span class="wp-bp__hint-label">insert</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="/" size="sm" />
<span class="wp-bp__hint-label">search</span>
<span class="wp-bp__hint-sep">·</span>
<Kbd keys="Esc" size="sm" />
<span class="wp-bp__hint-label">cancel</span>
</div>
<Button size="sm" @click="emit('close')">Cancel</Button>
<Button
size="sm"
variant="primary"
icon="pi-plus"
data-testid="bundle-picker-create"
@click="emit('create')"
>Create Bundle</Button>
</template>
</Modal>
<ConfirmModal
:open="pendingReinsert !== null"
:title="confirmTitle"
@@ -305,37 +303,12 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
.wp-bp__skel-list { padding: 6px 16px; }
.wp-bp {
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: min(8px, var(--wp-radius-cap, 999px));
width: 720px;
max-width: 92vw;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--wp-shadow-lg, 0 20px 60px rgba(0, 0, 0, 0.5));
min-height: 0;
}
/* Header — brand gradient + dark wash overlay, matches edit modals. */
.wp-bp__head {
background: var(--wp-brand-gradient);
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--wp-border);
position: relative;
}
.wp-bp__head::after {
content: "";
position: absolute;
inset: 0;
/* Theme-aware wash so the brand gradient reads correctly in both palettes. */
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
pointer-events: none;
}
.wp-bp__head > * { position: relative; z-index: 1; }
.wp-bp__head-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.wp-bp__head-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.wp-bp__eyebrow {
font: 500 11px/1.2 var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
@@ -461,14 +434,6 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
/* Footer — keyboard hints inline with Cancel + Create. Matches the
* module picker pattern: the keys are the kit's Kbd. */
.wp-bp__foot {
border-top: 1px solid var(--wp-border);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
background: var(--wp-bg-deep, var(--wp-bg));
}
.wp-bp__hints {
display: inline-flex;
align-items: center;
+166 -9
View File
@@ -1966,7 +1966,7 @@ describe("ContextWidget drag-drop overhaul", () => {
}
}
it("dragover top half adds wp-gap-before (gap metaphor)", async () => {
it("dragover top half adds wp-rowdrag--before (gap metaphor)", async () => {
const wrapper = mountFour();
const cards = wrapper.findAll(".wp-module");
stubRects([
@@ -1981,14 +1981,14 @@ describe("ContextWidget drag-drop overhaul", () => {
await cards[1].trigger("dragover", { clientY: 40, dataTransfer: makeDataTransfer() });
await flushPromises();
expect(cards[1].classes()).toContain("wp-gap-before");
expect(cards[1].classes()).not.toContain("wp-gap-after");
expect(cards[1].classes()).toContain("wp-rowdrag--before");
expect(cards[1].classes()).not.toContain("wp-rowdrag--after");
await cards[0].trigger("dragend");
wrapper.unmount();
});
it("dragover bottom half canonicalises to wp-gap-before on next row", async () => {
it("dragover bottom half canonicalises to wp-rowdrag--before on next row", async () => {
// Single canonical zone per gap — bottom-half of row N projects to
// "before row N+1", so cards[2] receives the class, not cards[1].
const wrapper = mountFour();
@@ -2004,9 +2004,9 @@ describe("ContextWidget drag-drop overhaul", () => {
await cards[1].trigger("dragover", { clientY: 55, dataTransfer: makeDataTransfer() });
await flushPromises();
expect(cards[2].classes()).toContain("wp-gap-before");
expect(cards[1].classes()).not.toContain("wp-gap-after");
expect(cards[1].classes()).not.toContain("wp-gap-before");
expect(cards[2].classes()).toContain("wp-rowdrag--before");
expect(cards[1].classes()).not.toContain("wp-rowdrag--after");
expect(cards[1].classes()).not.toContain("wp-rowdrag--before");
await cards[0].trigger("dragend");
wrapper.unmount();
@@ -2335,9 +2335,13 @@ describe("ContextWidget bundle drag/drop regressions (Batch 2)", () => {
(body.element as HTMLElement).getBoundingClientRect = rectOf(30, 0);
}
// Drag std → bundle header bottom half (clientY in bottom 15px window).
// Drag std → the middle of the closed bundle's header (its top and
// bottom 30% drop beside it).
await stdCard.trigger("dragstart", { dataTransfer: makeDataTransfer() });
await header.trigger("dragover", { clientY: 22, dataTransfer: makeDataTransfer() });
await header.trigger("dragover", { clientY: 15, dataTransfer: makeDataTransfer() });
// The closed frame lights up and draws the into line.
expect(wrapper.find(".wp-bundle").classes()).toContain("wp-bundle--drop-into");
expect(wrapper.find(".wp-bundle").classes()).toContain("wp-bundle--drop-target");
await header.trigger("drop", { dataTransfer: makeDataTransfer() });
await flushPromises();
@@ -2351,6 +2355,78 @@ describe("ContextWidget bundle drag/drop regressions (Batch 2)", () => {
wrapper.unmount();
});
it("a closed bundle's top edge drops beside it: the row stays out and the bundle stays closed", async () => {
const modules = [
{
id: "ch0aaaaa", _uid: "u0", type: "wildcard", enabled: true,
meta: { name: "ch0" }, entries: [],
payload: { var_binding: "v0", options: [] }, payload_hash: "ph0",
bundle_origin: "bundleUidXX",
},
{
id: "ch1aaaaa", _uid: "u1", type: "wildcard", enabled: true,
meta: { name: "ch1" }, entries: [],
payload: { var_binding: "v1", options: [] }, payload_hash: "ph1",
bundle_origin: "bundleUidXX",
},
{
id: "stdaaaaa", _uid: "u2", type: "wildcard", enabled: true,
meta: { name: "std" }, entries: [],
payload: { var_binding: "vs", options: [] }, payload_hash: "phs",
},
];
const bundles = [{
_uid: "bundleUidXX", library_id: "lib_b", start_idx: 0, end_idx: 1,
enabled: true, collapsed: true, inserted_at_hash: "phB", name: "Group", color: null,
}];
const onChange = vi.fn();
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 9302,
initialJson: JSON.stringify({ version: 1, modules, bundles }),
upstreamVars: [],
onChange,
},
});
await flushPromises();
onChange.mockClear();
const header = wrapper.find(".wp-bundle-header");
(header.element as HTMLElement).getBoundingClientRect = rectOf(0, 30);
const stdCard = wrapper.find('[data-module-id="stdaaaaa"]');
(stdCard.element as HTMLElement).getBoundingClientRect = rectOf(80, 30);
// Collapsed bundle frame rect = just the header strip (children
// container has 0 height via grid-template-rows: auto 0fr). The
// slot-zone resolver drills via the frame rect so it needs to be
// > 0 height for drill-in to fire.
const bundle = wrapper.find(".wp-bundle");
(bundle.element as HTMLElement).getBoundingClientRect = rectOf(0, 30);
const body = wrapper.find(".wp-bundle-children");
if (body.exists()) {
(body.element as HTMLElement).getBoundingClientRect = rectOf(30, 0);
}
// Drag std → the top edge of the closed bundle's header.
await stdCard.trigger("dragstart", { dataTransfer: makeDataTransfer() });
await header.trigger("dragover", { clientY: 3, dataTransfer: makeDataTransfer() });
// A gap opens above the frame (kit line, accent colour at top level).
const frame = wrapper.find(".wp-bundle");
expect(frame.classes()).toContain("wp-rowdrag--before");
expect(frame.classes()).not.toContain("wp-bundle--drop-into");
await header.trigger("drop", { dataTransfer: makeDataTransfer() });
await flushPromises();
const last = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
const parsed = JSON.parse(last);
expect(parsed.bundles).toHaveLength(1);
expect(parsed.bundles[0].collapsed).toBe(true);
// std moved above the bundle, outside it.
expect(parsed.modules[0].id).toBe("stdaaaaa");
expect(parsed.modules[0].bundle_origin).toBeUndefined();
wrapper.unmount();
});
// (Test "in-bundle reorder does NOT paint frame highlight" deleted —
// see slot-zone note above. The negative-case mirror of the deleted
// positive test is also obsolete since the class no longer exists.)
@@ -2702,3 +2778,84 @@ describe("ContextWidget bulk toolbar — bundle-aware", () => {
wrapper.unmount();
});
});
describe("ContextWidget collapsed bundles", () => {
const json = (collapsed: boolean): string => JSON.stringify({
version: 1,
modules: [{
id: "dddddddd", type: "wildcard", enabled: true, meta: { name: "Lost" }, entries: [],
payload: { var_binding: "v", options: [{ id: "o1", value: "x", weight: 1 }] },
payload_hash: "h1", bundle_origin: "buidcollapse1",
}],
bundles: [{
_uid: "buidcollapse1", library_id: "bn_c", start_idx: 0, end_idx: 0,
enabled: true, collapsed, inserted_at_hash: "h", name: "Box", color: null,
}],
});
it("hidden rows don't widen the node; the header shows their dot", async () => {
const { hashes } = await import("./drift-store");
hashes.value = {}; // library loaded, the row's id isn't in it → missing
try {
const widths: number[] = [];
mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 401, initialJson: json(true), upstreamVars: [], onChange: () => {},
onRequestMinWidth: (w: number) => widths.push(w),
},
});
await flushPromises();
expect(widths[widths.length - 1]).toBe(380);
const dots = document.querySelectorAll("[data-test=bundle-alerts] .wp-bundle-alert");
expect(Array.from(dots).map((d) => d.getAttribute("data-alert"))).toEqual(["missing"]);
const open: number[] = [];
mount(ContextWidget, {
attachTo: document.body,
props: {
nodeId: 402, initialJson: json(false), upstreamVars: [], onChange: () => {},
onRequestMinWidth: (w: number) => open.push(w),
},
});
await flushPromises();
expect(open[open.length - 1]).toBe(440);
expect(document.querySelectorAll("[data-bundle-uid=buidcollapse1] [data-test=bundle-alerts]").length).toBe(1);
} finally {
resetDriftStore();
}
});
});
describe("ContextWidget — Show node", () => {
it("unfolds the bundles around the module WP Debug points at", async () => {
const initialJson = JSON.stringify({
version: 1,
modules: [
{
id: "eeeeeeee", _uid: "m-child", type: "wildcard", enabled: true,
meta: { name: "Deep" }, entries: [],
payload: { var_binding: "v", options: [{ id: "o1", value: "x", weight: 1 }] },
payload_hash: "h", bundle_origin: "b-inner",
},
],
bundles: [
{ _uid: "b-outer", library_id: "bn_o", start_idx: 0, end_idx: 0, enabled: true, collapsed: true, inserted_at_hash: "h", name: "Outer", color: null },
{ _uid: "b-inner", parent_uid: "b-outer", library_id: "bn_i", start_idx: 0, end_idx: 0, enabled: true, collapsed: true, inserted_at_hash: "h", name: "Inner", color: null },
{ _uid: "b-other", library_id: "bn_x", start_idx: 0, end_idx: 0, enabled: true, collapsed: true, inserted_at_hash: "h", name: "Other", color: null },
],
});
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 302, initialJson, upstreamVars: [], onChange: () => {} },
});
await flushPromises();
const folded = () => [...document.querySelectorAll(".wp-bundle--collapsed")].map((el) => el.getAttribute("data-uid"));
expect(folded()).toContain("b-outer");
wrapper.find("[data-wp-reveal]").element.dispatchEvent(new CustomEvent("wp-reveal", { detail: { uid: "m-child" } }));
await flushPromises();
expect(folded()).not.toContain("b-outer");
expect(folded()).not.toContain("b-inner");
wrapper.unmount();
});
});
+35 -10
View File
@@ -15,6 +15,8 @@ import ModulePickerModal from "./ModulePickerModal.vue";
import BundlePickerModal from "./BundlePickerModal.vue";
import BundleFrame from "./bundles/BundleFrame.vue";
import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-ctx";
import { bundleAlerts, bundleDescendants, collapsedAround, isHiddenInCollapsedBundle } from "./bundles/bundle-alerts";
import { revealTarget } from "../../widgets/flash-target";
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import {
@@ -25,7 +27,6 @@ import {
import { LockSeedKey } from "./lock-seed-ctx";
import { reconcileBundleRanges } from "./bundles/drag";
import { bundleSnapshotModified, ensureBundleFingerprints } from "./bundles/bundle-fingerprint";
import BundleDropBar from "./bundles/BundleDropBar.vue";
import { createBundleOps } from "./bundles/bundle-ops";
import { createBundleMasterToggles } from "./bundles/master-toggles";
import { useBundleEdit } from "./bundles/bundle-edit";
@@ -33,7 +34,6 @@ import { buildTopLevelItems } from "./bundles/top-level-items";
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
import type { BundleInstance } from "../../widgets/_shared";
import ModuleEditModal from "./ModuleEditModal.vue";
import ModalShell from "@/kit/ModalShell.vue";
// Lazy: bundle edit modal pulls in its own IdentitySection +
// RuntimeSection + color-preset markup. Keeping it out of the
// ContextWidget entry chunk preserves the size budget — the modal
@@ -601,9 +601,16 @@ const {
// Base 380 covers the footer's two "+ Add module" / "+ Add Bundle"
// buttons side-by-side, which is the widest mandatory element. Per-
// badge bumps give the name room to breathe.
const hasAnyConflict = computed(() => Object.keys(conflictsByModule.value).length > 0);
// Only rows in sight count: a row inside a collapsed bundle hides its chips,
// so they must not widen the node (the collapsed header shows a dot instead).
const visibleModules = computed(() =>
value.value.modules.filter((m) => !isHiddenInCollapsedBundle(m, value.value.bundles)),
);
const hasAnyConflict = computed(() =>
visibleModules.value.some((m) => (conflictsByModule.value[m._uid ?? m.id]?.length ?? 0) > 0),
);
const hasAnyStateBadge = computed(() =>
value.value.modules.some((m) => isModified(m) || isDrifted(m) || isMissingFromLibrary(m)),
visibleModules.value.some((m) => isModified(m) || isDrifted(m) || isMissingFromLibrary(m)),
);
const requiredMinWidth = computed(() => {
@@ -709,19 +716,42 @@ const bundleFrameCtx: BundleFrameCtx = {
// have diverged from, so MOD would be diffing against a ghost.
isBundleSnapshotModified: (b: BundleInstance) =>
!isBundleUnlinked(b) && bundleSnapshotModified(b, value.value.modules),
bundleAlerts: (b: BundleInstance) => bundleAlerts(
bundleDescendants(b, value.value.modules, value.value.bundles),
{
severityFor: conflictRowCtx.severityFor,
conflictLabel: conflictRowCtx.conflictBadgeText,
isMissingFromLibrary,
isTypeConflict,
},
),
...bundleOpsFrameCtx,
openBundleContextMenu,
...dragFrameCtx,
};
provide(BundleFrameCtxKey, bundleFrameCtx);
/** WP Debug "Show node": unfold the bundles that hide the module it names. */
function onReveal(ev: Event): void {
const uid = revealTarget(ev)?.uid;
if (!uid) return;
const open = new Set(collapsedAround(uid, value.value.modules, value.value.bundles));
if (!open.size) return;
value.value = {
...value.value,
bundles: (value.value.bundles ?? []).map((b) => (open.has(b._uid) ? { ...b, collapsed: false } : b)),
};
}
</script>
<template>
<div
class="wp-context"
data-wp-reveal
:class="{ 'wp-context--muted': isMuted }"
:data-mode-label="isMuted ? muteLabel : undefined"
@dragleave="onContainerLeave($event)"
@wp-reveal="onReveal"
>
<!-- Node identity strip. A fixed, read-only CODENAME naming THIS
WP_Context node — a stable `adjective-noun` derived from the
@@ -781,10 +811,6 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
class="wp-modules"
:data-suppress-move="suppressMove ? 'true' : null"
>
<!-- Floating drop indicator for top-level scope. Hidden when
the resolved zone targets a nested bundle container — that
container renders its own BundleDropBar. -->
<BundleDropBar :container-uid="null" />
<template v-for="item in topLevelItems" :key="item.key">
<BundleFrame
v-if="item.kind === 'bundle'"
@@ -884,7 +910,6 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
bundle drives `lockState` / `internalState` (not the draft)
since those toggles cascade onto children, mirroring the
BundleHeader buttons. -->
<ModalShell :visible="bundleDraft !== null" @close="cancelBundleEdit">
<BundleInstanceModal
v-if="bundleDraft !== null && editingBundleEntry"
:bundle="bundleDraft"
@@ -903,7 +928,6 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
@reset-to-library="onBundleEditResetToLibrary"
@open-spa="cancelBundleEdit"
/>
</ModalShell>
<!-- Standalone push-to-library entry point — fires from the
right-click menu without going through the edit modal first.
@@ -967,5 +991,6 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
<style>
/* Unscoped widget styles live in context-widget.css — same cascade as the
* inline block they came from (NOT layered; see the note at its top). */
@import "@/kit/row-drag.css";
@import "./context-widget.css";
</style>
+46 -46
View File
@@ -2,7 +2,6 @@
import Button from "@/kit/Button.vue";
import { ref, computed, watch } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
import { type ResolvedValue } from "../../widgets/richTokenize";
import {
@@ -86,17 +85,6 @@ watch([() => props.visible, () => props.module], () => {
}
}, { immediate: true });
// Keys arrive from ModalShell, not from window: the modal shield stops
// inside-modal keys at the overlay so they cannot reach ComfyUI's global
// shortcuts, which means a window listener here would never fire.
function onKeydown(ev: KeyboardEvent) {
if (!props.visible) return;
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
save();
}
}
// Modified-state — true when ANY registry field on `instance` is
// non-null. The `_ui` namespace is excluded by virtue of not appearing
// in `INSTANCE_FIELDS_PER_KIND`, so toggling Lock off (which leaves a
@@ -426,24 +414,10 @@ function cancel() {
</script>
<template>
<ModalShell :visible="visible" @close="cancel" @keydown="onKeydown">
<!-- Column so the frame banner stacks ABOVE the per-kind modal card
(each per-kind modal renders a plain card; ModalShell's wrapper is
a centered flex ROW, so a bare banner sibling would land beside the
card). align-self:stretch on the banner spans it to the card width. -->
<div class="wp-frame-edit-body">
<!-- Frame-context banner — shown when the user is editing overrides
for a specific iteration frame (#k). Sits above all per-kind
modal bodies. Invisible on base editing (currentFrame == null). -->
<div
v-if="currentFrame != null"
class="wp-modal-frame-banner"
data-test="modal-frame-banner"
>
<span>Editing frame #{{ currentFrame + 1 }} — changes affect only this frame.</span>
<Button size="sm" variant="outline" icon="pi-replay" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</Button>
</div>
<!-- Each kind's window is a kit Modal of its own (coloured header in the
kind's colour, id chip, its own footer buttons). The frame banner goes
in at the top of its body through the window's `banner` slot. -->
<template v-if="visible">
<!-- 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). -->
@@ -461,7 +435,14 @@ function cancel() {
@cancel="cancel"
@save-to-library="onSaveToLibraryClick"
@clear-all-overrides="onClearAllOverrides"
/>
>
<template #banner>
<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 size="sm" variant="outline" icon="pi-replay" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</Button>
</div>
</template>
</WildcardInstanceModal>
<!-- v2 fixed-values branch — single-pane tailored modal. -->
<FixedValuesInstanceModal
@@ -474,7 +455,14 @@ function cancel() {
@cancel="cancel"
@save-to-library="onSaveToLibraryClick"
@clear-all-overrides="onClearAllOverrides"
/>
>
<template #banner>
<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 size="sm" variant="outline" icon="pi-replay" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</Button>
</div>
</template>
</FixedValuesInstanceModal>
<!-- v2 combine branch — single-pane tailored modal. Library-defining
edits stay in SPA; modal exposes only runtime + per-instance
@@ -493,7 +481,14 @@ function cancel() {
@cancel="cancel"
@save-to-library="onSaveToLibraryClick"
@clear-all-overrides="onClearAllOverrides"
/>
>
<template #banner>
<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 size="sm" variant="outline" icon="pi-replay" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</Button>
</div>
</template>
</CombineInstanceModal>
<!-- v2 derivation branch — single-pane tailored modal. Library
(rule conditions / branches / actions) stays in SPA; modal
@@ -516,7 +511,14 @@ function cancel() {
@cancel="cancel"
@save-to-library="onSaveToLibraryClick"
@clear-all-overrides="onClearAllOverrides"
/>
>
<template #banner>
<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 size="sm" variant="outline" icon="pi-replay" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</Button>
</div>
</template>
</DerivationInstanceModal>
<!-- v2 constraint branch — single-pane tailored modal. Library
(matrix shape, source/target wildcard pair, exception authoring)
@@ -535,9 +537,15 @@ function cancel() {
@cancel="cancel"
@save-to-library="onSaveToLibraryClick"
@clear-all-overrides="onClearAllOverrides"
/>
</div>
</ModalShell>
>
<template #banner>
<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 size="sm" variant="outline" icon="pi-replay" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</Button>
</div>
</template>
</ConstraintInstanceModal>
</template>
<!-- Themed confirm dialog — replaces window.confirm() for any
prompt the modal raises (reset overrides, reset to library,
@@ -570,18 +578,10 @@ function cancel() {
* `.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;
margin: 10px 14px 0;
align-items: center;
justify-content: space-between;
padding: 7px 11px;
+21 -48
View File
@@ -1,22 +1,19 @@
<template>
<ModalShell :visible="visible" @close="$emit('close')">
<div ref="rootEl" class="wp-picker" tabindex="-1">
<!-- Header -->
<header class="wp-picker__head">
<div class="wp-picker__head-text">
<div class="wp-picker__eyebrow">
Add modules from library
</div>
<div class="wp-picker__title">
Browse Library
<span class="wp-picker__title-detail">
· {{ modules.length }} {{ modules.length === 1 ? "module" : "modules" }}
</span>
</div>
<Modal :open="visible" size="lg" flush accent="brand" icon="th-large" @update:open="(v: boolean) => { if (!v) $emit('close'); }">
<template #header="{ titleId }">
<div class="wp-picker__head-text">
<div class="wp-picker__eyebrow">
Add modules from library
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" @click="$emit('close')" />
</header>
<div :id="titleId" class="wp-picker__title">
Browse Library
<span class="wp-picker__title-detail">
· {{ modules.length }} {{ modules.length === 1 ? "module" : "modules" }}
</span>
</div>
</div>
</template>
<div ref="rootEl" class="wp-picker" tabindex="-1">
<!-- Toolbar: search + kind tabs + compact filter buttons.
Filters are inline on the toolbar (no dedicated row) so
@@ -309,7 +306,7 @@
>Add {{ selected.size }} {{ selected.size === 1 ? "module" : "modules" }}</Button>
</footer>
</div>
</ModalShell>
</Modal>
</template>
<script setup lang="ts">
@@ -332,7 +329,7 @@
import Kbd from "@/kit/Kbd.vue";
import SearchInput from "@/kit/SearchInput.vue";
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import Skeleton from "@/kit/Skeleton.vue";
import WpSelect from "@/kit/WpSelect.vue";
import { kindIcon } from "../shared/kind-icons";
@@ -970,41 +967,17 @@ onBeforeUnmount(detachCaptureListeners);
.wp-picker__skel-rows { padding: 0 10px; }
.wp-picker {
background: var(--wp-bg-2);
border: 1px solid var(--wp-border-strong);
border-radius: var(--wp-radius-lg);
box-shadow: var(--wp-shadow-lg);
width: 780px;
max-width: calc(100vw - 32px);
height: 600px;
max-height: calc(100vh - 32px);
height: 560px;
max-height: calc(100vh - 120px);
display: flex;
flex-direction: column;
color: var(--wp-text);
overflow: hidden;
font-family: var(--wp-font);
outline: none;
}
/* ── Header — brand gradient + dark wash overlay. Mirrors BundlePicker
* so both pickers read as part of the same family. */
.wp-picker__head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient);
position: relative;
}
.wp-picker__head::after {
content: "";
position: absolute;
inset: 0;
/* Theme-aware wash so the brand gradient stays subtle in both
* palettes — dark bg in dark mode, near-white in light mode. */
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
pointer-events: none;
}
.wp-picker__head > * { position: relative; z-index: 1; }
.wp-picker__head-text { flex: 1; min-width: 0; }
/* ── Header text (Modal draws the brand wash). */
.wp-picker__head-text { flex: 1 1 auto; min-width: 0; }
.wp-picker__eyebrow {
font-size: 11px;
text-transform: uppercase;
+1
View File
@@ -32,6 +32,7 @@ function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
summaryTokens: () => [],
siblingInfo: () => null,
rowGap: () => null,
rowLineStyle: () => undefined,
draggingModuleUid: ref<string | null>(null),
recentDropUids: ref(new Set<string>()),
pulseDelayFor: () => "0ms",
+8 -7
View File
@@ -27,7 +27,7 @@ const {
isModified, isDrifted, isMissingFromLibrary, isTypeConflict,
severityFor, conflictTooltip, conflictBadgeText,
modifiedTooltip, summaryFor, summaryTokens, siblingInfo,
rowGap,
rowGap, rowLineStyle,
toggleCollapsed, toggleEnabled, removeModule,
toggleLockOnCard, toggleInternalOnCard,
onDragStart, onDragEnd, openContextMenu, onCardKeydown,
@@ -42,7 +42,7 @@ const {
:data-module-id="module.id"
:data-module-idx="idx"
:data-kind="module.type"
class="wp-module"
class="wp-module wp-rowdrag-row"
tabindex="0"
draggable="true"
:class="{
@@ -53,17 +53,18 @@ const {
'wp-state-modified': isModified(module),
'wp-state-drift': isDrifted(module),
'wp-state-missing': isMissingFromLibrary(module) && !module.example,
'wp-gap-before': rowGap(idx) === 'before',
'wp-gap-after': rowGap(idx) === 'after',
'wp-rowdrag--before': rowGap(idx) === 'before',
'wp-rowdrag--after': rowGap(idx) === 'after',
'wp-module--dragging': module._uid === ctx.draggingModuleUid.value,
'wp-drop-pulse': !!module._uid && ctx.recentDropUids.value.has(module._uid),
'wp-mod--mod': isModified(module),
'wp-mod--drift': isDrifted(module),
'wp-mod--err': isMissingFromLibrary(module) && !module.example,
}"
:style="!!module._uid && ctx.recentDropUids.value.has(module._uid)
? { animationDelay: ctx.pulseDelayFor(module._uid) }
: undefined"
:style="{
...rowLineStyle(idx),
...(!!module._uid && ctx.recentDropUids.value.has(module._uid) ? { animationDelay: ctx.pulseDelayFor(module._uid) } : {}),
}"
@dragstart="(ev) => onDragStart(ev, module, idx)"
@dragend="onDragEnd"
@contextmenu.stop.prevent="(ev) => openContextMenu(ev, module, idx)"
@@ -29,7 +29,7 @@ import { bundleContentKey } from "./bundles/bundle-fingerprint";
import type { BundleInstance } from "../../widgets/_shared";
import Checkbox from "@/kit/Checkbox.vue";
import Button from "@/kit/Button.vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import { vToggleRow } from "@/kit/toggle-row";
interface ChildPreview {
@@ -421,16 +421,8 @@ function pickCopyName(base: string, taken: ReadonlySet<string>): string {
</script>
<template>
<ModalShell :visible="!!(open && bundle)" labelledby="wp-pbtl-title" @close="close">
<div
class="wp-ptl"
data-test="pbtl"
>
<header class="wp-ptl__head">
<h3 id="wp-pbtl-title" class="wp-ptl__title">Push bundle to library</h3>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="pbtl-close" @click="close" />
</header>
<Modal :open="!!(open && bundle)" title="Push bundle to library" size="md" flush @update:open="(v: boolean) => { if (!v) close(); }">
<div class="wp-ptl" data-test="pbtl">
<div class="wp-ptl__body">
<div class="wp-ptl-field">
<label class="wp-ptl-field__label" for="wp-pbtl-name">Name</label>
@@ -605,46 +597,30 @@ function pickCopyName(base: string, taken: ReadonlySet<string>): string {
</div>
</div>
<footer class="wp-ptl__foot">
<Button size="sm" data-test="pbtl-cancel" :disabled="busy" @click="close">Cancel</Button>
<span class="wp-ptl-spacer" />
<Button
size="sm"
data-test="pbtl-save-new"
:disabled="busy"
@click="doSaveAsNew"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
data-test="pbtl-update"
:disabled="busy || !canUpdateExisting"
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
@click="doUpdate"
>Update existing</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<Button size="sm" data-test="pbtl-cancel" :disabled="busy" @click="close">Cancel</Button>
<span class="wp-ptl-spacer" />
<Button
size="sm"
data-test="pbtl-save-new"
:disabled="busy"
@click="doSaveAsNew"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
data-test="pbtl-update"
:disabled="busy || !canUpdateExisting"
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
@click="doUpdate"
>Update existing</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-ptl {
width: min(620px, 100%);
max-height: calc(100vh - 32px);
background: var(--wp-bg-1);
border: 1px solid var(--wp-border-strong, var(--wp-border));
border-radius: var(--wp-radius-lg, 10px);
box-shadow: var(--wp-shadow-lg);
display: flex; flex-direction: column;
overflow: hidden;
color: var(--wp-text);
}
.wp-ptl__head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--wp-border);
}
.wp-ptl__title { margin: 0; font-size: 14px; font-weight: 600; }
.wp-ptl { color: var(--wp-text); }
.wp-ptl__body {
flex: 1; min-height: 0; overflow-y: auto;
padding: 14px;
@@ -801,11 +777,5 @@ function pickCopyName(base: string, taken: ReadonlySet<string>): string {
color: var(--wp-danger);
font-size: 12px;
}
.wp-ptl__foot {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg-1);
}
.wp-ptl-spacer { flex: 1; }
</style>
+23 -53
View File
@@ -41,7 +41,7 @@ import type { ModuleEntry } from "../../widgets/_shared";
import Checkbox from "@/kit/Checkbox.vue";
import LoadingMark from "@/kit/LoadingMark.vue";
import Button from "@/kit/Button.vue";
import ModalShell from "@/kit/ModalShell.vue";
import Modal from "@/kit/Modal.vue";
import CodeBlock from "@/kit/CodeBlock.vue";
import { vToggleRow } from "@/kit/toggle-row";
@@ -443,16 +443,8 @@ async function doSaveAsNew(): Promise<void> {
</script>
<template>
<ModalShell :visible="!!(open && draft)" labelledby="wp-ptl-title" @close="close">
<div
class="wp-ptl"
data-test="ptl"
>
<header class="wp-ptl__head">
<h3 id="wp-ptl-title" class="wp-ptl__title">Push to library</h3>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="ptl-close" @click="close" />
</header>
<Modal :open="!!(open && draft)" title="Push to library" size="md" flush @update:open="(v: boolean) => { if (!v) close(); }">
<div class="wp-ptl" data-test="ptl">
<div class="wp-ptl__body">
<div class="wp-ptl-field">
<label class="wp-ptl-field__label" for="wp-ptl-name">Name</label>
@@ -603,46 +595,30 @@ async function doSaveAsNew(): Promise<void> {
</div>
</div>
<footer class="wp-ptl__foot">
<Button size="sm" data-test="ptl-cancel" :disabled="busy" @click="close">Cancel</Button>
<span class="wp-ptl-spacer" />
<Button
size="sm"
data-test="ptl-save-new"
:disabled="busy"
@click="doSaveAsNew"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
data-test="ptl-update"
:disabled="busy || !canUpdateExisting"
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
@click="doUpdate"
>Update existing</Button>
</footer>
</div>
</ModalShell>
<template #footer>
<Button size="sm" data-test="ptl-cancel" :disabled="busy" @click="close">Cancel</Button>
<span class="wp-ptl-spacer" />
<Button
size="sm"
data-test="ptl-save-new"
:disabled="busy"
@click="doSaveAsNew"
>Save as new entry</Button>
<Button
size="sm"
variant="primary"
data-test="ptl-update"
:disabled="busy || !canUpdateExisting"
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
@click="doUpdate"
>Update existing</Button>
</template>
</Modal>
</template>
<style scoped>
.wp-ptl {
width: min(620px, 100%);
max-height: calc(100vh - 32px);
background: var(--wp-bg-1);
border: 1px solid var(--wp-border-strong, var(--wp-border));
border-radius: var(--wp-radius-lg, 10px);
box-shadow: var(--wp-shadow-lg);
display: flex; flex-direction: column;
overflow: hidden;
color: var(--wp-text);
}
.wp-ptl__head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--wp-border);
}
.wp-ptl__title { margin: 0; font-size: 14px; font-weight: 600; }
.wp-ptl { color: var(--wp-text); }
.wp-ptl__body {
flex: 1; min-height: 0; overflow-y: auto;
padding: 14px;
@@ -759,11 +735,5 @@ async function doSaveAsNew(): Promise<void> {
color: var(--wp-danger);
font-size: 12px;
}
.wp-ptl__foot {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--wp-border);
background: var(--wp-bg-1);
}
.wp-ptl-spacer { flex: 1; }
</style>
@@ -1,67 +0,0 @@
<script setup lang="ts">
/**
* Floating drop indicator bar. Rendered inside every drop-container
* (top-level wp-list + every wp-bundle-children). Position is computed
* from the resolved DropZone — when the zone targets this container,
* the bar appears at the correct slot; otherwise it stays hidden.
*
* The bar is absolutely positioned, sized to the container's content
* width (via left/right inset = 0). With the slot-zone redesign there
* is only one zone kind — every drop resolves to a slot anchor in
* exactly one container, so this SFC handles the full indicator
* painting on its own.
*
* The container provides a `dropBarFor(containerUid)` lookup through
* BundleFrameCtx. ContextWidget owns the dragOver ref + the rect math;
* this component is purely declarative.
*
* **No rAF refinement here.** dropBarFor uses `offsetTop` walkers
* (CSS pixels, zoom-independent) instead of getBoundingClientRect
* (viewport pixels, scaled by the canvas zoom transform). offsetTop
* is layout-stable across Vue's flush:"post" boundary, so a simple
* computed off the resolved zone is enough — no second-pass refinement
* needed. Earlier rAF-based refinement was added when the position
* used getBoundingClientRect; that machinery is no longer required
* and removing it eliminates a potential double-paint glitch when
* the bundle moves under wp-gap-before margin and the rAF callback
* reads a different value than the eager paint.
*/
import { computed, inject } from "vue";
import { BundleFrameCtxKey } from "./bundle-frame-ctx";
interface Props {
/** The container's bundle uid, or null for the top-level list. */
containerUid: string | null;
}
const props = defineProps<Props>();
const ctx = inject(BundleFrameCtxKey);
const position = computed<{ top: number } | null>(() => {
if (!ctx) return null;
return ctx.dropBarFor(props.containerUid);
});
</script>
<template>
<div
v-if="position"
class="wp-drop-bar"
:style="{ top: position.top + 'px' }"
aria-hidden="true"
/>
</template>
<style scoped>
.wp-drop-bar {
position: absolute;
left: 4px;
right: 4px;
height: 2px;
background: var(--wp-accent);
border-radius: min(1px, var(--wp-radius-cap, 999px));
pointer-events: none;
z-index: 5;
box-shadow: 0 0 6px var(--wp-accent);
}
</style>
+14 -8
View File
@@ -20,7 +20,6 @@ import { inject } from "vue";
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
import { BundleFrameCtxKey } from "./bundle-frame-ctx";
import BundleHeader from "./BundleHeader.vue";
import BundleDropBar from "./BundleDropBar.vue";
import ModuleRow from "../ModuleRow.vue";
type BundleChild =
@@ -43,18 +42,21 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
<template>
<div
class="wp-bundle"
class="wp-bundle wp-rowdrag-row"
:class="{
'wp-bundle--collapsed': bundle.collapsed,
'wp-bundle--disabled': !bundle.enabled,
'wp-bundle--nested': nested,
'wp-bundle--drop-target': ctx.isBundleDropTarget(bundle._uid),
'wp-gap-before': ctx.bundleHeaderGap(bundle._uid) === 'before',
'wp-gap-after': ctx.bundleHeaderGap(bundle._uid) === 'after',
'wp-bundle--drop-into': ctx.isBundleDropInto(bundle._uid),
'wp-rowdrag--before': ctx.bundleHeaderGap(bundle._uid) === 'before',
'wp-rowdrag--after': ctx.bundleHeaderGap(bundle._uid) === 'after',
'wp-rowdrag--dragging': ctx.draggingBundleUid.value === bundle._uid,
'wp-drop-pulse': ctx.recentDropUids.value.has(bundle._uid),
}"
:style="{
'--wp-bundle-color': bundle.color ? bundle.color : 'var(--wp-bundle-default)',
...ctx.bundleLineStyle(bundle._uid),
...(ctx.recentDropUids.value.has(bundle._uid) ? { animationDelay: ctx.pulseDelayFor(bundle._uid) } : {}),
}"
:data-bundle-uid="bundle._uid"
@@ -73,6 +75,7 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
:internal-state="ctx.bundleInternalState(bundle)"
:lock-state="ctx.bundleLockState(bundle)"
:snapshot-modified="ctx.isBundleSnapshotModified(bundle)"
:alerts="bundle.collapsed ? ctx.bundleAlerts(bundle) : null"
@toggle-collapse="ctx.toggleBundleCollapsed(bundle._uid)"
@toggle-enabled="(next: boolean) => ctx.toggleBundleEnabled(bundle._uid, next)"
@toggle-internal="ctx.toggleBundleInternal(bundle._uid)"
@@ -83,7 +86,6 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
@dragend="ctx.onDragEnd()"
/>
<div class="wp-bundle-children">
<BundleDropBar :container-uid="bundle._uid" />
<template v-for="child in children" :key="child.key">
<BundleFrame
v-if="child.kind === 'bundle'"
@@ -117,6 +119,11 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
*/
margin: 2px 0 4px 0;
}
/* The drop gap still opens beside a nested frame: its own margins above
* outrank the kit's one-class wp-rowdrag rules, so the gap and its line
* get the same weight here. */
.wp-bundle.wp-bundle--nested.wp-rowdrag--before { margin-top: var(--wp-rowdrag-gap, 14px); }
.wp-bundle.wp-bundle--nested.wp-rowdrag--after { margin-bottom: var(--wp-rowdrag-gap, 14px); }
.wp-bundle.wp-bundle--nested > .wp-bundle-children {
padding: 4px 5px 5px 5px;
}
@@ -132,9 +139,8 @@ if (!ctx) throw new Error("BundleFrame requires BundleFrameCtx — provided by C
padding: 0;
overflow: hidden;
}
/* Drop-bar anchor — the floating indicator inside .wp-bundle-children
* is absolutely positioned; the container needs to be the offset
* parent. Both nested + outer frames share this rule. */
/* Offset parent for anything absolutely placed in a bundle's body.
* Both nested + outer frames share this rule. */
.wp-bundle > .wp-bundle-children {
position: relative;
}
@@ -152,3 +152,27 @@ describe("BundleHeader — EXAMPLE chip", () => {
expect(w.find('[data-test="bundle-example"]').exists()).toBe(false);
});
});
describe("BundleHeader alerts", () => {
it("draws a dot per alert kind beside the example chip", () => {
const w = mount(BundleHeader, {
props: {
instance: instance({ collapsed: true }), name: "x", childCount: 2,
libraryMissing: true, example: true,
alerts: { conflict: "warning", state: "missing", title: "Inside this bundle:\nA: missing var" },
},
});
const box = w.find("[data-test=bundle-alerts]");
expect(box.exists()).toBe(true);
expect(box.attributes("title")).toContain("A: missing var");
expect(box.findAll(".wp-bundle-alert").map((d) => d.attributes("data-alert"))).toEqual(["warning", "missing"]);
expect(w.find("[data-test=bundle-example]").exists()).toBe(true);
});
it("draws nothing without alerts", () => {
const w = mount(BundleHeader, {
props: { instance: instance({ collapsed: true }), name: "x", childCount: 2,
alerts: { conflict: null, state: null, title: "" } },
});
expect(w.find("[data-test=bundle-alerts]").exists()).toBe(false);
});
});
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from "vue";
import type { BundleInstance } from "../../../widgets/_shared";
import type { BundleAlerts } from "./bundle-alerts";
import Checkbox from "@/kit/Checkbox.vue";
import WpIconButton from "@/kit/WpIconButton.vue";
import DragHandle from "@/kit/DragHandle.vue";
@@ -64,8 +65,12 @@ const props = withDefaults(
/** A sub-bundle. An example sub-bundle shows no chip: its top bundle's
* chip already says the whole group came with the workflow. */
nested?: boolean;
/** Trouble in the rows a COLLAPSED bundle hides (null while expanded:
* the rows show their own chips then). Drawn as dots only, so it
* fits beside EXAMPLE / MISSING on a narrow node. */
alerts?: BundleAlerts | null;
}>(),
{ example: false, nested: false, color: null, driftedCount: 0, libraryDrifted: false, internalState: "none", lockState: null, snapshotModified: false, libraryMissing: false },
{ alerts: null, example: false, nested: false, color: null, driftedCount: 0, libraryDrifted: false, internalState: "none", lockState: null, snapshotModified: false, libraryMissing: false },
);
const emit = defineEmits<{
@@ -167,6 +172,18 @@ const summary = computed(() => {
subtitle ("N drifted") since each child row already carries
its own dot — re-stacking them at the bundle level would be
visual noise. -->
<!-- Rows hidden by the collapse: one dot for the worst conflict, one
for the worst library state. Own classes (not .wp-mod-dot /
.wp-conflict-dot) so the "badges only" indicator style can't hide
the one sign a collapsed bundle gives. -->
<span v-if="alerts && (alerts.conflict || alerts.state)" class="wp-bundle-alerts"
data-test="bundle-alerts" :title="alerts.title" role="img"
:aria-label="alerts.title">
<span v-if="alerts.conflict" class="wp-bundle-alert"
:class="`wp-conflict-dot--${alerts.conflict}`" :data-alert="alerts.conflict"></span>
<span v-if="alerts.state" class="wp-bundle-alert"
:class="`wp-mod-dot--${alerts.state}`" :data-alert="alerts.state"></span>
</span>
<span v-if="libraryMissing || libraryDrifted || snapshotModified" class="wp-mod-dots">
<!-- Visual hierarchy: MISSING (no live library entry) is the most severe
and shows first; LIBRARY UPDATED (drift) dominates next; MOD
@@ -346,12 +363,71 @@ const summary = computed(() => {
flex-shrink: 0;
}
.wp-bundle-name {
flex: 1;
font: 600 12px/1.3 var(--wp-font-sans);
color: var(--wp-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* Sized by its text and shrinking slowly next to the badges and the
* summary (flex-shrink 8 and 2 there), so the name gives way last; a few
* letters always show. */
flex: 1 1 auto;
min-width: 3em;
}
/* On a narrow header the state badges and the summary shorten instead of
* pushing the lock / internal / remove buttons past the frame. The badges
* keep one line (the row-wide cluster wraps, which would make the header
* taller than its rows) and ellipsize; the dot beside each keeps the colour. */
.wp-bundle-header {
container: wp-bundle-head / inline-size;
}
.wp-bundle-header .wp-mod-dots {
flex: 0 8 auto;
flex-wrap: nowrap;
max-width: none;
/* Room for the dot; the badge word inside shortens to nothing first. */
min-width: 11px;
/* Start-aligned: the row-wide cluster packs to the end, which clipped a
* shortened badge from the LEFT ("ARY UPDATED"). */
justify-content: flex-start;
}
.wp-bundle-header .wp-mod-dots .wp-mod-badge {
flex-shrink: 8;
min-width: 3em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Below 480 px the badge words go when a dot already says the same thing
* (indicator style "Both"): a word cut to "L…" says less than the dot and
* its tooltip. "Badges only" keeps its shortened word. */
@container wp-bundle-head (max-width: 480px) {
.wp-indicator-both .wp-bundle-header .wp-mod-dots .wp-mod-badge:not(.wp-mod-badge--example) {
display: none;
}
}
/* Narrowest headers (the preview's 300 px floor, a node squeezed thin):
* the summary goes, the name keeps what is left. */
@container wp-bundle-head (max-width: 280px) {
.wp-bundle-header .wp-bundle-summary { display: none; }
}
/* Dots for trouble inside a collapsed bundle. Same 7px circle as the row
* dots; the colour comes from the shared `.wp-conflict-dot--*` /
* `.wp-mod-dot--*` modifier rules. Never shrinks, so a narrow node still
* shows them while the name ellipsises. */
.wp-bundle-alerts {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
cursor: help;
}
.wp-bundle-alert {
width: 7px;
height: 7px;
border-radius: 50%;
border: 1px solid transparent;
flex-shrink: 0;
}
/* Child-count / drift summary. Was 10px in --wp-text-dim (#666 on the dark
* theme), which sat right at the edge of legibility against the bundle frame
@@ -361,7 +437,10 @@ const summary = computed(() => {
.wp-bundle-summary {
font: 600 11px/1.3 var(--wp-font-sans);
color: var(--wp-text-muted, var(--wp-text2));
flex-shrink: 0;
flex: 0 2 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
padding: 1px 6px;
border-radius: var(--wp-radius-cap, 999px);
background: color-mix(in oklab, var(--wp-text-muted, var(--wp-text2)) 12%, transparent);
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { bundleAlerts, bundleDescendants, collapsedAround, isHiddenInCollapsedBundle } from "./bundle-alerts";
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
function bundle(uid: string, over: Partial<BundleInstance> = {}): BundleInstance {
return {
_uid: uid, library_id: "lib", start_idx: 0, end_idx: 0, enabled: true,
collapsed: false, inserted_at_hash: "h", name: uid, color: null, ...over,
};
}
function mod(uid: string, origin: string | null, over: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: `id-${uid}`, _uid: uid, type: "wildcard", enabled: true,
meta: { name: `Mod ${uid}` }, payload: {}, bundle_origin: origin, ...over,
} as ModuleEntry;
}
const checks = (sev: Record<string, string>, missing: string[] = [], clash: string[] = []) => ({
severityFor: (id: string) => sev[id] ?? null,
conflictLabel: (id: string) => (sev[id] ? "missing var" : null),
isMissingFromLibrary: (m: ModuleEntry) => missing.includes(m._uid ?? ""),
isTypeConflict: (m: ModuleEntry) => clash.includes(m._uid ?? ""),
});
describe("isHiddenInCollapsedBundle", () => {
it("hides rows of a collapsed bundle and of a bundle inside one", () => {
const bundles = [bundle("outer", { collapsed: true }), bundle("inner", { parent_uid: "outer" })];
expect(isHiddenInCollapsedBundle(mod("a", "outer"), bundles)).toBe(true);
expect(isHiddenInCollapsedBundle(mod("b", "inner"), bundles)).toBe(true);
});
it("keeps top-level rows and rows of open bundles in sight", () => {
const bundles = [bundle("open"), bundle("shut", { collapsed: true })];
expect(isHiddenInCollapsedBundle(mod("a", null), bundles)).toBe(false);
expect(isHiddenInCollapsedBundle(mod("b", "open"), bundles)).toBe(false);
expect(isHiddenInCollapsedBundle(mod("c", "gone"), bundles)).toBe(false);
});
});
describe("bundleDescendants", () => {
it("takes sub-bundle rows too", () => {
const bundles = [bundle("outer"), bundle("inner", { parent_uid: "outer" }), bundle("other")];
const mods = [mod("a", "outer"), mod("b", "inner"), mod("c", "other"), mod("d", null)];
expect(bundleDescendants(bundles[0], mods, bundles).map((m) => m._uid)).toEqual(["a", "b"]);
expect(bundleDescendants(bundles[1], mods, bundles).map((m) => m._uid)).toEqual(["b"]);
});
});
describe("bundleAlerts", () => {
it("is empty when nothing is wrong", () => {
expect(bundleAlerts([mod("a", "x")], checks({}))).toEqual({ conflict: null, state: null, title: "" });
});
it("keeps the worst conflict and names each row", () => {
const r = bundleAlerts([mod("a", "x"), mod("b", "x")], checks({ a: "info", b: "warning" }));
expect(r.conflict).toBe("warning");
expect(r.title).toContain("Mod a: missing var");
expect(r.title).toContain("Mod b: missing var");
});
it("reports missing rows but not example ones, and clash over missing", () => {
expect(bundleAlerts([mod("a", "x", { example: true })], checks({}, ["a"])).state).toBeNull();
expect(bundleAlerts([mod("a", "x")], checks({}, ["a"])).state).toBe("missing");
expect(bundleAlerts([mod("a", "x"), mod("b", "x")], checks({}, ["a"], ["b"])).state).toBe("clash");
expect(bundleAlerts([mod("b", "x"), mod("a", "x")], checks({}, ["a"], ["b"])).state).toBe("clash");
});
});
describe("collapsedAround (Show node unfolds)", () => {
const outer = bundle("b-out", { collapsed: true });
const inner = bundle("b-in", { collapsed: true, parent_uid: "b-out" });
const open = bundle("b-open", { parent_uid: "b-out" });
const modules = [mod("m1", "b-in"), mod("m2", "b-open"), mod("m3", null)];
it("names every folded bundle around a module, innermost first", () => {
expect(collapsedAround("m1", modules, [outer, inner, open])).toEqual(["b-in", "b-out"]);
expect(collapsedAround("m2", modules, [outer, inner, open])).toEqual(["b-out"]);
});
it("leaves a bundle's own fold alone but opens the ones above it", () => {
expect(collapsedAround("b-in", modules, [outer, inner, open])).toEqual(["b-out"]);
});
it("is empty for a top-level module, an unknown uid or no folds", () => {
expect(collapsedAround("m3", modules, [outer, inner])).toEqual([]);
expect(collapsedAround("nope", modules, [outer, inner])).toEqual([]);
expect(collapsedAround("m1", modules, [bundle("b-in", { parent_uid: null })])).toEqual([]);
});
});
@@ -0,0 +1,118 @@
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
/** Bundle uid → parent bundle uid (null for a top-level bundle). */
function parentMap(bundles: readonly BundleInstance[] | undefined): Map<string, string | null> {
const parent = new Map<string, string | null>();
for (const b of bundles ?? []) {
if (!b || typeof b._uid !== "string") continue;
parent.set(b._uid, typeof b.parent_uid === "string" ? b.parent_uid : null);
}
return parent;
}
/** Every bundle uid a module sits in, innermost first. Depth-limited so a
* corrupt parent cycle can't loop. */
function bundleChain(module: ModuleEntry, parent: Map<string, string | null>): string[] {
const origin = (module as ModuleEntry & { bundle_origin?: string | null }).bundle_origin;
return chainFrom(typeof origin === "string" ? origin : null, parent);
}
/** `start` and every bundle above it, innermost first. */
function chainFrom(start: string | null | undefined, parent: Map<string, string | null>): string[] {
const out: string[] = [];
let cur = start;
while (cur && parent.has(cur) && !out.includes(cur) && out.length < 8) {
out.push(cur);
cur = parent.get(cur);
}
return out;
}
/** True when the module's row is out of sight because it, or one of its
* outer bundles, is collapsed. Its chips are hidden then, so they must not
* widen the node. */
export function isHiddenInCollapsedBundle(
module: ModuleEntry,
bundles: readonly BundleInstance[] | undefined,
): boolean {
const parent = parentMap(bundles);
const collapsed = new Set((bundles ?? []).filter((b) => b?.collapsed).map((b) => b._uid));
if (!collapsed.size) return false;
return bundleChain(module, parent).some((uid) => collapsed.has(uid));
}
/** The collapsed bundles that hide the module or bundle `uid` (a bundle's
* own fold is left alone: its header shows). Empty when nothing hides it. */
export function collapsedAround(
uid: string,
modules: readonly ModuleEntry[],
bundles: readonly BundleInstance[] | undefined,
): string[] {
const parent = parentMap(bundles);
const collapsed = new Set((bundles ?? []).filter((b) => b?.collapsed).map((b) => b._uid));
if (!collapsed.size) return [];
const mod = modules.find((m) => m?._uid === uid);
const chain = mod ? bundleChain(mod, parent) : parent.has(uid) ? chainFrom(parent.get(uid), parent) : [];
return chain.filter((b) => collapsed.has(b));
}
/** The modules a bundle holds, its sub-bundles' modules included. */
export function bundleDescendants(
bundle: BundleInstance,
modules: readonly ModuleEntry[],
bundles: readonly BundleInstance[] | undefined,
): ModuleEntry[] {
const parent = parentMap(bundles);
return modules.filter((m) => bundleChain(m, parent).includes(bundle._uid));
}
export type ConflictSeverity = "error" | "warning" | "info";
export type ChildState = "missing" | "clash";
export interface BundleAlertChecks {
severityFor: (id: string) => string | null | undefined;
conflictLabel: (id: string) => string | null | undefined;
isMissingFromLibrary: (m: ModuleEntry) => boolean;
isTypeConflict: (m: ModuleEntry) => boolean;
}
/** What a collapsed bundle's header shows about the rows it hides: the
* worst graph conflict and the worst library state among its modules, with
* a tooltip naming each module that has one. Drift and local edits are left
* out: the header already says those (the "N drifted" summary and the
* bundle's own modified chip). */
export interface BundleAlerts {
conflict: ConflictSeverity | null;
state: ChildState | null;
title: string;
}
const RANK: Record<ConflictSeverity, number> = { error: 3, warning: 2, info: 1 };
export function bundleAlerts(
children: readonly ModuleEntry[],
checks: BundleAlertChecks,
): BundleAlerts {
let conflict: ConflictSeverity | null = null;
let state: ChildState | null = null;
const lines: string[] = [];
for (const m of children) {
const name = m.meta?.name || "(unnamed)";
const sev = checks.severityFor(m._uid ?? m.id);
if (sev === "error" || sev === "warning" || sev === "info") {
if (!conflict || RANK[sev] > RANK[conflict]) conflict = sev;
lines.push(`${name}: ${checks.conflictLabel(m._uid ?? m.id) ?? sev}`);
}
if (checks.isTypeConflict(m)) {
state = "clash";
lines.push(`${name}: id clash`);
} else if (checks.isMissingFromLibrary(m) && !m.example) {
if (state !== "clash") state = "missing";
lines.push(`${name}: not in library`);
}
}
const title = lines.length
? `Inside this bundle:\n${lines.join("\n")}\nExpand it to see the rows.`
: "";
return { conflict, state, title };
}
@@ -1,5 +1,6 @@
import type { InjectionKey, Ref } from "vue";
import type { BundleInstance } from "../../../widgets/_shared";
import type { BundleAlerts } from "./bundle-alerts";
/** Shared state + handlers ContextWidget provides for any BundleFrame
* descendant to inject. Keeps the recursive frame component free of
@@ -24,9 +25,15 @@ export interface BundleFrameCtx {
bundleLockState: (b: BundleInstance) => "all" | "none" | "partial" | null;
/** Returns "before"/"after" when this bundle is the indicator anchor
* for the current slot zone, null otherwise. Drives the
* `wp-gap-before`/`wp-gap-after` margin class so a gap opens for the
* drop-bar. */
* kit's `wp-rowdrag--before`/`--after` class (row-drag.css): a gap
* opens with the drop line in it. */
bundleHeaderGap: (uid: string) => "before" | "after" | null;
/** The drop line's colour variable when this frame anchors the drop. */
bundleLineStyle: (uid: string) => Record<string, string> | undefined;
/** True when the drop goes into this CLOSED bundle (opens on drop). */
isBundleDropInto: (uid: string) => boolean;
/** `_uid` of the bundle being dragged by its header (its frame dims). */
draggingBundleUid: Ref<string | null>;
/** True when the current drag's resolved slot zone targets THIS
* bundle's body — i.e. the drop will land inside this bundle. Drives
* a frame-level highlight (ring + tinted background) so users get a
@@ -40,6 +47,9 @@ export interface BundleFrameCtx {
* per-module MOD dot, but at bundle scope. False when no
* fingerprint is stored yet (backfill case). */
isBundleSnapshotModified: (b: BundleInstance) => boolean;
/** Worst conflict + library state among the rows the bundle holds. A
* collapsed header shows them as dots, so trouble inside stays visible. */
bundleAlerts: (b: BundleInstance) => BundleAlerts;
recentDropUids: Ref<Set<string>>;
pulseDelayFor: (uid: string | null | undefined) => string;
toggleBundleCollapsed: (uid: string) => void;
@@ -50,12 +60,6 @@ export interface BundleFrameCtx {
openBundleContextMenu: (ev: MouseEvent, uid: string) => void;
onBundleDragStart: (ev: DragEvent, uid: string) => void;
onDragEnd: () => void;
/** Returns the drop-indicator position for a given container scope.
* `containerUid = null` means the top-level list. Returns null when
* the resolved zone doesn't target this container — caller hides the
* bar. ContextWidget owns the dragOver ref + DOM rect math; this
* ctx surfaces the per-container slice. */
dropBarFor: (containerUid: string | null) => { top: number } | null;
}
export const BundleFrameCtxKey: InjectionKey<BundleFrameCtx> = Symbol("bundleFrameCtx");
@@ -61,6 +61,10 @@ function collectMovingBundleUids(
return out;
}
/** Share of a closed bundle's height, at its top and at its bottom, that
* drops beside it instead of into it. */
export const CLOSED_EDGE = 0.3;
/** Resolves a pointer position to a drop zone.
*
* jsdom + headless environments don't implement elementFromPoint —
@@ -203,6 +207,15 @@ function resolveWithin(
const b = (value.bundles ?? []).find((bb) => bb._uid === uid);
const targetIsNested = b && typeof b.parent_uid === "string" && !!b.parent_uid;
const tierCap = drag?.kind === "bundle" && (dragHasNested || targetIsNested);
// A closed bundle is just its header: the top and bottom edges
// drop beside it, the middle drops INTO it (at its end; the
// bundle opens on drop).
if (b && b.collapsed && !tierCap) {
const edge = r.height * CLOSED_EDGE;
if (y < r.top + edge) return { kind: "slot", containerUid, insertIdx: startIdxOf(k, value) };
if (y > r.bottom - edge) return { kind: "slot", containerUid, insertIdx: endIdxOf(k, value) + 1 };
return { kind: "slot", containerUid: uid, insertIdx: b.end_idx + 1 };
}
if (!tierCap) {
const body = k.querySelector<HTMLElement>(":scope > .wp-bundle-children");
if (body) return resolveWithin(body, y, uid, value, drag, moving);
+2 -1
View File
@@ -91,7 +91,8 @@ describe("applyFlip", () => {
await new Promise(r => requestAnimationFrame(r));
expect(a.style.transition).toContain("transform");
expect(a.style.transition).toContain(`${MOTION_FLIP_MS}ms`);
expect(a.style.transform).toBe("translate(0, 0)");
// jsdom 31 serialises `translate(0, 0)` as `translate(0px)`.
expect(a.style.transform).toMatch(/^translate\(0(px)?(, 0(px)?)?\)$/);
document.body.removeChild(container);
});
+66 -118
View File
@@ -71,6 +71,8 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
// lifted-ghost class so the source visually picks up alongside the
// browser's default drag image.
const draggingModuleUid = ref<string | null>(null);
// `_uid` of the bundle being dragged by its header: its frame dims.
const draggingBundleUid = ref<string | null>(null);
// `_uid` of the most recently dropped row — pulses for 450ms after a
// drop lands, then clears.
// Phase B.4: Set + order map for multi-child pulse on cross-node bundle
@@ -88,9 +90,9 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
const suppressMove = ref(false);
let suppressMoveTimer: number | null = null;
// Drag anchor: the row or bundle whose CSS class opens the gap for the
// indicator bar. Computed once per dragOver change so rowGap +
// bundleGap + dropBarFor all consult the same source of truth.
// Drag anchor: the row or bundle whose kit class (row-drag.css) opens
// the gap with the drop line in it. Computed once per dragOver change so
// rowGap, bundleHeaderGap and the line styles share one answer.
//
// Slot semantics:
// - `insertIdx === <item start_idx>` → anchor at THAT item with pos
@@ -98,11 +100,14 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
// - No matching item → anchor at the LAST item in the container with
// pos "after" (bar below)
//
// Empty container → no anchor (bar paints at container center via the
// dropBarFor fallback).
// Empty container → no anchor (the empty-state hero takes the drop).
// A closed bundle → "into": the whole frame is the target.
type AnchorInfo =
| { kind: "row"; idx: number; pos: "before" | "after"; containerUid: string | null }
| { kind: "bundle"; uid: string; pos: "before" | "after"; containerUid: string | null };
| { kind: "bundle"; uid: string; pos: "before" | "after"; containerUid: string | null }
// A closed bundle takes the drop as a whole: its frame lights up and a
// line in its colour runs under the header (no gap, its rows are hidden).
| { kind: "into"; uid: string; containerUid: string };
function containerItems(
containerUid: string | null,
@@ -145,6 +150,10 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
const dragAnchor = computed<AnchorInfo | null>(() => {
const z = dragOver.value;
if (!z) return null;
if (z.containerUid !== null) {
const target = (value.value.bundles ?? []).find((b) => b._uid === z.containerUid);
if (target?.collapsed) return { kind: "into", uid: target._uid, containerUid: target._uid };
}
const items = containerItems(z.containerUid, value.value);
if (items.length === 0) return null;
for (const it of items) {
@@ -175,6 +184,41 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
return a.pos;
}
/** The drop line's colour: a bundle's own colour when the drop lands in
* that bundle, the accent at the top level (the kit's default). */
function containerLineColor(containerUid: string | null): string | null {
if (containerUid === null) return null;
const b = (value.value.bundles ?? []).find((bb) => bb._uid === containerUid);
if (!b) return null;
return b.color ? b.color : "var(--wp-bundle-default)";
}
/** CSS variables for the kit's drop line on its anchor (row or bundle
* frame), so a line inside a bundle wears that bundle's colour. */
function dropLineStyle(a: AnchorInfo | null): Record<string, string> | undefined {
if (!a) return undefined;
const color = containerLineColor(a.containerUid);
return color ? { "--wp-rowdrag-color": color } : undefined;
}
function rowLineStyle(idx: number): Record<string, string> | undefined {
const a = dragAnchor.value;
if (!a || a.kind !== "row" || a.idx !== idx) return undefined;
return dropLineStyle(a);
}
function bundleLineStyle(uid: string): Record<string, string> | undefined {
const a = dragAnchor.value;
if (!a || a.kind !== "bundle" || a.uid !== uid) return undefined;
return dropLineStyle(a);
}
/** True when the drop goes into this CLOSED bundle (it opens on drop). */
function isBundleDropInto(uid: string): boolean {
const a = dragAnchor.value;
return a?.kind === "into" && a.uid === uid;
}
// Bundle gap (frame margin opens above/below the bundle). Reads the
// drag anchor; returns "before" when this bundle is the anchor with
// pos "before", "after" when it's the anchor with pos "after".
@@ -195,110 +239,6 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
return z.containerUid === uid;
}
/** Looks up the indicator-bar position for a drop container.
*
* `containerUid = null` → top-level list (the `.wp-modules` container
* inside the `.wp-modules-frame` ref).
* `containerUid = bundleUid` → that bundle's `.wp-bundle-children`
* scrolling body.
*
* Returns `{top}` in pixels relative to that container's content box
* when the resolved zone targets the container; null otherwise. The
* BundleDropBar component renders an absolutely-positioned line at the
* returned offset.
*
* The bar paints in the MIDDLE of the gap opened by the `wp-gap-*`
* margin (14px) — half that offset (7px) lifts the bar off the row's
* edge so it sits in the gap visibly rather than overlapping the
* row's own top/bottom border. That gives ONE clear line per drop
* position instead of "is that the bar or the row's border?".
*/
const GAP_BAR_OFFSET = 7;
function dropBarFor(containerUid: string | null): { top: number } | null {
const a = dragAnchor.value;
if (!a) {
// Empty container case (only fires for empty top-level — bundles
// always have ≥1 child by invariant). Paint at container center.
const z = dragOver.value;
if (!z || (z.containerUid ?? null) !== (containerUid ?? null)) return null;
const containerEl = findContainerEl(containerUid);
if (!containerEl) return null;
return { top: containerEl.clientHeight / 2 };
}
if ((a.containerUid ?? null) !== (containerUid ?? null)) return null;
const containerEl = findContainerEl(containerUid);
if (!containerEl) return null;
// CRITICAL: ComfyUI applies `transform: scale(zoom)` to the dom-widget
// container. `getBoundingClientRect` returns VIEWPORT pixels (scaled).
// `offsetTop` is in CSS pixels relative to offsetParent — that's the
// right unit for `position:absolute; top` under a transformed ancestor.
const anchorEl: HTMLElement | null =
a.kind === "row" ? findRowEl(a.idx, containerEl) : findBundleFrameEl(a.uid, containerEl);
if (!anchorEl) return null;
const off = elementOffsetTopWithin(anchorEl, containerEl);
return {
top: a.pos === "before"
? off - GAP_BAR_OFFSET
: off + anchorEl.offsetHeight + GAP_BAR_OFFSET,
};
}
/** Walks `offsetParent` chain from `el` up until reaching `containerEl`,
* accumulating CSS-pixel offsets. Returns `el`'s top relative to
* `containerEl`'s padding edge (i.e. the same unit `position:absolute;
* top: <value>` resolves against when `containerEl` is positioned).
* Falls back to `el.offsetTop` if `containerEl` isn't reachable via
* the offsetParent chain (defensive — would indicate broken layout). */
function elementOffsetTopWithin(el: HTMLElement, containerEl: HTMLElement): number {
let off = 0;
let cur: HTMLElement | null = el;
let safety = 0;
while (cur && cur !== containerEl && safety < 32) {
off += cur.offsetTop;
cur = cur.offsetParent as HTMLElement | null;
safety += 1;
}
if (cur !== containerEl) return el.offsetTop;
return off;
}
/** Locate the offset container for a given scope. Top-level → the
* `.wp-modules` div inside modulesContainer.value. Nested → that
* bundle's `.wp-bundle-children`. Returns null when the container
* isn't mounted yet (e.g. early reactive read). */
function findContainerEl(containerUid: string | null): HTMLElement | null {
const frame = modulesContainer.value;
if (!frame) return null;
if (containerUid === null) {
return frame.querySelector<HTMLElement>(":scope > .wp-modules");
}
return frame.querySelector<HTMLElement>(
`.wp-bundle[data-bundle-uid="${containerUid}"] > .wp-bundle-children`,
);
}
/** Find a module row by its absolute module idx, restricted to a given
* container scope (so nested rows don't get matched at top level). */
function findRowEl(moduleIdx: number, containerEl: HTMLElement): HTMLElement | null {
return containerEl.querySelector<HTMLElement>(
`:scope > .wp-module[data-module-idx="${moduleIdx}"]`,
) ?? containerEl.querySelector<HTMLElement>(
`.wp-module[data-module-idx="${moduleIdx}"]`,
);
}
/** Find a bundle's outer wrapper element. Used for header.before/after
* anchoring; `:scope >` keeps us in the container's immediate scope
* so nested bundles don't bleed into a parent container's lookup. */
function findBundleFrameEl(uid: string, containerEl: HTMLElement): HTMLElement | null {
return containerEl.querySelector<HTMLElement>(
`:scope > .wp-bundle[data-bundle-uid="${uid}"]`,
) ?? containerEl.querySelector<HTMLElement>(
`.wp-bundle[data-bundle-uid="${uid}"]`,
);
}
function clearDragHover() {
if (dragOver.value === null) return;
dragOver.value = null;
@@ -468,6 +408,7 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
children,
innerInstances,
};
draggingBundleUid.value = uid;
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", `bundle:${uid}`);
@@ -529,6 +470,7 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
sameNodeDropHandled = false;
dragOver.value = null;
draggingModuleUid.value = null;
draggingBundleUid.value = null;
}
let sameNodeDropHandled = false;
@@ -543,9 +485,8 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
/** Dragover handler — delegates pointer-to-zone classification to the
* pure resolver. The resolver walks DOM from the pointer (innermost
* `.wp-bundle` ancestor wins as the target container) and applies the
* tier-2 cap. ContextWidget just sets `dragOver.value`; the indicator
* paint happens via the per-container `<BundleDropBar>` reading
* `dropBarFor()`. */
* tier-2 cap. ContextWidget just sets `dragOver.value`; the anchor's
* kit classes paint the gap and line. */
function onListDragOver(ev: DragEvent) {
const ds = dragState.value;
if (!ds) return;
@@ -713,7 +654,10 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
return;
}
holdSuppressMove();
commitModules(next.modules, next.bundles);
// A bundle dropped into a closed bundle opens it, like a row does.
const parentUid = zone?.containerUid ?? null;
commitModules(next.modules, (next.bundles ?? []).map((b) =>
parentUid && b._uid === parentUid && b.collapsed ? { ...b, collapsed: false } : b));
await nextTick();
playFlipSnapshot(flipSnap);
// Pulse the bundle wrapper AND every row in its range so a
@@ -768,7 +712,11 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
// reconcileBundleRanges (via commitModules) will recompute
// start_idx/end_idx for every bundle from each child's bundle_origin
// chain — the placeholder ranges on freshInners get overwritten.
commitModules(list, [...bundles, newBundle, ...freshInners]);
commitModules(list, [
...bundles.map((b) => (outerParentUid && b._uid === outerParentUid && b.collapsed ? { ...b, collapsed: false } : b)),
newBundle,
...freshInners,
]);
// Snap fingerprints on the freshly-cloned outer + inners so the
// cross-node receive registers as a clean baseline (matches the
// semantics of a library insert).
@@ -851,11 +799,11 @@ export function useContextDrag(state: WidgetState, props: { readonly nodeId: num
}
const rowCtx = {
rowGap, draggingModuleUid, recentDropUids, pulseDelayFor, onDragStart, onDragEnd,
rowGap, rowLineStyle, draggingModuleUid, recentDropUids, pulseDelayFor, onDragStart, onDragEnd,
} satisfies Partial<ModuleRowCtx>;
const frameCtx = {
bundleHeaderGap, isBundleDropTarget, recentDropUids, pulseDelayFor,
onBundleDragStart, onDragEnd, dropBarFor,
bundleHeaderGap, bundleLineStyle, isBundleDropTarget, isBundleDropInto, draggingBundleUid,
recentDropUids, pulseDelayFor, onBundleDragStart, onDragEnd,
} satisfies Partial<BundleFrameCtx>;
return {
+34 -17
View File
@@ -59,9 +59,6 @@
display: flex;
flex-direction: column;
gap: var(--wp-row-gap);
/* Drop-bar anchor — the floating <BundleDropBar :container-uid="null">
* is absolutely positioned and needs this as its offset parent so
* its top/left coordinates resolve against the top-level scope. */
position: relative;
}
@@ -150,6 +147,10 @@
border-radius: var(--wp-radius, 4px);
background: color-mix(in srgb, var(--wp-bundle-color, var(--wp-bundle-default)) 5%, transparent);
display: grid;
/* minmax(0, 1fr): an auto column grows to the header's min-content, so
* on a narrow node (or the Settings live preview) the frame and its
* children ran past the right edge instead of the header shortening. */
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr;
transition: grid-template-rows var(--wp-motion-collapse) var(--wp-motion-curve-collapse),
border-width var(--wp-motion-quick) ease,
@@ -478,27 +479,43 @@
.wp-module.wp-mod--drift { border-color: var(--wp-warn); background: color-mix(in srgb, var(--wp-warn) 8%, var(--wp-bg3)); }
.wp-module.wp-mod--err { border-color: var(--wp-danger); background: color-mix(in srgb, var(--wp-danger) 8%, var(--wp-bg3)); }
/* Gap metaphor: target row gets margin so a slot opens; the
* `.wp-gap-bar` element paints inside that slot. Margin transitions
* via the row's existing `transition: transform/...` rule below; we
* extend it to cover `margin`. */
.wp-module.wp-gap-before,
.wp-bundle-header.wp-gap-before,
.wp-w-footer.wp-gap-before { margin-top: 14px; }
.wp-module.wp-gap-after { margin-bottom: 14px; }
/* Margins snap (no transition) so back-to-back zone changes don't
* leave half-open gaps mid-flight. Transform/opacity/etc still animate
* via TransitionGroup's FLIP rules + the wp-list-* classes below. */
/* Drop gap + line: the kit's row-drag.css (the Context Injector's look).
* The row or bundle frame the drop lands beside gets `wp-rowdrag--before`
* / `--after`: a 14px gap opens with a glowing line in it, in the bundle's
* colour inside a bundle. The gap eases open (margins transition here
* too, since this rule outranks the kit's). */
.wp-modules .wp-module {
transition: transform var(--wp-motion-fade) var(--wp-motion-curve-flip),
margin-top var(--wp-motion-fade) var(--wp-motion-curve-flip),
margin-bottom var(--wp-motion-fade) var(--wp-motion-curve-flip),
background var(--wp-motion-quick) ease, border-color var(--wp-motion-quick) ease, opacity var(--wp-motion-quick) ease;
}
.wp-bundle-header { transition: border-bottom-color var(--wp-motion-fade) ease, background var(--wp-motion-quick) ease; }
.wp-bundle {
transition: border-width var(--wp-motion-quick) ease, background var(--wp-motion-quick) ease, box-shadow var(--wp-motion-quick) ease;
transition: border-width var(--wp-motion-quick) ease, background var(--wp-motion-quick) ease, box-shadow var(--wp-motion-quick) ease,
margin-top var(--wp-motion-fade) var(--wp-motion-curve-flip), margin-bottom var(--wp-motion-fade) var(--wp-motion-curve-flip),
opacity var(--wp-motion-quick) ease;
}
/* A closed bundle takes a drop as a whole: the frame lights up (drop
* target, above) and a line in its colour runs under the header, the
* same line the kit draws in a gap. It opens when the drop lands. */
.wp-bundle.wp-bundle--drop-into::after {
content: "";
position: absolute;
left: 6px;
right: 6px;
bottom: 3px;
z-index: 3;
height: 3px;
background: var(--wp-bundle-color, var(--wp-bundle-default));
border-radius: min(2px, var(--wp-radius-cap, 999px));
box-shadow: 0 0 8px var(--wp-bundle-color, var(--wp-bundle-default));
pointer-events: none;
animation: wp-rowdrag-line-in var(--wp-motion-quick, 120ms) ease-out;
}
@media (prefers-reduced-motion: reduce) {
.wp-bundle.wp-bundle--drop-into::after { animation: none; }
}
.wp-bundle.wp-gap-before { margin-top: 14px; }
.wp-bundle.wp-gap-after { margin-bottom: 14px; }
/* Source ghost — lift the row while it's mid-drag. */
.wp-module--dragging {
+4 -37
View File
@@ -1,7 +1,7 @@
/* Shared modal-head styling — adopted from InjectorBindingModal so
* every per-instance edit modal in the extension reads as one
* design family. Each modal sets `--modal-accent` to its kind color;
* the gradient + chip + icon all read from that single var.
* design family. The frame and the coloured header are the kit Modal's
* (`accent` = the kind colour); the chip reads it as `--wp-modal-accent`.
*
* Imported once from ContextWidget's unscoped `<style>` block — the
* class names are namespaced (`wp-cbm__`, `wp-cnm__`, `wp-dvm__`,
@@ -12,39 +12,6 @@
* can't parse @layer; namespace prefix carries isolation duty).
*/
/* Per-modal accent — kind color drives the head gradient + chip + icon. */
.wp-cbm__head { --modal-accent: var(--wp-kind-combine); }
.wp-cnm__head { --modal-accent: var(--wp-kind-constraint, var(--wp-accent)); }
.wp-dvm__head { --modal-accent: var(--wp-kind-derivation, var(--wp-accent)); }
.wp-fvm__head { --modal-accent: var(--wp-kind-fixed); }
.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;
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-cbm__head-icon, .wp-cnm__head-icon, .wp-dvm__head-icon, .wp-fvm__head-icon, .wp-wcm__head-icon {
color: var(--modal-accent, var(--wp-accent));
font-size: 18px;
width: 24px;
text-align: center;
}
.wp-cbm__title-block, .wp-cnm__title-block, .wp-dvm__title-block, .wp-fvm__title-block, .wp-wcm__title-block {
flex: 1;
min-width: 0;
@@ -67,8 +34,8 @@
letter-spacing: 0.06em;
padding: 2px 6px;
border-radius: 2px;
background: color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 22%, transparent);
color: var(--modal-accent, var(--wp-accent));
background: color-mix(in srgb, var(--wp-modal-accent, var(--wp-accent)) 22%, transparent);
color: var(--wp-modal-accent, var(--wp-accent));
}
.wp-cbm__sub, .wp-cnm__sub, .wp-dvm__sub, .wp-fvm__sub, .wp-wcm__sub {
@@ -5,7 +5,7 @@
import { describe, it, expect } from "vitest";
import { tokenize, type Surface } from "./preview-tokens";
import { hasInlinePicks, tokenize, type Surface } from "./preview-tokens";
describe("preview-tokens", () => {
describe("VAR token", () => {
@@ -134,3 +134,12 @@ describe("preview-tokens", () => {
});
});
});
describe("hasInlinePicks", () => {
it("finds picks and repeats, not plain braces or vars", () => {
expect(hasInlinePicks("{a|b|c}")).toBe(true);
expect(hasInlinePicks("x {2$$, $$a|b} y")).toBe(true);
expect(hasInlinePicks("$time light")).toBe(false);
expect(hasInlinePicks("")).toBe(false);
});
});
@@ -163,3 +163,14 @@ export function tokenize(text: string, surface: Surface): PreviewToken[] {
pushText(out, textBuf);
return out;
}
/** Words for a field that writes text as it is, with no seed to roll on
* (Context Injector templates, a WP Toggles Pick one Value): inline
* `{a|b}` picks and `{N$$…}` repeats print as written there. */
export const INLINE_PICKS_NOTE = "{a|b} picks need a seed, so they print as written here. Roll them in a wildcard or WP Context instead.";
/** Whether `text` holds an inline pick or repeat (`{a|b}`, `{2$$, $$a|b}`). */
export function hasInlinePicks(text: string): boolean {
if (!text.includes("{")) return false;
return tokenize(text, "assembler").some((t) => t.kind === "alt" || t.kind === "repeat");
}
@@ -11,6 +11,7 @@ import IdentitySection from "./sections/IdentitySection.vue";
import RuntimeSection from "./sections/RuntimeSection.vue";
import InstanceIdChip from "../InstanceIdChip.vue";
import Button from "@/kit/Button.vue";
import Modal from "@/kit/Modal.vue";
/** Platform-correct save chord for the footer hint. */
@@ -46,6 +47,17 @@ const emit = defineEmits<{
(e: "reset-to-library"): void;
(e: "open-spa"): void;
}>();
/** The kit Modal closes on Escape, the backdrop and its ×: that is Cancel. */
function onModalOpen(open: boolean): void {
if (!open) emit("cancel");
}
/** Ctrl/Cmd+Enter saves from anywhere in the window. */
function onModalKey(ev: KeyboardEvent): void {
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
emit("save");
}
}
/** SPA URL for the bundle library entry. Mirrors WildcardInstanceModal's
* `/wp/wildcards/{id}/edit` pattern. */
@@ -68,12 +80,11 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
</script>
<template>
<div class="wp-bdm" :style="{ '--modal-accent': headerColor }">
<header class="wp-bdm__head">
<i class="pi pi-box wp-bdm__head-icon" aria-hidden="true" />
<Modal :open="true" size="lg" flush :accent="headerColor" icon="box" @update:open="onModalOpen" @keydown="onModalKey">
<template #header="{ titleId }">
<div class="wp-bdm__title-block">
<div class="wp-bdm__title-row">
<span class="wp-bdm__name" data-test="bdm-name-display">{{ headerName }}</span>
<span :id="titleId" class="wp-bdm__name" data-test="bdm-name-display">{{ headerName }}</span>
<span class="wp-bdm__chip" data-test="bdm-chip">bundle</span>
<InstanceIdChip :id="bundle.library_id" :uid="bundle._uid" />
<span
@@ -91,9 +102,10 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
</div>
<div class="wp-bdm__sub">Per-Context instance · name + color edits stay local</div>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="bdm-close" @click="emit('cancel')" />
</header>
</template>
<div class="wp-bdm">
<slot name="banner" />
<IdentitySection
:bundle="bundle"
:library-name="libraryName"
@@ -108,7 +120,9 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
@toggle-internal="emit('toggle-internal')"
/>
<footer class="wp-bdm__foot">
</div>
</div>
<template #footer>
<a
v-if="bundle.library_id"
class="wp-bdm__spa-link"
@@ -141,25 +155,21 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
>Save to library</Button>
<Button size="sm" data-test="bdm-cancel" @click="emit('cancel')">Cancel</Button>
<Button size="sm" variant="primary" data-test="bdm-save" @click="emit('save')">Save</Button>
</footer>
</div>
</div>
</template>
</Modal>
</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-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: color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 9%, var(--wp-bg2)); 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 { display: flex; flex-direction: column; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
.wp-bdm__title-block { flex: 1; min-width: 0; }
.wp-bdm__title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.wp-bdm__name { font: 600 13px var(--wp-font-sans); color: var(--wp-text); }
.wp-bdm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 22%, transparent); color: var(--modal-accent, var(--wp-accent)); }
.wp-bdm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-modal-accent, var(--wp-accent)) 22%, transparent); color: var(--wp-modal-accent, var(--wp-accent)); }
.wp-bdm__sub { font: 400 10px var(--wp-font-mono); color: var(--wp-text-dim, var(--wp-text3)); margin-top: 2px; }
.wp-bdm__badge { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); }
.wp-bdm__badge--drift { background: color-mix(in srgb, var(--wp-warn) 22%, transparent); color: var(--wp-warn); }
.wp-bdm__badge--mod { background: color-mix(in srgb, var(--wp-status-modified) 22%, transparent); color: var(--wp-status-modified); }
.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)); }
.wp-bdm__spa-link .pi { font-size: 10px; }
@@ -6,10 +6,13 @@
// `--wp-kind-combine` accent.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import { mount, config } from "@vue/test-utils";
import CombineInstanceModal from "./CombineInstanceModal.vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
// The window is a kit Modal, which teleports to <body>: render it in place.
config.global.stubs = { ...config.global.stubs, teleport: true };
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "ab12cd34",
@@ -30,7 +33,7 @@ function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
describe("CombineInstanceModal", () => {
it("renders pi-link icon in header", () => {
const w = mount(CombineInstanceModal, { props: { module: makeModule() } });
expect(w.find(".wp-cbm__head-icon.pi.pi-link").exists()).toBe(true);
expect(w.find(".wp-modal__icon.pi-link").exists()).toBe(true);
});
it("renders 'combine' chip + module name in header", () => {
@@ -21,6 +21,7 @@ import type { VarProducerLike } from "../../../../manager/components/RefChip.vue
import RuntimeSection from "./sections/RuntimeSection.vue";
import InstanceIdChip from "../InstanceIdChip.vue";
import Button from "@/kit/Button.vue";
import Modal from "@/kit/Modal.vue";
/** Platform-correct save chord for the footer hint. */
@@ -66,6 +67,17 @@ const emit = defineEmits<{
"save-to-library": [];
"clear-all-overrides": [];
}>();
/** The kit Modal closes on Escape, the backdrop and its ×: that is Cancel. */
function onModalOpen(open: boolean): void {
if (!open) emit("cancel");
}
/** Ctrl/Cmd+Enter saves from anywhere in the window. */
function onModalKey(ev: KeyboardEvent): void {
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
emit("save");
}
}
const isLibraryTracked = computed(() => Boolean(props.module.payload_hash));
// See WildcardInstanceModal — PushToLibraryModal owns the update vs fork
@@ -90,20 +102,20 @@ function onSpaClick(): void {
</script>
<template>
<div class="cbm">
<header class="wp-cbm__head">
<i class="pi pi-link wp-cbm__head-icon" aria-hidden="true" />
<Modal :open="true" size="lg" flush accent="var(--wp-kind-combine)" icon="link" @update:open="onModalOpen" @keydown="onModalKey">
<template #header="{ titleId }">
<div class="wp-cbm__title-block">
<div class="wp-cbm__title-row">
<span class="wp-cbm__name" data-test="cbm-name">{{ module.meta?.name ?? module.type }}</span>
<span :id="titleId" class="wp-cbm__name" data-test="cbm-name">{{ module.meta?.name ?? module.type }}</span>
<span class="wp-cbm__chip" data-test="cbm-chip">combine</span>
<InstanceIdChip :id="module.id" :uid="module._uid" />
</div>
<div class="wp-cbm__sub">Library entry · template interpolates $vars into output binding</div>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="cbm-close" @click="emit('cancel')" />
</header>
</template>
<div class="cbm">
<slot name="banner" />
<IdentitySection
:module="module"
:upstream-vars="upstreamVars"
@@ -122,7 +134,9 @@ function onSpaClick(): void {
<div class="wp-cbm__dock">
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-cbm__foot">
</div>
</div>
<template #footer>
<a
v-if="isLibraryTracked"
class="wp-cbm__spa-link"
@@ -154,27 +168,20 @@ function onSpaClick(): void {
>Save to library</Button>
<Button size="sm" data-test="cbm-cancel" @click="emit('cancel')">Cancel</Button>
<Button size="sm" variant="primary" data-test="cbm-save" @click="emit('save')">Save</Button>
</footer>
</div>
</div>
</template>
</Modal>
</template>
<style scoped>
.cbm {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
width: 820px;
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);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
/* The frame, the coloured header and the footer row are the kit Modal's;
* the title block's styles live 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 {
@@ -183,14 +190,6 @@ function onSpaClick(): void {
z-index: 2;
background: var(--wp-bg2);
}
.wp-cbm__foot {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--wp-bg3);
border-top: 1px solid var(--wp-border);
}
.wp-cbm__spa-link {
display: inline-flex;
align-items: center;
@@ -1,7 +1,10 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { mount } from "@vue/test-utils";
import { mount, config } from "@vue/test-utils";
import ConstraintInstanceModal from "./ConstraintInstanceModal.vue";
// The window is a kit Modal, which teleports to <body>: render it in place.
config.global.stubs = { ...config.global.stubs, teleport: true };
// Preview-resolver lookups MISS so the source uuid reads as dangling.
vi.mock("../../../../extension/preview-resolver", () => ({
cacheVersion: { value: 0 },
@@ -6,11 +6,14 @@
// honor `locked_seed`.
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import { mount, config } from "@vue/test-utils";
import ConstraintInstanceModal from "./ConstraintInstanceModal.vue";
import TargetReachSection from "./sections/TargetReachSection.vue";
import type { ModuleEntry, TargetSelect } from "../../../../widgets/_shared";
// The window is a kit Modal, which teleports to <body>: render it in place.
config.global.stubs = { ...config.global.stubs, teleport: true };
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "cn012345",
@@ -33,7 +36,7 @@ function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
describe("ConstraintInstanceModal", () => {
it("renders pi-link icon in header", () => {
const w = mount(ConstraintInstanceModal, { props: { module: makeModule() } });
expect(w.find(".wp-cnm__head-icon.pi.pi-link").exists()).toBe(true);
expect(w.find(".wp-modal__icon.pi-link").exists()).toBe(true);
});
it("renders 'constraint' chip + module name", () => {
@@ -47,6 +47,7 @@ import { collectTransitiveDeps } from "../../../../manager/community/transitive-
import { downloadDepsForDangling } from "../../../../manager/community/download-and-reattach";
import { installEnvelope } from "../../../../manager/import-export/install";
import Button from "@/kit/Button.vue";
import Modal from "@/kit/Modal.vue";
/** Platform-correct save chord for the footer hint. */
@@ -87,6 +88,17 @@ const emit = defineEmits<{
"save-to-library": [];
"clear-all-overrides": [];
}>();
/** The kit Modal closes on Escape, the backdrop and its ×: that is Cancel. */
function onModalOpen(open: boolean): void {
if (!open) emit("cancel");
}
/** Ctrl/Cmd+Enter saves from anywhere in the window. */
function onModalKey(ev: KeyboardEvent): void {
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
emit("save");
}
}
const isLibraryTracked = computed(() => Boolean(props.module.payload_hash));
// See WildcardInstanceModal — PushToLibraryModal owns the update vs fork
@@ -675,20 +687,20 @@ function onSpaClick(): void {
</script>
<template>
<div class="cnm">
<header class="wp-cnm__head">
<i class="pi pi-link wp-cnm__head-icon" aria-hidden="true" />
<Modal :open="true" size="lg" flush accent="var(--wp-kind-constraint, var(--wp-accent))" icon="link" @update:open="onModalOpen" @keydown="onModalKey">
<template #header="{ titleId }">
<div class="wp-cnm__title-block">
<div class="wp-cnm__title-row">
<span class="wp-cnm__name" data-test="cnm-name">{{ module.meta?.name ?? module.type }}</span>
<span :id="titleId" class="wp-cnm__name" data-test="cnm-name">{{ module.meta?.name ?? module.type }}</span>
<span class="wp-cnm__chip" data-test="cnm-chip">constraint</span>
<InstanceIdChip :id="module.id" :uid="module._uid" />
</div>
<div class="wp-cnm__sub">Library entry · source pick → modifies target option weights</div>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="cnm-close" @click="emit('cancel')" />
</header>
</template>
<div class="cnm">
<slot name="banner" />
<ConstraintReattachSection
v-if="hasDangling"
:dangling-source="danglingSource"
@@ -745,7 +757,20 @@ function onSpaClick(): void {
:target-name="targetName"
@update:model-value="onTargetSelect"
/>
<footer class="wp-cnm__foot">
</div>
<!-- Feature 2: confirm pulling the missing dependency (+ its closure)
from the community before any network/install side effect. -->
<ConfirmModal
:open="downloadConfirmOpen"
title="Download dependencies"
:body="downloadConfirmBody"
confirm-label="Download & install"
@confirm="confirmDownload"
@cancel="downloadConfirmOpen = false"
/>
</div>
<template #footer>
<a
v-if="isLibraryTracked"
class="wp-cnm__spa-link"
@@ -777,38 +802,20 @@ function onSpaClick(): void {
>Save to library</Button>
<Button size="sm" data-test="cnm-cancel" @click="emit('cancel')">Cancel</Button>
<Button size="sm" variant="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. -->
<ConfirmModal
:open="downloadConfirmOpen"
title="Download dependencies"
:body="downloadConfirmBody"
confirm-label="Download & install"
@confirm="confirmDownload"
@cancel="downloadConfirmOpen = false"
/>
</div>
</template>
</Modal>
</template>
<style scoped>
.cnm {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
width: 820px;
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);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
/* The frame, the coloured header and the footer row are the kit Modal's;
* the title block's styles live in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-cnm__dock {
position: sticky;
@@ -816,14 +823,6 @@ function onSpaClick(): void {
z-index: 2;
background: var(--wp-bg2);
}
.wp-cnm__foot {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--wp-bg3);
border-top: 1px solid var(--wp-border);
}
.wp-cnm__spa-link {
display: inline-flex;
align-items: center;
@@ -5,10 +5,13 @@
// (same icon picker.ts uses for derivation kind).
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import { mount, config } from "@vue/test-utils";
import DerivationInstanceModal from "./DerivationInstanceModal.vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
// The window is a kit Modal, which teleports to <body>: render it in place.
config.global.stubs = { ...config.global.stubs, teleport: true };
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "dv012345",
@@ -34,7 +37,7 @@ function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
describe("DerivationInstanceModal", () => {
it("renders pi-arrow-right-arrow-left icon in header", () => {
const w = mount(DerivationInstanceModal, { props: { module: makeModule() } });
expect(w.find(".wp-dvm__head-icon.pi.pi-arrow-right-arrow-left").exists()).toBe(true);
expect(w.find(".wp-modal__icon.pi-arrow-right-arrow-left").exists()).toBe(true);
});
it("renders 'derivation' chip + module name", () => {
@@ -28,6 +28,7 @@ import RuntimeSection from "./sections/RuntimeSection.vue";
import InstanceIdChip from "../InstanceIdChip.vue";
import { CONTEXT_POOLS_KEY, overlayContextPools } from "../../../../extension/context-pools";
import Button from "@/kit/Button.vue";
import Modal from "@/kit/Modal.vue";
/** Platform-correct save chord for the footer hint. */
@@ -138,6 +139,17 @@ const emit = defineEmits<{
"save-to-library": [];
"clear-all-overrides": [];
}>();
/** The kit Modal closes on Escape, the backdrop and its ×: that is Cancel. */
function onModalOpen(open: boolean): void {
if (!open) emit("cancel");
}
/** Ctrl/Cmd+Enter saves from anywhere in the window. */
function onModalKey(ev: KeyboardEvent): void {
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
emit("save");
}
}
const isLibraryTracked = computed(() => Boolean(props.module.payload_hash));
// See WildcardInstanceModal — PushToLibraryModal owns the update vs fork
@@ -159,20 +171,20 @@ function onSpaClick(): void {
</script>
<template>
<div class="dvm">
<header class="wp-dvm__head">
<i class="pi pi-arrow-right-arrow-left wp-dvm__head-icon" aria-hidden="true" />
<Modal :open="true" size="lg" flush accent="var(--wp-kind-derivation, var(--wp-accent))" icon="arrow-right-arrow-left" @update:open="onModalOpen" @keydown="onModalKey">
<template #header="{ titleId }">
<div class="wp-dvm__title-block">
<div class="wp-dvm__title-row">
<span class="wp-dvm__name" data-test="dvm-name">{{ module.meta?.name ?? module.type }}</span>
<span :id="titleId" class="wp-dvm__name" data-test="dvm-name">{{ module.meta?.name ?? module.type }}</span>
<span class="wp-dvm__chip" data-test="dvm-chip">derivation</span>
<InstanceIdChip :id="module.id" :uid="module._uid" />
</div>
<div class="wp-dvm__sub">Library entry · conditional rule-based variable rewrites</div>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="dvm-close" @click="emit('cancel')" />
</header>
</template>
<div class="dvm">
<slot name="banner" />
<IdentitySection :module="module" @update="onUpdate" />
<RulesSection
:module="module"
@@ -191,7 +203,9 @@ function onSpaClick(): void {
<div class="wp-dvm__dock">
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-dvm__foot">
</div>
</div>
<template #footer>
<a
v-if="isLibraryTracked"
class="wp-dvm__spa-link"
@@ -223,19 +237,12 @@ function onSpaClick(): void {
>Save to library</Button>
<Button size="sm" data-test="dvm-cancel" @click="emit('cancel')">Cancel</Button>
<Button size="sm" variant="primary" data-test="dvm-save" @click="emit('save')">Save</Button>
</footer>
</div>
</div>
</template>
</Modal>
</template>
<style scoped>
.dvm {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
width: 820px;
max-width: 100%;
max-height: 80vh;
display: flex;
flex-direction: column;
/* The MODAL does not scroll — only the RULES list inside does (it caps at
@@ -253,7 +260,8 @@ function onSpaClick(): void {
font-size: 12px;
color: var(--wp-text);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
/* The frame, the coloured header and the footer row are the kit Modal's;
* the title block's styles live in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-dvm__dock {
position: sticky;
@@ -262,14 +270,6 @@ function onSpaClick(): void {
background: var(--wp-bg2);
flex-shrink: 0;
}
.wp-dvm__foot {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--wp-bg3);
border-top: 1px solid var(--wp-border);
}
.wp-dvm__spa-link {
display: inline-flex;
align-items: center;
@@ -1,8 +1,11 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import { mount, config } from "@vue/test-utils";
import FixedValuesInstanceModal from "./FixedValuesInstanceModal.vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
// The window is a kit Modal, which teleports to <body>: render it in place.
config.global.stubs = { ...config.global.stubs, teleport: true };
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "fv012345",
@@ -7,6 +7,7 @@ import ValuesSection from "./sections/ValuesSection.vue";
import RuntimeSection from "./sections/RuntimeSection.vue";
import InstanceIdChip from "../InstanceIdChip.vue";
import Button from "@/kit/Button.vue";
import Modal from "@/kit/Modal.vue";
/** Platform-correct save chord for the footer hint. */
@@ -32,6 +33,17 @@ const emit = defineEmits<{
"save-to-library": [];
"clear-all-overrides": [];
}>();
/** The kit Modal closes on Escape, the backdrop and its ×: that is Cancel. */
function onModalOpen(open: boolean): void {
if (!open) emit("cancel");
}
/** Ctrl/Cmd+Enter saves from anywhere in the window. */
function onModalKey(ev: KeyboardEvent): void {
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
emit("save");
}
}
const isLibraryTracked = computed(() => Boolean(props.module.payload_hash));
// See WildcardInstanceModal — PushToLibraryModal owns the update vs fork
@@ -53,26 +65,28 @@ function onSpaClick(): void {
</script>
<template>
<div class="wp-fvm">
<header class="wp-fvm__head">
<i class="pi pi-tag wp-fvm__head-icon" aria-hidden="true" />
<Modal :open="true" size="lg" flush accent="var(--wp-kind-fixed)" icon="tag" @update:open="onModalOpen" @keydown="onModalKey">
<template #header="{ titleId }">
<div class="wp-fvm__title-block">
<div class="wp-fvm__title-row">
<span class="wp-fvm__name" data-test="fvm-name">{{ module.meta?.name ?? module.type }}</span>
<span :id="titleId" class="wp-fvm__name" data-test="fvm-name">{{ module.meta?.name ?? module.type }}</span>
<span class="wp-fvm__chip" data-test="fvm-chip">fixed</span>
<InstanceIdChip :id="module.id" :uid="module._uid" />
</div>
<div class="wp-fvm__sub">Library entry · static $var → value pairs</div>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="fvm-close" @click="emit('cancel')" />
</header>
</template>
<div class="wp-fvm">
<slot name="banner" />
<IdentitySection :module="module" @update="onUpdate" />
<ValuesSection :module="module" @update="onUpdate" />
<div class="wp-fvm__dock">
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-fvm__foot">
</div>
</div>
<template #footer>
<a
v-if="isLibraryTracked"
class="wp-fvm__spa-link"
@@ -105,27 +119,20 @@ function onSpaClick(): void {
>Save to library</Button>
<Button size="sm" data-test="fvm-cancel" @click="emit('cancel')">Cancel</Button>
<Button size="sm" variant="primary" data-test="fvm-save" @click="emit('save')">Save</Button>
</footer>
</div>
</div>
</template>
</Modal>
</template>
<style scoped>
.wp-fvm {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
width: 820px;
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);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
/* The frame, the coloured header and the footer row are the kit Modal's;
* the title block's styles live in src/components/context/editors/_modal-head.css
* (imported once by ContextWidget). */
.wp-fvm__dock {
position: sticky;
@@ -133,14 +140,6 @@ function onSpaClick(): void {
z-index: 2;
background: var(--wp-bg2);
}
.wp-fvm__foot {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--wp-bg3);
border-top: 1px solid var(--wp-border);
}
.wp-fvm__spa-link {
display: inline-flex;
align-items: center;
@@ -9,7 +9,7 @@ type Instance = NonNullable<ModuleEntry["instance"]>;
/** Distinct sub-category tags referenced by a parsed filter expression. */
function exprTags(ast: Ast | null): string[] {
if (!ast) return [];
if (!ast || "var" in ast) return [];
if ("tag" in ast) return [ast.tag];
if (ast.op === "not") return exprTags(ast.x);
return ast.kids.flatMap(exprTags);
@@ -1,8 +1,11 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import { mount, config } from "@vue/test-utils";
import WildcardInstanceModal from "./WildcardInstanceModal.vue";
import type { ModuleEntry } from "../../../../widgets/_shared";
// The window is a kit Modal, which teleports to <body>: render it in place.
config.global.stubs = { ...config.global.stubs, teleport: true };
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
return {
id: "ab12cd34",
@@ -9,6 +9,7 @@ import PoolSection from "./sections/PoolSection.vue";
import RuntimeSection from "./sections/RuntimeSection.vue";
import InstanceIdChip from "../InstanceIdChip.vue";
import Button from "@/kit/Button.vue";
import Modal from "@/kit/Modal.vue";
/** Platform-correct save chord for the footer hint. */
@@ -54,6 +55,17 @@ const emit = defineEmits<{
"save-to-library": [];
"clear-all-overrides": [];
}>();
/** The kit Modal closes on Escape, the backdrop and its ×: that is Cancel. */
function onModalOpen(open: boolean): void {
if (!open) emit("cancel");
}
/** Ctrl/Cmd+Enter saves from anywhere in the window. */
function onModalKey(ev: KeyboardEvent): void {
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
emit("save");
}
}
// Save-to-library is always available when the row has a payload —
// the unified PushToLibraryModal owns the explicit "Update existing"
@@ -89,12 +101,11 @@ function onSpaClick(): void {
</script>
<template>
<div class="wp-wcm">
<header class="wp-wcm__head">
<i class="pi pi-sparkles wp-wcm__head-icon" aria-hidden="true" />
<Modal :open="true" size="lg" flush accent="var(--wp-kind-wildcard)" icon="sparkles" @update:open="onModalOpen" @keydown="onModalKey">
<template #header="{ titleId }">
<div class="wp-wcm__title-block">
<div class="wp-wcm__title-row">
<span class="wp-wcm__name" data-test="wcm-name">{{ module.meta?.name ?? module.type }}</span>
<span :id="titleId" class="wp-wcm__name" data-test="wcm-name">{{ module.meta?.name ?? module.type }}</span>
<span class="wp-wcm__chip" data-test="wcm-chip">wildcard</span>
<InstanceIdChip :id="module.id" :uid="module._uid" />
</div>
@@ -104,9 +115,10 @@ function onSpaClick(): void {
: "Library entry · weighted options resolved per pick" }}
</div>
</div>
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="wcm-close" @click="emit('cancel')" />
</header>
</template>
<div class="wp-wcm">
<slot name="banner" />
<IdentitySection
:module="module"
:upstream-vars="upstreamVars"
@@ -123,7 +135,9 @@ function onSpaClick(): void {
<div class="wp-wcm__dock">
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
<footer class="wp-wcm__foot">
</div>
</div>
<template #footer>
<a
v-if="module.payload_hash"
class="wp-wcm__spa-link"
@@ -155,27 +169,20 @@ function onSpaClick(): void {
>Save to library</Button>
<Button size="sm" data-test="wcm-cancel" @click="emit('cancel')">Cancel</Button>
<Button size="sm" variant="primary" data-test="wcm-save" @click="emit('save')">Save</Button>
</footer>
</div>
</div>
</template>
</Modal>
</template>
<style scoped>
.wp-wcm {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
width: 820px;
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);
}
/* Head styling lives in src/components/context/editors/_modal-head.css
/* The frame, the coloured header and the footer row are the kit Modal's;
* the title block's styles live 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 {
@@ -184,14 +191,6 @@ function onSpaClick(): void {
z-index: 2;
background: var(--wp-bg2);
}
.wp-wcm__foot {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--wp-bg3);
border-top: 1px solid var(--wp-border);
}
.wp-wcm__spa-link {
display: inline-flex;
align-items: center;
@@ -1,6 +1,9 @@
import { mount as vtuMount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import PoolSection from "../sections/PoolSection.vue";
import Combobox from "@/kit/Combobox.vue";
import type { VarProducerLike } from "@/manager/components/RefChip.vue";
import type { Component } from "vue";
/**
* The tag-pill grid ships COLLAPSED (a well-tagged wildcard's 40-odd pills
@@ -216,6 +219,36 @@ describe("PoolSection collapse", () => {
});
});
describe("PoolSection with no tags", () => {
const untagged = {
payload: { options: [{ id: "a", value: "dawn", weight: 1 }, { id: "b", value: "night", weight: 1 }] },
instance: {},
} as unknown as ModuleEntry;
it("explains why the pool is empty instead of showing an empty strip", () => {
const w = mount(PoolSection, { props: { module: untagged } });
expect(w.get('[data-test="pool-empty"]').text()).toContain("No tags to filter by");
expect(w.find(".pool__groups").exists()).toBe(false);
});
it("does not show the placeholder while collapsed, nor when tags exist", () => {
expect(vtuMount(PoolSection, { props: { module: untagged } }).find('[data-test="pool-empty"]').exists()).toBe(false);
expect(mount(PoolSection, { props: { module } }).find('[data-test="pool-empty"]').exists()).toBe(false);
});
it("does not accent the collapse control when there is nothing to open", () => {
const w = vtuMount(PoolSection, { props: { module: untagged } });
expect(w.get('[data-test="pool-collapse"]').classes()).not.toContain("pool__collapse--nudge");
});
it("Advanced says a filter has nothing to match", async () => {
const w = mount(PoolSection, { props: { module: untagged } });
await w.get('[data-test="pool-advanced"]').trigger("click");
expect(w.find('[data-test="pool-empty-advanced"]').exists()).toBe(true);
expect(w.find('[data-test="pool-palette"]').exists()).toBe(false);
});
});
describe("PoolSection grouped quick pills", () => {
it("ticking family=feline + temp=warm builds 'feline and warm'", async () => {
const w = mount(PoolSection, { props: { module } });
@@ -438,3 +471,50 @@ describe("PoolSection option rows", () => {
expect(w.find('[data-test="pool-summary"]').text()).toContain("2 of 3");
});
});
describe("PoolSection filter by variable (schema v11)", () => {
const producers = new Map<string, VarProducerLike>([
["model_variant", { kind: "model", shadowed: 0 }],
["season", { kind: "wildcard", shadowed: 0 }],
]);
it("offers upstream variables and adds a picked one as its own AND clause", async () => {
const w = mount(PoolSection, { props: { module, varProducers: producers } });
await w.get('[data-test="cat-chip-feline"]').trigger("click");
const combo = w.getComponent(Combobox as unknown as Component);
expect(((combo.props() as { options: Array<{ label: string }> }).options).map((o) => o.label))
.toEqual(["$model_variant", "$season"]);
combo.vm.$emit("update:modelValue", ["model_variant"]);
await w.vm.$nextTick();
expect(lastPatch(w).instance?.category_filter).toBe("feline and $model_variant");
});
it("keeps the variables when a pill is toggled, and says what they do", async () => {
const withVar = {
...module,
instance: { category_filter: "warm and $model_variant" },
} as unknown as ModuleEntry;
const w = mount(PoolSection, { props: { module: withVar, varProducers: producers } });
expect(w.get('[data-test="pool-vars-hint"]').text()).toContain("$model_variant");
await w.get('[data-test="cat-chip-feline"]').trigger("click");
expect(lastPatch(w).instance?.category_filter).toBe("feline and warm and $model_variant");
});
it("counts a variable among the collapsed selections", () => {
const withVar = { ...module, instance: { category_filter: "$season" } } as unknown as ModuleEntry;
const w = vtuMount(PoolSection, { props: { module: withVar } });
expect(w.get('[data-test="pool-collapsed-count"]').text()).toContain("1 selected");
});
it("Advanced accepts a $term, counts what the tags narrow, and offers variable chips", async () => {
const w = mount(PoolSection, { props: { module, varProducers: producers } });
await w.get('[data-test="pool-advanced"]').trigger("click");
await w.get('[data-test="pool-ins-var-season"]').trigger("click");
expect(lastPatch(w).instance?.category_filter).toBe("$season");
const input = w.get('[data-test="expr-input"]');
await input.setValue("feline and $season");
await input.trigger("input");
expect(w.find('[data-test="expr-error"]').exists()).toBe(false);
expect(w.get('[data-test="expr-match-count"]').text()).toMatch(/1 of 2 match\s+before \$season/);
});
});

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