Compare commits

...
68 Commits
Author SHA1 Message Date
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
329 changed files with 18599 additions and 4367 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
+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.
+18 -12
View File
@@ -1,39 +1,45 @@
# 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.
- **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")),
+56 -24
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.\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. A toggle dropped under a label joins that group (a folded one opens), and one dropped above the first label leaves its group. A label moves with its toggles. 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,7 @@
},
{
"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": "Merge the Boolean nodes you have",
@@ -1855,7 +1875,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",
@@ -2073,7 +2093,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 +2105,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.\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 +2216,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 +2291,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 +2311,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 +2348,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 +2366,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,7 +2598,7 @@
"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",
@@ -2646,7 +2678,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 +2727,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 +2739,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
+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
},
+17 -15
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.17.0",
"pinia": "^2.2.6",
"primeicons": "^7.0.0",
"primeicons": "^8.0.2",
"vue": "^3.5.44",
"vue-router": "^4.4.5",
"zod": "^3.25.76"
},
"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",
"@tailwindcss/postcss": "^4.3.5",
"@typescript-eslint/eslint-plugin": "^8.72.1",
"@typescript-eslint/parser": "^8.72.1",
"@vitejs/plugin-vue": "^5.1.4",
"@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",
"playwright": "1.65.0",
"postcss": "^8.5.32",
"tailwindcss": "^4.3.5",
"typescript": "^5.6.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"
}
}
+674 -750
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

