Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
108c2b3e53 | ||
|
|
e41ae4ded0 | ||
|
|
3a7671b467 | ||
|
|
6818abdc25 | ||
|
|
4005954615 | ||
|
|
4ff45d2492 | ||
|
|
f30185d36a | ||
|
|
802fa2b36c | ||
|
|
eb9d8e483d |
@@ -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 -->
|
||||
|
||||
+11
-8
@@ -6,25 +6,28 @@ Several on/off switches in one node. Each toggle writes `$its_variable` as `true
|
||||
## 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.
|
||||
- **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 (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. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along.
|
||||
- **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. 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. Internal until you turn off the row's globe. Show puts it under the name.
|
||||
- **Style**: Switch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Greyed out in Pick one, where the Style in the top bar sets every row's look.
|
||||
- **Value (Pick one)**: What the picked variable gets when this toggle is picked, instead of its 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.
|
||||
- **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.
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
- **Any extension picture in a WP Note.** The Pictures panel's **From the extension** now offers every screenshot from the docs, not just the toolbar one, found by typing part of its name (the grid shows only matches, so the panel stays tidy). After you add a picture the panel stays open, so its width slider is right there, and the icon list and picture grid use the window's height instead of a small scrolling box. The icon picker on the node now fills the editor's width.
|
||||
- **Export as plain wildcard files.** Import / Export → Export has a new **Wildcard files (.zip)** button: the picks become `.txt` wildcard files for Dynamic Prompts, Impact Pack, Prompt-PostProcessor, Easy-Use and A1111/Forge, so you can share a pack with people who don't use Wildcard Pipeline. References and `$variables` become `__folder/name__`, uneven weights become repeated lines, and a README in the zip lists what had no plain form (rules, tags, the chance of nothing, negatives).
|
||||
|
||||
- **More looks for WP Toggles.** Pick one has its own **Style** in the edit view: Radio (as before), Chip, Switch or Check, the same for every row; each row's Style is greyed out there since it no longer applies. A pick's **Value** is now a text box like the Assembler's that you can make taller: `$` lists every variable upstream (Context Injector ones too) and the toggles' own, tag, LoRA and embedding autocomplete work in it, and a `$name` nothing upstream sets is flagged under the box and reported in WP Debug after a run. Inline `{a|b}` picks need a seed, so in the Value and in Context Injector templates they print as written; both editors now say so. A Chip toggle now spans the whole row, in Any on and Pick one, so it's an easy target and lines up with the rows around it. In the edit view you can drag a toggle into or out of a label's group (the Group field still works), and a grouped toggle shows its label's colour on its left edge.
|
||||
|
||||
- **A toggle's settings open in a window.** In WP Toggles' edit view each row now has a pencil, the globe and a trash button, like a module on a Context node. The pencil opens that toggle's settings in a window instead of a panel squeezed into the node, with the row drawn on top as the node will show it; Done saves, Cancel drops the changes (it asks first when there are any). Mode, order, labels and the Pick one options stay on the node.
|
||||
- **Icons and colours for WP Toggles.** A toggle can show an icon before its name and have its own colours for the icon, the name and the control itself, so Day can be yellow and Night blue. A label picks any colour with the colour picker (not just the eight presets) and an icon too; the label's icon button is a small square like its colour one. A Chip toggle shows its variable after the name (Day · `$day`) while Show is on, a variable wears its name's colour, and icons now sit level with the name.
|
||||
- **A negative for each Pick one toggle.** Under a toggle's Value there is now a Negative: words to keep out of the image while that toggle is picked (Night: "sunlight, blue sky"). They go with the picked variable, so an Assembler that prints it puts them in its negative output.
|
||||
- **Every toggle can have a Value and a Negative, and `$name.bool`.** Give a toggle a Value and its variable prints it while on and nothing while off: a Rain toggle with "heavy rain" makes `$rain` print "heavy rain" or nothing, and its Negative ("sunny, dry") goes to the Assembler's negative output with it. The switch is still there as `$rain.bool` (true or false) for rules and conditions; `bool` can't name a tag group any more. Packs that read `$name.bool` need this version (schema 12).
|
||||
- **An Off value and Off negative for Any on toggles.** A toggle can also say what its variable writes while off, with its own negative: a Buttons toggle Anime / Real writes "anime style" or "photorealistic". Empty, off prints nothing as before.
|
||||
- **More looks for WP Toggles.** Pick one has its own **Style**: Radio (as before), Chip, Switch or Check, the same for every row; a toggle's own Style and Needs are hidden there since they don't apply. A pick's **Value** is now a text box like the Assembler's that you can make taller: `$` lists every variable upstream (Context Injector ones too) and the toggles' own, tag, LoRA and embedding autocomplete work in it, and a `$name` nothing upstream sets is flagged under the box and reported in WP Debug after a run. Inline `{a|b}` picks need a seed, so in the Value and in Context Injector templates they print as written; both editors now say so. A Chip toggle now spans the whole row, in Any on and Pick one, so it's an easy target and lines up with the rows around it. In the edit view you can drag a toggle into or out of a label's group (the Group field still works), and a grouped toggle shows its label's colour on its left edge.
|
||||
- **WP Debug knows the newer nodes.** The Variables tab now groups variables into one card per node that set them, with each row's kind as a small square and a Context module's odds, pin, sweep or branch beside its name, and WP Context Loop, WP Model Info, WP Toggles and the Context Injector each get their own icon, colour (orange, lime, indigo, blue) and row details: the Loop says which frame ran, with its seed, sweep combination and value line; Model Info says where each value came from; Toggles shows each toggle's true / false, or its value text with its `.bool` state and negative (in Pick one too, beside the picked value); the Injector shows each value with its type. Group headers show the node's title beside a Context's codename. A long value folds to three lines (a long list to its first three items, a long negative to one line) until you press **Show all**, and `.axis`, `.bool` and tag values are chips. A trace step now says much more about why it came out that way: a wildcard lists every option with its weight, chance and why it couldn't come up (filtered out, toggled off, excluded by a constraint, weight 0) and the pool after each step, plus its seed, repeats and separator; a combine shows its template and what each `$variable` read; fixed values say which rows were used; a constraint lists the steps it re-weighted; and the Loop, Model Info, Toggles and Injector rows say what they read (model class, matched rule, upstream values, a parent toggle that is off, a template's inputs). The Trace tab stays the run in order: those nodes' rows are numbered steps like any module, with their icon and colour, and module rows carry their kind's icon too. **Show node** now makes the module or field the row came from blink on the canvas, for every node, and a constraint warning links to its step in the trace again. Each row says where its value came from (a Loop frame and its seed and sweep combination, a Model Info detection, a pin or a file-name rule, a Toggles switch set upstream), and their warnings have readable names and sit on the node's row. The Loop's `$iteration` variables no longer show as coming from upstream, a sweep frame's wildcard says **swept**, Match variable says which variable it matched, a pool filter shows the value its `$variable` read, and with a Loop that skips frames the frame picker shows the Loop's own frame number.
|
||||
|
||||
### Wildcards
|
||||
|
||||
@@ -14,6 +21,7 @@
|
||||
|
||||
### Fixes
|
||||
|
||||
- **WP Var nodes read `$name.bool`.** WP Var → Bool (and the other Var nodes) can now read a toggle's switch as `$name.bool`. A toggle with a Value holds that value in its variable, so the **Batches and grids** example's Quick preview stopped picking the small image size. Its Var → Bool now reads `$quick_preview.bool`.
|
||||
- **WP Toggles variables reach the Assembler.** The Pick one variable (like `$time`) never showed in a downstream Prompt Assembler, even with its box ticked, so the Assembler said "No upstream variables yet". It shows now, and the box is called **Show in Assembler**. Each toggle also has a globe on its row in the edit view, on by default: it keeps the toggle's true/false variable internal, for rules only, and turning it off lets the Assembler print it, in Any on and Pick one alike. **Show** now hides the `$variable` under the name in the edit view too, so you can see what it does.
|
||||
- **Pop-up panels in the manager have their padding back.** Since the move to Tailwind 4, the manager's pop-up panels (the Test Runner's **Add to stack** picker and others built on the same panel) lost their inner padding, so the search box, kind chips, match count and Sort sat flush against the panel's edges. They have room on every side again.
|
||||
- **Empty wildcard pool.** A wildcard whose options have no tags used to open an empty Pool. It now says why: there are no tags to filter by, so every enabled option can roll, and tags added in the library editor (Sub-Categories) show up there as pills. Advanced says the same instead of an empty palette, and the Pool's expand button is no longer highlighted when there is nothing to open.
|
||||
|
||||
@@ -1183,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",
|
||||
@@ -1748,15 +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.\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 (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. 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."
|
||||
"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. Internal until you turn off the row's globe. Show puts it under the name.\nStyle\nSwitch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Greyed out in Pick one, where the Style in the top bar sets every row's look.\nValue (Pick one)\nWhat the picked variable gets when this toggle is picked, instead of its 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.\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. 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."
|
||||
"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."
|
||||
"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",
|
||||
@@ -1764,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 Assembler is on. All rows share one look here: the Style next to those boxes picks Radio (the default), Chip, Switch or Check, and each row's own Style is greyed out."
|
||||
"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",
|
||||
@@ -2089,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",
|
||||
@@ -2101,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",
|
||||
@@ -2204,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",
|
||||
@@ -2666,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.\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 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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
+3
-1
@@ -391,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)
|
||||
|
||||
@@ -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()}
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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 = 11
|
||||
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
|
||||
@@ -51,6 +51,8 @@ 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,
|
||||
|
||||
@@ -5,6 +5,8 @@ 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
|
||||
@@ -39,6 +41,7 @@ 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,
|
||||
@@ -59,6 +62,13 @@ _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_]"
|
||||
@@ -263,6 +273,21 @@ def uses_filter_variables(node: Any) -> bool:
|
||||
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)
|
||||
@@ -271,6 +296,8 @@ 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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -959,10 +1018,12 @@ class WildcardHandler(ModuleHandler):
|
||||
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
|
||||
@@ -1007,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),
|
||||
@@ -1055,6 +1126,10 @@ class WildcardHandler(ModuleHandler):
|
||||
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: ""}
|
||||
@@ -1088,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.
|
||||
@@ -1131,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
|
||||
@@ -1261,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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
+64
-3
@@ -76,6 +76,19 @@ class Toggle:
|
||||
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
|
||||
@@ -118,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,
|
||||
@@ -129,6 +145,9 @@ def parse_config(raw: Any) -> Config:
|
||||
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,
|
||||
@@ -245,20 +264,62 @@ 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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -376,10 +376,15 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// syntax ride in the note's lazy editor chunk (dev was already ~636.9 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 -> 660 KB on 2026-11-12: every docs screenshot can go in a WP
|
||||
// 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.
|
||||
const TOTAL_LIMIT = 660 * 1024; // 660 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -34,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
|
||||
@@ -901,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"
|
||||
@@ -920,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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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__: [{
|
||||
|
||||
@@ -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. */
|
||||
@@ -286,6 +383,12 @@ export const WARNING_LABELS: Record<string, string> = {
|
||||
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> = {
|
||||
@@ -319,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 {
|
||||
@@ -361,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";
|
||||
@@ -427,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);
|
||||
@@ -446,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}`;
|
||||
}
|
||||
@@ -469,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 {
|
||||
@@ -540,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
|
||||
@@ -576,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,
|
||||
@@ -595,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,
|
||||
};
|
||||
});
|
||||
@@ -619,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";
|
||||
@@ -630,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,
|
||||
@@ -715,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),
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -197,6 +197,7 @@ function resetRules(): void {
|
||||
:class="{ 'is-pinned': isPinned(row.key), 'is-empty': !row.value && !showsInput(row.key) }"
|
||||
:data-test="`mi-var-${row.key}`"
|
||||
:data-wp-socket="row.key"
|
||||
:data-wp-vars="row.variable.replace(/^\$/, '')"
|
||||
>
|
||||
<span class="wp-mi__var-name" :title="`${row.variable}: ${ROW_TOOLTIPS[row.key]}`">{{ row.label }}</span>
|
||||
<Input
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* the node's own card (its look, tone, colour, title and width); a label
|
||||
* shows its tag and the hover card that holds its text. It edits a draft
|
||||
* (text + pictures); Done writes it to the node, Cancel drops it. The
|
||||
* frame is the kit's Modal.
|
||||
* frame is the kit's Modal, its header tinted in the note's colour.
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
@@ -67,7 +67,7 @@ function onKey(ev: KeyboardEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="true" size="lg" @update:open="onOpen" @keydown="onKey">
|
||||
<Modal :open="true" size="lg" :accent="toneCss || 'var(--wp-accent)'" icon="align-left" @update:open="onOpen" @keydown="onKey">
|
||||
<template #header="{ titleId }">
|
||||
<div class="wp-note-tx__head">
|
||||
<div class="wp-note-tx__title-row">
|
||||
@@ -137,8 +137,8 @@ function onKey(ev: KeyboardEvent): void {
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 6px;
|
||||
border-radius: min(2px, var(--wp-radius-cap, 999px));
|
||||
background: color-mix(in srgb, var(--wp-accent) 22%, transparent);
|
||||
color: var(--wp-accent);
|
||||
background: color-mix(in srgb, var(--wp-modal-accent, var(--wp-accent)) 22%, transparent);
|
||||
color: var(--wp-modal-accent, var(--wp-accent));
|
||||
}
|
||||
.wp-note-tx__sub { font-size: 11px; color: var(--wp-text-dim); margin-top: 3px; }
|
||||
.wp-note-tx__hint { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; font-size: 11px; color: var(--wp-text-dim); }
|
||||
|
||||
@@ -7,7 +7,7 @@ import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import { deriveLoopSeeds, parseSeedLocks, type SeedStrategy } from "./seed-derive";
|
||||
import SeedLockRow from "./SeedLockRow.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
nodeName: string;
|
||||
@@ -141,17 +141,14 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="true" label="Iteration seeds" @close="emit('close')">
|
||||
<Modal :open="true" size="md" flush accent="var(--wp-accent)" icon="list" @update:open="(v: boolean) => { if (!v) emit('close'); }">
|
||||
<template #header="{ titleId }">
|
||||
<div class="sm__title-block">
|
||||
<div class="sm__title-row"><span :id="titleId" class="sm__name">{{ nodeName }}</span><span class="sm__chip">Iteration Seeds</span></div>
|
||||
<div class="sm__sub">base {{ baseSeed }} · {{ strategy }} · {{ Math.max(1, count) }} iterations</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sm" data-test="seed-modal">
|
||||
<div class="sm__head">
|
||||
<span class="sm__head-icon"><i class="pi pi-list" aria-hidden="true" /></span>
|
||||
<div class="sm__title-block">
|
||||
<div class="sm__title-row"><span class="sm__name">{{ nodeName }}</span><span class="sm__chip">Iteration Seeds</span></div>
|
||||
<div class="sm__sub">base {{ baseSeed }} · {{ strategy }} · {{ Math.max(1, count) }} iterations</div>
|
||||
</div>
|
||||
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="seed-modal-close" @click="emit('close')" />
|
||||
</div>
|
||||
|
||||
<div class="sm__bar">
|
||||
<span class="sm__count">{{ Math.max(1, count) }} iterations</span>
|
||||
<span class="sm__strat">{{ strategy }}</span>
|
||||
@@ -200,34 +197,32 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sm__foot">
|
||||
<span class="sm__foot-hint"><Kbd keys="Esc" size="sm" /> to close · locks save with the node</span>
|
||||
<span class="sm__foot-spacer" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline" :class="{ 'sm__done': pasted }" :icon="pasted ? 'pi-check' : 'pi-clipboard'"
|
||||
data-test="mx-seed-paste" hint="Replace all locks from copied text" @click="pasteAll"
|
||||
>{{ pasted ? "Pasted" : "Paste" }}</Button>
|
||||
<Button size="sm" variant="primary" data-test="seed-modal-done" @click="emit('close')">Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
<template #footer>
|
||||
<span class="sm__foot-hint"><Kbd keys="Esc" size="sm" /> to close · locks save with the node</span>
|
||||
<span class="sm__foot-spacer" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline" :class="{ 'sm__done': pasted }" :icon="pasted ? 'pi-check' : 'pi-clipboard'"
|
||||
data-test="mx-seed-paste" hint="Replace all locks from copied text" @click="pasteAll"
|
||||
>{{ pasted ? "Pasted" : "Paste" }}</Button>
|
||||
<Button size="sm" variant="primary" data-test="seed-modal-done" @click="emit('close')">Done</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 560px, up from 486px: the toolbar (iterations · strategy · N locked · N
|
||||
/* The kit Modal at size md (560px), up from 486px: the toolbar (iterations · strategy · N locked · N
|
||||
* bypassed · Lock all · Unlock all · Copy) and each row's three toggles were
|
||||
* being squeezed into near-square boxes to fit. Widening is the cheaper fix
|
||||
* than re-organising the toolbar. */
|
||||
.sm { width: 560px; max-width: 100%; max-height: 86vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: min(6px, var(--wp-radius-cap, 999px)); overflow: hidden; color: var(--wp-text); font-size: 12px; font-family: var(--wp-font-sans, sans-serif); box-shadow: var(--wp-shadow-lg); }
|
||||
.sm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: color-mix(in srgb, var(--wp-accent) 9%, var(--wp-bg2)); }
|
||||
.sm__head-icon { color: var(--wp-accent); width: 24px; display: flex; justify-content: center; }
|
||||
.sm { display: flex; flex-direction: column; color: var(--wp-text); font-size: 12px; font-family: var(--wp-font-sans, sans-serif); }
|
||||
.sm__title-block { flex: 1; min-width: 0; }
|
||||
.sm__title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.sm__name { font: 600 13px var(--wp-font-sans); color: var(--wp-text); }
|
||||
.sm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent); }
|
||||
.sm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-modal-accent, var(--wp-accent)) 22%, transparent); color: var(--wp-modal-accent, var(--wp-accent)); }
|
||||
.sm__sub { font: 400 10px var(--wp-font-mono); color: var(--wp-text-dim, var(--wp-text3)); margin-top: 3px; }
|
||||
.sm__bar { display: flex; align-items: center; gap: 8px; padding: 9px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); }
|
||||
.sm__bar { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 8px; padding: 9px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: var(--wp-bg-2); }
|
||||
.sm__count { font: 600 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.sm__strat { font: 600 9px var(--wp-font-mono); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: min(3px, var(--wp-radius-cap, 999px)); color: var(--wp-text-muted, var(--wp-text2)); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); }
|
||||
.sm__lockcount { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: min(3px, var(--wp-radius-cap, 999px)); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.12); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
@@ -235,9 +230,8 @@ onBeforeUnmount(() => {
|
||||
.sm__done, .sm__done:hover { border-color: var(--wp-green); color: var(--wp-green); }
|
||||
.sm__hint { display: flex; align-items: center; gap: 7px; padding: 7px 14px; flex-shrink: 0; font: 11px var(--wp-font-sans); color: color-mix(in srgb, var(--wp-amber) 75%, var(--wp-text-muted)); background: color-mix(in srgb, var(--wp-amber) 9%, transparent); border-bottom: 1px solid var(--wp-border); }
|
||||
.sm__hint .pi { flex-shrink: 0; color: var(--wp-amber); }
|
||||
.sm__list { overflow-y: auto; padding: 6px; flex: 1; min-height: 0; }
|
||||
.sm__list { padding: 6px; }
|
||||
.sm__inactive-label { padding: 8px 8px 4px; margin-top: 4px; border-top: 1px dashed var(--wp-border); font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .08em; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.sm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; flex-shrink: 0; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
|
||||
.sm__foot-hint { font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.sm__foot-spacer { flex: 1; }
|
||||
</style>
|
||||
|
||||
@@ -351,6 +351,10 @@ body.wp-color-muted {
|
||||
--wp-kind-derivation: oklch(from #fbbf24 l calc(c * 0.55) h);
|
||||
--wp-kind-constraint: oklch(from #f472b6 l calc(c * 0.55) h);
|
||||
--wp-kind-fixed: oklch(from #22d3ee l calc(c * 0.55) h);
|
||||
--wp-kind-injector: oklch(from #60a5fa l calc(c * 0.55) h);
|
||||
--wp-kind-loop: oklch(from #fb923c l calc(c * 0.55) h);
|
||||
--wp-kind-model: oklch(from #a3e635 l calc(c * 0.55) h);
|
||||
--wp-kind-toggle: oklch(from #818cf8 l calc(c * 0.55) h);
|
||||
}
|
||||
|
||||
body.wp-color-vivid {
|
||||
@@ -367,6 +371,10 @@ body.wp-color-vivid {
|
||||
--wp-kind-derivation: oklch(from #fbbf24 l calc(c * 1.30) h);
|
||||
--wp-kind-constraint: oklch(from #f472b6 l calc(c * 1.30) h);
|
||||
--wp-kind-fixed: oklch(from #22d3ee l calc(c * 1.30) h);
|
||||
--wp-kind-injector: oklch(from #60a5fa l calc(c * 1.30) h);
|
||||
--wp-kind-loop: oklch(from #fb923c l calc(c * 1.30) h);
|
||||
--wp-kind-model: oklch(from #a3e635 l calc(c * 1.30) h);
|
||||
--wp-kind-toggle: oklch(from #818cf8 l calc(c * 1.30) h);
|
||||
}
|
||||
|
||||
/* Light-theme muted/vivid — re-seed the kind-color relative adjustments
|
||||
@@ -384,6 +392,10 @@ body.wp-color-muted .wp-theme-light {
|
||||
--wp-kind-combine: oklch(from #065f46 l calc(c * 0.55) h);
|
||||
--wp-kind-derivation: oklch(from #92400e l calc(c * 0.55) h);
|
||||
--wp-kind-constraint: oklch(from #9d174d l calc(c * 0.55) h);
|
||||
--wp-kind-injector: oklch(from #0369a1 l calc(c * 0.55) h);
|
||||
--wp-kind-loop: oklch(from #9a3412 l calc(c * 0.55) h);
|
||||
--wp-kind-model: oklch(from #3f6212 l calc(c * 0.55) h);
|
||||
--wp-kind-toggle: oklch(from #4338ca l calc(c * 0.55) h);
|
||||
}
|
||||
|
||||
.wp-theme-light body.wp-color-vivid,
|
||||
@@ -393,5 +405,9 @@ body.wp-color-vivid .wp-theme-light {
|
||||
--wp-kind-combine: oklch(from #065f46 l calc(c * 1.30) h);
|
||||
--wp-kind-derivation: oklch(from #92400e l calc(c * 1.30) h);
|
||||
--wp-kind-constraint: oklch(from #9d174d l calc(c * 1.30) h);
|
||||
--wp-kind-injector: oklch(from #0369a1 l calc(c * 1.30) h);
|
||||
--wp-kind-loop: oklch(from #9a3412 l calc(c * 1.30) h);
|
||||
--wp-kind-model: oklch(from #3f6212 l calc(c * 1.30) h);
|
||||
--wp-kind-toggle: oklch(from #4338ca l calc(c * 1.30) h);
|
||||
}
|
||||
} /* end @layer wp-extension */
|
||||
|
||||
@@ -127,6 +127,12 @@
|
||||
* module family). Slate-blue chosen to read as "external input"
|
||||
* without clashing with any module kind. */
|
||||
--wp-kind-injector: #60a5fa;
|
||||
/* The other context-chain nodes WP Debug traces: Context Loop
|
||||
* (orange: frames), Model Info (lime: the checkpoint) and Toggles
|
||||
* (indigo: switches). Picked to stay apart from the module kinds. */
|
||||
--wp-kind-loop: #fb923c;
|
||||
--wp-kind-model: #a3e635;
|
||||
--wp-kind-toggle: #818cf8;
|
||||
|
||||
/* Type + spacing scale, mirrored from the SPA's tokens.css.
|
||||
*
|
||||
@@ -393,6 +399,10 @@
|
||||
--wp-kind-constraint: #9d174d; /* pink-800 — chip 5.29:1 on white */
|
||||
/* Fixed moved off cyan-800 — too close to combine emerald-800 on light bg. */
|
||||
--wp-kind-fixed: #1d4ed8; /* blue-700 — chip 6.61:1 on white */
|
||||
--wp-kind-injector: #0369a1; /* sky-700 */
|
||||
--wp-kind-loop: #9a3412; /* orange-800 */
|
||||
--wp-kind-model: #3f6212; /* lime-800 */
|
||||
--wp-kind-toggle: #4338ca; /* indigo-700 */
|
||||
|
||||
/* Constraint source/target — darker shades for legibility on light. */
|
||||
--wp-constraint-source: #7c3aed; /* violet-600 — chip 6.16:1 on white */
|
||||
@@ -468,6 +478,9 @@
|
||||
.wp-kind-chip--derivation { background: color-mix(in oklab, var(--wp-kind-derivation) 22%, transparent); color: var(--wp-kind-derivation); }
|
||||
.wp-kind-chip--constraint { background: color-mix(in oklab, var(--wp-kind-constraint) 22%, transparent); color: var(--wp-kind-constraint); }
|
||||
.wp-kind-chip--injector { background: color-mix(in oklab, var(--wp-kind-injector) 22%, transparent); color: var(--wp-kind-injector); }
|
||||
.wp-kind-chip--loop { background: color-mix(in oklab, var(--wp-kind-loop) 22%, transparent); color: var(--wp-kind-loop); }
|
||||
.wp-kind-chip--model { background: color-mix(in oklab, var(--wp-kind-model) 22%, transparent); color: var(--wp-kind-model); }
|
||||
.wp-kind-chip--toggle { background: color-mix(in oklab, var(--wp-kind-toggle) 22%, transparent); color: var(--wp-kind-toggle); }
|
||||
|
||||
/* Pairing badge — appears when multiple constraints share a target
|
||||
* wildcard. Each constraint module and its claimed downstream target
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* One WP Toggles toggle's settings, in a kit `Modal`: the node
|
||||
* keeps its edit view (mode, order, globe, remove, labels) and the row's
|
||||
* pencil opens this. It edits a draft of the row; Done hands the draft and
|
||||
* its group back to `TogglesWidget.vue`, which stores it (a name change
|
||||
* carries a variable that followed the old name along). The strip on top is
|
||||
* the row as the node will draw it.
|
||||
*/
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import Input from "@/kit/Input.vue";
|
||||
import WpSegmented from "@/kit/WpSegmented.vue";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import WpSelect from "@/kit/WpSelect.vue";
|
||||
import Combobox from "@/kit/Combobox.vue";
|
||||
import ColorField from "@/kit/ColorField.vue";
|
||||
import IconPicker from "@/kit/IconPicker.vue";
|
||||
import { vTooltip } from "@/kit/tooltip";
|
||||
import { vToggleRow } from "@/kit/toggle-row";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ConfirmModal from "@/kit/ConfirmModal.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Kbd from "@/kit/Kbd.vue";
|
||||
import Banner from "@/kit/Banner.vue";
|
||||
import ToggleRowView from "./ToggleRow.vue";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import type { PickStyle, ToggleControl, ToggleRow, ToggleStyle } from "../../extension/toggles";
|
||||
import type { ControlState } from "../../extension/toggle-control";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
import { INLINE_PICKS_NOTE, hasInlinePicks, tokenize } from "../context/editors/_shared/preview-tokens";
|
||||
|
||||
const RichTextInput = defineAsyncComponent(() => import("../../manager/components/RichTextInput.vue"));
|
||||
const NegativeField = defineAsyncComponent(() => import("../../manager/components/NegativeField.vue"));
|
||||
|
||||
interface Choice { value: string; label: string }
|
||||
|
||||
const props = defineProps<{
|
||||
t: ToggleRow;
|
||||
pickOne: boolean;
|
||||
pickStyle?: PickStyle | undefined;
|
||||
shownOn: boolean;
|
||||
effOn: boolean;
|
||||
parentName: string;
|
||||
blocked: boolean;
|
||||
problem?: string | undefined;
|
||||
needsOptions: Choice[];
|
||||
/** Label groups to move to; empty hides the field. */
|
||||
groupOptions: Choice[];
|
||||
groupId: string;
|
||||
/** All 16 outputs are taken (an unpinned toggle can't get one). */
|
||||
outputsFull: boolean;
|
||||
controlState?: ControlState | null | undefined;
|
||||
canvasGroups?: string[] | undefined;
|
||||
varSuggestions?: string[] | undefined;
|
||||
varProducers?: Map<string, VarProducerLike> | undefined;
|
||||
nodeTitle?: string | undefined;
|
||||
}>();
|
||||
const emit = defineEmits<{ done: [next: ToggleRow, groupId: string]; cancel: [] }>();
|
||||
|
||||
// Plain copies: the props are reactive proxies.
|
||||
const d = ref<ToggleRow>(JSON.parse(JSON.stringify(props.t)) as ToggleRow);
|
||||
const draftGroup = ref(props.groupId);
|
||||
const dirty = computed(() => JSON.stringify(d.value) !== JSON.stringify(props.t) || draftGroup.value !== props.groupId);
|
||||
|
||||
function patch(p: Partial<ToggleRow>): void {
|
||||
const next: Record<string, unknown> = { ...d.value, ...p };
|
||||
// An undefined field is stored as absent.
|
||||
for (const k of Object.keys(next)) if (next[k] === undefined) delete next[k];
|
||||
d.value = next as unknown as ToggleRow;
|
||||
}
|
||||
function value(ev: Event): string {
|
||||
return (ev.target as HTMLInputElement).value.trim();
|
||||
}
|
||||
|
||||
const CONTROLS = [
|
||||
{ value: "", label: "Nothing", hint: "Off only writes false and sends False" },
|
||||
{ value: "mute", label: "Mute", hint: "Off also mutes the nodes wired into the dot in front of the row" },
|
||||
{ value: "bypass", label: "Bypass", hint: "Off also bypasses the nodes wired into the dot in front of the row" },
|
||||
];
|
||||
function setControl(v: string): void {
|
||||
patch(v ? { control: v as ToggleControl } : { control: undefined, groups: undefined });
|
||||
}
|
||||
function setGroups(v: string[] | string | null): void {
|
||||
const list = Array.isArray(v) ? v : [];
|
||||
patch({ groups: list.length ? list : undefined });
|
||||
}
|
||||
|
||||
const STYLES: { value: ToggleStyle; label: string; hint: string }[] = [
|
||||
{ value: "switch", label: "Switch", hint: "A sliding switch" },
|
||||
{ value: "checkbox", label: "Check", hint: "A checkbox" },
|
||||
{ value: "buttons", label: "Buttons", hint: "Two buttons with your own words, e.g. Day / Night" },
|
||||
{ value: "chip", label: "Chip", hint: "A small chip with the name, lit when on" },
|
||||
];
|
||||
|
||||
/** `$names` the Value or Off value reads that nothing upstream (nor this
|
||||
* node) sets. Only with a graph to look at (`varSuggestions` given). */
|
||||
const missingVars = computed(() => {
|
||||
const known = props.varSuggestions;
|
||||
const text = `${d.value.value ?? ""} ${d.value.off_value ?? ""}`;
|
||||
if (!known || !text.includes("$")) return [];
|
||||
const names = tokenize(text, "assembler")
|
||||
.filter((tok) => tok.kind === "var" && tok.varName)
|
||||
.map((tok) => tok.varName ?? "");
|
||||
return [...new Set(names)].filter((n) => !known.includes(n));
|
||||
});
|
||||
const missingNote = computed(() => {
|
||||
const list = missingVars.value.map((v) => "$" + v).join(", ");
|
||||
return missingVars.value.length > 1
|
||||
? `${list}: nothing upstream sets these, so they print nothing`
|
||||
: `${list}: nothing upstream sets this, so it prints nothing`;
|
||||
});
|
||||
|
||||
/** A colour field's value: the colour, or `none`. */
|
||||
function colorValue(v: string | undefined): string {
|
||||
return v ?? "none";
|
||||
}
|
||||
/** A colour field's pick as a stored colour (`none` clears it). */
|
||||
function pickColor(v: string): string | undefined {
|
||||
const c = v.slice(0, 7).toLowerCase();
|
||||
return /^#[0-9a-f]{6}$/.test(c) ? c : undefined;
|
||||
}
|
||||
|
||||
/** The preview's switch: Any on shows (and flips) the draft's own choice. */
|
||||
const previewOn = computed(() => (props.pickOne ? props.shownOn : d.value.on));
|
||||
/** The header wears the toggle's own looks: icon colour (else its control
|
||||
* colour), control colour on the chip, name colour on the name line. */
|
||||
const headStyle = computed(() => {
|
||||
const s: Record<string, string> = {};
|
||||
if (d.value.icon_color) s["--wp-tgw-icon"] = d.value.icon_color;
|
||||
if (d.value.tint) s["--wp-tgw-tint"] = d.value.tint;
|
||||
if (d.value.text_color) s["--wp-tgw-text"] = d.value.text_color;
|
||||
return s;
|
||||
});
|
||||
function previewSet(on: boolean): void {
|
||||
if (!props.pickOne) patch({ on });
|
||||
}
|
||||
|
||||
// Closing with changes (Escape, ×, Cancel) asks first.
|
||||
const asking = ref(false);
|
||||
function cancel(): void {
|
||||
if (dirty.value) asking.value = true;
|
||||
else emit("cancel");
|
||||
}
|
||||
function onKey(ev: KeyboardEvent): void {
|
||||
// Ctrl/Cmd+Enter is Done from anywhere in the window.
|
||||
if (!asking.value && ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) {
|
||||
ev.preventDefault();
|
||||
emit("done", d.value, draftGroup.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="true" size="md" @update:open="(v: boolean) => { if (!v) cancel(); }" @keydown="onKey">
|
||||
<template #header="{ titleId }">
|
||||
<i class="pi wp-tgw__icon" :class="d.icon ? `pi-${d.icon}` : 'pi-sliders-h'" :style="headStyle" aria-hidden="true" data-test="tg-window-icon" />
|
||||
<div class="wp-tgw__titles">
|
||||
<div class="wp-tgw__title-row">
|
||||
<h3 :id="titleId" class="wp-tgw__title">{{ nodeTitle || 'WP Toggles' }}</h3>
|
||||
<span class="wp-tgw__chip" :style="headStyle">Toggle</span>
|
||||
</div>
|
||||
<div class="wp-tgw__sub" :style="headStyle" data-test="tg-window-sub">
|
||||
<span class="wp-tgw__subname">{{ d.name }}</span>
|
||||
<template v-if="d.var">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span class="wp-tgw__subvar" :class="d.text_color ? 'has-text' : varColorClass(d.var)">${{ d.var }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="wp-tgw__preview" data-test="tg-window-preview">
|
||||
<span class="wp-tgw__cap">ON THE NODE</span>
|
||||
<ToggleRowView
|
||||
:t="d"
|
||||
:pick-one="pickOne"
|
||||
:pick-style="pickStyle"
|
||||
:shown-on="previewOn"
|
||||
:eff-on="pickOne ? effOn : d.on && !blocked"
|
||||
:parent-name="parentName"
|
||||
:blocked="blocked"
|
||||
:indent="0"
|
||||
:editing="false"
|
||||
:needs-options="[]"
|
||||
:group-options="[]"
|
||||
group-id=""
|
||||
:outputs-full="outputsFull"
|
||||
:editable="false"
|
||||
:control-state="controlState"
|
||||
@set-on="previewSet"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgw__body">
|
||||
<div class="wp-tgr__edit" :data-test="`tg-editor-${d.id}`">
|
||||
<label class="wp-tgr__field">
|
||||
<span>Name <em>on the row and its output</em></span>
|
||||
<Input size="sm" :model-value="d.name" spellcheck="false" data-test="tg-name" @keydown.stop @change="patch({ name: value($event) })" />
|
||||
</label>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Variable <em>{{ d.value || d.off_value ? `its Value while on, its Off value while off; $${d.var || 'name'}.bool is true or false` : 'true or false; the globe keeps it internal' }}</em></span>
|
||||
<span class="wp-tgr__var-row">
|
||||
<Input
|
||||
size="sm"
|
||||
mono
|
||||
prefix="$"
|
||||
class="wp-tgr__var"
|
||||
:class="d.var ? varColorClass(d.var) : undefined"
|
||||
:tone="problem ? 'warn' : undefined"
|
||||
:model-value="d.var"
|
||||
placeholder="variable_name"
|
||||
spellcheck="false"
|
||||
data-test="tg-var"
|
||||
@keydown.stop
|
||||
@change="patch({ var: value($event).replace(/^\$/, '') })"
|
||||
/>
|
||||
<span v-tooltip="'Show the $variable under the name'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!d.hide_var"
|
||||
size="sm"
|
||||
aria-label="Show the variable under the name"
|
||||
data-test="tg-show-var"
|
||||
@update:model-value="(v: boolean) => patch({ hide_var: v ? undefined : true })"
|
||||
/>
|
||||
<span aria-hidden="true">Show</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="wp-tgr__field">
|
||||
<span>Looks <em>icon and colours; None keeps the theme's</em></span>
|
||||
<span class="wp-tgr__looks">
|
||||
<IconPicker
|
||||
:model-value="d.icon ?? null"
|
||||
all
|
||||
size="sm"
|
||||
:color="d.icon_color"
|
||||
aria-label="Icon"
|
||||
data-test="tg-icon"
|
||||
@update:model-value="(v: string | null) => patch({ icon: v || undefined })"
|
||||
/>
|
||||
<span class="wp-tgr__look">
|
||||
<ColorField :model-value="colorValue(d.icon_color)" allow-none presets compact aria-label="Icon colour" data-test="tg-icon-color" @update:model-value="(v: string) => patch({ icon_color: pickColor(v) })" />
|
||||
Icon
|
||||
</span>
|
||||
<span class="wp-tgr__look">
|
||||
<ColorField :model-value="colorValue(d.text_color)" allow-none presets compact aria-label="Name colour" data-test="tg-text-color" @update:model-value="(v: string) => patch({ text_color: pickColor(v) })" />
|
||||
Name
|
||||
</span>
|
||||
<span class="wp-tgr__look">
|
||||
<ColorField :model-value="colorValue(d.tint)" allow-none presets compact aria-label="Control colour" data-test="tg-tint" @update:model-value="(v: string) => patch({ tint: pickColor(v) })" />
|
||||
Control
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="wp-tgr__field">
|
||||
<span>Value <em>{{ pickOne ? "the variable and the pick get this, not true or the name" : "the variable gets this while on, instead of true" }}</em></span>
|
||||
<RichTextInput
|
||||
class="wp-tgr__value"
|
||||
:model-value="d.value ?? ''"
|
||||
surface="combine"
|
||||
multiline
|
||||
:rows="2"
|
||||
:placeholder="pickOne ? d.name : 'true'"
|
||||
:var-suggestions="varSuggestions ?? []"
|
||||
:var-producers="varProducers"
|
||||
:graph-aware="!!varProducers"
|
||||
aria-label="Value"
|
||||
data-test="tg-value"
|
||||
@update:model-value="(v: string) => patch({ value: v.trim() ? v : undefined })"
|
||||
/>
|
||||
<NegativeField
|
||||
:model-value="d.negative"
|
||||
surface="combine"
|
||||
label="toggle"
|
||||
test-id="tg-negative"
|
||||
:var-suggestions="varSuggestions ?? []"
|
||||
:var-producers="varProducers"
|
||||
:graph-aware="!!varProducers"
|
||||
@update:model-value="(v: string | undefined) => patch({ negative: v })"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!pickOne" class="wp-tgr__field">
|
||||
<span>Off value <em>the variable gets this while off; empty prints nothing</em></span>
|
||||
<RichTextInput
|
||||
class="wp-tgr__value"
|
||||
:model-value="d.off_value ?? ''"
|
||||
surface="combine"
|
||||
multiline
|
||||
:rows="2"
|
||||
:placeholder="d.value ? 'nothing' : 'false'"
|
||||
:var-suggestions="varSuggestions ?? []"
|
||||
:var-producers="varProducers"
|
||||
:graph-aware="!!varProducers"
|
||||
aria-label="Off value"
|
||||
data-test="tg-off-value"
|
||||
@update:model-value="(v: string) => patch({ off_value: v.trim() ? v : undefined })"
|
||||
/>
|
||||
<NegativeField
|
||||
:model-value="d.off_negative"
|
||||
surface="combine"
|
||||
label="toggle while off"
|
||||
test-id="tg-off-negative"
|
||||
:var-suggestions="varSuggestions ?? []"
|
||||
:var-producers="varProducers"
|
||||
:graph-aware="!!varProducers"
|
||||
@update:model-value="(v: string | undefined) => patch({ off_negative: v })"
|
||||
/>
|
||||
</div>
|
||||
<Banner v-if="missingVars.length" tone="warn" size="sm" data-test="tg-missing-vars">{{ missingNote }}</Banner>
|
||||
<Banner v-if="hasInlinePicks(`${d.value ?? ''} ${d.off_value ?? ''}`)" tone="warn" size="sm" data-test="tg-inline-picks">{{ INLINE_PICKS_NOTE }}</Banner>
|
||||
<div v-if="!pickOne" class="wp-tgr__field">
|
||||
<span>Style</span>
|
||||
<WpSegmented
|
||||
:model-value="d.style"
|
||||
:options="STYLES"
|
||||
size="sm"
|
||||
block
|
||||
aria-label="Style"
|
||||
test-prefix="tg-style"
|
||||
@update:model-value="(v: ToggleStyle) => patch({ style: v })"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="d.style === 'buttons' && !pickOne" class="wp-tgr__two">
|
||||
<label class="wp-tgr__field">
|
||||
<span>On word</span>
|
||||
<Input size="sm" :model-value="d.on_label ?? ''" placeholder="On" data-test="tg-on-label" @keydown.stop @change="patch({ on_label: value($event) || undefined })" />
|
||||
</label>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Off word</span>
|
||||
<Input size="sm" :model-value="d.off_label ?? ''" placeholder="Off" data-test="tg-off-label" @keydown.stop @change="patch({ off_label: value($event) || undefined })" />
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!pickOne" class="wp-tgr__field">
|
||||
<span>Needs <em>only on while that toggle is on</em></span>
|
||||
<WpSelect
|
||||
:model-value="d.needs ?? ''"
|
||||
:options="needsOptions"
|
||||
aria-label="Needs"
|
||||
data-test="tg-needs"
|
||||
@update:model-value="(v: string) => patch({ needs: v || undefined })"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgr__field">
|
||||
<span>When off <em>also mute or bypass other nodes</em></span>
|
||||
<WpSegmented
|
||||
:model-value="d.control ?? ''"
|
||||
:options="CONTROLS"
|
||||
size="sm"
|
||||
block
|
||||
aria-label="When off"
|
||||
test-prefix="tg-control"
|
||||
@update:model-value="setControl"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="d.control" v-tooltip="'Mute or bypass while the toggle is ON instead, for a node used only as a switchboard'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!!d.invert"
|
||||
size="sm"
|
||||
aria-label="Invert"
|
||||
data-test="tg-invert"
|
||||
@update:model-value="(v: boolean) => patch({ invert: v ? true : undefined })"
|
||||
/>
|
||||
<span aria-hidden="true">Invert: {{ d.control === "mute" ? "mute" : "bypass" }} while on</span>
|
||||
</span>
|
||||
<div v-if="d.control" class="wp-tgr__field">
|
||||
<span>Canvas groups <em>wire nodes into the dot in front of the row, or pick groups</em></span>
|
||||
<Combobox
|
||||
:model-value="d.groups ?? []"
|
||||
multiple
|
||||
:options="(canvasGroups ?? []).map((g) => ({ value: g, label: g }))"
|
||||
placeholder="No groups"
|
||||
size="sm"
|
||||
aria-label="Canvas groups"
|
||||
data-test="tg-groups"
|
||||
@update:model-value="setGroups"
|
||||
/>
|
||||
</div>
|
||||
<span v-tooltip="'When the upstream Context holds this variable as a yes/no word (true, on, 1, no …), it sets the switch'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!!d.upstream"
|
||||
size="sm"
|
||||
aria-label="Upstream can set it"
|
||||
data-test="tg-upstream"
|
||||
@update:model-value="(v: boolean) => patch({ upstream: v ? true : undefined })"
|
||||
/>
|
||||
<span aria-hidden="true">Upstream can set it</span>
|
||||
</span>
|
||||
<div v-if="groupOptions.length" class="wp-tgr__field">
|
||||
<span>Group</span>
|
||||
<WpSelect
|
||||
:model-value="draftGroup"
|
||||
:options="groupOptions"
|
||||
aria-label="Group"
|
||||
data-test="tg-group"
|
||||
@update:model-value="(v: string) => (draftGroup = v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgr__edit-foot">
|
||||
<span v-tooltip="!d.pin && outputsFull ? 'All 16 outputs are in use' : 'Show a BOOLEAN output for switch nodes'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="d.pin"
|
||||
size="sm"
|
||||
:disabled="!d.pin && outputsFull"
|
||||
aria-label="BOOLEAN output"
|
||||
data-test="tg-pin"
|
||||
@update:model-value="(v: boolean) => patch({ pin: v })"
|
||||
/>
|
||||
<span aria-hidden="true">BOOLEAN output</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="wp-tgw__hint"><Kbd keys="Mod+Enter" size="sm" /> Done · Changes save with the node when you press Done.</span>
|
||||
<Button size="sm" data-test="tg-window-cancel" @click="cancel">Cancel</Button>
|
||||
<Button size="sm" variant="primary" data-test="tg-window-done" @click="emit('done', d, draftGroup)">Done</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
<ConfirmModal
|
||||
:open="asking"
|
||||
title="Discard your changes?"
|
||||
body="What you changed in this window isn't on the node yet."
|
||||
confirm-label="Discard"
|
||||
cancel-label="Keep editing"
|
||||
tone="warn"
|
||||
@confirm="asking = false; emit('cancel')"
|
||||
@cancel="asking = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The row as the node draws it stays on top while the settings scroll. */
|
||||
.wp-tgw__preview {
|
||||
position: sticky;
|
||||
top: calc(-1 * var(--wp-space-5));
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: calc(-1 * var(--wp-space-5)) calc(-1 * var(--wp-space-5)) var(--wp-space-5);
|
||||
padding: 10px var(--wp-space-5);
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
}
|
||||
.wp-tgw__icon { color: var(--wp-tgw-icon, var(--wp-tgw-tint, var(--wp-accent))); font-size: 14px; }
|
||||
.wp-tgw__titles { flex: 1; min-width: 0; }
|
||||
.wp-tgw__title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.wp-tgw__title { margin: 0; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wp-tgw__chip {
|
||||
flex: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: min(2px, var(--wp-radius-cap, 999px));
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
background: color-mix(in srgb, var(--wp-tgw-tint, var(--wp-accent)) 22%, transparent);
|
||||
color: var(--wp-tgw-tint, var(--wp-accent));
|
||||
}
|
||||
.wp-tgw__subname { color: var(--wp-tgw-text, var(--wp-text)); font-weight: 600; }
|
||||
.wp-tgw__subvar { font-family: var(--wp-font-mono, monospace); }
|
||||
.wp-tgw__subvar.has-text { color: var(--wp-tgw-text); }
|
||||
.wp-tgw__sub { margin-top: 3px; font-size: 11px; color: var(--wp-text-dim); }
|
||||
.wp-tgw__hint { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; font-size: 11px; color: var(--wp-text-dim); }
|
||||
.wp-tgw__cap { font: 600 9px var(--wp-font-sans, sans-serif); letter-spacing: 0.08em; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
/* The window is the panel: no second box around it. */
|
||||
.wp-tgw__body .wp-tgr__edit { padding: 0; border: 0; background: none; }
|
||||
.wp-tgr__edit { display: flex; flex-direction: column; gap: 10px; }
|
||||
.wp-tgr__field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.wp-tgr__field > span { font-size: 10.5px; font-weight: 600; color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-tgr__field em { font-style: normal; font-weight: 400; color: var(--wp-text-dim, var(--wp-text3)); margin-left: 4px; }
|
||||
.wp-tgr__two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
|
||||
.wp-tgr__edit-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.wp-tgr__check { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; cursor: pointer; }
|
||||
.wp-tgr__var-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.wp-tgr__var-row .wp-tgr__var { flex: 1 1 0; min-width: 80px; }
|
||||
.wp-tgr__looks { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
|
||||
.wp-tgr__look { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
</style>
|
||||
@@ -1,31 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* One toggle row of WP Toggles, plus its edit panel when open. Stateless:
|
||||
* One toggle row of WP Toggles (its settings open in `ToggleEditWindow.vue`). Stateless:
|
||||
* the parent (`TogglesWidget.vue`) works out every fact shown here and
|
||||
* applies every change this emits.
|
||||
*
|
||||
* The row carries `data-wp-out="toggle_<out>"` while it has an output, so
|
||||
* `extension/row-outputs.ts` puts that BOOLEAN dot at the row's right edge.
|
||||
*/
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Input from "@/kit/Input.vue";
|
||||
import WpIconButton from "@/kit/WpIconButton.vue";
|
||||
import WpSegmented from "@/kit/WpSegmented.vue";
|
||||
import WpSwitch from "@/kit/WpSwitch.vue";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import WpSelect from "@/kit/WpSelect.vue";
|
||||
import Combobox from "@/kit/Combobox.vue";
|
||||
import { vTooltip } from "@/kit/tooltip";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import { computed, defineAsyncComponent } from "vue";
|
||||
import type { PickStyle, ToggleControl, ToggleRow, ToggleStyle } from "../../extension/toggles";
|
||||
import { computed } from "vue";
|
||||
import type { PickStyle, ToggleRow, ToggleStyle } from "../../extension/toggles";
|
||||
import { ctlInputName, type ControlState } from "../../extension/toggle-control";
|
||||
import { vToggleRow } from "@/kit/toggle-row";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
import { INLINE_PICKS_NOTE, hasInlinePicks, tokenize } from "../context/editors/_shared/preview-tokens";
|
||||
|
||||
// Loaded with the edit panel only: the clean view never needs the editor.
|
||||
const RichTextInput = defineAsyncComponent(() => import("../../manager/components/RichTextInput.vue"));
|
||||
|
||||
interface Choice { value: string; label: string }
|
||||
|
||||
@@ -68,50 +59,26 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
"set-on": [on: boolean];
|
||||
patch: [patch: Partial<ToggleRow>];
|
||||
rename: [name: string];
|
||||
"set-var": [v: string];
|
||||
edit: [];
|
||||
"move-group": [labelId: string];
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
const CONTROLS = [
|
||||
{ value: "", label: "Nothing", hint: "Off only writes false and sends False" },
|
||||
{ value: "mute", label: "Mute", hint: "Off also mutes the nodes wired into the dot in front of the row" },
|
||||
{ value: "bypass", label: "Bypass", hint: "Off also bypasses the nodes wired into the dot in front of the row" },
|
||||
];
|
||||
function setControl(v: string): void {
|
||||
emit("patch", v ? { control: v as ToggleControl } : { control: undefined, groups: undefined });
|
||||
}
|
||||
function setGroups(v: string[] | string | null): void {
|
||||
const list = Array.isArray(v) ? v : [];
|
||||
emit("patch", { groups: list.length ? list : undefined });
|
||||
}
|
||||
|
||||
const STYLES: { value: ToggleStyle; label: string; hint: string }[] = [
|
||||
{ value: "switch", label: "Switch", hint: "A sliding switch" },
|
||||
{ value: "checkbox", label: "Check", hint: "A checkbox" },
|
||||
{ value: "buttons", label: "Buttons", hint: "Two buttons with your own words, e.g. Day / Night" },
|
||||
{ value: "chip", label: "Chip", hint: "A small chip with the name, lit when on" },
|
||||
];
|
||||
|
||||
/** `$names` the Pick one Value reads that nothing upstream (nor this node)
|
||||
* sets. Only with a graph to look at (`varSuggestions` given). */
|
||||
const missingVars = computed(() => {
|
||||
const known = props.varSuggestions;
|
||||
if (!props.pickOne || !known || !props.t.value?.includes("$")) return [];
|
||||
const names = tokenize(props.t.value, "assembler")
|
||||
.filter((tok) => tok.kind === "var" && tok.varName)
|
||||
.map((tok) => tok.varName ?? "");
|
||||
return [...new Set(names)].filter((n) => !known.includes(n));
|
||||
/** The row's icon and name colours as CSS variables (absent: the theme's). */
|
||||
const looks = computed(() => {
|
||||
const s: Record<string, string> = {};
|
||||
if (props.t.icon_color) s["--wp-tg-icon"] = props.t.icon_color;
|
||||
if (props.t.text_color) s["--wp-tg-text"] = props.t.text_color;
|
||||
return s;
|
||||
});
|
||||
const missingNote = computed(() => {
|
||||
const list = missingVars.value.map((v) => "$" + v).join(", ");
|
||||
return missingVars.value.length > 1
|
||||
? `${list}: nothing upstream sets these, so they print nothing`
|
||||
: `${list}: nothing upstream sets this, so it prints nothing`;
|
||||
/** `tint` stands in for the accent the control is drawn with. Set on the
|
||||
* control only, so the row's own buttons (globe) keep the theme's. */
|
||||
const tintStyle = computed(() => {
|
||||
const c = props.t.tint;
|
||||
if (!c) return undefined;
|
||||
const s: Record<string, string> = {};
|
||||
for (const k of ["--wp-accent", "--wp-accent-text", "--wp-accent-500", "--wp-accent-600", "--check-on"]) s[k] = c;
|
||||
return s;
|
||||
});
|
||||
|
||||
/** The control this row draws: Pick one's shared look, else its own style. */
|
||||
const look = computed<ToggleStyle | "radio">(() => (props.pickOne ? props.pickStyle ?? "radio" : props.t.style));
|
||||
|
||||
@@ -126,9 +93,6 @@ function subClass(): string {
|
||||
if (props.parentName) return props.blocked ? "is-warn" : "is-linked";
|
||||
return props.t.var ? varColorClass(props.t.var) : "is-dim";
|
||||
}
|
||||
function value(ev: Event): string {
|
||||
return (ev.target as HTMLInputElement).value.trim();
|
||||
}
|
||||
function internalHint(): string {
|
||||
const v = `$${props.t.var}`;
|
||||
return props.t.visible
|
||||
@@ -141,25 +105,33 @@ function flip(): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-tgr" :style="indent ? { marginLeft: `${indent}px` } : undefined">
|
||||
<div class="wp-tgr" :style="indent ? { marginLeft: `${indent}px` } : undefined" :data-wp-vars="t.var || undefined">
|
||||
<div
|
||||
class="wp-tgr__row"
|
||||
:class="{ 'is-blocked': blocked, 'is-on': effOn, 'is-chip': look === 'chip', 'is-editing': editing }"
|
||||
:class="{ 'is-blocked': blocked, 'is-on': effOn, 'is-chip': look === 'chip', 'is-editing': editing, 'has-icon-color': !!t.icon_color, 'has-text-color': !!t.text_color }"
|
||||
:data-wp-out="t.pin && t.out ? `toggle_${t.out}` : undefined"
|
||||
:data-wp-socket="t.control ? ctlInputName(t.id) : undefined"
|
||||
:data-test="`tg-row-${t.id}`"
|
||||
:style="looks"
|
||||
>
|
||||
<template v-if="look === 'chip'">
|
||||
<span v-tooltip="blocked ? blockedHint() : t.var ? `$${t.var}` : ''" class="wp-tgr__chip">
|
||||
<span v-tooltip="blocked ? blockedHint() : t.var ? `$${t.var}` : ''" class="wp-tgr__chip" :style="tintStyle">
|
||||
<WpSwitch
|
||||
:model-value="shownOn"
|
||||
variant="chip"
|
||||
size="sm"
|
||||
:label="t.name"
|
||||
:icon="t.icon ? `pi-${t.icon}` : undefined"
|
||||
:disabled="blocked"
|
||||
:data-test="`tg-ctl-${t.id}`"
|
||||
@update:model-value="(v: boolean) => emit('set-on', v)"
|
||||
/>
|
||||
>
|
||||
<span class="wp-tgr__chipname">{{ t.name }}</span>
|
||||
<template v-if="t.var && !t.hide_var">
|
||||
<span class="wp-tgr__chipdot" aria-hidden="true">·</span>
|
||||
<span class="wp-tgr__chipvar" :class="varColorClass(t.var)" :data-test="`tg-chip-var-${t.id}`">${{ t.var }}</span>
|
||||
</template>
|
||||
</WpSwitch>
|
||||
</span>
|
||||
<span v-if="parentName" class="wp-tgr__sub" :class="subClass()"><i class="pi pi-link" aria-hidden="true" />{{ sub() }}</span>
|
||||
<i
|
||||
@@ -171,7 +143,11 @@ function flip(): void {
|
||||
:aria-label="controlState.hint"
|
||||
/>
|
||||
</template>
|
||||
<span v-else class="wp-tgr__name">
|
||||
<template v-else>
|
||||
<!-- The icon is a column of its own, centred against the name and
|
||||
its $var, so both lines start at the same edge. -->
|
||||
<i v-if="t.icon" class="pi wp-tgr__icon" :class="`pi-${t.icon}`" aria-hidden="true" :data-test="`tg-icon-${t.id}`" />
|
||||
<span class="wp-tgr__name">
|
||||
<span class="wp-tgr__title">
|
||||
<b>{{ t.name }}</b>
|
||||
<i
|
||||
@@ -188,6 +164,7 @@ function flip(): void {
|
||||
<i v-if="t.upstream" v-tooltip="'Upstream can set it'" class="pi pi-arrow-down-right wp-tgr__up" aria-label="Upstream can set it" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<i
|
||||
v-if="problem"
|
||||
v-tooltip="problem"
|
||||
@@ -195,30 +172,39 @@ function flip(): void {
|
||||
role="img"
|
||||
:aria-label="problem"
|
||||
/>
|
||||
<WpIconButton
|
||||
v-if="editable && t.var"
|
||||
icon="pi-globe"
|
||||
tone="accent"
|
||||
:pressed="!t.visible"
|
||||
:label="t.visible ? 'Mark internal' : 'Unmark internal'"
|
||||
:hint="internalHint()"
|
||||
:data-test="`tg-internal-${t.id}`"
|
||||
@click="emit('patch', { visible: t.visible ? undefined : true })"
|
||||
/>
|
||||
<WpIconButton
|
||||
v-if="editable"
|
||||
icon="pi-pencil"
|
||||
:pressed="editing"
|
||||
:label="`Edit ${t.name}`"
|
||||
:data-test="`tg-edit-${t.id}`"
|
||||
@click="emit('edit')"
|
||||
/>
|
||||
<span v-if="editable" class="wp-tgr__btns">
|
||||
<WpIconButton
|
||||
icon="pi-pencil"
|
||||
:pressed="editing"
|
||||
:label="`Edit ${t.name}`"
|
||||
:data-test="`tg-edit-${t.id}`"
|
||||
@click="emit('edit')"
|
||||
/>
|
||||
<WpIconButton
|
||||
v-if="t.var"
|
||||
icon="pi-globe"
|
||||
tone="accent"
|
||||
:pressed="!t.visible"
|
||||
:label="t.visible ? 'Mark internal' : 'Unmark internal'"
|
||||
:hint="internalHint()"
|
||||
:data-test="`tg-internal-${t.id}`"
|
||||
@click="emit('patch', { visible: t.visible ? undefined : true })"
|
||||
/>
|
||||
<WpIconButton
|
||||
icon="pi-trash"
|
||||
tone="danger"
|
||||
:label="`Remove ${t.name}`"
|
||||
:data-test="`tg-remove-${t.id}`"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
</span>
|
||||
<template v-if="look === 'radio'">
|
||||
<span
|
||||
v-tooltip="shownOn ? 'Picked' : 'Pick this one'"
|
||||
role="radio"
|
||||
tabindex="0"
|
||||
class="wp-tgr__radio"
|
||||
:style="tintStyle"
|
||||
:class="{ 'is-on': shownOn }"
|
||||
:aria-checked="shownOn"
|
||||
:aria-label="t.name"
|
||||
@@ -228,7 +214,7 @@ function flip(): void {
|
||||
@keydown.enter.prevent.stop="flip"
|
||||
/>
|
||||
</template>
|
||||
<span v-else-if="look === 'checkbox'" v-tooltip="blockedHint()" class="wp-tgr__ctl">
|
||||
<span v-else-if="look === 'checkbox'" v-tooltip="blockedHint()" class="wp-tgr__ctl" :style="tintStyle">
|
||||
<Checkbox
|
||||
:model-value="shownOn"
|
||||
:disabled="blocked"
|
||||
@@ -237,7 +223,7 @@ function flip(): void {
|
||||
@update:model-value="(v: boolean) => emit('set-on', v)"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="look === 'buttons'" v-tooltip="blockedHint()" class="wp-tgr__ctl">
|
||||
<span v-else-if="look === 'buttons'" v-tooltip="blockedHint()" class="wp-tgr__ctl" :style="tintStyle">
|
||||
<WpSegmented
|
||||
:model-value="t.on ? 'on' : 'off'"
|
||||
:options="[{ value: 'on', label: t.on_label || 'On' }, { value: 'off', label: t.off_label || 'Off' }]"
|
||||
@@ -248,7 +234,7 @@ function flip(): void {
|
||||
@update:model-value="(v: string) => emit('set-on', v === 'on')"
|
||||
/>
|
||||
</span>
|
||||
<span v-else-if="look === 'switch'" v-tooltip="blockedHint()" class="wp-tgr__ctl">
|
||||
<span v-else-if="look === 'switch'" v-tooltip="blockedHint()" class="wp-tgr__ctl" :style="tintStyle">
|
||||
<WpSwitch
|
||||
:model-value="shownOn"
|
||||
size="sm"
|
||||
@@ -260,174 +246,6 @@ function flip(): void {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="editable && editing" class="wp-tgr__edit" :data-test="`tg-editor-${t.id}`">
|
||||
<div class="wp-tgr__edit-head">
|
||||
<span class="wp-tgr__cap">EDIT TOGGLE</span>
|
||||
<Button variant="ghost" icon="times" size="sm" aria-label="Done" @click="emit('edit')" />
|
||||
</div>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Name <em>on the row and its output</em></span>
|
||||
<Input size="sm" :model-value="t.name" spellcheck="false" data-test="tg-name" @keydown.stop @change="emit('rename', value($event))" />
|
||||
</label>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Variable <em>true or false; the globe keeps it internal</em></span>
|
||||
<span class="wp-tgr__var-row">
|
||||
<Input
|
||||
size="sm"
|
||||
mono
|
||||
prefix="$"
|
||||
class="wp-tgr__var"
|
||||
:class="t.var ? varColorClass(t.var) : undefined"
|
||||
:tone="problem ? 'warn' : undefined"
|
||||
:model-value="t.var"
|
||||
placeholder="variable_name"
|
||||
spellcheck="false"
|
||||
data-test="tg-var"
|
||||
@keydown.stop
|
||||
@change="emit('set-var', value($event).replace(/^\$/, ''))"
|
||||
/>
|
||||
<span v-tooltip="'Show the $variable under the name'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!t.hide_var"
|
||||
size="sm"
|
||||
aria-label="Show the variable under the name"
|
||||
data-test="tg-show-var"
|
||||
@update:model-value="(v: boolean) => emit('patch', { hide_var: v ? undefined : true })"
|
||||
/>
|
||||
<span aria-hidden="true">Show</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="pickOne" class="wp-tgr__field">
|
||||
<span>Value <em>picked variable gets this, not the name</em></span>
|
||||
<RichTextInput
|
||||
class="wp-tgr__value"
|
||||
:model-value="t.value ?? ''"
|
||||
surface="combine"
|
||||
multiline
|
||||
:rows="2"
|
||||
:placeholder="t.name"
|
||||
:var-suggestions="varSuggestions ?? []"
|
||||
:var-producers="varProducers"
|
||||
:graph-aware="!!varProducers"
|
||||
aria-label="Value"
|
||||
data-test="tg-value"
|
||||
@update:model-value="(v: string) => emit('patch', { value: v.trim() ? v : undefined })"
|
||||
/>
|
||||
<p v-if="missingVars.length" class="wp-tgr__note" data-test="tg-missing-vars">
|
||||
<i class="pi pi-exclamation-triangle" aria-hidden="true" />{{ missingNote }}
|
||||
</p>
|
||||
<p v-if="hasInlinePicks(t.value ?? '')" class="wp-tgr__note" data-test="tg-inline-picks">
|
||||
<i class="pi pi-exclamation-triangle" aria-hidden="true" />{{ INLINE_PICKS_NOTE }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="wp-tgr__field">
|
||||
<span>Style <em v-if="pickOne">Pick one sets every row's look at the top</em></span>
|
||||
<WpSegmented
|
||||
:model-value="t.style"
|
||||
:options="STYLES"
|
||||
size="sm"
|
||||
block
|
||||
:disabled="pickOne"
|
||||
aria-label="Style"
|
||||
test-prefix="tg-style"
|
||||
@update:model-value="(v: ToggleStyle) => emit('patch', { style: v })"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="t.style === 'buttons' && !pickOne" class="wp-tgr__two">
|
||||
<label class="wp-tgr__field">
|
||||
<span>On word</span>
|
||||
<Input size="sm" :model-value="t.on_label ?? ''" placeholder="On" data-test="tg-on-label" @keydown.stop @change="emit('patch', { on_label: value($event) || undefined })" />
|
||||
</label>
|
||||
<label class="wp-tgr__field">
|
||||
<span>Off word</span>
|
||||
<Input size="sm" :model-value="t.off_label ?? ''" placeholder="Off" data-test="tg-off-label" @keydown.stop @change="emit('patch', { off_label: value($event) || undefined })" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="wp-tgr__field">
|
||||
<span>Needs <em>{{ pickOne ? "not used while Pick one is on" : "only on while that toggle is on" }}</em></span>
|
||||
<WpSelect
|
||||
:model-value="t.needs ?? ''"
|
||||
:options="needsOptions"
|
||||
aria-label="Needs"
|
||||
data-test="tg-needs"
|
||||
:disabled="pickOne"
|
||||
@update:model-value="(v: string) => emit('patch', { needs: v || undefined })"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgr__field">
|
||||
<span>When off <em>also mute or bypass other nodes</em></span>
|
||||
<WpSegmented
|
||||
:model-value="t.control ?? ''"
|
||||
:options="CONTROLS"
|
||||
size="sm"
|
||||
block
|
||||
aria-label="When off"
|
||||
test-prefix="tg-control"
|
||||
@update:model-value="setControl"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="t.control" v-tooltip="'Mute or bypass while the toggle is ON instead, for a node used only as a switchboard'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!!t.invert"
|
||||
size="sm"
|
||||
aria-label="Invert"
|
||||
data-test="tg-invert"
|
||||
@update:model-value="(v: boolean) => emit('patch', { invert: v ? true : undefined })"
|
||||
/>
|
||||
<span aria-hidden="true">Invert: {{ t.control === "mute" ? "mute" : "bypass" }} while on</span>
|
||||
</span>
|
||||
<div v-if="t.control" class="wp-tgr__field">
|
||||
<span>Canvas groups <em>wire nodes into the dot in front of the row, or pick groups</em></span>
|
||||
<Combobox
|
||||
:model-value="t.groups ?? []"
|
||||
multiple
|
||||
:options="(canvasGroups ?? []).map((g) => ({ value: g, label: g }))"
|
||||
placeholder="No groups"
|
||||
size="sm"
|
||||
aria-label="Canvas groups"
|
||||
data-test="tg-groups"
|
||||
@update:model-value="setGroups"
|
||||
/>
|
||||
</div>
|
||||
<span v-tooltip="'When the upstream Context holds this variable as a yes/no word (true, on, 1, no …), it sets the switch'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="!!t.upstream"
|
||||
size="sm"
|
||||
aria-label="Upstream can set it"
|
||||
data-test="tg-upstream"
|
||||
@update:model-value="(v: boolean) => emit('patch', { upstream: v ? true : undefined })"
|
||||
/>
|
||||
<span aria-hidden="true">Upstream can set it</span>
|
||||
</span>
|
||||
<div v-if="groupOptions.length" class="wp-tgr__field">
|
||||
<span>Group</span>
|
||||
<WpSelect
|
||||
:model-value="groupId"
|
||||
:options="groupOptions"
|
||||
aria-label="Group"
|
||||
data-test="tg-group"
|
||||
@update:model-value="(v: string) => emit('move-group', v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="wp-tgr__edit-foot">
|
||||
<span v-tooltip="!t.pin && outputsFull ? 'All 16 outputs are in use' : 'Show a BOOLEAN output for switch nodes'" v-toggle-row class="wp-tgr__check">
|
||||
<Checkbox
|
||||
:model-value="t.pin"
|
||||
size="sm"
|
||||
:disabled="!t.pin && outputsFull"
|
||||
aria-label="BOOLEAN output"
|
||||
data-test="tg-pin"
|
||||
@update:model-value="(v: boolean) => emit('patch', { pin: v })"
|
||||
/>
|
||||
<span aria-hidden="true">BOOLEAN output</span>
|
||||
</span>
|
||||
<span class="wp-tgr__foot-btns">
|
||||
<Button variant="ghost" size="sm" icon="pi-trash" data-test="tg-delete" @click="emit('remove')">Remove</Button>
|
||||
<Button variant="primary" size="sm" icon="pi-check" data-test="tg-done" @click="emit('edit')">Done</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -491,16 +309,57 @@ function flip(): void {
|
||||
.wp-tgr__sub.is-warn { font-family: var(--wp-font-sans, sans-serif); color: var(--wp-amber, var(--wp-warn)); }
|
||||
.wp-tgr__sub.is-dim { font-family: var(--wp-font-sans, sans-serif); font-style: italic; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-tgr__title { display: flex; align-items: center; gap: 5px; min-width: 0; }
|
||||
/* A fixed box with the glyph centred in it: the icon font's own line box sat
|
||||
* the glyph a pixel or two off the name's middle. */
|
||||
.wp-tgr__icon {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: var(--wp-tg-icon, var(--wp-text-muted, var(--wp-text2)));
|
||||
}
|
||||
/* The chip says "Name · $var" (the var when Show is on). */
|
||||
.wp-tgr__chipname {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 16px;
|
||||
color: var(--wp-tg-text, inherit);
|
||||
}
|
||||
/* A name colour carries over to its variable (row line and chip). */
|
||||
.wp-tgr__row.has-text-color .wp-tgr__chipvar,
|
||||
.wp-tgr__row.has-text-color .wp-tgr__sub:not(.is-linked):not(.is-warn) { color: var(--wp-tg-text); }
|
||||
.wp-tgr__chipdot { flex: none; line-height: 16px; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-tgr__chipvar {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: 10.5px/16px var(--wp-font-mono, monospace);
|
||||
}
|
||||
.wp-tgr__chip :deep(.wp-switch--chip .wp-switch__icon) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.wp-tgr__name b { color: var(--wp-tg-text, inherit); }
|
||||
/* Own colours win over the chip's lit accent, so a yellow sun stays yellow. */
|
||||
.wp-tgr__row.has-icon-color .wp-tgr__chip :deep(.wp-switch--chip .wp-switch__icon) { color: var(--wp-tg-icon); }
|
||||
.wp-tgr__btns { display: inline-flex; align-items: center; gap: 2px; flex: none; }
|
||||
/* Mute / bypass icon: dim while it isn't acting, coloured while it is
|
||||
* (bypass in the host's bypass violet, mute in amber). */
|
||||
.wp-tgr__ctlicon { flex: none; font-size: 10px; color: var(--wp-text-dim, var(--wp-text3)); opacity: 0.7; }
|
||||
.wp-tgr__ctlicon.is-active { opacity: 1; }
|
||||
.wp-tgr__ctlicon.is-active.is-bypass { color: var(--wp-violet); }
|
||||
.wp-tgr__ctlicon.is-active.is-mute { color: var(--wp-amber, var(--wp-warn)); }
|
||||
.wp-tgr__var-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.wp-tgr__var-row .wp-tgr__var { flex: 1 1 0; min-width: 80px; }
|
||||
.wp-tgr__up { font-size: 9px; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-tgr__note { margin: 0; display: flex; align-items: baseline; gap: 5px; font-size: 10.5px; line-height: 1.4; color: var(--wp-amber, var(--wp-warn)); }
|
||||
.wp-tgr__warn { font-size: 11px; color: var(--wp-amber, var(--wp-warn)); }
|
||||
.wp-tgr__ctl { display: inline-flex; flex: none; }
|
||||
.wp-tgr__radio {
|
||||
@@ -526,27 +385,4 @@ function flip(): void {
|
||||
}
|
||||
.wp-tgr__radio:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus, var(--wp-accent)); }
|
||||
|
||||
/* Edit panel */
|
||||
.wp-tgr__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: min(4px, var(--wp-radius-cap, 999px));
|
||||
}
|
||||
.wp-tgr__edit-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.wp-tgr__cap {
|
||||
font: 600 9px var(--wp-font-sans, sans-serif);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--wp-text-dim, var(--wp-text-muted, var(--wp-text2)));
|
||||
}
|
||||
.wp-tgr__field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.wp-tgr__field > span { font-size: 10.5px; font-weight: 600; color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-tgr__field em { font-style: normal; font-weight: 400; color: var(--wp-text-dim, var(--wp-text3)); margin-left: 4px; }
|
||||
.wp-tgr__two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
|
||||
.wp-tgr__edit-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.wp-tgr__foot-btns { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.wp-tgr__check { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import TogglesWidget from "./TogglesWidget.vue";
|
||||
import { defaultTogglesConfig } from "../../extension/toggles";
|
||||
import { defaultTogglesConfig, type ToggleRow, type TogglesConfig } from "../../extension/toggles";
|
||||
|
||||
describe("TogglesWidget edit request (toolbar pencil)", () => {
|
||||
it("flips the edit view each time the request is bumped", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: defaultTogglesConfig(), editRequest: 0 } });
|
||||
const cfg: TogglesConfig = {
|
||||
...defaultTogglesConfig(),
|
||||
rows: [
|
||||
{ id: "00000001", kind: "toggle", name: "Day", var: "day", on: true, pin: false, out: 0, style: "switch", icon: "sun" },
|
||||
{ id: "aaaa0001", kind: "label", name: "Time", color: 2, hex: "#ffaa00", icon: "clock" },
|
||||
{ id: "00000002", kind: "toggle", name: "Night", var: "night", on: false, pin: false, out: 0, style: "switch" },
|
||||
],
|
||||
};
|
||||
|
||||
/** The toggle window is teleported to body and loads lazily. */
|
||||
async function settle(): Promise<void> {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await vi.dynamicImportSettled();
|
||||
await flushPromises();
|
||||
}
|
||||
}
|
||||
function q(sel: string): HTMLElement | null {
|
||||
return document.body.querySelector<HTMLElement>(sel);
|
||||
}
|
||||
function lastConfig(w: { emitted: (e: string) => unknown[][] | undefined }): TogglesConfig | undefined {
|
||||
const all = w.emitted("update:modelValue");
|
||||
return all?.[all.length - 1]?.[0] as TogglesConfig | undefined;
|
||||
}
|
||||
|
||||
describe("TogglesWidget edit view (toolbar pencil)", () => {
|
||||
it("flips the edit view in the node each time the request is bumped", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg, editRequest: 0 } });
|
||||
expect(w.find(".wp-tg--edit").exists()).toBe(false);
|
||||
await w.setProps({ editRequest: 1 });
|
||||
expect(w.find(".wp-tg--edit").exists()).toBe(true);
|
||||
@@ -13,4 +37,65 @@ describe("TogglesWidget edit request (toolbar pencil)", () => {
|
||||
expect(w.find(".wp-tg--edit").exists()).toBe(false);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("keeps the group buttons in the node", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg, editRequest: 0 } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
expect(w.find('[data-test="tg-color-aaaa0001"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="tg-label-icon-aaaa0001"]').exists()).toBe(true);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TogglesWidget toggle window", () => {
|
||||
it("a row's pencil opens its window; Done stores the draft and the variable follows the name", async () => {
|
||||
const w = mount(TogglesWidget, { attachTo: document.body, props: { modelValue: cfg, editRequest: 0, nodeTitle: "Light" } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
await w.find('[data-test="tg-edit-00000001"]').trigger("click");
|
||||
await settle();
|
||||
expect(q('[data-test="modal"]')?.textContent).toContain("Light");
|
||||
const name = q('[data-test="tg-name"]') as HTMLInputElement | null;
|
||||
if (name) {
|
||||
name.value = "Sunrise";
|
||||
name.dispatchEvent(new Event("change"));
|
||||
}
|
||||
await settle();
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
q('[data-test="tg-window-done"]')?.click();
|
||||
await settle();
|
||||
const row = lastConfig(w)?.rows.find((r): r is ToggleRow => r.id === "00000001" && r.kind === "toggle");
|
||||
expect(row).toMatchObject({ name: "Sunrise", var: "sunrise", icon: "sun" });
|
||||
expect(q('[data-test="modal"]')).toBeNull();
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("Done moves the toggle to the group picked in its window", async () => {
|
||||
const w = mount(TogglesWidget, { attachTo: document.body, props: { modelValue: cfg, editRequest: 0 } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
await w.find('[data-test="tg-edit-00000002"]').trigger("click");
|
||||
await settle();
|
||||
// Night sits under "Time"; Top moves it above the label.
|
||||
const win = w.findComponent({ name: "ToggleEditWindow" });
|
||||
expect(win.exists()).toBe(true);
|
||||
win.vm.$emit("done", cfg.rows[2], "");
|
||||
await settle();
|
||||
expect(lastConfig(w)?.rows.map((r) => r.id)).toEqual(["00000001", "00000002", "aaaa0001"]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("the row's trash removes the toggle", async () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg, editRequest: 0 } });
|
||||
await w.setProps({ editRequest: 1 });
|
||||
await w.find('[data-test="tg-remove-00000002"]').trigger("click");
|
||||
expect(lastConfig(w)?.rows.map((r) => r.id)).toEqual(["00000001", "aaaa0001"]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("wears the label's own colour and icon", () => {
|
||||
const w = mount(TogglesWidget, { props: { modelValue: cfg } });
|
||||
const label = w.find('[data-test="tg-label-aaaa0001"]');
|
||||
expect(label.attributes("style")).toContain("color: rgb(255, 170, 0)");
|
||||
expect(label.find(".pi-clock").exists()).toBe(true);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,21 +9,25 @@
|
||||
*
|
||||
* Two views: the clean view shows only the rows and their controls; the
|
||||
* pencil in ComfyUI's selection toolbar (`editRequest`) opens the edit view
|
||||
* (mode, grips, row editors, add buttons) and Done closes it. A node with no toggles opens in the edit view.
|
||||
* The view is per session, never saved.
|
||||
* (mode, Pick one options, grips, row buttons, add buttons) and Done closes
|
||||
* it. A node with no toggles opens in the edit view. The view is per
|
||||
* session, never saved. A row's pencil opens that toggle's own settings in
|
||||
* a window (`ToggleEditWindow.vue`) with a preview of the row.
|
||||
*
|
||||
* Toggles belong to the label above them. Labels move with their toggles
|
||||
* (outer list), toggles move inside their group (inner lists); the edit
|
||||
* panel's Group field moves one to another label.
|
||||
* Toggles belong to the label above them. One sortable list holds labels
|
||||
* and toggles, so a toggle drags into or out of a group and a label drags
|
||||
* with its toggles (`dragRow`); the edit panel's Group field moves one too.
|
||||
*
|
||||
* Value contract: `modelValue` is a `TogglesConfig`; every edit emits the
|
||||
* whole next config (`src/widgets/toggles.ts` fits the outputs to it).
|
||||
*/
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { computed, defineAsyncComponent, nextTick, ref, watch } from "vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Input from "@/kit/Input.vue";
|
||||
import WpIconButton from "@/kit/WpIconButton.vue";
|
||||
import WpSegmented from "@/kit/WpSegmented.vue";
|
||||
import ColorField from "@/kit/ColorField.vue";
|
||||
import IconPicker from "@/kit/IconPicker.vue";
|
||||
import SortableList from "@/kit/SortableList.vue";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import { vTooltip } from "@/kit/tooltip";
|
||||
@@ -56,6 +60,9 @@ import type { ControlState } from "../../extension/toggle-control";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
import { vToggleRow } from "@/kit/toggle-row";
|
||||
|
||||
// The window loads on first open only: most canvases never edit a toggle.
|
||||
const ToggleEditWindow = defineAsyncComponent(() => import("./ToggleEditWindow.vue"));
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: TogglesConfig;
|
||||
@@ -65,15 +72,20 @@ const props = withDefaults(
|
||||
controls?: Record<string, ControlState>;
|
||||
/** Canvas group titles, for the control's group picker. */
|
||||
canvasGroups?: string[];
|
||||
/** Bumped by the pencil in ComfyUI's selection toolbar: flips the
|
||||
* edit view (extension/node-toolbar.ts). */
|
||||
/** Bumped by the pencil in ComfyUI's selection toolbar: opens the
|
||||
* edit window (extension/node-toolbar.ts). */
|
||||
editRequest?: number;
|
||||
/** Variables upstream of the node, for the Pick one Value's `$` list. */
|
||||
upstreamVars?: string[];
|
||||
/** Who writes each upstream variable (the Value's chip hover card). */
|
||||
varProducers?: Map<string, VarProducerLike> | undefined;
|
||||
/** The node's title, for the toggle window's header. */
|
||||
nodeTitle?: string;
|
||||
}>(),
|
||||
{ nodeMode: 0, controls: () => ({}), canvasGroups: () => [], editRequest: 0, upstreamVars: () => [], varProducers: undefined },
|
||||
{
|
||||
nodeMode: 0, controls: () => ({}), canvasGroups: () => [], editRequest: 0, upstreamVars: () => [],
|
||||
varProducers: undefined, nodeTitle: "",
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{ "update:modelValue": [next: TogglesConfig] }>();
|
||||
|
||||
@@ -152,6 +164,7 @@ function shownOn(t: ToggleRow): boolean {
|
||||
|
||||
/* ── rows ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** The toggle whose window is open. */
|
||||
const editing = ref<string | null>(null);
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
/** Edit view (see the header comment). */
|
||||
@@ -172,17 +185,11 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
async function focusEditor(): Promise<void> {
|
||||
await nextTick();
|
||||
root.value?.querySelector<HTMLInputElement>(".wp-tgr__edit input")?.select();
|
||||
}
|
||||
|
||||
function addToggle(): void {
|
||||
const t = newToggle(cfg.value);
|
||||
if (pinCount.value >= MAX_OUTPUTS) t.pin = false;
|
||||
setRows([...cfg.value.rows, t]);
|
||||
editing.value = t.id;
|
||||
void focusEditor();
|
||||
}
|
||||
function addLabel(): void {
|
||||
const used = new Set(cfg.value.rows.filter((r): r is LabelRow => r.kind === "label").map((r) => r.color));
|
||||
@@ -205,19 +212,39 @@ function removeLabel(l: LabelRow): void {
|
||||
setRows(cfg.value.rows.filter((r) => r.id !== l.id));
|
||||
}
|
||||
|
||||
function rename(t: ToggleRow, raw: string): void {
|
||||
const name = raw.trim() || t.name;
|
||||
// A variable that still follows the old name follows the new one.
|
||||
const follows = !t.var || t.var === uniqueVar(varFromName(t.name), cfg.value, t.id);
|
||||
const v = follows ? uniqueVar(varFromName(name) || t.var, cfg.value, t.id) : t.var;
|
||||
patchToggle(t.id, { name, var: v });
|
||||
}
|
||||
function setVar(t: ToggleRow, v: string): void {
|
||||
patchToggle(t.id, { var: v });
|
||||
}
|
||||
function toggleEdit(t: ToggleRow): void {
|
||||
editing.value = editing.value === t.id ? null : t.id;
|
||||
if (editing.value) void focusEditor();
|
||||
}
|
||||
/** The toggle whose window is open (gone if it was removed meanwhile). */
|
||||
const editingRow = computed(() => (editing.value ? byId.value.get(editing.value) : undefined));
|
||||
function windowProps(t: ToggleRow) {
|
||||
return { ...rowProps(t), nodeTitle: props.nodeTitle };
|
||||
}
|
||||
/** Done in a toggle's window: store its draft, moving it to its group. */
|
||||
function applyWindow(t: ToggleRow, next: ToggleRow, groupId: string): void {
|
||||
editing.value = null;
|
||||
let row: ToggleRow = { ...next, id: t.id, kind: "toggle" };
|
||||
const name = row.name.trim() || t.name;
|
||||
if (name !== t.name && row.var === t.var) {
|
||||
// A variable that still follows the old name follows the new one.
|
||||
const follows = !t.var || t.var === uniqueVar(varFromName(t.name), cfg.value, t.id);
|
||||
if (follows) row = { ...row, var: uniqueVar(varFromName(name) || t.var, cfg.value, t.id) };
|
||||
}
|
||||
row = { ...row, name };
|
||||
const rows = cfg.value.rows.map((r) => (r.id === t.id ? row : r));
|
||||
if (groupId === groupIdOf(t)) {
|
||||
setRows(rows);
|
||||
return;
|
||||
}
|
||||
const next2: ToggleGroup[] = groupsOf({ ...cfg.value, rows }).map((g) => ({ label: g.label, toggles: g.toggles.filter((x) => x.id !== t.id) }));
|
||||
const target = next2.find((g) => (g.label?.id ?? "") === groupId);
|
||||
if (!target) {
|
||||
setRows(rows);
|
||||
return;
|
||||
}
|
||||
target.toggles.push(row);
|
||||
if (target.label?.collapsed) target.label = { ...target.label, collapsed: false };
|
||||
setRows(rowsOf(next2));
|
||||
}
|
||||
/** `$` names a Pick one Value can read: upstream's, then the toggles' own. */
|
||||
const valueVars = computed(() => {
|
||||
@@ -265,16 +292,6 @@ function groupOptions(): { value: string; label: string }[] {
|
||||
function groupIdOf(t: ToggleRow): string {
|
||||
return groups.value.find((g) => g.toggles.some((x) => x.id === t.id))?.label?.id ?? "";
|
||||
}
|
||||
/** Move a toggle to the end of another label's group. */
|
||||
function moveToGroup(t: ToggleRow, labelId: string): void {
|
||||
const next: ToggleGroup[] = groups.value.map((g) => ({ label: g.label, toggles: g.toggles.filter((x) => x.id !== t.id) }));
|
||||
const target = next.find((g) => (g.label?.id ?? "") === labelId);
|
||||
if (!target) return;
|
||||
target.toggles.push(t);
|
||||
if (target.label?.collapsed) target.label = { ...target.label, collapsed: false };
|
||||
setRows(rowsOf(next));
|
||||
}
|
||||
|
||||
/* ── ordering ─────────────────────────────────────────────────────────── */
|
||||
|
||||
type LabelledGroup = ToggleGroup & { label: LabelRow };
|
||||
@@ -284,15 +301,28 @@ const shownRows = computed(() => visibleRows(cfg.value));
|
||||
function groupOfLabel(l: LabelRow): ToggleGroup {
|
||||
return groups.value.find((g) => g.label?.id === l.id) ?? { label: l, toggles: [] };
|
||||
}
|
||||
/** A label's colour as CSS: its own pick, else its variable colour. */
|
||||
function labelColor(l: LabelRow): string {
|
||||
return l.hex ?? `var(--wp-var-${l.color})`;
|
||||
}
|
||||
/** A grouped toggle wears its label's colour on its left edge. */
|
||||
function groupColorOf(t: ToggleRow): number | undefined {
|
||||
return groups.value.find((x) => x.toggles.some((y) => y.id === t.id))?.label?.color;
|
||||
function groupColorOf(t: ToggleRow): string | undefined {
|
||||
const l = groups.value.find((x) => x.toggles.some((y) => y.id === t.id))?.label;
|
||||
return l ? labelColor(l) : undefined;
|
||||
}
|
||||
|
||||
/* ── labels ───────────────────────────────────────────────────────────── */
|
||||
|
||||
function nextColor(l: LabelRow): void {
|
||||
patchLabel(l.id, { color: (l.color % 8) + 1 });
|
||||
/** The colour picker's value for a label: its own pick, else the swatch it
|
||||
* wears now, so the picker opens on the colour you see. */
|
||||
function labelPickerValue(l: LabelRow): string {
|
||||
if (l.hex) return l.hex;
|
||||
const el = root.value;
|
||||
const v = el ? getComputedStyle(el).getPropertyValue(`--wp-var-${l.color}`).trim() : "";
|
||||
return v || "none";
|
||||
}
|
||||
function setLabelColor(l: LabelRow, v: string): void {
|
||||
patchLabel(l.id, { hex: /^#[0-9a-f]{6}$/i.test(v.slice(0, 7)) ? v.slice(0, 7).toLowerCase() : undefined });
|
||||
}
|
||||
function onCount(g: ToggleGroup): number {
|
||||
return g.toggles.filter((t) => eff.value.get(t.id)).length;
|
||||
@@ -317,7 +347,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
test-prefix="tg-mode"
|
||||
@update:model-value="setMode"
|
||||
/>
|
||||
<label v-if="isOne" class="wp-tg__group">
|
||||
<label v-if="isOne" class="wp-tg__group" :data-wp-vars="cfg.group_var || undefined">
|
||||
<span class="wp-tg__group-cap">writes</span>
|
||||
<Input
|
||||
size="sm"
|
||||
@@ -382,7 +412,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
<div
|
||||
v-if="g.label"
|
||||
class="wp-tg__label"
|
||||
:class="`var-${g.label.color}`"
|
||||
:style="{ color: labelColor(g.label) }"
|
||||
:data-wp-out="foldedOuts(g)"
|
||||
:data-test="`tg-label-${g.label.id}`"
|
||||
>
|
||||
@@ -393,6 +423,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
:data-test="`tg-fold-${g.label.id}`"
|
||||
@click="patchLabel(g.label.id, { collapsed: !g.label.collapsed })"
|
||||
/>
|
||||
<i v-if="g.label.icon" class="pi wp-tg__label-icon" :class="`pi-${g.label.icon}`" aria-hidden="true" />
|
||||
<span class="wp-tg__label-text">{{ g.label.name || "Group" }}</span>
|
||||
<span class="wp-tg__rule" />
|
||||
<span class="wp-tg__count">{{ onCount(g) }}/{{ g.toggles.length }} on</span>
|
||||
@@ -425,7 +456,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
<div
|
||||
v-if="item.kind === 'label'"
|
||||
class="wp-tg__label"
|
||||
:class="`var-${item.color}`"
|
||||
:style="{ color: labelColor(item) }"
|
||||
:data-wp-out="foldedOuts(groupOfLabel(item))"
|
||||
:data-test="`tg-label-${item.id}`"
|
||||
>
|
||||
@@ -448,12 +479,23 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
/>
|
||||
<span class="wp-tg__rule" />
|
||||
<span class="wp-tg__count">{{ groupOfLabel(item).toggles.length }}<template v-if="item.collapsed"> · {{ onCount(groupOfLabel(item)) }} on</template></span>
|
||||
<WpIconButton
|
||||
icon="pi-palette"
|
||||
:label="`Colour ${item.color} of 8`"
|
||||
hint="Next colour"
|
||||
<IconPicker
|
||||
:model-value="item.icon ?? null"
|
||||
all
|
||||
compact
|
||||
size="sm"
|
||||
:color="labelColor(item)"
|
||||
aria-label="Group icon"
|
||||
:data-test="`tg-label-icon-${item.id}`"
|
||||
@update:model-value="(v: string | null) => patchLabel(item.id, { icon: v || undefined })"
|
||||
/>
|
||||
<ColorField
|
||||
:model-value="labelPickerValue(item)"
|
||||
presets
|
||||
compact
|
||||
aria-label="Group colour"
|
||||
:data-test="`tg-color-${item.id}`"
|
||||
@click="nextColor(item)"
|
||||
@update:model-value="(v: string) => setLabelColor(item, v)"
|
||||
/>
|
||||
<WpIconButton
|
||||
icon="pi-times"
|
||||
@@ -468,16 +510,13 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
v-else
|
||||
class="wp-tg__member"
|
||||
:class="{ 'is-grouped': groupColorOf(item) }"
|
||||
:style="groupColorOf(item) ? { '--wp-tg-group': `var(--wp-var-${groupColorOf(item)})` } : undefined"
|
||||
:style="groupColorOf(item) ? { '--wp-tg-group': groupColorOf(item) } : undefined"
|
||||
>
|
||||
<ToggleRowView
|
||||
v-bind="rowProps(item)"
|
||||
@set-on="(v: boolean) => setOn(item, v)"
|
||||
@patch="(p: Partial<ToggleRow>) => patchToggle(item.id, p)"
|
||||
@rename="(n: string) => rename(item, n)"
|
||||
@set-var="(v: string) => setVar(item, v)"
|
||||
@edit="toggleEdit(item)"
|
||||
@move-group="(id: string) => moveToGroup(item, id)"
|
||||
@remove="removeToggle(item)"
|
||||
/>
|
||||
</div>
|
||||
@@ -492,6 +531,13 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="!toggles.length" class="wp-tg__ghost">No toggles yet: select the node and press the pencil above it.</div>
|
||||
<ToggleEditWindow
|
||||
v-if="editingRow"
|
||||
:key="editingRow.id"
|
||||
v-bind="windowProps(editingRow)"
|
||||
@done="(next: ToggleRow, groupId: string) => editingRow && applyWindow(editingRow, next, groupId)"
|
||||
@cancel="editing = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -513,6 +559,7 @@ function foldedOuts(g: ToggleGroup): string | undefined {
|
||||
.wp-tg__pickstyle { display: inline-flex; align-items: center; gap: 8px; flex-basis: 100%; }
|
||||
.wp-tg__check { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; cursor: pointer; }
|
||||
.wp-tg__mode-cap { font-size: 10.5px; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-tg__label-icon { font-size: 11px; }
|
||||
.wp-tg__label-text {
|
||||
font: 600 11px var(--wp-font-sans, sans-serif);
|
||||
padding: 0 4px;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import ToggleEditWindow from "../ToggleEditWindow.vue";
|
||||
import type { ToggleRow as Row } from "../../../extension/toggles";
|
||||
|
||||
const base: Row = { id: "a", kind: "toggle", name: "Dawn", var: "dawn", on: true, pin: false, out: 0, style: "switch", control: "bypass" };
|
||||
|
||||
function open(t: Row, extra: { pickOne?: boolean; varSuggestions?: string[]; groupId?: string } = {}) {
|
||||
return mount(ToggleEditWindow, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
t, pickOne: extra.pickOne ?? false, shownOn: true, effOn: true, parentName: "", blocked: false,
|
||||
needsOptions: [{ value: "", label: "Nothing" }],
|
||||
groupOptions: extra.groupId !== undefined ? [{ value: "", label: "Top" }, { value: "g1", label: "Light" }] : [],
|
||||
groupId: extra.groupId ?? "", outputsFull: false, nodeTitle: "Light",
|
||||
...(extra.varSuggestions ? { varSuggestions: extra.varSuggestions } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
/** The window is teleported to body. */
|
||||
function q(sel: string): HTMLElement | null {
|
||||
return document.body.querySelector<HTMLElement>(sel);
|
||||
}
|
||||
function done(w: ReturnType<typeof open>): [Row, string] | undefined {
|
||||
q('[data-test="tg-window-done"]')?.click();
|
||||
return w.emitted("done")?.[0] as [Row, string] | undefined;
|
||||
}
|
||||
|
||||
describe("ToggleEditWindow", () => {
|
||||
it.each([
|
||||
["Show", { hide_var: true }],
|
||||
["Invert: bypass while on", { invert: true }],
|
||||
["Upstream can set it", { upstream: true }],
|
||||
["BOOLEAN output", { pin: true }],
|
||||
])("the words %s tick their box", async (words, patch) => {
|
||||
const w = open(base);
|
||||
const label = [...document.body.querySelectorAll<HTMLElement>(".wp-tgr__check > span")].find((s) => s.textContent === words);
|
||||
expect(label, words).toBeTruthy();
|
||||
label?.click();
|
||||
await flushPromises();
|
||||
expect(done(w)?.[0]).toMatchObject(patch);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("writes nothing until Done, then hands back the draft and its group", async () => {
|
||||
const w = open(base, { groupId: "" });
|
||||
const name = q('[data-test="tg-name"]') as HTMLInputElement | null;
|
||||
if (name) {
|
||||
name.value = "Sunrise";
|
||||
name.dispatchEvent(new Event("change"));
|
||||
}
|
||||
await flushPromises();
|
||||
expect(w.emitted("done")).toBeUndefined();
|
||||
expect(q('[data-test="tg-window-preview"]')?.textContent).toContain("Sunrise");
|
||||
const out = done(w);
|
||||
expect(out?.[0].name).toBe("Sunrise");
|
||||
expect(out?.[1]).toBe("");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("Cancel without changes closes at once", async () => {
|
||||
const w = open(base);
|
||||
q('[data-test="tg-window-cancel"]')?.click();
|
||||
await flushPromises();
|
||||
expect(w.emitted("cancel")).toHaveLength(1);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("hides Style and Needs in Pick one", () => {
|
||||
const one = open(base, { pickOne: true });
|
||||
expect(q('[aria-label="Style"]')).toBeNull();
|
||||
expect(q('[data-test="tg-needs"]')).toBeNull();
|
||||
one.unmount();
|
||||
const any = open(base);
|
||||
expect(q('[aria-label="Style"]')).not.toBeNull();
|
||||
expect(q('[data-test="tg-needs"]')).not.toBeNull();
|
||||
any.unmount();
|
||||
});
|
||||
|
||||
it("offers a Value and a negative in both modes", async () => {
|
||||
for (const pickOne of [true, false]) {
|
||||
const w = open({ ...base, negative: "night sky" }, { pickOne });
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await vi.dynamicImportSettled();
|
||||
await flushPromises();
|
||||
}
|
||||
expect(q('[data-test="tg-value"]'), String(pickOne)).not.toBeNull();
|
||||
expect(q('[data-test="tg-negative"]'), String(pickOne)).not.toBeNull();
|
||||
w.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers an Off value and an Off negative in Any on only", async () => {
|
||||
for (const pickOne of [true, false]) {
|
||||
const w = open({ ...base, off_value: "photorealistic", off_negative: "anime" }, { pickOne });
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await vi.dynamicImportSettled();
|
||||
await flushPromises();
|
||||
}
|
||||
expect(q('[data-test="tg-off-value"]') === null, String(pickOne)).toBe(pickOne);
|
||||
expect(q('[data-test="tg-off-negative"]') === null, String(pickOne)).toBe(pickOne);
|
||||
w.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("warns that inline picks print as written", () => {
|
||||
const w = open({ ...base, value: "{golden|soft} light" }, { pickOne: true });
|
||||
expect(q('[data-test="tg-inline-picks"]')).not.toBeNull();
|
||||
w.unmount();
|
||||
const plain = open({ ...base, value: "$time light" }, { pickOne: true });
|
||||
expect(q('[data-test="tg-inline-picks"]')).toBeNull();
|
||||
plain.unmount();
|
||||
});
|
||||
|
||||
it("flags a $name nothing upstream sets", () => {
|
||||
const w = open({ ...base, value: "$time and $nowhere" }, { pickOne: true, varSuggestions: ["time"] });
|
||||
const note = q('[data-test="tg-missing-vars"]')?.textContent ?? "";
|
||||
expect(note).toContain("$nowhere");
|
||||
expect(note).not.toContain("$time");
|
||||
w.unmount();
|
||||
});
|
||||
it("colours the header with the toggle's looks", () => {
|
||||
const w = open({ ...base, icon: "moon", icon_color: "#7aa7ff", text_color: "#a9c4ff", tint: "#4c7dff" });
|
||||
const icon = q('[data-test="tg-window-icon"]');
|
||||
expect(icon?.className).toContain("pi-moon");
|
||||
expect(icon?.getAttribute("style")).toContain("--wp-tgw-icon: #7aa7ff");
|
||||
expect(q('[data-test="tg-window-sub"]')?.getAttribute("style")).toContain("--wp-tgw-text: #a9c4ff");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -47,15 +47,46 @@ describe("ToggleRow internal button", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToggleRow style in Pick one", () => {
|
||||
it("is disabled while Pick one is on", () => {
|
||||
const one = mountRow(base, { pickOne: true, editing: true });
|
||||
expect(one.find('[aria-label="Style"]').attributes("aria-disabled") ?? one.find('[aria-label="Style"] button').attributes("disabled")).toBeDefined();
|
||||
expect(one.text()).toContain("not used while Pick one is on");
|
||||
one.unmount();
|
||||
const any = mountRow(base, { editing: true });
|
||||
expect(any.find('[aria-label="Style"] button').attributes("disabled")).toBeUndefined();
|
||||
any.unmount();
|
||||
describe("ToggleRow chip", () => {
|
||||
it("says Name · $var, and the name alone when Show is off", () => {
|
||||
const chip: Row = { ...base, style: "chip" };
|
||||
const w = mountRow(chip, { editable: false });
|
||||
expect(w.find(".wp-tgr__chip").text().replace(/\s+/g, "")).toBe("Dawn·$dawn");
|
||||
w.unmount();
|
||||
const bare = mountRow({ ...chip, hide_var: true }, { editable: false });
|
||||
expect(bare.find('[data-test="tg-chip-var-a"]').exists()).toBe(false);
|
||||
expect(bare.find(".wp-tgr__chip").text()).toBe("Dawn");
|
||||
bare.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToggleRow looks", () => {
|
||||
it("draws the icon and hands its colours to the row", () => {
|
||||
const w = mountRow({ ...base, icon: "sun", icon_color: "#ffcc00", text_color: "#ff0000", tint: "#00aaff" }, {});
|
||||
const row = w.find(`[data-test="tg-row-${base.id}"]`);
|
||||
expect(row.find(".pi-sun").exists()).toBe(true);
|
||||
const style = row.attributes("style") ?? "";
|
||||
expect(style).toContain("--wp-tg-icon: #ffcc00");
|
||||
expect(style).toContain("--wp-tg-text: #ff0000");
|
||||
expect(style).not.toContain("--wp-accent");
|
||||
expect(w.find(".wp-tgr__ctl").attributes("style")).toContain("--wp-accent: #00aaff");
|
||||
expect(row.classes()).toContain("has-icon-color");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("keeps the theme's look with no colours set", () => {
|
||||
const w = mountRow(base, {});
|
||||
expect(w.find(`[data-test="tg-row-${base.id}"]`).attributes("style") ?? "").not.toContain("--wp-accent");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("offers edit, globe and remove on the row in the editor", async () => {
|
||||
const w = mountRow(base, { editable: true });
|
||||
expect(w.find(`[data-test="tg-edit-${base.id}"]`).exists()).toBe(true);
|
||||
expect(w.find(`[data-test="tg-internal-${base.id}"]`).exists()).toBe(true);
|
||||
await w.find(`[data-test="tg-remove-${base.id}"]`).trigger("click");
|
||||
expect(w.emitted("remove")).toHaveLength(1);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,29 +126,11 @@ describe("ToggleRow Pick one style", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToggleRow Pick one Value", () => {
|
||||
it("warns that inline picks print as written", () => {
|
||||
const w = mountRow({ ...base, value: "{golden|soft} light" }, { pickOne: true, editing: true });
|
||||
expect(w.find('[data-test="tg-inline-picks"]').exists()).toBe(true);
|
||||
w.unmount();
|
||||
const plain = mountRow({ ...base, value: "$time light" }, { pickOne: true, editing: true });
|
||||
expect(plain.find('[data-test="tg-inline-picks"]').exists()).toBe(false);
|
||||
plain.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToggleRow Pick one Value variables", () => {
|
||||
it("flags a $name nothing upstream sets", () => {
|
||||
const w = mount(ToggleRow, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
t: { ...base, value: "$time and $nowhere" }, pickOne: true, shownOn: true, effOn: true, parentName: "", blocked: false,
|
||||
indent: 0, editing: true, needsOptions: [], groupOptions: [], groupId: "", outputsFull: false, editable: true,
|
||||
varSuggestions: ["time"],
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="tg-missing-vars"]').text()).toContain("$nowhere");
|
||||
expect(w.find('[data-test="tg-missing-vars"]').text()).not.toContain("$time");
|
||||
describe("ToggleRow controls", () => {
|
||||
it("draws a checkbox for the Check style", () => {
|
||||
const w = mountRow({ ...base, style: "checkbox" }, { editable: false });
|
||||
expect(w.find(`[data-test="tg-ctl-${base.id}"]`).exists()).toBe(true);
|
||||
expect(w.find(".wp-tgr__ctl").html()).toContain("checkbox");
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ToggleRow from "../ToggleRow.vue";
|
||||
import type { ToggleRow as Row } from "../../../extension/toggles";
|
||||
|
||||
const row: Row = { id: "a", kind: "toggle", name: "Settings from the model", var: "model_settings", on: true, pin: false, out: 0, style: "switch", control: "bypass" };
|
||||
|
||||
function mountRow() {
|
||||
return mount(ToggleRow, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
t: row, pickOne: false, shownOn: true, effOn: true, parentName: "", blocked: false, indent: 0, editing: true,
|
||||
needsOptions: [], groupOptions: [], groupId: "", outputsFull: false, editable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("ToggleRow edit panel: the words beside each box tick it", () => {
|
||||
it.each([
|
||||
["Show", { hide_var: true }],
|
||||
["Invert: bypass while on", { invert: true }],
|
||||
["Upstream can set it", { upstream: true }],
|
||||
["BOOLEAN output", { pin: true }],
|
||||
])("%s", async (words, patch) => {
|
||||
const w = mountRow();
|
||||
const label = w.findAll(".wp-tgr__check > span").find((s) => s.text() === words);
|
||||
expect(label, words).toBeTruthy();
|
||||
await label?.trigger("click");
|
||||
expect(w.emitted("patch")?.[0]).toEqual([patch]);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -134,3 +134,8 @@ export const bookmarkCells: WeakMap<object, { get(): unknown; set(next: unknown)
|
||||
* ComfyUI's selection toolbar calls it. Registered by the widget glue of
|
||||
* each node with an edit view (WP Toggles, WP Bookmark, WP Note). */
|
||||
export const nodeEditors: WeakMap<object, () => void> = new WeakMap();
|
||||
|
||||
/** Every WP DOM widget host a node owns. WP Debug's "Show node" looks up the
|
||||
* row it should flash inside them (works in both renderers: the legacy
|
||||
* renderer keeps DOM widgets in an overlay, not inside a node card). */
|
||||
export const nodeWidgetHosts: WeakMap<object, Set<HTMLElement>> = new WeakMap();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1978,6 +1978,12 @@ function expandValue(
|
||||
// `$name.neg` reads the variable's negatives, which the static preview
|
||||
// doesn't track: render nothing rather than a stray accessor.
|
||||
if (axis === "neg") return "";
|
||||
// `$name.bool` reads a toggle's on/off: a toggle with a value holds the
|
||||
// value while on and nothing while off.
|
||||
if (axis === "bool") {
|
||||
const v = base.trim().toLowerCase();
|
||||
return v === "true" || v === "false" ? v : v ? "true" : "false";
|
||||
}
|
||||
const tag = previewAxisTag(catalog, name, axis);
|
||||
return tag ?? full;
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
withUpstream,
|
||||
groupsOf,
|
||||
internalToggleVars,
|
||||
ownToggleNegatives,
|
||||
toggleBools,
|
||||
needsDepth,
|
||||
needsLoop,
|
||||
newToggle,
|
||||
@@ -45,6 +47,8 @@ describe("toggles corpus (shared with engine/toggles.py)", () => {
|
||||
it(c.name, () => {
|
||||
const parsed = withUpstream(parseTogglesConfig(JSON.stringify(c.config)), "upstream" in c ? c.upstream : null).config;
|
||||
expect(resolveToggles(parsed)).toEqual(c.values);
|
||||
expect(toggleBools(parsed)).toEqual(c.bools);
|
||||
expect(ownToggleNegatives(parsed)).toEqual("negatives" in c ? c.negatives : {});
|
||||
const vals = outputValues(parsed);
|
||||
expect(vals).toHaveLength(MAX_OUTPUTS);
|
||||
expect(vals.slice(0, c.outputs.length)).toEqual(c.outputs);
|
||||
@@ -276,3 +280,51 @@ describe("dragRow (edit view drag across groups)", () => {
|
||||
expect(ids(dragRow(f, 1, 3))).toEqual(["a", "L2", "d", "L1", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTogglesConfig negative", () => {
|
||||
it("keeps a toggle's negative and drops a blank one", () => {
|
||||
const cfg = parseTogglesConfig(JSON.stringify({
|
||||
mode: "one",
|
||||
rows: [
|
||||
{ id: "t1", name: "Day", var: "day", negative: "night sky" },
|
||||
{ id: "t2", name: "Night", var: "night", negative: " " },
|
||||
],
|
||||
}));
|
||||
expect(cfg.rows.find((r) => r.id === "t1")).toMatchObject({ negative: "night sky" });
|
||||
expect(cfg.rows.find((r) => r.id === "t2")).not.toHaveProperty("negative");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTogglesConfig off value", () => {
|
||||
it("keeps an Off value and Off negative, drops blank ones", () => {
|
||||
const cfg = parseTogglesConfig(JSON.stringify({
|
||||
mode: "any",
|
||||
rows: [
|
||||
{ id: "t1", name: "Style", var: "style", off_value: "photo", off_negative: "anime" },
|
||||
{ id: "t2", name: "Rain", var: "rain", off_value: "", off_negative: " " },
|
||||
],
|
||||
}));
|
||||
expect(cfg.rows.find((r) => r.id === "t1")).toMatchObject({ off_value: "photo", off_negative: "anime" });
|
||||
const t2 = cfg.rows.find((r) => r.id === "t2");
|
||||
expect(t2).not.toHaveProperty("off_value");
|
||||
expect(t2).not.toHaveProperty("off_negative");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTogglesConfig looks", () => {
|
||||
it("keeps icons and #rrggbb colours, drops anything else", () => {
|
||||
const cfg = parseTogglesConfig({
|
||||
rows: [
|
||||
{ id: "l1", kind: "label", name: "G", color: 3, hex: "#FFAA00", icon: "pi-clock" },
|
||||
{ id: "l2", kind: "label", name: "H", color: 3, hex: "red", icon: "<b>" },
|
||||
{ id: "t1", name: "Day", var: "day", icon: "sun", icon_color: "#ffcc00", text_color: "#123456", tint: "#00AAFF" },
|
||||
{ id: "t2", name: "Night", var: "night", icon: "", icon_color: "blue", tint: "#12345" },
|
||||
],
|
||||
});
|
||||
expect(cfg.rows[0]).toMatchObject({ hex: "#ffaa00", icon: "clock" });
|
||||
expect(cfg.rows[1]).not.toHaveProperty("hex");
|
||||
expect(cfg.rows[1]).not.toHaveProperty("icon");
|
||||
expect(cfg.rows[2]).toMatchObject({ icon: "sun", icon_color: "#ffcc00", text_color: "#123456", tint: "#00aaff" });
|
||||
for (const k of ["icon", "icon_color", "text_color", "tint"]) expect(cfg.rows[3]).not.toHaveProperty(k);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,11 +49,28 @@ export interface ToggleRow {
|
||||
invert?: true;
|
||||
/** Clean view: no `$variable` line under the name. */
|
||||
hide_var?: true;
|
||||
/** Pick one: what the picked variable gets instead of the name (may read `$vars`). */
|
||||
/** What the variable gets while on, instead of "true" ("" while off); in
|
||||
* Pick one also what the picked variable gets instead of the name. May
|
||||
* read `$vars`. `$name.bool` still reads the switch. */
|
||||
value?: string;
|
||||
/** Negative text the variable carries while on (send-to-negative); in
|
||||
* Pick one the picked variable carries it too. */
|
||||
negative?: string;
|
||||
/** What the variable gets while OFF ("" when absent). With a Value or an
|
||||
* Off value the toggle writes text, not true/false. */
|
||||
off_value?: string;
|
||||
/** Negative text the variable carries while off. */
|
||||
off_negative?: string;
|
||||
/** The variable shows in the Assembler and prints in a prompt (the
|
||||
* globe "internal" button turned off). Absent: internal, for rules only. */
|
||||
visible?: true;
|
||||
/** Looks only (absent: the theme's). A PrimeIcons name without `pi-`,
|
||||
* shown before the name; colours are `#rrggbb`. `tint` colours the
|
||||
* control (switch, check, radio, chip, buttons). */
|
||||
icon?: string;
|
||||
icon_color?: string;
|
||||
text_color?: string;
|
||||
tint?: string;
|
||||
}
|
||||
|
||||
export const TOGGLE_CONTROLS = ["mute", "bypass"] as const;
|
||||
@@ -63,8 +80,12 @@ export interface LabelRow {
|
||||
id: string;
|
||||
kind: "label";
|
||||
name: string;
|
||||
/** Variable colour 1..8. */
|
||||
/** Variable colour 1..8 (used while `hex` is absent). */
|
||||
color: number;
|
||||
/** Own colour `#rrggbb`, chosen in the colour picker. */
|
||||
hex?: string;
|
||||
/** PrimeIcons name without `pi-`, before the name. */
|
||||
icon?: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
@@ -187,6 +208,15 @@ export function toggleRows(cfg: TogglesConfig): ToggleRow[] {
|
||||
}
|
||||
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
||||
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
const ICON_RE = /^[a-z0-9-]{1,40}$/;
|
||||
/** A `#rrggbb` colour, or "" for anything else. */
|
||||
const hex = (v: unknown): string => (typeof v === "string" && HEX_RE.test(v.trim()) ? v.trim().toLowerCase() : "");
|
||||
/** A PrimeIcons name (without `pi-`), or "" for anything else. */
|
||||
const iconName = (v: unknown): string => {
|
||||
const s = str(v).replace(/^pi-/, "");
|
||||
return ICON_RE.test(s) ? s : "";
|
||||
};
|
||||
|
||||
export function parseTogglesConfig(raw: unknown): TogglesConfig {
|
||||
let data: unknown = raw;
|
||||
@@ -210,6 +240,8 @@ export function parseTogglesConfig(raw: unknown): TogglesConfig {
|
||||
if (o.kind === "label") {
|
||||
const c = typeof o.color === "number" && o.color >= 1 && o.color <= 8 ? Math.round(o.color) : 1;
|
||||
const label: LabelRow = { id, kind: "label", name: str(o.name), color: c };
|
||||
if (hex(o.hex)) label.hex = hex(o.hex);
|
||||
if (iconName(o.icon)) label.icon = iconName(o.icon);
|
||||
if (o.collapsed === true) label.collapsed = true;
|
||||
rows.push(label);
|
||||
continue;
|
||||
@@ -231,11 +263,18 @@ export function parseTogglesConfig(raw: unknown): TogglesConfig {
|
||||
if (str(o.off_label)) t.off_label = str(o.off_label);
|
||||
if (str(o.needs)) t.needs = str(o.needs);
|
||||
if (typeof o.value === "string" && o.value) t.value = o.value;
|
||||
if (typeof o.negative === "string" && o.negative.trim()) t.negative = o.negative;
|
||||
if (typeof o.off_value === "string" && o.off_value) t.off_value = o.off_value;
|
||||
if (typeof o.off_negative === "string" && o.off_negative.trim()) t.off_negative = o.off_negative;
|
||||
if (o.upstream === true) t.upstream = true;
|
||||
if (TOGGLE_CONTROLS.includes(o.control as ToggleControl)) t.control = o.control as ToggleControl;
|
||||
if (o.invert === true) t.invert = true;
|
||||
if (o.hide_var === true) t.hide_var = true;
|
||||
if (o.visible === true) t.visible = true;
|
||||
if (iconName(o.icon)) t.icon = iconName(o.icon);
|
||||
if (hex(o.icon_color)) t.icon_color = hex(o.icon_color);
|
||||
if (hex(o.text_color)) t.text_color = hex(o.text_color);
|
||||
if (hex(o.tint)) t.tint = hex(o.tint);
|
||||
const groups = Array.isArray(o.groups) ? o.groups.map(str).filter(Boolean) : [];
|
||||
if (groups.length) t.groups = [...new Set(groups)];
|
||||
rows.push(t);
|
||||
@@ -287,12 +326,21 @@ export function effectiveToggles(cfg: TogglesConfig): Map<string, boolean> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Variable → "true" / "false" (+ the Pick one variable), as the node writes. */
|
||||
/** Whether a toggle writes text (a Value or an Off value), not true/false. */
|
||||
export function writesText(t: ToggleRow): boolean {
|
||||
return !!(t.value || t.off_value);
|
||||
}
|
||||
|
||||
/** Variable → its value (+ the Pick one variable), as the node writes: a
|
||||
* toggle with a Value or an Off value writes the Value while on and the Off
|
||||
* value ("" when none) while off; one with neither writes "true" / "false". */
|
||||
export function resolveToggles(cfg: TogglesConfig): Record<string, string> {
|
||||
const eff = effectiveToggles(cfg);
|
||||
const values: Record<string, string> = {};
|
||||
for (const t of toggleRows(cfg)) {
|
||||
if (t.var && VAR_RE.test(t.var)) values[t.var] = eff.get(t.id) ? "true" : "false";
|
||||
if (!t.var || !VAR_RE.test(t.var)) continue;
|
||||
const on = eff.get(t.id) === true;
|
||||
values[t.var] = writesText(t) ? (on ? t.value ?? "" : t.off_value ?? "") : on ? "true" : "false";
|
||||
}
|
||||
if (cfg.mode === "one" && cfg.group_var && VAR_RE.test(cfg.group_var)) {
|
||||
const picked = toggleRows(cfg).find((t) => eff.get(t.id));
|
||||
@@ -301,6 +349,29 @@ export function resolveToggles(cfg: TogglesConfig): Record<string, string> {
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Each toggle variable's on/off, what `$name.bool` reads (mirror of
|
||||
* `engine/toggles.py::bools`); a later row wins. */
|
||||
export function toggleBools(cfg: TogglesConfig): Record<string, boolean> {
|
||||
const eff = effectiveToggles(cfg);
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const t of toggleRows(cfg)) if (t.var && VAR_RE.test(t.var)) out[t.var] = eff.get(t.id) === true;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Toggle variable → its negative: the Negative while on, the Off negative
|
||||
* while off (mirror of `engine/toggles.py::own_negatives`). */
|
||||
export function ownToggleNegatives(cfg: TogglesConfig): Record<string, string> {
|
||||
const eff = effectiveToggles(cfg);
|
||||
const out: Record<string, string> = {};
|
||||
for (const t of toggleRows(cfg)) {
|
||||
if (!t.var || !VAR_RE.test(t.var)) continue;
|
||||
const text = (eff.get(t.id) ? t.negative : t.off_negative)?.trim();
|
||||
if (text) out[t.var] = text;
|
||||
else delete out[t.var];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Variables the node writes that stay out of the prompt (mirror of
|
||||
* `engine/toggles.py::internal_vars`): a toggle not `visible` (the
|
||||
* last row writing a variable decides) and the Pick one variable unless
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
DERIVATION_CONDITIONS_SCHEMA_VERSION,
|
||||
FALLBACK_SCHEMA_VERSION,
|
||||
BOOL_ACCESSOR_SCHEMA_VERSION,
|
||||
FILTER_VARIABLES_SCHEMA_VERSION,
|
||||
NEGATED_TESTS_SCHEMA_VERSION,
|
||||
NEGATIVES_SCHEMA_VERSION,
|
||||
@@ -601,3 +602,18 @@ describe("schemaVersionForPayload — variables in a filter (v11)", () => {
|
||||
expect(schemaVersionForPayload(wildcard("costs $5 at @{0de11002:indoor}"))).toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schemaVersionForPayload: $name.bool (v12)", () => {
|
||||
it("stamps BOOL_ACCESSOR (12) for a .bool read in text or a condition", () => {
|
||||
expect(schemaVersionForPayload({ template: "a $rain.bool b" })).toBe(BOOL_ACCESSOR_SCHEMA_VERSION);
|
||||
expect(schemaVersionForPayload({ template: "a $rain.0.bool b" })).toBe(BOOL_ACCESSOR_SCHEMA_VERSION);
|
||||
expect(schemaVersionForPayload({
|
||||
rules: [{ branches: [{ condition: { var: "rain.bool", op: "equals", value: "true" } }] }],
|
||||
})).toBe(BOOL_ACCESSOR_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("leaves look-alikes at the chain head", () => {
|
||||
expect(schemaVersionForPayload({ template: "a $rain.boolean b" })).toBe(CURRENT_SCHEMA_VERSION);
|
||||
expect(schemaVersionForPayload({ template: "file.bool" })).toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
MAX_KNOWN_SCHEMA_VERSION,
|
||||
FILTER_VARIABLES_SCHEMA_VERSION,
|
||||
BOOL_ACCESSOR_SCHEMA_VERSION,
|
||||
} from "@/manager/import-export/migrations";
|
||||
import { MAX_KNOWN_SCHEMA_VERSION as MAX_KNOWN_VIA_INSTALL } from "@/manager/import-export/install";
|
||||
import { schemaVersionForPayload } from "@/manager/import-export/single-row-publish";
|
||||
@@ -12,22 +12,22 @@ import { schemaVersionForPayload } from "@/manager/import-export/single-row-publ
|
||||
* `docs/superpowers/specs/2026-06-14-schema-gate-max-known-version-design.md`):
|
||||
*
|
||||
* - CURRENT_SCHEMA_VERSION = 2 — head of the migration chain (v2→v3
|
||||
* through v10→v11 are no-ops, so the chain genuinely stops at 2).
|
||||
* - MAX_KNOWN_SCHEMA_VERSION = 11 — highest version this runtime can READ +
|
||||
* through v11→v12 are no-ops, so the chain genuinely stops at 2).
|
||||
* - MAX_KNOWN_SCHEMA_VERSION = 12 — highest version this runtime can READ +
|
||||
* WRITE; the value advertised to the community publish-gate / boot
|
||||
* catalog-probe.
|
||||
*
|
||||
* The community gate predicate is `hostSchema < minCompatible` where
|
||||
* `minCompatible = max(breaking versions) = 3`. Advertising CURRENT (2)
|
||||
* fails the gate (`2 < 3`); advertising MAX_KNOWN (11) clears it.
|
||||
* fails the gate (`2 < 3`); advertising MAX_KNOWN (12) clears it.
|
||||
*/
|
||||
describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
|
||||
it("CURRENT_SCHEMA_VERSION is the migration-chain head (2)", () => {
|
||||
expect(CURRENT_SCHEMA_VERSION).toBe(2);
|
||||
});
|
||||
|
||||
it("MAX_KNOWN_SCHEMA_VERSION is the support ceiling (11)", () => {
|
||||
expect(MAX_KNOWN_SCHEMA_VERSION).toBe(11);
|
||||
it("MAX_KNOWN_SCHEMA_VERSION is the support ceiling (12)", () => {
|
||||
expect(MAX_KNOWN_SCHEMA_VERSION).toBe(12);
|
||||
});
|
||||
|
||||
it("the ceiling is strictly above the chain head (the whole point of the fork)", () => {
|
||||
@@ -40,7 +40,7 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
|
||||
// whatever install.ts re-exports. Pin that it is MAX_KNOWN, identical to
|
||||
// the source-of-truth constant.
|
||||
expect(MAX_KNOWN_VIA_INSTALL).toBe(MAX_KNOWN_SCHEMA_VERSION);
|
||||
expect(MAX_KNOWN_VIA_INSTALL).toBe(11);
|
||||
expect(MAX_KNOWN_VIA_INSTALL).toBe(12);
|
||||
});
|
||||
|
||||
it("a plain v2-content payload still stamps at the chain head (unchanged)", () => {
|
||||
@@ -56,8 +56,8 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
|
||||
// content-stamp today: a bundle holding a wildcard with a fallback option
|
||||
// next to a derivation with an AND / OR group holding a NOT-toggled
|
||||
// "does not contain" test and an "Add to negative" action, plus a
|
||||
// wildcard use whose pool filter reads `$model_variant` → stamps
|
||||
// FILTER_VARIABLES_SCHEMA_VERSION (11).
|
||||
// wildcard use whose pool filter reads `$model_variant` and a test on
|
||||
// `rain.bool` → stamps BOOL_ACCESSOR_SCHEMA_VERSION (12).
|
||||
const highestFeaturePayload = { children: [{
|
||||
id: "wc-001abc",
|
||||
type: "wildcard",
|
||||
@@ -75,6 +75,7 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
|
||||
conditions: [
|
||||
{ var: "a", op: "equals", value: "1" },
|
||||
{ var: "b", op: "not_contains", value: "2" },
|
||||
{ var: "rain.bool", op: "equals", value: "true" },
|
||||
],
|
||||
},
|
||||
action: { target_var: "c", mode: "negative", value: "3" },
|
||||
@@ -83,7 +84,7 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
|
||||
},
|
||||
}] };
|
||||
const stamped = schemaVersionForPayload(highestFeaturePayload);
|
||||
expect(stamped).toBe(FILTER_VARIABLES_SCHEMA_VERSION);
|
||||
expect(stamped).toBe(BOOL_ACCESSOR_SCHEMA_VERSION);
|
||||
|
||||
// The mechanical maintenance contract from the spec: if someone teaches
|
||||
// schemaVersionForPayload a higher stamp without bumping MAX_KNOWN, update
|
||||
|
||||
@@ -53,6 +53,17 @@ describe("matchSuggestions", () => {
|
||||
expect(match({ trigger: "$", query: "nope.n" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers .bool for a toggle variable only", () => {
|
||||
const producers = new Map([...base.varProducers, ["rain", { kind: "toggle", shadowed: 0 }]]);
|
||||
const m = (query: string): string[] => matchSuggestions(
|
||||
"$", query, [...base.varSuggestions, "rain"], base.refSuggestions, base.uuidToName, producers,
|
||||
);
|
||||
expect(m("rain.b")).toEqual(["rain.bool"]);
|
||||
expect(m("rain.")).toEqual(["rain.neg", "rain.bool"]);
|
||||
expect(m("mood.b")).toEqual([]);
|
||||
expect(axisKnownFor({ kind: "var", name: "rain", axis: "bool" }, producers)).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes the row cap", () => {
|
||||
expect(AC_MAX_ITEMS).toBe(50);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* arrives as an argument.
|
||||
*/
|
||||
import type { Atom } from "../atomicEditorModel";
|
||||
import { NEG_ACCESSOR, varAccessorParts } from "../../../widgets/richTokenize";
|
||||
import { BOOL_ACCESSOR, NEG_ACCESSOR, varAccessorParts } from "../../../widgets/richTokenize";
|
||||
import { expandVarsWithAxes, type ProducerLike } from "../../utils/suggestion-rows";
|
||||
import { varColorIndex } from "../../../components/shared/var-color";
|
||||
|
||||
@@ -58,6 +58,10 @@ export function matchSuggestions(
|
||||
if (NEG_ACCESSOR.includes(frag) && (varSuggestions.includes(base) || axes.length)) {
|
||||
out.push(`${base}${idx}.${NEG_ACCESSOR}`);
|
||||
}
|
||||
// `.bool` reads a WP Toggles variable's on/off: offered for those only.
|
||||
if (BOOL_ACCESSOR.includes(frag) && !idx && varProducers?.get(base)?.kind === "toggle") {
|
||||
out.push(`${base}.${BOOL_ACCESSOR}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// `$` pool carries each variable's `accepts` axes as `name.AXIS` entries,
|
||||
@@ -141,8 +145,9 @@ export function varTint(name: string): Record<string, string> {
|
||||
*/
|
||||
export function axisKnownFor(atom: Atom, varProducers: AxisProducerMap | undefined): boolean | undefined {
|
||||
if (atom.kind !== "var" || !atom.axis) return undefined;
|
||||
// `.neg` is not an axis: every variable has negatives to read.
|
||||
if (atom.axis === NEG_ACCESSOR) return true;
|
||||
// `.neg` is not an axis: every variable has negatives to read. `.bool`
|
||||
// neither: it reads a toggle's on/off.
|
||||
if (atom.axis === NEG_ACCESSOR || atom.axis === BOOL_ACCESSOR) return true;
|
||||
const axes = varProducers?.get(atom.name)?.axes;
|
||||
if (!axes || axes.length === 0) return undefined;
|
||||
return axes.some((a) => a.axis === atom.axis);
|
||||
@@ -177,7 +182,7 @@ export function varSpanAttrsFor(
|
||||
// declared, so a perfectly valid index-first reference got the unknown-axis
|
||||
// warning. That is the wavy underline the assembler template showed.
|
||||
const { base, axis = "" } = varAccessorParts(name);
|
||||
if (graphAware && axis && axis !== NEG_ACCESSOR) {
|
||||
if (graphAware && axis && axis !== NEG_ACCESSOR && axis !== BOOL_ACCESSOR) {
|
||||
// The base resolves but the axis does not: the engine renders that as an
|
||||
// empty string, so without a mark the only symptom is a missing word.
|
||||
// Only claimed when the producer declares SOME axes — see `axisKnownFor`
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* current state and returns a new value; nothing here mutates its input.
|
||||
*/
|
||||
import { axisHueAt, UNGROUPED_HUE } from "../../../components/shared/axis-color";
|
||||
import { NEG_ACCESSOR } from "../../../widgets/richTokenize";
|
||||
import { BOOL_ACCESSOR, NEG_ACCESSOR } from "../../../widgets/richTokenize";
|
||||
|
||||
export type TagGroups = Record<string, string[]>;
|
||||
export type TagGroupKinds = Record<string, "accepts">;
|
||||
@@ -125,14 +125,22 @@ export function nextGroupName(tagGroups: TagGroups): string {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/** Why `name` cannot be an `accepts` axis: `"neg"` (reserved by `$var.neg`),
|
||||
* `"ident"` (not an identifier the accessor grammar can parse), or null. */
|
||||
/** Why `name` cannot be an `accepts` axis: `"neg"` (a reserved accessor:
|
||||
* `$var.neg` or `$var.bool`), `"ident"` (not an identifier the accessor
|
||||
* grammar can parse), or null. */
|
||||
export function acceptsNameProblem(name: string): "neg" | "ident" | null {
|
||||
if (name === NEG_ACCESSOR) return "neg";
|
||||
if (name === NEG_ACCESSOR || name === BOOL_ACCESSOR) return "neg";
|
||||
if (!AXIS_IDENT.test(name)) return "ident";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why a reserved accessor can't name an accepts group. */
|
||||
function reservedDetail(name: string): string {
|
||||
return name === BOOL_ACCESSOR
|
||||
? `$var.${BOOL_ACCESSOR} reads a WP Toggles variable's on/off, so an accepts group can't be called "${BOOL_ACCESSOR}".`
|
||||
: `$var.${NEG_ACCESSOR} reads a variable's negatives, so an accepts group can't be called "${NEG_ACCESSOR}".`;
|
||||
}
|
||||
|
||||
/** A refusal to show as a warning toast. */
|
||||
export interface Refusal {
|
||||
summary: string;
|
||||
@@ -147,7 +155,7 @@ export function acceptsPromotionRefusal(axis: string): Refusal | null {
|
||||
if (problem === "neg") {
|
||||
return {
|
||||
summary: `Rename "${axis}" first`,
|
||||
detail: `$var.${NEG_ACCESSOR} reads a variable's negatives, so an accepts group can't be called "${NEG_ACCESSOR}".`,
|
||||
detail: reservedDetail(axis),
|
||||
};
|
||||
}
|
||||
if (problem === "ident") {
|
||||
@@ -169,7 +177,7 @@ export function acceptsRenameRefusal(oldAxis: string, name: string): Refusal | n
|
||||
if (problem === "neg") {
|
||||
return {
|
||||
summary: `"${oldAxis}" keeps its name`,
|
||||
detail: `$var.${NEG_ACCESSOR} reads a variable's negatives, so an accepts group can't be called "${NEG_ACCESSOR}".`,
|
||||
detail: reservedDetail(name),
|
||||
};
|
||||
}
|
||||
if (problem === "ident") {
|
||||
|
||||
@@ -114,8 +114,8 @@ export const NODE_FIELDS = {
|
||||
{ term: "context (in)", sockets: ["in:context"], desc: "Any WP node's context output. The node has no output and changes nothing; drag its corner to resize." },
|
||||
],
|
||||
tabs: [
|
||||
{ term: "Variables", pin: "css:[role=tab]:nth-of-type(1)@top", desc: "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." },
|
||||
{ term: "Trace", pin: "css:[role=tab]:nth-of-type(2)@top", desc: "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." },
|
||||
{ term: "Variables", pin: "css:[role=tab]:nth-of-type(1)@top", desc: "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." },
|
||||
{ term: "Trace", pin: "css:[role=tab]:nth-of-type(2)@top", desc: "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)." },
|
||||
{ term: "Warnings", pin: "css:[role=tab]:nth-of-type(3)@top", desc: "What came up during the run, such as missing variables or a constraint that changed nothing. Look here first when something seems off." },
|
||||
{ term: "Raw", pin: "css:[role=tab]:nth-of-type(4)@top", desc: "Everything as JSON, with a download button. Handy for bug reports." },
|
||||
],
|
||||
@@ -244,24 +244,27 @@ export const NODE_FIELDS = {
|
||||
WP_Toggles: {
|
||||
controls: [
|
||||
{ term: "upstream (in)", sockets: ["in:upstream"], desc: "Optional Context to extend. Only nodes after this one see the toggle variables. It can also set toggles (see below)." },
|
||||
{ term: "Pencil (toolbar)", pin: "page:.wp-tb-icon--edit", desc: "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." },
|
||||
{ term: "Pencil (toolbar)", pin: "page:.wp-tb-icon--edit", desc: "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." },
|
||||
{ term: "Any on / Pick one", desc: "Any on: every toggle is on or off by itself. Pick one: exactly one is on, like radio buttons." },
|
||||
{ term: "writes (Pick one)", desc: "Optional variable that also gets the picked toggle's value (or its name), so one rule can branch on it." },
|
||||
{ term: "Globe (row)", desc: "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." },
|
||||
{ term: "Show in Assembler (Pick one)", desc: "The picked variable shows in the Assembler and can print in the prompt. Off, it is internal." },
|
||||
{ term: "Style (Pick one)", desc: "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." },
|
||||
{ term: "Pencil (row)", desc: "Opens the toggle's edit panel: name, variable, style, needs, what off mutes or bypasses, group and its output." },
|
||||
{ term: "Add toggle / Add label", desc: "A new toggle, or a label that groups the toggles under it. Click a label's arrow to fold it. Drag a toggle's grip under a label to move it into that group; a dragged label takes its toggles along." },
|
||||
{ term: "Pencil, globe, trash (row)", desc: "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." },
|
||||
{ term: "Add toggle / Add label", desc: "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." },
|
||||
{ term: "control dots (in)", pin: "in:ctl_*", desc: "A toggle that mutes or bypasses has a dot at the left of its row: wire the nodes it drives into it." },
|
||||
{ term: "context (out)", sockets: ["out:context"], desc: "The Context with every toggle variable added. Internal ones (the globe) are for rules only; the rest show in the Assembler." },
|
||||
{ term: "toggle outputs", sockets: ["out:toggle_*"], desc: "One BOOLEAN per toggle that has an output, at the right of its row. Up to 16." },
|
||||
],
|
||||
edit: [
|
||||
{ term: "Name", desc: "Shown on the row and on its output." },
|
||||
{ term: "Variable", desc: "Gets 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." },
|
||||
{ term: "Style", desc: "Switch, Check, Buttons (two words of your own, like Day / Night) or Chip (a small chip with the name). Greyed out in Pick one, where the Style in the top bar sets every row's look." },
|
||||
{ term: "Value (Pick one)", desc: "What the picked variable gets when this toggle is picked, instead of its 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." },
|
||||
{ term: "Needs", desc: "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." },
|
||||
{ term: "Name", desc: "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." },
|
||||
{ term: "Variable", desc: "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." },
|
||||
{ term: "Looks", desc: "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." },
|
||||
{ term: "Style", desc: "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." },
|
||||
{ term: "Value", desc: "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." },
|
||||
{ term: "Negative", desc: "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." },
|
||||
{ term: "Off value (Any on)", desc: "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." },
|
||||
{ term: "Needs", desc: "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." },
|
||||
{ term: "When off", desc: "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)." },
|
||||
{ term: "Invert", desc: "Mute or bypass while the toggle is ON instead, for a node used only as a switchboard." },
|
||||
{ term: "Canvas groups", desc: "Groups on the canvas whose nodes When off also covers, by title. No wires needed." },
|
||||
|
||||
@@ -58,6 +58,11 @@ import DocRef from "../../../components/docs/DocRef.vue";
|
||||
<td><VarToken>$name.neg</VarToken></td>
|
||||
<td>The variable's negative words. See <DocRef id="negatives" />.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><VarToken>$name.bool</VarToken></td>
|
||||
<td>A <DocRef id="wp-toggles" /> variable's switch, <code>true</code> or
|
||||
<code>false</code>, even when the toggle writes its own Value.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><VarToken kind="ref">@{…}</VarToken></td>
|
||||
<td>A fresh pick from another wildcard. Type <b>@</b> to insert one; it shows as a chip.
|
||||
|
||||
@@ -53,6 +53,53 @@ import CrossLinks from "../../../components/docs/CrossLinks.vue";
|
||||
</Banner>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="What a trace step tells you">
|
||||
<p>Click a step in the Trace tab to see why it came out the way it did. Each kind says what matters for it:</p>
|
||||
<ul>
|
||||
<li><b>Wildcard</b>: 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. <b>Options</b> 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.</li>
|
||||
<li><b>Derivation</b>: each rule, which branch fired, every test with the value it read, and each action.</li>
|
||||
<li><b>Fixed values</b>: 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.</li>
|
||||
<li><b>Combine</b>: the template and what each <code>$variable</code> in it read, with one nothing set flagged.</li>
|
||||
<li><b>Constraint</b>: its reach, how often it applied and which steps it re-weighted (click one to go there).</li>
|
||||
<li><b>WP Context Loop</b>: the frame, its seed and seed strategy, skipped frames and the sweep pins.</li>
|
||||
<li><b>WP Model Info</b>: the model class the family came from, the rule that matched the variant (or that none did) and the file name.</li>
|
||||
<li><b>WP Toggles</b>: 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.</li>
|
||||
<li><b>WP Context Injector</b>: the input a value came from, and a template with what each <code>$name</code> filled with.</li>
|
||||
</ul>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Rows from other nodes">
|
||||
<p>
|
||||
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:
|
||||
</p>
|
||||
<ul>
|
||||
<li><b>WP Context Loop</b> (orange): <code>$iteration</code>, <code>$iteration_total</code> and <code>$iteration_value</code>, 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.</li>
|
||||
<li><b>WP Model Info</b> (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.</li>
|
||||
<li><b>WP Toggles</b> (indigo): each toggle's variable as <code>true</code> / <code>false</code>, or its value text when the toggle has one (empty while off), with its <code>.bool</code> state and negative under it. In Pick one, each pick still has its own on/off variable (<code>true</code> is the picked one) and the Pick one variable holds the picked value. The source says when an upstream variable set a toggle.</li>
|
||||
<li><b>WP Context Injector</b> (blue): each wired value with its type (STR, INT, FLOAT, BOOL, or TPL for a template).</li>
|
||||
</ul>
|
||||
<p>
|
||||
<b>Show node</b> (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.
|
||||
</p>
|
||||
<p>
|
||||
A long value shows its first three lines (a long list its first three items, a long negative one
|
||||
line) until you press <b>Show all</b> under it; <b>Show less</b> folds it again.
|
||||
</p>
|
||||
<p>
|
||||
Their warnings (an unknown <code>$variable</code> 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 <i>swept</i>, one set to Match variable says which variable it matched, and a pool
|
||||
filter that reads a <code>$variable</code> shows the value it read. With a Loop that skips frames, the
|
||||
frame picker shows the Loop's own frame number.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Where to put it">
|
||||
<p>
|
||||
After one Context to check that step, or after the last to see everything. Use as many as
|
||||
|
||||
@@ -60,6 +60,16 @@ import VarToken from "../../../components/docs/VarToken.vue";
|
||||
</Banner>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Icons and colours">
|
||||
<p>
|
||||
Give a toggle an icon and its own colours under <b>Looks</b> 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.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Use a toggle in the prompt">
|
||||
<p>
|
||||
A toggle's variable is internal by default: rules read it, but the Assembler doesn't offer
|
||||
@@ -68,6 +78,20 @@ import VarToken from "../../../components/docs/VarToken.vue";
|
||||
same for the picked variable, so <VarToken>$time</VarToken> can print the picked toggle's
|
||||
<b>Value</b>.
|
||||
</p>
|
||||
<p>
|
||||
Give a toggle a <b>Value</b> 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
|
||||
<VarToken>$rain</VarToken> print "heavy rain" or nothing. The switch itself is still there as
|
||||
<VarToken>$rain.bool</VarToken>, <code>true</code> or <code>false</code>, for rules and
|
||||
conditions. Its <b>Negative</b> goes with the variable while it is on, so an Assembler that
|
||||
prints <VarToken>$rain</VarToken> also sends "sunny, dry" to its negative output.
|
||||
</p>
|
||||
<p>
|
||||
In Any on a toggle can also say what to write while it is <b>off</b>: its <b>Off value</b>
|
||||
and <b>Off negative</b>. 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.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
<DocSection title="Mute or bypass nodes">
|
||||
@@ -97,9 +121,11 @@ import VarToken from "../../../components/docs/VarToken.vue";
|
||||
One toggle is on. Which one, first match wins: the toggle the upstream picked variable
|
||||
names (with <b>Upstream can pick</b>), then the first toggle upstream set to a yes word,
|
||||
then the first one you switched on. The picked variable gets the toggle's
|
||||
<b>Value</b>, or its name; it is internal unless <b>Show in Assembler</b> is on. All rows share one look here:
|
||||
the <b>Style</b> next to those boxes picks Radio (the default), Chip, Switch or Check, and
|
||||
each row's own Style is greyed out.
|
||||
<b>Value</b>, or its name; it is internal unless <b>Show in Assembler</b> is on. A toggle's
|
||||
<b>Negative</b>, 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 <b>Style</b> next to those boxes picks Radio (the default), Chip, Switch or Check, so a
|
||||
toggle's own Style and Needs are hidden.
|
||||
</p>
|
||||
</DocSection>
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ import VarToken from "../../../components/docs/VarToken.vue";
|
||||
So <VarToken>true maybe false</VarToken> has two: index 0 is <code>true</code>, index 1 is
|
||||
<code>false</code>.
|
||||
</p>
|
||||
<Banner tone="info" note>
|
||||
A WP Toggles toggle with a Value holds that value in its variable, not true or false. Read
|
||||
its switch with <code>.bool</code>: <VarToken>$rain.bool</VarToken> is <code>true</code>
|
||||
while the toggle is on and <code>false</code> while it's off.
|
||||
</Banner>
|
||||
<Banner tone="info" note>
|
||||
A wildcard with options <code>on</code> and <code>off</code> through WP Var → Bool switches
|
||||
any true/false input at random. Weights set how often.
|
||||
|
||||
@@ -35,7 +35,11 @@ const runtimeWarnings: WarningRow[] = [
|
||||
{ label: "Constraint failed to register", severity: "warn", desc: "A constraint couldn't be set up, usually because its data is damaged. It was skipped." },
|
||||
{ label: "Module failed", severity: "warn", desc: "A module hit an error and was skipped. The message says which." },
|
||||
{ label: "Invalid injector name", severity: "warn", desc: "A WP Context Injector row used a variable name that isn't valid." },
|
||||
{ label: "Reserved injector name", severity: "warn", desc: "A WP Context Injector row name starts with two underscores, which are kept for the engine." },
|
||||
{ label: "Reserved injector name", severity: "warn", desc: "A WP Context Injector row name starts with _, which is kept for the engine." },
|
||||
{ label: "Model Info found nothing", severity: "warn", desc: "WP Model Info has no model wired and nothing pinned, so $model_family, $model_variant and $model_name are empty." },
|
||||
{ label: "Model Info rule skipped", severity: "warn", desc: "One of WP Model Info's variant rules couldn't be read (often a broken pattern), so it was skipped." },
|
||||
{ label: "Toggles setup problem", severity: "warn", desc: "Something in a WP Toggles node's setup doesn't add up, such as two rows writing the same variable. The message says what." },
|
||||
{ label: "Upstream turned on several", severity: "warn", desc: "In Pick one with Upstream can pick, upstream variables turned on more than one row, so the first of them was picked." },
|
||||
];
|
||||
|
||||
const scannerRules: WarningRow[] = [
|
||||
|
||||
@@ -356,13 +356,13 @@ describe("installEnvelope — natively-supported future versions install as-is",
|
||||
expect(opts[0].value).toBe("at @{0de11002#Setting:$model_variant}");
|
||||
});
|
||||
|
||||
it("rejects a v12 envelope (> MAX_KNOWN) at parse with the future-version error", async () => {
|
||||
const v12Envelope = {
|
||||
schema_version: 12,
|
||||
it("rejects a v13 envelope (> MAX_KNOWN) at parse with the future-version error", async () => {
|
||||
const v13Envelope = {
|
||||
schema_version: 13,
|
||||
bundles: [], wildcards: [], fixed_values: [], combines: [], derivations: [], constraints: [], categories: [], templates: [],
|
||||
};
|
||||
const { importExport, seen } = fakeCommit();
|
||||
const result = await installEnvelope({ envelope: v12Envelope }, { importExport });
|
||||
const result = await installEnvelope({ envelope: v13Envelope }, { importExport });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error?.code).toBe("parse_failed");
|
||||
|
||||
@@ -31,7 +31,7 @@ export const CURRENT_SCHEMA_VERSION = 2;
|
||||
/**
|
||||
* Highest schema version this runtime can correctly READ + WRITE — distinct
|
||||
* from CURRENT_SCHEMA_VERSION (the migration-chain head, which stays 2 because
|
||||
* v2→v3 through v10→v11 are no-ops). This is the value advertised to the community
|
||||
* v2→v3 through v11→v12 are no-ops). This is the value advertised to the community
|
||||
* publish-gate / boot catalog-probe ("am I new enough to publish?").
|
||||
*
|
||||
* MAINTENANCE CONTRACT: bump this whenever `schemaVersionForPayload()` learns
|
||||
@@ -39,7 +39,7 @@ export const CURRENT_SCHEMA_VERSION = 2;
|
||||
* very shapes this runtime just learned to produce, one version up. The
|
||||
* regression test below pins MAX_KNOWN >= the highest content-stamp.
|
||||
*/
|
||||
export const MAX_KNOWN_SCHEMA_VERSION = 11;
|
||||
export const MAX_KNOWN_SCHEMA_VERSION = 12;
|
||||
|
||||
/**
|
||||
* Community catalog version for the SP2b nested multi-pick TEXT grammar
|
||||
@@ -165,6 +165,16 @@ export const NEGATED_TESTS_SCHEMA_VERSION = 10;
|
||||
*/
|
||||
export const FILTER_VARIABLES_SCHEMA_VERSION = 11;
|
||||
|
||||
/**
|
||||
* Community catalog version for WP Toggles booleans: text reading
|
||||
* `$name.bool` (or `$name.K.bool`), or a derivation test whose `var` is
|
||||
* `name.bool`. A toggle with a value writes the value, and `.bool` reads its
|
||||
* on/off. Additive, no migrator; a pre-v12 runtime reads `.bool` as a tag
|
||||
* axis and renders nothing, so the stamp makes it refuse the pack up front.
|
||||
* Publish stamps it only when a payload reads one (`usesBoolAccessor`).
|
||||
*/
|
||||
export const BOOL_ACCESSOR_SCHEMA_VERSION = 12;
|
||||
|
||||
export interface MigrationOk<T> {
|
||||
ok: true;
|
||||
migrated: T;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type ReferencingModule,
|
||||
} from "./dependencies";
|
||||
import {
|
||||
BOOL_ACCESSOR_SCHEMA_VERSION,
|
||||
CONSTRAINT_ONLY_SCHEMA_VERSION,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
DERIVATION_CONDITIONS_SCHEMA_VERSION,
|
||||
@@ -293,9 +294,30 @@ export function usesFilterVariables(node: unknown): boolean {
|
||||
return Object.values(node).some((value) => usesFilterVariables(value));
|
||||
}
|
||||
|
||||
/** `$name.bool` / `$name.K.bool` in text, and the same reference without `$`
|
||||
* as a condition's `var`. Mirror of the engine's `_BOOL_ACCESSOR_RE` /
|
||||
* `_BOOL_VAR_RE`. */
|
||||
const BOOL_ACCESSOR_RE = /\$[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?\.bool(?![A-Za-z0-9_])/;
|
||||
const BOOL_VAR_RE = /^\$?[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?\.bool$/;
|
||||
|
||||
/**
|
||||
* True when ANY text reads `$name.bool` or ANY derivation test's `var` is
|
||||
* `name.bool`, at any depth. Mirror of
|
||||
* `engine/migrations/stamping.py:uses_bool_accessor`.
|
||||
*/
|
||||
export function usesBoolAccessor(node: unknown): boolean {
|
||||
if (typeof node === "string") return BOOL_ACCESSOR_RE.test(node);
|
||||
if (Array.isArray(node)) return node.some((child) => usesBoolAccessor(child));
|
||||
if (!isPlainObject(node)) return false;
|
||||
if (typeof node.var === "string" && BOOL_VAR_RE.test(node.var.trim())) return true;
|
||||
return Object.values(node).some((value) => usesBoolAccessor(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the community catalog `schema_version` to stamp for a payload — the
|
||||
* MAX version any feature in the payload requires:
|
||||
* - `BOOL_ACCESSOR_SCHEMA_VERSION` (12) when ANY text or condition reads
|
||||
* `$name.bool` (`usesBoolAccessor`).
|
||||
* - `FILTER_VARIABLES_SCHEMA_VERSION` (11) when ANY pool or ref filter
|
||||
* reads a `$name` variable (`usesFilterVariables`).
|
||||
* - `NEGATED_TESTS_SCHEMA_VERSION` (10) when ANY derivation test uses
|
||||
@@ -328,6 +350,7 @@ export function usesFilterVariables(node: unknown): boolean {
|
||||
* doesn't actually use a newer feature.
|
||||
*/
|
||||
export function schemaVersionForPayload(payload: Record<string, unknown>): number {
|
||||
if (usesBoolAccessor(payload)) return BOOL_ACCESSOR_SCHEMA_VERSION;
|
||||
if (usesFilterVariables(payload)) return FILTER_VARIABLES_SCHEMA_VERSION;
|
||||
if (usesNegatedTests(payload)) return NEGATED_TESTS_SCHEMA_VERSION;
|
||||
if (usesFallbackOption(payload)) return FALLBACK_SCHEMA_VERSION;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createApp, effectScope, type App, type Component, type EffectScope } from "vue";
|
||||
|
||||
import { nodeCollapseAnimating } from "../extension/_stashes";
|
||||
import { nodeCollapseAnimating, nodeWidgetHosts } from "../extension/_stashes";
|
||||
import { isVueNodes } from "../extension/renderer";
|
||||
import { installClipboardShield } from "./clipboard-shield";
|
||||
import { registerOverlapClip } from "./dom-clip";
|
||||
@@ -226,6 +226,11 @@ export function createDomWidgetHost<P extends Record<string, unknown>>(
|
||||
host.classList.add("wp-widget");
|
||||
host.dataset.wpWidget = widgetName;
|
||||
liveHosts.add(host);
|
||||
{
|
||||
let hosts = nodeWidgetHosts.get(node);
|
||||
if (!hosts) nodeWidgetHosts.set(node, (hosts = new Set()));
|
||||
hosts.add(host);
|
||||
}
|
||||
host.style.width = "100%";
|
||||
host.style.boxSizing = "border-box";
|
||||
|
||||
@@ -622,6 +627,7 @@ export function createDomWidgetHost<P extends Record<string, unknown>>(
|
||||
if (unmounted) return;
|
||||
unmounted = true;
|
||||
liveHosts.delete(host);
|
||||
nodeWidgetHosts.get(node)?.delete(host);
|
||||
// Each step guarded so one failing teardown can't strand the rest.
|
||||
const steps: Array<() => void> = [
|
||||
() => scope.stop(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { attachThemeDetector } from "../extension/theme-detector";
|
||||
import { reactiveFromGraph } from "../extension/reactive";
|
||||
import { findRootGraph, showGraph, type LiteGraphLike, type LiteNodeLike } from "../extension/graph";
|
||||
import { baseCodename } from "../extension/node-codename";
|
||||
import { flashInNode, type FlashTarget } from "./flash-target";
|
||||
|
||||
const DebugViewer = defineAsyncComponent(() => import("../components/debug/DebugViewer.vue"));
|
||||
|
||||
@@ -59,8 +60,9 @@ function nodeInfo(id: string): { title: string; codename: string } {
|
||||
|
||||
/** Select + centre the node. A node in another graph than the one on screen
|
||||
* (inside a subgraph, or back out at the root) opens that graph first, the
|
||||
* same way the breadcrumb does. */
|
||||
export function focusNode(id: string): void {
|
||||
* same way the breadcrumb does. With a `target`, the row it names (a module,
|
||||
* or the field that writes a variable) blinks once the node is in view. */
|
||||
export function focusNode(id: string, target?: FlashTarget): void {
|
||||
const hit = findExecNode(id);
|
||||
const canvas = (app as unknown as { canvas?: CanvasLike }).canvas;
|
||||
if (!hit || !canvas) return;
|
||||
@@ -71,6 +73,7 @@ export function focusNode(id: string): void {
|
||||
canvas.selectNode?.(node);
|
||||
canvas.centerOnNode?.(node);
|
||||
canvas.setDirty?.(true, true);
|
||||
if (target) flashInNode(node, target);
|
||||
};
|
||||
showGraph(canvas, hit.graph, show);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { nodeWidgetHosts } from "../extension/_stashes";
|
||||
import { blink, findFlashElement, flashInNode } from "./flash-target";
|
||||
|
||||
function host(html: string): HTMLElement {
|
||||
const h = document.createElement("div");
|
||||
h.className = "wp-widget";
|
||||
h.innerHTML = html;
|
||||
document.body.appendChild(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
describe("Show node blink", () => {
|
||||
beforeEach(() => {
|
||||
// jsdom lays nothing out; treat every element as visible.
|
||||
vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("finds a Context module by uid before any variable match", () => {
|
||||
const h = host('<div data-wp-vars="hair">a</div><div data-uid="m-1">b</div>');
|
||||
expect(findFlashElement([h], { uid: "m-1", vars: ["hair"] })?.textContent).toBe("b");
|
||||
});
|
||||
|
||||
it("finds another node's field by the variable it writes", () => {
|
||||
const h = host('<div data-wp-vars="i i_total">loop</div><div data-wp-vars="i_value">values</div>');
|
||||
expect(findFlashElement([h], { vars: ["i_total"] })?.textContent).toBe("loop");
|
||||
expect(findFlashElement([h], { vars: ["i_value"] })?.textContent).toBe("values");
|
||||
expect(findFlashElement([h], { vars: ["other"] })).toBeNull();
|
||||
});
|
||||
|
||||
it("skips hosts that left the page", () => {
|
||||
const gone = document.createElement("div");
|
||||
gone.innerHTML = '<div data-wp-vars="x">old</div>';
|
||||
const h = host('<div data-wp-vars="x">new</div>');
|
||||
expect(findFlashElement([gone, h], { vars: ["x"] })?.textContent).toBe("new");
|
||||
});
|
||||
|
||||
it("blinks the field once the node is in view", () => {
|
||||
vi.useFakeTimers();
|
||||
const node = {};
|
||||
const h = host('<div data-wp-vars="model_family">Family</div>');
|
||||
nodeWidgetHosts.set(node, new Set([h]));
|
||||
const el = h.firstElementChild as HTMLElement;
|
||||
const animate = vi.fn();
|
||||
el.animate = animate as unknown as HTMLElement["animate"];
|
||||
flashInNode(node, { vars: ["model_family"] });
|
||||
expect(animate).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(animate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does nothing without animate support", () => {
|
||||
const el = document.createElement("div");
|
||||
expect(() => blink(el)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* WP Debug "Show node" blink: after the canvas centres a node, find the row
|
||||
* the step came from inside that node's widgets and pulse it.
|
||||
*
|
||||
* Context modules (and bundle children) are found by `data-uid`; rows of the
|
||||
* other nodes (Injector, Model Info, Toggles, Loop) carry the variables they
|
||||
* write in `data-wp-vars` (space separated, no `$`).
|
||||
*/
|
||||
import { nodeWidgetHosts } from "../extension/_stashes";
|
||||
|
||||
export interface FlashTarget {
|
||||
/** Module instance `_uid` (Context rows, bundle children). */
|
||||
uid?: string;
|
||||
/** Variable names (no `$`) the row writes. */
|
||||
vars?: string[];
|
||||
}
|
||||
|
||||
function escapeAttr(v: string): string {
|
||||
return typeof CSS !== "undefined" && typeof CSS.escape === "function"
|
||||
? CSS.escape(v)
|
||||
: v.replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** The element in `hosts` that best stands for `target`, or null. */
|
||||
export function findFlashElement(hosts: Iterable<HTMLElement>, target: FlashTarget): HTMLElement | null {
|
||||
const live = [...hosts].filter((h) => h.isConnected);
|
||||
const selectors: string[] = [];
|
||||
if (target.uid) selectors.push(`[data-uid="${escapeAttr(target.uid)}"]`);
|
||||
for (const v of target.vars ?? []) if (v) selectors.push(`[data-wp-vars~="${escapeAttr(v)}"]`);
|
||||
for (const sel of selectors) {
|
||||
for (const h of live) {
|
||||
const el = h.querySelector<HTMLElement>(sel);
|
||||
if (el && el.getClientRects().length) return el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Scroll `el` into view inside its own widget only (never the page or the
|
||||
* canvas container, which `scrollIntoView` would also move). */
|
||||
function revealInHost(el: HTMLElement, host: HTMLElement): void {
|
||||
for (let p = el.parentElement; p && p !== host.parentElement; p = p.parentElement) {
|
||||
if (p.scrollHeight <= p.clientHeight + 1) continue;
|
||||
const oy = getComputedStyle(p).overflowY;
|
||||
if (oy !== "auto" && oy !== "scroll") continue;
|
||||
const pr = p.getBoundingClientRect();
|
||||
const er = el.getBoundingClientRect();
|
||||
if (er.top < pr.top) p.scrollTop -= pr.top - er.top + 8;
|
||||
else if (er.bottom > pr.bottom) p.scrollTop += er.bottom - pr.bottom + 8;
|
||||
}
|
||||
}
|
||||
|
||||
const RING = "var(--wp-accent)";
|
||||
|
||||
/** Pulse `el` twice with an accent ring. */
|
||||
export function blink(el: HTMLElement): void {
|
||||
if (typeof el.animate !== "function") return;
|
||||
const reduce = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)")?.matches === true;
|
||||
const on = { boxShadow: `0 0 0 2px ${RING}, 0 0 14px 2px ${RING}` };
|
||||
const off = { boxShadow: "0 0 0 0 transparent, 0 0 0 0 transparent" };
|
||||
el.animate(reduce ? [on, on, off] : [off, on, off, on, off], {
|
||||
duration: reduce ? 900 : 1100,
|
||||
easing: "ease-in-out",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and blink `target` inside `node`'s widgets. The node may have just
|
||||
* been opened in another graph, so this keeps looking for about a second.
|
||||
*/
|
||||
export function flashInNode(node: object, target: FlashTarget): void {
|
||||
if (!target.uid && !target.vars?.length) return;
|
||||
let tries = 0;
|
||||
const look = (): void => {
|
||||
const hosts = nodeWidgetHosts.get(node);
|
||||
const el = hosts ? findFlashElement(hosts, target) : null;
|
||||
if (el) {
|
||||
const host = [...(hosts ?? [])].find((h) => h.contains(el));
|
||||
if (host) revealInHost(el, host);
|
||||
blink(el);
|
||||
return;
|
||||
}
|
||||
if (++tries < 12) setTimeout(look, 90);
|
||||
};
|
||||
// Let the canvas finish centring (and a graph switch mount its nodes).
|
||||
setTimeout(look, 160);
|
||||
}
|
||||
@@ -92,6 +92,10 @@ export function varBaseName(raw: string): string {
|
||||
* `$pose.0.neg`) — send-to-negative. Reserved: no `accepts` tag group may use
|
||||
* this name. Mirrors `NEG_ACCESSOR` in `engine/syntax/resolve.py`. */
|
||||
export const NEG_ACCESSOR = "neg";
|
||||
/** `$name.bool` reads a WP Toggles variable's on/off, whatever value the
|
||||
* toggle writes. Reserved like `neg`. Mirrors `BOOL_ACCESSOR` in
|
||||
* `engine/syntax/resolve.py`. */
|
||||
export const BOOL_ACCESSOR = "bool";
|
||||
|
||||
export function varAccessorParts(
|
||||
raw: string,
|
||||
|
||||
@@ -120,7 +120,7 @@ export function create(node: TogglesHostNode, inputName: string) {
|
||||
const ctlNames: string[] = [];
|
||||
const ctlNode = node as unknown as CtlNode;
|
||||
/** Bumped by the pencil in ComfyUI's selection toolbar
|
||||
* (extension/node-toolbar.ts); the widget flips its edit view. */
|
||||
* (extension/node-toolbar.ts); the widget opens its edit window. */
|
||||
const editRequest = ref(0);
|
||||
nodeEditors.set(node, () => {
|
||||
editRequest.value += 1;
|
||||
@@ -238,6 +238,7 @@ export function create(node: TogglesHostNode, inputName: string) {
|
||||
controls,
|
||||
canvasGroups: canvasGroups.value,
|
||||
editRequest: editRequest.value,
|
||||
nodeTitle: (node as { title?: string }).title ?? "",
|
||||
upstreamVars: upstreamVars.value,
|
||||
varProducers: varProducers.value,
|
||||
"onUpdate:modelValue": onUpdate,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""`$name.bool`: WP Toggles' on/off, in the text resolver and in conditions."""
|
||||
from engine import template
|
||||
from engine.modules.derivation_handler import _ctx_get, _ctx_has
|
||||
|
||||
|
||||
def test_resolver_reads_the_table_then_a_true_false_value():
|
||||
ctx = {"rain": "heavy rain", "wind": "false", "mood": "calm",
|
||||
"__wp_toggle_bools__": {"rain": True}}
|
||||
assert template.resolve_variables("$rain.bool $wind.bool [$mood.bool]", ctx) == "true false []"
|
||||
|
||||
|
||||
def test_unknown_variable_warns():
|
||||
ctx: dict = {"__wp_warnings__": []}
|
||||
assert template.resolve_variables("$ghost.bool", ctx) == ""
|
||||
assert any(w.get("type") == "unknown_var" for w in ctx["__wp_warnings__"])
|
||||
|
||||
|
||||
def test_conditions_read_bool():
|
||||
ctx = {"rain": "heavy rain", "__wp_toggle_bools__": {"rain": True, "fog": False}}
|
||||
assert _ctx_get(ctx, "rain.bool") == "true"
|
||||
assert _ctx_get(ctx, "fog.bool") == "false"
|
||||
assert _ctx_has(ctx, "rain.bool") is True
|
||||
assert _ctx_get(ctx, "mood.bool") == ""
|
||||
assert _ctx_has(ctx, "mood.bool") is False
|
||||
@@ -263,3 +263,34 @@ def test_wired_values_cap_at_the_frame_limit():
|
||||
seed=0, count=1, wp_context_loop_config="{}", values="\n".join(map(str, range(1200))),
|
||||
)
|
||||
assert len(out.values[0]) == 999
|
||||
|
||||
|
||||
def test_frames_carry_debug_trace_rows():
|
||||
"""WP Debug names the Loop as the writer of its variables and shows the
|
||||
frame each payload is, with its seed when the Loop drives seeds."""
|
||||
payloads = _execute(7, 4, {
|
||||
"override_seed": True, "bypass_frames": [1], "iteration_internal": True,
|
||||
"total_internal": False,
|
||||
})
|
||||
assert [p.internals["__wp_loop_index__"] for p in payloads] == [0, 2, 3]
|
||||
rows = payloads[1].debug["__wp_trace__"]
|
||||
assert [(r["binding"], r["value"], r["internal"]) for r in rows] == [
|
||||
("iteration", "3", True), ("iteration_total", "4", False),
|
||||
]
|
||||
assert all(r["node"] == "WP_ContextLoop" for r in rows)
|
||||
detail = rows[0]["detail"]
|
||||
assert detail["frame"] == 3 and detail["of"] == 4
|
||||
assert detail["seed"] == payloads[1].internals["__wp_seed_override__"]
|
||||
|
||||
|
||||
def test_trace_rows_note_the_value_line():
|
||||
out = WPContextLoop.execute(
|
||||
seed=0, count=1, wp_context_loop_config="{}", values=["a\nb"],
|
||||
)
|
||||
payloads = out.values[0]
|
||||
rows = payloads[1].debug["__wp_trace__"]
|
||||
value_row = next(r for r in rows if r["binding"] == "iteration_value")
|
||||
assert value_row["value"] == "b"
|
||||
assert value_row["detail"]["value_line"] == 2
|
||||
assert value_row["detail"]["values"] == 2
|
||||
assert "seed" not in value_row["detail"]
|
||||
|
||||
@@ -96,3 +96,22 @@ def test_lookup_var_joins_and_indexes_listvar():
|
||||
)
|
||||
def test_lookup_var(ctx, name, expected):
|
||||
assert lookup_var(_StubCtx(ctx), name) == expected
|
||||
|
||||
|
||||
class _BoolCtx:
|
||||
def __init__(self, ctx, internals):
|
||||
self.context = ctx
|
||||
self.internals = internals
|
||||
|
||||
|
||||
def test_lookup_var_bool_reads_the_toggle_switch():
|
||||
"""A toggle with a Value writes the value; `.bool` reads its switch."""
|
||||
ctx = _BoolCtx({"quick": "12", "plain": "true"}, {"__wp_toggle_bools__": {"quick": True}})
|
||||
assert lookup_var(ctx, "$quick") == "12"
|
||||
assert lookup_var(ctx, "$quick.bool") == "true"
|
||||
assert parse_bool(lookup_var(ctx, "$quick.bool"), 0, False) is True
|
||||
off = _BoolCtx({"quick": ""}, {"__wp_toggle_bools__": {"quick": False}})
|
||||
assert lookup_var(off, "quick.bool") == "false"
|
||||
# No table entry: a variable that already is true/false reads as itself.
|
||||
assert lookup_var(ctx, "$plain.bool") == "true"
|
||||
assert lookup_var(_StubCtx({"x": "red"}), "$x.bool") == ""
|
||||
|
||||
@@ -164,3 +164,81 @@ def test_warnings_name_the_module_that_raised_them():
|
||||
(warn,) = [x for x in ctx["__wp_warnings__"] if x["type"] == "unknown_ref"]
|
||||
assert warn["owner_uid"] == "w1" and warn["owner_id"] == "w1"
|
||||
assert warn["detail"]["name"] == "hat"
|
||||
|
||||
|
||||
# ── Options table, pool steps, seeds (WP Debug "why did it never pick X") ──
|
||||
|
||||
|
||||
def test_wildcard_lists_every_option_and_why_it_is_out():
|
||||
w = _wildcard(
|
||||
"w1", "mood", [("calm", 1), ("tense", 3), ("angry", 2), ("sad", 0)], name="Mood",
|
||||
enabled_options=["calm", "tense", "sad"], option_weights={"tense": 5},
|
||||
locked_seed=7,
|
||||
)
|
||||
w["payload"]["options"][0]["sub_categories"] = ["soft"]
|
||||
w["payload"]["options"][1]["sub_categories"] = ["soft"]
|
||||
w["payload"]["options"][3]["sub_categories"] = ["soft"]
|
||||
w["instance"]["category_filter"] = "soft"
|
||||
d = _row(_explain_run([w]), "w1")["detail"]
|
||||
rows = {r["id"]: r for r in d["options"]}
|
||||
assert rows["angry"]["cut"] == "filter" and rows["angry"]["weight"] is None
|
||||
assert "cut" not in rows["calm"] and rows["calm"]["weight"] == 1
|
||||
tense = rows["tense"]
|
||||
assert tense["base"] == 3 and tense["override"] == 5 and tense["weight"] == 5
|
||||
assert rows["sad"]["cut"] == "weight"
|
||||
assert d["steps"] == {"library": 4, "filter": 3, "toggles": 3, "live": 2}
|
||||
assert d["locked_seed"] is True
|
||||
|
||||
|
||||
def test_toggled_off_option_is_marked_toggle():
|
||||
w = _wildcard("w1", "mood", [("calm", 1), ("tense", 1)], enabled_options=["calm"])
|
||||
d = _row(_explain_run([w]), "w1")["detail"]
|
||||
assert {r["id"]: r.get("cut") for r in d["options"]} == {"calm": None, "tense": "toggle"}
|
||||
assert d["steps"]["toggles"] == 1
|
||||
|
||||
|
||||
def test_options_table_is_capped():
|
||||
from engine.modules.wildcard_handler import OPTIONS_SHOWN
|
||||
w = _wildcard("w1", "n", [(f"o{i}", 1) for i in range(OPTIONS_SHOWN + 5)])
|
||||
d = _row(_explain_run([w]), "w1")["detail"]
|
||||
assert len(d["options"]) == OPTIONS_SHOWN and d["options_more"] == 5
|
||||
|
||||
|
||||
def test_multi_pick_reports_its_separator():
|
||||
w = _wildcard(
|
||||
"w1", "outfit", [("a", 1), ("b", 1), ("c", 1)],
|
||||
pick_min=2, pick_max=2, pick_separator=" + ",
|
||||
)
|
||||
d = _row(_explain_run([w]), "w1")["detail"]
|
||||
assert d["separator"] == " + " and d["range"] == [2, 2]
|
||||
|
||||
|
||||
def test_match_variable_reports_the_value_it_read():
|
||||
w = _wildcard("w1", "style", [("pony", 1), ("sdxl", 1)], match_variable="model_variant")
|
||||
d = _row(_explain_run([w], model_variant="pony"), "w1")["detail"]
|
||||
assert d["match_variable"] == "model_variant" and d["match_value"] == "pony"
|
||||
|
||||
|
||||
def test_fixed_values_report_source_rows_and_toggled_off():
|
||||
fv = {
|
||||
"type": "fixed_values", "id": "f1", "_uid": "f1",
|
||||
"payload": {"values": [
|
||||
{"id": "a", "name": "quality", "value": "{best|best} quality"},
|
||||
{"id": "b", "name": "steps", "value": "28"},
|
||||
]},
|
||||
"instance": {"enabled_options": ["a"]},
|
||||
}
|
||||
d = _row(_explain_run([fv]), "f1")["detail"]
|
||||
assert d["source"] == "library" and d["toggled_off"] == ["steps"]
|
||||
assert d["rows"] == [{"name": "quality", "raw": "{best|best} quality"}]
|
||||
|
||||
|
||||
def test_combine_reports_template_and_reads():
|
||||
cb = {
|
||||
"type": "combine", "id": "c1", "_uid": "c1",
|
||||
"payload": {"template": "$mood $missing light", "output_var": "scene", "input_vars": []},
|
||||
"instance": {},
|
||||
}
|
||||
d = _row(_explain_run([cb], mood="calm"), "c1")["detail"]
|
||||
assert d["template"] == "$mood $missing light" and d["template_source"] == "library"
|
||||
assert d["reads"] == {"mood": "calm", "missing": None}
|
||||
|
||||
@@ -442,3 +442,33 @@ def test_boolean_pass_through_drives_an_equals_true_rule():
|
||||
cond = {"var": "lora_on", "op": "equals", "value": "true"}
|
||||
assert _eval_condition(cond, on) is True
|
||||
assert _eval_condition(cond, off) is False
|
||||
|
||||
|
||||
def test_binding_warnings_say_what_was_skipped():
|
||||
"""WP Debug shows the message, so a skipped row must say why."""
|
||||
rows = json.dumps({"version": 1, "rows": [
|
||||
_row("input_0", "_meta"), _row("input_1", "has spaces"),
|
||||
]})
|
||||
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="a", input_1="b")
|
||||
warnings = out.values[0].debug["__wp_warnings__"]
|
||||
by_type = {w["type"]: w for w in warnings}
|
||||
assert "$_meta" in by_type["injector_reserved_binding"]["message"]
|
||||
assert "has spaces" in by_type["injector_invalid_binding"]["message"]
|
||||
assert all(w["severity"] == "warning" for w in warnings)
|
||||
|
||||
|
||||
def test_trace_detail_names_the_input_and_how_a_template_filled():
|
||||
rows = json.dumps({
|
||||
"version": 1,
|
||||
"rows": [
|
||||
_row("input_1", "plain"),
|
||||
_row_with_template("input_0", "phrase", "hi $input_0 $nope"),
|
||||
],
|
||||
})
|
||||
out = WPContextInjector.execute(wp_rows=rows, upstream=None, input_0="x", input_1="y")
|
||||
trace = {t["binding"]: t for t in out.values[0].debug["__wp_trace__"]}
|
||||
assert trace["plain"]["detail"] == {"input": "input_1"}
|
||||
assert trace["phrase"]["detail"] == {
|
||||
"input": "input_0", "template": "hi $input_0 $nope",
|
||||
"reads": {"input_0": "x", "nope": None},
|
||||
}
|
||||
|
||||
@@ -109,14 +109,14 @@ def test_defaults_missing_arrays_to_empty():
|
||||
# `target_select`; v5 = additive `accepts` tag axes; v6 = the constraint
|
||||
# `only` rule; v7 = derivation AND / OR condition groups; v8 = send-to-negative
|
||||
# `negative` text; v9 = the wildcard `fallback` option flag; v10 = NOT tests;
|
||||
# v11 = `$name` terms in filters) is shape-compatible with
|
||||
# v11 = `$name` terms in filters; v12 = `$name.bool`) is shape-compatible with
|
||||
# v2 and natively handled at runtime, so the engine's commit-side re-validate must accept it AS-IS:
|
||||
# not rejected, not migrated (the while-loop bound stays CURRENT so there is
|
||||
# nothing to do), schema_version preserved. Only v > MAX_KNOWN rejects.
|
||||
|
||||
|
||||
def test_max_known_schema_version_is_11():
|
||||
assert MAX_KNOWN_SCHEMA_VERSION == 11
|
||||
def test_max_known_schema_version_is_12():
|
||||
assert MAX_KNOWN_SCHEMA_VERSION == 12
|
||||
|
||||
|
||||
def test_v3_payload_passes_through_unchanged():
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import pytest
|
||||
|
||||
from engine.migrations import (
|
||||
BOOL_ACCESSOR_SCHEMA_VERSION,
|
||||
CONSTRAINT_ONLY_SCHEMA_VERSION,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
DERIVATION_CONDITIONS_SCHEMA_VERSION,
|
||||
@@ -295,3 +296,20 @@ def test_ref_filter_variables_need_v11(text, expected):
|
||||
row = {"id": "eeeeeeee", "type": "wildcard", "name": "w",
|
||||
"payload": {"var_binding": "w", "options": [{"id": "o1", "value": text}]}}
|
||||
assert schema_version_for_payload(row) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node,expected", [
|
||||
({"template": "a $rain.bool b"}, BOOL_ACCESSOR_SCHEMA_VERSION),
|
||||
({"template": "a $rain.0.bool b"}, BOOL_ACCESSOR_SCHEMA_VERSION),
|
||||
({"template": "a $rain.boolean b"}, CURRENT_SCHEMA_VERSION),
|
||||
({"template": "file.bool"}, CURRENT_SCHEMA_VERSION),
|
||||
(
|
||||
{"rules": [{"branches": [
|
||||
{"condition": {"var": "rain.bool", "op": "equals", "value": "true"}},
|
||||
]}]},
|
||||
BOOL_ACCESSOR_SCHEMA_VERSION,
|
||||
),
|
||||
])
|
||||
def test_bool_accessor_needs_v12(node, expected):
|
||||
assert schema_version_for_payload(node) == expected
|
||||
assert MAX_KNOWN_SCHEMA_VERSION >= BOOL_ACCESSOR_SCHEMA_VERSION
|
||||
|
||||
@@ -124,6 +124,17 @@ def test_pipeline_picks_the_pinned_option():
|
||||
assert mods[0]["instance"] == {"variable_binding": "hair"}
|
||||
|
||||
|
||||
def test_swept_pick_says_so_in_the_debug_detail():
|
||||
"""WP Debug says "swept" rather than "pinned option" for a sweep frame."""
|
||||
mods = [_wildcard("u1", "hair", ["red", "blue"])]
|
||||
ctx = PipelineEngine().run(
|
||||
mods, ctx={PIN_KEY: {"u1": "hair1"}, "__wp_explain__": True}, seed=7,
|
||||
)
|
||||
row = next(t for t in ctx["__wp_trace__"] if t.get("_uid") == "u1")
|
||||
assert row["detail"]["mode"] == "pinned"
|
||||
assert row["detail"]["swept"] is True
|
||||
|
||||
|
||||
def test_unknown_pinned_option_falls_back_to_a_roll():
|
||||
mods = [_wildcard("u1", "hair", ["red", "blue"])]
|
||||
ctx = PipelineEngine().run(mods, ctx={PIN_KEY: {"u1": "gone"}}, seed=7)
|
||||
|
||||
@@ -16,6 +16,8 @@ def test_corpus(case):
|
||||
parsed = toggles.parse_config(json.dumps(case["config"]))
|
||||
cfg, _ = toggles.with_upstream(parsed, case.get("upstream"))
|
||||
assert toggles.resolve(cfg) == case["values"]
|
||||
assert toggles.bools(cfg) == case["bools"]
|
||||
assert toggles.own_negatives(cfg) == case.get("negatives", {})
|
||||
n = len(case["outputs"])
|
||||
vals = toggles.output_values(cfg)
|
||||
assert len(vals) == toggles.MAX_OUTPUTS
|
||||
|
||||
Vendored
+217
-25
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "Shared by engine/toggles.py (tests/engine/test_toggles.py) and src/extension/toggles.ts (toggles.test.ts). outputs lists the pinned outputs only; the node pads to 16 with false. A case's optional upstream is the upstream Context (applied with with_upstream / withUpstream first). A case's optional internal lists the variables kept out of the prompt (internal_vars / internalToggleVars), sorted.",
|
||||
"_comment": "Shared by engine/toggles.py (tests/engine/test_toggles.py) and src/extension/toggles.ts (toggles.test.ts). outputs lists the pinned outputs only; the node pads to 16 with false. A case's optional upstream is the upstream Context (applied with with_upstream / withUpstream first). A case's optional internal lists the variables kept out of the prompt (internal_vars / internalToggleVars), sorted. values is what each variable holds (a toggle with a value or an off_value writes the value while on and the off_value, or \"\", while off); bools is each toggle variable's on/off ($name.bool, bools / toggleBools); negatives, when present, the toggle negatives filed under their own variables (negative while on, off_negative while off) (own_negatives / ownToggleNegatives).",
|
||||
"cases": [
|
||||
{
|
||||
"name": "plain switches",
|
||||
@@ -45,7 +45,11 @@
|
||||
"outputs": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"a": true,
|
||||
"b": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "child of an off parent is off",
|
||||
@@ -85,7 +89,11 @@
|
||||
"outputs": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"lora": false,
|
||||
"clothes": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "child of an on parent keeps its own choice",
|
||||
@@ -134,7 +142,12 @@
|
||||
"clothes": "true",
|
||||
"shoes": "false"
|
||||
},
|
||||
"outputs": []
|
||||
"outputs": [],
|
||||
"bools": {
|
||||
"lora": true,
|
||||
"clothes": true,
|
||||
"shoes": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chain",
|
||||
@@ -183,7 +196,12 @@
|
||||
"b": "false",
|
||||
"c": "false"
|
||||
},
|
||||
"outputs": []
|
||||
"outputs": [],
|
||||
"bools": {
|
||||
"a": false,
|
||||
"b": false,
|
||||
"c": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "loop is off",
|
||||
@@ -220,7 +238,11 @@
|
||||
"a": "false",
|
||||
"b": "false"
|
||||
},
|
||||
"outputs": []
|
||||
"outputs": [],
|
||||
"bools": {
|
||||
"a": false,
|
||||
"b": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "missing parent is off",
|
||||
@@ -245,7 +267,10 @@
|
||||
"values": {
|
||||
"a": "false"
|
||||
},
|
||||
"outputs": []
|
||||
"outputs": [],
|
||||
"bools": {
|
||||
"a": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pick one takes the first on and ignores needs",
|
||||
@@ -298,7 +323,12 @@
|
||||
"outputs": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"portrait": false,
|
||||
"full_body": true,
|
||||
"landscape": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pick one with nothing on",
|
||||
@@ -324,7 +354,10 @@
|
||||
"portrait": "false",
|
||||
"framing": ""
|
||||
},
|
||||
"outputs": []
|
||||
"outputs": [],
|
||||
"bools": {
|
||||
"portrait": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "outputs follow out, not row order",
|
||||
@@ -364,7 +397,11 @@
|
||||
"outputs": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"a": true,
|
||||
"b": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "repeated out falls back to row order",
|
||||
@@ -404,7 +441,11 @@
|
||||
"outputs": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"a": true,
|
||||
"b": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unpinned and bad vars",
|
||||
@@ -453,7 +494,10 @@
|
||||
},
|
||||
"outputs": [
|
||||
true
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "same var twice, lower wins",
|
||||
@@ -489,7 +533,10 @@
|
||||
"values": {
|
||||
"x": "false"
|
||||
},
|
||||
"outputs": []
|
||||
"outputs": [],
|
||||
"bools": {
|
||||
"x": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upstream sets a switch only from a lone yes/no word",
|
||||
@@ -579,7 +626,14 @@
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"a": true,
|
||||
"b": false,
|
||||
"c": true,
|
||||
"d": false,
|
||||
"e": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upstream feeds Pick one before the first-on rule",
|
||||
@@ -626,7 +680,11 @@
|
||||
"outputs": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"a": false,
|
||||
"b": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pick one: upstream yes beats the canvas pick; a value replaces the name",
|
||||
@@ -680,8 +738,8 @@
|
||||
"dusk": "1"
|
||||
},
|
||||
"values": {
|
||||
"day": "false",
|
||||
"night": "true",
|
||||
"day": "",
|
||||
"night": "moonlit night",
|
||||
"dusk": "false",
|
||||
"look": "moonlit night"
|
||||
},
|
||||
@@ -689,7 +747,12 @@
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"day": false,
|
||||
"night": true,
|
||||
"dusk": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pick one: the upstream picked variable names the pick",
|
||||
@@ -749,7 +812,12 @@
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"day": false,
|
||||
"night": false,
|
||||
"dusk": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "internal by default, visible shows",
|
||||
@@ -788,7 +856,11 @@
|
||||
"outputs": [],
|
||||
"internal": [
|
||||
"a"
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"a": true,
|
||||
"b": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pick one variable shows with group_visible",
|
||||
@@ -823,14 +895,18 @@
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"dawn": "true",
|
||||
"dawn": "at dawn",
|
||||
"dusk": "false",
|
||||
"time": "at dawn"
|
||||
},
|
||||
"outputs": [],
|
||||
"internal": [
|
||||
"dawn"
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"dawn": true,
|
||||
"dusk": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pick one variable internal without group_visible",
|
||||
@@ -871,7 +947,11 @@
|
||||
"dawn",
|
||||
"dusk",
|
||||
"time"
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"dawn": false,
|
||||
"dusk": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "last row writing a variable decides internal",
|
||||
@@ -909,7 +989,119 @@
|
||||
"outputs": [],
|
||||
"internal": [
|
||||
"a"
|
||||
]
|
||||
],
|
||||
"bools": {
|
||||
"a": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "any on: a value prints while on and nothing while off; .bool keeps the switch",
|
||||
"config": {
|
||||
"mode": "any",
|
||||
"rows": [
|
||||
{
|
||||
"id": "a1",
|
||||
"kind": "toggle",
|
||||
"name": "Rain",
|
||||
"var": "rain",
|
||||
"on": true,
|
||||
"value": "heavy rain",
|
||||
"negative": "sunny"
|
||||
},
|
||||
{
|
||||
"id": "a2",
|
||||
"kind": "toggle",
|
||||
"name": "Fog",
|
||||
"var": "fog",
|
||||
"on": false,
|
||||
"value": "thick fog"
|
||||
},
|
||||
{
|
||||
"id": "a3",
|
||||
"kind": "toggle",
|
||||
"name": "Wind",
|
||||
"var": "wind",
|
||||
"on": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"outputs": [],
|
||||
"values": {
|
||||
"rain": "heavy rain",
|
||||
"fog": "",
|
||||
"wind": "true"
|
||||
},
|
||||
"bools": {
|
||||
"rain": true,
|
||||
"fog": false,
|
||||
"wind": true
|
||||
},
|
||||
"negatives": {
|
||||
"rain": "sunny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "any on off values",
|
||||
"config": {
|
||||
"version": 1,
|
||||
"mode": "any",
|
||||
"group_var": "",
|
||||
"rows": [
|
||||
{
|
||||
"id": "00000031",
|
||||
"kind": "toggle",
|
||||
"name": "Style",
|
||||
"var": "style",
|
||||
"on": false,
|
||||
"pin": false,
|
||||
"out": 0,
|
||||
"style": "buttons",
|
||||
"value": "anime style",
|
||||
"off_value": "photorealistic",
|
||||
"negative": "photo, 3d",
|
||||
"off_negative": "anime, cartoon",
|
||||
"on_label": "Anime",
|
||||
"off_label": "Real"
|
||||
},
|
||||
{
|
||||
"id": "00000032",
|
||||
"kind": "toggle",
|
||||
"name": "Rain",
|
||||
"var": "rain",
|
||||
"on": true,
|
||||
"pin": false,
|
||||
"out": 0,
|
||||
"style": "buttons",
|
||||
"off_value": "clear sky",
|
||||
"off_negative": "rain"
|
||||
},
|
||||
{
|
||||
"id": "00000033",
|
||||
"kind": "toggle",
|
||||
"name": "Fog",
|
||||
"var": "fog",
|
||||
"on": false,
|
||||
"pin": false,
|
||||
"out": 0,
|
||||
"style": "buttons",
|
||||
"value": "thick fog"
|
||||
}
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"style": "photorealistic",
|
||||
"rain": "",
|
||||
"fog": ""
|
||||
},
|
||||
"bools": {
|
||||
"style": false,
|
||||
"rain": true,
|
||||
"fog": false
|
||||
},
|
||||
"negatives": {
|
||||
"style": "anime, cartoon"
|
||||
},
|
||||
"outputs": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,3 +292,20 @@ def test_sets_and_stories_modes_hold_what_they_say():
|
||||
beats = graph.run_frames(loop, asm)
|
||||
assert len({_subject(p) for p in beats}) == 1, beats
|
||||
assert len(set(beats)) == len(beats)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pick,quick", [("Quick", True), ("Full", False)])
|
||||
def test_quick_preview_switch_follows_the_pick(pick: str, quick: bool):
|
||||
"""Example 2: the Quick toggle has a Value (its steps), so Var → Bool reads its switch
|
||||
through `$quick_preview.bool`, not the value."""
|
||||
path = next(p for p in EXAMPLES if p.stem.startswith("2 "))
|
||||
wf = _load(path)
|
||||
tog = next(n for n in wf["nodes"] if n["type"] == "WP_Toggles")
|
||||
wv = tog["widgets_values"]
|
||||
i = wv.index(_json_value(wv, "rows"))
|
||||
cfg = json.loads(wv[i])
|
||||
for row in cfg["rows"]:
|
||||
row["on"] = row.get("name") == pick
|
||||
wv[i] = json.dumps(cfg)
|
||||
node = next(n for n in wf["nodes"] if n["type"] == "WP_VarToBool")
|
||||
assert Graph(wf).output(node, 0) is quick
|
||||
|
||||
@@ -151,3 +151,10 @@ def test_frame_zero_payload_survives_a_bypassed_frame_zero():
|
||||
payloads = WPContextLoop.execute(seed=1, count=3, wp_context_loop_config=raw).values[0]
|
||||
assert [p.context["iteration"] for p in payloads] == ["2", "3"]
|
||||
assert all(p.internals["__wp_frame0__"]["context"]["iteration"] == "1" for p in payloads)
|
||||
|
||||
|
||||
def test_trace_detail_says_strategy_and_skipped_frames():
|
||||
payloads, _ = _run(0, 5, {"override_seed": True, "bypass_frames": [1, 3]})
|
||||
row = payloads[0].debug["__wp_trace__"][0]
|
||||
assert row["detail"]["skipped"] == 2
|
||||
assert "strategy" in row["detail"]
|
||||
|
||||
@@ -194,3 +194,14 @@ def test_internal_variables_stay_out_of_the_assembler():
|
||||
)
|
||||
assert res.values[0].startswith("[sdxl] [")
|
||||
assert "pony" not in res.values[0]
|
||||
|
||||
|
||||
def test_trace_details_name_the_class_rule_and_file():
|
||||
out = _run(model=_model(SDXL))
|
||||
trace = {t["binding"]: t for t in out.values[0].debug["__wp_trace__"]}
|
||||
assert trace["model_family"]["detail"] == {"config_class": "SDXL"}
|
||||
assert trace["model_name"]["detail"]["file"] == "SDXL/ponyDiffusionV6XL.safetensors"
|
||||
variant = trace["model_variant"]["detail"]
|
||||
assert variant["stem"] == "ponyDiffusionV6XL"
|
||||
got = out.values[0].context["model_variant"]
|
||||
assert variant.get("rule_variant") == got or "rule" not in variant
|
||||
|
||||
@@ -193,3 +193,113 @@ def test_pick_value_missing_variable_warns():
|
||||
warns = payload.debug["__wp_warnings__"]
|
||||
assert any(w["type"] == "unknown_var" and "$nowhere" in w["message"] for w in warns)
|
||||
assert all(w["node_id"] == "7" for w in warns)
|
||||
|
||||
|
||||
def test_pick_one_files_the_picked_negative():
|
||||
up = ContextPayload(
|
||||
context={"shade": "dark"},
|
||||
debug={},
|
||||
internals={negatives.NEG_KEY: {"time": [{"text": "old", "pick": None, "source": "x"}]}},
|
||||
)
|
||||
cfg = {"mode": "one", "group_var": "time", "rows": [
|
||||
{"id": "1", "kind": "toggle", "name": "Day", "var": "day", "on": False,
|
||||
"negative": "night sky"},
|
||||
{"id": "2", "kind": "toggle", "name": "Night", "var": "night", "on": True,
|
||||
"negative": " $shade sun "},
|
||||
]}
|
||||
payload = _run(config=cfg, upstream=up).values[0]
|
||||
assert payload.internals[negatives.NEG_KEY]["time"] == [
|
||||
{"text": "dark sun", "pick": None, "source": "WP_Toggles"},
|
||||
]
|
||||
|
||||
|
||||
def test_pick_one_without_negative_clears_old_one():
|
||||
up = ContextPayload(
|
||||
context={}, debug={},
|
||||
internals={negatives.NEG_KEY: {"time": [{"text": "old", "pick": None, "source": "x"}]}},
|
||||
)
|
||||
cfg = {"mode": "one", "group_var": "time", "rows": [
|
||||
{"id": "1", "kind": "toggle", "name": "Day", "var": "day", "on": True},
|
||||
]}
|
||||
payload = _run(config=cfg, upstream=up).values[0]
|
||||
assert "time" not in payload.internals[negatives.NEG_KEY]
|
||||
|
||||
|
||||
def test_any_on_value_bool_and_negative():
|
||||
up = ContextPayload(
|
||||
context={"weather": "storm"}, debug={},
|
||||
internals={negatives.NEG_KEY: {"fog": [{"text": "old", "pick": None, "source": "x"}]}},
|
||||
)
|
||||
cfg = {"mode": "any", "rows": [
|
||||
{"id": "1", "kind": "toggle", "name": "Rain", "var": "rain", "on": True,
|
||||
"value": "heavy $weather rain", "negative": "sunny"},
|
||||
{"id": "2", "kind": "toggle", "name": "Fog", "var": "fog", "on": False,
|
||||
"value": "thick fog", "negative": "clear air"},
|
||||
{"id": "3", "kind": "toggle", "name": "Wind", "var": "wind", "on": True},
|
||||
]}
|
||||
payload = _run(config=cfg, upstream=up).values[0]
|
||||
assert payload.context["rain"] == "heavy storm rain"
|
||||
assert payload.context["fog"] == ""
|
||||
assert payload.context["wind"] == "true"
|
||||
assert payload.internals["__wp_toggle_bools__"] == {"rain": True, "fog": False, "wind": True}
|
||||
neg = payload.internals[negatives.NEG_KEY]
|
||||
assert neg["rain"] == [{"text": "sunny", "pick": None, "source": "WP_Toggles"}]
|
||||
assert "fog" not in neg # off: the old negative is cleared, its own not filed
|
||||
rows = {t["binding"]: t for t in payload.debug["__wp_trace__"]}
|
||||
assert rows["rain"]["bool"] is True and rows["rain"]["has_value"] is True
|
||||
assert rows["rain"]["negative"] == "sunny"
|
||||
assert rows["wind"]["bool"] is True and "has_value" not in rows["wind"]
|
||||
|
||||
|
||||
def test_any_on_off_value_and_off_negative():
|
||||
up = ContextPayload(context={"look": "film"}, debug={}, internals={})
|
||||
cfg = {"mode": "any", "rows": [
|
||||
{"id": "1", "kind": "toggle", "name": "Style", "var": "style", "on": False,
|
||||
"value": "anime style", "negative": "photo",
|
||||
"off_value": "$look photo", "off_negative": "anime, cartoon"},
|
||||
{"id": "2", "kind": "toggle", "name": "Rain", "var": "rain", "on": True,
|
||||
"off_value": "clear sky", "off_negative": "rain"},
|
||||
]}
|
||||
payload = _run(config=cfg, upstream=up).values[0]
|
||||
assert payload.context["style"] == "film photo"
|
||||
assert payload.context["rain"] == "" # on, no Value: nothing, not "true"
|
||||
assert payload.internals["__wp_toggle_bools__"] == {"style": False, "rain": True}
|
||||
neg = payload.internals[negatives.NEG_KEY]
|
||||
assert neg["style"] == [{"text": "anime, cartoon", "pick": None, "source": "WP_Toggles"}]
|
||||
assert "rain" not in neg
|
||||
rows = {t["binding"]: t for t in payload.debug["__wp_trace__"]}
|
||||
assert rows["style"]["has_value"] is True and rows["style"]["negative"] == "anime, cartoon"
|
||||
|
||||
|
||||
def test_bool_accessor_reads_across_nodes_in_the_assembler():
|
||||
from engine import template
|
||||
from engine.context import strip_internals, with_resolver_tables
|
||||
|
||||
cfg = {"mode": "any", "rows": [
|
||||
{"id": "1", "kind": "toggle", "name": "Rain", "var": "rain", "on": True,
|
||||
"value": "heavy rain", "visible": True},
|
||||
{"id": "2", "kind": "toggle", "name": "Wind", "var": "wind", "on": False},
|
||||
]}
|
||||
payload = _run(config=cfg).values[0]
|
||||
render = {**payload.context, **payload.internals}
|
||||
ctx = with_resolver_tables(strip_internals(render), render)
|
||||
out = template.resolve_variables("$rain | $rain.bool | $wind.bool", ctx)
|
||||
assert out == "heavy rain | true | false"
|
||||
|
||||
|
||||
def test_trace_details_say_why_a_toggle_is_off_and_how_pick_one_chose():
|
||||
payload, *_ = _run().values
|
||||
trace = {t["binding"]: t for t in payload.debug["__wp_trace__"]}
|
||||
child = trace["use_own_clothes"]["detail"]
|
||||
assert child == {"switch": True, "needs": "LoRA character", "held_off": True}
|
||||
cfg = json.loads(json.dumps(PICK))
|
||||
cfg["group_upstream"] = True
|
||||
up = ContextPayload(
|
||||
context={"time": "noon", "night": "yes", "look": "Dusk"}, debug={}, internals={},
|
||||
)
|
||||
payload, *_ = _run(cfg, upstream=up).values
|
||||
trace = {t["binding"]: t for t in payload.debug["__wp_trace__"]}
|
||||
pick = trace["look"]["detail"]
|
||||
assert pick["picked"] == "Dusk" and pick["forced_by"] == "Dusk"
|
||||
assert pick["upstream_on"] == ["Night"]
|
||||
assert trace["night"]["detail"]["upstream_read"] == "yes"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user