Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfe62eb1c1 | ||
|
|
b7bfe8498c | ||
|
|
108c2b3e53 | ||
|
|
e41ae4ded0 |
@@ -24,8 +24,9 @@ Several on/off switches in one node. Each toggle writes `$its_variable` as `true
|
||||
- **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 nothing, 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.
|
||||
- **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.
|
||||
|
||||
@@ -6,12 +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).
|
||||
|
||||
|
||||
- **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. 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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -1752,7 +1752,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Editing a 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 nothing, 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.\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."
|
||||
"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",
|
||||
@@ -1760,7 +1760,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Use a toggle in the prompt",
|
||||
"text": "A toggle's variable is internal by default: rules read it, but the Assembler doesn't offer it, since true or false rarely belongs in a prompt. To print it, open the edit view and turn off the globe on its row. In Pick one, Show in Assembler does the same for the picked variable, so $time can print the picked toggle's Value.\nGive a toggle a Value and its variable holds that text while it is on and nothing while it is off: a Rain toggle with the Value \"heavy rain\" makes $rain print \"heavy rain\" or nothing. The switch itself is still there as $rain.bool, true or false, for rules and conditions. Its Negative goes with the variable while it is on, so an Assembler that prints $rain also sends \"sunny, dry\" to its negative output."
|
||||
"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",
|
||||
@@ -2107,6 +2107,10 @@
|
||||
"title": "The tabs",
|
||||
"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."
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -1017,6 +1078,8 @@ class WildcardHandler(ModuleHandler):
|
||||
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),
|
||||
@@ -1063,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: ""}
|
||||
@@ -1096,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.
|
||||
@@ -1139,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
|
||||
@@ -1269,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)
|
||||
|
||||
+25
-8
@@ -79,6 +79,16 @@ class Toggle:
|
||||
#: 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
|
||||
@@ -122,6 +132,8 @@ def parse_config(raw: Any) -> Config:
|
||||
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,
|
||||
@@ -134,6 +146,8 @@ def parse_config(raw: Any) -> Config:
|
||||
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,
|
||||
@@ -257,14 +271,15 @@ BOOLS_KEY = "__wp_toggle_bools__"
|
||||
|
||||
def resolve(cfg: Config) -> dict[str, str]:
|
||||
"""Variable → its value (plus the Pick one variable), in row order. A
|
||||
toggle with a Value writes it while on and ``""`` while off; one without
|
||||
writes ``"true"`` / ``"false"``. A later row with the same variable wins."""
|
||||
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):
|
||||
if t.value:
|
||||
values[t.var] = t.value if eff[t.id] else ""
|
||||
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):
|
||||
@@ -280,14 +295,16 @@ def bools(cfg: Config) -> dict[str, bool]:
|
||||
|
||||
|
||||
def own_negatives(cfg: Config) -> dict[str, str]:
|
||||
"""Toggle variable → its negative, for the toggles that are on and have
|
||||
one. A later row writing the same variable decides, as in :func:`resolve`."""
|
||||
"""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):
|
||||
if eff[t.id] and t.negative.strip():
|
||||
out[t.var] = t.negative.strip()
|
||||
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
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
"announce": "node scripts/discord-announce.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dumiflex/wp-kit": "^0.18.0",
|
||||
"@dumiflex/wp-kit": "^0.17.0",
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^8.0.2",
|
||||
"vue": "^3.5.44",
|
||||
|
||||
Generated
+5
-5
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@dumiflex/wp-kit':
|
||||
specifier: ^0.18.0
|
||||
version: 0.18.0(primeicons@8.0.2)(vue@3.5.44(typescript@5.9.3))
|
||||
specifier: ^0.17.0
|
||||
version: 0.17.0(primeicons@8.0.2)(vue@3.5.44(typescript@5.9.3))
|
||||
pinia:
|
||||
specifier: ^2.2.6
|
||||
version: 2.3.1(typescript@5.9.3)(vue@3.5.44(typescript@5.9.3))
|
||||
@@ -287,8 +287,8 @@ packages:
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@dumiflex/wp-kit@0.18.0':
|
||||
resolution: {integrity: sha512-md2NF4dJJIUKXSpgUnXzzvwhznq6hYspyfjRC2T51ifBrqqfyFd5zNMN/K5gdNK6eepO74ugK0Dgt+iFTBPEHA==}
|
||||
'@dumiflex/wp-kit@0.17.0':
|
||||
resolution: {integrity: sha512-7DS9FWvdS3H3MqZi48dl5+ry/7dcVCeUckUvrwqba/VNCNd+SsJ8z2ioRWGGRKMZSA912aYQBLEMkcYbXNLIrA==}
|
||||
engines: {node: '>=22'}
|
||||
peerDependencies:
|
||||
primeicons: ^7.0.0 || ^8.0.0
|
||||
@@ -2436,7 +2436,7 @@ snapshots:
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@dumiflex/wp-kit@0.18.0(primeicons@8.0.2)(vue@3.5.44(typescript@5.9.3))':
|
||||
'@dumiflex/wp-kit@0.17.0(primeicons@8.0.2)(vue@3.5.44(typescript@5.9.3))':
|
||||
dependencies:
|
||||
primeicons: 8.0.2
|
||||
vue: 3.5.44(typescript@5.9.3)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { computed, nextTick, ref, watch } from "vue";
|
||||
import WpSelect from "@/kit/WpSelect.vue";
|
||||
import { api } from "../../manager/api/client";
|
||||
import type { TemplateRow } from "../../manager/api/types";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import Skeleton from "@/kit/Skeleton.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Chip from "@/kit/Chip.vue";
|
||||
@@ -128,17 +128,19 @@ function pick(row: TemplateRow) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="open" size="lg" flush accent="brand" icon="folder-open" @update:open="(v: boolean) => { if (!v) emit('close'); }">
|
||||
<template #header="{ titleId }">
|
||||
<div class="wp-ltm__head-text">
|
||||
<div class="wp-ltm__eyebrow">Load template from library</div>
|
||||
<div :id="titleId" class="wp-ltm__title">
|
||||
Browse Templates
|
||||
<span class="wp-ltm__title-detail">· {{ rows.length }} {{ rows.length === 1 ? "template" : "templates" }}</span>
|
||||
<ModalShell :visible="open" labelledby="wp-ltm-title" @close="emit('close')">
|
||||
<div class="wp-ltm" @click.stop>
|
||||
<header class="wp-ltm__head">
|
||||
<div class="wp-ltm__head-text">
|
||||
<div class="wp-ltm__eyebrow">Load template from library</div>
|
||||
<div id="wp-ltm-title" class="wp-ltm__title">
|
||||
Browse Templates
|
||||
<span class="wp-ltm__title-detail">· {{ rows.length }} {{ rows.length === 1 ? "template" : "templates" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="wp-ltm">
|
||||
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="load-tpl-close" @click="emit('close')" />
|
||||
</header>
|
||||
|
||||
<div class="wp-ltm__toolbar">
|
||||
<SearchInput
|
||||
ref="searchInput"
|
||||
@@ -267,26 +269,49 @@ function pick(row: TemplateRow) {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<footer class="wp-ltm__foot">
|
||||
<span class="wp-ltm__hint">Click a template to load it into the assembler.</span>
|
||||
<span class="wp-ltm__foot-spacer" />
|
||||
<Button size="sm" data-test="load-tpl-cancel" @click="emit('close')">Cancel</Button>
|
||||
</footer>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="wp-ltm__hint">Click a template to load it into the assembler.</span>
|
||||
<span class="wp-ltm__foot-spacer" />
|
||||
<Button size="sm" data-test="load-tpl-cancel" @click="emit('close')">Cancel</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-ltm {
|
||||
height: 480px;
|
||||
max-height: calc(100vh - 160px);
|
||||
background: var(--wp-bg-2);
|
||||
border: 1px solid var(--wp-border-strong, var(--wp-border));
|
||||
border-radius: var(--wp-radius-lg, 12px);
|
||||
box-shadow: var(--wp-shadow-lg, 0 20px 60px rgba(0, 0, 0, 0.55));
|
||||
width: 640px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: 560px;
|
||||
max-height: calc(100vh - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--wp-text);
|
||||
overflow: hidden;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
}
|
||||
|
||||
.wp-ltm__head-text { flex: 1 1 auto; min-width: 0; }
|
||||
/* Header — brand wash mirrors ModulePickerModal. */
|
||||
.wp-ltm__head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-brand-gradient, transparent);
|
||||
position: relative;
|
||||
}
|
||||
.wp-ltm__head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-ltm__head > * { position: relative; z-index: 1; }
|
||||
.wp-ltm__head-text { flex: 1; min-width: 0; }
|
||||
.wp-ltm__eyebrow {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
@@ -428,6 +453,12 @@ function pick(row: TemplateRow) {
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.wp-ltm__foot {
|
||||
height: 44px; padding: 0 16px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
border-top: 1px solid var(--wp-border);
|
||||
background: var(--wp-bg-1);
|
||||
color: var(--wp-text-dim); font-size: 11px;
|
||||
}
|
||||
.wp-ltm__foot-spacer { flex: 1; }
|
||||
.wp-ltm__hint { align-self: center; color: var(--wp-text-dim); font-size: 11px; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { api } from "../../manager/api/client";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import Textarea from "@/kit/Textarea.vue";
|
||||
import Input from "@/kit/Input.vue";
|
||||
import WpSelect from "@/kit/WpSelect.vue";
|
||||
@@ -100,8 +100,12 @@ async function submit(mode: "update" | "new") {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="open" title="Save template" size="md" flush accent="brand" icon="save" @update:open="(v: boolean) => { if (!v) emit('close'); }">
|
||||
<div class="wp-stm">
|
||||
<ModalShell :visible="open" labelledby="wp-stm-title" @close="emit('close')">
|
||||
<div class="wp-stm" @click.stop>
|
||||
<header class="wp-stm__head">
|
||||
<h3 id="wp-stm-title" class="wp-stm__title">Save template</h3>
|
||||
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="save-tpl-close" @click="emit('close')" />
|
||||
</header>
|
||||
|
||||
<div class="wp-stm__body">
|
||||
<div class="wp-stm-field">
|
||||
@@ -178,30 +182,60 @@ async function submit(mode: "update" | "new") {
|
||||
<p v-if="errorMsg" class="wp-stm-error" data-test="save-tpl-error">{{ errorMsg }}</p>
|
||||
</div>
|
||||
|
||||
<footer class="wp-stm__foot">
|
||||
<Button size="sm" data-test="save-tpl-cancel" @click="emit('close')">Cancel</Button>
|
||||
<span class="wp-stm-spacer" />
|
||||
<Button
|
||||
v-if="updateTargetId"
|
||||
size="sm"
|
||||
:disabled="!canSubmit"
|
||||
data-test="save-tpl-save-new"
|
||||
@click="submit('new')"
|
||||
>Save as new entry</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:disabled="!canSubmit"
|
||||
data-test="save-tpl-submit"
|
||||
@click="submit(updateTargetId ? 'update' : 'new')"
|
||||
>{{ updateTargetId ? "Update existing" : "Save" }}</Button>
|
||||
</footer>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button size="sm" data-test="save-tpl-cancel" @click="emit('close')">Cancel</Button>
|
||||
<span class="wp-stm-spacer" />
|
||||
<Button
|
||||
v-if="updateTargetId"
|
||||
size="sm"
|
||||
:disabled="!canSubmit"
|
||||
data-test="save-tpl-save-new"
|
||||
@click="submit('new')"
|
||||
>Save as new entry</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:disabled="!canSubmit"
|
||||
data-test="save-tpl-submit"
|
||||
@click="submit(updateTargetId ? 'update' : 'new')"
|
||||
>{{ updateTargetId ? "Update existing" : "Save" }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-stm { display: flex; flex-direction: column; color: var(--wp-text); font-family: var(--wp-font-sans, sans-serif); }
|
||||
.wp-stm {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 40px);
|
||||
background: var(--wp-bg-1);
|
||||
border: 1px solid var(--wp-border-strong, var(--wp-border));
|
||||
border-radius: var(--wp-radius-lg, 12px);
|
||||
box-shadow: var(--wp-shadow-lg, 0 20px 60px rgba(0, 0, 0, 0.55));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
}
|
||||
.wp-stm__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-brand-gradient, transparent);
|
||||
position: relative;
|
||||
}
|
||||
.wp-stm__head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-stm__head > * { position: relative; z-index: 1; }
|
||||
.wp-stm__title { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
|
||||
.wp-stm__body {
|
||||
flex: 1;
|
||||
@@ -246,5 +280,13 @@ async function submit(mode: "update" | "new") {
|
||||
color: var(--wp-danger);
|
||||
}
|
||||
|
||||
.wp-stm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 16px;
|
||||
border-top: 1px solid var(--wp-border);
|
||||
background: var(--wp-bg-1);
|
||||
}
|
||||
.wp-stm-spacer { flex: 1; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Kit Modal for the cleaner's blocklist. Mode toggle:
|
||||
* Body-teleported modal for the cleaner's blocklist. Mode toggle:
|
||||
* - "list" — comma- or newline-separated, exact tag match (case-insensitive)
|
||||
* - "regex" — one regex per line, case-insensitive
|
||||
*
|
||||
@@ -8,7 +8,7 @@
|
||||
* Cancel discards. Parent gates visibility via `visible`.
|
||||
*/
|
||||
import { ref, watch, computed } from "vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import Textarea from "@/kit/Textarea.vue";
|
||||
import type { BlocklistKind } from "./types";
|
||||
import Button from "@/kit/Button.vue";
|
||||
@@ -60,8 +60,11 @@ function onCancel(): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="visible" title="Blocklist" size="md" flush @update:open="(v: boolean) => { if (!v) onCancel(); }">
|
||||
<ModalShell :visible="visible" @close="onCancel">
|
||||
<div class="wp-blocklist">
|
||||
<header class="wp-blocklist__head">
|
||||
<span class="wp-blocklist__title">Blocklist</span>
|
||||
</header>
|
||||
<div class="wp-blocklist__mode-row">
|
||||
<span class="wp-blocklist__label">MODE</span>
|
||||
<WpSegmented
|
||||
@@ -90,15 +93,28 @@ function onCancel(): void {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="wp-blocklist__foot">
|
||||
<Button size="sm" data-test="blocklist-cancel" @click="onCancel">Cancel</Button>
|
||||
<Button size="sm" variant="primary" data-test="blocklist-save" @click="onSave">Save</Button>
|
||||
</footer>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button size="sm" data-test="blocklist-cancel" @click="onCancel">Cancel</Button>
|
||||
<Button size="sm" variant="primary" data-test="blocklist-save" @click="onSave">Save</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-blocklist {
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
background: var(--wp-bg);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius, 6px);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-blocklist__head {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
}
|
||||
.wp-blocklist__title { font-size: 14px; font-weight: 600; }
|
||||
.wp-blocklist__mode-row {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
@@ -135,4 +151,11 @@ function onCancel(): void {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.wp-blocklist__foot {
|
||||
padding: 10px 16px 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--wp-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
* run, so the grid can be checked before anything is queued.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import SweepPanel from "./SweepPanel.vue";
|
||||
import { liveAxes, sweepTotal, type SweepConfig } from "./types";
|
||||
import type { SweepCandidate } from "./sweep-candidates";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Icon from "@/kit/Icon.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -62,14 +63,17 @@ const preview = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="true" size="md" flush accent="var(--wp-accent)" icon="th-large" @update:open="(v: boolean) => { if (!v) emit('close'); }">
|
||||
<template #header="{ titleId }">
|
||||
<div class="wp-swm__title-block">
|
||||
<div class="wp-swm__title-row"><span :id="titleId" class="wp-swm__name">WP Context Loop</span><span class="wp-swm__chip">Sweep</span></div>
|
||||
<div class="wp-swm__sub">Run every combination of the wildcards you pick, one frame each.</div>
|
||||
</div>
|
||||
</template>
|
||||
<ModalShell :visible="true" @close="emit('close')">
|
||||
<div class="wp-swm" data-test="sweep-modal">
|
||||
<div class="wp-swm__head">
|
||||
<span class="wp-swm__head-icon"><Icon name="th-large" :size="15" /></span>
|
||||
<div class="wp-swm__title-block">
|
||||
<div class="wp-swm__title-row"><span class="wp-swm__name">WP Context Loop</span><span class="wp-swm__chip">Sweep</span></div>
|
||||
<div class="wp-swm__sub">Run every combination of the wildcards you pick, one frame each.</div>
|
||||
</div>
|
||||
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="sweep-modal-close" @click="emit('close')" />
|
||||
</div>
|
||||
|
||||
<div class="wp-swm__body">
|
||||
<SweepPanel
|
||||
:model-value="modelValue"
|
||||
@@ -90,21 +94,23 @@ const preview = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wp-swm__foot">
|
||||
<span class="wp-swm__foot-hint">Changes save as you make them.</span>
|
||||
<span class="wp-swm__spacer" />
|
||||
<Button size="sm" variant="primary" data-test="sweep-modal-done" @click="emit('close')">Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="wp-swm__foot-hint">Changes save as you make them.</span>
|
||||
<span class="wp-swm__spacer" />
|
||||
<Button size="sm" variant="primary" data-test="sweep-modal-done" @click="emit('close')">Done</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-swm { display: flex; flex-direction: column; min-height: 0; color: var(--wp-text); font-size: 12px; }
|
||||
.wp-swm__title-block { flex: 1 1 auto; min-width: 0; }
|
||||
.wp-swm { width: 520px; max-width: 100%; max-height: 86vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: min(6px, var(--wp-radius-cap, 999px)); overflow: hidden; color: var(--wp-text); font-size: 12px; box-shadow: var(--wp-shadow-lg); }
|
||||
.wp-swm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: color-mix(in srgb, var(--wp-accent) 9%, var(--wp-bg2)); }
|
||||
.wp-swm__head-icon { color: var(--wp-accent); width: 24px; display: flex; justify-content: center; }
|
||||
.wp-swm__title-block { flex: 1; min-width: 0; }
|
||||
.wp-swm__title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.wp-swm__name { font: 600 13px var(--wp-font-sans); }
|
||||
.wp-swm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-modal-accent) 22%, transparent); color: var(--wp-modal-accent); }
|
||||
.wp-swm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: min(2px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent); }
|
||||
.wp-swm__sub { font: 10.5px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); margin-top: 3px; }
|
||||
|
||||
.wp-swm__body { overflow-y: auto; padding: 12px 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; font-size: 11px; }
|
||||
@@ -116,6 +122,7 @@ const preview = computed(() => {
|
||||
.wp-swm__frame-vals { color: var(--wp-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wp-swm__frame-more { padding: 4px 0 0 42px; font: 10.5px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
|
||||
.wp-swm__foot-hint { align-self: center; font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-swm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; flex-shrink: 0; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
|
||||
.wp-swm__foot-hint { font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.wp-swm__spacer { flex: 1; }
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Kbd from "@/kit/Kbd.vue";
|
||||
import SearchInput from "@/kit/SearchInput.vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import ConfirmModal from "@/kit/ConfirmModal.vue";
|
||||
import { api } from "../../manager/api/client";
|
||||
import type { BundleRow } from "../../manager/api/types";
|
||||
@@ -178,17 +178,19 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="visible" size="lg" flush accent="brand" icon="box" @update:open="(v: boolean) => { if (!v) emit('close'); }" @keydown="onKeydown">
|
||||
<template #header="{ titleId }">
|
||||
<div class="wp-bp__head-text">
|
||||
<div class="wp-bp__eyebrow">Add bundle from library</div>
|
||||
<div :id="titleId" class="wp-bp__title">
|
||||
Browse Bundles
|
||||
<span class="wp-bp__title-detail">· {{ filtered.length }} {{ filtered.length === 1 ? "bundle" : "bundles" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ModalShell :visible="visible" label="Add bundle from library" @close="emit('close')" @keydown="onKeydown">
|
||||
<div class="wp-bp">
|
||||
<header class="wp-bp__head">
|
||||
<div class="wp-bp__head-text">
|
||||
<div class="wp-bp__eyebrow">Add bundle from library</div>
|
||||
<div class="wp-bp__title">
|
||||
Browse Bundles
|
||||
<span class="wp-bp__title-detail">· {{ filtered.length }} {{ filtered.length === 1 ? "bundle" : "bundles" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" @click="emit('close')" />
|
||||
</header>
|
||||
|
||||
<div class="wp-bp__toolbar">
|
||||
<SearchInput
|
||||
ref="searchInput"
|
||||
@@ -261,32 +263,32 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="wp-bp__hints">
|
||||
<Kbd keys="↑" size="sm" />
|
||||
<Kbd keys="↓" size="sm" />
|
||||
<span class="wp-bp__hint-label">nav</span>
|
||||
<span class="wp-bp__hint-sep">·</span>
|
||||
<Kbd keys="Enter" size="sm" />
|
||||
<span class="wp-bp__hint-label">insert</span>
|
||||
<span class="wp-bp__hint-sep">·</span>
|
||||
<Kbd keys="/" size="sm" />
|
||||
<span class="wp-bp__hint-label">search</span>
|
||||
<span class="wp-bp__hint-sep">·</span>
|
||||
<Kbd keys="Esc" size="sm" />
|
||||
<span class="wp-bp__hint-label">cancel</span>
|
||||
<div class="wp-bp__foot">
|
||||
<div class="wp-bp__hints">
|
||||
<Kbd keys="↑" size="sm" />
|
||||
<Kbd keys="↓" size="sm" />
|
||||
<span class="wp-bp__hint-label">nav</span>
|
||||
<span class="wp-bp__hint-sep">·</span>
|
||||
<Kbd keys="Enter" size="sm" />
|
||||
<span class="wp-bp__hint-label">insert</span>
|
||||
<span class="wp-bp__hint-sep">·</span>
|
||||
<Kbd keys="/" size="sm" />
|
||||
<span class="wp-bp__hint-label">search</span>
|
||||
<span class="wp-bp__hint-sep">·</span>
|
||||
<Kbd keys="Esc" size="sm" />
|
||||
<span class="wp-bp__hint-label">cancel</span>
|
||||
</div>
|
||||
<Button size="sm" @click="emit('close')">Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon="pi-plus"
|
||||
data-testid="bundle-picker-create"
|
||||
@click="emit('create')"
|
||||
>Create Bundle</Button>
|
||||
</div>
|
||||
<Button size="sm" @click="emit('close')">Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon="pi-plus"
|
||||
data-testid="bundle-picker-create"
|
||||
@click="emit('create')"
|
||||
>Create Bundle</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</ModalShell>
|
||||
<ConfirmModal
|
||||
:open="pendingReinsert !== null"
|
||||
:title="confirmTitle"
|
||||
@@ -303,12 +305,37 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
|
||||
.wp-bp__skel-list { padding: 6px 16px; }
|
||||
|
||||
.wp-bp {
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: min(8px, var(--wp-radius-cap, 999px));
|
||||
width: 720px;
|
||||
max-width: 92vw;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-shadow: var(--wp-shadow-lg, 0 20px 60px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
.wp-bp__head-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
/* Header — brand gradient + dark wash overlay, matches edit modals. */
|
||||
.wp-bp__head {
|
||||
background: var(--wp-brand-gradient);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
position: relative;
|
||||
}
|
||||
.wp-bp__head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* Theme-aware wash so the brand gradient reads correctly in both palettes. */
|
||||
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-bp__head > * { position: relative; z-index: 1; }
|
||||
.wp-bp__head-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.wp-bp__eyebrow {
|
||||
font: 500 11px/1.2 var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
@@ -434,6 +461,14 @@ const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
|
||||
|
||||
/* Footer — keyboard hints inline with Cancel + Create. Matches the
|
||||
* module picker pattern: the keys are the kit's Kbd. */
|
||||
.wp-bp__foot {
|
||||
border-top: 1px solid var(--wp-border);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
}
|
||||
.wp-bp__hints {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<template>
|
||||
<Modal :open="visible" size="lg" flush accent="brand" icon="th-large" @update:open="(v: boolean) => { if (!v) $emit('close'); }">
|
||||
<template #header="{ titleId }">
|
||||
<div class="wp-picker__head-text">
|
||||
<div class="wp-picker__eyebrow">
|
||||
Add modules from library
|
||||
</div>
|
||||
<div :id="titleId" class="wp-picker__title">
|
||||
Browse Library
|
||||
<span class="wp-picker__title-detail">
|
||||
· {{ modules.length }} {{ modules.length === 1 ? "module" : "modules" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ModalShell :visible="visible" @close="$emit('close')">
|
||||
<div ref="rootEl" class="wp-picker" tabindex="-1">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="wp-picker__head">
|
||||
<div class="wp-picker__head-text">
|
||||
<div class="wp-picker__eyebrow">
|
||||
Add modules from library
|
||||
</div>
|
||||
<div class="wp-picker__title">
|
||||
Browse Library
|
||||
<span class="wp-picker__title-detail">
|
||||
· {{ modules.length }} {{ modules.length === 1 ? "module" : "modules" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" @click="$emit('close')" />
|
||||
</header>
|
||||
|
||||
<!-- Toolbar: search + kind tabs + compact filter buttons.
|
||||
Filters are inline on the toolbar (no dedicated row) so
|
||||
the body has the maximum vertical space. Quick-toggle
|
||||
@@ -306,7 +309,7 @@
|
||||
>Add {{ selected.size }} {{ selected.size === 1 ? "module" : "modules" }}</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -329,7 +332,7 @@
|
||||
import Kbd from "@/kit/Kbd.vue";
|
||||
import SearchInput from "@/kit/SearchInput.vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import Skeleton from "@/kit/Skeleton.vue";
|
||||
import WpSelect from "@/kit/WpSelect.vue";
|
||||
import { kindIcon } from "../shared/kind-icons";
|
||||
@@ -967,17 +970,41 @@ onBeforeUnmount(detachCaptureListeners);
|
||||
.wp-picker__skel-rows { padding: 0 10px; }
|
||||
|
||||
.wp-picker {
|
||||
height: 560px;
|
||||
max-height: calc(100vh - 120px);
|
||||
background: var(--wp-bg-2);
|
||||
border: 1px solid var(--wp-border-strong);
|
||||
border-radius: var(--wp-radius-lg);
|
||||
box-shadow: var(--wp-shadow-lg);
|
||||
width: 780px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: 600px;
|
||||
max-height: calc(100vh - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--wp-text);
|
||||
overflow: hidden;
|
||||
font-family: var(--wp-font);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Header text (Modal draws the brand wash). */
|
||||
.wp-picker__head-text { flex: 1 1 auto; min-width: 0; }
|
||||
/* ── Header — brand gradient + dark wash overlay. Mirrors BundlePicker
|
||||
* so both pickers read as part of the same family. */
|
||||
.wp-picker__head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-brand-gradient);
|
||||
position: relative;
|
||||
}
|
||||
.wp-picker__head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* Theme-aware wash so the brand gradient stays subtle in both
|
||||
* palettes — dark bg in dark mode, near-white in light mode. */
|
||||
background: color-mix(in srgb, var(--wp-bg) 85%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-picker__head > * { position: relative; z-index: 1; }
|
||||
.wp-picker__head-text { flex: 1; min-width: 0; }
|
||||
.wp-picker__eyebrow {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -29,7 +29,7 @@ import { bundleContentKey } from "./bundles/bundle-fingerprint";
|
||||
import type { BundleInstance } from "../../widgets/_shared";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import { vToggleRow } from "@/kit/toggle-row";
|
||||
|
||||
interface ChildPreview {
|
||||
@@ -421,8 +421,16 @@ function pickCopyName(base: string, taken: ReadonlySet<string>): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="!!(open && bundle)" title="Push bundle to library" size="md" flush @update:open="(v: boolean) => { if (!v) close(); }">
|
||||
<div class="wp-ptl" data-test="pbtl">
|
||||
<ModalShell :visible="!!(open && bundle)" labelledby="wp-pbtl-title" @close="close">
|
||||
<div
|
||||
class="wp-ptl"
|
||||
data-test="pbtl"
|
||||
>
|
||||
<header class="wp-ptl__head">
|
||||
<h3 id="wp-pbtl-title" class="wp-ptl__title">Push bundle to library</h3>
|
||||
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="pbtl-close" @click="close" />
|
||||
</header>
|
||||
|
||||
<div class="wp-ptl__body">
|
||||
<div class="wp-ptl-field">
|
||||
<label class="wp-ptl-field__label" for="wp-pbtl-name">Name</label>
|
||||
@@ -597,30 +605,46 @@ function pickCopyName(base: string, taken: ReadonlySet<string>): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="wp-ptl__foot">
|
||||
<Button size="sm" data-test="pbtl-cancel" :disabled="busy" @click="close">Cancel</Button>
|
||||
<span class="wp-ptl-spacer" />
|
||||
<Button
|
||||
size="sm"
|
||||
data-test="pbtl-save-new"
|
||||
:disabled="busy"
|
||||
@click="doSaveAsNew"
|
||||
>Save as new entry</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
data-test="pbtl-update"
|
||||
:disabled="busy || !canUpdateExisting"
|
||||
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
|
||||
@click="doUpdate"
|
||||
>Update existing</Button>
|
||||
</footer>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button size="sm" data-test="pbtl-cancel" :disabled="busy" @click="close">Cancel</Button>
|
||||
<span class="wp-ptl-spacer" />
|
||||
<Button
|
||||
size="sm"
|
||||
data-test="pbtl-save-new"
|
||||
:disabled="busy"
|
||||
@click="doSaveAsNew"
|
||||
>Save as new entry</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
data-test="pbtl-update"
|
||||
:disabled="busy || !canUpdateExisting"
|
||||
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
|
||||
@click="doUpdate"
|
||||
>Update existing</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-ptl { color: var(--wp-text); }
|
||||
.wp-ptl {
|
||||
width: min(620px, 100%);
|
||||
max-height: calc(100vh - 32px);
|
||||
background: var(--wp-bg-1);
|
||||
border: 1px solid var(--wp-border-strong, var(--wp-border));
|
||||
border-radius: var(--wp-radius-lg, 10px);
|
||||
box-shadow: var(--wp-shadow-lg);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-ptl__head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
}
|
||||
.wp-ptl__title { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.wp-ptl__body {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
padding: 14px;
|
||||
@@ -777,5 +801,11 @@ function pickCopyName(base: string, taken: ReadonlySet<string>): string {
|
||||
color: var(--wp-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wp-ptl__foot {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--wp-border);
|
||||
background: var(--wp-bg-1);
|
||||
}
|
||||
.wp-ptl-spacer { flex: 1; }
|
||||
</style>
|
||||
|
||||
@@ -41,7 +41,7 @@ import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import LoadingMark from "@/kit/LoadingMark.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import CodeBlock from "@/kit/CodeBlock.vue";
|
||||
import { vToggleRow } from "@/kit/toggle-row";
|
||||
|
||||
@@ -443,8 +443,16 @@ async function doSaveAsNew(): Promise<void> {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="!!(open && draft)" title="Push to library" size="md" flush @update:open="(v: boolean) => { if (!v) close(); }">
|
||||
<div class="wp-ptl" data-test="ptl">
|
||||
<ModalShell :visible="!!(open && draft)" labelledby="wp-ptl-title" @close="close">
|
||||
<div
|
||||
class="wp-ptl"
|
||||
data-test="ptl"
|
||||
>
|
||||
<header class="wp-ptl__head">
|
||||
<h3 id="wp-ptl-title" class="wp-ptl__title">Push to library</h3>
|
||||
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="ptl-close" @click="close" />
|
||||
</header>
|
||||
|
||||
<div class="wp-ptl__body">
|
||||
<div class="wp-ptl-field">
|
||||
<label class="wp-ptl-field__label" for="wp-ptl-name">Name</label>
|
||||
@@ -595,30 +603,46 @@ async function doSaveAsNew(): Promise<void> {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="wp-ptl__foot">
|
||||
<Button size="sm" data-test="ptl-cancel" :disabled="busy" @click="close">Cancel</Button>
|
||||
<span class="wp-ptl-spacer" />
|
||||
<Button
|
||||
size="sm"
|
||||
data-test="ptl-save-new"
|
||||
:disabled="busy"
|
||||
@click="doSaveAsNew"
|
||||
>Save as new entry</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
data-test="ptl-update"
|
||||
:disabled="busy || !canUpdateExisting"
|
||||
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
|
||||
@click="doUpdate"
|
||||
>Update existing</Button>
|
||||
</footer>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button size="sm" data-test="ptl-cancel" :disabled="busy" @click="close">Cancel</Button>
|
||||
<span class="wp-ptl-spacer" />
|
||||
<Button
|
||||
size="sm"
|
||||
data-test="ptl-save-new"
|
||||
:disabled="busy"
|
||||
@click="doSaveAsNew"
|
||||
>Save as new entry</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
data-test="ptl-update"
|
||||
:disabled="busy || !canUpdateExisting"
|
||||
:hint="!isLibraryTracked ? 'No existing library entry to update' : isLibraryMissing ? 'Library entry has been deleted — save as new entry instead' : undefined"
|
||||
@click="doUpdate"
|
||||
>Update existing</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-ptl { color: var(--wp-text); }
|
||||
.wp-ptl {
|
||||
width: min(620px, 100%);
|
||||
max-height: calc(100vh - 32px);
|
||||
background: var(--wp-bg-1);
|
||||
border: 1px solid var(--wp-border-strong, var(--wp-border));
|
||||
border-radius: var(--wp-radius-lg, 10px);
|
||||
box-shadow: var(--wp-shadow-lg);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-ptl__head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
}
|
||||
.wp-ptl__title { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.wp-ptl__body {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
padding: 14px;
|
||||
@@ -735,5 +759,11 @@ async function doSaveAsNew(): Promise<void> {
|
||||
color: var(--wp-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wp-ptl__foot {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--wp-border);
|
||||
background: var(--wp-bg-1);
|
||||
}
|
||||
.wp-ptl-spacer { flex: 1; }
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
@@ -41,6 +42,10 @@ const nodeFacts = computed((): string[] => {
|
||||
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") {
|
||||
@@ -51,15 +56,40 @@ const nodeFacts = computed((): string[] => {
|
||||
"wired in": "from the node's text input",
|
||||
"loader file": "the checkpoint loader's file name",
|
||||
};
|
||||
return [why[s.source] ?? (s.source || "WP Model Info")];
|
||||
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 out = [s.source.startsWith("Pick one") ? "Pick one: the picked row's value" : "switch: true or false"];
|
||||
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") {
|
||||
return [s.isTemplate ? "template filled from the node's inputs" : `wired ${s.valueType || "value"}`];
|
||||
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 [];
|
||||
});
|
||||
@@ -72,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);
|
||||
@@ -137,21 +209,62 @@ function modeGlyph(mode: string | undefined): string {
|
||||
<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' && 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></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">
|
||||
@@ -260,6 +373,39 @@ 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>
|
||||
@@ -279,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 -->
|
||||
@@ -390,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);
|
||||
|
||||
@@ -77,6 +77,48 @@ export interface RawDetail {
|
||||
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[];
|
||||
@@ -95,6 +137,25 @@ export interface RawDetail {
|
||||
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 {
|
||||
@@ -212,6 +273,8 @@ export interface TraceStep {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -720,6 +783,7 @@ export function buildModel(snap: Record<string, unknown>): DebugModel {
|
||||
refs: refsByOwner.get(`${nodeId}|${str(t._uid)}`) ?? [],
|
||||
constraint,
|
||||
appliedBy: [],
|
||||
appliedTo: [],
|
||||
warningCount: 0,
|
||||
};
|
||||
});
|
||||
@@ -739,6 +803,9 @@ 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* it: the run keeps waiting until one of the three answers is sent.
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import Textarea from "@/kit/Textarea.vue";
|
||||
import WpSegmented, { type SegmentedOption } from "@/kit/WpSegmented.vue";
|
||||
import WpImageZoom from "@/kit/WpImageZoom.vue";
|
||||
@@ -263,9 +263,8 @@ const painting = ref(false);
|
||||
const pickerEl = ref<HTMLElement | null>(null);
|
||||
function keepFocusInside(): void {
|
||||
const root = pickerEl.value;
|
||||
const dialog = root?.closest<HTMLElement>(".wp-modal") ?? root;
|
||||
const at = document.activeElement;
|
||||
if (root && !(at instanceof Node && dialog?.contains(at))) root.focus({ preventScroll: true });
|
||||
if (root && !(at instanceof Node && root.contains(at))) root.focus({ preventScroll: true });
|
||||
}
|
||||
watch([mode, () => painting.value], () => { void nextTick(keepFocusInside); }, { flush: "post" });
|
||||
const tool = ref<MaskTool>("brush");
|
||||
@@ -446,9 +445,6 @@ const now = ref(Date.now());
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => { now.value = Date.now(); }, 1000);
|
||||
// Focus the picker itself, not its first control (Modal leaves focus where it
|
||||
// is when it is already inside): the keys work from there, and no tooltip pops.
|
||||
pickerEl.value?.focus({ preventScroll: true });
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
@@ -550,40 +546,33 @@ const subLine = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="true"
|
||||
size="full"
|
||||
flush
|
||||
accent="var(--wp-accent)"
|
||||
icon="filter"
|
||||
@update:open="(v: boolean) => { if (!v) onClose(); }"
|
||||
@keydown="onKey"
|
||||
>
|
||||
<template #header="{ titleId }">
|
||||
<div class="wp-ifp__title-block">
|
||||
<div class="wp-ifp__title-row">
|
||||
<span :id="titleId" class="wp-ifp__name">WP Image Filter</span>
|
||||
<span class="wp-ifp__chip">Waiting</span>
|
||||
<span v-if="moreWaiting" class="wp-ifp__more" data-test="image-filter-more">+{{ moreWaiting }} more waiting</span>
|
||||
</div>
|
||||
<div class="wp-ifp__sub">
|
||||
<template v-if="multiFrame">{{ frames.length }} frames, {{ total }} images.</template>
|
||||
<template v-else>{{ total }} {{ total === 1 ? "image" : "images" }}.</template>
|
||||
{{ subLine }}
|
||||
</div>
|
||||
</div>
|
||||
<WpSegmented
|
||||
:model-value="mode"
|
||||
:options="MODE_OPTIONS"
|
||||
size="sm"
|
||||
aria-label="Mode"
|
||||
test-prefix="image-filter-mode"
|
||||
:disabled="!total"
|
||||
@update:model-value="(m) => setMode(m)"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" icon="pi-minus" aria-label="Hide for now (the run keeps waiting)" data-test="image-filter-minimize" @click="emit('minimize')" />
|
||||
</template>
|
||||
<ModalShell :visible="true" @close="onClose" @keydown="onKey">
|
||||
<div ref="pickerEl" class="wp-ifp" :class="[`is-${mode}`, { 'is-fit': isFit }]" data-test="image-filter-picker" :data-mode="mode" tabindex="-1">
|
||||
<div class="wp-ifp__head">
|
||||
<i class="pi pi-filter wp-ifp__head-icon" aria-hidden="true" />
|
||||
<div class="wp-ifp__title-block">
|
||||
<div class="wp-ifp__title-row">
|
||||
<span class="wp-ifp__name">WP Image Filter</span>
|
||||
<span class="wp-ifp__chip">Waiting</span>
|
||||
<span v-if="moreWaiting" class="wp-ifp__more" data-test="image-filter-more">+{{ moreWaiting }} more waiting</span>
|
||||
</div>
|
||||
<div class="wp-ifp__sub">
|
||||
<template v-if="multiFrame">{{ frames.length }} frames, {{ total }} images.</template>
|
||||
<template v-else>{{ total }} {{ total === 1 ? "image" : "images" }}.</template>
|
||||
{{ subLine }}
|
||||
</div>
|
||||
</div>
|
||||
<WpSegmented
|
||||
:model-value="mode"
|
||||
:options="MODE_OPTIONS"
|
||||
size="sm"
|
||||
aria-label="Mode"
|
||||
test-prefix="image-filter-mode"
|
||||
:disabled="!total"
|
||||
@update:model-value="(m) => setMode(m)"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" icon="pi-minus" aria-label="Hide for now (the run keeps waiting)" data-test="image-filter-minimize" @click="emit('minimize')" />
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<template v-if="mode === 'grid'">
|
||||
@@ -1004,27 +993,28 @@ const subLine = computed(() => {
|
||||
</WpImageZoom>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="wp-ifp__status" data-test="image-filter-status">
|
||||
<span><strong>{{ pickedList.length }}</strong> of {{ total }} picked<template v-if="multiFrame"> from {{ pickedFrames }} {{ pickedFrames === 1 ? "frame" : "frames" }}</template></span>
|
||||
<span v-if="editCount" class="wp-ifp__edits">{{ editCount }} refined</span>
|
||||
<span v-if="leftLabel" class="wp-ifp__left" :class="{ 'is-low': (left ?? 99) <= 30 }">{{ leftLabel }}</span>
|
||||
<div class="wp-ifp__foot">
|
||||
<div class="wp-ifp__status" data-test="image-filter-status">
|
||||
<span><strong>{{ pickedList.length }}</strong> of {{ total }} picked<template v-if="multiFrame"> from {{ pickedFrames }} {{ pickedFrames === 1 ? "frame" : "frames" }}</template></span>
|
||||
<span v-if="editCount" class="wp-ifp__edits">{{ editCount }} refined</span>
|
||||
<span v-if="leftLabel" class="wp-ifp__left" :class="{ 'is-low': (left ?? 99) <= 30 }">{{ leftLabel }}</span>
|
||||
</div>
|
||||
<span class="wp-ifp__spacer" />
|
||||
<Button v-if="canSendToLoop" size="sm" :disabled="pickedFrames === 0" hint="Lock the picked frames' seeds in the Context Loop and bypass the rest, so the next run repeats only your picks" data-test="image-filter-send-loop" @click="sendToLoop">Send to Loop</Button>
|
||||
<Button size="sm" variant="danger" hint="Skip everything after this node for this run" data-test="image-filter-stop" @click="stop">Stop branch</Button>
|
||||
<Button size="sm" data-test="image-filter-keep-all" @click="keepAll">Keep all {{ total }}</Button>
|
||||
<Button size="sm" variant="primary" kbd="Enter" :disabled="pickedList.length === 0" data-test="image-filter-keep-picked" @click="keepPicked">{{ pickedList.length ? `Keep ${pickedList.length} picked` : "Keep picked" }}</Button>
|
||||
</div>
|
||||
<span class="wp-ifp__spacer" />
|
||||
<Button v-if="canSendToLoop" size="sm" :disabled="pickedFrames === 0" hint="Lock the picked frames' seeds in the Context Loop and bypass the rest, so the next run repeats only your picks" data-test="image-filter-send-loop" @click="sendToLoop">Send to Loop</Button>
|
||||
<Button size="sm" variant="danger" hint="Skip everything after this node for this run" data-test="image-filter-stop" @click="stop">Stop branch</Button>
|
||||
<Button size="sm" data-test="image-filter-keep-all" @click="keepAll">Keep all {{ total }}</Button>
|
||||
<Button size="sm" variant="primary" kbd="Enter" :disabled="pickedList.length === 0" data-test="image-filter-keep-picked" @click="keepPicked">{{ pickedList.length ? `Keep ${pickedList.length} picked` : "Keep picked" }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* One size in every mode, so switching never moves the window under the mouse. */
|
||||
.wp-ifp { height: 100%; min-height: 0; display: flex; flex-direction: column; color: var(--wp-text); font-size: 12px; outline: none; }
|
||||
/* Grows past the kit header spacer so the mode switch sits at the right, by the ×. */
|
||||
.wp-ifp__title-block { flex: 999 1 auto; min-width: 0; }
|
||||
.wp-ifp { width: min(1280px, 95vw); height: 90vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: min(6px, var(--wp-radius-cap, 999px)); overflow: hidden; color: var(--wp-text); font-size: 12px; box-shadow: var(--wp-shadow-lg); outline: none; }
|
||||
.wp-ifp__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: color-mix(in srgb, var(--wp-accent) 9%, var(--wp-bg2)); }
|
||||
.wp-ifp__head-icon { color: var(--wp-accent); width: 24px; font-size: 15px; text-align: center; }
|
||||
.wp-ifp__title-block { flex: 1; min-width: 0; }
|
||||
.wp-ifp__title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.wp-ifp__name { font: 600 13px var(--wp-font-sans); }
|
||||
.wp-ifp__chip { font: 600 10px var(--wp-font-sans); letter-spacing: .02em; padding: 2px 6px; border-radius: min(3px, var(--wp-radius-cap, 999px)); background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent-text, var(--wp-accent)); }
|
||||
@@ -1142,7 +1132,8 @@ const subLine = computed(() => {
|
||||
.wp-ifp__full-strip .is-current { border-color: var(--wp-accent); }
|
||||
.wp-ifp__full-sep { width: 1px; height: 32px; background: var(--wp-border); }
|
||||
|
||||
.wp-ifp__status { align-self: center; display: flex; gap: 12px; font: 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-ifp__foot { display: flex; align-items: center; gap: 8px; padding: 10px 14px; flex-shrink: 0; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
|
||||
.wp-ifp__status { display: flex; gap: 12px; font: 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-ifp__status strong { color: var(--wp-text); }
|
||||
.wp-ifp__edits { color: var(--wp-accent-text, var(--wp-accent)); }
|
||||
.wp-ifp__left { color: var(--wp-text-dim, var(--wp-text3)); font-family: var(--wp-font-mono, monospace); }
|
||||
|
||||
@@ -33,7 +33,7 @@ import RichTextInput from "../../manager/components/RichTextInput.vue";
|
||||
import { INLINE_PICKS_NOTE, hasInlinePicks } from "../context/editors/_shared/preview-tokens";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import ListRow from "@/kit/ListRow.vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import Popover from "@/kit/Popover.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -355,25 +355,20 @@ function onKeydown(ev: KeyboardEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="true"
|
||||
size="md"
|
||||
flush
|
||||
:accent="headerColor"
|
||||
:icon="headerIcon.replace(/^pi-/, '')"
|
||||
@update:open="(v: boolean) => { if (!v) onShellClose(); }"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<template #header="{ titleId }">
|
||||
<div class="ibm__title-block">
|
||||
<div class="ibm__title-row">
|
||||
<span :id="titleId" class="ibm__name" data-test="ibm-name">{{ headerName }}</span>
|
||||
<span class="ibm__chip" data-test="ibm-chip">{{ headerType }}</span>
|
||||
<ModalShell :visible="true" labelledby="ibm-title" @close="onShellClose" @keydown="onKeydown">
|
||||
<div class="ibm" tabindex="-1">
|
||||
<header class="ibm__head" :style="{ '--ibm-accent': headerColor }">
|
||||
<i :class="['pi', headerIcon, 'ibm__head-icon']" aria-hidden="true" />
|
||||
<div class="ibm__title-block">
|
||||
<div class="ibm__title-row">
|
||||
<span id="ibm-title" class="ibm__name" data-test="ibm-name">{{ headerName }}</span>
|
||||
<span class="ibm__chip" data-test="ibm-chip">{{ headerType }}</span>
|
||||
</div>
|
||||
<div class="ibm__sub">Injector binding · ctx[$<wbr>{{ draftBinding || "…" }}] ← {{ templateValue ? "template" : isGeneral ? "no template yet" : "socket value" }}</div>
|
||||
</div>
|
||||
<div class="ibm__sub">Injector binding · ctx[$<wbr>{{ draftBinding || "…" }}] ← {{ templateValue ? "template" : isGeneral ? "no template yet" : "socket value" }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ibm">
|
||||
<Button variant="ghost" icon="times" aria-label="Close" size="sm" kbd="Esc" data-test="ibm-close" @click="onCancel" />
|
||||
</header>
|
||||
|
||||
<section class="ibm__section">
|
||||
<div class="ibm__section-head">
|
||||
<span class="ibm__section-label">Binding</span>
|
||||
@@ -569,15 +564,15 @@ function onKeydown(ev: KeyboardEvent): void {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<footer class="ibm__foot">
|
||||
<span class="ibm__hint">
|
||||
<Kbd keys="Esc" size="sm" /> cancel · <Kbd keys="Mod+Enter" size="sm" /> save
|
||||
</span>
|
||||
<Button size="sm" data-test="ibm-cancel" @click="onCancel">Cancel</Button>
|
||||
<Button size="sm" variant="primary" :disabled="!isDirty" data-test="ibm-save" @click="onSave">Save</Button>
|
||||
</footer>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="ibm__hint">
|
||||
<Kbd keys="Esc" size="sm" /> cancel · <Kbd keys="Mod+Enter" size="sm" /> save
|
||||
</span>
|
||||
<Button size="sm" data-test="ibm-cancel" @click="onCancel">Cancel</Button>
|
||||
<Button size="sm" variant="primary" :disabled="!isDirty" data-test="ibm-save" @click="onSave">Save</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<!-- Shared modal-template-controls partial — imported UNSCOPED so the
|
||||
@@ -595,12 +590,37 @@ function onKeydown(ev: KeyboardEvent): void {
|
||||
|
||||
<style scoped>
|
||||
.ibm {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius, 6px);
|
||||
box-shadow: var(--wp-shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ibm__title-block { flex: 1 1 auto; min-width: 0; }
|
||||
.ibm__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--ibm-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%,
|
||||
var(--wp-bg2) 100%
|
||||
);
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
}
|
||||
.ibm__head-icon {
|
||||
font-size: 18px;
|
||||
color: var(--ibm-accent, var(--wp-accent));
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.ibm__title-block { flex: 1; min-width: 0; }
|
||||
.ibm__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -616,8 +636,8 @@ function onKeydown(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-modal-accent, var(--wp-accent)) 22%, transparent);
|
||||
color: var(--wp-modal-accent, var(--wp-accent));
|
||||
background: color-mix(in srgb, var(--ibm-accent, var(--wp-accent)) 22%, transparent);
|
||||
color: var(--ibm-accent, var(--wp-accent));
|
||||
}
|
||||
.ibm__sub {
|
||||
font: 400 10px var(--wp-font-mono);
|
||||
@@ -779,9 +799,15 @@ function onKeydown(ev: KeyboardEvent): void {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ibm__foot {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--wp-border-soft, var(--wp-border));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.ibm__hint {
|
||||
margin-right: auto;
|
||||
align-self: center;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import WpSegmented from "@/kit/WpSegmented.vue";
|
||||
import { kindIcon } from "../shared/kind-icons";
|
||||
import { choicesFor, countLabel, type Choice, type LibraryPlan, type PlanItem } from "./note-library-plan";
|
||||
@@ -51,14 +51,12 @@ function close(result: Record<string, Choice> | null): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="visible" size="md" flush @update:open="(v: boolean) => { if (!v) close(null); }">
|
||||
<template #header="{ titleId }">
|
||||
<div class="wp-note-lib__head">
|
||||
<span :id="titleId" class="wp-note-lib__title">Add to your library</span>
|
||||
<span class="wp-note-lib__sub">From {{ scope }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<ModalShell :visible="visible" labelledby="wp-note-lib-title" @close="close(null)">
|
||||
<div class="wp-note-lib" data-test="note-library-dialog">
|
||||
<header class="wp-note-lib__head">
|
||||
<span id="wp-note-lib-title" class="wp-note-lib__title">Add to your library</span>
|
||||
<span class="wp-note-lib__sub">From {{ scope }}</span>
|
||||
</header>
|
||||
<div class="wp-note-lib__body">
|
||||
<section v-if="fresh.length" class="wp-note-lib__group">
|
||||
<h3 class="wp-note-lib__label">New: {{ countLabel(fresh) }}</h3>
|
||||
@@ -97,27 +95,51 @@ function close(result: Record<string, Choice> | null): void {
|
||||
</p>
|
||||
<p class="wp-note-lib__hint">One step: Undo on the message afterwards takes it all back out.</p>
|
||||
</div>
|
||||
<footer class="wp-note-lib__foot">
|
||||
<Button size="sm" data-test="note-library-cancel" @click="close(null)">Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon="pi-download"
|
||||
:disabled="going.length === 0"
|
||||
data-test="note-library-add"
|
||||
@click="close(choices)"
|
||||
>{{ going.length ? `Add ${countLabel(going)}` : "Nothing to add" }}</Button>
|
||||
</footer>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button size="sm" data-test="note-library-cancel" @click="close(null)">Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon="pi-download"
|
||||
:disabled="going.length === 0"
|
||||
data-test="note-library-add"
|
||||
@click="close(choices)"
|
||||
>{{ going.length ? `Add ${countLabel(going)}` : "Nothing to add" }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-note-lib { color: var(--wp-text); font-size: 13px; }
|
||||
.wp-note-lib__head { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; }
|
||||
.wp-note-lib {
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--wp-bg);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: min(8px, var(--wp-radius-cap, 999px));
|
||||
color: var(--wp-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.wp-note-lib__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
}
|
||||
.wp-note-lib__title { font-size: 14px; font-weight: 600; }
|
||||
.wp-note-lib__sub { font-size: 12px; color: var(--wp-text-muted); }
|
||||
.wp-note-lib__body { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; }
|
||||
.wp-note-lib__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.wp-note-lib__group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.wp-note-lib__label {
|
||||
margin: 0;
|
||||
@@ -154,4 +176,11 @@ function close(result: Record<string, Choice> | null): void {
|
||||
}
|
||||
.wp-note-lib__note { margin: 0; font-size: 12px; color: var(--wp-text-muted); }
|
||||
.wp-note-lib__hint { margin: 0; font-size: 11px; color: var(--wp-text-dim); }
|
||||
.wp-note-lib__foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding: 10px 16px 12px;
|
||||
border-top: 1px solid var(--wp-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,7 +40,8 @@ import PlaygroundBehaviorPreview from "./PlaygroundBehaviorPreview.vue";
|
||||
import Checkbox from "@/kit/Checkbox.vue";
|
||||
import WpSelect from "@/kit/WpSelect.vue";
|
||||
import Button from "@/kit/Button.vue";
|
||||
import Modal from "@/kit/Modal.vue";
|
||||
import ModalShell from "@/kit/ModalShell.vue";
|
||||
import { useDialogFocus } from "@/kit/useDialogFocus";
|
||||
import { useHostDialogGuard } from "@/components/shared/useHostDialogGuard";
|
||||
import { vToggleRow } from "@/kit/toggle-row";
|
||||
|
||||
@@ -262,12 +263,18 @@ function onClose(): void {
|
||||
}
|
||||
|
||||
// The playground opens from ComfyUI's Settings dialog, which stays open
|
||||
// underneath. Modal gives the overlay, the backdrop and Escape close, the key
|
||||
// shield and the focus trap + return; the host guard, bound to the modal's
|
||||
// overlay, stops the Settings dialog from swallowing clicks, pulling focus
|
||||
// back or closing along with us.
|
||||
// underneath. ModalShell gives the overlay, the backdrop and Escape close and
|
||||
// the key shield; useDialogFocus moves focus in, keeps Tab inside (run from
|
||||
// the shell's keydown, since the shield stops keys before window) and puts
|
||||
// focus back; the host guard, bound to the shell's overlay, stops the
|
||||
// Settings dialog from swallowing clicks, pulling focus back or closing
|
||||
// along with us.
|
||||
const panelEl = ref<HTMLElement | null>(null);
|
||||
const overlayEl = computed(() => panelEl.value?.closest<HTMLElement>(".wp-modal-overlay") ?? null);
|
||||
const focus = useDialogFocus(panelEl, () => playgroundOpen.value, {
|
||||
onEscape: () => undefined,
|
||||
window: false,
|
||||
});
|
||||
useHostDialogGuard(overlayEl, () => playgroundOpen.value);
|
||||
|
||||
function onOpenChange(open: boolean): void {
|
||||
@@ -300,27 +307,26 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="playgroundOpen"
|
||||
size="lg"
|
||||
flush
|
||||
accent="brand"
|
||||
icon="palette"
|
||||
@update:open="(v: boolean) => { if (!v) onClose(); }"
|
||||
>
|
||||
<template #header="{ titleId }">
|
||||
<div class="wp-pg__head-text">
|
||||
<h3 :id="titleId" class="wp-pg__title">Display Playground</h3>
|
||||
<p class="wp-pg__subtitle">
|
||||
Tweak Wildcard Pipeline visuals — changes apply live.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<ModalShell :visible="playgroundOpen" labelledby="wp-pg-title" @close="onClose" @keydown="focus.onKeydown">
|
||||
<section
|
||||
ref="panelEl"
|
||||
class="wp-pg"
|
||||
tabindex="-1"
|
||||
data-test="playground-panel"
|
||||
>
|
||||
<header class="wp-pg__head">
|
||||
<div class="wp-pg__head-inner">
|
||||
<i class="pi pi-palette wp-pg__head-icon" aria-hidden="true" />
|
||||
<div>
|
||||
<h3 id="wp-pg-title" class="wp-pg__title">Display Playground</h3>
|
||||
<p class="wp-pg__subtitle">
|
||||
Tweak Wildcard Pipeline visuals — changes apply live.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" icon="times" aria-label="Close playground" size="sm" kbd="Esc" @click="onClose" />
|
||||
</header>
|
||||
|
||||
<div class="wp-pg__body">
|
||||
<section class="wp-pg__controls" data-test="playground-controls">
|
||||
<fieldset class="wp-pg__group">
|
||||
@@ -532,13 +538,12 @@ onBeforeUnmount(() => {
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer class="wp-pg__foot">
|
||||
<Button size="md" @click="onResetAll">Reset all to defaults</Button>
|
||||
<Button variant="primary" size="md" @click="onClose">Done</Button>
|
||||
</footer>
|
||||
</section>
|
||||
<template #footer>
|
||||
<Button size="md" @click="onResetAll">Reset all to defaults</Button>
|
||||
<span class="wp-pg__foot-spacer" />
|
||||
<Button variant="primary" size="md" @click="onClose">Done</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -548,12 +553,52 @@ onBeforeUnmount(() => {
|
||||
* subservient to unlayered ones in the same origin. */
|
||||
@layer wp-extension {
|
||||
.wp-pg {
|
||||
background: var(--wp-bg);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
box-shadow: var(--wp-shadow-lg, 0 12px 40px rgba(0, 0, 0, 0.5));
|
||||
width: min(960px, 100%);
|
||||
max-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
color: var(--wp-text);
|
||||
outline: none;
|
||||
}
|
||||
.wp-pg__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-brand-gradient);
|
||||
position: relative;
|
||||
}
|
||||
/* Scrim that knocks the brand gradient back so the title stays readable on
|
||||
* top of it. It was hardcoded to a near-black rgba, so under the light theme
|
||||
* the banner stayed dark while `--wp-text` flipped to a DARK ink — dark text
|
||||
* on a dark band. The scrim now follows the theme: near-black under dark,
|
||||
* near-white under light, each keeping the same 0.85 knock-back. */
|
||||
.wp-pg__head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--wp-pg-head-scrim, rgba(35, 35, 35, 0.85));
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-theme-light .wp-pg__head {
|
||||
--wp-pg-head-scrim: rgba(248, 248, 250, 0.85);
|
||||
}
|
||||
.wp-theme-dark .wp-pg__head {
|
||||
--wp-pg-head-scrim: rgba(35, 35, 35, 0.85);
|
||||
}
|
||||
.wp-pg__head > * { position: relative; z-index: 1; }
|
||||
.wp-pg__head-inner { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.wp-pg__head-icon {
|
||||
font-size: 14px;
|
||||
color: var(--wp-violet);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wp-pg__head-text { flex: 1 1 auto; min-width: 0; }
|
||||
.wp-pg__title {
|
||||
margin: 0;
|
||||
font: 600 13px/1.2 var(--wp-font-sans);
|
||||
@@ -614,7 +659,14 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wp-pg__foot-spacer { flex: 1; }
|
||||
.wp-pg__foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--wp-border);
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
/* Narrow viewport — stack panes */
|
||||
@media (max-width: 720px) {
|
||||
.wp-pg__body {
|
||||
|
||||
@@ -32,8 +32,8 @@ describe("DisplayPlaygroundModal", () => {
|
||||
await open();
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
expect(dialog?.getAttribute("aria-modal")).toBe("true");
|
||||
const titleId = dialog?.getAttribute("aria-labelledby") ?? "";
|
||||
expect(document.getElementById(titleId)?.textContent).toBe("Display Playground");
|
||||
expect(dialog?.getAttribute("aria-labelledby")).toBe("wp-pg-title");
|
||||
expect(document.getElementById("wp-pg-title")?.textContent).toBe("Display Playground");
|
||||
expect(document.body.querySelector('[data-test="playground-controls"]')).not.toBeNull();
|
||||
expect(document.body.querySelector('[data-test="playground-preview"]')).not.toBeNull();
|
||||
});
|
||||
@@ -73,7 +73,7 @@ describe("DisplayPlaygroundModal", () => {
|
||||
|
||||
it("Done closes it and focus goes back to what opened it", async () => {
|
||||
const trigger = await open();
|
||||
expect(document.body.querySelector('[role="dialog"]')?.contains(document.activeElement)).toBe(true);
|
||||
expect(document.body.querySelector(".wp-pg")?.contains(document.activeElement)).toBe(true);
|
||||
const done = [...document.body.querySelectorAll("button")].find((b) => b.textContent?.trim() === "Done");
|
||||
done?.click();
|
||||
await nextTick();
|
||||
|
||||
@@ -92,12 +92,13 @@ const STYLES: { value: ToggleStyle; label: string; hint: string }[] = [
|
||||
{ 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). */
|
||||
/** `$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;
|
||||
if (!known || !d.value.value?.includes("$")) return [];
|
||||
const names = tokenize(d.value.value, "assembler")
|
||||
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));
|
||||
@@ -195,7 +196,7 @@ function onKey(ev: KeyboardEvent): void {
|
||||
<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 ? `its Value while on, nothing while off; $${d.var || 'name'}.bool is true or false` : 'true or false; the globe keeps it internal' }}</em></span>
|
||||
<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"
|
||||
@@ -265,8 +266,6 @@ function onKey(ev: KeyboardEvent): void {
|
||||
data-test="tg-value"
|
||||
@update:model-value="(v: string) => patch({ value: v.trim() ? v : undefined })"
|
||||
/>
|
||||
<Banner v-if="missingVars.length" tone="warn" size="sm" data-test="tg-missing-vars">{{ missingNote }}</Banner>
|
||||
<Banner v-if="hasInlinePicks(d.value ?? '')" tone="warn" size="sm" data-test="tg-inline-picks">{{ INLINE_PICKS_NOTE }}</Banner>
|
||||
<NegativeField
|
||||
:model-value="d.negative"
|
||||
surface="combine"
|
||||
@@ -278,6 +277,35 @@ function onKey(ev: KeyboardEvent): void {
|
||||
@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
|
||||
|
||||
@@ -90,6 +90,19 @@ describe("ToggleEditWindow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -295,6 +295,22 @@ describe("parseTogglesConfig 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({
|
||||
|
||||
@@ -56,6 +56,11 @@ export interface ToggleRow {
|
||||
/** 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;
|
||||
@@ -259,6 +264,8 @@ export function parseTogglesConfig(raw: unknown): TogglesConfig {
|
||||
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;
|
||||
@@ -319,16 +326,21 @@ export function effectiveToggles(cfg: TogglesConfig): Map<string, boolean> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 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 writes it while on and "" while off, one without
|
||||
* writes "true" / "false". */
|
||||
* 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)) continue;
|
||||
const on = eff.get(t.id) === true;
|
||||
values[t.var] = t.value ? (on ? t.value : "") : on ? "true" : "false";
|
||||
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));
|
||||
@@ -346,14 +358,15 @@ export function toggleBools(cfg: TogglesConfig): Record<string, boolean> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Toggle variable → its negative, for toggles that are on and have one
|
||||
* (mirror of `engine/toggles.py::own_negatives`). */
|
||||
/** 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;
|
||||
if (eff.get(t.id) && t.negative?.trim()) out[t.var] = t.negative.trim();
|
||||
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;
|
||||
|
||||
@@ -261,8 +261,9 @@ export const NODE_FIELDS = {
|
||||
{ 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 nothing, 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: "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." },
|
||||
|
||||
@@ -53,6 +53,21 @@ 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
|
||||
|
||||
@@ -86,6 +86,12 @@ import VarToken from "../../../components/docs/VarToken.vue";
|
||||
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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -455,3 +455,20 @@ def test_binding_warnings_say_what_was_skipped():
|
||||
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},
|
||||
}
|
||||
|
||||
Vendored
+64
-1
@@ -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. values is what each variable holds (a toggle with a value writes it while on, \"\" 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 (own_negatives / ownToggleNegatives).",
|
||||
"_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",
|
||||
@@ -1039,6 +1039,69 @@
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -251,6 +251,26 @@ def test_any_on_value_bool_and_negative():
|
||||
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
|
||||
@@ -265,3 +285,21 @@ def test_bool_accessor_reads_across_nodes_in_the_assembler():
|
||||
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"
|
||||
|
||||
@@ -139,9 +139,84 @@ tutorial(
|
||||
await t.uncallout("out");
|
||||
});
|
||||
|
||||
await t.h.step("A value and a negative", async () => {
|
||||
await t.say("A toggle can also carry words. Edit the first WP Toggles and add a toggle called Rain.");
|
||||
await openEdit(tg);
|
||||
await t.click(t.node(tg).locator('[data-test="tg-add"]'));
|
||||
await t.beat(700);
|
||||
await t.fill(winField("tg-name"), "Rain", { cps: 14 });
|
||||
await t.press("Tab");
|
||||
await t.beat(300);
|
||||
await t.fill(winField("tg-var"), "rain", { cps: 14 });
|
||||
await t.press("Tab");
|
||||
await t.beat(400);
|
||||
await t.waitVoice();
|
||||
await t.fill(winField("tg-value"), "in the rain", { cps: 14 });
|
||||
await t.beat(400);
|
||||
await t.callout("value", win().locator('[data-test="tg-value"]').first(), "On: $rain prints this", 5);
|
||||
await t.say("Give it a Value, and while it's on, rain holds those words instead of true. While it's off, it holds nothing.");
|
||||
await t.waitVoice();
|
||||
await t.uncallout("value");
|
||||
await t.click(win().locator('[data-test="tg-negative-add"]'));
|
||||
await t.beat(500);
|
||||
await t.fill(winField("tg-negative"), "sunny sky", { cps: 14 });
|
||||
await t.beat(400);
|
||||
await t.callout("neg", win().locator('[data-test="tg-negative"]').first(), "Goes to the negative prompt", 5);
|
||||
await t.say("A Negative goes with it, to the negative prompt, only while the toggle is on.");
|
||||
await t.waitVoice();
|
||||
await t.uncallout("neg");
|
||||
await t.fill(winField("tg-off-value"), "under a clear sky", { cps: 14 });
|
||||
await t.beat(400);
|
||||
await t.callout("off", win().locator('[data-test="tg-off-value"]').first(), "Off: $rain prints this instead", 5);
|
||||
await t.say("An Off value is what it holds while it's off. It can have its own negative too.");
|
||||
await t.waitVoice();
|
||||
await t.uncallout("off");
|
||||
await winDone();
|
||||
const rain = t.node(tg).locator('[data-test^="tg-row-"]').filter({ hasText: "Rain" });
|
||||
await t.callout("globe", rain.locator('[data-test^="tg-internal-"]').first(), "Off: the Assembler can print it", 4);
|
||||
await t.say("Turn off its globe, so the Assembler can print it.");
|
||||
await t.click(rain.locator('[data-test^="tg-internal-"]').first());
|
||||
await t.beat(500);
|
||||
await t.waitVoice();
|
||||
await t.uncallout("globe");
|
||||
await t.click(t.node(tg).locator('[data-test="tg-edit-done"]'));
|
||||
await t.beat(800);
|
||||
await t.deselect();
|
||||
await t.say("Add rain to the end of the prompt, switch Rain on, and run.");
|
||||
const box = t.node(nodes.asm).locator("textarea, [contenteditable='true']").first();
|
||||
await t.click(box);
|
||||
await t.page.keyboard.press("ControlOrMeta+End");
|
||||
await t.type(", $rain", { cps: 14 });
|
||||
await t.press("Escape");
|
||||
await t.beat(400);
|
||||
await t.click(t.node(tg).locator('[data-test^="tg-row-"]').filter({ hasText: "Rain" }).locator('[data-test^="tg-ctl-"]').first());
|
||||
await t.beat(500);
|
||||
await t.waitVoice();
|
||||
await runFor(/in the rain/);
|
||||
await t.callout("out", t.node(nodes.text), "The value in the prompt", 5);
|
||||
await t.say("Rain's words are in the prompt.");
|
||||
await t.waitVoice();
|
||||
await t.uncallout("out");
|
||||
const neg = t.node(nodes.asm).locator('[data-test="asm-neg-preview"]');
|
||||
await t.callout("negp", neg, "sunny sky", 5);
|
||||
await t.say("And sunny sky is in the negative.");
|
||||
await t.waitVoice();
|
||||
await t.uncallout("negp");
|
||||
await t.say("Switch Rain off, and run again.");
|
||||
await t.click(t.node(tg).locator('[data-test^="tg-row-"]').filter({ hasText: "Rain" }).locator('[data-test^="tg-ctl-"]').first());
|
||||
await t.beat(500);
|
||||
await runFor(/under a clear sky/);
|
||||
await t.callout("out", t.node(nodes.text), "The Off value", 5);
|
||||
await t.say("Now it's the Off value, and sunny sky has left the negative.");
|
||||
await t.waitVoice();
|
||||
await t.uncallout("out");
|
||||
await t.say("Rules still get the switch: rain dot bool is true or false, whatever the values.");
|
||||
await t.waitVoice();
|
||||
});
|
||||
|
||||
await t.h.step("Pick one", async () => {
|
||||
await t.say("A toggle can also pick the words itself. Add a second WP Toggles after the first, and set it to Pick one.");
|
||||
const tg2 = await t.addNode("WP Toggles", { x: 300, y: 470 });
|
||||
const tg2 = await t.addNode("WP Toggles", { x: 300, y: 540 });
|
||||
await t.wire(tg, "context", tg2, "upstream");
|
||||
await t.wire(tg2, "context", nodes.ctx1, "upstream");
|
||||
await openEdit(tg2);
|
||||
|
||||
@@ -353,6 +353,14 @@ class WPContextLoop(io.ComfyNode):
|
||||
detail["values"] = len(wired_values)
|
||||
if bypass:
|
||||
detail["bypass"] = True
|
||||
if has_override:
|
||||
detail["strategy"] = strategy
|
||||
if len(kept) < total_count:
|
||||
detail["skipped"] = total_count - len(kept)
|
||||
if sweep_pins:
|
||||
# This frame's sweep pins: module uid → option id.
|
||||
detail["pins"] = dict(sweep_pins[idx % combos])
|
||||
detail["hold_others"] = bool(sweep["hold_others"])
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name, value in context_vars.items():
|
||||
row: dict[str, Any] = {
|
||||
|
||||
@@ -45,6 +45,10 @@ def _render_template(template: str, slot_values: dict[str, Any]) -> str:
|
||||
return _TEMPLATE_TOKEN_RE.sub(repl, template)
|
||||
|
||||
|
||||
|
||||
#: `$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_]*)")
|
||||
|
||||
class WPContextInjector(io.ComfyNode):
|
||||
"""Graph-side ctx writer. No library, no payload, no resolve-time logic.
|
||||
|
||||
@@ -160,7 +164,8 @@ class WPContextInjector(io.ComfyNode):
|
||||
neg_table.pop(binding_name, None)
|
||||
|
||||
def _emit_trace(
|
||||
binding_name: str, stored_val: Any, trace_kind: str, is_internal: bool
|
||||
binding_name: str, stored_val: Any, trace_kind: str, is_internal: bool,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
# `value` surfaces the written ctx value in Debug's trace
|
||||
# tab. Engine module traces use `writes: [{...}]` for the
|
||||
@@ -180,8 +185,20 @@ class WPContextInjector(io.ComfyNode):
|
||||
"internal": is_internal,
|
||||
"type": trace_kind,
|
||||
"value": value,
|
||||
**({"detail": detail} if detail else {}),
|
||||
})
|
||||
|
||||
def _template_detail(tmpl: str, scope: dict[str, Any]) -> dict[str, Any]:
|
||||
"""WP Debug: the template and what each `$name` in it filled with
|
||||
(``None`` when nothing in scope set it)."""
|
||||
reads: dict[str, str | None] = {}
|
||||
for name in _TEMPLATE_VAR_RE.findall(tmpl):
|
||||
if name in reads:
|
||||
continue
|
||||
val = scope.get(name)
|
||||
reads[name] = None if val is None else str(val)[:160]
|
||||
return {"template": tmpl[:400], **({"reads": reads} if reads else {})}
|
||||
|
||||
def _validated_binding(row: dict) -> str | None:
|
||||
"""Shared binding validation for both row kinds. Returns the
|
||||
stripped binding, or None when the row should be skipped
|
||||
@@ -250,6 +267,7 @@ class WPContextInjector(io.ComfyNode):
|
||||
if isinstance(template_raw, str) and template_raw.strip()
|
||||
else ""
|
||||
)
|
||||
own_slot: dict[str, Any] = {}
|
||||
# Pass-through path: row has no template, just needs the
|
||||
# wired socket value. Requires the slot to be wired or the
|
||||
# row is skipped (matches legacy behavior).
|
||||
@@ -271,7 +289,7 @@ class WPContextInjector(io.ComfyNode):
|
||||
# as any unknown ref) — top-to-bottom resolution means an
|
||||
# early socket row must not read a later socket. To
|
||||
# compose multiple sockets, use a GENERAL row instead.
|
||||
own_slot: dict[str, Any] = (
|
||||
own_slot = (
|
||||
{slot_name: slot_values.get(slot_name, "")}
|
||||
if isinstance(slot_name, str)
|
||||
else {}
|
||||
@@ -287,7 +305,13 @@ class WPContextInjector(io.ComfyNode):
|
||||
is_internal = bool(row.get("internal", False))
|
||||
if is_internal:
|
||||
internal_keys.add(stripped)
|
||||
_emit_trace(stripped, stored, trace_type, is_internal)
|
||||
_emit_trace(
|
||||
stripped, stored, trace_type, is_internal,
|
||||
{
|
||||
**({"input": slot_name} if isinstance(slot_name, str) and slot_name else {}),
|
||||
**(_template_detail(template_str, own_slot) if template_str else {}),
|
||||
},
|
||||
)
|
||||
|
||||
# ── Pass 2: general rows (in order, after all socket rows) ────
|
||||
# A general row resolves its template against `merged`: every
|
||||
@@ -322,7 +346,10 @@ class WPContextInjector(io.ComfyNode):
|
||||
is_internal = bool(row.get("internal", False))
|
||||
if is_internal:
|
||||
internal_keys.add(stripped)
|
||||
_emit_trace(stripped, stored, "str(template)", is_internal)
|
||||
_emit_trace(
|
||||
stripped, stored, "str(template)", is_internal,
|
||||
_template_detail(template_str, merged),
|
||||
)
|
||||
|
||||
if internal_keys:
|
||||
ctx["__wp_internal_keys__"] = sorted(internal_keys)
|
||||
|
||||
@@ -144,9 +144,8 @@ class WPModelInfo(io.ComfyNode):
|
||||
"message": f"WP Model Info skipped a variant rule: {problem}",
|
||||
})
|
||||
|
||||
family = pins["family"] or model_info.family_from_config_name(
|
||||
_config_class_name(model),
|
||||
)
|
||||
config_class = _config_class_name(model)
|
||||
family = pins["family"] or model_info.family_from_config_name(config_class)
|
||||
stem = model_info.model_stem(file_name)
|
||||
variant = pins["variant"] or model_info.detect_variant(stem, rules)
|
||||
|
||||
@@ -193,6 +192,22 @@ class WPModelInfo(io.ComfyNode):
|
||||
model_info.VARIANT_VAR: _pin_source("variant", wired, cfg.variant, "rules"),
|
||||
model_info.NAME_VAR: name_source,
|
||||
}
|
||||
# WP Debug: what each value was read from.
|
||||
matched = next(
|
||||
(r for r in rules if stem and not pins["variant"] and r.regex.search(stem)),
|
||||
None,
|
||||
)
|
||||
details: dict[str, dict[str, Any]] = {
|
||||
model_info.FAMILY_VAR: (
|
||||
{"config_class": config_class} if config_class and not pins["family"] else {}
|
||||
),
|
||||
model_info.VARIANT_VAR: {
|
||||
**({"rule": matched.pattern, "rule_variant": matched.variant} if matched else {}),
|
||||
**({"rules_count": len(rules)} if not pins["variant"] else {}),
|
||||
**({"stem": stem} if stem else {}),
|
||||
},
|
||||
model_info.NAME_VAR: {"file": file_name} if file_name else {},
|
||||
}
|
||||
traces = [
|
||||
{
|
||||
"node": "WP_ModelInfo",
|
||||
@@ -200,6 +215,7 @@ class WPModelInfo(io.ComfyNode):
|
||||
"internal": name in internal_vars,
|
||||
"type": sources[name],
|
||||
"value": value,
|
||||
**({"detail": details[name]} if details[name] else {}),
|
||||
}
|
||||
for name, value in values.items()
|
||||
]
|
||||
|
||||
@@ -11,6 +11,8 @@ Resolution (``needs`` links, Pick one) lives in ``engine/toggles.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
|
||||
|
||||
from engine import negatives, template, toggles
|
||||
@@ -66,8 +68,9 @@ class WPToggles(io.ComfyNode):
|
||||
values = toggles.resolve(cfg)
|
||||
set_vars = {t.var for t in cfg.toggles if t.id in from_upstream}
|
||||
group = cfg.group_var if cfg.mode == "one" else ""
|
||||
raw_values = dict(values)
|
||||
on_off = toggles.bools(cfg)
|
||||
valued = {t.var for t in cfg.toggles if t.value and t.var in values}
|
||||
valued = {t.var for t in cfg.toggles if t.writes_text and t.var in values}
|
||||
# A value may read upstream variables and the toggles' own. Inline
|
||||
# {a|b} picks need a seed the node doesn't have, so they stay as
|
||||
# written (the Assembler's seedless surface), like the Context
|
||||
@@ -88,8 +91,9 @@ class WPToggles(io.ComfyNode):
|
||||
return text.strip()
|
||||
|
||||
# Negatives go with their variable, so an Assembler that prints it
|
||||
# sends them to the negative prompt: each toggle's while it is on,
|
||||
# and the pick's with the Pick one variable.
|
||||
# sends them to the negative prompt: each toggle's Negative while it
|
||||
# is on (its Off negative while off), and the pick's with the Pick one
|
||||
# variable.
|
||||
own_negs = {k: resolve_neg(v) for k, v in toggles.own_negatives(cfg).items()}
|
||||
neg_text = resolve_neg(toggles.picked_negative(cfg)) if group in values else ""
|
||||
|
||||
@@ -136,6 +140,7 @@ class WPToggles(io.ComfyNode):
|
||||
flags.pop(k, None)
|
||||
if flags or "__wp_internal_flags__" in out_internals:
|
||||
out_internals["__wp_internal_flags__"] = flags
|
||||
details = _trace_details(cfg, values, group, raw_values, upstream_ctx)
|
||||
traces = [
|
||||
{
|
||||
"node": "WP_Toggles",
|
||||
@@ -147,6 +152,7 @@ class WPToggles(io.ComfyNode):
|
||||
**({"has_value": True} if name in valued and name != group else {}),
|
||||
**({"negative": filed[name]} if name in filed else {}),
|
||||
**({"source": "upstream"} if name in set_vars else {}),
|
||||
**({"detail": details[name]} if details.get(name) else {}),
|
||||
}
|
||||
for name, value in values.items()
|
||||
]
|
||||
@@ -182,3 +188,42 @@ class WPToggles(io.ComfyNode):
|
||||
PipelineContext.Type(context=ctx, debug=debug, internals=out_internals),
|
||||
*toggles.output_values(cfg),
|
||||
)
|
||||
|
||||
|
||||
def _trace_details(
|
||||
cfg: toggles.Config,
|
||||
values: dict[str, str],
|
||||
group: str,
|
||||
raw_values: dict[str, str],
|
||||
upstream_ctx: dict[str, Any],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""WP Debug: why each toggle ended up on or off, and how Pick one chose."""
|
||||
eff = toggles.effective(cfg)
|
||||
by_id = {t.id: t for t in cfg.toggles}
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for t in cfg.toggles:
|
||||
if not t.var or t.var not in values:
|
||||
continue
|
||||
d: dict[str, Any] = {"switch": t.on}
|
||||
if raw_values.get(t.var, "") != values[t.var]:
|
||||
d["raw"] = raw_values.get(t.var, "")[:240]
|
||||
if t.upstream:
|
||||
raw = upstream_ctx.get(t.var)
|
||||
d["upstream_read"] = None if raw is None else str(raw)[:80]
|
||||
if cfg.mode == "any" and t.needs:
|
||||
parent = by_id.get(t.needs)
|
||||
d["needs"] = parent.name if parent else ""
|
||||
if t.on and not eff[t.id]:
|
||||
d["held_off"] = True
|
||||
out[t.var] = d
|
||||
if group and group in values:
|
||||
picked = next((t for t in cfg.toggles if eff.get(t.id)), None)
|
||||
d = {"picked": picked.name if picked else ""}
|
||||
if cfg.forced and cfg.forced in by_id:
|
||||
d["forced_by"] = str(upstream_ctx.get(group, ""))[:80]
|
||||
if cfg.upstream_on:
|
||||
d["upstream_on"] = [by_id[i].name for i in cfg.upstream_on if i in by_id]
|
||||
if raw_values.get(group, "") != values[group]:
|
||||
d["raw"] = raw_values.get(group, "")[:240]
|
||||
out[group] = d
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user