+11 -1
View File
@@ -374,7 +374,17 @@ 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.
const TOTAL_LIMIT = 670 * 1024; // 670 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";
@@ -542,3 +542,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);
});
});
+26 -3
View File
@@ -304,6 +304,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
// ---------------------------------------------------------------------------
@@ -537,14 +548,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 +664,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));
@@ -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 {
+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>
@@ -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. */
@@ -2702,3 +2702,51 @@ 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();
}
});
});
+19 -5
View File
@@ -15,6 +15,7 @@ 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, isHiddenInCollapsedBundle } from "./bundles/bundle-alerts";
import ModuleRow from "./ModuleRow.vue";
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
import {
@@ -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,6 +716,15 @@ 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,
@@ -884,7 +900,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 +918,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.
+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;
@@ -73,6 +73,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)"
@@ -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
@@ -353,6 +370,24 @@ const summary = computed(() => {
text-overflow: ellipsis;
white-space: nowrap;
}
/* 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
* tint — the one piece of information telling you how big a collapsed bundle
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { bundleAlerts, bundleDescendants, 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");
});
});
@@ -0,0 +1,98 @@
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 out: string[] = [];
const origin = (module as ModuleEntry & { bundle_origin?: string | null }).bundle_origin;
let cur: string | null | undefined = typeof origin === "string" ? origin : null;
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 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
@@ -40,6 +41,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;
+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/);
});
});
@@ -13,12 +13,15 @@ import {
matches,
readsAs,
validateExpression,
exprVariables,
type Ast,
} from "@/manager/parsing/subcatFilter";
import OptionRow from "./OptionRow.vue";
import Checkbox from "@/kit/Checkbox.vue";
import Banner from "@/kit/Banner.vue";
import Button from "@/kit/Button.vue";
import Chip from "@/kit/Chip.vue";
import Combobox from "@/kit/Combobox.vue";
import WpIconButton from "@/kit/WpIconButton.vue";
import { axisHueAt, UNGROUPED_HUE } from "../../../../shared/axis-color";
import { vToggleRow } from "@/kit/toggle-row";
@@ -100,7 +103,7 @@ function tagsInExpression(expr: string): Set<string> {
return seen;
}
const walk = (n: Ast | null): void => {
if (!n) return;
if (!n || "var" in n) return;
if ("tag" in n) { seen.add(n.tag); return; }
if (n.op === "not") { walk(n.x); return; }
n.kids.forEach(walk);
@@ -125,6 +128,33 @@ watch(filterExpr, (next) => {
if (!same) ticked.value = incoming;
});
/* ── Variables in the filter (schema v11) ───────────────────────────
* A `$name` term reads a variable at run time: options tagged with its
* value stay (plus options with no tag of that value's group). Each
* variable is its own AND clause next to the pills. */
const filterVars = ref<string[]>(exprVariables(filterExpr.value));
watch(filterExpr, (next) => {
const incoming = exprVariables(next);
if (incoming.join(" ") !== filterVars.value.join(" ")) filterVars.value = incoming;
});
/** Variables the combobox offers: what upstream writes, plus any the
* filter already reads. */
const varOptions = computed(() => {
const names = new Set<string>([...props.varProducers.keys(), ...filterVars.value]);
return [...names].sort().map((name) => {
const producer = props.varProducers.get(name);
const hint = producer ? [producer.kind, producer.nodeLabel].filter(Boolean).join(" · ") : undefined;
return { value: name, label: `$${name}`, hint };
});
});
function onVarsChange(next: string | string[] | null): void {
const list = Array.isArray(next) ? next : next ? [next] : [];
filterVars.value = list;
emit("update", patchInstance(props.module, "category_filter", buildExpression(ticked.value, list)));
}
function isTagTicked(tag: string): boolean {
return ticked.value.has(tag);
}
@@ -132,7 +162,7 @@ function isTagTicked(tag: string): boolean {
/** Recompute the filter string from a ticked-tag set, respecting group
* structure: OR within a group, AND across groups, parens around a
* multi-term group clause, empty selection → "". */
function buildExpression(ticked: Set<string>): string {
function buildExpression(ticked: Set<string>, vars: readonly string[] = filterVars.value): string {
const clauses: string[] = [];
for (const group of pillGroups.value) {
const picked = group.tags.filter((t) => ticked.has(t));
@@ -140,6 +170,7 @@ function buildExpression(ticked: Set<string>): string {
const clause = picked.join(" or ");
clauses.push(picked.length > 1 ? `(${clause})` : clause);
}
for (const v of vars) clauses.push(`$${v}`);
return clauses.join(" and ");
}
@@ -178,7 +209,7 @@ const advanced = ref(false);
* ticked, and the chevron is accented while shut so it still reads as
* something to press. */
const poolOpen = ref(props.defaultOpen);
const tickedCount = computed(() => ticked.value.size);
const tickedCount = computed(() => ticked.value.size + filterVars.value.length);
const draft = ref(filterExpr.value);
// Re-seed the draft when the upstream filter changes (e.g. pills edited it
@@ -197,7 +228,11 @@ const draftReadsAs = computed<string>(() => {
}
});
/** Live match count for the current valid draft — "N of M options match". */
/** Variables the draft reads: their part is only known at run time. */
const draftVars = computed<string[]>(() => (draftError.value ? [] : exprVariables(draft.value)));
/** Live match count for the current valid draft — "N of M options match"
* (what the tags narrow before the run; `$name` terms come on top). */
const draftMatchCount = computed<number>(() => {
if (draftError.value) return 0;
let ast: Ast | null;
@@ -360,7 +395,7 @@ const skewedTowards = computed(() => {
<div class="pool__head">
<WpIconButton
:icon="poolOpen ? 'pi-chevron-down' : 'pi-chevron-right'"
:class="{ 'pool__collapse--nudge': !poolOpen }"
:class="{ 'pool__collapse--nudge': !poolOpen && subCategories.length > 0 }"
:aria-expanded="poolOpen"
data-test="pool-collapse"
:label="poolOpen ? 'Collapse tag pool' : 'Expand tag pool'"
@@ -381,7 +416,23 @@ const skewedTowards = computed(() => {
</div>
<!-- Quick mode — grouped pills. OR within an axis, AND across axes. -->
<div v-if="!advanced && poolOpen" class="pool__groups">
<!-- No tags on any option: nothing to filter by. Say why the pool is
empty and where tags come from, instead of an empty strip. -->
<Banner
v-if="!advanced && poolOpen && pillGroups.length === 0"
tone="neutral"
size="sm"
icon="pi-tags"
title="No tags to filter by"
note
class="pool__empty"
data-test="pool-empty"
>
None of this wildcard's options have tags yet, so every enabled option can roll.
Add tags to the options in the library editor (Sub-Categories) and they show up
here as pills: tick some to only roll the options that carry them.
</Banner>
<div v-else-if="!advanced && poolOpen" class="pool__groups">
<div
v-for="group in pillGroups"
:key="group.axis"
@@ -408,6 +459,29 @@ const skewedTowards = computed(() => {
</Chip>
</div>
</div>
<div class="pool__group pool__vars" data-test="pool-vars">
<span class="pool__group-name">by variable</span>
<div class="pool__vars-body">
<Combobox
:model-value="filterVars"
:options="varOptions"
multiple
mono
size="sm"
placeholder="Add a variable…"
aria-label="Filter by variable"
empty-text="No variables upstream. Type one under Advanced."
data-test="pool-vars-input"
@update:model-value="onVarsChange"
/>
<p v-if="filterVars.length" class="pool__vars-hint" data-test="pool-vars-hint">
At run time keeps the options tagged with
<template v-for="(v, i) in filterVars" :key="v">{{ i ? " and " : "" }}<code>${{ v }}</code></template>'s
value. Options with no tag in that value's group stay; an unset variable or a value
that is no tag here filters nothing.
</p>
</div>
</div>
<div v-if="filterExpr" class="pool__effective" data-test="pool-effective">
<span class="pool__effective-label">filter</span>
<code>{{ filterExpr }}</code>
@@ -439,12 +513,17 @@ const skewedTowards = computed(() => {
<code>{{ draftReadsAs || "(no filter)" }}</code>
</span>
<span class="pool__expr-count" data-test="expr-match-count">
{{ draftMatchCount }} of {{ totalCount }} match
{{ draftMatchCount }} of {{ totalCount }} match<template v-if="draftVars.length">
before {{ draftVars.map((v) => `$${v}`).join(", ") }}</template>
</span>
</div>
<!-- Insert-at-cursor palette (#7): grouped sub-categories + operators
so the user sees what they can type, like the SPA ref-filter. -->
<div v-if="subCategories.length > 0" class="pool__palette" data-test="pool-palette">
<div v-if="subCategories.length === 0" class="pool__expr-hint" data-test="pool-empty-advanced">
This wildcard's options have no tags yet, so a filter has nothing to match. Add tags in the
library editor (Sub-Categories) first.
</div>
<div v-else class="pool__palette" data-test="pool-palette">
<div
v-for="group in pillGroups"
:key="group.axis"
@@ -461,6 +540,17 @@ const skewedTowards = computed(() => {
@click="insertToken(cat)"
>{{ cat }}</Chip>
</div>
<div v-if="varOptions.length" class="pool__palette-group" data-test="pool-palette-vars">
<span class="pool__palette-axis">variables</span>
<Chip
v-for="v in varOptions"
:key="v.value"
as="button"
class="pool__ins-chip pool__ins-chip--var"
:data-test="`pool-ins-var-${v.value}`"
@click="insertToken(v.label)"
>{{ v.label }}</Chip>
</div>
<div class="pool__palette-ops">
<Chip
v-for="op in OPERATORS"
@@ -654,6 +744,26 @@ const skewedTowards = computed(() => {
color: var(--wp-text);
}
.pool__exclude-null--off { opacity: 0.55; }
.pool__vars-body {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
max-width: 420px;
}
.pool__vars-hint {
margin: 0;
font: 11px/1.45 var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
.pool__vars-hint code { font: 11px var(--wp-font-mono); color: var(--wp-text); }
.pool__empty { margin-bottom: 12px; }
.pool__expr-hint {
font: 11px/1.45 var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
.pool__expr-hint { margin-top: 6px; }
.pool__groups {
display: flex;
flex-direction: column;
@@ -100,3 +100,84 @@ describe("DebugStepDetail derivation rules", () => {
expect(rows[1].text()).toContain("extra arms");
});
});
function mountStep(entries: unknown[], index = 0) {
const model = buildModel({ __wp_trace__: entries });
return {
model,
w: mount(DebugStepDetail, {
props: { step: model.steps[index], warnings: [], uuidToName: new Map(), uuidToKind: new Map(), canFocus: false },
}),
};
}
describe("DebugStepDetail per-kind details", () => {
it("lists a wildcard's options, picked first and the cut ones last with why", async () => {
const { w } = mountStep([{
id: "w0000001", _uid: "u1", type: "wildcard", status: "ok", name: "Mood",
writes: [{ variable: "mood", value: "calm" }],
detail: {
option_id: "calm", chance: 0.5, pool: 3, live: 2,
steps: { library: 4, filter: 3, toggles: 3, live: 2 },
options: [
{ id: "angry", value: "angry", base: 1, weight: null, cut: "filter" },
{ id: "tense", value: "tense", base: 1, weight: 0, cut: "constraint" },
{ id: "dreamy", value: "dreamy", base: 1, override: 3, weight: 3 },
{ id: "calm", value: "calm", base: 1, weight: 1 },
],
},
}]);
expect(w.find('[data-test="dbg-pool-chain"]').text()).toBe("4 options→3 after the filter→2 drawable");
// Folded while something could be drawn.
expect(w.find('[data-test="dbg-options"]').exists()).toBe(false);
await w.find('[data-test="dbg-options-head"]').trigger("click");
const rows = w.findAll('[data-test="dbg-option"]');
expect(rows.map((r) => r.find(".wp-dbg-opt__value").text())).toEqual(["calm", "dreamy", "angry", "tense"]);
expect(rows[0].classes()).toContain("is-picked");
expect(rows[1].text()).toContain("75%");
expect(rows[2].find('[data-test="dbg-option-cut"]').text()).toBe("filtered out");
expect(rows[3].find('[data-test="dbg-option-cut"]').text()).toBe("excluded by a constraint");
});
it("opens the options when nothing could be drawn", () => {
const { w } = mountStep([{
id: "w0000001", type: "wildcard", status: "ok", writes: [{ variable: "mood", value: "" }],
detail: { pool: 0, live: 0, steps: { library: 1, filter: 1, toggles: 0, live: 0 }, options: [{ id: "a", value: "a", base: 1, weight: null, cut: "toggle" }] },
}]);
expect(w.find('[data-test="dbg-options"]').exists()).toBe(true);
expect(w.find('[data-test="dbg-pool-chain"]').classes()).toContain("is-dead");
});
it("shows a combine's template and flags a $var nothing set", () => {
const { w } = mountStep([{
id: "c0000001", type: "combine", status: "ok", writes: [{ variable: "scene", value: "calm light" }],
detail: { template: "$mood light $weather", template_source: "library", reads: { mood: "calm", weather: null } },
}]);
expect(w.find('[data-test="dbg-template"]').text()).toContain("light");
const reads = w.find('[data-test="dbg-reads"]').text();
expect(reads).toContain("$mood");
expect(reads).toContain("calm");
expect(reads).toContain("not set");
});
it("lists the steps a constraint re-weighted", () => {
const { w } = mountStep([
{ id: "k0000001", _uid: "k1", type: "constraint", status: "ok", name: "Fits", detail: { uid: "k1", reach: { mode: "all" } } },
{
id: "w0000001", _uid: "u1", type: "wildcard", status: "ok", name: "Mood", writes: [{ variable: "mood", value: "calm" }],
detail: { constraints: [{ id: "k0000001", uid: "k1", name: "Fits", source: "s", source_value: "x" }] },
},
]);
expect(w.find('[data-test="dbg-constraint-targets"]').text()).toContain("2 · Mood");
});
it("says why a toggle is off and what upstream read", () => {
const { w } = mountStep([{
node: "WP_Toggles", node_id: "5", binding: "rain", type: "toggle", value: "false", source: "upstream",
detail: { switch: true, upstream_read: "no", needs: "Weather", held_off: true },
}]);
const facts = w.find('[data-test="dbg-node-facts"]').text();
expect(facts).toContain('upstream read "no"');
expect(facts).toContain("off: needs Weather, which is off");
});
});
+227 -3
View File
@@ -10,11 +10,12 @@
import { computed, ref } from "vue";
import RichTextPreview from "../../manager/components/RichTextPreview.vue";
import DebugCondition from "./DebugCondition.vue";
import { ruleActions, type RawRuleDetail, type TraceStep, type WarningRow } from "./debug-model";
import { ruleActions, type RawOptionRow, type RawRuleDetail, type TraceStep, type WarningRow } from "./debug-model";
import { isNegativeMode } from "../../extension/derivation-conditions";
import Button from "@/kit/Button.vue";
import Disclosure from "@/kit/Disclosure.vue";
import CopyButton from "@/kit/CopyButton.vue";
import Chip from "@/kit/Chip.vue";
const props = defineProps<{
step: TraceStep;
@@ -31,6 +32,68 @@ const emit = defineEmits<{
const d = computed(() => props.step.detail);
/** What a node row (not a module) did, as short facts. */
const nodeFacts = computed((): string[] => {
const s = props.step;
const det = s.detail;
if (s.kind === "loop") {
const out = [s.source || "Context Loop"];
if (det?.bypass) out.push("loop bypassed: one run");
if (typeof det?.seed === "number") out.push(`frame seed ${det.seed}`);
if (typeof det?.combo === "number") out.push(`sweep combination ${det.combo} of ${det.combos ?? "?"}`);
if (typeof det?.value_line === "number") out.push(`value line ${det.value_line} of ${det.values ?? "?"}`);
if (det?.strategy) out.push(`seeds: ${det.strategy}`);
if (typeof det?.skipped === "number") out.push(`${det.skipped} frame${det.skipped === 1 ? "" : "s"} skipped`);
const pins = Object.keys(det?.pins ?? {}).length;
if (pins) out.push(`sweep pins ${pins} module${pins === 1 ? "" : "s"}${det?.hold_others ? ", holds the rest" : ""}`);
return out;
}
if (s.kind === "model") {
const why: Record<string, string> = {
"from the model": "read from the wired model",
"file-name rule": "matched a variant rule on the file name",
pinned: "pinned on the node",
"wired in": "from the node's text input",
"loader file": "the checkpoint loader's file name",
};
const out = [why[s.source] ?? (s.source || "WP Model Info")];
if (det?.config_class) out.push(`model class ${det.config_class}`);
if (det?.rule) out.push(`rule "${det.rule}" → ${det.rule_variant ?? ""}`);
else if (typeof det?.rules_count === "number") {
out.push(det.rules_count ? `none of ${det.rules_count} rule${det.rules_count === 1 ? "" : "s"} matched` : "no variant rules");
}
if (det?.stem && s.bindings[0] === "model_variant") out.push(`file name ${det.stem}`);
if (det?.file) out.push(`file ${det.file}`);
return out;
}
if (s.kind === "toggle") {
const pick = s.source.startsWith("Pick one");
const out = [
pick ? "Pick one: the picked row's value"
: s.toggleHasValue ? `switch with a value: its text while on, empty while off (now ${s.toggleBool ? "on" : "off"})`
: "switch: true or false",
];
if (s.source.endsWith("set upstream")) out.push("set by an upstream variable");
if (!pick && typeof det?.switch === "boolean") out.push(`switch on the node: ${det.switch ? "on" : "off"}`);
if (det && "upstream_read" in det) {
out.push(det.upstream_read == null ? "upstream didn't set it" : `upstream read "${det.upstream_read}"`);
}
if (det?.needs) out.push(det.held_off ? `off: needs ${det.needs}, which is off` : `needs ${det.needs}`);
if (pick) {
out.push(det?.picked ? `picked ${det.picked}` : "nothing picked");
if (det?.forced_by) out.push(`upstream named "${det.forced_by}"`);
if (det?.upstream_on?.length) out.push(`upstream turned on ${det.upstream_on.join(", ")}`);
}
return out;
}
if (s.kind === "injector") {
const out = [s.isTemplate ? "template filled from the node's inputs" : `wired ${s.valueType || "value"}`];
if (det?.input) out.push(`from input ${det.input}`);
return out;
}
return [];
});
const chanceText = computed(() => {
const c = d.value?.chance;
if (typeof c !== "number") return "";
@@ -39,6 +102,48 @@ const chanceText = computed(() => {
return `${pct < 1 ? pct.toFixed(1) : Math.round(pct)}% odds`;
});
/** The pool after each step, leaving out steps that removed nothing. */
const poolChain = computed((): string[] => {
const st = d.value?.steps;
if (!st) return [];
const out = [`${st.library} option${st.library === 1 ? "" : "s"}`];
if (st.filter !== st.library) out.push(`${st.filter} after the filter`);
if (st.toggles !== st.filter) out.push(`${st.toggles} after toggles`);
out.push(`${st.live} drawable`);
return out;
});
const CUT_LABEL: Record<string, string> = {
filter: "filtered out",
toggle: "toggled off",
constraint: "excluded by a constraint",
weight: "weight 0",
};
const pickedIds = computed(() => new Set<string>([d.value?.option_id, ...(d.value?.option_ids ?? [])].filter((x): x is string => typeof x === "string")));
function isPicked(o: RawOptionRow): boolean {
return typeof o.id === "string" && pickedIds.value.has(o.id);
}
/** Picked first, then drawable, then the ones that are out. */
const optionRows = computed((): RawOptionRow[] => {
const rows = d.value?.options ?? [];
const rank = (o: RawOptionRow): number => (isPicked(o) ? 0 : o.cut ? 2 : 1);
return [...rows].sort((a, b) => rank(a) - rank(b));
});
const optionsOpen = ref<boolean | null>(null);
/** Open by default when nothing could be drawn: that's what went wrong. */
const optionsShown = computed(() => optionsOpen.value ?? (d.value?.steps?.live === 0));
function fmtWeight(w: number | null | undefined): string {
if (w == null) return "—";
return Number.isInteger(w) ? String(w) : w.toFixed(2).replace(/0+$/, "");
}
function chanceOf(o: RawOptionRow): string {
const total = (d.value?.options ?? []).reduce((n, r) => n + (r.cut ? 0 : r.weight ?? 0), 0);
if (o.cut || !o.weight || !total) return "";
const pct = (o.weight / total) * 100;
return `${pct < 1 ? pct.toFixed(1) : Math.round(pct)}%`;
}
const readRows = computed(() => Object.entries(d.value?.reads ?? {}));
function refName(uuid: string | undefined): string {
if (!uuid) return "";
return props.uuidToName.get(uuid) ?? uuid.slice(0, 8);
@@ -102,17 +207,64 @@ function modeGlyph(mode: string | undefined): string {
<div class="wp-dbg-detail__facts">
<span v-if="d.held" class="wp-dbg-fact">held from frame 0</span>
<span v-if="d.fallback" class="wp-dbg-fact wp-dbg-fact--accent" data-test="dbg-fallback">fallback used</span>
<span v-if="d.mode === 'pinned'" class="wp-dbg-fact wp-dbg-fact--accent"><i class="pi pi-thumbtack" /> pinned option</span>
<span v-if="d.mode === 'pinned' && d.swept" class="wp-dbg-fact wp-dbg-fact--accent" data-test="dbg-swept"><i class="pi pi-replay" /> swept: this frame's option</span>
<span v-else-if="d.mode === 'pinned'" class="wp-dbg-fact wp-dbg-fact--accent"><i class="pi pi-thumbtack" /> pinned option</span>
<span v-if="d.mode === 'match_variable'" class="wp-dbg-fact wp-dbg-fact--accent" data-test="dbg-match-var">matched <code>${{ d.match_variable }}</code><template v-if="d.match_value != null"> = <code>{{ d.match_value || '""' }}</code></template></span>
<span v-if="d.mode === 'match_fallback'" class="wp-dbg-fact wp-dbg-fact--accent" data-test="dbg-match-var">no option matched <code>${{ d.match_variable }}</code>, fallback used</span>
<span v-if="chanceText" class="wp-dbg-fact wp-dbg-fact--accent" data-test="dbg-chance">{{ chanceText }}</span>
<span v-if="typeof d.pool === 'number'" class="wp-dbg-fact">
{{ d.pool }} option{{ d.pool === 1 ? "" : "s" }}<template v-if="typeof d.live === 'number' && d.live !== d.pool">, {{ d.live }} drawable</template>
</span>
<span v-if="d.option_ids" class="wp-dbg-fact">
{{ d.option_ids.length }} pick{{ d.option_ids.length === 1 ? "" : "s" }}<template v-if="d.range"> (asked {{ d.range[0] === d.range[1] ? d.range[0] : `${d.range[0]}–${d.range[1]}` }})</template><template v-if="d.independent">, repeats allowed</template>
{{ d.option_ids.length }} pick{{ d.option_ids.length === 1 ? "" : "s" }}<template v-if="d.range"> (asked {{ d.range[0] === d.range[1] ? d.range[0] : `${d.range[0]}–${d.range[1]}` }})</template>{{ d.independent ? ", repeats allowed" : ", no repeats" }}
</span>
<span v-if="d.option_ids && d.separator != null && d.separator !== ', '" class="wp-dbg-fact" data-test="dbg-separator">joined by <code>"{{ d.separator }}"</code></span>
<span v-if="d.locked_seed" class="wp-dbg-fact"><i class="pi pi-lock" /> locked seed</span>
<span v-if="d.filter" class="wp-dbg-fact">filter <code>{{ d.filter }}</code></span>
<span v-for="(val, name) in d.filter_vars ?? {}" :key="`fv-${name}`" class="wp-dbg-fact" data-test="dbg-filter-var">
<code>${{ name }}</code> read <code>{{ val || '""' }}</code>
</span>
<span v-if="d.exclude_null" class="wp-dbg-fact">no empty option</span>
</div>
<div v-if="poolChain.length" class="wp-dbg-detail__chain" data-test="dbg-pool-chain" :class="{ 'is-dead': d.steps?.live === 0 }">
<template v-for="(p, i) in poolChain" :key="i"><span v-if="i" class="wp-dbg-dim" aria-hidden="true">→</span><span>{{ p }}</span></template>
</div>
<Disclosure
v-if="d.options?.length"
size="sm"
lazy
:open="optionsShown"
:trigger-attrs="{ 'data-test': 'dbg-options-head' }"
@update:open="(v: boolean) => (optionsOpen = v)"
>
<template #summary>
<span class="wp-dbg-detail__label">Options ({{ (d.options?.length ?? 0) + (d.options_more ?? 0) }})</span>
</template>
<ul class="wp-dbg-opts" data-test="dbg-options">
<li
v-for="o in optionRows"
:key="String(o.id)"
class="wp-dbg-opt"
:class="{ 'is-picked': isPicked(o), 'is-out': !!o.cut }"
data-test="dbg-option"
>
<i :class="['pi', isPicked(o) ? 'pi-check-circle' : o.cut ? 'pi-ban' : 'pi-circle']" class="wp-dbg-opt__mark" aria-hidden="true" />
<span class="wp-dbg-opt__value" :title="o.value">
<template v-if="o.null"><em class="wp-dbg-dim">empty option</em></template>
<template v-else>{{ o.value }}</template>
</span>
<Chip v-if="o.fallback" size="xs" tone="info">fallback</Chip>
<Chip v-if="o.cut" size="xs" :tone="o.cut === 'constraint' ? 'warn' : 'default'" data-test="dbg-option-cut">{{ CUT_LABEL[o.cut] }}</Chip>
<span class="wp-dbg-opt__weight" :title="o.override != null ? `library weight ${fmtWeight(o.base)}, this node sets ${fmtWeight(o.override)}` : 'weight'">
<template v-if="o.override != null"><s>{{ fmtWeight(o.base) }}</s> {{ fmtWeight(o.override) }}</template>
<template v-else>{{ fmtWeight(o.base) }}</template>
<template v-if="!o.cut && o.weight != null && o.weight !== (o.override ?? o.base)"> → {{ fmtWeight(o.weight) }}</template>
</span>
<span class="wp-dbg-opt__chance">{{ chanceOf(o) }}</span>
</li>
<li v-if="d.options_more" class="wp-dbg-dim">and {{ d.options_more }} more</li>
</ul>
</Disclosure>
<div v-if="step.appliedBy.length" class="wp-dbg-detail__block">
<div class="wp-dbg-detail__label">Re-weighted by</div>
<div v-for="(c, i) in step.appliedBy" :key="i" class="wp-dbg-detail__line" data-test="dbg-applied-constraint">
@@ -221,6 +373,44 @@ function modeGlyph(mode: string | undefined): string {
Run the graph again to see which branch fired.
</p>
<!-- Fixed values -->
<template v-if="step.kind === 'fixed' && d">
<div class="wp-dbg-detail__facts" data-test="dbg-fixed-facts">
<span class="wp-dbg-fact wp-dbg-fact--accent">{{ d.source === "override" ? "this node's own rows" : "the library's rows" }}</span>
<span v-if="d.held" class="wp-dbg-fact">held from frame 0</span>
<span v-if="d.locked_seed" class="wp-dbg-fact"><i class="pi pi-lock" /> locked seed</span>
<span v-if="d.toggled_off?.length" class="wp-dbg-fact">toggled off: {{ d.toggled_off.map((n) => `$${n}`).join(", ") }}</span>
</div>
<div v-if="d.rows?.some((r) => r.raw)" class="wp-dbg-detail__block">
<div class="wp-dbg-detail__label">Written as</div>
<div v-for="r in d.rows.filter((x) => x.raw)" :key="r.name" class="wp-dbg-detail__line">
<code>${{ r.name }}</code><span class="wp-dbg-dim">from</span>
<RichTextPreview :value="r.raw ?? ''" :uuid-to-name="uuidToName" :uuid-to-kind="uuidToKind" surface="fixed_values" />
</div>
</div>
</template>
<!-- Templates: combine, Injector rows, Toggles Pick one -->
<div v-if="d?.template || d?.raw" class="wp-dbg-detail__block" data-test="dbg-template">
<div class="wp-dbg-detail__label">
{{ d.template ? (d.template_source === "override" ? "Template (this node's)" : "Template") : "Value as written" }}
</div>
<RichTextPreview :value="d.template || d.raw || ''" :uuid-to-name="uuidToName" :uuid-to-kind="uuidToKind" surface="wildcard" />
</div>
<div v-if="readRows.length" class="wp-dbg-detail__block" data-test="dbg-reads">
<div class="wp-dbg-detail__label">Read</div>
<div v-for="[name, val] in readRows" :key="name" class="wp-dbg-detail__line">
<code>${{ name }}</code>
<template v-if="val === null"><Chip size="xs" tone="warn">not set</Chip></template>
<template v-else><span class="wp-dbg-dim">=</span><span class="wp-dbg-read">{{ val || '""' }}</span></template>
</div>
</div>
<!-- Node rows: Context Loop, Model Info, Toggles, Injector -->
<div v-if="nodeFacts.length" class="wp-dbg-detail__facts" data-test="dbg-node-facts">
<span v-for="(f, i) in nodeFacts" :key="i" class="wp-dbg-fact" :class="{ 'wp-dbg-fact--accent': i === 0 }">{{ f }}</span>
</div>
<!-- Constraint -->
<template v-if="step.constraint">
<div class="wp-dbg-detail__facts">
@@ -235,6 +425,20 @@ function modeGlyph(mode: string | undefined): string {
</span>
<span v-if="step.constraint.only" class="wp-dbg-fact wp-dbg-fact--accent" title="An Only rule turns its row into an allow-list">uses Only</span>
</div>
<div v-if="step.appliedTo.length" class="wp-dbg-detail__block" data-test="dbg-constraint-targets">
<div class="wp-dbg-detail__label">Re-weighted</div>
<div class="wp-dbg-detail__line">
<Button
v-for="t in step.appliedTo"
:key="t.key"
variant="link"
size="sm"
class="wp-dbg-link"
hint="Show this step in the trace"
@click="emit('goto-step', t.key)"
>{{ t.order }} · {{ t.name }}</Button>
</div>
</div>
</template>
<!-- Nested refs -->
@@ -346,6 +550,26 @@ function modeGlyph(mode: string | undefined): string {
.wp-dbg-kind-dot { width: 7px; height: 7px; border-radius: min(2px, var(--wp-radius-cap, 999px)); flex: none; background: var(--wp-kind-constraint); }
.wp-dbg-dim code { font-family: var(--wp-font-mono); color: var(--wp-accent-text, var(--wp-accent)); }
.wp-dbg-detail__note { margin: 0; font-style: italic; }
.wp-dbg-detail__chain { display: flex; flex-wrap: wrap; gap: 5px; font-size: 10.5px; color: var(--wp-text-muted); }
.wp-dbg-detail__chain.is-dead > span:last-child { color: var(--wp-warn); font-weight: 600; }
.wp-dbg-read { font-family: var(--wp-font-mono); color: var(--wp-text); overflow-wrap: anywhere; }
.wp-dbg-opts { list-style: none; margin: 2px 0 0; padding: 0; display: flex; flex-direction: column; }
.wp-dbg-opt {
display: flex; align-items: center; gap: 6px; min-width: 0;
padding: 2px 4px;
border-radius: min(4px, var(--wp-radius-cap, 999px));
font-size: 11px;
}
.wp-dbg-opt.is-picked { background: color-mix(in oklab, var(--wp-accent) 12%, transparent); }
.wp-dbg-opt.is-out .wp-dbg-opt__value { color: var(--wp-text-dim); text-decoration: line-through; text-decoration-color: color-mix(in oklab, var(--wp-text-dim) 60%, transparent); }
/* Kit chips at the row's text size. */
.wp-dbg-opt .wp-chip, .wp-dbg-detail__line .wp-chip { height: 16px; min-height: 0; box-sizing: border-box; padding: 0 6px; font: 600 9.5px/1 var(--wp-font-sans); flex: none; }
.wp-dbg-opt__mark { flex: none; font-size: 10px; color: var(--wp-text-dim); }
.wp-dbg-opt.is-picked .wp-dbg-opt__mark { color: var(--wp-accent-text, var(--wp-accent)); }
.wp-dbg-opt__value { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--wp-font-mono); color: var(--wp-text); }
.wp-dbg-opt__weight { flex: none; font: 500 10px/1 var(--wp-font-mono); color: var(--wp-text-muted); }
.wp-dbg-opt__weight s { color: var(--wp-text-dim); }
.wp-dbg-opt__chance { flex: none; width: 3.5em; text-align: right; font: 600 10px/1 var(--wp-font-mono); color: var(--wp-accent-text, var(--wp-accent)); }
.wp-dbg-rule {
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius, 4px);
+70 -16
View File
@@ -8,7 +8,18 @@ import WpIconButton from "@/kit/WpIconButton.vue";
import { nextTick, ref, watch } from "vue";
import RichTextPreview from "../../manager/components/RichTextPreview.vue";
import DebugStepDetail from "./DebugStepDetail.vue";
import type { NodeInfo, TraceGroup, TraceStep, WarningRow } from "./debug-model";
import { TEMPLATE_TYPE, valueTypeLook, type ValueTypeLook } from "../injector/value-types";
import {
KIND_ICONS,
NODE_KINDS,
NODE_KIND_TITLES,
kindColor,
type Kind,
type NodeInfo,
type TraceGroup,
type TraceStep,
type WarningRow,
} from "./debug-model";
const props = defineProps<{
groups: TraceGroup[];
@@ -27,7 +38,7 @@ const emit = defineEmits<{
(e: "toggle", key: string): void;
(e: "goto-step", key: string): void;
(e: "row-menu", ev: MouseEvent, step: TraceStep): void;
(e: "focus-node", nodeId: string): void;
(e: "focus-node", nodeId: string, step?: TraceStep): void;
}>();
// Open rows whose one-line value is cut off in the row. Their detail repeats
@@ -71,12 +82,31 @@ function refName(uuid: string): string {
function groupLabel(g: TraceGroup): string {
if (!g.nodeId) return "Chain";
const info = props.nodeInfo(g.nodeId);
return info.codename || info.title || `Node ${g.nodeId}`;
// The node's own title; a Context's codename sits beside it as a chip.
return info.title || NODE_KIND_TITLES[groupKind(g) ?? "unknown"] || (info.codename ? "WP Context" : `Node ${g.nodeId}`);
}
/** The node kind a group came from (Loop, Toggles …), or null for a
* Context node, whose rows are modules. */
function groupKind(g: TraceGroup): Kind | null {
const k = g.steps[0]?.kind;
return k && NODE_KINDS.has(k) ? k : null;
}
/** A node group's rows as card entries (each row is one variable). */
/** An Injector step's wire type, in the Injector's own icon and colour. */
function typeLook(s: TraceStep): ValueTypeLook {
return s.isTemplate ? TEMPLATE_TYPE : valueTypeLook(s.valueType);
}
function groupIcon(g: TraceGroup): string {
const k = groupKind(g);
return k ? KIND_ICONS[k] : "pi pi-sitemap";
}
function groupColor(g: TraceGroup): string {
const k = groupKind(g);
return k ? kindColor(k) : "var(--wp-accent-text, var(--wp-accent))";
}
function groupTooltip(g: TraceGroup): string {
if (!g.nodeId) return "";
const info = props.nodeInfo(g.nodeId);
return [info.codename ? info.title : "", `node id ${g.nodeId}`].filter(Boolean).join(" · ");
return `node id ${g.nodeId}`;
}
</script>
@@ -84,12 +114,14 @@ function groupTooltip(g: TraceGroup): string {
<div ref="root" class="wp-dbg-trace" data-test="dbg-trace">
<section v-for="(g, gi) in groups" :key="`${g.nodeId}:${gi}`" class="wp-dbg-group">
<header v-if="showGroupHeads" class="wp-dbg-group__head" data-test="dbg-group-head">
<i :class="['wp-dbg-group__icon', groupIcon(g)]" :style="{ color: groupColor(g) }" aria-hidden="true" />
<span class="wp-dbg-group__title" :title="groupTooltip(g)" data-test="dbg-group-title">{{ groupLabel(g) }}</span>
<span
class="wp-dbg-group__title"
:class="{ 'wp-dbg-group__title--codename': !!(g.nodeId && nodeInfo(g.nodeId).codename) }"
:title="groupTooltip(g)"
data-test="dbg-group-title"
>{{ groupLabel(g) }}</span>
v-if="g.nodeId && nodeInfo(g.nodeId).codename"
class="wp-dbg-group__title wp-dbg-group__title--codename"
title="The Context's codename"
data-test="dbg-group-codename"
>{{ nodeInfo(g.nodeId).codename }}</span>
<span v-if="g.seed" class="wp-dbg-group__seed" title="Chain seed this node ran with">seed {{ g.seed }}</span>
<WpIconButton
v-if="canFocus && g.nodeId"
@@ -111,7 +143,9 @@ function groupTooltip(g: TraceGroup): string {
'is-ctx': ctxActiveKey === s.key,
},
]"
:style="{ '--wp-dbg-kind': kindColor(s.kind) }"
:data-step-key="s.key"
:data-kind="s.kind"
data-test="dbg-step"
>
<div
@@ -126,7 +160,7 @@ function groupTooltip(g: TraceGroup): string {
@contextmenu="(ev) => emit('row-menu', ev, s)"
>
<span class="wp-dbg-step__order">{{ s.order }}</span>
<span class="wp-kind-chip wp-dbg-step__kind" :class="`wp-kind-chip--${s.kind === 'unknown' ? 'unknown' : s.kind}`">{{ s.kindLabel }}</span>
<span class="wp-kind-chip wp-dbg-step__kind" :class="`wp-kind-chip--${s.kind === 'unknown' ? 'unknown' : s.kind}`"><i :class="KIND_ICONS[s.kind]" aria-hidden="true" />{{ s.kindLabel }}</span>
<span class="wp-dbg-step__title" :title="title(s)">
<i v-if="pinned.has(s.key)" class="pi pi-star-fill wp-dbg-pin" aria-label="pinned" />
<span class="wp-dbg-step__title-text">{{ title(s) }}</span>
@@ -140,7 +174,7 @@ function groupTooltip(g: TraceGroup): string {
<code>${{ refName(s.constraint.target) }}</code>
</template>
<template v-else-if="s.writes.length">
<code>${{ s.writes[0].variable }}</code>
<code class="wp-dbg-step__var" :title="`$${s.writes[0].variable}`">${{ s.writes[0].variable }}</code>
<span class="wp-dbg-step__arrow">=</span>
<span class="wp-dbg-step__val">
<RichTextPreview
@@ -159,7 +193,13 @@ function groupTooltip(g: TraceGroup): string {
</template>
</span>
<span class="wp-dbg-step__badges">
<span v-if="s.valueType" class="wp-dbg-badge" :title="s.isTemplate ? 'Template rendered from $slot values' : 'Value type on the wire'">{{ s.isTemplate ? "TPL" : s.valueType }}</span>
<span v-if="s.source" class="wp-dbg-badge wp-dbg-badge--kind" data-test="dbg-step-source" title="Where this value came from">{{ s.source }}</span>
<span
v-if="s.valueType"
class="wp-dbg-badge wp-dbg-badge--type"
:style="{ '--wp-dbg-type': typeLook(s).color }"
:title="s.isTemplate ? 'Template rendered from $slot values' : 'Value type on the wire'"
><i :class="['pi', typeLook(s).icon]" aria-hidden="true" />{{ s.isTemplate ? "TPL" : s.valueType }}</span>
<span
v-if="typeof s.detail?.chance === 'number' && s.detail.chance < 0.995"
class="wp-dbg-badge"
@@ -216,7 +256,7 @@ function groupTooltip(g: TraceGroup): string {
:uuid-to-kind="uuidToKind"
:can-focus="canFocus"
@goto-step="(k) => emit('goto-step', k)"
@focus-node="(id) => emit('focus-node', id)"
@focus-node="(id) => emit('focus-node', id, s)"
/>
</div>
</div>
@@ -225,6 +265,12 @@ function groupTooltip(g: TraceGroup): string {
</template>
<style scoped>
.wp-dbg-badge.wp-dbg-badge--type {
display: inline-flex; align-items: center; gap: 3px;
color: var(--wp-dbg-type);
background: color-mix(in oklab, var(--wp-dbg-type) 16%, transparent);
}
.wp-dbg-badge--type .pi { font-size: 8px; }
.wp-dbg-trace { display: flex; flex-direction: column; gap: 8px; }
.wp-dbg-group { display: flex; flex-direction: column; }
.wp-dbg-group__head {
@@ -235,6 +281,7 @@ function groupTooltip(g: TraceGroup): string {
font-size: 10px;
color: var(--wp-text-dim);
}
.wp-dbg-group__icon { font-size: 10px; }
.wp-dbg-group__title { font: 600 11px/1.4 var(--wp-font-sans); color: var(--wp-text); }
.wp-dbg-group__seed { font-family: var(--wp-font-mono); }
.wp-dbg-group__title--codename {
@@ -254,7 +301,7 @@ function groupTooltip(g: TraceGroup): string {
.wp-dbg-step.is-ctx .wp-dbg-step__row { outline: 1px solid var(--wp-accent); outline-offset: -1px; }
.wp-dbg-step__row {
display: grid;
grid-template-columns: 18px 68px minmax(70px, 130px) minmax(0, 1fr) auto 12px;
grid-template-columns: 18px 82px minmax(70px, 130px) minmax(0, 1fr) auto 12px;
align-items: center;
column-gap: 8px;
padding: 5px 6px;
@@ -264,7 +311,11 @@ function groupTooltip(g: TraceGroup): string {
.wp-dbg-step__row:hover { background: var(--wp-row-hover, var(--wp-bg2)); }
.wp-dbg-step__row:focus-visible { outline: 1px solid var(--wp-accent); outline-offset: -1px; }
.wp-dbg-step__order { font: 500 9px/1 var(--wp-font-mono); color: var(--wp-text-dim); text-align: right; }
.wp-dbg-step__kind { font-size: 9px; justify-self: start; }
.wp-dbg-step__kind { font-size: 9px; justify-self: start; display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
.wp-dbg-step__kind .pi { font-size: 8px; }
/* Each row wears its kind's colour on the left edge, so rows from a Loop,
Toggles or Model Info node read apart from a Context's modules. */
.wp-dbg-step { box-shadow: inset 2px 0 0 color-mix(in oklab, var(--wp-dbg-kind, transparent) 70%, transparent); }
.wp-dbg-step__title {
font: 600 11px/1.3 var(--wp-font-sans);
color: var(--wp-text);
@@ -294,6 +345,8 @@ function groupTooltip(g: TraceGroup): string {
flex: none;
}
.wp-dbg-step__arrow { color: var(--wp-text-dim); flex: none; }
/* A long name keeps at most half the summary, so the value still shows. */
.wp-dbg-step__sum code.wp-dbg-step__var { flex: none; max-width: 50%; overflow: hidden; text-overflow: ellipsis; }
.wp-dbg-step__val { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The row is a one-line summary; the full value lives in the detail. */
.wp-dbg-step__val :deep(*) { white-space: nowrap; }
@@ -311,6 +364,7 @@ function groupTooltip(g: TraceGroup): string {
gap: 3px;
}
.wp-dbg-badge .pi { font-size: 8px; }
.wp-dbg-badge--kind { font-family: var(--wp-font-sans); color: var(--wp-dbg-kind); background: color-mix(in oklab, var(--wp-dbg-kind) 14%, transparent); }
.wp-dbg-badge--warn { background: color-mix(in oklab, var(--wp-warn) 18%, transparent); color: var(--wp-warn); }
.wp-dbg-status {
font: 600 9px/1 var(--wp-font-sans);
+477 -27
View File
@@ -6,9 +6,23 @@
* `$name.AXIS` reads they answer.
*/
import Button from "@/kit/Button.vue";
import { computed } from "vue";
import Chip from "@/kit/Chip.vue";
import CopyButton from "@/kit/CopyButton.vue";
import { computed, ref } from "vue";
import RichTextPreview from "../../manager/components/RichTextPreview.vue";
import type { VarRow } from "./debug-model";
import { TEMPLATE_TYPE, valueTypeLook, type ValueTypeLook } from "../injector/value-types";
import {
KIND_ICONS,
NODE_KINDS,
NODE_KIND_TITLES,
kindColor,
type Kind,
type NodeInfo,
type RawDetail,
type TraceStep,
type VarRow,
type WarningRow,
} from "./debug-model";
const props = defineProps<{
rows: VarRow[];
@@ -17,10 +31,241 @@ const props = defineProps<{
uuidToName: Map<string, string>;
uuidToKind: Map<string, string>;
ctxActiveKey: string | null;
/** Title + codename of a graph node (both "" when unknown). */
nodeInfo?: (nodeId: string) => NodeInfo;
stepsByKey?: Map<string, TraceStep>;
warningsByStep?: Map<string, WarningRow[]>;
/** The host can select a node on the canvas. */
canFocus?: boolean;
}>();
/** The variables grouped by the node that wrote them last: each Loop,
* Model Info, Toggles or Injector node gets a card drawn its own way, each
* Context a block of module rows, and values from outside the trace go
* last. One plain list when there is only one source. */
interface Section {
id: string;
kind: Kind | "context" | "upstream";
nodeId: string;
title: string;
/** A Context's codename, shown as a chip beside the title. */
codename: string;
rows: VarRow[];
firstKey: string;
}
const sections = computed((): Section[] => {
const byId = new Map<string, Section>();
const order: Section[] = [];
for (const v of props.rows) {
const nodeKind = v.writerKind && NODE_KINDS.has(v.writerKind) ? v.writerKind : null;
const id = !v.writerKey ? "upstream" : `${nodeKind ? "n" : "c"}:${v.writerNodeId}`;
let sec = byId.get(id);
if (!sec) {
const info = v.writerNodeId ? props.nodeInfo?.(v.writerNodeId) : undefined;
sec = {
id,
kind: !v.writerKey ? "upstream" : nodeKind ?? "context",
nodeId: v.writerNodeId,
title: !v.writerKey
? "From upstream"
: nodeKind
? info?.title || NODE_KIND_TITLES[nodeKind] || nodeKind
: info?.title || "WP Context",
codename: v.writerKey && !nodeKind ? info?.codename ?? "" : "",
rows: [],
firstKey: v.writerKey ?? "",
};
byId.set(id, sec);
order.push(sec);
}
sec.rows.push(v);
if (v.writerKey && Number(v.writerKey) < Number(sec.firstKey)) sec.firstKey = v.writerKey;
}
return order.sort((a, b) => {
if (a.kind === "upstream") return 1;
if (b.kind === "upstream") return -1;
return Number(a.firstKey) - Number(b.firstKey);
});
});
const grouped = computed(() => sections.value.length > 1 || sections.value.some((s) => s.kind !== "context" && s.kind !== "upstream"));
// ── Rows from other nodes ────────────────────────────────────────────────
// Every row keeps the same metrics and everything a Context row shows
// (multi-pick items, negatives, axes, tags, write count); a node's rows only
// add how that node works: a switch, a radio mark, a model value, a type.
/** Long values fold to a few lines (and long lists to their first items)
* until the row is opened, so one long story doesn't fill the panel. */
const LONG_VALUE = 160;
const LONG_NEGATIVE = 120;
const FOLDED_ITEMS = 3;
const openRows = ref(new Set<string>());
function isLong(v: VarRow): boolean {
if ((v.value ?? "").length > LONG_VALUE) return true;
if (v.items && (v.items.length > FOLDED_ITEMS || v.items.some((it) => it.length > LONG_VALUE))) return true;
return v.negatives.reduce((n, neg) => n + neg.text.length, 0) > LONG_NEGATIVE;
}
function isFolded(v: VarRow): boolean {
return isLong(v) && !openRows.value.has(v.name);
}
function shownItems(v: VarRow): string[] {
const items = v.items ?? [];
return isFolded(v) ? items.slice(0, FOLDED_ITEMS) : items;
}
function moreLabel(v: VarRow): string {
const hidden = (v.items?.length ?? 0) - FOLDED_ITEMS;
return isFolded(v) && hidden > 0 ? `Show all (${hidden} more)` : isFolded(v) ? "Show all" : "Show less";
}
function toggleOpen(name: string): void {
const next = new Set(openRows.value);
if (next.has(name)) next.delete(name);
else next.add(name);
openRows.value = next;
}
function stepOf(v: VarRow): TraceStep | null {
return v.writerKey ? props.stepsByKey?.get(v.writerKey) ?? null : null;
}
function nodeKindOf(v: VarRow): Kind | null {
return v.writerKind && NODE_KINDS.has(v.writerKind) ? v.writerKind : null;
}
function boolOf(v: string): boolean | null {
const t = v.trim().toLowerCase();
if (t === "true") return true;
if (t === "false") return false;
return null;
}
function isPick(v: VarRow): boolean {
return !!stepOf(v)?.source.startsWith("Pick one");
}
/** A toggle's on/off state (also each pick of a Pick one node); null for
* anything else. A toggle with a value text holds that text (empty while
* off) and its state stays readable as `$name.bool`. */
function switchOf(v: VarRow): boolean | null {
if (v.writerKind !== "toggle" || isPick(v) || v.items) return null;
const step = stepOf(v);
if (step) return step.toggleBool;
return boolOf(v.value);
}
function isSwitch(v: VarRow): boolean {
return switchOf(v) !== null;
}
function hasToggleValue(v: VarRow): boolean {
return !!stepOf(v)?.toggleHasValue;
}
/** Pick one mode: this node also wrote a Pick one variable. */
function inPickMode(sec: Section): boolean {
return sec.kind === "toggle" && sec.rows.some(isPick);
}
/** The source column for a node-written variable: how that node set it. */
function nodeNote(sec: Section, v: VarRow): string {
const step = stepOf(v);
switch (v.writerKind) {
case "toggle": {
const up = step?.source.endsWith("set upstream") ? ", set upstream" : "";
if (isPick(v)) return `pick one${up}`;
if (!inPickMode(sec)) return `switch${up}`;
return `${switchOf(v) ? "picked" : "not picked"}${up}`;
}
case "loop":
return v.name.endsWith("_value") ? "value line" : v.name.endsWith("_total") ? "frames" : "frame";
case "model":
return step?.source ?? "";
case "injector":
return step?.isTemplate ? "template" : "wired in";
default:
return "";
}
}
/** A Context module's one-line fact for the source column: how a wildcard
* picked (its odds, pinned, swept, matched) or which branch a derivation
* ran. Empty when there is nothing short to say. */
function moduleFact(v: VarRow): string {
const step = stepOf(v);
const d = step?.detail;
if (!step || !d) return "";
if (step.kind === "wildcard") {
if (d.swept) return "swept";
if (d.match_variable) return `matched $${d.match_variable}`;
if (d.mode === "pinned") return "pinned";
if (typeof d.chance === "number" && d.chance > 0 && d.chance < 1) return `${Math.round(d.chance * 100)}%`;
return "";
}
if (step.kind === "derivation") {
const fired = (d.rules ?? []).map((r) => r.fired).find((f) => f != null);
if (fired === "else") return "else";
if (typeof fired === "number") return `branch ${fired + 1}`;
}
return "";
}
/** The Injector's own icon + colour for this value's wire type. */
function injectorLook(v: VarRow): ValueTypeLook {
const step = stepOf(v);
if (step?.isTemplate) return TEMPLATE_TYPE;
return valueTypeLook(step?.valueType);
}
function injectorType(v: VarRow): string {
if (v.writerKind !== "injector") return "";
const step = stepOf(v);
if (!step) return "";
return step.isTemplate ? "TPL" : step.valueType;
}
/** The Loop's frame line: frame, seed, sweep combination, value line. */
function loopDetail(sec: Section): RawDetail | null {
if (sec.kind !== "loop") return null;
for (const v of sec.rows) {
const d = stepOf(v)?.detail;
if (d && typeof d.frame === "number") return d;
}
return null;
}
function frameParts(sec: Section): string[] {
const d = loopDetail(sec);
if (!d) return [];
const out = [d.of ? `frame ${d.frame} of ${d.of}` : `frame ${d.frame}`];
if (d.bypass) out.push("loop bypassed");
if (typeof d.combo === "number") out.push(`combination ${d.combo} / ${d.combos ?? "?"}`);
if (typeof d.value_line === "number") out.push(`line ${d.value_line} / ${d.values ?? "?"}`);
return out;
}
function loopSeed(sec: Section): string {
const s = loopDetail(sec)?.seed;
return typeof s === "number" ? String(s) : "";
}
function sectionWarnings(sec: Section): WarningRow[] {
if (sec.kind === "context" || sec.kind === "upstream") return [];
if (!props.warningsByStep || !props.stepsByKey) return [];
const out: WarningRow[] = [];
for (const [key, list] of props.warningsByStep) {
if (props.stepsByKey.get(key)?.nodeId === sec.nodeId) out.push(...list);
}
return out;
}
function sectionIcon(sec: Section): string {
if (sec.kind === "upstream") return "pi pi-arrow-down";
if (sec.kind === "context") return "pi pi-sitemap";
return KIND_ICONS[sec.kind];
}
function sectionColor(sec: Section): string {
if (sec.kind === "upstream") return "var(--wp-text-dim)";
if (sec.kind === "context") return "var(--wp-accent-text, var(--wp-accent))";
return kindColor(sec.kind);
}
/** What the source column calls a variable's writer: the module's name, or
* for a node-written variable (Toggles, Model Info, Loop, Injector) that
* node's title on the canvas. */
function writerLabel(v: VarRow): string {
if (v.writerName) return v.writerName;
const kind = v.writerKind;
if (kind && NODE_KINDS.has(kind)) {
const title = v.writerNodeId ? props.nodeInfo?.(v.writerNodeId).title ?? "" : "";
return title || NODE_KIND_TITLES[kind] || kind;
}
return kind ?? "";
}
const emit = defineEmits<{
(e: "goto-step", key: string): void;
(e: "row-menu", ev: MouseEvent, row: VarRow): void;
(e: "show-var", row: VarRow): void;
}>();
/** One name column for every row, sized to the longest name (capped;
@@ -32,11 +277,47 @@ const nameWidth = computed(() => {
</script>
<template>
<div class="wp-dbg-vars" data-test="dbg-vars" :style="{ '--wp-dbg-name-w': nameWidth }">
<div class="wp-dbg-vars" :class="{ 'is-grouped': grouped }" data-test="dbg-vars" :style="{ '--wp-dbg-name-w': nameWidth }">
<section
v-for="sec in sections"
:key="sec.id"
class="wp-dbg-vsec"
:data-kind="sec.kind"
:style="{ '--wp-dbg-kind': sectionColor(sec) }"
data-test="dbg-var-section"
>
<header v-if="grouped" class="wp-dbg-vsec__head" data-test="dbg-var-section-head">
<i :class="['wp-dbg-vsec__icon', sectionIcon(sec)]" aria-hidden="true" />
<span class="wp-dbg-vsec__title">{{ sec.title }}</span>
<span v-if="sec.codename" class="wp-dbg-vsec__codename" data-test="dbg-var-section-codename">{{ sec.codename }}</span>
<span class="wp-dbg-vsec__count">{{ sec.rows.length }}</span>
<Button
v-if="sec.kind !== 'upstream' && sec.firstKey"
variant="link"
size="sm"
class="wp-dbg-vsec__goto"
icon="pi-bolt"
hint="Show this node's steps in the trace"
@click="emit('goto-step', sec.firstKey)"
>trace</Button>
</header>
<div v-if="frameParts(sec).length" class="wp-dbg-vsec__frame" data-test="dbg-loop-frame">
<span v-for="(p, i) in frameParts(sec)" :key="i" :class="{ 'is-lead': i === 0 }">{{ p }}</span>
<CopyButton
v-if="loopSeed(sec)"
:text="loopSeed(sec)"
label="Copy this frame's seed"
size="sm"
variant="ghost"
class="wp-dbg-vsec__seed"
><span class="wp-dbg-vsec__seed-label">seed</span>{{ loopSeed(sec) }}</CopyButton>
</div>
<div
v-for="v in rows"
v-for="v in sec.rows"
:key="v.name"
class="wp-dbg-var-row"
:style="{ '--wp-dbg-kind': v.writerKind ? kindColor(v.writerKind) : 'transparent' }"
:data-kind="v.writerKind ?? 'upstream'"
:class="{
'is-internal': v.internal,
'wp-row-flash': flashed.has(v.name),
@@ -47,17 +328,33 @@ const nameWidth = computed(() => {
>
<div class="wp-dbg-var-row__name">
<i v-if="pinned.has(`var:${v.name}`)" class="pi pi-star-fill wp-dbg-pin" aria-label="pinned" />
<span class="wp-dbg-var-row__mark" aria-hidden="true" />
<code>${{ v.name }}</code>
<i v-if="v.internal" class="pi pi-eye-slash wp-dbg-flag" title="Internal: downstream modules can read it, the prompt never shows it" />
<span v-if="v.writeCount > 1" class="wp-dbg-var-row__count" :title="`Written ${v.writeCount} times; the last write wins`">×{{ v.writeCount }}</span>
</div>
<div class="wp-dbg-var-row__value">
<ol v-if="v.items" class="wp-dbg-var-row__items" data-test="dbg-var-items">
<li v-for="(it, i) in v.items" :key="i">
<div class="wp-dbg-var-row__value" :class="{ 'is-model': v.writerKind === 'model', 'is-folded': isFolded(v) }">
<Chip
v-if="isSwitch(v) && !hasToggleValue(v)"
class="wp-dbg-var-row__switch"
:class="switchOf(v) ? 'is-on' : 'is-off'"
:color="switchOf(v) ? kindColor('toggle') : null"
dot
:title="hasToggleValue(v) ? `$${v.name}.bool: the toggle is ${switchOf(v) ? 'on' : 'off'}` : `$${v.name}: the toggle is ${switchOf(v) ? 'on' : 'off'}`"
data-test="dbg-node-switch"
>{{ switchOf(v) ? "true" : "false" }}</Chip>
<template v-if="isSwitch(v) && !hasToggleValue(v)" />
<ol v-else-if="v.items" class="wp-dbg-var-row__items" data-test="dbg-var-items">
<li v-for="(it, i) in shownItems(v)" :key="i">
<span class="wp-dbg-var-row__idx" :title="`$${v.name}.${i}`">.{{ i }}</span>
<RichTextPreview :value="it" :uuid-to-name="uuidToName" :uuid-to-kind="uuidToKind" surface="wildcard" />
</li>
</ol>
<span v-else-if="v.value && v.writerKind === 'model'" class="wp-dbg-var-row__model">{{ v.value }}</span>
<span v-else-if="v.value && isPick(v)" class="wp-dbg-var-row__pick">
<i class="pi pi-circle-on wp-dbg-var-row__radio" title="The picked value" aria-hidden="true" />
<RichTextPreview :value="v.value" :uuid-to-name="uuidToName" :uuid-to-kind="uuidToKind" surface="wildcard" />
</span>
<RichTextPreview
v-else-if="v.value"
:value="v.value"
@@ -65,7 +362,7 @@ const nameWidth = computed(() => {
:uuid-to-kind="uuidToKind"
surface="wildcard"
/>
<span v-else class="wp-dbg-var-row__empty">empty</span>
<span v-else class="wp-dbg-var-row__empty">{{ v.writerKind === "model" ? "not detected" : isPick(v) ? "nothing picked" : isSwitch(v) ? "empty while off" : "empty" }}</span>
<div
v-for="(n, i) in v.negatives"
:key="`neg-${i}`"
@@ -77,12 +374,36 @@ const nameWidth = computed(() => {
<span class="wp-dbg-var-row__neg-text">{{ n.text }}</span>
<span v-if="n.source" class="wp-dbg-var-row__neg-src" data-test="dbg-var-neg-src">· {{ n.source }}</span>
</div>
<div v-if="v.axes.length || v.tags.length" class="wp-dbg-var-row__extra">
<span v-for="a in v.axes" :key="a.axis" class="wp-dbg-var-row__axis" data-test="dbg-var-axis" :title="`$${v.name}.${a.axis}`">
<code>.{{ a.axis }}</code> {{ a.value || "—" }}
</span>
<span v-for="t in v.tags" :key="t" class="wp-dbg-var-row__tag">{{ t }}</span>
<div v-if="v.axes.length || v.tags.length || hasToggleValue(v)" class="wp-dbg-var-row__extra">
<Chip
v-if="hasToggleValue(v)"
class="wp-dbg-var-row__axis"
:class="{ 'is-on': switchOf(v) }"
:color="switchOf(v) ? kindColor('toggle') : null"
dot
data-test="dbg-var-bool"
:title="`$${v.name}.bool: the toggle is ${switchOf(v) ? 'on' : 'off'}`"
><code>.bool</code> {{ switchOf(v) ? "true" : "false" }}</Chip>
<Chip
v-for="a in v.axes"
:key="a.axis"
class="wp-dbg-var-row__axis"
:color="kindColor(v.writerKind ?? 'wildcard')"
data-test="dbg-var-axis"
:title="`$${v.name}.${a.axis}`"
><code>.{{ a.axis }}</code> {{ a.value || "—" }}</Chip>
<Chip v-for="t in v.tags" :key="t" class="wp-dbg-var-row__tag" icon="pi-tag">{{ t }}</Chip>
</div>
<Button
v-if="isLong(v)"
variant="link"
size="sm"
class="wp-dbg-var-row__more"
:icon="isFolded(v) ? 'pi-chevron-down' : 'pi-chevron-up'"
:aria-expanded="!isFolded(v)"
data-test="dbg-var-more"
@click="toggleOpen(v.name)"
>{{ moreLabel(v) }}</Button>
</div>
<Button
v-if="v.writerKey"
@@ -93,30 +414,98 @@ const nameWidth = computed(() => {
data-test="dbg-var-src"
@click="emit('goto-step', v.writerKey)"
>
<span class="wp-dbg-kind-dot" :style="{ background: `var(--wp-kind-${v.writerKind === 'fixed' ? 'fixed' : v.writerKind})` }" />
<span class="wp-dbg-var-row__src-name">{{ v.writerName || v.writerKind }}</span>
<i :class="['wp-dbg-var-row__src-icon', KIND_ICONS[v.writerKind ?? 'unknown']]" aria-hidden="true" />
<Chip
v-if="injectorType(v)"
:color="injectorLook(v).color"
:icon="injectorLook(v).icon"
class="wp-dbg-var-row__type"
data-test="dbg-node-type"
>{{ injectorType(v) }}</Chip>
<span class="wp-dbg-var-row__src-name" :title="writerLabel(v)" :data-test="nodeKindOf(v) ? 'dbg-node-source' : undefined">{{ nodeKindOf(v) ? nodeNote(sec, v) || writerLabel(v) : writerLabel(v) }}</span>
<span v-if="!nodeKindOf(v) && moduleFact(v)" class="wp-dbg-var-row__fact" data-test="dbg-var-fact">{{ moduleFact(v) }}</span>
</Button>
<span v-else class="wp-dbg-var-row__src wp-dbg-var-row__src--upstream" title="Came in from upstream, no module in this trace wrote it">upstream</span>
</div>
<div
v-for="w in sectionWarnings(sec)"
:key="`w-${w.key}`"
class="wp-dbg-vsec__warn"
:class="`is-${w.severity}`"
data-test="dbg-node-warning"
>
<i class="pi pi-exclamation-triangle" aria-hidden="true" />
<span class="wp-dbg-vsec__warn-label">{{ w.label }}</span>
<span class="wp-dbg-vsec__warn-msg">{{ w.message }}</span>
</div>
</section>
</div>
</template>
<style scoped>
.wp-dbg-vars { display: flex; flex-direction: column; }
.wp-dbg-vars.is-grouped { gap: 10px; }
.wp-dbg-vsec { display: flex; flex-direction: column; }
/* Grouped: each node is its own card, tinted by its kind. */
.wp-dbg-vars.is-grouped .wp-dbg-vsec {
border: 1px solid color-mix(in oklab, var(--wp-dbg-kind) 26%, var(--wp-border));
border-radius: min(7px, var(--wp-radius-cap, 999px));
background: color-mix(in oklab, var(--wp-dbg-kind) 3%, transparent);
overflow: hidden;
}
.wp-dbg-vsec__head {
display: flex; align-items: center; gap: 7px;
padding: 5px 8px;
font-size: 10px; color: var(--wp-text-dim);
background: color-mix(in oklab, var(--wp-dbg-kind) 10%, transparent);
border-bottom: 1px solid color-mix(in oklab, var(--wp-dbg-kind) 20%, var(--wp-border));
}
.wp-dbg-vsec__icon {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; flex: none;
border-radius: min(4px, var(--wp-radius-cap, 999px));
font-size: 10px; color: var(--wp-dbg-kind);
background: color-mix(in oklab, var(--wp-dbg-kind) 18%, transparent);
}
.wp-dbg-vsec__title { font: 600 11.5px/1.4 var(--wp-font-sans); color: var(--wp-text); }
.wp-dbg-vsec__codename {
display: inline-flex; align-items: center;
height: 17px; box-sizing: border-box;
font: 600 10px/1 var(--wp-font-mono);
letter-spacing: 0.03em;
color: var(--wp-text);
padding: 0 7px;
border-radius: min(5px, var(--wp-radius-cap, 999px));
border: 1px solid var(--wp-border-strong, var(--wp-border));
background: var(--wp-bg, transparent);
}
.wp-dbg-vsec__count { font: 500 9.5px/1 var(--wp-font-mono); }
.wp-dbg-vsec__goto.wp-btn { margin-left: auto; height: auto; min-height: 0; padding: 0; font-size: 10px; }
.wp-dbg-var-row {
display: grid;
grid-template-columns: max(90px, var(--wp-dbg-name-w, 90px)) minmax(0, 1fr) auto;
column-gap: 12px;
align-items: start;
padding: 5px 6px;
padding: 6px 8px;
border-top: 1px solid var(--wp-border-soft, var(--wp-border));
font-size: 11.5px;
}
.wp-dbg-var-row:first-child { border-top: 0; }
.wp-dbg-vsec__head + .wp-dbg-var-row, .wp-dbg-vsec__frame + .wp-dbg-var-row, .wp-dbg-var-row:first-child { border-top: 0; }
/* The writer's kind as a small square before the name (none upstream). */
.wp-dbg-var-row__mark {
display: inline-block; vertical-align: 1px; margin-right: 6px;
width: 6px; height: 6px;
border-radius: min(2px, var(--wp-radius-cap, 999px));
background: var(--wp-dbg-kind, var(--wp-border-strong));
}
.wp-dbg-var-row[data-kind="upstream"] .wp-dbg-var-row__mark { background: transparent; box-shadow: inset 0 0 0 1px var(--wp-text-dim); }
.wp-dbg-var-row:hover { background: var(--wp-row-hover, var(--wp-bg2)); }
.wp-dbg-var-row.is-ctx { outline: 1px solid var(--wp-accent); outline-offset: -1px; }
.wp-dbg-var-row__name { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; }
.wp-dbg-var-row__name code { overflow-wrap: anywhere; }
/* Inline flow, not flex: a long name breaks inside itself and the square
stays on its first line. */
.wp-dbg-var-row__name { display: block; min-width: 0; line-height: 1.5; }
.wp-dbg-var-row__name > .wp-dbg-flag, .wp-dbg-var-row__name > .wp-dbg-var-row__count, .wp-dbg-var-row__name > .wp-dbg-pin { margin: 0 4px; }
.wp-dbg-var-row__name code { word-break: break-all; }
.wp-dbg-var-row__name code { font: 600 11.5px/1.5 var(--wp-font-mono); color: var(--wp-accent-text, var(--wp-accent)); }
.wp-dbg-var-row.is-internal .wp-dbg-var-row__name code { color: var(--wp-text-muted); }
.wp-dbg-flag { font-size: 9px; color: var(--wp-text-dim); }
@@ -131,15 +520,32 @@ const nameWidth = computed(() => {
.wp-dbg-var-row__neg-mark, .wp-dbg-var-row__neg-text { color: var(--wp-red); }
.wp-dbg-var-row__neg-text { min-width: 0; overflow-wrap: anywhere; }
.wp-dbg-var-row__neg-src { flex: none; color: var(--wp-text-dim); font-family: var(--wp-font-sans); }
/* Folded: the value shows three lines, each negative one, until opened. */
.wp-dbg-var-row__value.is-folded > .wp-rtp,
.wp-dbg-var-row__value.is-folded .wp-dbg-var-row__pick > .wp-rtp,
.wp-dbg-var-row__value.is-folded .wp-dbg-var-row__items li > .wp-rtp {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden;
}
.wp-dbg-var-row__value.is-folded .wp-dbg-var-row__neg-text {
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; line-clamp: 1; overflow: hidden;
}
.wp-btn.wp-dbg-var-row__more { align-self: flex-start; height: auto; min-height: 0; padding: 0; font-size: 10.5px; }
.wp-dbg-var-row__extra { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
.wp-dbg-var-row__axis, .wp-dbg-var-row__tag {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--wp-radius-cap, 999px);
background: var(--wp-bg-2, var(--wp-bg2));
color: var(--wp-text-muted);
/* Kit chips, sized like the switch chip; the accessor reads in mono. */
.wp-chip.wp-dbg-var-row__axis, .wp-chip.wp-dbg-var-row__tag {
height: 17px; min-height: 0; box-sizing: border-box;
padding: 0 6px; gap: 4px;
font: 500 10px/1 var(--wp-font-sans);
}
.wp-dbg-var-row__axis code { font: 600 10px/1 var(--wp-font-mono); }
.wp-dbg-var-row__fact {
flex: none;
font: 600 9.5px/1 var(--wp-font-mono);
padding: 2px 5px;
border-radius: min(3px, var(--wp-radius-cap, 999px));
color: var(--wp-dbg-kind);
background: color-mix(in oklab, var(--wp-dbg-kind) 14%, transparent);
}
.wp-dbg-var-row__axis code { font-family: var(--wp-font-mono); color: var(--wp-kind-wildcard); }
/* The plain span (upstream) and the kit link Button share this look. */
.wp-dbg-var-row__src, .wp-dbg-var-row__src.wp-btn {
display: inline-flex; align-items: center; gap: 5px; height: auto; min-height: 0; padding: 1px 0;
@@ -148,8 +554,52 @@ const nameWidth = computed(() => {
}
.wp-dbg-var-row__src.wp-btn:hover .wp-dbg-var-row__src-name { color: var(--wp-text); text-decoration: underline; }
.wp-dbg-var-row__src-name { overflow: hidden; text-overflow: ellipsis; }
/* Rows from other nodes: the same row, with how that node works. */
.wp-dbg-var-row__value.is-model { font: 600 11.5px/1.5 var(--wp-font-mono); color: var(--wp-dbg-kind); }
.wp-dbg-var-row__model { overflow-wrap: anywhere; }
.wp-dbg-var-row__pick { display: inline-flex; align-items: baseline; gap: 5px; min-width: 0; }
.wp-dbg-var-row__radio { font-size: 9px; color: var(--wp-dbg-kind); }
/* Kit chips sized to the row's text so they don't push it around. */
.wp-chip.wp-dbg-var-row__switch, .wp-chip.wp-dbg-var-row__type {
height: 17px; min-height: 0; box-sizing: border-box;
padding: 0 6px; gap: 4px;
font: 600 10px/1 var(--wp-font-mono);
vertical-align: middle;
}
.wp-dbg-vsec__frame {
display: flex; align-items: center; flex-wrap: wrap; gap: 4px 10px;
padding: 4px 8px;
font: 500 10.5px/1.6 var(--wp-font-sans);
color: var(--wp-text-dim);
border-bottom: 1px solid var(--wp-border-soft, var(--wp-border));
}
.wp-dbg-vsec__frame .is-lead { color: var(--wp-dbg-kind); font-weight: 600; }
.wp-copy-btn.wp-dbg-vsec__seed {
margin-left: auto;
display: inline-flex; align-items: center; gap: 4px;
height: auto; min-height: 0;
background: transparent; border: 0; padding: 0;
color: var(--wp-text-muted);
font: 500 10.5px/1.6 var(--wp-font-mono);
cursor: pointer;
}
.wp-copy-btn.wp-dbg-vsec__seed:hover { color: var(--wp-text); }
.wp-dbg-vsec__seed-label { color: var(--wp-text-dim); font-family: var(--wp-font-sans); }
.wp-dbg-vsec__warn {
display: flex; align-items: baseline; flex-wrap: wrap; gap: 5px;
padding: 4px 8px;
font-size: 10.5px; color: var(--wp-warn);
border-top: 1px solid var(--wp-border-soft, var(--wp-border));
background: color-mix(in oklab, var(--wp-warn) 8%, transparent);
}
.wp-dbg-vsec__warn.is-error { color: var(--wp-red); background: color-mix(in oklab, var(--wp-red) 8%, transparent); }
.wp-dbg-vsec__warn.is-info { color: var(--wp-text-muted); background: transparent; }
.wp-dbg-vsec__warn .pi { font-size: 9px; }
.wp-dbg-vsec__warn-label { font-weight: 600; }
.wp-dbg-vsec__warn-msg { color: var(--wp-text-muted); }
.wp-dbg-var-row__src--upstream { cursor: default; font-style: italic; }
.wp-dbg-kind-dot { width: 7px; height: 7px; border-radius: min(2px, var(--wp-radius-cap, 999px)); flex: none; }
.wp-dbg-var-row__src-icon { font-size: 9px; flex: none; color: var(--wp-dbg-kind); }
.wp-dbg-var-row__src.wp-btn :deep(.wp-btn__label) { display: inline-flex; align-items: center; gap: 5px; min-width: 0; }
.wp-dbg-var-row__src.wp-btn :deep(.wp-btn__label) { line-height: inherit; }
</style>
+11 -4
View File
@@ -129,13 +129,14 @@ describe("DebugViewer", () => {
expect(step.find('[data-test="dbg-rule"]').exists()).toBe(true);
});
it("groups the trace by Context node, named by its codename", async () => {
it("groups the trace by Context node, with its title and codename", async () => {
const nodeInfo = vi.fn((id: string) => ({ title: id === "1" ? "Scene" : "Details", codename: `code-${id}` }));
const w = mountRun({ nodeInfo });
await openTab(w, "trace");
const heads = w.findAll('[data-test="dbg-group-head"]');
expect(heads.map((h) => h.text().replace(/\s+/g, ""))).toEqual(["code-1seed3", "code-2seed7"]);
expect(heads[0].get('[data-test="dbg-group-title"]').attributes("title")).toBe("Scene · node id 1");
expect(heads.map((h) => h.text().replace(/\s+/g, ""))).toEqual(["Scenecode-1seed3", "Detailscode-2seed7"]);
expect(heads[0].get('[data-test="dbg-group-codename"]').text()).toBe("code-1");
expect(heads[0].get('[data-test="dbg-group-title"]').attributes("title")).toBe("node id 1");
});
it("names a node without a codename by its title, and a missing one by its id", async () => {
@@ -223,7 +224,8 @@ describe("DebugViewer", () => {
await openTab(w, "trace");
await w.find('[data-step-key="0"] .wp-dbg-step__row').trigger("click");
await w.find('[data-test="dbg-focus-node"]').trigger("click");
expect(focusNode).toHaveBeenCalledWith("1");
// With the step, so the canvas can blink that module.
expect(focusNode).toHaveBeenCalledWith("1", { uid: "tim00001u", vars: ["time"] });
const plain = mountRun();
await openTab(plain, "trace");
await plain.find('[data-step-key="0"] .wp-dbg-step__row').trigger("click");
@@ -305,6 +307,11 @@ describe("DebugViewer", () => {
expect(w.emitted("update:iterationIndex")?.[0]).toEqual([2]);
});
it("shows the Loop's own frame number when it differs from the item count", () => {
const w = mountRun({ iterationCount: 2, iterationIndex: 1 }, { __wp_loop_index__: 4, __wp_trace__: [] });
expect(w.find('[data-test="dbg-iter-label"]').text()).toBe("frame 5 · 2 / 2");
});
it("shows the raw snapshot JSON", async () => {
const w = mountRun();
await openTab(w, "raw");
+87 -23
View File
@@ -11,7 +11,7 @@
import SearchInput from "@/kit/SearchInput.vue";
import { computed, nextTick, ref, watch } from "vue";
import { highlightJson } from "./highlight";
import ContextMenu, { type ContextMenuItem } from "@/kit/ContextMenu.vue";
import ContextMenu, { type ContextMenuHeader, type ContextMenuItem } from "@/kit/ContextMenu.vue";
import WpTabs, { type TabItem } from "@/kit/WpTabs.vue";
import Button from "@/kit/Button.vue";
import CopyButton from "@/kit/CopyButton.vue";
@@ -21,6 +21,9 @@ import DebugTrace from "./DebugTrace.vue";
import DebugWarnings from "./DebugWarnings.vue";
import {
buildModel,
KIND_ICONS,
kindColor,
NODE_KINDS,
parseSnapshot,
stepSearchText,
unresolvedUuids,
@@ -42,7 +45,7 @@ const props = withDefaults(
/** Title + codename of the graph node with this id ("" when unknown). */
nodeInfo?: (nodeId: string) => NodeInfo;
/** Select + centre a graph node. Omitted in tests / off-canvas. */
focusNode?: ((nodeId: string) => void) | null;
focusNode?: ((nodeId: string, target?: { uid?: string; vars?: string[] }) => void) | null;
}>(),
{
nodeMode: 0,
@@ -66,6 +69,22 @@ const isSkipped = computed(() => props.nodeMode === 2 || props.nodeMode === 4);
const raw = computed(() => parseSnapshot(props.snapshot));
const model = computed(() => (raw.value ? buildModel(raw.value) : null));
/** The picker counts the items Debug received; a Loop that skipped frames
* (or an Image Filter that kept some) numbers them differently, so show
* the Loop's own frame number when it isn't the same. */
const frameLabel = computed(() => {
const pos = `${props.iterationIndex + 1} / ${props.iterationCount}`;
const li = model.value?.loopIndex;
if (typeof li === "number" && li !== props.iterationIndex) return `frame ${li + 1} · ${pos}`;
return `frame ${pos}`;
});
const frameTitle = computed(() => {
const li = model.value?.loopIndex;
return typeof li === "number" && li !== props.iterationIndex
? `Loop frame ${li + 1}; item ${props.iterationIndex + 1} of the ${props.iterationCount} this Debug received`
: "";
});
function gotoIteration(next: number): void {
if (next < 0 || next >= props.iterationCount || next === props.iterationIndex) return;
emit("update:iterationIndex", next);
@@ -194,8 +213,22 @@ async function gotoStep(key: string): Promise<void> {
el?.scrollIntoView?.({ block: "nearest" });
}
const canFocus = computed(() => typeof props.focusNode === "function");
function focusNode(id: string): void {
props.focusNode?.(id);
/** Select the node; with a step, also blink the row that step came from
* (a Context module by uid, any other node's field by the variable). */
function focusNode(id: string, s?: TraceStep): void {
if (!s) { props.focusNode?.(id); return; }
const uid = s.uid || s.detail?.uid;
props.focusNode?.(id, {
...(typeof uid === "string" && uid ? { uid } : {}),
vars: [...s.bindings],
});
}
/** Select the node that wrote `$name` and blink the field that wrote it. */
function showVar(v: VarRow): void {
if (!v.writerNodeId) return;
const step = v.writerKey ? stepsByKey.value.get(v.writerKey) : undefined;
const uid = step && !NODE_KINDS.has(step.kind) ? step.uid : "";
props.focusNode?.(v.writerNodeId, { ...(uid ? { uid } : {}), vars: [v.name] });
}
// Keep expansions only while the steps they name still exist.
@@ -279,7 +312,7 @@ interface MenuState {
x: number;
y: number;
items: ContextMenuItem[];
header?: { icon: string; label: string };
header?: ContextMenuHeader;
}
const menu = ref<MenuState>({ visible: false, x: 0, y: 0, items: [] });
const ctxActiveKey = ref<string | null>(null);
@@ -306,40 +339,66 @@ function pinItem(key: string): ContextMenuItem {
icon: on ? "pi-star-fill" : "pi-star",
subtitle: on ? "Let the filter hide it again" : "Keep it visible while filtering",
onSelect: () => togglePin(key),
divider: true,
};
}
/** Groups of menu items, a divider between non-empty groups (never under
* the header, which draws its own line). */
function grouped(...groups: ContextMenuItem[][]): ContextMenuItem[] {
const out: ContextMenuItem[] = [];
for (const g of groups) {
if (!g.length) continue;
g.forEach((item, i) => out.push(i === 0 && out.length ? { ...item, divider: true } : { ...item, divider: false }));
}
return out;
}
function openVarMenu(ev: MouseEvent, v: VarRow): void {
const key = `var:${v.name}`;
const items: ContextMenuItem[] = [
pinItem(key),
const go: ContextMenuItem[] = [];
if (v.writerKey) {
const k = v.writerKey;
go.push({ label: "Show in trace", icon: "pi-bolt", onSelect: () => { void gotoStep(k); } });
}
if (canFocus.value && v.writerNodeId) {
go.push({ label: "Show on node", icon: "pi-arrow-up-right", onSelect: () => showVar(v) });
}
const copy: ContextMenuItem[] = [
{ label: "Copy value", icon: "pi-clone", disabled: !v.value, onSelect: () => { void clipboardWrite(v.value); } },
{ label: `Copy $${v.name}`, icon: "pi-dollar", onSelect: () => { void clipboardWrite(`$${v.name}`); } },
];
if (v.writerKey) {
const k = v.writerKey;
items.push({ label: "Show in trace", icon: "pi-bolt", divider: true, onSelect: () => { void gotoStep(k); } });
if (v.negatives.length) {
const neg = v.negatives.map((n) => n.text).join(", ");
copy.push({ label: "Copy negative", icon: "pi-minus-circle", onSelect: () => { void clipboardWrite(neg); } });
}
openMenu(ev, key, items, { icon: "pi-dollar", label: `Variable · $${v.name}` });
const kind = v.writerKind ?? "unknown";
openMenu(ev, key, grouped(go, copy, [pinItem(key)]), {
icon: v.writerKind ? KIND_ICONS[kind].replace(/^pi\s+/, "") : "pi-arrow-down",
iconColor: v.writerKind ? kindColor(kind) : undefined,
label: `$${v.name}`,
});
}
function openStepMenu(ev: MouseEvent, s: TraceStep): void {
const first = s.writes[0];
const items: ContextMenuItem[] = [
pinItem(s.key),
const go: ContextMenuItem[] = [
{ label: expanded.value.has(s.key) ? "Collapse" : "Expand", icon: "pi-chevron-down", onSelect: () => toggleStep(s.key) },
];
if (canFocus.value && s.nodeId) {
const id = s.nodeId;
go.push({ label: "Show node", icon: "pi-arrow-up-right", onSelect: () => focusNode(id, s) });
}
const copy: ContextMenuItem[] = [
{ label: "Copy value", icon: "pi-clone", disabled: !first?.value, onSelect: () => { void clipboardWrite(first?.value ?? ""); } },
];
if (s.bindings[0]) {
const b = s.bindings[0];
items.push({ label: `Copy $${b}`, icon: "pi-dollar", onSelect: () => { void clipboardWrite(`$${b}`); } });
copy.push({ label: `Copy $${b}`, icon: "pi-dollar", onSelect: () => { void clipboardWrite(`$${b}`); } });
}
if (s.seed) items.push({ label: "Copy seed", icon: "pi-hashtag", onSelect: () => { void clipboardWrite(s.seed); } });
if (s.id) items.push({ label: "Copy module id", icon: "pi-id-card", onSelect: () => { void clipboardWrite(s.id); } });
if (canFocus.value && s.nodeId) {
const id = s.nodeId;
items.push({ label: "Show node", icon: "pi-arrow-up-right", divider: true, onSelect: () => focusNode(id) });
}
openMenu(ev, s.key, items, { icon: "pi-bolt", label: `Step ${s.order} · ${s.name || s.kindLabel}` });
if (s.seed) copy.push({ label: "Copy seed", icon: "pi-hashtag", onSelect: () => { void clipboardWrite(s.seed); } });
if (s.id) copy.push({ label: "Copy module id", icon: "pi-id-card", onSelect: () => { void clipboardWrite(s.id); } });
openMenu(ev, s.key, grouped(go, copy, [pinItem(s.key)]), {
icon: KIND_ICONS[s.kind].replace(/^pi\s+/, ""),
iconColor: kindColor(s.kind),
label: `${s.order} · ${s.name || s.kindLabel}`,
});
}
</script>
@@ -364,7 +423,7 @@ function openStepMenu(ev: MouseEvent, s: TraceStep): void {
hint="Previous iteration"
@click="gotoIteration(iterationIndex - 1)"
/>
<span class="wp-dbg-iter__label" data-test="dbg-iter-label">frame {{ iterationIndex + 1 }} / {{ iterationCount }}</span>
<span class="wp-dbg-iter__label" data-test="dbg-iter-label" :title="frameTitle">{{ frameLabel }}</span>
<Button size="sm" variant="ghost" :disabled="iterationIndex>= iterationCount - 1"
data-test="dbg-iter-next"
title="Next iteration"
@@ -449,8 +508,13 @@ function openStepMenu(ev: MouseEvent, s: TraceStep): void {
:uuid-to-name="uuidToName"
:uuid-to-kind="uuidToKind"
:ctx-active-key="ctxActiveKey"
:node-info="nodeInfo"
:steps-by-key="stepsByKey"
:warnings-by-step="warningsByStep"
:can-focus="canFocus"
@goto-step="gotoStep"
@row-menu="openVarMenu"
@show-var="showVar"
/>
<div v-else class="wp-dbg-empty">
<i class="pi pi-dollar wp-dbg-empty__icon" aria-hidden="true" />
+6 -5
View File
@@ -7,7 +7,7 @@
import Button from "@/kit/Button.vue";
import { computed } from "vue";
import RichTextPreview from "../../manager/components/RichTextPreview.vue";
import type { TraceStep, WarningRow } from "./debug-model";
import { KIND_ICONS, kindColor, type TraceStep, type WarningRow } from "./debug-model";
const props = defineProps<{
rows: WarningRow[];
@@ -53,9 +53,10 @@ function ownerLabel(key: string | null): string {
data-test="dbg-warning-owner"
@click="emit('goto-step', w.stepKey)"
>
<span
class="wp-dbg-kind-dot"
:style="{ background: `var(--wp-kind-${stepsByKey.get(w.stepKey)?.kind})` }"
<i
:class="['wp-dbg-warn__kind', KIND_ICONS[stepsByKey.get(w.stepKey)?.kind ?? 'unknown']]"
:style="{ color: kindColor(stepsByKey.get(w.stepKey)?.kind) }"
aria-hidden="true"
/>
{{ ownerLabel(w.stepKey) }}
</Button>
@@ -110,7 +111,7 @@ function ownerLabel(key: string | null): string {
}
.wp-dbg-warn__owner.wp-btn:hover { color: var(--wp-text); }
.wp-dbg-warn__msg { font-size: 11px; color: var(--wp-text-muted); word-break: break-word; }
.wp-dbg-kind-dot { width: 7px; height: 7px; border-radius: min(2px, var(--wp-radius-cap, 999px)); flex: none; }
.wp-dbg-warn__kind { font-size: 9px; flex: none; }
.wp-dbg-warn__owner.wp-btn :deep(.wp-btn__label) { display: inline-flex; align-items: center; gap: 5px; min-width: 0; }
.wp-dbg-warn__owner.wp-btn :deep(.wp-btn__label) { line-height: inherit; }
</style>
+11
View File
@@ -92,6 +92,17 @@ describe("buildModel", () => {
expect(m.steps[2].constraint).toMatchObject({ hits: 4, reach: "next 2 targets", cells: 1, only: false });
});
it("links a constraint warning to its row by the instance uid", () => {
const m = buildModel({
__wp_trace__: [{
id: "lib00001", _uid: "inst-con-0001", type: "constraint", status: "ok",
detail: { uid: "inst-con-0001", reach: { mode: "all" }, cells: 1, exceptions: 0, only: false },
}],
__wp_warnings__: [{ type: "constraint_never_applied", module_id: "inst-con-0001" }],
});
expect(m.warnings[0].stepKey).toBe(m.steps[0].key);
});
it("leaves a constraint whose ref-only target wasn't drawn ok", () => {
const m = buildModel({
__wp_trace__: [{
+206 -4
View File
@@ -65,12 +65,60 @@ export interface RawDetail {
pool?: number;
live?: number;
filter?: string;
/** Pool filter by variable (v11): what each `$name` in the filter read. */
filter_vars?: Record<string, string>;
/** `match_variable` picks: the variable the option text had to equal. */
match_variable?: string;
/** A Context Loop sweep pinned this frame's option. */
swept?: boolean;
exclude_null?: boolean;
held?: boolean;
/** Schema v9: nothing else was left, so the fallback option was used. */
fallback?: boolean;
range?: [number, number];
independent?: boolean;
/** Multi-pick: what joins the picks. */
separator?: string;
/** `match_variable`: the value the variable held. */
match_value?: string;
/** Every option of the use: library weight, final weight (null when the
* filter or a toggle took it out), why it can't be drawn. */
options?: RawOptionRow[];
/** Options past the shown ones (only counted). */
options_more?: number;
/** Pool size after each step: library → filter → toggles → drawable. */
steps?: { library: number; filter: number; toggles: number; live: number };
/** Rolled with the instance's locked seed. */
locked_seed?: boolean;
// fixed values
/** "override" (the node's own rows) or "library". */
source?: string;
toggled_off?: string[];
rows?: Array<{ name: string; raw?: string }>;
// combine + Injector templates
template?: string;
template_source?: string;
/** Each `$name` the template read; null when nothing set it. */
reads?: Record<string, string | null>;
// Model Info rows
config_class?: string;
rule?: string;
rule_variant?: string;
/** Model Info: how many variant rules it had (absent when pinned). */
rules_count?: number;
stem?: string;
file?: string;
// Toggles rows
switch?: boolean;
upstream_read?: string | null;
needs?: string;
held_off?: boolean;
picked?: string;
forced_by?: string;
upstream_on?: string[];
raw?: string;
// Injector rows
input?: string;
constraints?: Array<{ id?: string; uid?: string; name?: string; source?: string; source_value?: unknown }>;
// derivation
rules?: RawRuleDetail[];
@@ -80,6 +128,34 @@ export interface RawDetail {
cells?: number;
exceptions?: number;
only?: boolean;
// Context Loop rows
frame?: number;
of?: number;
seed?: number;
combo?: number;
combos?: number;
value_line?: number;
values?: number;
bypass?: boolean;
strategy?: string;
skipped?: number;
/** This frame's sweep pins: module uid → option id. */
pins?: Record<string, string>;
hold_others?: boolean;
}
export interface RawOptionRow {
id?: string | null;
value?: string;
base?: number;
weight?: number | null;
/** Why it can't be drawn: the pool filter, the node's option toggles,
* a constraint, or a weight of 0. */
cut?: "filter" | "toggle" | "constraint" | "weight";
/** The node's weight override, when it differs from the library's. */
override?: number;
null?: boolean;
fallback?: boolean;
}
export interface RawTraceEntry {
@@ -99,6 +175,14 @@ export interface RawTraceEntry {
seed?: number;
constraint_source?: string;
constraint_target?: string;
/** WP Toggles: "upstream" when an upstream variable set the switch. */
source?: string;
/** WP Toggles: the toggle's on/off state (what `$name.bool` reads). */
bool?: boolean;
/** WP Toggles: the row wrote the toggle's value text, not true/false. */
has_value?: boolean;
/** WP Toggles: the negative it filed for its variable. */
negative?: string;
error?: string | { type?: string; message?: string } | null;
detail?: RawDetail;
}
@@ -116,7 +200,7 @@ export interface RawRef {
// ── View model ──────────────────────────────────────────────────────────
export type Kind = "wildcard" | "fixed" | "combine" | "derivation" | "constraint" | "injector" | "model" | "toggle" | "unknown";
export type Kind = "wildcard" | "fixed" | "combine" | "derivation" | "constraint" | "injector" | "model" | "toggle" | "loop" | "unknown";
export type StepStatus = "ok" | "off" | "frame" | "error" | "unknown" | "never";
export type Severity = "info" | "warning" | "error";
@@ -176,11 +260,21 @@ export interface TraceStep {
error: string | null;
/** Injector rows: the value type on the wire and whether it was templated. */
valueType: string;
/** Node rows (Model Info, Toggles, Context Loop): where the value came
* from, in words ("from the model", "set upstream", "frame 3 of 8"). */
source: string;
/** Toggle rows: on/off (`$name.bool`), null for other rows and Pick one. */
toggleBool: boolean | null;
/** Toggle rows: the variable holds the toggle's value text (empty while
* off) instead of true/false. */
toggleHasValue: boolean;
isTemplate: boolean;
detail: RawDetail | null;
refs: RefRow[];
constraint: ConstraintInfo | null;
appliedBy: AppliedConstraint[];
/** Constraint steps: the wildcard steps it re-weighted this run. */
appliedTo: Array<{ key: string; name: string; order: number }>;
warningCount: number;
}
@@ -207,6 +301,9 @@ export interface VarRow {
writerKey: string | null;
writerKind: Kind | null;
writerName: string;
/** Graph node of that step ("" when unknown), so the view can name a
* node-written variable after its node. */
writerNodeId: string;
/** How many steps wrote this variable (>1 means it was overwritten). */
writeCount: number;
/** Set by an upstream node's pass-through, not by any traced module. */
@@ -281,10 +378,17 @@ export const WARNING_LABELS: Record<string, string> = {
fallback_used: "Fallback used",
pinned_option_missing: "Pinned option missing",
match_variable_no_match: "No option matches the variable",
filter_variable_no_tag: "Filter variable left out",
constraint_factor_ignored_on_allow: "Constraint factor ignored (allow)",
unknown_constraint_mode: "Unknown constraint mode",
fixed_values_overrides_malformed: "Fixed-values overrides malformed",
handler_error: "Module failed",
injector_reserved_binding: "Reserved injector name",
injector_invalid_binding: "Invalid injector name",
model_info_nothing_detected: "Model Info found nothing",
model_info_bad_rule: "Model Info rule skipped",
toggles_problem: "Toggles setup problem",
toggles_upstream_many: "Upstream turned on several",
};
export const OP_LABELS: Record<string, string> = {
@@ -318,9 +422,42 @@ const KIND_LABELS: Record<Kind, string> = {
injector: "injector",
model: "model info",
toggle: "toggle",
loop: "loop",
unknown: "module",
};
/** PrimeIcons mark per kind, so each source reads at a glance in the
* Variables and Trace tabs (same marks as the Assembler chips). */
export const KIND_ICONS: Record<Kind, string> = {
wildcard: "pi pi-sparkles",
fixed: "pi pi-tag",
combine: "pi pi-link",
derivation: "pi pi-arrow-right-arrow-left",
constraint: "pi pi-filter",
injector: "pi pi-bolt",
model: "pi pi-microchip",
toggle: "pi pi-check-square",
loop: "pi pi-replay",
unknown: "pi pi-circle",
};
/** Kinds written by a node of their own rather than a module in a Context. */
export const NODE_KINDS = new Set<Kind>(["injector", "model", "toggle", "loop"]);
/** The node each node kind comes from, for rows the canvas can't name. */
export const NODE_KIND_TITLES: Partial<Record<Kind, string>> = {
injector: "WP Context Injector",
model: "WP Model Info",
toggle: "WP Toggles",
loop: "WP Context Loop",
};
/** A kind's colour token (`--wp-kind-*`). */
export function kindColor(kind: Kind | null | undefined): string {
if (!kind || kind === "unknown") return "var(--wp-text-dim)";
return `var(--wp-kind-${kind})`;
}
// ── Helpers ─────────────────────────────────────────────────────────────
export function formatValue(v: unknown): string {
@@ -360,6 +497,7 @@ function kindOf(entry: RawTraceEntry): Kind {
if (entry.node === "WP_ContextInjector") return "injector";
if (entry.node === "WP_ModelInfo") return "model";
if (entry.node === "WP_Toggles") return "toggle";
if (entry.node === "WP_ContextLoop") return "loop";
switch (entry.type) {
case "wildcard": return "wildcard";
case "fixed_values": return "fixed";
@@ -426,7 +564,8 @@ function warningDetailText(type: string, detail: Record<string, unknown>): strin
return str(detail.name) ? "placeholder, or a module that was deleted" : "";
}
if (type === "unknown_var" || type === "var_out_of_surface") {
return str(detail.surface) ? `in a ${str(detail.surface)}` : "";
const surface = str(detail.surface);
return surface ? `in ${/^[aeiou]/i.test(surface) ? "an" : "a"} ${surface}` : "";
}
if (type === "unknown_tag_axis") {
const declared = asList<string>(detail.declared);
@@ -445,6 +584,9 @@ function negativeSourceLabel(variable: string, source: string, carriers: Map<str
const carrier = carriers.get(source);
if (carrier) return carrier;
if (source === "injector") return "Injector";
// WP Toggles files a toggle's (or Pick one's) negative under the variable
// it writes, so it is the variable's own.
if (source === "WP_Toggles") return "";
if (source === "derivation") return "derivation";
return `$${source}`;
}
@@ -468,6 +610,41 @@ export function negativeLines(
return out;
}
const MODEL_SOURCES: Record<string, string> = {
model: "from the model",
rules: "file-name rule",
pinned: "pinned",
wired: "wired in",
loader: "loader file",
};
/** Where a node row's value came from, in words. */
export function sourceText(kind: Kind, entry: RawTraceEntry): string {
const type = str(entry.type);
if (kind === "model") return MODEL_SOURCES[type] ?? "";
if (kind === "toggle") {
const what = type === "pick" ? "Pick one" : "switch";
return entry.source === "upstream" ? `${what}, set upstream` : what;
}
if (kind === "loop") {
const d = entry.detail;
if (!d || typeof d.frame !== "number") return "";
return typeof d.of === "number" ? `frame ${d.frame} of ${d.of}` : `frame ${d.frame}`;
}
return "";
}
/** A toggle row's on/off state: its `bool` field, else a true/false value
* (toggles without a value text). Pick one rows have none. */
export function toggleBoolOf(kind: Kind, entry: RawTraceEntry): boolean | null {
if (kind !== "toggle" || str(entry.type) === "pick") return null;
if (typeof entry.bool === "boolean") return entry.bool;
const v = typeof entry.value === "string" ? entry.value.trim().toLowerCase() : entry.value;
if (v === "true" || v === true) return true;
if (v === "false" || v === false) return false;
return null;
}
// ── Build ───────────────────────────────────────────────────────────────
export function buildModel(snap: Record<string, unknown>): DebugModel {
@@ -539,7 +716,7 @@ export function buildModel(snap: Record<string, unknown>): DebugModel {
overwrite: !!w.overwrite,
negative: str(w.negative).trim(),
}));
if ((kind === "injector" || kind === "model" || kind === "toggle") && t.binding) {
if (NODE_KINDS.has(kind) && t.binding) {
writes.push({ variable: t.binding, value: formatValue(t.value), overwrite: false, negative: "" });
}
const declared = writes.length
@@ -575,7 +752,11 @@ export function buildModel(snap: Record<string, unknown>): DebugModel {
const key = String(i);
const nodeId = str(t.node_id);
if (t._uid) stepKeyByOwner.set(`${nodeId}|${t._uid}`, key);
if (t.id && !stepKeyByModuleId.has(t.id)) stepKeyByModuleId.set(t.id, key);
// Engine warnings name a module by its instance `_uid` (constraints key
// their hit counters per instance) or its library id.
for (const id of [str(t._uid), str(t.id)]) {
if (id && !stepKeyByModuleId.has(id)) stepKeyByModuleId.set(id, key);
}
return {
key,
order: i + 1,
@@ -594,11 +775,15 @@ export function buildModel(snap: Record<string, unknown>): DebugModel {
internal: !!t.internal,
error: errorText(t.error),
valueType: kind === "injector" ? (isTemplate ? "str" : injectorType).toUpperCase() : "",
source: sourceText(kind, t),
toggleBool: toggleBoolOf(kind, t),
toggleHasValue: kind === "toggle" && t.has_value === true,
isTemplate,
detail,
refs: refsByOwner.get(`${nodeId}|${str(t._uid)}`) ?? [],
constraint,
appliedBy: [],
appliedTo: [],
warningCount: 0,
};
});
@@ -618,9 +803,24 @@ export function buildModel(snap: Record<string, unknown>): DebugModel {
source: str(c.source),
sourceValue: formatValue(c.source_value),
});
if (own && !own.appliedTo.some((t) => t.key === s.key)) {
own.appliedTo.push({ key: s.key, name: s.name || s.bindings[0] || s.kindLabel, order: s.order });
}
}
}
// Node rows (Toggles, Model Info, Injector, Loop) carry no module ids, so
// their warnings find their row by node: the row for the warning's
// variable, else the node's Pick one row, else its first row.
const nodeStepFor = (nodeId: string, o: Record<string, unknown>): string | null => {
const mine = steps.filter((s) => s.nodeId === nodeId && NODE_KINDS.has(s.kind));
if (!mine.length) return null;
const names = [str(o.binding), str(asRecord(o.detail).name)].filter(Boolean);
const byName = mine.find((s) => s.bindings.some((b) => names.includes(b)));
if (byName) return byName.key;
const pick = mine.find((s) => s.source.startsWith("Pick one"));
return (pick ?? mine[0]).key;
};
const warnings: WarningRow[] = rawWarnings.map((w, i) => {
const o = asRecord(w);
const type = str(o.type) || "unknown";
@@ -629,6 +829,7 @@ export function buildModel(snap: Record<string, unknown>): DebugModel {
let stepKey = owner ? stepKeyByOwner.get(owner) ?? null : null;
if (!stepKey && str(o.module_id)) stepKey = stepKeyByModuleId.get(str(o.module_id)) ?? null;
if (!stepKey && str(o.owner_id)) stepKey = stepKeyByModuleId.get(str(o.owner_id)) ?? null;
if (!stepKey && str(o.node_id)) stepKey = nodeStepFor(str(o.node_id), o);
return {
key: String(i),
type,
@@ -714,6 +915,7 @@ export function buildModel(snap: Record<string, unknown>): DebugModel {
writerKey: writer?.key ?? null,
writerKind: writer?.kind ?? null,
writerName: writer?.name ?? "",
writerNodeId: writer?.nodeId ?? "",
writeCount: writeCount.get(name) ?? 0,
fromUpstream: !writer,
negatives: negativeLines(name, negTable[name], carriers),
+227
View File
@@ -0,0 +1,227 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import DebugStepDetail from "./DebugStepDetail.vue";
import DebugVariables from "./DebugVariables.vue";
import DebugTrace from "./DebugTrace.vue";
import { WARNING_LABELS, buildModel, kindColor } from "./debug-model";
// WP Debug and the nodes that write context without a module: Context Loop,
// Model Info, Toggles and the Injector.
const loopRow = (binding: string, value: string, detail: Record<string, unknown> = {}) => ({
node: "WP_ContextLoop", node_id: "3", binding, value, internal: false,
type: "iteration", detail: { frame: 5, of: 18, ...detail },
});
describe("node rows in the model", () => {
it("reads Context Loop rows as their own kind and names the frame", () => {
const m = buildModel({ iteration: "5", __wp_trace__: [loopRow("iteration", "5", { seed: 42, combo: 2, combos: 4 })] });
const s = m.steps[0];
expect(s.kind).toBe("loop");
expect(s.kindLabel).toBe("loop");
expect(s.source).toBe("frame 5 of 18");
expect(s.writes).toEqual([{ variable: "iteration", value: "5", overwrite: false, negative: "" }]);
expect(m.variables[0]).toMatchObject({ name: "iteration", writerKind: "loop", writerNodeId: "3", fromUpstream: false });
});
it("says where Model Info and Toggles values came from", () => {
const m = buildModel({
__wp_trace__: [
{ node: "WP_ModelInfo", node_id: "7", binding: "model_family", type: "model", value: "SDXL" },
{ node: "WP_ModelInfo", node_id: "7", binding: "model_variant", type: "rules", value: "pony" },
{ node: "WP_Toggles", node_id: "8", binding: "toggle_1", type: "toggle", value: "true", source: "upstream" },
{ node: "WP_Toggles", node_id: "8", binding: "look", type: "pick", value: "noir" },
],
});
expect(m.steps.map((s) => s.source)).toEqual([
"from the model", "file-name rule", "switch, set upstream", "Pick one",
]);
});
it("labels every node warning and ties it to the node's row", () => {
for (const t of [
"injector_reserved_binding", "injector_invalid_binding", "model_info_nothing_detected",
"model_info_bad_rule", "toggles_problem", "toggles_upstream_many",
]) expect(WARNING_LABELS[t]).toBeTruthy();
const m = buildModel({
__wp_trace__: [
{ node: "WP_Toggles", node_id: "8", binding: "toggle_1", type: "toggle", value: "true" },
{ node: "WP_Toggles", node_id: "8", binding: "look", type: "pick", value: "$missing" },
{ node: "WP_ContextInjector", node_id: "9", binding: "subject", type: "str", value: "cat" },
],
__wp_warnings__: [
{ type: "unknown_var", node_id: "8", message: "WP Toggles value: $missing", detail: { name: "missing", surface: "assembler" } },
{ type: "toggles_problem", node_id: "8", message: "WP Toggles: two rows share $x" },
{ type: "injector_invalid_binding", node_id: "9", binding: "subject", message: "skipped" },
{ type: "unknown_var", node_id: "404", message: "elsewhere" },
],
});
expect(m.warnings.map((w) => w.stepKey)).toEqual(["1", "1", "2", null]);
expect(m.warnings[0].detailText).toBe("in an assembler");
expect(m.steps[1].warningCount).toBe(2);
});
it("gives every node kind a colour token", () => {
expect(kindColor("loop")).toBe("var(--wp-kind-loop)");
expect(kindColor("toggle")).toBe("var(--wp-kind-toggle)");
expect(kindColor("unknown")).toBe("var(--wp-text-dim)");
});
});
describe("node rows in the view", () => {
it("shows the Loop's frame facts when a row opens", () => {
const m = buildModel({ __wp_trace__: [loopRow("iteration", "5", { seed: 42, combo: 2, combos: 4, value_line: 1, values: 3 })] });
const w = mount(DebugStepDetail, {
props: { step: m.steps[0], warnings: [], uuidToName: new Map(), uuidToKind: new Map(), canFocus: false },
});
const facts = w.find('[data-test="dbg-node-facts"]').text();
expect(facts).toContain("frame 5 of 18");
expect(facts).toContain("frame seed 42");
expect(facts).toContain("sweep combination 2 of 4");
expect(facts).toContain("value line 1 of 3");
});
it("says a wildcard was swept, matched a variable or read a filter variable", () => {
const wcRow = (detail: Record<string, unknown>) => ({
id: "aaaa1111", _uid: "u1", type: "wildcard", status: "ok", writes: [{ variable: "style", value: "noir" }], detail,
});
const m = buildModel({ __wp_trace__: [
wcRow({ mode: "pinned", swept: true }),
wcRow({ mode: "match_variable", match_variable: "model_variant" }),
wcRow({ filter: "$model_variant", filter_vars: { model_variant: "pony" } }),
] });
const at = (i: number) => mount(DebugStepDetail, {
props: { step: m.steps[i], warnings: [], uuidToName: new Map(), uuidToKind: new Map(), canFocus: false },
});
expect(at(0).find('[data-test="dbg-swept"]').exists()).toBe(true);
expect(at(0).text()).not.toContain("pinned option");
expect(at(1).find('[data-test="dbg-match-var"]').text()).toContain("$model_variant");
expect(at(2).find('[data-test="dbg-filter-var"]').text()).toContain("pony");
});
it("groups variables by the node that wrote them, each node drawn its own way", () => {
const m = buildModel({
toggle_1: "true",
model_family: "SDXL",
mood: "calm",
__wp_trace__: [
{ node: "WP_Toggles", node_id: "8", binding: "toggle_1", type: "toggle", value: "true" },
{ node: "WP_ModelInfo", node_id: "7", binding: "model_family", type: "model", value: "SDXL" },
{ id: "aaaa1111", _uid: "u", node_id: "9", type: "wildcard", status: "ok", writes: [{ variable: "mood", value: "calm" }] },
],
});
const w = mount(DebugVariables, {
props: {
rows: m.variables, pinned: new Set<string>(), flashed: new Set<string>(), uuidToName: new Map(), uuidToKind: new Map(),
ctxActiveKey: null, nodeInfo: (id: string) => ({ title: id === "8" ? "Moods" : "", codename: id === "9" ? "ALPHA" : "" }),
stepsByKey: new Map(m.steps.map((s) => [s.key, s])),
},
});
const secs = w.findAll('[data-test="dbg-var-section"]');
expect(secs.map((x) => x.attributes("data-kind"))).toEqual(["toggle", "model", "context"]);
expect(secs[0].find('[data-test="dbg-var-section-head"]').text()).toContain("Moods");
expect(secs[0].find(".wp-dbg-var-row__switch").classes()).toContain("is-on");
expect(secs[1].find(".wp-dbg-var-row__value.is-model").text()).toBe("SDXL");
expect(secs[1].find('[data-test="dbg-node-source"]').text()).toBe("from the model");
expect(secs[2].find('[data-test="dbg-var-section-head"]').text()).toContain("ALPHA");
expect(secs[2].find('[data-test="dbg-var"]').exists()).toBe(true);
});
it("draws every pick of a Pick one node as its own switch", async () => {
const m = buildModel({
warm: "true", cold: "false", mood: "warm light",
__wp_trace__: [
{ node: "WP_Toggles", node_id: "8", binding: "warm", type: "toggle", value: "true" },
{ node: "WP_Toggles", node_id: "8", binding: "cold", type: "toggle", value: "false" },
{ node: "WP_Toggles", node_id: "8", binding: "mood", type: "pick", value: "warm light" },
],
});
const w = mount(DebugVariables, {
props: {
rows: m.variables, pinned: new Set<string>(), flashed: new Set<string>(), uuidToName: new Map(), uuidToKind: new Map(),
ctxActiveKey: null, stepsByKey: new Map(m.steps.map((s) => [s.key, s])), canFocus: true,
},
});
const rows = w.findAll('[data-test="dbg-var"]');
const byName = (n: string) => rows.find((r) => r.text().includes(`$${n}`));
expect(byName("warm")?.find(".wp-dbg-var-row__switch").classes()).toContain("is-on");
expect(byName("warm")?.text()).toContain("picked");
expect(byName("cold")?.find(".wp-dbg-var-row__switch").classes()).toContain("is-off");
expect(byName("cold")?.text()).toContain("not picked");
expect(byName("mood")?.text()).toContain("pick one");
});
it("shows a toggle with a value as its text plus its .bool state", () => {
const m = buildModel({
day: "bright daylight", night: "",
__wp_negatives__: { day: [{ text: "dark", pick: null, source: "WP_Toggles" }] },
__wp_trace__: [
{ node: "WP_Toggles", node_id: "8", binding: "day", type: "toggle", value: "bright daylight", bool: true, has_value: true, negative: "dark" },
{ node: "WP_Toggles", node_id: "8", binding: "night", type: "toggle", value: "", bool: false, has_value: true },
],
});
const w = mount(DebugVariables, {
props: {
rows: m.variables, pinned: new Set<string>(), flashed: new Set<string>(), uuidToName: new Map(), uuidToKind: new Map(),
ctxActiveKey: null, stepsByKey: new Map(m.steps.map((s) => [s.key, s])),
},
});
const rows = w.findAll('[data-test="dbg-var"]');
const day = rows.find((r) => r.text().includes("$day"));
const night = rows.find((r) => r.text().includes("$night"));
// With a value, the row shows the text; on/off is the .bool line.
expect(day?.find(".wp-dbg-var-row__switch").exists()).toBe(false);
expect(day?.text()).toContain("bright daylight");
expect(day?.find('[data-test="dbg-var-bool"]').text()).toContain("true");
expect(day?.find('[data-test="dbg-var-neg"]').text()).toContain("dark");
expect(night?.find('[data-test="dbg-var-bool"]').text()).toContain("false");
expect(night?.text()).toContain("empty while off");
});
it("folds a long value and long negatives until the row is opened", async () => {
const long = "word ".repeat(60).trim();
const m = buildModel({
story: long, short: "calm",
__wp_negatives__: { story: [{ text: "bad ".repeat(40).trim(), pick: null, source: "" }] },
__wp_trace__: [
{ id: "aaaa1111", _uid: "u1", node_id: "9", type: "wildcard", status: "ok", writes: [{ variable: "story", value: long }] },
{ id: "bbbb2222", _uid: "u2", node_id: "9", type: "wildcard", status: "ok", writes: [{ variable: "short", value: "calm" }] },
],
});
const w = mount(DebugVariables, {
props: {
rows: m.variables, pinned: new Set<string>(), flashed: new Set<string>(), uuidToName: new Map(), uuidToKind: new Map(),
ctxActiveKey: null, stepsByKey: new Map(m.steps.map((s) => [s.key, s])),
},
});
const rowOf = (name: string) => w.findAll('[data-test="dbg-var"]').find((r) => r.text().includes(`$${name}`));
expect(rowOf("short")?.find('[data-test="dbg-var-more"]').exists()).toBe(false);
const story = rowOf("story");
expect(story?.find(".wp-dbg-var-row__value").classes()).toContain("is-folded");
const more = story?.find('[data-test="dbg-var-more"]');
expect(more?.text()).toBe("Show all");
await more?.trigger("click");
expect(rowOf("story")?.find(".wp-dbg-var-row__value").classes()).not.toContain("is-folded");
expect(rowOf("story")?.find('[data-test="dbg-var-more"]').text()).toBe("Show less");
});
it("draws node rows in the trace as numbered steps, in run order", () => {
const m = buildModel({
__wp_trace__: [
{ id: "aaaa1111", _uid: "u", node_id: "9", type: "wildcard", status: "ok", writes: [{ variable: "mood", value: "calm" }] },
{ node: "WP_Toggles", node_id: "8", binding: "warm", type: "toggle", value: "true" },
{ node: "WP_Toggles", node_id: "8", binding: "cold", type: "toggle", value: "false" },
],
});
const w = mount(DebugTrace, {
props: {
groups: m.groups, showGroupHeads: true, expanded: new Set<string>(), pinned: new Set<string>(), flashKey: null,
warningsByStep: new Map(), uuidToName: new Map(), uuidToKind: new Map(),
nodeInfo: () => ({ title: "", codename: "" }), canFocus: false, ctxActiveKey: null,
},
});
expect(w.find('[data-test="dbg-node-card"]').exists()).toBe(false);
expect(w.findAll(".wp-dbg-step__order").map((x) => x.text())).toEqual(["1", "2", "3"]);
expect(w.findAll(".wp-dbg-step").map((x) => x.attributes("data-kind"))).toEqual(["wildcard", "toggle", "toggle"]);
});
});
+8 -1
View File
@@ -11,6 +11,7 @@ import PickerModal from "./PickerModal.vue";
import { app } from "#comfyui/app";
import { submitAnswer, waiting } from "../../extension/image-filter";
import type { PickAnswer } from "./types";
import type { LiteGraphLike } from "../../extension/graph";
const current = computed(() => waiting.value[0] ?? null);
const minimized = ref(false);
@@ -20,6 +21,12 @@ watch(() => current.value?.token, (token, prev) => {
if (token && token !== prev) minimized.value = false;
});
// The host types `app.graph` as unknown; the picker reads it as a graph,
// like the other walkers do.
function hostGraph(): LiteGraphLike | undefined {
return app.graph as LiteGraphLike | undefined;
}
function onAnswer(answer: PickAnswer): void {
const req = current.value;
if (req) void submitAnswer(req.token, answer);
@@ -31,7 +38,7 @@ function onAnswer(answer: PickAnswer): void {
v-if="current && !minimized"
:request="current"
:more-waiting="waiting.length - 1"
:graph="app.graph"
:graph="hostGraph()"
@answer="onAnswer"
@minimize="minimized = true"
/>
@@ -54,6 +54,17 @@ describe("PickerModal", () => {
expect(w.emitted("answer")?.[0]?.[0]).toEqual({ action: "picks", picks: [[0, 1], [0, 3]], labels: { "0:1": "#1 · 2", "0:3": "#1 · 4" } });
});
it("a mode change that removes the focused control hands focus back to the picker", async () => {
const w = mount(PickerModal, { props: { request: request([3]) }, global: { stubs: { teleport: true } }, attachTo: document.body });
await w.find('[data-test="image-filter-mode-detail"]').trigger("click");
(document.activeElement as HTMLElement | null)?.blur();
expect(document.activeElement).toBe(document.body);
await w.find('[data-test="image-filter-mode-zoom"]').trigger("click");
await new Promise((r) => setTimeout(r, 0));
expect(document.activeElement).toBe(w.find('[data-test="image-filter-picker"]').element);
w.unmount();
});
it("clicking a frame opens Detail on its first image; clicking an image only picks it", async () => {
const w = mk(request([2, 2], { labels: [{ loop_index: 0 }, { loop_index: 1 }] }));
await w.find('[data-test="image-filter-tile-1-1"]').trigger("click");
+13 -1
View File
@@ -255,6 +255,18 @@ function togglePin(): void {
}
const painting = ref(false);
// The picker's keys only reach it from inside the modal (its key shield). Switching
// mode or leaving the painter removes the focused control (the mask view switch, a
// Detail button), which drops focus to <body>, so G / 0 / 1 / 2 would go to the canvas
// (bookmark shortcuts) instead. Hand focus back to the picker when that happens.
const pickerEl = ref<HTMLElement | null>(null);
function keepFocusInside(): void {
const root = pickerEl.value;
const at = document.activeElement;
if (root && !(at instanceof Node && root.contains(at))) root.focus({ preventScroll: true });
}
watch([mode, () => painting.value], () => { void nextTick(keepFocusInside); }, { flush: "post" });
const tool = ref<MaskTool>("brush");
const TOOL_OPTIONS: SegmentedOption<MaskTool>[] = [
{ value: "brush", label: "Brush", hint: "Paint with a round brush" },
@@ -535,7 +547,7 @@ const subLine = computed(() => {
<template>
<ModalShell :visible="true" @close="onClose" @keydown="onKey">
<div class="wp-ifp" :class="[`is-${mode}`, { 'is-fit': isFit }]" data-test="image-filter-picker" :data-mode="mode" tabindex="-1">
<div ref="pickerEl" class="wp-ifp" :class="[`is-${mode}`, { 'is-fit': isFit }]" data-test="image-filter-picker" :data-mode="mode" tabindex="-1">
<div class="wp-ifp__head">
<i class="pi pi-filter wp-ifp__head-icon" aria-hidden="true" />
<div class="wp-ifp__title-block">
@@ -432,3 +432,12 @@ describe("InjectorBindingModal — Negative field (send-to-negative)", () => {
expect(internal.find('[data-test="ibm-neg-note"]').text()).toContain("won't reach any prompt");
});
});
describe("InjectorBindingModal — inline picks", () => {
it("warns that {a|b} prints as written", async () => {
const w = mountModal(InjectorBindingModal, { props: { row: makeRow({ template: "$input_0" }) } });
expect(w.find('[data-test="ibm-inline-picks"]').exists()).toBe(false);
await typeTemplate(w, "{a|b|c}");
expect(w.find('[data-test="ibm-inline-picks"]').text()).toContain("print as written");
});
});
@@ -24,11 +24,13 @@
* there is no draft buffer, mirroring the lightweight per-row UX of
* the inline row controls. Save = close.
*/
import Banner from "@/kit/Banner.vue";
import Kbd from "@/kit/Kbd.vue";
import Input from "@/kit/Input.vue";
import { computed, ref, watch } from "vue";
import type { InjectorRow } from "../../widgets/_shared";
import RichTextInput from "../../manager/components/RichTextInput.vue";
import { INLINE_PICKS_NOTE, hasInlinePicks } from "../context/editors/_shared/preview-tokens";
import Button from "@/kit/Button.vue";
import ListRow from "@/kit/ListRow.vue";
import ModalShell from "@/kit/ModalShell.vue";
@@ -178,6 +180,9 @@ watch(
);
const templateValue = computed(() => draftTemplate.value);
/** The injector writes text as it is, with no seed: say so when an inline
* pick would otherwise look like it rolls. */
const inlinePicks = computed(() => hasInlinePicks(draftTemplate.value) || hasInlinePicks(draftNegative.value));
const isDirty = computed(() => {
if (draftBinding.value !== props.row.binding) return true;
@@ -470,6 +475,9 @@ function onKeydown(ev: KeyboardEvent): void {
{{ refCount }} refs<span v-if="unknownRefCount > 0"> · {{ unknownRefCount }} unknown</span>
</span>
</div>
<Banner v-if="inlinePicks" tone="warn" size="sm" class="wp-inline-picks-note" data-test="ibm-inline-picks">
{{ INLINE_PICKS_NOTE }}
</Banner>
</section>
@@ -719,6 +727,7 @@ function onKeydown(ev: KeyboardEvent): void {
}
.ibm__template::placeholder { color: var(--wp-text-dim, var(--wp-text3)); }
.wp-inline-picks-note { margin: 6px 0 0; }
.ibm__detected {
display: flex;
align-items: center;
+3 -23
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { valueTypeLook } from "./value-types";
import { computed, ref } from "vue";
import type { InjectorRow } from "../../widgets/_shared";
import Checkbox from "@/kit/Checkbox.vue";
@@ -74,30 +75,8 @@ const isEmpty = computed(() => !props.row.binding.trim());
* the user correlate the wire visually. Falls back to pi-circle for
* unknown types so the icon slot never goes empty (consistent row
* height). */
const TYPE_ICONS: Record<string, string> = {
// Engine-native primitives
string: "pi-pencil",
int: "pi-hashtag",
float: "pi-percentage",
boolean: "pi-check-square",
// Common ComfyUI tensor / model types — stringified by engine but
// worth surfacing visually so the user reads the wire kind.
image: "pi-image",
mask: "pi-clone",
latent: "pi-cloud",
conditioning: "pi-comment",
model: "pi-cube",
clip: "pi-tag",
vae: "pi-box",
audio: "pi-volume-up",
video: "pi-video",
noise: "pi-sparkles",
sigmas: "pi-chart-line",
guider: "pi-compass",
sampler: "pi-sliders-h",
};
const typeIcon = computed(
() => TYPE_ICONS[(props.valueType ?? "").toLowerCase()] ?? "pi-circle",
() => valueTypeLook(props.valueType).icon,
);
const hasTemplate = computed(() => {
@@ -165,6 +144,7 @@ function onDragEnd(): void {
class="wp-inj-row"
:data-type="(valueType ?? '').toLowerCase()"
:data-uid="row._uid"
:data-wp-vars="row.binding.trim() || undefined"
:data-kind="isGeneral ? 'general' : 'socket'"
:class="{
'wp-inj-row--disconnected': props.isConnected === false,
+45
View File
@@ -0,0 +1,45 @@
/**
* The Context Injector's value types: the icon and colour each wire type
* wears, shared by the Injector's rows and WP Debug so a value looks the
* same on both. Unknown types get a plain circle.
*/
export interface ValueTypeLook {
icon: string;
color: string;
}
export const VALUE_TYPES: Record<string, ValueTypeLook> = {
// Engine-native primitives
string: { icon: "pi-pencil", color: "var(--wp-amber)" },
int: { icon: "pi-hashtag", color: "var(--wp-green)" },
float: { icon: "pi-percentage", color: "var(--wp-var-7)" },
boolean: { icon: "pi-check-square", color: "var(--wp-var-5)" },
// Common ComfyUI tensor / model types, stringified by the engine but
// worth surfacing so the user reads the wire kind.
image: { icon: "pi-image", color: "var(--wp-var-1)" },
mask: { icon: "pi-clone", color: "var(--wp-var-6)" },
latent: { icon: "pi-cloud", color: "var(--wp-var-4)" },
conditioning: { icon: "pi-comment", color: "var(--wp-var-2)" },
model: { icon: "pi-cube", color: "var(--wp-var-8)" },
clip: { icon: "pi-tag", color: "var(--wp-var-3)" },
vae: { icon: "pi-box", color: "var(--wp-var-6)" },
audio: { icon: "pi-volume-up", color: "var(--wp-var-7)" },
video: { icon: "pi-video", color: "var(--wp-var-4)" },
noise: { icon: "pi-sparkles", color: "var(--wp-var-6)" },
sigmas: { icon: "pi-chart-line", color: "var(--wp-var-1)" },
guider: { icon: "pi-compass", color: "var(--wp-var-3)" },
sampler: { icon: "pi-sliders-h", color: "var(--wp-var-2)" },
};
/** The engine's trace says Python type names (`str`, `bool`). */
const ALIASES: Record<string, string> = { str: "string", bool: "boolean", tensor: "image" };
export const UNKNOWN_TYPE: ValueTypeLook = { icon: "pi-circle", color: "var(--wp-text-dim)" };
/** A templated row (filled from the node's inputs). */
export const TEMPLATE_TYPE: ValueTypeLook = { icon: "pi-bolt", color: "var(--wp-kind-injector)" };
export function valueTypeLook(type: string | undefined | null): ValueTypeLook {
const t = (type ?? "").trim().toLowerCase();
return VALUE_TYPES[ALIASES[t] ?? t] ?? UNKNOWN_TYPE;
}

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