Compare commits

...
124 Commits
Author SHA1 Message Date
semantic-release-bot aee42389c4 chore(release): 2.16.0 [skip ci] 2026-09-26 14:46:46 +00:00
Dumitru Picalau 44db79a9e2 Merge pull request #27 from DumiFlex/claude/mjs-lazy-chunks
perf(extension): emit lazy chunks as .mjs so ComfyUI stops importing them all
2026-09-26 16:45:46 +02:00
Dumitru Picalau a1c7b886cf Merge pull request #26 from DumiFlex/claude/project-thread-q5btk3
feat(constraints): link picks with the new Only rule
2026-09-26 16:45:42 +02:00
Claude 9ff8014590 build(release): rebuild after stamping the version so the zip matches it
The release job built js/ + web/ before semantic-release stamped the new
version, so the release zip carried a bundle one version behind its
pyproject.toml and the new update notice would never clear.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 14:38:38 +00:00
Claude 43b2f0b3cf docs(assets): don't name a release in the prune comment
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 14:35:08 +00:00
Claude d6e1a9c2d6 perf(extension): emit lazy chunks as .mjs so ComfyUI stops importing them all
ComfyUI lists every **/*.js under WEB_DIRECTORY in /api/extensions and
the frontend imports each one, so every lazy chunk loaded on every page,
even with no WP node on the canvas (62 files, 1.73 MB raw). Only main.js
keeps .js now; an empty canvas loads 42 files, 576 KB raw.

The size gate counts .mjs and reports it under the old .js names so base
and head still diff. The stale-asset prune matches .mjs, and on the first
start after updating it deletes the old .js chunks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 01:03:38 +00:00
Claude bb78e94178 feat(ui): ask for a refresh when the pack was updated under an open page
/wp responses now carry X-WP-Version (the installed pyproject version).
The manager's stale banner names the new version, and the canvas shows a
one-time toast with a Refresh button, when it differs from the version
the page was loaded with.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 00:37:58 +00:00
Claude 4a666fcd29 fix(editors): show only links on the canvas and read newer exception keys
The canvas constraint editor now shows the same Linked note as the
manager, following instance mode overrides, disabled rows and extras.
The manager's exceptions table reads source_value/target_value first,
matching the engine, so rows written with the newer keys no longer load
blank.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-26 00:35:06 +00:00
Claude 020da57218 fix(editors): keep the exceptions table visible beside the only-link note
The Linked note sat inside the exceptions table's v-if/v-else chain, so
any only exception hid the whole table. Move it after the tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-25 22:42:26 +00:00
Claude fbd7b743a9 feat(editors): offer the only rule in the matrix and exceptions editors
The cell popover, matrix legend, SPA exceptions picker and canvas
exceptions cycle all gain Only. Neutral cells in a row that has an Only
rule show a faint x, since they are shut out at run time. The SPA
exceptions table says which source values are linked.

The canvas matrix now stores a neutral override as `allow`, the engine's
name for it; it used to store `neutral`, which failed the constraint.

Docs: add a Linking picks section, and correct Allow, which the docs said
keeps only the matching options when it has always meant no change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-25 22:20:49 +00:00
Claude dbc694f4d8 feat(schema): stamp schema_version 6 for constraints using the only rule
A payload with an `only` rule anywhere (library matrix or exceptions,
instance extra exceptions, instance mode overrides) now stamps
CONSTRAINT_ONLY_SCHEMA_VERSION (6) in both the TS publish path and the
Python exporter, and MAX_KNOWN_SCHEMA_VERSION moves to 6. An older engine
rejects the unknown mode outright, so the stamp makes that extension refuse
the pack and ask for an update. Additive: no migrator, CURRENT stays 2.

Needs community catalog v6 deployed first (server-first ordering).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-25 22:18:56 +00:00
Claude 3bce46b28d feat(constraints): add the only rule for linking picks
A constraint rule with mode `only` turns its row into an allow-list: when
the source fires, a target with no rule of its own in that row is excluded.
On an exception it links literal values (maid forces frilled apron); in a
matrix row it links tags. Before, a link needed an exclude rule for every
other target, and a target added later slipped through.

The rule itself weighs 1.0 and targets with any other rule keep it. A matrix
`allow` cell does not count as a rule because the editors show it as
Neutral. The axis-menu probe ignores value-level `only` so a tag link in
the same constraint still pins an accepts roll. Python and TS folds stay in
step through new constraint-corpus cases.

Also reads a legacy `neutral` cell mode override as `allow`: the canvas
matrix stored that value, and the engine rejected it, failing the whole
constraint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137To3tFTPKen2wYqEo3wme
2026-09-25 22:17:30 +00:00
semantic-release-bot c7314e98f2 chore(release): 2.15.3 [skip ci] 2026-09-25 21:45:52 +00:00
Dumitru Picalau 6cdf7996c8 Merge pull request #25 from DumiFlex/claude/project-thread-h85wt4
fix(axes): stop silent accepts demotion on rename, and explain empty axis reads
2026-09-25 23:45:14 +02:00
Claude aef082964d fix(axes): say when a $var.AXIS read is empty because the pick has no tag
When the picked option carried no tag on a declared accepts axis, the run
warned "$outfit has no 'SHOES' axis — is that tag group marked
'accepts'?", sending users to fix a group that was fine. Wildcards now
record which accepts axes they declare (`__wp_axis_decl__`, carried across
nodes and held seeds like `__wp_axes__`), so the resolver can tell the
cases apart: an untagged pick becomes an info note, and a genuinely
unknown axis names the axes that do exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxeNnfTMTyaKQwCYWA6frg
2026-09-25 21:25:26 +00:00
Claude 67ee027592 fix(editor): refuse renaming an accepts group to a non-identifier name
Renaming an accepts tag group to a name `$var.NAME` cannot parse used to
demote it to classify without a word, silently emptying every
`$var.AXIS` read of it. The rename is now refused with a toast (same rule
as promoting such a name), and the input puts the old name back. A name
colliding with another group now does the same instead of leaving the
input showing a name that was never stored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxeNnfTMTyaKQwCYWA6frg
2026-09-25 21:24:04 +00:00
Dumitru PicalauandClaude c436bd931f docs(chips): document chips, @{…} references and the filter grammar (#23)
New Docs page "Chips & references" (How it connects): what a chip is, its
three states (resolved, filtered, broken), how to make a placeholder $var
chip, the four-segment @{uuid#name:filter!null} form versus plain @name, and
the sub-category filter grammar (not / and / or / comma, parentheses,
precedence, reserved words, null as a flag). Registered with keywords so a
docs search for "chip" or "filter" finds it. Glossary gains Chip, Reference
and Filter expression; the wildcard and variable-pipeline pages link to it.


Claude-Session: https://claude.ai/code/session_012LhbKjx2xyGYREkvcShihy

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 22:59:57 +02:00
semantic-release-bot 417e36da5d chore(release): 2.15.2 [skip ci] 2026-09-25 20:46:07 +00:00
Dumitru Picalau 0d7e5b73a4 Merge pull request #24 from DumiFlex/claude/project-thread-pqody6
fix(axes): positional multi-pick axis reads + real rolls in the assembler preview
2026-09-25 22:45:29 +02:00
Claude 35b401c371 Merge remote-tracking branch 'origin/main' into claude/project-thread-pqody6 2026-09-25 20:37:56 +00:00
semantic-release-bot b8679a621e chore(release): 2.15.1 [skip ci] 2026-09-25 20:35:58 +00:00
Dumitru Picalau 918ba26b27 Merge pull request #22 from DumiFlex/claude/project-thread-w2diej
fix(export): stamp library file exports with the schema version their content needs
2026-09-25 22:35:14 +02:00
Claude 9f0f38a0a6 fix(assembler): preview $var.AXIS from the preview run's roll
The assembler preview filled every `$var.AXIS` with the axis's first
tag, independent of the option the preview had picked, so it could show
"sandals | robe | red stilettos". /wp/api/preview/resolve now returns
the rolled `__wp_axes__` beside the values and the preview reads it
with the same shaping as the engine, falling back to the first-tag
guess only until the run answers. Also accepts the axis-first
`$var.AXIS.K` order the engine already parses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fQ7Lkc8AxwXqsRZrFBxE9
2026-09-25 20:18:38 +00:00
Claude 4afbb5a08e fix(axes): keep multi-pick axis reads positional
`$outfit.K.AXIS` compacted away picks whose option has no tag on the
axis, so every later index slid down and `$outfit.0.SHOES` could print
pick 1's shoe beside pick 0. Both the resolver (`get_axis`) and the
derivation condition reader now keep one slot per pick.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011fQ7Lkc8AxwXqsRZrFBxE9
2026-09-25 20:18:14 +00:00
Claude 6d8b609454 fix(export): stamp library file exports with the schema version their content needs
build_export_payload always stamped CURRENT_SCHEMA_VERSION (2), so a file
export using range multi-picks, a non-default constraint target_select or
an accepts tag axis went out as v2 and an older extension installed it
while misreading or dropping those features.

Port the TS schemaVersionForPayload logic to engine/migrations/stamping.py
(same SP2b regex, reach and tag-axis walks) and stamp the export from all
buckets, auto-included inner bundles included. The SP2B / SP3 / TAG_AXES
constants now also live in engine/migrations beside MAX_KNOWN.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174Xsr85WVPSVjSREXkSi5a
2026-09-25 19:55:46 +00:00
semantic-release-bot 7960d46085 chore(release): 2.15.0 [skip ci] 2026-09-25 12:15:41 +00:00
Dumitru Picalau ad4b78cfbb Merge pull request #21 from DumiFlex/claude/project-thread-slpl3o
Unify schema_version stamps: v5 for accepts tag axes, installs keep the pack's own version
2026-09-25 14:14:27 +02:00
semantic-release-bot a54c525bf6 chore(release): 2.14.1 [skip ci] 2026-09-25 12:10:08 +00:00
Dumitru Picalau 06231cf226 Merge pull request #20 from DumiFlex/claude/project-thread-rxg60x
fix(editor): stop a second cut on an empty field wiping the clipboard
2026-09-25 14:09:36 +02:00
Claude 0a7f4f4386 test(parity): fail CI when engine-parity fixtures are stale; fix schema docs
The vitest parity suite checks the TS validators against fixtures that
scripts/dump_engine_shapes.py writes, but nothing re-ran the script, so a
handler shape change left the fixtures silently stale. A pytest now
re-runs the dump in memory and compares.

CLAUDE.md's schema section pointed at files that don't exist
(import-export/validators/, migration-machine.ts, ENGINE_SCHEMA_VERSION).
It now names the real files and spells out CURRENT vs MAX_KNOWN vs the
content stamp, plus the additive-bump procedure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112KkzW3unDU8PGrTpTDL7r
2026-09-25 11:46:07 +00:00
Claude 82d5bcaa1b feat(schema): stamp schema_version 5 for wildcards with accepts tag axes
A wildcard whose tag_group_kinds marks a group accepts now publishes as
v5 (TAG_AXES_SCHEMA_VERSION) instead of v2, and MAX_KNOWN_SCHEMA_VERSION
moves to 5 in TS and Python. An older extension strips tag_group_kinds
on install, silently turning accepts axes back into classify groups; the
v5 stamp makes it refuse the pack and ask for an update instead.
Additive like v4: no migrator, CURRENT_SCHEMA_VERSION stays 2.

Needs community catalog v5 deployed first (server-first ordering).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112KkzW3unDU8PGrTpTDL7r
2026-09-25 11:43:38 +00:00
Claude 8b6b6ad6d2 fix(community): install downloads at the schema version they were published with
wrapAsEngineExport pinned schema_version 1 and the dependency downloader
pinned 4. installEnvelope migrates from that number, so every community
update re-ran the v1->v2 migration over already-current content, and a
genuinely old dependency would have skipped the migration it needed.
Both now carry the download's own schema_version.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112KkzW3unDU8PGrTpTDL7r
2026-09-25 11:37:24 +00:00
DumiFlex 27943d8da3 fix(editor): stop a second cut on an empty field wiping the clipboard
Backlog P1 "Text input instability on Firefox" asked for a clean repro of
the reported path on the current build: two-option wildcard, add a third,
Ctrl+A Ctrl+X an option into the empty one, twice. Driven in Firefox 153
against 2.14.0 across five variants (cut/paste chains, back-and-forth,
hammered without waits, cut twice in one field). None crashed, bricked the
field or logged an error; the fragment-anchor repairs from 2.13.x hold.

One real defect did reproduce, and it is the content-loss half of the
report. Ctrl+A on an empty field selects nothing but the ZWSP caret pad.
`onHostCut` / `onHostCopy` saw an empty raw selection and returned without
preventDefault, so the browser performed the cut itself and put the pad on
the clipboard, overwriting the text the user had just cut. The following
paste then wrote U+200B into the option value, where it is invisible and
effectively undeletable.

- Cut and copy now swallow a non-collapsed selection that covers no value,
  leaving the clipboard untouched. A collapsed caret still falls through
  to the browser as before.
- Paste strips ZWSPs from incoming data, so a clipboard that already holds
  a pad (from an older build) cannot smuggle one into a value.

Re-measured in Firefox on the rebuilt manager: the double cut now pastes
"red dress" back, and all five variants stay clean.
2026-09-25 13:33:07 +02:00
semantic-release-bot 38f539d400 chore(release): 2.14.0 [skip ci] 2026-08-20 21:11:41 +00:00
DumiFlex bd3fbd54c2 fix(release): drop stray root QA screenshots that broke pack-release
Three QA screenshots (accepts-icon-candidates.png, accepts-mark-live.png,
refchip-card.png) were git-added by accident in fd6642b2 and landed on
main via the tag-axes merge. pack-release.mjs asserts every top-level git
path is either in SHIP_PATHS or DEV_ONLY, so these unclassified captures
failed the 2.14.0 release in the prepare step (no tag or release was cut).

Remove them and anchor `/*.png` in .gitignore: root-level PNGs are always
scratch QA captures here, while every real image lives under public/. The
anchor keeps public/images/** untouched and stops this recurring.
2026-08-21 00:10:32 +03:00
Dumitru Picalau eb18825853 Merge pull request #19 from DumiFlex/feat/tag-axes
Tag axes + v2.14 sweep
2026-08-21 00:04:20 +03:00
DumiFlex 58360c75f8 docs(axes): document tag axes and add the 2.14 release highlight
Wildcard page: correct the accepts fold description (one rolled tag, not an OR
over the menu). Variable-pipeline: $var.AXIS / $var.K.AXIS accessor row.
Constraints concept: an accepts-axis source keys on the rolled tag, an accepts
target OR-folds, and the two agree under a diagonal. Glossary + warning-types:
axis/accepts/classify terms and the two tag-axis warnings. next.md: tag axes
leads the highlights.
2026-08-21 00:01:41 +03:00
DumiFlex 934f7dc92d fix(axes): drive constraints by the rolled tag, fix the assembler read, preserve on import
An accepts axis rolls one tag per pick (read as $var.AXIS), but a constraint
sourced from it folded over the option's whole menu instead of that rolled tag,
so $outfit.SHOES and the constrained $shoes could disagree. The source now
sends only the rolled winner; a target accepts axis OR-folds so a multi-tag
option survives when any alternative is allowed, then its own roll is pinned to
the allowed tag -- $source.AXIS == $target.AXIS under a diagonal. Classify
groups are unchanged (multiply).

The assembler also rendered $var.AXIS empty: __wp_axes__ crosses the socket on
ContextPayload.internals but the node only merged the internal-flag map; it now
merges the whole carve-out so the accessor resolves.

Import: the community validators silently dropped tag_group_kinds (and the
constraint target_select) because the lenient payload object never listed them,
degrading a shared accepts axis to classify. Both are now in the v2/v1
validators + the engine-parity fixtures, with preservation tests. Engine math
mirrored in TS via the shared corpus.
2026-08-20 23:58:44 +03:00
DumiFlex caffb9824f feat(editor): advise when an accepts-axis option carries no axis tag
An accepts axis only yields a value for options that carry one of its tags; an
option with none makes $var.AXIS render empty when it's picked, with no other
symptom. The wildcard editor's sub-category panel now shows a non-blocking
amber note per accepts group when some options don't cover it ("N options carry
no SHOES tag — $outfit.SHOES is empty when it's picked"). Silent when the axis
is fully covered or the group is classify; the null option is excluded (it
deliberately produces nothing). Verified live both directions.
2026-08-19 23:56:29 +03:00
DumiFlex fd6642b258 feat(constraints): mark accepts axes in the matrix
The constraint matrix showed an accepts group (SHOES) exactly like an ordinary
tag group — the editor tells them apart via its accepts button, but the matrix
couldn't. Plumb tag_group_kinds through to the grid (SPA ConstraintMatrix + the
canvas MatrixSection, both wired from their editor/modal) and mark an accepts
band with a small amber pi-sync after the axis name, keeping the group's own hue
so it never collides with an amber group. A hover tooltip and a MatrixLegend row
(shown only when the matrix has an accepts axis) explain the mark. Verified live:
mark on SHOES, none on a classify-only source, legend row appears/​hides with it.
2026-08-19 23:30:00 +03:00
DumiFlex 1cb59b2159 fix(select): size short options to content, and let a small list opt out of filtering
The exception mode dropdown clipped its labels and overlapped the selected
row's tick with a scrollbar. Two real causes, both in Select:

- `.wp-select__option-label` was `flex: 1` (basis 0), so it split the row 50/50
  with the always-present spacer and squeezed a short nowrap option to
  half-width, clipped by overflow:hidden even with room. `flex: 0 1 auto` sizes
  it to content and lets the spacer do the filling; a long option still shrinks
  into the 2-line clamp.
- The "Type to filter" hint showed for any list of 2+, and its height pushed the
  4-item menu past its cap into a scrollbar that overlaid the tick. New
  `filterable` prop (default on) turns the hint + type-to-filter off; the mode
  picker sets it false, so 4 options render as a clean menu — no hint, no scroll.
2026-08-19 19:13:30 +03:00
DumiFlex afa4e47bb8 fix(constraints): stop the exception mode dropdown clipping its labels
The mode Select sits in a table cell that collapses the trigger narrow, and the
Select sizes its menu to the trigger — so the nowrap mode chips clipped to
"N / Exc / Boo / Red". Floor the control at 132px so Neutral / Exclude / Boost /
Reduce each fit. (This is the constraints-mode.mp4 report.)
2026-08-19 19:00:17 +03:00
DumiFlex 1486785df1 feat(refs): make the ref info card readable and actionable
The nested-ref hover card was 10px mono and pointer-events:none — the report's
"unnecessarily small", and un-clickable by design. Bumped to 12px and made it
interactive with a hover bridge (leave the chip, enter the card, it stays — the
GitHub-hovercard pattern), so a footer of actions is reachable: Copy id (with a
"Copied" tick) and Open (the linked module's SPA editor in a new window; full
phrasing in the title). Copy works even for a broken ref; Open is gated on the
ref resolving. The chip's own click is untouched, so no clash with the template
editor's click-to-edit-filter.
2026-08-19 18:58:55 +03:00
DumiFlex b765235f2b feat(spa): sub-category panel default setting, and Send to Test Runner
Two backlog items.

- The wildcard editor's sub-category panel now opens on load per a new
  "Sub-categories panel" display-pref (Settings → Editing): "Expanded when it
  has groups" (default), "Always expanded", or "Always collapsed". Resolved once
  per load so collapsing it and adding a tag doesn't yank it back open. (Also
  dropped a duplicated tag_group_kinds assignment in the load path.)

- "Send to Test Runner" jumps to the runner with a module/bundle preselected via
  ?kind=&module=, which TestRunner reads on mount. Added as an icon row action on
  every list view (and All items, keyed by row.kind) and a labelled button in
  every module editor header. New shared SendToTestRunner.vue.
2026-08-19 07:55:11 +03:00
DumiFlex fac9c4df4b fix(editors): jump to the first error when a save fails
The Save button and the validation banner sit at opposite ends of a long
editor, so clicking Save on a wildcard with 30 options did nothing the user
could see — a reporter clicked it five times before finding the banner off
screen. EditorFrame now watches its `errors` prop and, on the empty→populated
edge (the "tried to save while invalid" moment every editor already gates on),
scrolls the first offending field into view via the existing scrollToField.
Guarded to that one edge so re-validation on each keystroke while fixing it
doesn't yank the scroll. Covers all five editors at once.
2026-08-19 04:56:18 +03:00
DumiFlex 69a855291c fix(spa): carry NSFW rating on copy, and give icon buttons a hover tooltip
Two backlog QoL fixes.

- duplicate_module dropped content_rating, so copying an 18+ module produced a
  SFW copy that slipped out from under the filter. Forward it.
- Icon-only Buttons carried an aria-label (screen-reader name) but no title, so
  a sighted user hovering a bare icon got nothing. Mirror the label into title
  when there's no visible text — fixes every icon button at once.
2026-08-19 04:45:11 +03:00
DumiFlex df252c736a feat(loop): rebind frame chips to Ctrl-bypass / Alt-lock
Shift for seed lock and Alt for bypass didn't match the reflex — users reach
for Ctrl to bypass and Alt to lock. Swap to Ctrl-click (Cmd on macOS, where a
Ctrl-click is a right-click and never reaches the handler) for bypass and
Alt-click for the seed lock; Shift is freed. Plain click still selects (Loop)
or locks (Seed List, no cursor). Legend + tooltips + the three widget test
suites updated. Verified on the live canvas: ctrl→bypass, alt→lock, shift no-op.
2026-08-19 04:31:26 +03:00
DumiFlex 2cf7f7e8c3 fix(axes,internal): fix the inline accessor warn+colour and internal last-write-wins
Two bugs the modal-only testing missed, both verified in the live canvas.

1. Assembler template (and every inline `.wp-rt-var` render) mis-parsed an
   index-first accessor. `varSpanAttrs` hand-rolled the axis extraction with a
   `.replace(/\.\d+$/,"")` that only strips a TRAILING index, so
   `$outfit.0.SHOES` produced axis "0.SHOES", matched no declared axis, and
   painted a valid reference with the unknown-axis warning — the yellow
   squiggle in the screenshot. Replaced with the one shared `varAccessorParts`
   grammar (index + axis, either order), and split the inline tail into a
   neutral index span and an amber axis span to match the settled chip. The
   canvas CSS copy (rich-text-canvas.css) had no accessor rule at all, so the
   amber never applied there — added it.

2. `instance.internal` was add-only in three places (engine pipeline, the
   graph.ts kinds walk, the static resolver), so a var written internal then
   overridden by a PUBLIC module stayed hidden from the assembler forever.
   Made all three last-write-wins: whichever module writes the var LAST decides
   its internal-ness, exactly like its value. Verified both directions live —
   internal→public shows "public value", public→internal hides it.
2026-08-19 03:27:25 +03:00
DumiFlex 0fce54165e fix(chip): stop tinting the pick index like an axis
A var chip's accessor is two unrelated things — a pick index (.0) and a tag
axis (.SHOES) — and both were rendered in one amber span. So `$outfit.0.SHOES`
painted the .0 amber too, and since amber is the axis accent it read as "the
index is an axis / the index is flagged". Worse for the exact case the user
hit: the index only means anything on a multi-pick source, and whether a
source is multi-pick is a runtime fact no static surface can know, so the
editor has no business colouring it at all.

Split the accessor into its own index span (neutral, dimmed, the chip's own
colour) and axis span (amber, or the unknown-axis warning). Only the axis —
the one part that names a library concept — carries a colour now.
2026-08-19 02:38:04 +03:00
DumiFlex f4c8411fb8 fix(assembler): resolve axis reads in the preview, keep engine keys out of the chips
The assembler preview walked the template with a regex that stopped at the
numeric accessor, so `$outfit.SHOES` matched `$outfit`, printed its value and
left ".SHOES" as literal text beside it. It now matches the whole reference
and resolves an axis against the chain's accepts axes — passed down from the
same walk that already feeds the resolved map — falling back to the raw
reference when the chain declares no such axis, so a typo is visible rather
than silently dropped.

Separately: `upstreamVars` is `Object.keys(fresh)`, and the API branch merged
the run ctx in without filtering, so every engine table rendered as a variable
chip. The fallback branch had always filtered; the API branch never did, which
is why the chips appeared as soon as a table existed to leak.
2026-08-19 01:30:12 +03:00
DumiFlex 6aadcf0575 fix(axes): resolve axis reads in the combine preview, stop leaking the tables
The preview tokenizer never carried the axis accessor, so every consumer
rendered the variable and left ".SHOES" beside it as text — which is why both
the PREVIEW and RESOLVED lines read "a white t-shirt and denim skirt.SHOES".
It now carries `axis` the same way it already carried `index`, and the combine
editor resolves an axis read to a tag rather than to the variable's value. An
axis the producer does not declare stays verbatim: swallowing it would delete
text the user typed.

Also undoes the leak from the previous commit. strip_internals' result IS the
variable surface — the assembler's variables panel enumerates it, so keeping
__wp_axes__ / __wp_picks__ there listed them as $__wp_axes__ chips. The tables
are re-attached by with_resolver_tables at the one call site that needs them.
2026-08-19 01:08:30 +03:00
DumiFlex c4e1663210 fix(axes): carry axes across writers, resolve them in previews and the assembler
Three separate places dropped an axis read on the floor:

- The producer map replaced a variable's record wholesale on every later
  write, so a derivation or injector rebinding $outfit erased the wildcard's
  axes. The engine keys __wp_axes__ by binding at pick time and a rebind
  never clears it, so the axes must survive the override. Carried forward in
  both the graph walk and the same-node sibling merge.

- The static preview resolver knew only the numeric .K accessor, so
  "$outfit.SHOES" substituted $outfit and stranded ".SHOES" — the preview and
  resolved lines both read "a white t-shirt and denim skirt.SHOES". It now
  resolves an axis to its first tag, the same deterministic convention @{uuid}
  expansion already uses, and leaves an accessor it cannot resolve verbatim
  rather than deleting text.

- strip_internals runs at the prompt-render boundary and dropped every
  __-prefixed key, __wp_axes__ and __wp_picks__ included. The assembler
  therefore rendered "$outfit.SHOES" and "$outfit.0" as literal text while the
  identical template resolved one node upstream. Those two tables are read by
  key, never as variables, so keeping them cannot leak a $var.
2026-08-19 00:51:50 +03:00
DumiFlex 13acd9a17d fix(rich-text): stop the host swap from erasing the field
Committing an autocomplete suggestion bumps hostEpoch, which replaces the
host element. In a browser that removes the node the caret is in, so the
browser fires blur on the way out — and onHostBlur treated it as the user
leaving: it read the already-detached host as "", saw that differ from the
atoms just applied, and re-applied the empty parse. Every $var commit wiped
the whole template, on the canvas and in the SPA alike.

Flag the swap and let blur skip it. jsdom does not fire blur on removal,
which is why the existing tests never saw this; the new ones dispatch the
blur the browser would, in the window before the swap settles.
2026-08-19 00:28:54 +03:00
DumiFlex a29c0fc564 fix(canvas): expose accepts axes for same-node wildcards
allVarProducers merges the graph walk with siblingVarInfo.producers, and a
variable written in the same Context node takes the sibling entry wholesale.
Only the graph walk carried axes, so `$var.AXIS` completion went missing for
the most common layout of all: the wildcard and the template that reads it
sitting in one node.

Read the axes off the module's own payload snapshot rather than the library,
so the node offers what it will actually run.
2026-08-19 00:02:49 +03:00
DumiFlex 7b820d193d fix(editor): do not flag an axis a stale node snapshot cannot vouch for
A canvas node carries its own payload SNAPSHOT of each module, so a wildcard
added before its group was promoted has tag_groups but no tag_group_kinds at
all. The warning treated that as 'declares no axes' and put a wavy underline
under $outfit.SHOES - a reference that resolves perfectly well, and will look
fine again the moment the node's snapshot is refreshed.

Absence of the key means UNKNOWABLE, not empty. The mark is now claimed only
when the producer declares SOME axes and this is not among them, which is the
one case where the read is known to be wrong. Same rule on both the chip and
the flat-text surface.

Does not fix the missing axis ROWS on canvas: those come from the same stale
snapshot and need the node's module refreshed from the library, which is the
existing drift flow rather than something to paper over here.
2026-08-18 23:52:42 +03:00
DumiFlex dc119ad6bd feat(editor): give the axis accessor one fixed amber
Was the axis group's own hue, matching the pills in the wildcard editor.
That made one meaning look different from variable to variable - SHOES came
out teal in one wildcard and purple in another - when an accessor is the same
KIND of thing everywhere it appears. Now a single --wp-axis token used by both
the chip and the suggestion row.

Amber-400 deliberately, not --wp-warn amber-500, which marks an UNDECLARED
axis: a valid accessor must never read as the warning.

Also restores the accessor style block itself. Both earlier attempts anchored
on a .wp-refchip__label rule that does not exist in this file, so neither the
colour nor the unknown-axis mark had ever actually shipped - the class was on
the element with no rule behind it. Verified in the running SPA: accessor
rgb(251,191,36) against the chip's rgb(34,197,94).
2026-08-18 23:30:50 +03:00
DumiFlex 7e6e373076 fix(editor): keep offering axes once the query carries an accessor
Typing $outfit.0. closed the popover: the flat pool is substring-filtered,
and no entry contains 'outfit.0.'. $outfit. only ever appeared to work by
luck - the pool holds 'outfit.SHOES', which happens to contain 'outfit.'.

From the first dot the question changes from 'which variable' to 'which
axis', so the pool does too: it offers that variable's axes, narrowed by the
fragment after the dot, and preserves a pick index the user already typed
($outfit.0. -> $outfit.0.SHOES). Verified in the running SPA.
2026-08-18 23:02:01 +03:00
DumiFlex e0b4ab0bcc chore: ignore playwright-mcp session artifacts
Browser-automation snapshots and console logs from a QA session; they are
per-run scratch, not source.
2026-08-18 22:56:18 +03:00
DumiFlex 27f6244142 fix(editor): stop a self-emit echo chipifying mid-word text on Backspace
Found by driving the real SPA with Playwright; jsdom could not see it. One
Backspace after typing $mo turned it into a chip, closed the popover and
threw the caret onto the host root - three separate reports, one cause.

The modelValue watcher guards against our own echo with
'next === lastEmittedValue && readHostAsText() === next'. But the
atom-direct edit paths sync the host DOM imperatively AFTER updating the
model, so at emit time readHostAsText() still returned the PRE-edit string.
The guard missed, the watcher re-parsed through parseForSurface, and that
chipifies. emitValue now claims the single round-trip it is about to cause,
released on the next tick so an uncontrolled parent cannot strand the claim.

Also fixed, both verified live:
- the axis segment now takes its group's hue on the chip, not just in the
  suggestion row; it was styled with opacity/weight only and rendered plain
  chip-green. A pick index deliberately stays neutral - it is positional,
  not semantic.
- the stale-results dimming applied to $var popovers, which have no async
  fetch, so rowsQuery never advanced and the list read stale from the first
  keystroke and never recovered.
- the axis row indent lost to a later padding SHORTHAND; now specificity-
  based so source order cannot undo it.
2026-08-18 22:54:46 +03:00
DumiFlex 726cca5d91 fix(editor): stop the atom model discarding var accessors, and mark unknown axes
QA sweep over axis authoring found the real fault under the reported
symptoms: the ATOM model had no accessor at all. VarAtom carried name and
index but no axis, and tokenToChipAtom never copied one, so $outfit.SHOES
round-tripped as $outfit - silent data loss, not a cosmetic issue. My
earlier fix was to inlineTokenHtml, a different layer, which is why the chip
still looked wrong.

Four hand-rolled var serialisers existed and one had already drifted:
parseForSurface wrote '$' + name and dropped a .K index, so $mood.0 lost
its accessor on the assembler surface today, independent of this feature.
All four now route through one serialiseVarAtom.

Also fixed from the sweep:
- the chip label was built from name alone, so the accessor never rendered
- a dangling dot ($outfit.) killed the trigger, closing the popover at the
  moment the axis list was most wanted
- the axis row's padding-left was reset by a later padding SHORTHAND on the
  base rule, so the indent never applied
- an undeclared axis was unmarked on chip surfaces; the warn styling had
  only landed on the flat text path

Two reported symptoms did NOT reproduce and are not fixed here: caret jumps,
and backspace committing a chip. The Backspace handler deletes and
preventDefaults correctly - an early version of this test deleted twice and
manufactured the failure.
2026-08-18 22:17:15 +03:00
DumiFlex 6d19bd94f4 feat(editor): render the accessor as its own segment, and stop flagging valid axis reads
The reference stays ONE .wp-rt-var atom - caret math and deletion are
unchanged - but the .AXIS / .K tail gets its own span so it reads as a
qualifier rather than part of the name.

Also fixes a real bug behind it: varSpanAttrs checked the WHOLE string against
varSuggestions, so $outfit.SHOES matched nothing and every valid axis read
wore the unknown-variable error styling. Scope is a property of the base
variable, so the check now uses the base.

A base that resolves with an axis that does not is warn-coloured rather than
silent, matching the unknown_tag_axis conflict rule. The engine renders an
undeclared axis as an empty string, so without a mark the only symptom is a
word quietly missing from the prompt.
2026-08-18 21:53:07 +03:00
DumiFlex 2e9a5a5b6a feat(editor): offer a variable's axes as rows in the $ autocomplete
Typing 'out' now lists outfit and, indented under it, outfit.SHOES and
outfit.EXPOSES with the tags each will roll from. One Enter commits the whole
reference, so a committed chip no longer seals the variable before you can
reach its axis.

Flattened into the existing list rather than a second picker like @{uuid}
uses: that picker exists because a ref needs a typed filter expression and a
toggle, which cannot live in a list row. An axis is just a name, so a second
popover would be machinery the problem does not call for - and it would tax
every axis-bearing variable, including the times you wanted the plain $outfit.

The member tags are shown because they are what tell SHOES from EXPOSES. The
accessor segment takes its group's own hue via axisHueAt, indexed over ALL
groups rather than the accepts ones so it matches the colour that group wears
in the wildcard editor.

No index rows: pick_min/pick_max are per-instance, so a library-authored
template has no range to offer and any number shown would be true for one use
and wrong for the next.
2026-08-18 21:48:01 +03:00
DumiFlex f487cc82a3 feat(editor): carry a variable's accepts axes to the template editors
Both surfaces already thread varProducers to RichTextInput, so axes ride on
that rather than a parallel map: the canvas reads them from the wildcard
payload during its graph walk, the SPA from the module catalog.

Read from the PAYLOAD deliberately. A group's kind is a library fact shared
by every instance, so completing against it is always correct - unlike
pick_min/pick_max, which live on the instance, so a library-authored
template has no pick range to offer and index completion is not
expressible at all.

Only accepts groups qualify: a classify group is not addressable as
$var.NAME, so suggesting one would complete to a reference the engine
renders as an empty string.
2026-08-18 21:39:19 +03:00
DumiFlex 995dc4d17a fix(editor): stop an axis read rendering as inert text and hijacking tag autocomplete
Two defects, both from the accessor grammar landing without the editor being
told about it.

varBaseName stripped a numeric .0 and nothing else, so outfit.SHOES matched
no known variable and never became a chip - the reference resolved fine at
runtime but looked like dead text while authoring. It now understands every
accessor shape and leaves genuinely invalid ones (two indices, three
segments) alone, since reducing those would claim a reference the engine
will not resolve.

The sigil probe walked back over SHOES, met a dot it did not recognise, and
gave up; the booru-tag probe then took the caret and offered shoe TAGS,
none of which are insertable there. It now skips up to two accessor
segments - one index and one axis, either order - and stops, so dotted prose
is not chewed backwards looking for a sigil.
2026-08-18 21:32:59 +03:00
DumiFlex 393004bc54 fix(editor): keep the group-kind control visible at rest
An earlier version faded the classify capsule in on header hover to keep the
default state quiet. That optimised for ink over discovery: a control you
only see by accident is one most people never learn exists, and this is a
capability nobody is looking for yet.

Every group now states its kind. The dim weight keeps it from competing with
the axis name.
2026-08-18 18:33:49 +03:00
DumiFlex 93a05c948e refactor(editor): make the group-kind control a capsule with a quiet default
The pills this control governs are hue-tinted capsules; the control was a
flat grey square borrowed from the ungroup button, which is the vocabulary
of a utility ACTION. On a piece of state it read as a stray control and the
hover landed as a hard block unrelated to anything near it.

Now the same capsule recipe as the pills - hue fill, 999px, 1px hue border -
so it reads as a property of the axis, and hover deepens the tint already
there rather than adding a second colour. It is also labelled, where an icon
needed a tooltip before anyone knew what it toggled.

A classify group shows nothing until its header is hovered, so the default -
every group that exists today - costs no ink; only the promoted state, which
is real information, is permanently visible. Hidden with opacity rather than
display, so it keeps its place in the tab order and :focus-visible reveals it
for keyboard users.
2026-08-18 18:21:32 +03:00
DumiFlex fe7f12e65d refactor(editor): rebuild the group-kind control from the header's own vocabulary
The first version was a raw <select> with hand-rolled sizing - a 10.5px font,
a 4px radius and a 1px border, none of which exist in the design system. It
tripped both the typography and radius token audits and read as a foreign
form control in a header made entirely of icon buttons.

Now a 24px icon toggle with the same shape and hover as the ungroup button
beside it, tokens throughout, tinted with the group's own hue when promoted.
A classify group - which is every group that exists today - carries no extra
chrome at all; the promoted state is what earns the ink.

Being a toggle also removes the select-reset hack: the button renders from
tagGroupKinds, so a refused promotion can no longer leave the control
displaying a state that was never stored.
2026-08-18 17:58:16 +03:00
DumiFlex 05d53a0c6c fix(wildcard): an excluded pool emits nothing instead of the first option
_pick_weighted returned options[0] when every weight was zero. That looked
like a working pick - a real option, right shape, right place - and stayed
wrong silently until the list was reordered and the choice changed. It hid
both over-narrowed constraints and a wildcard whose options were all
disabled.

Now returns None, which every caller already turned into an empty binding.
That is what the null option means, so the engine had the vocabulary for
this already.

Applies to all three pools that constraints can zero: the chain handler, the
nested-@{} path in the resolver, and the SPA preview. The inline {a|b}
branch weights are left alone - a user writing {0::a|0::b} is a different
situation and constraints never touch it.

Three tests asserted the old behaviour and are rewritten to state the new
one; the constraint_excludes_all_options warning stays, and now
distinguishes an over-narrowed pool from a legitimate null roll.
2026-08-18 17:38:05 +03:00
DumiFlex b55a9794a3 docs(tags): explain what a tag is, and the two kinds
A user who had read the docs several times still expected $tag_name to
work, which was a documentation finding independent of any feature. Tags now
have two kinds, so the obligation got larger rather than smaller.

Teaches $outfit.0.SHOES as the one spelling. $outfit.SHOES.0 is accepted
but undocumented - offering both would hand the reader a choice with no
consequence.
2026-08-17 23:19:33 +03:00
DumiFlex c7c761424a feat(conflicts): flag a var axis read no upstream wildcard declares
The engine renders an undeclared axis as empty, so without a signal the
only symptom is a word silently missing from the prompt. Commonest cause is
a group that exists but still says classify; second is an axis renamed after
the read was written.

Deliberately conservative: the scanner runs per node and receives upstream
variables as names only, never payloads, so a read is checked only when a
wildcard in THIS node writes that binding. Guessing about an unseen upstream
producer would fire on correct workflows, and under-reporting beats a
warning nobody can act on.

The producer map is built before the module loop because a read can sit
above the wildcard that feeds it - resolution is by ctx, not by position.
2026-08-17 23:16:47 +03:00
DumiFlex 1e4398ae29 feat(editor): let a tag group declare whether it classifies or accepts
A select on each group header, defaulting to classify. Only accepts entries
are ever stored and the payload key is omitted when nothing is promoted, so
an untouched wildcard saves byte-identically.

The name check runs here rather than at save: an accepts group is read as
$var.NAME, and hearing about it after the work is done is too late. On
refusal the select is put back explicitly - the bound value never changed,
so Vue has no reason to re-render, and it would otherwise sit showing a
state we just declined to store.

Rename carries the kind across and ungroup drops it, since a kind naming no
group is invalid payload. renameGroup's comment claimed axis names were not
part of any grammar; that stopped being true with $var.AXIS.
2026-08-17 23:09:08 +03:00
DumiFlex f0117d4230 fix(constraints): a multi-tag source no longer excludes its whole target pool
The reported failure: an outfit option tagged sneakers, high_heels and
sandals excluded every shoe in the constrained target, the pool summed to
zero, and _pick_weighted fell through to options[0] forever - looking like a
working pick until the list was reordered.

The kind map is derived from the pick records rather than passed down from a
payload, since a pick only carries an axes entry when its source declared
that group accepts. That keeps combine_constraint_factor pure and makes a
legacy pick with no axes take the unchanged flat-product path.

Also fixes the SPA preview resolver, which mirrors the applier: it dropped
axes when building its per-pick list, so the preview would have shown a
narrower pool than the run actually produced.
2026-08-17 22:58:23 +03:00
DumiFlex 5d910f5f39 feat(derivation): let a condition test a rolled axis
Conditions store condition.var as raw text rather than tokens, so this path
parses the accessor itself via parse_var_reference. _ctx_set is left on
split_var_accessor: an action writing to $outfit.SHOES should still target
the base var rather than pretend to write an axis.

_ctx_has checks the axes bucket rather than ctx, since $outfit.SHOES can
exist while outfit is unbound and vice versa.

Action values needed no work - they already route through resolve_text.
2026-08-17 22:52:52 +03:00
DumiFlex 14edfa3458 feat(syntax): resolve axis reads from the rolled ctx projection
get_axis sits beside get_var as a plain dict lookup over a projection
build_resolve_ctx makes at frame construction, so the resolver stays a pure
consumer with no ctx dependency. Keyed by binding, matching what the
reference addresses.

Branches before the var lookup: $outfit.SHOES must resolve even when
nothing bound $outfit itself, and must not trip unknown_var on the way
past. Never routes through deref_var_value either - an axis value is not a
ListVar, so the SP2a accessor contract is untouched.

A missing axis renders empty and warns unknown_tag_axis rather than
failing, matching how every other unresolvable reference behaves.
2026-08-17 22:48:53 +03:00
DumiFlex 9fe33ab4b2 feat(syntax): accept an axis accessor on a var reference
A reference now carries at most one pick index and one axis, in either
order - $o.0.SHOES and $o.SHOES.0 name the same value. Accepting both is
deliberate: $o.0 is already meaningful on its own, so appending .SHOES is
composition rather than a second syntax, and rejecting it would make the
natural spelling the broken one.

Two mirrored regex alternatives, so .0.1 and .SHOES.BELTS reject by not
matching their second segment and the tail stays literal text - no
validation pass. Both tokenizers normalise to one (name, index, axis)
triple immediately, so nothing downstream learns which spelling was used.

Replaces the var_nondigit_accessor_is_text corpus case, which pinned the
behaviour this changes, with cases for both orders and both nonsense forms.
2026-08-17 21:04:56 +03:00
DumiFlex 2f64f6c7ac feat(engine): propagate rolled axes across nodes and held iterations
A held wildcard early-returns before recording, so its axis vanished on
iteration 2+ and every read rendered empty. Carries the frame-0 roll forward
the way picks already are, but keyed by binding rather than module id, since
that is the namespace a var read addresses.

setdefault semantics, not assignment: a wildcard that never asked to hold
still re-rolls each iteration, which the third test pins by running six
seeds over a two-tag menu.
2026-08-17 20:54:34 +03:00
DumiFlex f4444394e5 feat(wildcard): roll one tag per accepts axis at pick time
Two consumers address a pick differently, so each gets the key it addresses
by. The constraint fold needs a tag-to-axis map and binds by module uuid, so
that rides on the pick entry. A $var.SHOES read binds by variable, so the
rolled winner goes to __wp_axes__ keyed by binding - which also means the
read never inherits the uuid-bucket collision.

Rolled at pick time, never on read: that is what makes two modules, or two
nodes, agree. The draw happens AFTER the option draw on the same rng, so an
existing locked_seed still reproduces its option.

Multi-select rolls per pick in pick order, so $outfit.SHOES mirrors the
shape of $outfit.
2026-08-17 20:49:47 +03:00
DumiFlex b6e6f55cce feat(wildcard): validate tag_group_kinds on the payload
A group may declare itself classify (the default, and what every stored
payload means) or accepts. Only an accepts name has to satisfy the
identifier rule, because only an accepts axis is addressable as $var.AXIS;
a classify group keeps whatever name it has today.

Also narrows the constraint weight multiply with isinstance rather than
sentinel identity, which the newly-typed fold made unprovable.
2026-08-17 20:16:41 +03:00
DumiFlex c811d6bc62 feat(constraints): mirror the accepts-axis fold in the ts twin
Same three phases in the same order as the Python side: an exception pair
short-circuits the pick, unclaimed tags multiply, each accepts axis
contributes its max and an all-zero axis excludes. Graded against the same
corpus so the two cannot drift.
2026-08-17 20:12:41 +03:00
DumiFlex 7cd08f070c feat(constraints): fold accepts-axis tags with max instead of product
An option tagged with several tags from one group means those tags are
alternatives it offers, not statements that are all true at once. The fold
multiplied across them, so any sibling's exclude zeroed the option and a
source carrying three shoe tags excluded every shoe in the target pool.

Tags inside a group the source declared 'accepts' now fold with max.
Everything else still multiplies, and the path is unreachable without
axis_kinds, so a payload predating the feature folds byte-identically -
locked by the corpus case that omits the kind map.

Also replaces the bare object() sentinel with a narrowable class: 'is
EXCLUDE' never narrowed, leaving every 'factor *= r' unprovable.
2026-08-17 20:09:32 +03:00
DumiFlex 1c305e4fb1 Merge remote-tracking branch 'origin/main' into feat/tag-axes 2026-08-12 03:09:26 +03:00
DumiFlex f23a9886d4 feat(assembler): declutter the node, and give model syntax one colour everywhere
The two rows around the variable strip go; the strip itself is untouched, being
the part that earns its space.

The standing hint row ("click → insert $var · Ctrl+click → remove · right-click
→ more") is deleted rather than shrunk, because the chips it describes ALREADY
carry that exact text in their own `title` — it was a permanent row duplicating
something one hover away. Removing it outright would have cost the only clue
that Ctrl+click removes, which is genuinely undiscoverable; it does not, because
that clue was never in the row alone.

Load / Save / Clear become icon-only. Each already had a `title` and an
`aria-label`, so the visible word was the only thing dropped, and three words
were most of the row's width. Kept as visible buttons rather than folded into
the context menu: an affordance you cannot see is one you do not know exists,
which is the mistake the hint row was compensating for.

`<lora:…>` and `embedding:…` now carry one fixed colour each — lavender and
cyan, matching the popover's own section icons — in the editor AND in the
resolved preview. The preview needed its own wiring: those two resolve to
themselves rather than to a variable, so they fell into `literal` tokens and
were the last place the syntaxes appeared uncoloured. It calls the same
`modelSyntaxHtml` the editor does, so the two cannot drift.

The colour is applied INLINE, which two independent facts each force. The SPA's
`rich-text.css` is not loaded on the canvas — the canvas has its own
`rich-text-canvas.css` carrying a "keep in sync" note — so a rule would have
had to be written twice. And these spans come from `v-html`, so they never
receive the `data-v-*` scope attribute a rule in a `<style scoped>` block
needs. The first attempt did it with a stylesheet rule and rendered plain #ddd
on both hosts; caught by reading the computed colour rather than the diff.

Verified on canvas: embedding rgb(103,232,249), lora rgb(196,181,253), `$camera`
still its hashed orange, hint row absent, three icon-only buttons. The preview
half is NOT verified — the helper freezes mid-`<Transition>` in this harness,
where `transitionend` never fires, so the preview never mounts.
2026-08-11 21:36:12 +03:00
DumiFlex 45c7bf50de fix(canvas): toggles take effect immediately, and model syntax gets its own colour
Turning a completion source on did nothing until the page was reloaded.
`completionSourceEnabled` asks ComfyUI's settings store (or localStorage)
directly, and neither is a Vue reactive source — so the `computed` gating the
popover cached on its other dependencies and never re-evaluated. Readers stay
where they are, because they are the single place that knows how to answer per
host; a version counter is now bumped by every writer and read by the gate.
A counter rather than mirroring each value into its own ref: there is one
question here — did any of this change — and nothing to keep in sync.

Both hosts notify: the canvas switches from their `wp-switch` handler, the
native separator boolean through ComfyUI's own `onChange`, and the manager from
`uiStore`'s single `writeFlag`. The first test in the new file asserts the BUG
— a computed over the raw reader going stale — so the fix cannot be quietly
undone.

`<lora:…>` and `embedding:…` now render in their own fixed colours, matching
the `loras` and `embeddings` section icons in the suggestion popover, so the
thing being inserted and the thing on the page read as the same kind. Fixed
per kind rather than hashed like `$var`: there are arbitrarily many variables
and the colour is how you tell two apart, but exactly two of these and they
always mean the same thing, so one colour each is learned once instead of
decoded each time.

Deliberately not a tokenizer kind. `tokenizeRich` is shared with the
engine-parity resolver and the atomic editor model, and neither has any
business knowing ComfyUI prompt syntax — this is a rendering concern and it
stays in the renderer, decorating text tokens on the way out. A half-typed
`<lora:` highlights too, since that is exactly when it is being written.
2026-08-11 21:19:27 +03:00
DumiFlex 8929b2e5cb fix(spa): the suggestion list rebuilt twice on every keystroke
The flicker on the last letter typed, from the screen recording. Two causes,
one of them mine.

The sources settled INDEPENDENTLY, which I chose deliberately so the popover
would never be held at the speed of the slower one. That optimised for a
latency that does not exist — both endpoints are in-process on localhost — and
paid for it with a visible double reflow on every keystroke: tag rows landed
and rendered, then model rows landed, appended their sections and re-capped the
tags, so the list rebuilt twice within a few tens of milliseconds. One settle
now, via `allSettled` so one source failing still shows the other.

Frame-stepping the recording also turned up something worse than flicker. At
2.60s the field and the header both read `lazy` while the third row was `laser`
(from the alias `lazer`) — not a `lazy` prefix at all. Those were the results
for `laz`: the header renders `acQuery`, which updates synchronously on every
keystroke, while the rows arrive from a debounced fetch, so there is a window
where the popover confidently shows the PREVIOUS query's results under the
CURRENT query's label. Pressing Enter there commits a row chosen from a list
that no longer matches what was typed.

The rows are deliberately still not cleared while the next fetch is in flight —
blanking on every keystroke flickers far worse than briefly-stale content — so
instead the popover says so: the header shows "searching…" and the rows dim
while `rowsQuery` differs from `acQuery`. That turns an unexplained reflow into
an obvious "still looking", and makes the stale window visible rather than
silent.

Also moved the GitHub star prompt directly under the hero. It sat at the very
bottom of the dashboard, below the stats, the quick-create row AND the full
recents list, so it could not be seen without deliberately scrolling to the end
of a page nobody scrolls to the end of. The reasoning for putting it last — a
favour belongs below what someone came for — was half right and produced
something invisible; an aside nobody can see is not tactful, it is absent. The
concern behind it still shapes the new spot: ABOVE the hero it was louder than
the product's own welcome, so under the hero keeps the welcome first and the
favour second.
2026-08-11 20:52:28 +03:00
DumiFlex 7523aae41e feat(autocomplete): match names the way people remember them
You cannot find a model by typing its name unless you also reproduce its
punctuation, and nobody remembers where the punctuation fell. `2xko` could not
find `2X-KO_more_-ill_r1` at all — reported exactly that way.

Matching now folds `-`, `_`, `.`, `/`, spaces and brackets out of BOTH sides
before comparing, in three tiers: an exact prefix still outranks a folded one,
which outranks a folded substring. So `2xko`, `2x-ko` and `2XKO` all reach
`2X-KO_more_-ill_r1`, `spritepixel` reaches `8.0-sprite pixel art style by
skormino`, and someone who typed a name exactly is never reordered beneath a
fuzzier match. Free to do here: the model list is tens of entries, not the
tag list's 286k.

Tag search is deliberately left alone — it is a bisect over a sorted array of
286,337 names and prefix-only is what every booru autocomplete does.

Sections were not delivering what I claimed for them. Tags lead, `lazy` matches
twenty of them, and the embedding section existed but sat entirely below the
fold — the user could not filter to their own models without scrolling a list
they were not looking for. The leading section is now capped to 6 rows WHEN a
model also matched, which is what actually makes the others reachable. A query
matching nothing but tags still gets the full twenty.

The LoRA and embedding settings move to the same hand-built switch as the tag
one, for the reason that control exists: a count beside the toggle. "50 LoRAs
found" answers the question a bare switch raises — is there anything for this
to suggest? — and it is the question that otherwise sends people to the manager
to check.

And the pill now reads PrimeVue's own `--p-toggleswitch-*` custom properties
for every dimension, not just its colours. Those are injected when their
component first mounts, which has always happened by the time ours renders,
because our row sits in their dialog among their switches — so at render time
this IS their pill and follows their theme. Aura's defaults remain as
fallbacks. Handle travel became a `calc()` over the same tokens rather than a
fixed 16px, so a differently-sized track still lands the knob flush.
2026-08-11 20:31:14 +03:00
DumiFlex ea16a06991 fix(settings): counts move onto the control row, and the pill matches ComfyUI's
Read off the screen recording rather than guessed at — ffmpeg frames of the
real settings dialog, which is the first time this control has been seen next
to the ones it has to sit among.

THE SWITCH WAS THE WRONG SIZE. 34x18 with a 14px knob, against PrimeVue's Aura
preset of 40x24 with a 16px handle, which is what ComfyUI renders and what the
frames measure. Beside three real toggles it read as visibly smaller and
duller.

IT WAS ALSO IN THE WRONG PLACE. The note and the switch were appended in that
order inside a flex row, so the control floated mid-row while every neighbour
sat flush against the right edge. A row that does not line up reads as broken
before it reads as ours. Note first, switch last.

AND THE KNOB TELEPORTED. `paint()` rewrote `style.cssText` on every change,
which replaces the `transition` declaration mid-flight — the browser then has
nothing to animate between. That is the visual glitch: the static styling is
now written once and only the properties that actually change are touched, so
the slide runs.

Colour and timing come from `--p-primary-color` and `--p-transition-duration`,
which ComfyUI publishes on `:root`, so both track their theme. The rest of
PrimeVue's `--p-toggleswitch-*` tokens are injected lazily when the component
first mounts and do not exist until the dialog has been opened once — so they
are read with fallbacks rather than relied on. Still not borrowing
`.p-toggleswitch` itself: those are ComfyUI's internals, and reusing them buys
a perfect match today and a control that renders as nothing the day they
restyle.

In the manager, the counts move onto the control's own row as a tinted pill
pushed to the far edge. First they were appended to the dim `hint`, where the
one fact worth reading was the least readable thing on the card; then they got
their own line, where they crowded the explanation above them. The whole badge
is tinted rather than just its dot — at 11px a dot alone is easy to miss, and
"none found" is the case that most needs noticing.

Verified live: badge on the same row as its toggle, pushed right, no overlap
with the hint, green tint, "50 found" and "17 found".
2026-08-11 19:57:56 +03:00
DumiFlex ef30b366ce fix(settings): a real toggle for the tag setting, and counts you can read
Three things from a QA pass on the restarted server.

The model counts were appended to the `hint`, which is dim by design — so the
one fact on the card worth reading was the least readable thing on it. Each
source now gets its own status line at normal text colour with the same
green/amber dot the tag card uses, so "is this usable?" reads identically on
both cards.

The tag setting kept rendering a checkbox while every other boolean in the
panel is a pill switch. It cannot be a plain `type: "boolean"` — it disables
itself when no tag list is installed, and a native boolean has no way to say
"on is not available" — so it gets a switch of its own: 34x18 pill, sliding
knob, `role="switch"`, space and enter, dimmed and non-interactive in the
disabled state.

Styled inline and namespaced rather than borrowing PrimeVue's classes. Those
are ComfyUI's internals; reusing them buys a pixel-perfect match today and a
control that silently looks like nothing the day they restyle or upgrade.

Typing a bare `<lora:` offered nothing. The endpoint refuses an empty query so
that a stray keystroke cannot dump the whole models folder into a popover —
correct for a bare word, and exactly wrong inside a reference, where the marker
IS the request and "show me what I have" is the main way these get used. The
client now sets `all=1` only when the caret is inside a reference, where the
intent is unambiguous, and the server lists the first N by name.

QA notes from the same pass, all verified against the live server:

- `/wp/api/models/*` serves: 50 loras, 17 embeddings.
- The data-folder migration ran and logged its own decision:
  "left in place — wildcard-pipeline.db (already present at destination)".
  The destination already held the real 1.13MB library, so the never-overwrite
  rule preserved the older loose copy instead of clobbering it, and the server
  resolves to the correct file. Working as designed.
- The two reported completion failures now return exactly one correct match
  each: the completed `embedding:style\lazyhand-e12c.safetensors` path, and a
  LoRA name carrying both spaces and dots.
- Sections render: "tags · embeddings" with per-source headers, tags first, and
  no header when only one source has hits.
2026-08-11 19:39:51 +03:00
DumiFlex 70bd479b83 perf(extension): stop polling a hidden tab, and stop shipping ComfyUI's Inter
Both halves of the low-end-hardware note, measured first as it asked.

PROFILE. An 8-node Context chain carrying 96 variables costs 0.591ms per
refresh. That is small alone and it is not what it costs: every node walks its
WHOLE upstream chain, so a series chain — the shape people actually build — is
quadratic in total work, and nine nodes already produce a visible long task
when the refreshes are forced back to back.

Two loops were paying it forever: `reactive.ts` at 400ms per WP node, and
`subgraph-badge.ts` at 800ms per SubgraphNode, each rescanning regardless of
whether anything could have changed. Neither stopped for a hidden tab and
neither backed off.

They now share `startAdaptivePoll`, which was extracted rather than written
twice — the two had already drifted into having nothing in common but the bug.
It gates on two things that both cost nothing while the user is working:

A hidden tab does no work at all. Nobody can observe a stale widget on a tab
they are not looking at, so this was work being done precisely when it was
least useful. One catch-up refresh runs on the way back to visible, so an edit
made elsewhere is never missed.

A still canvas backs off 3x then 8x after ~4s of no pointer, key or wheel
activity AND no observed change. The fallback exists for upstream edits that
fire no event, which cannot happen while nobody is editing. Any real change or
any user activity snaps it straight back. One shared document listener feeds
every node — N listeners for a single global fact would repeat the mistake the
per-node intervals already make.

The event paths are untouched: `onConnectionsChange`, the mode intercept and
graph load all still fire instantly. Only the fallback slows, and only while
nothing is happening.

FONTS. We shipped a 48 KB Inter that ComfyUI already serves, declared
identically — same family, same `font-weight: 100 900` variable axis, same
latin woff2, same `font-display: swap` — from its main stylesheet, so it is
loaded on every page a canvas widget can appear on.

The note wanted the removal risk measured before taking this, so: added
2025-09-16 in Comfy-Org/ComfyUI_frontend 4ff18fd ("Add Inter font used by
Figma designs", #5535) and untouched since — one commit in its entire history.
It is the typeface of their whole UI, so dropping it means restyling their
product rather than removing an optional asset.

The coupling is also weaker than the note assumed. We depend on the FAMILY
NAME, not their URL: our stack names `Inter` and the browser resolves it
against whatever `Inter` face is loaded, so a rename, re-subset or re-hash is
invisible to us. Their copy lives in `public/fonts/`, which Vite serves
un-hashed exactly so URLs stay stable. And the failure mode is the one the
stack already shows on every cold load — fall through to `-apple-system`.

JetBrains Mono stays; ComfyUI ships no monospace face.

Verified live: zero requests for our Inter, their `inter-latin-normal.woff2`
loaded, `document.fonts.check("16px Inter")` true.
2026-08-11 19:14:19 +03:00
DumiFlex 4b2a76e72b fix(spa): stale textarea styling produced a second scrollbar
Three leftovers from fields that used to be `<textarea>` and became
RichTextInput in ca87384b and 8aea38dd. The markup changed; the CSS written for
the textarea stayed, still matching because the class moved onto the
component's root.

`.row__value` declared `overflow-y: auto` and `max-height` on that root, and
the component's own `.wp-rt__host` already declares both — so a capped,
scrolling box sat inside a capped, scrolling box and a fixed-values row showed
TWO vertical scrollbars. Reported in the bundle snapshot editor, but it was
every fixed-values row everywhere. `min-height`, `line-height` and
`white-space` were the same mistake without a visible symptom. Only
`word-break` survives — a long unbroken value is a row-layout concern, not an
editor one.

`.row__grip` and `.row__value-wrap--more::after` reimplemented the drag grip
and the overflow fade, which existed here only because a textarea cannot carry
either. Both now belong to RichTextInput, and the markup that used these went
with the textarea — the rules had been matching nothing since.

`.ibm__template` kept `resize: vertical`, which put the browser's native
resizer in the same corner as the component's own grip: two handles, one of
which the component cannot clamp. That is the class of bug `startResize` exists
to avoid.

Also from the same report: the SPA's template editor was a plain Textarea, so
it was the one place a PromptAssembler template could be written with no tag,
LoRA or embedding completion at all — despite templates being saved here and
loaded into the assembler. It uses RichTextInput on the `assembler` surface
now, so both ends agree on what the syntax means.
2026-08-11 18:58:01 +03:00
DumiFlex e8b75a11a9 feat(autocomplete): understand lora and embedding syntax, and finish the settings
Four things, all of them the same feature being genuinely usable rather than
merely present.

The completion could not see a filename. `probeTagWord` bounds a word with the
character class `[a-zA-Z0-9_'-]`, which contains no dot and no path separator —
so a caret at the end of `embedding:style\lazyhand-e12c.safetensors` walked back
only as far as the dot and searched for `safetensors`, matching every model on
disk and nothing that was meant. Real names carry dots, separators and spaces
(`8.0-sprite pixel art style by skormino`), and no character class can bound
that.

So the new `probeModelRef` bounds by SYNTAX instead: everything between
`<lora:` or `embedding:` and the caret. Unambiguous, and it describes names a
class never could. It stops at a comma, newline or `>` so a completed reference
earlier in the prompt cannot claim a caret that has moved on, and past a LoRA's
second colon, which is a weight and not a name.

It also takes precedence over the bare word and RESTRICTS the search to the one
kind being named — a reference names exactly one by construction, so offering
the other, or offering tags, was offering something that could not legally be
inserted there. That is the "caret in the embedding, popup full of LoRAs"
report. Committing inside a reference now inserts the bare path, since the
marker is already typed.

Server side, the query is separator-normalised like the stored paths, and a
full path now ranks as a PREFIX hit — otherwise a completed reference sorted
below unrelated substring matches.

The canvas settings use plain `type: "boolean"`, which is what renders
ComfyUI's own pill toggle; the checkboxes came from the hand-built renderer.
The tag setting keeps its custom control because it DISABLES itself when no
list is installed, which no native type can express — the two model sources
have no such state, so there is nothing to express.

New opt-in setting appends `", "` after a completion. Suppressed inside a
reference, where a comma would terminate the thing being completed. Applied on
both the keyboard and mouse commit paths, because a preference that lands on
one of them is worse than not having it.

And the gap I left last time: the model sources had no SPA surface at all —
canvas settings and playground mirrors only, with nothing anywhere writing
their storage keys, so outside the canvas they could not be switched on.
`ModelSourcesCard` adds the toggles, the counts, and the Refresh that
`/wp/api/models/refresh` was built for and nothing called. `uiStore` gains the
writers; `tagSetting.ts` stays the reader, because the canvas has no Pinia.
`enabledCompletionSources` is deleted — it was referenced only by its own test.
2026-08-11 18:49:38 +03:00
DumiFlex cce1ac4dae fix(canvas): the node corner is the only way to size the template editor
Maintainer call after three failed rounds: drop the in-editor grip, let
ComfyUI's node corner own the height, and make the editor scroll instead of
growing to fit its content.

That is the right call and my argument against it was wrong. I rejected
`fillHost` on the grounds that the assembler has TWO widgets, so
node-drives-widget would stretch the chip strip and preview alongside the
editor. Measured, it does not — the helper carries `autoHeight`, which pins it
to its content, so every pixel of slack goes to the editor:

    node 390 -> editor  70, helper 178
    node 700 -> editor 168, helper 178
    node 1000-> editor 273, helper 178
    node 300 -> clamps to 420, editor back to 70

Shrinking works again too: 1000 -> 300 clamps at the widgets' legitimate 420
floor rather than springing back to fit the text, and the box scrolls
(scrollHeight 928 against clientHeight 98) instead of inflating the node.

The root cause of all of it was two resize authorities over one box. Each round
found and fixed a genuine defect — `autoHeight` discarding the user's height,
grip-follow mutating scroll from inside the resize flush, a lifted height cap
outliving its drag, a lost `pointerup` stranding `dragging` — and none of them
was the symptom, because arbitrating between two authorities cannot make them
agree. Removing one can.

`fillHost` also skips the content-size ResizeObserver entirely and takes the
subtree out of flow, so the template can no longer push the node at all. That
is the auto-scaling half of the report.

New `fill` prop on the editor: hides the grip, drops the 14rem cap (which
exists to bound AUTO-growth, and would otherwise cut a node the user
deliberately dragged tall), and sets `min-height: 0` on both the root and the
host. That last part is load-bearing — both are flex items, and a flex item
defaults to `min-height: auto` and refuses to shrink below its content, which
is exactly how the payload used to push the node.

The grip stays everywhere else. The SPA's modals and pages have no node corner
to drag, so there the in-editor handle is the only control there is — and the
stuck-drag fix in 25527c37 still matters for them.
2026-08-11 17:55:10 +03:00
DumiFlex 25527c37ff fix(canvas): a lost pointerup left the resize drag stuck to the cursor
Reported as "the resize is linked to my mouse and resizes as I move my mouse
around without holding, and can't be interrupted no matter what I do or press."

That is one state — `dragging` stuck true — and it was reachable whenever a
single `pointerup` was missed. Three things combined:

`setPointerCapture` reads like the right call and is what stranded the drag.
Capture retargets every later pointer event to the grip, INCLUDING `pointerup`.
The editor re-renders while it resizes, and a captured element that gets
replaced is detached — so the `pointerup` was delivered to a node no longer in
the tree and never bubbled to `window`. Removed: the listeners are on `window`,
which sees the events regardless of what is under the cursor, so capture bought
nothing here.

The listeners were in bubble phase, so a `stopPropagation()` anywhere between
the target and the window hid the release too. ComfyUI's node layer does exactly
that to stop the canvas panning under a widget. All three are capture phase now,
which fires before anything downstream can swallow them.

Nothing ever cleared `dragging` afterwards, which is the "can't be interrupted"
half — there was no path back to a stopped state at all. Now `pointerdown`,
`blur` and Escape all end a drag, and `onResizeMove` bails when `ev.buttons` is
0. That last one is the one that cannot be outsmarted: every pointermove reports
which buttons are down, so a move with none held means the release was lost
whatever swallowed it. It ends the state even for a cause we have not found.

Two existing tests drove a drag with a synthetic `PointerEvent` whose `buttons`
defaults to 0, which the new guard correctly reads as a release. They now pass
`buttons: 1`, which is what a held drag actually reports and what they meant to
simulate.

This is separate from bc8421fe. Those three fixes are in and correct — they
addressed the node fighting the editor over height. This is the drag never
ending, which no amount of height policy would have cured.
2026-08-07 17:19:33 +03:00
DumiFlex 09257471f8 feat(autocomplete): lora and embedding sources, in sections beside the tags
Two more completion sources for a bare word, each behind its own setting.
Far cheaper than the tag list: ComfyUI already enumerates both folders for its
own node combos, so there is nothing to download, cache, byte-cap or parse, and
the security surface that dominated the tag work does not exist here.

Sections rather than one ranked list, because the sources have no common
ranking key. A tag ranks by post count in the tens of thousands; a model has no
count at all — you either have the file or you do not. Any flat ordering has to
invent a comparison between "12,000 posts" and "a file on disk", and whichever
is invented, tags win on weight of numbers and the user's own models sink below
the fold. Sections also let each source cap its own rows, so 20 tags cannot
crowd out 3 matching LoRAs.

The rows stay ONE FLAT ARRAY internally even though they render grouped,
because `acActive` indexes it. Grouping the state would put the keyboard
selection in charge of two coordinates and make "skip the header" a case to
handle; flat, a header simply is not in the array and cannot be selected. The
header also only appears when more than one source has hits — with a single
group it would cost a row to say what the group already says.

A model row inserts its FULL RELATIVE PATH, not the display name. Two folders
can hold the same filename and ComfyUI resolves by path, so inserting the short
name would silently pick a different file from the one on screen. The folder is
shown as the row subtitle for the same reason.

Icons are `pi-asterisk` for lora and `pi-code` for embedding — the only two
candidates from the shortlist with zero existing uses in `src/`. That check is
the method: `pi-tags` was chosen for booru tags precisely because `pi-tag`
singular is already the fixed_values module kind.

Each source is independently switchable. Someone writing danbooru prompts may
have no LoRAs at all; someone with 200 of them may find the tag list noise.
`tagSetting.ts` generalises to answer per source, keeping its host-aware split —
the canvas reads ComfyUI's settings store, the SPA reads localStorage directly
because this is called on every keystroke probe.

The settings parity test earned its keep again: it failed the moment the two
new canvas settings existed without playground mirrors.

Sources settle independently rather than being awaited together, so the popover
is never held at the speed of the slower one — the model lookup is an
in-process list scan, the tag lookup walks a multi-megabyte index.

The new routes need a ComfyUI restart before they serve. Verified that until
then the 404s degrade silently: settings persist and read back, tag suggestions
are unaffected, nothing throws.
2026-08-07 15:52:07 +03:00
DumiFlex 09b0e0ddd3 fix(canvas): template variables are actually text now, not restyled chips
The previous commit only restyled the chip — box off, colour on, atom intact —
and that is not what "coloured text" means. It still behaved like an object:
one Backspace deleted the whole token, the caret could not enter it, it kept a
pointer cursor and its leading `⌘` glyph. Looking like text is not being text.

The mechanism for this already existed and I missed it. `parseForSurface` has a
per-surface COLLAPSE SET that keeps a token as literal text instead of turning
it into an atom — `wildcard` collapses `var`, `fixed_values` collapses both. The
assembler simply was not in it. Adding it there makes `$name` ordinary editable
text, which is the whole fix: caret enters it, Backspace takes one character,
no glyph, no atom.

Colour then comes from `inlineTokenHtml`, which already emits editable
`<span class="wp-rt-var">` sub-spans — it was being told to collapse `var` to
bare text. `textAtomHtml`'s always-collapse rule has a documented rationale
("the ABSENCE of a chip already signals not-committed, so an inline colour
would only compete with the settled chip palette"), and the assembler is the
one surface where it does not hold: there are no var chips there at all, so
nothing is being competed with and the colour is the only signal.

Per-variable colour rides on an inline `style` from a new `varAttrs` hook,
because `.wp-rt .wp-rt-var` already sets a colour at higher specificity than
the global `.var-N` palette classes. Unbound names keep the danger colour and
wavy underline, still only claimed where the host walked a graph.

The hand cursor was unrelated to any of this and is fixed separately: nothing
ever set a cursor on `.wp-rt__host`, so it inherited from the surrounding
chrome — on the canvas that is ComfyUI's node wrapper carrying `cursor-grab`
for dragging. A grab hand over an editable field reads as "you cannot type
here". Never visible in the SPA, which is why it survived.

`RichTextPreview` gets the same surface rule, or the same template would render
with chips read-only and without them in the editor.

`RefChip`'s `flat` prop and its test are deleted rather than left in place —
with vars collapsed to text on the only surface that used it, the whole path is
unreachable. One insert test asserted the assembler chipifies, which this makes
false by design; it now asserts the rendering that replaced it, with a sibling
case proving chipping surfaces are untouched.

Verified live: 0 chips, cursor `text`, `$quality` → `$qualit` on one Backspace,
colours unchanged from the strip.
2026-08-07 15:26:15 +03:00
DumiFlex bc8421fed3 fix(canvas): the template editor stopped fighting the node over its height
Three defects, each independently wrong, all pushing on the same thing. Keeping
the grip rather than moving to `fillHost`: the assembler has TWO widgets, and
`fillHost` means the node's size drives the widget — with two of them sharing
the node height, dragging the corner would stretch the chip strip and preview
too. Debug gets away with it because it is the only widget on its node.

`autoHeight` was copied onto the template editor from the Context and Injector
widgets without their reason for it. Those need it because a node stuck at a
manually-set tall height breaks their collapse animation; this widget has no
collapse animation. And the flag is precisely the branch that discards a user's
height:

    const userControlsHeight = !options.autoHeight && …
    const rawTargetH = userControlsHeight ? Math.max(cur[1], min[1]) : min[1];

With it off, the default path already does what a template editor wants —
preserve the height the user settled on, grow only when content needs more. The
mechanism was there; the flag was switching it off.

`followGrip` now runs only while a grip is actually held, which is what its name
always implied — it exists to keep the handle under the cursor. Ungated it also
fired on content-driven growth, so typing a long enough template scrolled the
page out from under you, and it made the resize flush mutate scroll position.
That moves the element the observer is measuring: a resize handler that can
change its own input is the shape a runaway needs.

`capLifted` now re-arms on pointerup. It guards a one-time "pin the current
height, release the max-height cap" step, and leaving it set meant the release
outlived the explicit height that bounded it — `applyAtoms` replaces the host
element on a rebuild, dropping the inline height but not the flag, so the next
drag found an uncapped, unpinned box.

Both new tests confirmed to fail with their fix reverted. Still not reproduced
end to end: the harness here runs the page as a hidden tab where neither rAF nor
ResizeObserver fires, so the drag cannot be exercised. Each change stands on
inspection; which of them was the reported symptom is for a real pointer to say.
2026-08-07 15:12:42 +03:00
DumiFlex 5c164bb6ee fix(spa): the overflow fade measured text in the wrong font
The "sometimes the highlight at the bottom of an input doesn't render" report.
Deterministic once the font is identified as the variable.

These fields are typed in `--wp-font-mono`, a bundled webfont declared
`font-display: swap`. So the first paint lays the text out in the system
fallback (Consolas / Monaco), the real face swaps in later, and the two stacks
have different metrics. `useGrowableField` measured `scrollHeight` once at
mount, via a single rAF — against type that was about to be replaced — and
nothing re-measured after the swap.

That produces exactly the reported intermittency: wrong on a cold load, correct
on a warm cache where the face is already there, and corrected by accident by
any later scroll, input or resize, since all three re-measure. Nothing in the
codebase waited on `document.fonts.ready` anywhere.

`attach()` now re-runs the overflow hint and the autosize once fonts settle.
Resolves once, no-ops when the font was already cached, and guarded for hosts
that expose no font-loading API.

Stated plainly: this is a strong hypothesis, not a confirmed reproduction. The
harness available here runs the page as a hidden tab where rAF never fires, so
the fade could not be observed either way. The mechanism is real and the fix is
correct regardless of whether it turns out to be the whole of what was seen.
2026-08-07 14:59:07 +03:00
DumiFlex 28487f1a68 feat(engine): database and tag list move into user/wildcard-pipeline/
Both files sat loose in ComfyUI's user directory next to ComfyUI's own. That
was fine with one of them and stopped being fine at two. One folder is also one
thing to back up and one thing to delete.

The subfolder alone would have read as data loss: every existing install has a
real library at the old path, and the extension would have come up empty while
the file sat one level up. So `engine/db/relocate.py` moves them in, once, on
first boot.

Four constraints shaped the mover:

Never overwrite. A file at the destination means either a finished migration
or something the user put there; both outrank anything we could infer, so the
legacy file is left alone and reported as skipped.

Run before the database is opened. SQLite keeps `-wal` and `-shm` sidecars
beside the main file and moving that set out from under an open connection
corrupts it. The call sits in `__init__.py` ABOVE the node imports, which can
reach the DB — placement is load-bearing, not stylistic.

Move the sidecars with it. A `-wal` holds committed transactions not yet
checkpointed into the main file, so moving the `.db` alone silently drops the
most recent writes.

Never raise. This runs at extension import; a throw would take the whole
extension down over housekeeping. Failures are collected into a report the
caller logs, and the degraded state is "keep using the old location".

`shutil.move` rather than `Path.rename` because a user directory that is a
mount or a symlink elsewhere would make rename fail with EXDEV.

`TAG_FILE_NAME` is duplicated in `relocate.py` rather than imported from
`wp_api/tags.py`: the directory contract keeps `engine/` free of ComfyUI-facing
imports, and that module pulls in aiohttp.

Five existing path assertions moved to the new location. They were asserting
the behaviour this changes, and now carry a note saying why the subfolder is
expected so nobody "fixes" them back.
2026-08-07 14:50:17 +03:00
DumiFlex 91e426b595 feat(canvas): assembler variables are coloured text, and the popover matches
The template editor and the variable strip under it disagreed about what
colour a variable is. `.wp-refchip--var` painted every var the same
`--wp-success` green; the strip ran each name through `varColorClass()` and got
eight. So `$quality` was green in the editor and blue in the strip, six pixels
apart, in one node.

Chips go, on this surface only. A border and a fill advertise "this is an
object you can act on" — true in the module editors, where clicking a ref chip
re-picks its wildcard, and false in a prompt template, which is prose you are
writing. Font size steps up to the surrounding text too: chips ran 10px inside
12px because a box has to sit within the line, and without the box a token a
size smaller than its own sentence just reads as misaligned.

Refs keep their box everywhere. A `@{uuid}` really is a clickable object with a
picker behind it.

The colour is applied inline rather than via the `.var-N` class. Those live in
`theme.css` at the same specificity as `.wp-refchip--var`, and this component's
scoped block is injected after it, so the class lost the cascade and every
token still rendered green — caught by measuring the computed style rather
than by reading the diff.

Dropping the box cost the one cue that separated a broken var from a working
one: with a chip, `$typo` still read as a chip. `resolved` cannot recover it
(for a var it is only `name.length > 0`, so a typo is "resolved"), so the state
comes from `inScope`, and only where the host actually walked a graph — in the
SPA every var is out of scope because there is no graph to be in. Unbound vars
get the danger colour and a wavy underline instead of a palette colour.

The `$` popover now takes the variable's colour rather than its module kind's,
so a row and a strip chip can be matched by looking. The glyph still carries
the kind, exactly as the strip does. `@` rows keep `kindTint` — there the kind
IS the identity of the thing being picked.

Verified live: `$quality` rgb(147,197,253), `$camera` rgb(253,186,116),
`$scene` rgb(249,168,212), identical in editor and popover, and `$typo_here`
red with a wavy underline.

Note on the preview I sent: it proposed raising the editor line-height from
1.55 to 1.75. The real value is already 1.9, so that part was wrong and is not
applied. The strip's gap and padding do move onto the `--wp-space-*` scale.
2026-08-07 14:41:26 +03:00
DumiFlex 17cefad53c fix(spa): a failed pointer capture killed the resize drag entirely
`startResize` called `setPointerCapture` before attaching its `pointermove`
and `pointerup` listeners. That call throws `NotFoundError` for any id with no
active pointer, and an uncaught throw there meant the listeners were never
attached — a grip that visibly does nothing when dragged.

Capture is only an optimisation here: it keeps move events coming when the
pointer outruns the 16px grip. The listeners are on `window` and work without
it, so a failure should cost nothing. Wrapped, and left after the listener
attach path so ordering can't matter again.

Found while trying to reproduce the reported resize bugs. Not yet confirmed to
be the cause of either — the harness available here runs the page as a hidden
tab, where `requestAnimationFrame` and `ResizeObserver` never fire, and the
whole autosize path is built on both. This fix stands on inspection regardless.
2026-08-07 14:26:54 +03:00
DumiFlex a723243d3d fix(canvas): autocomplete now closes on canvas clicks and hides no-op vars
Two reports against the assembler's editor, both fixed at the source.

The suggestion popover never closed when clicking away. The dismiss listener
was bound to `mousedown` only, and litegraph drives the canvas from pointer
events and calls `preventDefault()` on `pointerdown` to suppress native
text-selection and drag — a prevented `pointerdown` also suppresses the
browser's compatibility `mousedown`, so the listener never heard the click.
On a node graph the canvas is most of the screen, which is why this read as
"it never closes". Now bound to `pointerdown` too, which fires first and
cannot be taken away downstream. `mousedown` stays as the fallback for hosts
with no PointerEvent (jsdom, older webviews); closing twice is idempotent.
Presses inside the popover still don't dismiss, so rows (which commit on
mousedown to beat the host blur) and the scrollbar keep working.

The `$` list also offered variables the assembler cannot render. It runs
`strip_internals` over the render context before resolving, so `$var` naming
a user-flagged internal variable substitutes to nothing — yet the popover
listed 52 matches, most of them `$iteration` and `*_bool` toggles. Those names
legitimately cross the socket for Combine and Derivation to read, so the fix
is not to drop them from the walker but to give the PROMPT surface its own
list: `collectUpstreamRenderableVariables`.

The internal-flag parse existed already as a private copy inside
`widgets/assembler.ts`. Extracted to `internalVarNames` and shared, because
the two surfaces on this same node have to agree about which names the prompt
will render, and they would have drifted with one copy each.
2026-08-07 14:18:40 +03:00
DumiFlex 51bc4893ec fix(canvas): one malformed fixed_values row could break canvas interaction
`resolveChainStatic` iterated `m.entries` with no guard and read
`e.variable_name` with no type check. A library payload that carries
`payload.values` but no `entries` mirror — which is every row installed
straight from the library, since `entries` only appears once a module has been
edited inline — threw "m.entries is not iterable".

The blast radius is the reason this matters. The walker runs inside
`onConnectionsChange` via `extension/reactive.ts`, so the throw does not
degrade to an empty preview: it escapes into litegraph's connection handling,
and one bad module takes out canvas interaction for the whole graph.

The other two `entries` readers in this file already used `?? []`; this one
was the outlier. Found while hand-writing test fixtures for the assembler
editor — hit it twice before recognising it as the code's problem rather than
the fixture's.
2026-08-07 14:10:40 +03:00
DumiFlex bb40882238 feat(canvas): assembler template gets the rich editor, keeping its link input
The template input now renders through RichTextInput, so it has the booru tag
autocomplete, `$var` chips with producer attribution, and the same editing
behaviour as every other WP text surface.

No payload transition. It is the SAME `template` widget: same name, same
position in `widgets_values`, same plain string value — only the renderer
changed. A workflow saved before this loads its template back untouched;
verified by loading a hand-written pre-change workflow JSON.

The seam is `widgetType`. The frontend picks a widget constructor with
`t.widgetType ?? t.type` while the socket keeps `t.type`, so tagging the input
spec routes the lookup to us without touching what the socket accepts. It has
to go through `extra_dict`: V3's `widget_type=` parameter would ALSO change
the socket, because `WidgetInput.get_io_type` returns widget_type when set —
the input would stop being STRING and no upstream STRING output could feed it.
`extra_dict` is merged last in `Input.as_dict` and never consulted by
`get_io_type`. The new schema test asserts both halves together, since either
one alone passes while the feature is broken.

Keeping the link input needs one more thing: ComfyUI only calls
`addInput(name, spec.type, {widget})` when the widget the factory returns has
no `socketless` flag, and `createDomWidgetHost` hardcoded `socketless: true`.
Hence the new `socketed` option — correct for every other WP widget, exactly
wrong for this one. While a link IS connected the editor goes read-only with
a placeholder saying so, matching the stock multiline widget. Derived from the
link rather than the widget's `computedDisabled`, which is recomputed lazily
during a draw and still read `true` a second after the link was removed.

Why a separate file and not `assembler.ts`: the helper widget must stay
socketless (it is an editor, not a value), and the two widgets need opposite
answers to that one question.

The helper's template mutators lose their `inputEl` branches. Those had been
dead for a while — the frontend moved widget values into a store, so the
textarea at `widget.inputEl` is detached and unrendered, and each of those
functions was really working through the `w.value` write sitting next to it.
Insert is the exception: it needs a caret, so it asks the editor through a
WeakMap hook instead of replacing the whole string.

Verified live on frontend 1.48.6 with Vue Nodes on: legacy value restores,
STRING link connects and disconnects, serialize round-trips, clear / remove /
insert all propagate, `$` autocomplete lists upstream vars with attribution,
tag autocomplete and its legend work, and a full Context → assembler chain
previews correctly.
2026-08-07 02:20:18 +03:00
DumiFlex bb91476f9f fix(spa): rich text editor swallowed an external clear, and gains a caret insert
Two changes to RichTextInput, both needed by the assembler's template editor
but neither specific to it.

The echo guard on the `modelValue` watcher compared only the string:

    if (next === lastEmittedValue) return;

`lastEmittedValue` seeds from the value present at mount. Every canvas widget
mounts empty — ComfyUI restores the saved workflow value AFTER the widget is
built — so `lastEmittedValue` stays `""` until the user types, and every
later external `""` looked like the editor's own echo and was dropped. The
assembler's Clear template button is exactly that write: the widget value went
to `""` and the old template stayed on screen. The guard's real question is
"does the DOM already show this?", so ask that instead. Non-empty writes were
never affected, which is why this survived this long.

`insertTextAtCaret` is the component's first public method. The assembler's
chip strip is a SEPARATE widget on the same node, so it cannot splice into
this editor's DOM; it used to splice into the native `<textarea>` at
`widget.inputEl`, which no longer renders. Writing `widget.value` instead
works but replaces the whole string and drops the caret.

It separates on BOTH sides. Leading-only was the old textarea's behaviour and
was already subtly wrong: inserting `$mood` before `portrait` yields
`$moodportrait`, which re-parses as a variable named `moodportrait`, so the
insert quietly changed what it inserted. Caught in the browser, then pinned
with a test that drives a real jsdom Selection — the other cases exercise the
end-of-text fallback, which cannot reach the trailing branch.

Both regression tests were confirmed to fail with their fix reverted.
2026-08-07 02:11:57 +03:00
DumiFlex 3de5b6a924 feat(spa): tag rows carry a tinted pi-tags icon instead of a colour bar
One slot now says both things: the glyph says "booru tag", its tint says which
category. The separate 4px colour bar goes.

`pi-tags` PLURAL is deliberate. `pi-tag` singular is already the fixed_values
module kind in KIND_ICON_MAP, so a booru tag row would have rendered
identically to a library module row. Checked every `pi-*` in the source before
choosing: `pi-sparkles` is the wildcard kind, `pi-sliders-h` the injector's
sampler, `pi-palette`/`pi-hashtag` are taken too. `pi-tags` is unused.

Neutral grey is `general`, which is most rows -- colouring every line would
make the coloured ones stop meaning anything.

Three defects caught by measuring the rendered result rather than reading the
diff: `artist` and `meta` kept `background:` because extra alignment
whitespace defeated the edit, so both fell back to inherited dim; the legend
icon lost its `pi pi-tags` classes and rendered as a coloured but glyph-less
box; and the legend swatch kept `width: 4px; height: 10px` from the bar it
replaced, squashing the icon to a sliver. Verified live: 20/20 row icons at
12px in the primeicons font, and legend glyphs at 11px in character green,
artist orange and general grey.
2026-08-07 01:31:33 +03:00
DumiFlex ca87384b5b feat(fixed-values): rich text input, tags on and $ / @ off
The canvas fixed-values row was a bare <textarea>, so booru tag autocomplete
never reached it. It is a RichTextInput now.

`$` and `@` stay off there, and that is the ENGINE's rule rather than a UI
preference. `fixed_values_handler.py` says the surface is a binding PRODUCER,
not a consumer: a fixed value DEFINES what `$name` resolves to, so `resolve_text`
gates var reads and `@{}` refs off and renders such a token as literal text
with a warning.

The frontend did not encode that. It hardcoded `surface === "wildcard"` in the
`$` gate, which made the SPA's fixed-values editor MORE permissive than the
engine -- offering `$` autocomplete for tokens the engine would never resolve,
while `parseForSurface` already collapsed those same vars to literal text. The
gate now derives from the producer/consumer split the engine defines, so both
surfaces agree and the existing SPA divergence closes with it.

No machinery was lost in the swap: RichTextInput uses the SAME
`useGrowableField` composable ValueRow did, so auto-grow, the overflow fade and
the drag grip come with it, and ValueRow's duplicate copy goes.

A Vue prop-casting trap cost two RichTextInput tests on the way, and the fix is
the interesting part. `allowVars?: boolean` is inferred as a Boolean prop, and
Vue casts an ABSENT Boolean prop to `false` rather than undefined -- so
`props.allowVars ?? <surface default>` never reached the default and EVERY
surface silently lost `$`. It is now `"auto" | "on" | "off"`, which no
prop-casting rule can quietly rewrite. Two existing tests caught it; without
them this ships as "$ autocomplete stopped working everywhere".

ValueRow's tests updated: the "is a TEXTAREA" assertion became "is multiline
and grows", which is the requirement the tag name was standing in for.
2026-08-07 01:22:59 +03:00
DumiFlex 8aea38dd45 feat(injector): rich text input for the template, scoped to this row's refs
The binding modal's template was a bare <textarea>, so it had no `$`
autocomplete at all -- reported as "the $ variable autocomplete does not work
in the context injector". Nothing was broken; the machinery had simply never
been attached to that field.

The suggestion pool was already sitting next to it. `insertOptions` builds the
"+ $ref" menu from this row's own references -- a socket row can reference only
its own slot, the general row can reference every socket -- and the
autocomplete now reads the SAME list, so the menu and the popover cannot drift.
Deliberately not the library's variables: nothing in this modal can resolve
one, and offering them would suggest bindings that silently do nothing.

Surface is `assembler` because this composes a template. Explicitly not
`wildcard`, which suppresses `$` on purpose.

`insertSlotRef` no longer splices at the caret. RichTextInput is
contenteditable with atom spans, so `selectionStart` does not exist and the old
path had nothing to splice into; it appends instead. Honest about what it does,
and the menu is the fallback now that typing `$` opens the same list in place.

Tests updated rather than worked around: seven drove the field as an
HTMLTextAreaElement. The modal's own suite stubs RichTextInput in its mount
helper the way the combine editor's tests do, and the two widget-level tests
drive the component directly, since jsdom does not implement contenteditable
editing and those tests care that the value reaches the persisted JSON rather
than how the caret moved.
2026-08-07 00:51:00 +03:00
DumiFlex e2b9f2f053 feat(spa): refresh and remove buttons for the tag list
Managing the file meant opening a file manager, which is how the maintainer
had to remove it to test the disabled state.

Refresh re-reads the path. The server already notices an mtime change on its
own, but nothing tells the PAGE to go and look, so a file copied in by hand
stayed invisible until a reload. Both actions also drop the cached
availability the editors hold, so an open editor picks up a list that appeared
or disappeared without a reload.

Remove deletes it. Same shape as the download for the same reason: no caller
input reaches the path, and a symlink there is refused rather than followed --
otherwise this unlinks whatever it points at.
2026-08-07 00:17:28 +03:00
DumiFlex a4a89e5694 fix(extension): put the tag setting where it belongs and guard the drift
The setting I added last commit was wrong three ways, all visible in one
screenshot of the panel: filed under "2. Sizing" when it is behaviour, in a
bespoke `editing.` namespace matching none of the three the playground routes,
and absent from the Display Playground entirely.

Now `wildcardPipeline.behavior.tagAutocomplete`, under "7. Runtime behavior",
registered in the playground store's routing set and bound to a control in the
modal.

The real fix is the fourth change. `playground-store.ts` carries a comment
asking the next person to "keep this routing in sync if a new key is added",
and a comment is not a mechanism -- it is a note that gets read after the
divergence, by whoever is looking for the cause. `playground-parity.test.ts`
reads both sources and asserts, for every registered setting, that it uses a
routable namespace, that the store knows its key, and that the modal binds a
control to it. 18 settings, three checks each.

That test caught its own bug on first run: the id regex matched `id:` sites
only, and most entries reference a `SETTING_ID_*` constant, so it found exactly
one setting. The "finds them at all" assertion exists for precisely that and
fired immediately -- a parity test that silently checks nothing is worse than
no parity test, because it reports success.

Playground rows for behaviour settings have nothing to preview on the right.
It is listed anyway: the playground is where users expect the settings
mirrored, and "has no preview" is not a reason to be absent from the list.
2026-08-07 00:09:05 +03:00
DumiFlex 107e1de41e feat(extension): enable tag autocomplete on the canvas, with its own setting
The canvas editors were bundled and rendering-ready but the feature never
fired there. Cause: the setting was read via `useUiStore()` behind a try/catch,
and NO Pinia is installed in the canvas widget mounts at all -- so it threw,
returned false, and stayed silently off. The try/catch I added to fix 113 tests
was hiding a missing implementation rather than handling an edge case.

Each host now answers for itself. `utils/tagSetting.ts` reads ComfyUI's own
setting when `app.extensionManager.setting` exists and the persisted SPA value
otherwise, so neither host depends on the other's machinery. The two switches
are deliberately independent: wanting suggestions in the full manager does not
mean wanting them while squinting at a node on a canvas.

The ComfyUI setting is hand-rendered rather than a plain boolean so it can
DISABLE itself when no tag list is installed, saying where to get one. A
checkbox that switches on and does nothing is the exact failure this feature
keeps having to design around -- a control implying a capability it does not
have.

Scope is unchanged and worth stating: RichTextInput only ever mounts inside
this extension's own widgets, so nothing here touches ComfyUI's text widgets or
another extension's. That is a property of where the component is used, and the
obvious future "improvement" of hooking ComfyUI's generic textarea would throw
it away.

Also fixes arrow navigation. The header and legend are sticky, so they overlay
the scrollport rather than shrinking it, and `scrollIntoView({block:"nearest"})`
only knows about the scrollport -- arrowing to the first or last row parked it
under a pinned band, selected and invisible. `scroll-margin` on the rows is the
mechanism for exactly that. Verified: arrowing to the last row leaves it fully
above the legend, and back to the first leaves it clear of the header.
2026-08-06 23:48:26 +03:00
DumiFlex b8957acf34 fix(spa): blank legend swatch and a row visible under the legend
Two reported by the maintainer from a screenshot, both specific.

The `general` swatch rendered as nothing. Legend swatches carried only a
modifier class, and `--general` was never defined -- the row bars get their
neutral grey from the base `.wp-rt-tag__cat` rule, which the legend markup did
not use. Swatches now have their own base class and `general` is stated
explicitly rather than inherited by accident, since it is a real category and
not an absence.

A scrolling row showed through beneath the legend band. The popover root has
`padding-bottom`, and `bottom: 0` sticks the legend to the CONTENT box, leaving
that padding transparent below it. The legend is now pulled down over it with a
negative offset and matching padding.

Verified live at full scroll: no blank swatches, and no row pixels below the
legend -- the only gap left is the 1px border.
2026-08-06 23:25:07 +03:00
DumiFlex 36969d089a fix(spa): add the missing tag legend and pin it while the list scrolls
The legend was designed, described and never built -- rows shipped with a
colour bar and nothing saying what the colours meant.

It appears only when more than one category is in the CURRENT results. On an
all-general query every bar is the same neutral grey, and a legend naming four
colours none of which are on screen is a row of height explaining nothing.
Verified live: a `mik` query renders "character artist general" -- the three
kinds actually present, not the full five.

Also pins the header and the legend. `overflow-y: auto` sits on the popover
root, so everything inside scrolled together: the query you are refining
scrolled out of view as you moved down the list. That was already true of the
`$`/`@` popover and is fixed for both. Sticky rather than a flex wrapper
because the row list has no container element -- restructuring the existing
markup would have put the working sigil popover at risk for no gain. Negative
margins cancel the root's horizontal padding so the pinned bands span the full
width instead of leaving a gutter for rows to show through.

Verified in the running SPA: scrolling the list 691px leaves both the header
and the legend visible while the rows move, and a field 189px from the bottom
of the viewport flips the popover above the caret rather than overflowing the
screen, legend included.
2026-08-06 22:18:23 +03:00
DumiFlex 51db05620d feat(spa): wire the booru tag autocomplete into the editor
Completes #27. Typing a word in an option value now suggests tags; `$` and `@`
are untouched.

Non-interference is structural, not coordinated. The tag mode is a third value
on the SAME `acTrigger` state, so only one popover can exist -- two independent
popovers could both open over one caret, which is the behaviour this feature
was not allowed to introduce. And it is probed only where `probeAutocomplete`
already declined the caret, so the sigil probe gets first refusal on every
keystroke. `probeTagWord` re-checks for a preceding sigil as a second lock, and
19 tests assert the two probes never both claim a caret.

Commit inserts PLAIN TEXT. `$`/`@` build chips because they carry engine
meaning; a tag is just characters in a prompt. Deliberately not a branch inside
`insertChipAtCaret` -- its defensive re-derivation scans backwards for a
`$`/`@` run, which is exactly wrong for a sigil-less word.

Rows follow the design pass: the line that gets INSERTED is always primary, so
an alias hit reads `head_tuft` with `from blue_head_tuft` beneath rather than
leading with the alias, which is not what Enter produces. The category bar
appears only when the loaded file has categories, and the legend only when the
results actually differ -- a column of identical grey bars explains nothing.

Three bugs found by QA against the real SPA, none of which any test caught:

- `useUiStore()` at setup scope threw in 113 existing tests, which mount this
  component without Pinia. Every other setting arrives as a prop, so the store
  dependency was the odd one out as well as the broken one; it is now resolved
  lazily and degrades to "off" when Pinia is absent.
- The popover renders on `acOpen && acItems.length > 0`, and tag mode returns
  no `acItems` by design, so it could never appear. Now gated on the row count
  for whichever mode is active.
- The "shared" status promise was a `let` inside `<script setup>`, which
  compiles into `setup()` and is therefore per-instance: 135 identical requests
  on a 133-option wildcard. Moved to a real module -- measured 135 -> 1.

Verified in the running SPA: the popover opens with 20 real rows from a
286,337-entry list, Enter completes `blue_h` to `blue_hair` as plain text with
zero chips, and typing `$mood` shows no tag rows at all.
2026-08-06 22:00:21 +03:00
DumiFlex b2fedcd87a feat(spa): settings entry for the tag autocomplete
Client half of #27, minus the editor wiring. Adds the API client namespace,
the types, the persisted on/off setting, and the Settings card.

The card separates two facts that look like one: whether the user WANTS
suggestions, and whether a tag list exists to make them from. The toggle owns
the first, the status row owns the second. Conflating them produces a switch
that silently does nothing, which is why /wp/api/tags/status reports
`available` independently of the setting.

Off by default -- the feature needs a list the user has to fetch first, so
defaulting it on would advertise a capability that is not there.

Four states the card renders honestly: nothing installed, installed and on,
downloading, and a two-column file that works but cannot colour-code. The last
reports "no categories" rather than pretending, because the dropdown will
visibly differ.

The path is shown even when nothing is installed -- it is the answer to "where
do I put my own?", which is the supported alternative to downloading ours.

The card states in the UI that Download fetches from GitHub, and that it is
the only thing this extension fetches. The README says so too, but someone
deciding whether to press a button should not have to read the README first.

Follows the Updates card shape (Field + Toggle, then a status row with an
action) because it is the same interaction: tell me the state, give me the
verb. Designed against the real tokens with real rows from a 286k index, not
mock data.
2026-08-06 21:35:52 +03:00
DumiFlex e7feeaa112 feat(api): download the tag list, with the capability removed rather than validated
The only outbound request this extension makes, and only on an explicit click
in Settings. Documented in the README under "Network access" -- a user should
be able to find out that a plugin talks to the internet without reading its
source.

ComfyUI serves its API unauthenticated, so "fetch a URL and write it to disk"
is the most abusable endpoint shape available. Each capability is therefore
removed rather than checked at the door:

- The URL is a module constant. The request body is ignored entirely, so there
  is no parameter to smuggle `http://169.254.169.254/` or an internal host
  through.
- Redirects are validated per hop against a fixed host list. GitHub serves
  release assets via a CDN so the chain must leave github.com -- to those hosts
  and nowhere else, https only.
- The destination is computed from ComfyUI's user dir plus a fixed filename, so
  no caller input reaches the path. A symlink there is refused rather than
  followed, so the write cannot be redirected.
- The body is size-capped while streaming. Content-Length is checked when
  present but never trusted as the only guard, since it is whatever the server
  chose to say.
- A single-flight lock means repeat triggering gets "already running" instead
  of N concurrent multi-megabyte fetches.
- The payload is parsed BEFORE it replaces anything, then moved atomically. A
  response that is not a readable tag list leaves the existing one intact, and
  an interrupted download cannot leave a half-written file in place.
- No proxy or cookie handler on the opener: one public asset, no ambient
  credentials.

21 tests, one per removed capability, because a comment claiming a capability
is absent is worth nothing on its own. Includes the metadata-address and
suffix-confusion hosts (`github.com.evil.example.com`), a Content-Length that
lies about a body over the cap, and a symlinked destination.

What is deliberately not claimed: this does not defend against a compromise of
our own release. It is a tag list, parsed into strings and never executed, so
the worst a bad file achieves is bad suggestions.
2026-08-06 19:00:07 +03:00
DumiFlex 33c2e718c1 ci: build the booru tag list on demand, publish to a fixed URL
Data source for the optional value autocomplete (#27). Replaces the
community script this started from, which walked all 1000 allowed pages and
kept only `name,post_count`.

Three changes, each from something measured against the live API on
2026-08-06.

A post-count floor instead of a page count. `search[order]=count` sorts
strictly descending ACROSS pages:

    page    1: first 8,244,950   last 55,918
    page  100: first        68   last     67
    page  800: first         1   last      1

so a popular tag cannot hide on a later page and stopping early provably
loses nothing. Walking to page 1000 spends ~900 requests and a quarter of an
hour collecting single-post tags. The default floor of 20 lands near what
other extensions ship.

Aliases, from the separate `tag_aliases.json` endpoint. `tags.json` carries
none, and aliases are what let someone type `hires` and land on `highres` --
demonstrably the most valuable field after the names.

Category is kept. The API returns it and the old script discarded it; it is
what lets the UI tell an artist tag from a character tag.

Also retries with backoff rather than aborting on the first non-200, which
over a 100-request run means one blip no longer discards everything.

The User-Agent is pinned to bare `product/version` and must stay that way --
Danbooru's bot filter 403s a bare token, a longer description, or anything
containing a URL. Adding a contact URL is the usual courtesy for a scraper and
is exactly what breaks it, so the reasoning is recorded next to the constant.

MANUAL ONLY. The list describes Danbooru, not this project, and goes stale
slowly: a two-year-old file still autocompletes correctly because staleness
moves post counts and therefore ranking, while tag names barely change. That
is not an argument for a timer against someone else's API. Output goes to a
fixed non-semver `tag-list` tag, replaced in place, so the download URL is
stable and the SPA can hardcode it -- and so semantic-release ignores it.
2026-08-06 18:33:02 +03:00
DumiFlex b4d764fd4e feat(api): booru tag index behind /wp/api/tags/*
Server half of the optional value autocomplete (#27). No UI yet.

The tag list is a file the user supplies at one path we own, under ComfyUI's
user dir -- not the package folder, which a reinstall replaces and the asset
pruner walks. Reading other extensions' tag files was considered and rejected:
it couples us to layouts they are free to change. Our own CI workflow will
generate the list instead.

Bundling was never viable regardless -- a usable list is 2-6 MB against a
420 KB gzipped budget -- and the file is not sent to the browser either.
Prefix search runs here and the client gets ~20 rows per keystroke, which is
the drift-poll lesson applied before making the mistake rather than after.

Both shapes in the wild are accepted:

    name,post_count                       ComfyUI-Custom-Scripts
    name,category,post_count,"aliases"    Danbooru API export

with no header to switch on, so they are told apart by magnitude: a category
is a small enum, a post count is not.

Built on the FIRST SUGGEST REQUEST, never at plugin import. Benchmarked on a
real 286,337-entry file: 785 ms to load, 5-950 us to query. Paying that at
startup would slow every ComfyUI launch for a feature most users leave off --
the exact problem this cycle just finished fixing.

Two defects the benchmark caught, both alias-related:

- `1girl` matched both itself and the alias `1girls`, which resolves back to
  `1girl`, so the same row rendered twice. Results are now deduplicated by the
  tag that would actually be inserted, and a direct hit beats an alias hit for
  the same canonical.
- An alias match displayed only its canonical, so `ka` returned `pokemon` with
  nothing to explain it. The response now carries `matched` alongside `name`
  so the UI can render `kalos -> pokemon`.

Prefix-only by design. Substring matching over 200k tags buries the wanted tag,
and every booru autocomplete users already know behaves this way.

Also exposes `comfyui_user_dir()` from engine.db.connection -- the detector
chain already existed there but only as private helpers behind the DB path,
and reaching across modules for underscore names to find it was the wrong
shape.
2026-08-06 18:23:27 +03:00
DumiFlex fd9ba60bec perf(packaging): stop leaking every previous build into the install
Investigating slow ComfyUI page loads on a real install turned up this
project as the single largest contributor, by a wide margin.

    js/    518 files   36.0 MB    179 stale chunk copies
    web/  1256 files   22.0 MB   1120 stale chunk copies

ComfyUI fetches EVERY *.js under WEB_DIRECTORY at page load, so that install
requested 237 of our chunks where 58 were live: 9.9 MB, 42% of all extension
bytes on the page and 27% of all extension requests. Twelve copies of `about`,
nine of `ContextWidget`.

Two independent causes, both ours.

Chunks are content-hashed, so every release emits new filenames, and Manager
installs an update by extracting over the installed folder without deleting
what the new version dropped. Each mechanism is reasonable alone. Together
they never remove anything. It stayed invisible for a dozen releases because
`emptyOutDir` wipes the output directory on every local build -- only installs
that UPDATE accumulate, and a maintainer never sees one.

And `sourcemap: "hidden"` still WRITES every .map, omitting only the
`sourceMappingURL` comment, so 22.5 MB of maps shipped that no browser had
ever requested. `pack-release.mjs` excluded them from the GitHub zip, but the
registry package comes from pyproject's `includes = ["js", "web"]`, which has
no exclusion -- so the fix has to be not emitting them.

Now: each build writes `.wp-assets.json` listing what it produced, and the
plugin prunes anything hashed and unlisted at import, before ComfyUI scans
WEB_DIRECTORY. Self-healing -- existing installs clean themselves on the next
start with no user action.

Deliberately conservative, because deleting a live file breaks an install
while missing a stale one only costs disk: no manifest, an empty manifest or a
corrupt one all mean do nothing; only bundler-shaped names are candidates
(hashed chunks, plus any .map -- the entry map is unhashed, which the hash
pattern alone walked straight past); every failure is swallowed.

Verified against a copy of the real install, updated the way Manager would:
1,881 files -> 302, 60 MB -> 10.9 MB, 1,579 removed. All 551 relative module
imports across the surviving chunks still resolve, and every asset
`web/index.html` references is intact.
2026-08-06 17:58:01 +03:00
DumiFlex 0d01a633d0 fix(editors): show the new filter bars on real-world list sizes
The constraint and derivation filters inherited the fixed-values threshold of
"more than 8 rows" unexamined, and it hid the feature outright. Measured
against a real library: 53 derivations with at most 3 rules, 21 constraints
with at most 2 exceptions. Nobody would ever have seen either filter.

Rules now appear above 2, exceptions above 5. Not merely to make them visible
-- a derivation rule is a whole card rather than a table row, so three of them
already scroll, while a fixed-value row is short enough that eight fit.

The prop's doc comment now says thresholds are the caller's call, sized from
how tall that surface's rows are, so the next surface does not inherit 8 by
accident too.
2026-08-06 17:29:11 +03:00
DumiFlex 39266cb87b fix(constraints): reject two exception rules for the same pair
Reported by the maintainer: the editor accepted the same source/target
combination more than once with contradictory modes and factors. Only one ever
applied. `combine_constraint_factor` files exceptions into a dict keyed by the
pair, so the last one in the list wins and every earlier one is discarded.

Verified against the pure math, and the alarming direction is real:

    [boost x3, reduce x0.5] -> 0.5        not the combined 1.5
    [reduce x0.5, boost x3] -> 3.0        order decides the result
    [boost x3, exclude]     -> EXCLUDE
    [exclude, boost x3]     -> 3.0        the exclusion is thrown away

That last line contradicts the documented model in CLAUDE.md, which says
constraints combine "with `exclude` (factor 0) absorbing". Within duplicate
exceptions it neither combines nor absorbs, and the deciding factor is list
position -- which no surface displays and nothing lets you edit.

Enforced at the AUTHORING boundary, not in `validate_payload`. `resolve` calls
that on every graph run, so a rule there would turn an already-stored payload
into a hard failure mid-execution -- strictly worse than the silent
misbehaviour it replaces, and unreachable for a user who cannot open the editor
without the graph running. Handlers may now expose an optional
`validate_authoring`, called by the module create/update paths and by bundle
child validation; existing rows stay executable.

Engine math is deliberately unchanged. Making duplicates combine would have
been defensible -- it is what the docs describe -- but it silently alters
re-weighting for anyone who already has them, and "you cannot create this
anymore" achieves the same end without touching anyone's output.

Scanned the maintainer's library first: 21 constraints, 0 duplicate pairs, so
nothing existing is affected either way.
2026-08-06 17:27:30 +03:00
DumiFlex ff6332cf23 fix(api): stop stamping our header on ComfyUI's own responses
`_startup_id_middleware` is registered on ComfyUI's application, not on a
sub-app, and had no path check despite a docstring claiming it tagged
"/wp/api/*". It therefore stamped X-WP-Startup-Id on every response the whole
server sent: ComfyUI's index, /api/extensions, image outputs, other
extensions' routes. Found by a remote probe during the 2026-08-06 session --
`GET /` came back carrying it.

Nothing depended on the wider scope. The only consumer is
manager/api/client.ts, which talks exclusively to /wp/api/*.

Same principle CLAUDE.md's extension-isolation section applies to CSS
selectors and litegraph node properties, extended to response headers: being a
guest in someone else's application means not writing on their responses.
2026-08-06 06:44:16 +03:00
DumiFlex 49f882f131 perf(api): make the drift poll conditional and stop rebuilding it
`drift-store.ts` polls /wp/api/modules/hashes and /wp/api/bundles/hashes every
5 seconds. Both the SPA and the in-graph WP_Context subscribe, so a ComfyUI tab
with a Context node on the graph polls these whether or not the manager is ever
opened.

Measured against a live 2.13.1 install on 2026-08-06: 33,008 bytes per poll, no
ETag, no Cache-Control, no Last-Modified, and 0 of 39 observed requests served
from cache. That is ~24 MB/hour per open tab on an idle page. Invisible on
localhost; over Tailscale it was the largest thing this extension does.

Producing it was not cheap either, despite the docstring's "lightweight ...
without measurable cost" -- that described the response, not the work.
`ModuleRepository.list()` is `SELECT *` plus a json.loads of every payload in
the library, and the handler kept three fields per row and discarded the rest.

Two changes:

- Conditional responses. `json_ok_revalidated` emits an ETag plus
  `Cache-Control: no-cache` -- store it, but revalidate -- and returns a bodied
  304 when `If-None-Match` matches. Nothing changes for the client: the browser
  serves the cached body as a 200, so `fetch().then(r => r.json())` is
  untouched and the saving is entirely on the wire.

- A cheap stamp behind the ETag. `library_stamp()` is
  COUNT/SUM(version)/MAX(updated_at) -- one aggregate row, no payloads. The
  expensive map is built only when the stamp moves, which is the whole point:
  computing an ETag from the response body would have done all the work anyway
  and then thrown it away.

Also narrows the map builders. `list_hash_rows` selects the columns the drift
map needs instead of every column via `_row_to_module`/`_row_to_bundle`, which
parse `tags` (and `children`, for bundles) per row for a map that wants
neither. Bundles get the bigger win -- `payload_hash` is a stored column there,
so that path now parses nothing at all. Modules still parse `payload` because
their hash is computed rather than stored; removing that needs a schema
migration, and the stamp makes it moot in the steady state.

Stamp precision, both directions verified: `set_community_origin` writes
without a version bump and cannot change the map, so the ETag correctly holds.
A favourite toggle goes through `update()`, whose single statement always does
`version = version + 1`, so it does move the ETag and costs one refetch of an
identical map. Accepted rather than fixed -- a rare user action against a second
counter tracking payload-affecting columns only -- and pinned by a test that
says so, and says a future failure would be an improvement.
2026-08-06 04:12:44 +03:00
DumiFlex eb9167e1c1 fix(editors): scope select-all to the filtered rows in the wildcard editor
Same bug as fa9cde05 fixed in the three simpler editors, carried
independently here because this editor predates `useBulkSelection` and was
never migrated onto it. `selectableOptions` was the full option list while the
table rendered `visibleOptionRows`, so select-all reached rows the filter was
hiding.

Worse blast radius than the fixed-values case. Bulk actions here are delete,
set-weight, and tag add/remove. Delete at least changes the row count;
`setWeightSelected` rewrote every weight in the list with no visible signal at
all, and a hand-tuned distribution has no undo.

`toggleSelectAll` now adds or removes only the visible ids, matching the
composable's semantics: a selection survives the user retyping the filter, and
`selectedCount` still reports the honest total.

Also migrates FixedEditor onto ui/ListFilter (the follow-up fa9cde05 deferred)
and drops the ~50 lines of markup and CSS it duplicated. `test-prefix="fv"`
keeps its data-test hooks byte-identical, so its tests are unchanged.

Two layout fixes found by QA, not by tests:

- ListFilter had no flex growth, so it never claimed the card header's slack
  the way the fixed-values filter it was extracted from did. Ported the
  `flex: 1000 1 auto` and its rationale.
- `.wp-card__header` could not wrap, so a crowded header shaved its flexible
  child instead. The derivation card has four buttons and squeezed the filter
  input to 18px in a narrow window. The header now wraps and the filter has a
  220px floor; measured 18px -> 194px, with every other card header on the
  page unchanged at 42-50px and nothing overflowing.

Verified on a 133-option wildcard: filtering to 20 and selecting all reports
20, not 133; clearing the filter keeps those 20; selecting all under a second
filter adds 15 more; deselecting under that filter returns to 20 with the
first selection intact.
2026-08-06 02:48:21 +03:00
DumiFlex fa9cde0546 feat(editors): filter bars on constraint exceptions and derivation rules
The wildcard and fixed-values editors grew a filter; these two never did,
which had it backwards. A constraint's exception list is routinely longer
than the wildcard it filters, and a rule card is tall enough that a dozen
rules is already a scroll.

Extracted `ui/ListFilter.vue` rather than copy the fixed-values markup a
third time, and `utils/listFilter.ts` for the two haystack builders, since
"what does this row match on?" is the part worth testing and mounting an
editor to assert it would test the wiring instead.

Both surfaces match on what the row READS AS, not what it stores. An
exception matches its resolved source/target labels -- the stored values are
frequently `@{uuid}` tokens and nobody searches for a uuid they cannot see.
A rule matches every variable, operator, mode and literal across its branches
and its else, but never its generated id.

Rows carry their ORIGINAL index through the filter. Both editors address rows
by position (`removeRule(idx)`, `exceptions.value[idx]`), so a plain filtered
array would edit and delete the wrong row, and the rule cards would renumber
under a filter and claim Rule 5 is Rule 1.

Also fixes a pre-existing destructive bug in the fixed-values editor, found
while porting the pattern: `useBulkSelection` scoped select-all to every row,
not the visible ones. Filtering 40 values to 3 and hitting the header checkbox
selected all 40, and the delete that usually follows removed 37 rows the user
never saw. It takes an optional second getter for the visible ids; toggleAll
and the header state now read that, while count and selectedIds stay global so
a selection survives the user retyping the filter.

Verified against a running ComfyUI on temporary modules, since nothing in the
library was long enough to trigger either filter: filtering 8 rules to 1 keeps
its card labelled Rule 5, select-all under that filter reports 1 selected
rather than 8, and deleting a filtered exception removes that row -- confirmed
by the surviving rows' mode sequence, not by row text. The temporary modules
were deleted afterwards.
2026-08-06 02:23:59 +03:00
DumiFlex c3e74e206d perf(extension): stop bundling the webfonts into JavaScript
Vite library mode ignores `build.assetsInlineLimit` and inlines every asset
unconditionally, so both woff2 files were base64'd into a chunk: 88,660 bytes
of font became a 118,896-byte JS file, awaited in main.ts's top-level
`Promise.all` and therefore parsed as JavaScript before ComfyUI could finish
registering the nodes.

fonts.ts believed the opposite -- its comment claimed `new URL(path,
import.meta.url)` kept the woff2 as separate assets. That transform is exactly
what Vite intercepts in lib mode, so the intent never held.

The fonts now leave the module graph entirely. `src/assets/fonts/wp-fonts.css`
is a real source file copied verbatim to `js/assets/fonts/` by copy-fonts.mjs;
fonts.ts <link>s it at a URL resolved against its own chunk, and the browser
resolves each woff2 relative to the stylesheet. No path arithmetic in JS, and
a renamed install directory still works.

The href is assembled from array parts on purpose: Vite's asset transform fires
on `new URL(<string literal>, import.meta.url)`, so a literal here would
silently re-inline the stylesheet with no build error and no failing test.

Measured with the gate's own method: 492,407 -> 403,055 bytes gzipped. Total
bytes on a cold cache are about unchanged -- gzip recovers base64's overhead --
so the win is latency and caching, not payload: registration no longer waits,
90 KB left the JS parse path, and the fonts stop being re-downloaded on every
release when the enclosing chunk's hash changes.

TOTAL_LIMIT tightened 490 -> 420 KB. Re-inlining costs ~90 KB, which fits under
490 KB and would have regressed unnoticed; it does not fit under 420 KB.

Verified against a running ComfyUI: the stylesheet and both woff2 serve 200 and
are fetched as fonts, and document.fonts.check() passes for JetBrains Mono,
which only this stylesheet provides.
2026-08-06 02:01:40 +03:00
236 changed files with 16448 additions and 961 deletions
+101
View File
@@ -0,0 +1,101 @@
name: Build tag list
# MANUAL ONLY, and deliberately so.
#
# The list is not tied to our release cycle — it describes Danbooru, not this
# project — and it goes stale slowly. Verified 2026-08-06: a roughly two-year-old
# tag file still autocompletes correctly, because staleness moves post counts
# (and therefore ranking) while tag NAMES barely change. There is no correctness
# argument for a schedule, only a ranking one, and that does not justify a timer
# pointed at someone else's API.
#
# So: run it when you want a refresh — a new character tag everyone is asking
# for, or once in a while out of tidiness. The asset stays put until then.
#
# It publishes to a FIXED tag, replacing the asset in place, so the download URL
# never changes:
#
# https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/releases/download/tag-list/wildcard-pipeline-tags.csv
#
# That stability is the point: the SPA's "download tag list" button hardcodes
# that URL and never needs updating. A per-run tag would break it every time.
#
# The tag is intentionally not semver, so semantic-release ignores it and this
# never looks like a product release.
on:
workflow_dispatch:
inputs:
min_count:
description: "Stop once tags drop below this many posts. 20 ≈ 150k tags; 68 ≈ the 100k other extensions ship."
required: true
default: "20"
type: string
alias_pages:
description: "Pages of aliases to fetch (1000 each). Aliases are what make `hires` find `highres`."
required: true
default: "60"
type: string
publish:
description: "Attach the result to the tag-list release. Off = build it and keep it as a run artifact only."
required: false
default: false
type: boolean
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# No dependencies on purpose: the builder uses urllib from the standard
# library, so there is nothing to install and nothing to keep patched.
- name: Build
run: |
python scripts/build_tag_list.py \
--min-count "${{ inputs.min_count }}" \
--alias-pages "${{ inputs.alias_pages }}" \
--out wildcard-pipeline-tags.csv
- name: Summarise
run: |
{
echo "### Tag list built"
echo
echo "| | |"
echo "|---|---|"
echo "| Tags | $(wc -l < wildcard-pipeline-tags.csv) |"
echo "| Size | $(du -h wildcard-pipeline-tags.csv | cut -f1) |"
echo "| Floor | ${{ inputs.min_count }} posts |"
echo "| Published | ${{ inputs.publish }} |"
} >> "$GITHUB_STEP_SUMMARY"
# Always kept, so a build can be inspected before it replaces the asset
# everyone downloads.
- uses: actions/upload-artifact@v4
with:
name: wildcard-pipeline-tags
path: wildcard-pipeline-tags.csv
retention-days: 14
- name: Publish to the fixed tag-list release
if: ${{ inputs.publish }}
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# `--clobber` replaces the asset in place, which is what keeps the
# download URL stable across refreshes.
if gh release view tag-list >/dev/null 2>&1; then
gh release upload tag-list wildcard-pipeline-tags.csv --clobber
else
gh release create tag-list wildcard-pipeline-tags.csv \
--title "Booru tag list" \
--notes "Autocomplete data for ComfyUI-Wildcard-Pipeline. Rebuilt on demand; not a product release. Download it from the manager's Settings page rather than by hand." \
--latest=false
fi
+7
View File
@@ -61,8 +61,15 @@ Thumbs.db
# Throwaway design / scratch artifacts (mockups, screenshots, demos)
/_temp/
# Root-level PNGs are always scratch QA screenshots here — legit images live
# under public/. Anchored so it never touches public/images/**. Keeps stray
# captures out of git and out of pack-release's ship-list gate.
/*.png
# DB location control — per-contributor sidecar config + root-location DB dir
db-config.json
/db/
.tmp-check/
# Playwright MCP session artifacts (page snapshots, console logs).
.playwright-mcp/
+6 -2
View File
@@ -119,7 +119,11 @@ export default {
"@semantic-release/exec",
{
// 1. Stamp version into package.json + pyproject.toml.
// 2. Pack the deployable file set into a zip so the
// 2. Rebuild js/ + web/ so the bundle's baked-in version matches the
// stamped pyproject.toml. The server reports that version and the
// canvas + manager ask for a reload while the two differ, so a zip
// packed from the pre-stamp build would nag on every page load.
// Pack the deployable file set into a zip so the
// @semantic-release/github plugin can attach it to the
// release. Zip is built AFTER versioning so the file lands
// with the right version in its name.
@@ -127,7 +131,7 @@ export default {
// from a clean slate (the content already lives in this release's
// notes + CHANGELOG.md).
prepareCmd:
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node -e \"require('fs').writeFileSync('docs/release-notes/next.md','')\" && node scripts/pack-release.mjs ${nextRelease.version}",
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node -e \"require('fs').writeFileSync('docs/release-notes/next.md','')\" && pnpm build && node scripts/pack-release.mjs ${nextRelease.version}",
},
],
[
+151
View File
@@ -1,3 +1,154 @@
## 🎉 Wildcard Pipeline 2.16.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
### Features
- **Link one wildcard's pick to another's with the new Only rule.** A constraint rule set to **Only** turns its row into a short list. An exception *maid → frilled apron* in Only mode means that whenever the source rolls "maid", the target can only roll "frilled apron", and an outfit you add to the target later is shut out too. Before, the same link needed an Exclude rule for every other outfit, and any outfit added afterwards slipped through. In the matrix, an Only cell links subcategories the same way, and the other cells in that row show a faint × so you can see what is shut out. Only is in the cell popover, the exceptions mode picker and the canvas instance editor. Constraints that use it publish as schema version 6, so an older extension refuses them and asks you to update instead of failing the constraint.
### Fixes
- **Setting a matrix cell back to Neutral on the canvas no longer breaks the constraint.** Overriding a library rule to Neutral in a Context node's constraint editor saved a value the engine didn't accept, so the whole constraint stopped applying when the graph ran. The editor now saves it correctly, and workflows saved with the old value run again.
- **The constraint docs described Allow wrongly.** They said Allow keeps only the matching options. Allow has always meant "no change" (it shows as Neutral in the matrix). The docs now say so, and the keep-only behaviour they described is what Only does.
---
**Full changelog:** [v2.15.3…v2.16.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.3...v2.16.0)
## 🎉 Wildcard Pipeline 2.15.3
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
New docs for chips, references and the filter grammar.
### Highlights
- **Docs: Chips & references.** A new page in the manager's Docs tab (How it connects → Chips & references) explains the chips you see in text fields and their three states (resolved, filtered, broken), how to make a placeholder `$var` chip before anything produces it, the full `@{uuid#name:filter!null}` reference form and why plain `@name` stays text, and the sub-category filter grammar: `not`, `and`, `or` (or a comma) and parentheses, e.g. `not warm and (low or high)`. Searching the docs for "chip" or "filter" now finds it.
---
**Full changelog:** [v2.15.2…v2.15.3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.2...v2.15.3)
## 🎉 Wildcard Pipeline 2.15.2
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
### Fixes
- **`$outfit.1.SHOES` now reads the second pick's shoe.** On a wildcard that picks several options, an indexed axis read skipped any pick whose option carries no tag on that axis, so every later index slid down by one: with `robe, jeans` picked, `$outfit.0.SHOES` printed the jeans' boots beside the robe and `$outfit.1.SHOES` came out empty. Each index now names the same pick as `$outfit.1`, in templates and in derivation conditions alike. The bare `$outfit.SHOES` read was already right and is unchanged.
- **The Prompt Assembler preview shows the tag that was actually rolled.** The preview filled `$outfit.SHOES` with the first tag of the group whatever the option, so it could read `sandals | robe | red stilettos` for an outfit with no shoes and a pair of heels. It now reads the same roll as the values beside it, and matches what the prompt will say.
---
**Full changelog:** [v2.15.1…v2.15.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.1...v2.15.2)
## 🎉 Wildcard Pipeline 2.15.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
### Fixes
- **Library file exports now record the schema version their content needs.** Exporting from the library always wrote schema version 2 into the file, even when it held range multi-picks like `{1-3$$…}`, constraints with a narrowed reach, or wildcards with an **accepts** tag axis. An older extension would then import the file and silently misread or drop those features. Exports now say 3, 4 or 5 when they use those features, so an older extension refuses the file and asks you to update. Files without them still export as version 2.
---
**Full changelog:** [v2.15.0…v2.15.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.0...v2.15.1)
## 🎉 Wildcard Pipeline 2.15.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
### Fixes
- **Shared wildcards with accepts tag axes now say when your extension is too old for them.** A wildcard whose tag group is marked **accepts** is now published as schema version 5. Before, it went out as version 2, and an extension from before 2.14 installed it without complaint while quietly dropping the accepts setting, so `$outfit.SHOES` read empty and constraints lost the rolled tag. That older extension now refuses the pack and asks you to update. Wildcards without an accepts group publish exactly as before.
- **Updating a community item no longer re-runs the old tag conversion on it.** "Update" and "Install as new entry" labelled every download as the oldest schema version, so each install ran the version 1 to 2 upgrade again over content that was already current. Downloads now keep the version the community recorded for them, and so do dependencies fetched from a constraint's "download missing" action.
---
**Full changelog:** [v2.14.1…v2.15.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.14.1...v2.15.0)
## 🎉 Wildcard Pipeline 2.14.1
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
### Fixes
- **Cutting an already-empty field no longer wipes your clipboard.** Cut an option's text, press Ctrl+A Ctrl+X again on the now-empty field, and the text you meant to paste was gone: the editor let the browser cut an invisible caret marker, which replaced what was on the clipboard. The second cut now leaves the clipboard alone, and a paste never writes that invisible character into a value.
---
**Full changelog:** [v2.14.0…v2.14.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.14.0...v2.14.1)
## 🎉 Wildcard Pipeline 2.14.0
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
Read a wildcard's chosen tag as a variable, a much faster ComfyUI start, and a bulk-delete fix worth reading.
### Highlights
- **Tag groups can now be axes you read as variables.** Mark a wildcard's tag group **accepts** and it becomes an *axis*: the wildcard rolls exactly one of its tags each pick, and you can read that tag anywhere with **`$var.AXIS`**. An `$outfit` wildcard with an accepts group `SHOES` exposes **`$outfit.SHOES`** as the shoe it chose this run — the same value in every template, combine, derivation and chained node, because it is rolled once when the option is picked rather than each time it is read. Groups left **classify** (the default) behave exactly as before, so nothing already in your library changes.
Where it pays off is constraints. When an accepts axis is the **source** of a constraint, the rule keys on the single rolled tag rather than every tag the option happens to carry — so a diagonal "same tag wins" matrix pins the target to exactly what was rolled, and `$outfit.SHOES` and the shoe that gets picked agree every run. If the **target** is also an accepts axis, it stays in the pool when the source allows any of its alternatives and then rolls the matched one, so the two wildcards never disagree on the axis. The editor marks accepts groups in the constraint matrix, warns when an option carries none of its axis's tags, and offers a wildcard's axes in the `$` autocomplete.
- **Optional booru tag autocomplete.** Turn it on in Settings → Tag autocomplete and typing a few letters into an option value suggests danbooru tags, ranked by how often they are actually used, colour-coded by kind (character, copyright, artist, meta) and resolving aliases — type `hires` and it offers `highres`, naming what it matched so the jump is never mysterious.
It stays out of the way of everything you already use: `$` variables and `@` references always win the caret, and suggestions never appear while you are typing one. Off unless you switch it on.
The tag list is a file you install once — press **Download** in Settings to fetch it from this project's GitHub release, or drop your own CSV at the path shown there. **This is the only thing this extension ever fetches from the internet**, it only happens when you press the button, and the [README](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline#network-access) documents exactly what that request does and the restrictions around it.
- **This extension was making ComfyUI slow to load, and it will clean up after itself on the next start.** Our frontend files carry a content hash in their names, so every release produced new filenames — and ComfyUI Manager installs an update by writing the new files over the old ones without removing what the new version no longer ships. Nothing ever deleted the previous version's files, so they piled up with every update.
On a real install that had been updated a dozen times: **1,774 files taking 60 MB, where about 300 were live.** ComfyUI requests every JavaScript file an extension exposes when the page loads, so that install was fetching 237 of our chunks instead of 58 — nearly 10 MB, which was 42% of all extension code on that page and by a wide margin the largest single contributor to its load time.
This release records what each build produces and deletes anything left over from an older version when ComfyUI starts. **No action needed — the first start after updating does the cleanup**, and it is conservative: only files this project's build system generates are ever removed. On the install measured above it freed 52 MB and removed 1,579 files.
- **Sourcemaps are no longer shipped.** They were being written on every build and included in the package, but never linked, so no browser had ever requested one — 22.5 MB of files that existed only to be ignored. Debug builds can still produce them.
- **"Select all" now means the rows you can see.** With a filter active, the header checkbox selected *every* row, not the filtered ones — so narrowing a list down to a handful, selecting all and deleting removed the whole list, most of it never on screen and with no warning. This affected the **wildcard** and **fixed-values** editors, and on wildcards it reached bulk weight and tag changes too: setting a weight on a filtered selection silently rewrote every option's weight, with no change in row count to hint at it. Select-all is now scoped to what the filter is showing, in both directions. If you have ever lost options or had a weight distribution flattened, this was why.
- **A constraint can no longer hold two rules for the same pair.** Adding the same source/target combination twice — say `rain → sandals` as both *exclude* and *boost ×3* — looked like two active rules, but only one ever applied: whichever came last in the list. Order decided the outcome, nothing showed you the order, and in the worst case a deliberate **exclude was silently thrown away** by a later duplicate. Saving or importing a constraint with a repeated pair is now refused, naming the pair so you know which one to remove. Existing constraints keep working exactly as before — nothing already in your library is rejected, and no generation changes.
- **Constraint exceptions and derivation rules have a filter bar.** The wildcard and fixed-values editors got one previously; these two did not, which was backwards — an exception list is routinely longer than the wildcard it filters. Type to narrow the list, with a count and a clear button.
Rules keep their real numbers while filtered, so "Rule 5" stays Rule 5 instead of renumbering to 1, and editing or deleting a filtered row acts on that row rather than the one that happens to share its position.
- **The library-change check stopped re-downloading your whole library every five seconds.** Both the manager and the in-graph Context node watch for library edits made elsewhere, by polling a list of module hashes. That list was re-sent in full on every tick with no caching headers — 33 KB every five seconds on a real library, roughly **24 MB an hour per open tab**, sitting idle. Producing it also re-read and re-parsed every module payload in the database each time.
The check now answers "nothing changed" with an empty response the browser already knows how to handle, and works that out from a cheap row count rather than by rebuilding the list. On a local install this was invisible; if you reach ComfyUI over Tailscale, a VPN or any metered connection, it was the largest thing this extension did.
- **The webfonts are no longer bundled into JavaScript.** Both fonts were being base64-encoded into a code file that ComfyUI had to download and parse *before* it could finish registering this extension's nodes — about 90 KB of JavaScript that was never really JavaScript. They are now ordinary font files the browser fetches on its own, in the background, while everything else carries on. Node registration no longer waits for them.
Two things follow from that. Text appears sooner on a slow CPU, because 90 KB stopped going through the JavaScript parser. And the fonts now stay cached when you update — previously every release changed the code file they were hidden inside, so your browser re-downloaded them every time.
Total download on a fresh install is roughly unchanged: the encoding overhead and the compression that undid it cancel out. The gain is in *when* the work happens, not how much of it there is.
<!-- /modal -->
### Upgrade notes
Nothing to do beyond updating. If the interface renders briefly in a system font on the first load after this update, that is the fonts arriving in the background as intended — it settles within a moment and does not recur.
---
**Full changelog:** [v2.13.2…v2.14.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.2...v2.14.0)
## 🎉 Wildcard Pipeline 2.13.2
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
+30 -9
View File
@@ -45,7 +45,7 @@ ruff check . # Python lint
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — the constraint's reach selector covers ZERO reachable downstream instances; per-selector, NOT count-aware — overlapping constraints may freely share an instance). Engine emits runtime warnings `constraint_never_applied` (the reach matched nothing at runtime) and `constraint_partial_reach` (a `next N` / `pick` reach matched fewer instances than requested).
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. Mode `only` (linked picks, schema v6) turns its row into an allow-list: when the source fires, a target with no rule of its own in that row is excluded (by value on an exception, by tag in a matrix row; a matrix `allow` cell is NOT a mention because editors show it as Neutral). No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
@@ -81,11 +81,19 @@ Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-
- `tolerant_drift_status TEXT` — `none` | `tolerant` | `strict`. Set by parseTolerantAsCurrentShape during install.
- `schema_migrated_at TEXT` — wall-clock stamp of last lazy migration step.
**Which number goes where** (three constants, one job each — don't collapse them):
- **`CURRENT_SCHEMA_VERSION` = 2** (`src/manager/import-export/migrations.ts`, mirrored in `engine/migrations/__init__.py`): head of the migration chain. Stays 2 because v3–v6 are additive and need no migrator.
- **`MAX_KNOWN_SCHEMA_VERSION` = 6** (same two files): the highest version this runtime reads + writes natively. Advertised to the community as `window.__wpcRuntime.schemaVersion` (`src/manager/main.ts`) and the ceiling above which `migrateImportEnvelope` refuses a pack.
- **Content stamp** (`schemaVersionForPayload` in `src/manager/import-export/single-row-publish.ts`): what publish sends — the LOWEST version the payload's features need (2 baseline, 3 SP2b range/`~` grammar, 4 non-default constraint `target_select`, 5 a wildcard `accepts` tag axis, 6 a constraint `only` rule).
A pack arriving from the community keeps the stamp the server stored for it (`CommunityDownload.schema_version`, fed to `wrapAsEngineExport` / the dependency downloader) so install migrates it from where it actually is. Never stamp a received pack with a constant. Local round-trips of live rows (e.g. `BundleEditor` extract) stamp `MAX_KNOWN_SCHEMA_VERSION`.
**The machine**:
- **Validator registry** (`src/manager/import-export/validators/`): one Zod validator per known `schema_version`. Strict at `≤ CURRENT_SCHEMA_VERSION`; future versions go through `parseTolerantAsCurrentShape` (additive-strip).
- **Validator registry** (`src/validators/index.ts:getValidator`): one Zod validator per shape-changing `schema_version` (v1, v2). v3–v6 didn't change a shape, so they validate against v2. Tolerant mode (`.strip()`) is for installing newer payloads (`parseTolerantAsCurrentShape`).
- **`parseTolerantAsCurrentShape(raw, currentVersion)`** (`src/manager/import-export/tolerant-parse.ts`): strips fields the current shape doesn't know, returns the projection that matches `currentVersion`. Used at install when source `schema_version > CURRENT`.
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migration-machine.ts`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migrations.ts`, beside the envelope-level `migrateImportEnvelope`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
- **`lazy_migrate_row(row, currentVersion, migrators, validators)`** (`engine/db/lazy_migrate.py`): reads `original_payload_json` (or `payload` if absent), walks the migrator chain to `currentVersion`, validates, writes back. Bulk-at-boot runner is eager iteration of this routine.
- **Install decision tree** (`src/manager/import-export/install.ts`): branches on `source_schema_version` vs `CURRENT_SCHEMA_VERSION`:
- `=`: strict validate + insert.
@@ -93,20 +101,30 @@ Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-
- `>` and tolerant-only diff: parseTolerantAsCurrentShape, mark `tolerant_drift_status='tolerant'`, insert.
- `>` and breaking-future diff (per catalog `is_breaking_from_previous` AND-fold across the interval): refuse install with a clear error.
**Server probe**: `engine-export-wrap.ts:ENGINE_SCHEMA_VERSION` is the sister's pinned CURRENT — it ships in the sister bundle, not fetched from server. The host bridge install path reads it from `window.__wpcRuntime.schemaVersion`. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's CURRENT.
**Server probe**: the host bridge advertises `MAX_KNOWN_SCHEMA_VERSION` as `window.__wpcRuntime.schemaVersion`; it ships in the sister bundle, not fetched from server. The community embed refuses to install a pack stamped above it. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's MAX_KNOWN.
### Bumping `schema_version` (the proper way)
When a payload shape change lands (not a row-column change — those don't bump schema):
1. **Add the new shape's validator.** New file under `src/manager/import-export/validators/v<N>.ts` exporting a Zod schema. Register in `validators/index.ts`.
2. **Add the migrator step.** In `src/manager/import-export/migration-machine.ts`, register `migrators[N-1 → N]: (payload) => ...`. Forward-only. The function is pure — no side effects.
3. **Add fixtures.** `src/manager/__tests__/import-export/fixtures/v<N>/` — at minimum: a `valid-strict.json`, a `from-v<N-1>-migrated.json` round-trip case, and one `breaking-shape.json` if the bump is breaking.
4. **Bump the constant.** `CURRENT_SCHEMA_VERSION` lives in the validator registry. Bump it to `N`. Also bump `ENGINE_SCHEMA_VERSION` in `src/manager/components/engine-export-wrap.ts`.
**Additive bump** (an optional field, new enum value or new text grammar an old runtime can safely lack — v3, v4, v5, v6 all were):
1. **Add the version constant** next to `TAG_AXES_SCHEMA_VERSION` / `CONSTRAINT_ONLY_SCHEMA_VERSION` in `migrations.ts` (and `engine/migrations/__init__.py`), with a comment saying what triggers it.
2. **Teach `schemaVersionForPayload`** AND its Python mirror `engine/migrations/stamping.py` to return it only when the payload actually uses the feature (structural walk, bundle children + instance overrides included), and add cases to `src/manager/__tests__/import-export/publish-stamping.test.ts` + `tests/engine/test_schema_stamping.py`.
3. **Bump `MAX_KNOWN_SCHEMA_VERSION`** in `migrations.ts` AND `engine/migrations/__init__.py`, and update the drift guard in `schema-version-fork.test.ts` to exercise the new highest stamp.
4. **Make sure the strict validator keeps the new field** (it strips unknown keys) — `src/validators/module/v2.ts` + the engine-parity test.
5. **Catalog row** — step 7 below. `CURRENT_SCHEMA_VERSION` does not move.
**Shape-changing bump** (needs a migrator):
1. **Add the new shape's validator.** New file under `src/validators/module/v<N>.ts` (and `bundle/` if needed). Register every subtype at `N` in `src/validators/index.ts`.
2. **Add the migrator step.** In `src/manager/import-export/migrations.ts`, add to `MIGRATION_CHAIN` (envelope) and/or `registerMigrator` (per row), and mirror it under `engine/migrations/`. Forward-only. The function is pure — no side effects.
3. **Add fixtures.** Under `src/validators/fixtures/v<N>/` — at minimum a strict-valid module per subtype, plus a migrated-from-v<N-1> round-trip case in `src/manager/__tests__/import-export/migration-machine.test.ts`.
4. **Bump the constants.** `CURRENT_SCHEMA_VERSION` and `MAX_KNOWN_SCHEMA_VERSION` to at least `N`, in both `migrations.ts` and `engine/migrations/__init__.py`.
5. **Write the migration test.** `tests/engine/db/test_lazy_migrate.py` should cover v<N-1>→v<N> round-trip + the breaking-future path if applicable.
6. **Run `pytest tests/engine/ -q && pnpm test` until green.** Sister pre-commit hook (~2 min) also runs lint + typecheck + build + size; expect that wait.
7. **Coordinate the community-side catalog row.** Community web's `schema_catalog` table gets the matching `(version, is_breaking_from_previous, notes)` row + a deploy that lands BEFORE sister's PR merges. See community CLAUDE.md "Bumping the schema" for the server side.
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's CURRENT, sister can safely refuse breaking-future shapes.
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's MAX_KNOWN, sister can safely publish the new stamp and refuse breaking-future shapes.
### Validator ↔ engine parity (DON'T skip)
@@ -122,6 +140,9 @@ this:
shape to `src/validators/fixtures/engine-parity/*.json`.
- `src/validators/__tests__/engine-parity.test.ts` asserts the **strict**
community validator accepts every one of those fixtures.
- `tests/test_engine_parity_fixtures.py` re-runs the dump in memory and fails
pytest when a committed fixture is stale, so forgetting the script is
caught in CI.
**Any time you change an engine module payload shape** (a `*_handler.py`
`validate_payload`, or what the editor stores), run
+21
View File
@@ -144,6 +144,27 @@ See [Nodes → WP Debug](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/w
- 💭 **[GitHub Discussions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions)** — long-form questions, ideas, design threads
- 🐛 **[Issue tracker](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)** — bug reports + feature requests
## Network access
This extension works entirely offline. It makes **one** outbound request, and only when you ask for it:
| What | When | Where to |
| --- | --- | --- |
| Booru tag list for the optional value autocomplete | Only when you click **Download** in Settings → Tag autocomplete | `github.com` → `objects.githubusercontent.com`, our own release asset |
Nothing is fetched at startup, on a schedule, or as a side effect of anything else. Delete the file and the feature simply turns off. The update check in Settings talks to the GitHub releases API on the same terms — you can switch it off, and it never runs without you.
Because ComfyUI serves its API without authentication, that download endpoint is deliberately built with nothing to steer:
- **The URL is a constant.** There is no URL parameter — the request body is ignored — so it cannot be pointed at your internal network or a cloud metadata address.
- **Redirects are checked at every hop** against a fixed host list, because GitHub serves release assets via a CDN. A redirect anywhere else is refused mid-chain.
- **The destination is computed server-side** from ComfyUI's user directory plus a fixed filename. No caller input reaches the path, and a symlink sitting at that path is refused rather than followed.
- **The response is size-capped while streaming**, so a hostile or corrupted reply cannot fill your disk. `Content-Length` is checked but never trusted alone.
- **One download at a time**, so the endpoint cannot be used to start many large fetches at once.
- **The file is parsed before it replaces anything.** A reply that is not a readable tag list leaves your existing one untouched, and the final move is atomic.
The reasoning is kept next to the code in [`wp_api/_tag_download.py`](wp_api/_tag_download.py), and each restriction has a test in [`tests/wp_api/test_tag_download.py`](tests/wp_api/test_tag_download.py).
## Status
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every branch push and PR.
+65 -1
View File
@@ -4,7 +4,14 @@ import os
import sys
# Tells ComfyUI to serve files from this folder under
# /extensions/<package-name>/ and auto-load any `*.js` it finds.
# /extensions/<package-name>/ and auto-load any `*.js` it finds. Only the
# entry `main.js` uses that extension; lazy chunks are `.mjs` so ComfyUI does
# not import them all up front (vite.config.mts).
#
# "any *.js it finds" is load-bearing: ComfyUI fetches EVERY file it lists
# here on page load, so a chunk left behind by an older version is not merely
# dead weight on disk — it is a request and a parse on every visit. Hence the
# prune below.
WEB_DIRECTORY = "./js"
# Expose engine/ and wp_nodes/ as top-level packages so internal absolute imports
@@ -22,6 +29,63 @@ if _PKG_DIR not in sys.path:
# ruff: noqa: E402 — imports must follow the sys.path shim above
from comfy_api.latest import ComfyExtension
# Remove chunks left by previous versions BEFORE ComfyUI scans WEB_DIRECTORY.
# Manager updates extract over the installed folder without deleting what the
# new version dropped, and our filenames are content-hashed, so every update
# leaves the previous build behind. Self-healing on the next start; see
# `wp_api/web_assets.py` for the measurements that motivated it.
#
# Wrapped because nothing here is worth failing a plugin load over — the
# fallback is the disk usage that already exists.
try:
from pathlib import Path as _Path
from wp_api.web_assets import prune_package_assets as _prune_package_assets
_prune_package_assets(_Path(_PKG_DIR))
except Exception: # noqa: BLE001 - never crash ComfyUI over housekeeping
import logging as _logging
_logging.getLogger(__name__).exception(
"wildcard-pipeline: stale-asset prune failed",
)
# Move our files into `<ComfyUI>/user/wildcard-pipeline/` if a previous version
# left them loose in the user directory.
#
# Placement is load-bearing: this must run BEFORE anything opens the database.
# SQLite keeps `-wal` and `-shm` sidecars beside the main file, and moving that
# set out from under an open connection corrupts it. Node imports below can
# reach the DB, so the migration goes above them.
#
# Wrapped for the same reason as the prune above: the fallback is "keep using
# the old location", which is strictly better than failing to load.
try:
from engine.db.relocate import migrate_user_data as _migrate_user_data
_report = _migrate_user_data()
if _report.did_anything:
import logging as _logging
_log = _logging.getLogger(__name__)
if _report.moved:
_log.info(
"wildcard-pipeline: moved into user/wildcard-pipeline/: %s",
", ".join(_report.moved),
)
# Skips and failures are warnings: in both cases a file the user cares
# about is not where the extension will now look for it.
for _note in _report.skipped:
_log.warning("wildcard-pipeline: left in place — %s", _note)
for _note in _report.failed:
_log.warning("wildcard-pipeline: could not move %s", _note)
except Exception: # noqa: BLE001 - never crash ComfyUI over housekeeping
import logging as _logging
_logging.getLogger(__name__).exception(
"wildcard-pipeline: user-data relocation failed",
)
from wp_nodes.assembler_node import WPPromptAssembler
from wp_nodes.context_loop import WPContextLoop
from wp_nodes.context_node import WPContext
+26
View File
@@ -21,6 +21,32 @@ class ContextInternals(TypedDict, total=False):
Context = dict[str, Any]
#: Engine tables the text resolver reads by KEY rather than as variables.
#: `strip_internals` still drops them — its result is the variable surface, and
#: anything enumerating it (the assembler's variables panel, debug views) would
#: list `$__wp_axes__` as a user variable. Callers that need accessor syntax
#: re-attach them to their own local dict via `with_resolver_tables`.
RESOLVER_TABLES = ("__wp_axes__", "__wp_picks__")
def with_resolver_tables(
stripped: dict[str, Any], source: dict[str, Any]
) -> dict[str, Any]:
"""Re-attach the accessor tables to an already-stripped ctx.
`$var.0` and `$var.AXIS` resolve against `__wp_picks__` / `__wp_axes__`,
which `strip_internals` removes along with every other `__` key. Without
them the assembler rendered both accessors as literal text while the same
template resolved one node upstream. The returned dict is for a resolver
call only — never for anything that enumerates variables.
"""
out = dict(stripped)
for key in RESOLVER_TABLES:
if key in source:
out[key] = source[key]
return out
def strip_internals(ctx: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of ``ctx`` with engine-only + user-flagged-internal keys removed.
+36 -3
View File
@@ -33,6 +33,17 @@ from pathlib import Path
DB_FILENAME = "wildcard-pipeline.db"
#: Our own folder under ComfyUI's user directory.
#:
#: Everything of ours that must survive an extension reinstall lives here: the
#: library database and the downloaded tag list, with room for whatever comes
#: next. They used to sit loose in `<ComfyUI>/user/` alongside ComfyUI's own
#: files, which was fine when there was one of them and stopped being fine at
#: two. One folder is also one thing to back up, and one thing to delete.
#:
#: `engine/db/relocate.py` moves pre-existing files in on first boot.
WP_DATA_DIRNAME = "wildcard-pipeline"
def _comfyui_user_dir_from_api() -> Path | None:
"""Use ComfyUI's `folder_paths.get_user_directory()` when the host
@@ -102,6 +113,28 @@ def _load_sidecar() -> dict:
return dict(load())
def comfyui_user_dir() -> Path | None:
"""ComfyUI's user directory, or ``None`` when it cannot be detected.
The same detector chain the ``user`` DB location uses, exposed because it
is not database-specific — anything of ours that must survive an extension
reinstall belongs under here rather than in the package folder.
"""
return _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
def wp_data_dir() -> Path | None:
"""Our folder inside ComfyUI's user directory, or ``None`` when the user
directory cannot be detected.
Does NOT create the directory — callers that write do that, so a read-only
probe (the Settings panel asking "where would this live?") never has the
side effect of making a folder the user did not ask for.
"""
base = comfyui_user_dir()
return (base / WP_DATA_DIRNAME) if base else None
def user_location_path() -> Path | None:
"""Return the ``user`` preference target path, or ``None`` if the
ComfyUI user dir can't be detected.
@@ -111,7 +144,7 @@ def user_location_path() -> Path | None:
list all three potential locations regardless of which one is
currently active (or whether one is detectable at all)."""
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
return (comfy_user / DB_FILENAME) if comfy_user else None
return (comfy_user / WP_DATA_DIRNAME / DB_FILENAME) if comfy_user else None
def global_location_path() -> Path:
@@ -153,14 +186,14 @@ def resolve_db_path_with_source() -> tuple[Path, str]:
if preference == "user":
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
if comfy_user is not None:
return comfy_user / DB_FILENAME, "user"
return comfy_user / WP_DATA_DIRNAME / DB_FILENAME, "user"
# Detection failed — fall through to the default chain rather
# than crash. Last-ditch fallback below will catch it.
# Default — same as explicit "user" preference.
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
if comfy_user is not None:
return comfy_user / DB_FILENAME, "user"
return comfy_user / WP_DATA_DIRNAME / DB_FILENAME, "user"
# Last-ditch fallback. The design folds the old "legacy" label into
# "global" because the path is identical and the UI has no separate
+103
View File
@@ -0,0 +1,103 @@
"""One-time move of our files from ``<ComfyUI>/user/`` into ``<user>/wildcard-pipeline/``.
The library database and the downloaded tag list used to sit loose in ComfyUI's
user directory. Existing installs have real data there, so introducing the
subfolder is only half the change — without this the extension would silently
come up with an empty library and no tag list, which reads as data loss even
though the files are still on disk.
Design constraints, in the order they mattered:
1. **Never overwrite.** If something already exists at the destination, the
legacy file is left exactly where it is and reported as skipped. A
destination file means either a completed migration or a deliberate
user-placed file; both outsrank anything we could infer.
2. **Run before the database is opened.** SQLite keeps ``-wal`` and ``-shm``
sidecars next to the main file, and moving the set out from under an open
connection corrupts it. ``__init__.py`` calls this at import, before routes
are registered and before anything connects.
3. **Move the sidecars with it.** A ``-wal`` left behind holds committed
transactions that have not been checkpointed into the main file yet; moving
the ``.db`` alone can lose the most recent writes.
4. **Never raise.** A failed migration must degrade to "extension starts with
the old path still in place", not to "ComfyUI fails to load the extension".
Every failure is reported in the return value for the caller to log.
"""
from __future__ import annotations
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from engine.db.connection import DB_FILENAME, comfyui_user_dir, wp_data_dir
#: Kept in sync with `wp_api/tags.py::TAG_FILE_NAME`. Duplicated rather than
#: imported because `engine/` must not import from `wp_api/` — the directory
#: contract in CLAUDE.md keeps the engine free of ComfyUI-facing modules, and
#: `wp_api` imports aiohttp.
TAG_FILE_NAME = "wildcard-pipeline-tags.csv"
#: SQLite's write-ahead-log sidecars. Moved with the database, never alone.
_DB_SIDECAR_SUFFIXES = ("-wal", "-shm")
@dataclass
class RelocationReport:
"""What happened, for the caller to log. Empty everywhere = nothing to do."""
moved: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list)
@property
def did_anything(self) -> bool:
return bool(self.moved or self.skipped or self.failed)
def _move_one(src: Path, dst: Path, report: RelocationReport) -> None:
"""Move ``src`` to ``dst`` if and only if ``src`` exists and ``dst`` does not."""
if not src.exists():
return
if dst.exists():
report.skipped.append(f"{src.name} (already present at destination)")
return
try:
dst.parent.mkdir(parents=True, exist_ok=True)
# `shutil.move` rather than `Path.rename`: the user directory and our
# subfolder are normally the same filesystem, but a user dir that is a
# mount or a symlink elsewhere would make rename fail with EXDEV.
shutil.move(str(src), str(dst))
report.moved.append(src.name)
except Exception as exc: # noqa: BLE001 - see module docstring, item 4
report.failed.append(f"{src.name}: {exc}")
def migrate_user_data() -> RelocationReport:
"""Move the database and tag list into ``<user>/wildcard-pipeline/``.
Idempotent: once the files are at the destination every subsequent call
finds nothing to move and returns an empty report. Safe to call on every
boot, which is exactly what happens.
"""
report = RelocationReport()
base = comfyui_user_dir()
target = wp_data_dir()
if base is None or target is None:
# No detectable user directory. `resolve_db_path_with_source` falls
# back to `~/.comfyui/` in this case, which was never inside the user
# dir, so there is nothing here to move.
return report
# Database first, sidecars immediately after it. Order matters only for
# readability of the report; the "never overwrite" rule makes each move
# independent.
_move_one(base / DB_FILENAME, target / DB_FILENAME, report)
for suffix in _DB_SIDECAR_SUFFIXES:
_move_one(
base / f"{DB_FILENAME}{suffix}",
target / f"{DB_FILENAME}{suffix}",
report,
)
_move_one(base / TAG_FILE_NAME, target / TAG_FILE_NAME, report)
return report
+75
View File
@@ -422,6 +422,57 @@ class ModuleRepository:
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_module(r) for r in rows]
def library_stamp(self) -> str:
"""A cheap value that changes whenever any module's payload changes.
Exists so the drift endpoint can answer "nothing changed" WITHOUT
building the hash map. That map costs a `json.loads` of every payload
in the library, and the drift poll asks for it every five seconds per
open tab — so the expensive part has to sit behind this, not in front
of it.
Three columns, each covering what the others miss:
- `COUNT(*)` — any insert or delete.
- `SUM(version)`— any payload edit; `update` does `version = version + 1`.
- `MAX(updated_at)` — backstop for a delete+insert that happened to
leave count and version-sum unchanged.
Note what deliberately does NOT move it: `set_community_origin` and the
favorite toggle write without bumping `version`, and neither changes a
`payload_hash`. A stamp that ignored them would be wrong; a stamp that
moved on them would just cost every client a pointless refetch.
"""
row = self._conn.execute(
"SELECT COUNT(*) AS n, COALESCE(SUM(version), 0) AS v, "
"COALESCE(MAX(updated_at), '') AS t FROM modules;"
).fetchone()
return f"{row['n']}:{row['v']}:{row['t']}"
def list_hash_rows(self) -> list[dict[str, Any]]:
"""`{id, type, payload_hash}` for every module — the drift map.
Deliberately not `list()`. That selects every column and runs each row
through `_row_to_module`, which parses `tags`, reads the community and
content-rating columns and builds a seventeen-key dict — all of it
discarded here. This selects the three columns the drift map needs.
The `payload` parse survives because `payload_hash` is computed, not
stored (unlike `bundles`, which has the column). Removing that too
means a schema migration; the stamp above makes it moot in the steady
state, which is the case that actually repeats.
"""
rows = self._conn.execute(
"SELECT id, type, payload FROM modules;"
).fetchall()
return [
{
"id": r["id"],
"type": r["type"],
"payload_hash": payload_hash(json.loads(r["payload"])),
}
for r in rows
]
def count(
self,
*,
@@ -813,6 +864,30 @@ class BundleRepository:
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_bundle(r) for r in rows]
def library_stamp(self) -> str:
"""Cheap change signal for the bundle drift poll.
See `ModuleRepository.library_stamp` — same three columns, same
reasoning."""
row = self._conn.execute(
"SELECT COUNT(*) AS n, COALESCE(SUM(version), 0) AS v, "
"COALESCE(MAX(updated_at), '') AS t FROM bundles;"
).fetchone()
return f"{row['n']}:{row['v']}:{row['t']}"
def list_hash_rows(self) -> list[dict[str, Any]]:
"""`{id, payload_hash}` for every bundle.
Unlike modules, `bundles.payload_hash` is a real stored column, so
this reads two columns and parses nothing at all. `list()` here would
have selected every column and run `_row_to_bundle` over each row,
which parses `tags` AND `children` per bundle for a map that wants
neither."""
rows = self._conn.execute(
"SELECT id, payload_hash FROM bundles;"
).fetchall()
return [{"id": r["id"], "payload_hash": r["payload_hash"]} for r in rows]
def count(
self,
*,
+10 -4
View File
@@ -33,7 +33,7 @@ from engine.db.repositories import (
TemplateNotFound,
TemplateRepository,
)
from engine.migrations import CURRENT_SCHEMA_VERSION
from engine.migrations.stamping import schema_version_for_payload
def _walk_inner_bundles(repo: BundleRepository, seed: list[str]) -> list[str]:
@@ -152,9 +152,7 @@ def build_export_payload(
except TemplateNotFound:
pass
return {
"schema_version": CURRENT_SCHEMA_VERSION,
"exported_at": now_iso(),
buckets: dict[str, list[dict[str, Any]]] = {
"bundles": bundles,
"wildcards": wildcards,
"fixed_values": fixed_values,
@@ -164,3 +162,11 @@ def build_export_payload(
"categories": categories,
"templates": templates,
}
# Stamp the lowest version the exported content needs (range grammar 3,
# constraint reach 4, accepts tag axes 5), not the migration-chain head:
# an older extension must refuse a file whose features it can't read.
return {
"schema_version": schema_version_for_payload(buckets),
"exported_at": now_iso(),
**buckets,
}
+15 -5
View File
@@ -16,16 +16,26 @@ CURRENT_SCHEMA_VERSION = 2
# Highest schema version this runtime can natively READ + WRITE — distinct
# from CURRENT_SCHEMA_VERSION (the migration-chain head, which stays 2 because
# v2->v3/v3->v4 are no-ops). v3 is the SP2b text-grammar bump (shape-identical
# to v2) and v4 is the additive `target_select` constraint reach; both are
# handled natively, so a payload at CURRENT < v <= MAX_KNOWN installs AS-IS
# with no migration. Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
# v2->v3 through v5->v6 are no-ops). v3 is the SP2b text-grammar bump (shape-
# identical to v2), v4 is the additive `target_select` constraint reach, v5
# is the additive `accepts` tag-axis kind and v6 the constraint `only` rule;
# all are handled natively, so a
# payload at CURRENT < v <= MAX_KNOWN installs AS-IS with no migration.
# Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
# `src/manager/import-export/migrations.ts`.
#
# MAINTENANCE CONTRACT: bump this whenever the TS `schemaVersionForPayload()`
# learns to stamp a new (higher) version — otherwise this commit-side
# re-validate will reject the very shapes the runtime just learned to produce.
MAX_KNOWN_SCHEMA_VERSION = 4
MAX_KNOWN_SCHEMA_VERSION = 6
# Feature stamps (mirror of the TS constants beside MAX_KNOWN). Each is the
# catalog version a payload needs once it uses that feature; see
# `engine/migrations/stamping.py:schema_version_for_payload`.
SP2B_SCHEMA_VERSION = 3 # range count / `~` flag in a `{N$$…}` multi-pick
SP3_REACH_SCHEMA_VERSION = 4 # non-default constraint `target_select`
TAG_AXES_SCHEMA_VERSION = 5 # wildcard `tag_group_kinds` with an `accepts` group
CONSTRAINT_ONLY_SCHEMA_VERSION = 6 # constraint rule with mode `only`
_CHAIN = {
0: migrate_v0_to_v1,
+151
View File
@@ -0,0 +1,151 @@
"""Content-driven ``schema_version`` stamping. Mirror of TS
``schemaVersionForPayload`` in ``src/manager/import-export/single-row-publish.ts``.
A payload is stamped with the LOWEST catalog version its features need, so
an older consumer can still install everything that doesn't use a newer
feature:
- ``CONSTRAINT_ONLY_SCHEMA_VERSION`` (6): a constraint matrix cell,
exception or instance mode override uses the ``only`` rule.
- ``TAG_AXES_SCHEMA_VERSION`` (5): a wildcard ``tag_group_kinds`` map marks
any group ``accepts``.
- ``SP3_REACH_SCHEMA_VERSION`` (4): a constraint carries a non-default
``target_select`` reach selector.
- ``SP2B_SCHEMA_VERSION`` (3): the payload text uses a range count or the
``~`` independent flag in a ``{N$$…}`` multi-pick.
- ``CURRENT_SCHEMA_VERSION`` (2) otherwise.
Each scan walks the whole structure (bundle children, instance overrides,
every export bucket), so the marker is found wherever it lives. Keep the two
sides in step: a new feature stamp lands in both files in the same PR.
"""
from __future__ import annotations
import json
import re
from typing import Any
from engine.migrations import (
CONSTRAINT_ONLY_SCHEMA_VERSION,
CURRENT_SCHEMA_VERSION,
SP2B_SCHEMA_VERSION,
SP3_REACH_SCHEMA_VERSION,
TAG_AXES_SCHEMA_VERSION,
)
# SP2b nested-multi-pick marker: a `{N$$…}` whose count is a range (`N-M`)
# or carries the `~` flag. A plain fixed-count `{N$$…}` predates SP2b.
_SP2B_MARKER_RE = re.compile(r"\{\d+(?:-\d+~?|~)\$\$")
def _is_number(value: Any) -> bool:
# JSON booleans come back as Python bools, which are ints; TS
# `typeof x === "number"` is false for them.
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _is_non_default_target_select(sel: Any) -> bool:
"""True when ``sel`` narrows reach. Only an absent selector or a bare
``{"mode": "all"}`` is the default; ``{"mode": "pick", "picks": []}``
still counts because a pre-SP3 consumer can't parse the mode at all."""
if not isinstance(sel, dict):
return False
mode = sel.get("mode")
if isinstance(mode, str) and mode != "all":
return True
if _is_number(sel.get("count")):
return True
picks = sel.get("picks")
return isinstance(picks, list) and len(picks) > 0
def uses_target_select_reach(node: Any) -> bool:
"""Any ``target_select`` at any depth holding a non-default selector."""
if isinstance(node, list):
return any(uses_target_select_reach(child) for child in node)
if not isinstance(node, dict):
return False
if _is_non_default_target_select(node.get("target_select")):
return True
return any(
isinstance(value, (dict, list)) and uses_target_select_reach(value)
for value in node.values()
)
def uses_accepts_tag_axis(node: Any) -> bool:
"""Any ``tag_group_kinds`` map at any depth marking a group ``accepts``."""
if isinstance(node, list):
return any(uses_accepts_tag_axis(child) for child in node)
if not isinstance(node, dict):
return False
kinds = node.get("tag_group_kinds")
if isinstance(kinds, dict) and any(k == "accepts" for k in kinds.values()):
return True
return any(
isinstance(value, (dict, list)) and uses_accepts_tag_axis(value)
for value in node.values()
)
_EXCEPTION_LIST_KEYS = ("exceptions", "extra_exceptions")
_MODE_OVERRIDE_KEYS = ("cell_mode_overrides", "exception_mode_overrides")
def _matrix_uses_only(matrix: Any) -> bool:
if not isinstance(matrix, dict):
return False
return any(
isinstance(row, dict) and any(
isinstance(cell, dict) and cell.get("mode") == "only"
for cell in row.values()
)
for row in matrix.values()
)
def uses_constraint_only_rule(node: Any) -> bool:
"""Any constraint rule at any depth whose mode is ``only``: a library
matrix cell or exception, an instance extra exception, or an instance mode
override (a string map)."""
if isinstance(node, list):
return any(uses_constraint_only_rule(child) for child in node)
if not isinstance(node, dict):
return False
if _matrix_uses_only(node.get("matrix")):
return True
for key in _EXCEPTION_LIST_KEYS:
excs = node.get(key)
if isinstance(excs, list) and any(
isinstance(e, dict) and e.get("mode") == "only" for e in excs
):
return True
for key in _MODE_OVERRIDE_KEYS:
overrides = node.get(key)
if isinstance(overrides, dict) and any(
m == "only" for m in overrides.values()
):
return True
return any(
isinstance(value, (dict, list)) and uses_constraint_only_rule(value)
for value in node.values()
)
def uses_sp2b_grammar(node: Any) -> bool:
"""Range-count or ``~`` multi-pick anywhere in the serialised payload."""
text = json.dumps(node, ensure_ascii=False, default=str)
return _SP2B_MARKER_RE.search(text) is not None
def schema_version_for_payload(payload: Any) -> int:
"""The lowest catalog version that covers every feature in ``payload``."""
if uses_constraint_only_rule(payload):
return CONSTRAINT_ONLY_SCHEMA_VERSION
if uses_accepts_tag_axis(payload):
return TAG_AXES_SCHEMA_VERSION
if uses_target_select_reach(payload):
return SP3_REACH_SCHEMA_VERSION
if uses_sp2b_grammar(payload):
return SP2B_SCHEMA_VERSION
return CURRENT_SCHEMA_VERSION
+47
View File
@@ -47,6 +47,46 @@ class _RuntimeResolveContext:
# occurrence matches on. None outside any carrier option.
_carrier_uid: str | None = None
_carrier_option_id: str | None = None
# Rolled `accepts`-axis choices, keyed by VARIABLE BINDING (the namespace
# `$outfit.SHOES` addresses) rather than module uuid like `_picks`. A
# projection of `ctx["__wp_axes__"]`, built once here so the resolver stays
# a pure consumer with no ctx dependency.
_axes: dict[str, Any] = field(default_factory=dict)
# Accepts axes each binding's wildcard declares (`ctx["__wp_axis_decl__"]`),
# so an empty axis read can say whether the axis is missing or the pick
# simply carried no tag on it.
_axis_decl: dict[str, Any] = field(default_factory=dict)
def get_axis(self, name: str, axis: str) -> Any:
"""Rolled axis choice(s) for a binding, or None when it has none.
Returns a plain string for a single-pick source and a list (one entry
per pick, in pick order) for a multi-select one, mirroring the shape of
the variable itself. Shaping and the empty/warn decision belong to the
resolver; this only fetches.
A binding that rolled no axes at all, and a binding that rolled some
but not THIS one, both yield None — the caller cannot act differently
on those two, and collapsing them keeps the read a single lookup.
"""
entry = self._axes.get(name)
if isinstance(entry, list):
# Positional: one slot per pick, None where that pick's option
# carries no tag on this axis. Compacting the gaps away shifted
# every later pick down, so `$outfit.1.SHOES` read pick 2's shoe
# beside `$outfit.1`.
got = [e.get(axis) if isinstance(e, dict) else None for e in entry]
return got if any(g is not None for g in got) else None
if isinstance(entry, dict):
return entry.get(axis)
return None
def declared_axes(self, name: str) -> list[str] | None:
"""Accepts axes the wildcard bound to `name` declares, or None when
that is unknown (nothing recorded, e.g. a ctx from before this table
existed or a binding no wildcard wrote)."""
got = self._axis_decl.get(name)
return [str(a) for a in got] if isinstance(got, list) else None
def get_var(self, name: str) -> str | None:
# SP2a: return the raw stored value (may be a ListVar from a
@@ -132,6 +172,8 @@ def build_resolve_ctx(
"""
constraints = ctx.get("__wp_constraints__")
picks = ctx.get("__wp_picks__")
axes = ctx.get("__wp_axes__")
axis_decl = ctx.get("__wp_axis_decl__")
hits = ctx.setdefault("__wp_constraint_hits__", {})
return _RuntimeResolveContext( # type: ignore[return-value]
rng=ctx["__wp_rng__"],
@@ -153,6 +195,11 @@ def build_resolve_ctx(
# resolver's increments stick in ctx + share one counter with
# the direct path for first/next coverage.
_hits=hits if isinstance(hits, dict) else {},
# Same treatment as `_vars`: a projection built at frame construction,
# so `get_axis` is a dict lookup and the resolver never reaches into
# ctx.
_axes=axes if isinstance(axes, dict) else {},
_axis_decl=axis_decl if isinstance(axis_decl, dict) else {},
)
+165 -15
View File
@@ -3,14 +3,53 @@
One multiply operator at three levels: across an option's matching matrix
cells (multi-tag), across source picks (multi-pick), and — by the caller —
across constraints. `exclude` (factor 0) is the absorbing element.
**One exception, added 2026-08 (tag axes).** Tags belonging to a source group
the wildcard declared `accepts` are ALTERNATIVES the option offers, not
statements that are all true at once. Those fold with `max`, so one sibling's
`exclude` cannot speak for the rest. Everything else still multiplies, and the
axis path is unreachable unless the caller passes `axis_kinds` — so a payload
that predates the feature folds byte-identically.
**`only` (2026-09, linked picks).** A rule that turns its row into an
allow-list: when the source fires, every target WITHOUT a rule of its own in
that row is excluded. On an exception it gates by literal value — any target
value with no exception for that source value drops out. In a matrix row it
gates by tag — an option carrying no tag that has a rule in the row drops out.
The `only` rule itself weighs 1.0 (like `allow`), and a target that has any
other rule in the row keeps that rule, so `only` shuts out the unmentioned and
leaves the mentioned alone. In a matrix row an `allow` cell does NOT count as
a mention: the editors show `allow` as the empty "neutral" state, so counting
it would let an invisible cell through. An `allow` exception is a visible row
in the exceptions list, so it does count. A payload with no `only` rule never reaches either
gate, so it folds byte-identically.
"""
from __future__ import annotations
from typing import Any
EXCLUDE = object() # sentinel: option drops out of the pool
def _apply_rule(rule: dict[str, Any]):
class _ExcludeSentinel:
"""Absorbing zero: the option drops out of the pool entirely.
A distinct class rather than a bare ``object()`` so a type checker can
narrow ``float | _ExcludeSentinel`` after an isinstance test. With the bare
sentinel every ``factor *= r`` was unprovable, because ``is EXCLUDE`` does
not narrow an ``object``.
"""
__slots__ = ()
def __repr__(self) -> str: # pragma: no cover - debug aid only
return "EXCLUDE"
EXCLUDE = _ExcludeSentinel() # sentinel: option drops out of the pool
_Factor = float | _ExcludeSentinel
def _apply_rule(rule: dict[str, Any]) -> _Factor:
mode = rule.get("mode")
if mode == "exclude":
return EXCLUDE
@@ -19,10 +58,87 @@ def _apply_rule(rule: dict[str, Any]):
return max(0.0, float(rule.get("factor", 1.0)))
except (TypeError, ValueError):
return 1.0
return 1.0 # allow / unknown -> no weight change
return 1.0 # allow / only / unknown -> no weight change
def combine_constraint_factor(source_picks, option, matrix, exceptions):
def _row_has_only(row: dict[str, Any]) -> bool:
return any(isinstance(r, dict) and r.get("mode") == "only" for r in row.values())
def _cell_factor(
matrix: dict[str, Any],
source_tag: str,
opt_tags: list[str],
target_axes: dict[str, list[str]] | None = None,
) -> _Factor:
"""Fold one source tag against every tag on the option.
Classify target tags fold with PRODUCT — an option sitting on several rows
of the same source tag is genuinely several separate statements about it.
Tags belonging to a target ``accepts`` axis (``target_axes``: group →
members) fold with MAX instead: the option OFFERS those as ALTERNATIVES, so
it stays viable when the source allows ANY of them (the mirror of the
source-side accepts fold — the target's own roll is then restricted to the
allowed members by the caller). A no-rule target tag is neutral (1.0).
EXCLUDE is absorbing for a classify tag and for a fully-excluded axis; the
``target_axes`` path is unreachable unless the caller passes it, so a legacy
call folds byte-identically.
"""
row = matrix.get(source_tag)
if not isinstance(row, dict):
return 1.0
# `only` makes the row an allow-list: an option carrying no tag that the
# row mentions is shut out. Inside an accepts axis an unmentioned member
# then offers nothing (0), rather than the usual neutral 1.0. A neutral
# (`allow`) cell is not a mention — see the module docstring.
has_only = _row_has_only(row)
if has_only:
row = {
t: r for t, r in row.items()
if isinstance(r, dict) and r.get("mode") != "allow"
}
if not any(t in row for t in opt_tags):
return EXCLUDE
unruled = 0.0 if has_only else 1.0
target_axes = target_axes or {}
axis_of = {t: ax for ax, members in target_axes.items() for t in (members or [])}
factor = 1.0
axis_members: dict[str, list[str]] = {}
for t in opt_tags:
ax = axis_of.get(t)
if ax is None:
rule = row.get(t)
if isinstance(rule, dict):
r = _apply_rule(rule)
if isinstance(r, _ExcludeSentinel):
return EXCLUDE
factor *= r
else:
axis_members.setdefault(ax, []).append(t)
for members in axis_members.values():
best = 0.0
for t in members:
rule = row.get(t)
r = _apply_rule(rule) if isinstance(rule, dict) else unruled
best = max(best, 0.0 if isinstance(r, _ExcludeSentinel) else r)
# Every alternative this axis offered was excluded → the option drops.
if best <= 0.0:
return EXCLUDE
factor *= best
return factor
def combine_constraint_factor(
source_picks: list[dict[str, Any]] | None,
option: dict[str, Any],
matrix: dict[str, Any] | None,
exceptions: list[Any] | None,
axis_kinds: dict[str, str] | None = None,
target_axes: dict[str, list[str]] | None = None,
) -> _Factor:
matrix = matrix or {}
axis_kinds = axis_kinds or {}
target_axes = target_axes or {}
exc_by_pair: dict[tuple[str, str], dict] = {}
for e in (exceptions or []):
if not isinstance(e, dict):
@@ -35,6 +151,12 @@ def combine_constraint_factor(source_picks, option, matrix, exceptions):
t = e.get("target")
if isinstance(s, str) and isinstance(t, str):
exc_by_pair[(s, t)] = e
# Source values whose exceptions include an `only`: for those, a target
# value with no exception of its own is excluded. Read off the de-duplicated
# pair map so it agrees with the rule that actually applies to each pair.
only_sources = {
s for (s, _t), e in exc_by_pair.items() if e.get("mode") == "only"
}
opt_value = str(option.get("value", ""))
opt_tags = option.get("tags") or []
factor = 1.0
@@ -44,19 +166,47 @@ def combine_constraint_factor(source_picks, option, matrix, exceptions):
exc = exc_by_pair.get((p_value, opt_value))
if exc is not None:
r = _apply_rule(exc)
if r is EXCLUDE:
if isinstance(r, _ExcludeSentinel):
return EXCLUDE
factor *= r
continue
for s in p_tags:
row = matrix.get(s)
if not isinstance(row, dict):
if p_value in only_sources:
return EXCLUDE
# Partition this pick's tags: those inside an `accepts` axis fold with
# max, the rest keep multiplying. A pick only carries `axes` when the
# source wildcard declared the group `accepts`, and `axis_kinds` gates
# it a second time, so a legacy pick takes the flat path untouched.
pick_axes = pick.get("axes") or {}
accepts: dict[str, list[str]] = {}
claimed: set[str] = set()
for axis, members in pick_axes.items():
if axis_kinds.get(axis) != "accepts":
continue
for t in opt_tags:
rule = row.get(t)
if isinstance(rule, dict):
r = _apply_rule(rule)
if r is EXCLUDE:
return EXCLUDE
factor *= r
in_axis = [t for t in p_tags if t in (members or [])]
if in_axis:
accepts[axis] = in_axis
claimed.update(in_axis)
for s in p_tags:
if s in claimed:
continue
r = _cell_factor(matrix, s, opt_tags, target_axes)
if isinstance(r, _ExcludeSentinel):
return EXCLUDE
factor *= r
for members in accepts.values():
best = 0.0
for s in members:
r = _cell_factor(matrix, s, opt_tags, target_axes)
# One member's EXCLUDE is that member declining, not the axis
# declining — it contributes 0 to the max and the siblings
# still get their say.
best = max(best, 0.0 if isinstance(r, _ExcludeSentinel) else r)
# Every alternative the option offered was excluded, so the option
# really does drop out.
if best <= 0.0:
return EXCLUDE
factor *= best
return factor
+17 -6
View File
@@ -132,6 +132,8 @@ def apply_constraints_for_target(
hits=None,
firing_uid=None,
carrier_ctx=None,
applied_out=None,
target_axes=None,
) -> tuple[list[dict[str, Any]], bool]:
"""Apply EVERY constraint whose reach selector covers this firing
target instance, combined sequentially.
@@ -219,9 +221,18 @@ def apply_constraints_for_target(
continue
adjust_warnings: list[dict[str, Any]] = []
options = _apply_constraint_to_options(
options, c, src_pick, adjust_warnings,
options, c, src_pick, adjust_warnings, target_axes,
)
any_applied = True
# Record the (constraint, resolved source pick) pairs that actually
# covered THIS firing target instance. The caller (wildcard_handler)
# uses them to restrict the target's OWN `accepts`-axis roll to tags
# these same constraints don't exclude — so `$source.AXIS` and
# `$target.AXIS` agree under a diagonal. Only covered constraints land
# here, so a selector-limited constraint (`first`/`next`/`pick`) that
# skipped this instance also leaves the axis roll alone.
if applied_out is not None:
applied_out.append((c, src_pick))
for w in adjust_warnings:
_push_constraint_warning(
warnings,
@@ -242,10 +253,10 @@ def warn_excludes_all(
warnings: list[dict[str, Any]],
) -> None:
"""Emit the ``constraint_excludes_all_options`` warning when post-
application weights sum to zero. ``_pick_weighted`` falls back to
``options[0]`` silently — without this warning the user sees the
same option forever with no signal that constraints over-narrowed
the pool.
application weights sum to zero. ``_pick_weighted`` returns None for
such a pool and the wildcard binds an empty string, so without this
warning an over-narrowed pool is indistinguishable from a wildcard
that legitimately rolled its null option.
Call AFTER ``apply_constraints_for_target`` returned ``any_applied``.
"""
@@ -259,6 +270,6 @@ def warn_excludes_all(
target_uuid=target_uuid,
detail={
"target_wildcard_id": target_uuid,
"hint": "every option was excluded; falling back to options[0]",
"hint": "every option was excluded; the target emits nothing",
},
)
+87 -3
View File
@@ -22,7 +22,9 @@ from typing import Any
from engine.modules._keys import decode_key, encode_key
from engine.modules.dispatcher import ModuleHandler
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
# `only` (2026-09, linked picks) turns its row into an allow-list — see
# `_constraint_math`. Additive: a payload using it stamps schema_version 6.
_VALID_MODES = {"allow", "exclude", "boost", "reduce", "only"}
# SP3 reach selector — which downstream target instance(s) a constraint
# reaches. Shape only here; reach *behaviour* is applied by a later task.
@@ -142,6 +144,14 @@ def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
})
def _override_mode(mode: Any) -> Any:
"""Canvas matrix builds before 2026-09 stored a neutral cell override as
``"neutral"`` (the editor's name) instead of ``"allow"`` (the engine's),
which failed the whole constraint at run time. Read it as ``allow`` so
workflows saved by those builds run."""
return "allow" if mode == "neutral" else mode
def _validate_cell(cell: Any, where: str) -> None:
if not isinstance(cell, dict):
raise ValueError(f"constraint {where} must be an object")
@@ -163,6 +173,55 @@ def _validate_cell(cell: Any, where: str) -> None:
raise ValueError(f"constraint {where}.factor must not be negative")
def exception_pair(exc: dict[str, Any]) -> tuple[str, str] | None:
"""The `(source, target)` key an exception is filed under, or None.
MUST match `_constraint_math.combine_constraint_factor`'s key resolution
exactly — tier-2 `source_value`/`target_value` first, legacy
`source`/`target` as fallback. If the two ever disagree, the duplicate
check below stops describing what the engine actually does.
"""
source = exc.get("source_value")
if source is None:
source = exc.get("source")
target = exc.get("target_value")
if target is None:
target = exc.get("target")
if isinstance(source, str) and isinstance(target, str):
return (source, target)
return None
def find_duplicate_exception_pairs(payload: dict[str, Any]) -> list[tuple[str, str]]:
"""`(source, target)` pairs appearing more than once, in first-seen order.
Two exceptions on the same pair cannot both apply. `combine_constraint_factor`
files them into a dict keyed by the pair, so the LAST one in the list wins
and every earlier one is discarded silently — and that includes discarding
an `exclude`, which the documented model says is absorbing:
exceptions [exclude, boost x3] -> factor 3.0 (exclusion defeated)
exceptions [boost x3, exclude] -> EXCLUDE
exceptions [boost x3, reduce x0.5] -> 0.5 (not the combined 1.5)
So the result depends on list order, which no surface shows and nothing
lets you edit. Reported by the maintainer 2026-08-06 after noticing the
editor would happily accept contradictory duplicates.
"""
seen: set[tuple[str, str]] = set()
duplicates: list[tuple[str, str]] = []
for exc in payload.get("exceptions") or []:
if not isinstance(exc, dict):
continue
pair = exception_pair(exc)
if pair is None:
continue
if pair in seen and pair not in duplicates:
duplicates.append(pair)
seen.add(pair)
return duplicates
class ConstraintHandler(ModuleHandler):
"""Records a constraint matrix into the context for downstream consumers."""
@@ -236,6 +295,31 @@ class ConstraintHandler(ModuleHandler):
)
_validate_target_select(payload.get("target_select"))
@classmethod
def validate_authoring(cls, payload: dict[str, Any]) -> None:
"""Extra rules applied when a payload is SAVED or IMPORTED, not when
it is executed.
Deliberately separate from `validate_payload`, which `resolve` calls on
every run. A rule enforced there would turn an existing bad payload
into a hard failure mid-graph — strictly worse than the silent
misbehaviour it replaces, and it would fire on data the user cannot
reach to fix without the graph running. Enforcing at the authoring
boundary stops new ones being created (from the editor, the API, or an
import) while leaving existing rows executable.
"""
duplicates = find_duplicate_exception_pairs(payload)
if duplicates:
listed = ", ".join(
f"({source!r} -> {target!r})" for source, target in duplicates
)
raise ValueError(
"constraint payload.exceptions contains more than one rule for "
f"the same source/target pair: {listed}. Only one can apply — "
"the engine keeps whichever comes last — so keep the rule you "
"want and delete the rest."
)
@classmethod
def resolve(
cls,
@@ -288,7 +372,7 @@ class ConstraintHandler(ModuleHandler):
# value across resolves.
merged = dict(cell)
if key in cell_mode_overrides:
mode = cell_mode_overrides[key]
mode = _override_mode(cell_mode_overrides[key])
if mode not in _VALID_MODES:
raise ValueError(
f"constraint cell_mode_overrides[{key!r}] must be one of "
@@ -328,7 +412,7 @@ class ConstraintHandler(ModuleHandler):
if len(parts) != 2:
continue
src, tgt = parts[0], parts[1]
mode = cell_mode_overrides.get(key, "allow")
mode = _override_mode(cell_mode_overrides.get(key, "allow"))
if mode not in _VALID_MODES:
raise ValueError(
f"constraint cell_mode_overrides[{key!r}] must be one of "
+56 -6
View File
@@ -19,7 +19,11 @@ from engine.modules import build_resolve_ctx
from engine.modules._seed import derive_module_rng
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
from engine.syntax.types import deref_var_value, split_var_accessor
from engine.syntax.types import (
deref_var_value,
parse_var_reference,
split_var_accessor,
)
_VALID_OPS = {
"equals", "not_equals", "contains", "matches",
@@ -56,11 +60,43 @@ def _ctx_get_raw(ctx: Any, name: str) -> Any:
return None
def _ctx_axis(ctx: Any, base: str, axis: str, index: int | None) -> str:
"""Read a rolled axis choice out of `ctx["__wp_axes__"]`.
Mirrors the resolver's `_resolve_axis` shaping so a condition and a
template agree on what `$outfit.SHOES` means. Missing yields "" rather than
raising: derivations are control flow, so an unanswerable condition is
simply false.
"""
if not isinstance(ctx, dict):
return ""
entry = (ctx.get("__wp_axes__") or {}).get(base)
if isinstance(entry, list):
# Positional, like the resolver: a pick with no tag on this axis keeps
# its slot so `.K` lines up with `$outfit.K`.
vals = [
(e.get(axis) or "") if isinstance(e, dict) else "" for e in entry
]
if index is None:
return ", ".join(v for v in vals if v)
return vals[index] if 0 <= index < len(vals) else ""
if isinstance(entry, dict):
got = entry.get(axis, "")
# A single pick behaves as a one-element list, as everywhere else.
if index is not None:
return got if index == 0 else ""
return got
return ""
def _ctx_get(ctx: Any, name: str) -> str:
"""Read `name` as a string, honoring a `.K` list accessor and folding a
ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` +
index 0. The fold/accessor contract lives in deref_var_value."""
base, index = split_var_accessor(name)
"""Read `name` as a string, honoring a `.K` list accessor, a `.AXIS` read
(2026-08 tag axes), and folding a ListVar to its joined value (SP2a):
`$mood.0` splits to base `mood` + index 0. The fold/accessor contract lives
in deref_var_value; the axis contract in `_ctx_axis`."""
base, index, axis = parse_var_reference(name)
if axis:
return _ctx_axis(ctx, base, axis, index)
return deref_var_value(_ctx_get_raw(ctx, base), index)
@@ -76,7 +112,21 @@ def _ctx_has(ctx: Any, name: str) -> bool:
"""
if ctx is None:
return False
name = split_var_accessor(name)[0] # SP2a: presence checks the base var
base, _index, axis = parse_var_reference(name)
if axis:
# An axis is present when the binding actually rolled it. Checked
# against the axes bucket, not ctx: `$outfit.SHOES` can exist while
# `outfit` is unbound, and vice versa.
entry = (
(ctx.get("__wp_axes__") or {}).get(base)
if isinstance(ctx, dict) else None
)
if isinstance(entry, dict):
return axis in entry
if isinstance(entry, list):
return any(isinstance(e, dict) and axis in e for e in entry)
return False
name = base # SP2a: presence checks the base var
# Engine ctx is a dict in tests + at runtime; check `__contains__`
# before falling back to a getter probe so we get the cheap path.
try:
+311 -19
View File
@@ -64,7 +64,13 @@ def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
return None
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
if total <= 0:
return options[0]
# Nothing is selectable — every option is either weight 0 (documented
# as "disable without deleting") or was excluded by a constraint.
# Returning options[0] here looked like a working pick: a real option,
# right shape, right place, and wrong. It stayed wrong silently until
# the list was reordered. Callers turn None into an empty binding,
# which is what the null option already means.
return None
r = rng.random() * total
acc = 0.0
for opt in options:
@@ -79,6 +85,7 @@ def _apply_constraint_to_options(
constraint: dict[str, Any],
source_pick: dict[str, Any],
adjustment_warnings: list[dict[str, Any]] | None = None,
target_axes: dict[str, list[str]] | None = None,
) -> list[dict[str, Any]]:
"""Adjust option weights according to a single constraint, given the
already-picked source option(s).
@@ -107,7 +114,7 @@ def _apply_constraint_to_options(
combine fn treats as a no-op.
These are diagnostic only; they never change the computed weight.
"""
from engine.modules._constraint_math import EXCLUDE, combine_constraint_factor
from engine.modules._constraint_math import combine_constraint_factor
matrix = constraint.get("matrix") or {}
exceptions = constraint.get("exceptions") or []
@@ -131,17 +138,33 @@ def _apply_constraint_to_options(
picks, options, matrix, exceptions, adjustment_warnings,
)
# A pick only carries an `axes` entry when the source declared that group
# `accepts` (see `_axis_menus`), so the pick record IS the kind map. This
# keeps `combine_constraint_factor` pure — it never looks up a payload —
# and a legacy pick with no `axes` yields an empty map, which takes the
# unchanged flat-product path.
axis_kinds = {
axis: "accepts"
for p in picks
for axis in (p.get("axes") or {})
}
adjusted: list[dict[str, Any]] = []
for opt in options:
option = {"value": opt.get("value", ""), "tags": opt.get("sub_categories") or []}
f = combine_constraint_factor(picks, option, matrix, exceptions)
f = combine_constraint_factor(
picks, option, matrix, exceptions, axis_kinds, target_axes,
)
# `isinstance(f, float)` rather than `f is not EXCLUDE`: identity
# against a sentinel does not narrow the union, so the multiply below
# was unprovable. Same test, and the type checker can follow it.
weight = float(opt.get("weight", 1))
weight = 0.0 if f is EXCLUDE else max(0.0, weight * float(f))
weight = max(0.0, weight * f) if isinstance(f, float) else 0.0
adjusted.append({**opt, "weight": weight})
return adjusted
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce"})
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce", "only"})
def _exc_pair_index(exceptions: list[Any]) -> dict[tuple[str, str], dict[str, Any]]:
@@ -205,8 +228,8 @@ def _warn_for_rule(
adjustment_warnings: list[dict[str, Any]],
) -> None:
mode = rule.get("mode")
if mode == "allow":
# `allow` is a weight no-op; a non-1 factor is almost certainly a
if mode in ("allow", "only"):
# `allow` / `only` weigh 1.0; a non-1 factor is almost certainly a
# user expecting weight×factor and silently getting full weight.
try:
factor = float(rule.get("factor", 1.0))
@@ -257,7 +280,175 @@ def _file_pick_by_origin(
entry["by_origin"] = by_origin
def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
def _axis_menus(
payload: dict[str, Any], tags: list[str] | None
) -> dict[str, list[str]]:
"""The option's tags, grouped by `accepts` axis, in registry order.
Only `accepts` groups appear — a `classify` group's tags stay in the flat
bag where the constraint fold multiplies them, which is today's behaviour.
An axis the option says nothing about is omitted rather than mapped to an
empty list, so callers can treat presence as meaning.
"""
kinds = payload.get("tag_group_kinds") or {}
groups = payload.get("tag_groups") or {}
have = set(tags or [])
out: dict[str, list[str]] = {}
for axis, members in groups.items():
if kinds.get(axis) != "accepts":
continue
in_axis = [m for m in (members or []) if m in have]
if in_axis:
out[axis] = in_axis
return out
def _accepts_axes(payload: dict[str, Any]) -> dict[str, list[str]]:
"""Every ``accepts`` axis on this payload as group → full member list
(independent of any option). Fed to the constraint fold as the TARGET side:
a target option's tags inside one of these groups are ALTERNATIVES, so the
fold keeps the option viable when the source allows any of them (symmetric
to the source's rolled-winner collapse), and the target's own roll is later
restricted to the allowed members. Empty ⇒ the fold takes the flat product
path, i.e. today's behaviour.
"""
kinds = payload.get("tag_group_kinds") or {}
groups = payload.get("tag_groups") or {}
return {
g: list(members or [])
for g, members in groups.items()
if kinds.get(g) == "accepts"
}
def _roll_axes(menus: dict[str, list[str]], rng) -> dict[str, str]:
"""One winner per axis, uniform over the option's own menu.
Uniform rather than weighted: option `weight` describes how often the
OPTION is chosen, and nothing in the payload expresses a preference between
an option's accepted tags. Inventing one would be a guess.
Axis order is the payload's group order, so the draw sequence is stable
across runs for a given seed.
"""
return {
axis: members[int(rng.random() * len(members))]
for axis, members in menus.items()
if members
}
def _pick_record_for_constraint(
chosen: dict[str, Any], payload: dict[str, Any], rolled: dict[str, str] | None
) -> dict[str, Any]:
"""The per-pick record a constraint reads. For an ``accepts`` axis the
option offers a menu but rolls ONE winner; the constraint keys on that
single winner (the value ``$var.AXIS`` exposes), NOT the whole menu —
otherwise a diagonal narrows the target to the entire accepted set instead
of the rolled tag, and ``$source.AXIS`` disagrees with the picked target
(the reported "picks are not good"). So each accepts axis collapses to its
winner: the axis members leave the flat ``tags`` bag and the winner is added
back, and ``axes`` carries only the winner. Classify tags are untouched
(they still multiply). An option with no accepts axis produces the same
record as before the feature.
"""
sub = list(chosen.get("sub_categories") or [])
menus = _axis_menus(payload, sub)
rolled = rolled or {}
axis_members = {m for members in menus.values() for m in members}
winners = [rolled[a] for a in menus if a in rolled]
tags = [t for t in sub if t not in axis_members] + winners
axes = {a: [rolled[a]] for a in menus if a in rolled}
return {"value": chosen.get("value", ""), "tags": tags, "axes": axes}
def _restrict_menus_by_constraints(
menus: dict[str, list[str]],
applied_constraints: list[tuple[dict[str, Any], dict[str, Any]]] | None,
) -> dict[str, list[str]]:
"""Drop from each accepts-axis menu the tags a FIRED constraint excludes, so
the target's own roll can only land on a constraint-allowed tag.
Under a diagonal from a source that rolled ``sandals``, the target's
``{sandals, heels}`` option can no longer roll ``heels`` — so
``$source.SHOES`` and ``$target.SHOES`` agree. A tag survives iff EVERY
fired constraint gives a single-tag option ``{tags: [t]}`` a positive
factor. Never empties an axis: if every member is excluded the menu is left
intact (the option itself survived the pool narrowing, so its axis must
still roll something) — reachable only with a pathological matrix.
"""
if not applied_constraints or not menus:
return menus
from engine.modules._constraint_math import combine_constraint_factor
out: dict[str, list[str]] = {}
for axis, members in menus.items():
allowed: list[str] = []
for t in members:
ok = True
for c, src_pick in applied_constraints:
s_picks = src_pick.get("picks")
if not isinstance(s_picks, list):
s_picks = [{
"value": src_pick.get("value", ""),
"tags": list(src_pick.get("sub_categories") or []),
}]
axis_kinds = {
ax: "accepts"
for p in s_picks for ax in (p.get("axes") or {})
}
# The probe is a bare tag with no real value, so a
# value-level `only` would shut it out and leave the menu
# unrestricted. The option itself already passed that gate
# in the pool, so only the tag rules matter here.
probe_excs = [
e for e in (c.get("exceptions") or [])
if not (isinstance(e, dict) and e.get("mode") == "only")
]
f = combine_constraint_factor(
s_picks, {"value": "", "tags": [t]},
c.get("matrix") or {}, probe_excs, axis_kinds,
)
if not (isinstance(f, float) and f > 0.0):
ok = False
break
if ok:
allowed.append(t)
out[axis] = allowed if allowed else members
return out
def _record_axes(
ctx: Any, binding: str, rolled: Any, payload: dict[str, Any]
) -> None:
"""Publish the rolled axis choices under the VARIABLE binding.
Deliberately not on the pick record: `$outfit.SHOES` addresses by binding,
while `__wp_picks__` is keyed by module uuid. Keying each consumer the way
it addresses things means neither has to translate, and the axis read never
inherits the uuid-bucket collision (task_5200c1fc) that only reaches
consumers binding by uuid.
"""
if not isinstance(ctx, dict) or not binding:
return
bucket = ctx.setdefault("__wp_axes__", {})
if isinstance(bucket, dict):
bucket[binding] = rolled
# Which accepts axes this binding's wildcard DECLARES, whatever the pick
# rolled. `__wp_axes__` alone cannot tell "the picked option has no SHOES
# tag" from "there is no SHOES axis", and the resolver's warning needs to
# say which one happened. Always written (even empty) so a later wildcard
# rebinding the same name replaces the earlier declaration.
decl = ctx.setdefault("__wp_axis_decl__", {})
if isinstance(decl, dict):
decl[binding] = list(_accepts_axes(payload))
def _record_pick(
ctx: Any,
chosen: dict[str, Any],
payload: dict[str, Any],
rolled: dict[str, str] | None = None,
) -> None:
"""Stash the picked option dict in `ctx["__wp_picks__"][module_id]` so a
downstream constraint-aware wildcard can look up its source's value +
sub_categories. Keyed by the active module id (set by pipeline.py).
@@ -282,10 +473,10 @@ def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
# SP3: per-pick list the combine fn reads. A single pick is a
# one-element list so the multi-tag/multi-pick applier has a
# uniform shape regardless of single- vs multi-select source.
"picks": [{
"value": chosen.get("value", ""),
"tags": list(chosen.get("sub_categories") or []),
}],
# An `accepts` axis is collapsed to its rolled winner here (see
# `_pick_record_for_constraint`) so the constraint keys on the one
# tag `$var.AXIS` exposes, not the whole menu.
"picks": [_pick_record_for_constraint(chosen, payload, rolled)],
}
# Additive per-instance view (task_5200c1fc). When this wildcard
# carries a `bundle_origin`, ALSO file the pick under
@@ -298,7 +489,13 @@ def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
bucket[module_id] = entry
def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) -> None:
def _record_pick_multi(
ctx: Any,
chosen_list: list[dict[str, Any]],
sep: str,
payload: dict[str, Any],
rolled_list: list[dict[str, str]] | None = None,
) -> None:
"""Multi-select counterpart to `_record_pick` (SP2a). Stash the joined
value + the individual picked values + the union of their sub-categories,
so the debug Picks view can render the list. Single-pick records (via
@@ -318,10 +515,15 @@ def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) ->
union.append(t)
# SP3: per-pick list (value + that pick's own tags, NOT the union) so the
# combine fn can apply the matrix per pick. The union above stays for the
# debug Picks view; `picks` is what constraint application reads.
# debug Picks view; `picks` is what constraint application reads. Each
# pick's `accepts` axis is collapsed to its own rolled winner (parallel to
# the single-pick path) so a multi-select source contributes one tag per
# pick, not each pick's whole menu.
per_pick = [
{"value": str(c.get("value", "")), "tags": list(c.get("sub_categories") or [])}
for c in chosen_list
_pick_record_for_constraint(
c, payload, (rolled_list[i] if rolled_list and i < len(rolled_list) else None)
)
for i, c in enumerate(chosen_list)
]
entry = {
"value": sep.join(values),
@@ -421,6 +623,38 @@ class WildcardHandler(ModuleHandler):
)
seen_grouped.add(mem)
# Group KIND (2026-08 tag axes). Additive: absence means every group
# is `classify`, which is exactly today's behaviour, so no stored
# payload changes meaning. `accepts` marks the group's tags as
# alternatives the option offers — an OR-set the constraint fold reads
# with max, and which `$var.AXIS` rolls one winner from.
kinds = payload.get("tag_group_kinds")
if kinds is not None:
if not isinstance(kinds, dict):
raise ValueError(
"wildcard payload.tag_group_kinds must be an object"
)
declared = tag_groups if isinstance(tag_groups, dict) else {}
for gname, kind in kinds.items():
if gname not in declared:
raise ValueError(
f"wildcard payload.tag_group_kinds[{gname!r}]: "
f"not a declared tag group"
)
if kind not in ("classify", "accepts"):
raise ValueError(
f"wildcard payload.tag_group_kinds[{gname!r}] must be "
f"'classify' or 'accepts' (got {kind!r})"
)
# Only an `accepts` axis is addressable as `$var.AXIS`, so only
# it has to survive the accessor grammar. A `classify` group
# keeps whatever name it has today.
if kind == "accepts" and not _IDENT_RE.match(gname):
raise ValueError(
f"wildcard payload.tag_group_kinds[{gname!r}]: an "
f"'accepts' group name must be a valid identifier"
)
seen_ids: set[str] = set()
null_count = 0
for i, opt in enumerate(options):
@@ -546,10 +780,23 @@ class WildcardHandler(ModuleHandler):
pinned_id = instance.get("pinned_option_id")
pinned = next((o for o in options if o.get("id") == pinned_id), None)
if pinned is not None:
# A pinned option still rolls its axes: pinning fixes WHICH
# option fires, not which of the shoes it accepts. Roll BEFORE
# recording so the pick carries the single rolled winner (the
# constraint source-view a downstream target reads), same as the
# random path. Pinned bypasses this wildcard's own constraint
# application, so there's nothing to restrict the roll against.
pinned_rolled = _roll_axes(
_axis_menus(payload, pinned.get("sub_categories")),
_derive_module_rng(
int(ctx.get("__wp_node_seed__", 0) or 0), f"{binding}::axes",
),
)
# Track the pinned pick the same way as a random pick —
# downstream constraint-aware wildcards need source
# info regardless of how the source resolved its option.
_record_pick(ctx, pinned)
_record_pick(ctx, pinned, payload, pinned_rolled)
_record_axes(ctx, binding, pinned_rolled, payload)
value = str(pinned.get("value", ""))
if not value:
return {binding: ""}
@@ -614,6 +861,11 @@ class WildcardHandler(ModuleHandler):
)
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.
# Used below to restrict this wildcard's OWN accepts-axis roll to tags
# those constraints allow, so `$source.AXIS` == `$target.AXIS` under a
# diagonal (A). Empty when nothing constrains this instance.
applied_constraints: list[tuple[dict[str, Any], dict[str, Any]]] = []
if my_id:
constraints = ctx.get("__wp_constraints__") if ctx is not None else None
picks = ctx.get("__wp_picks__") if ctx is not None else None
@@ -633,6 +885,13 @@ class WildcardHandler(ModuleHandler):
options, any_constraint_applied = apply_constraints_for_target(
options, my_id, constraints, picks, ctx["__wp_warnings__"],
hits=hits, firing_uid=firing_uid,
applied_out=applied_constraints,
# TARGET-side accepts axes: a target option's tags in one of
# these groups are alternatives the option offers, so the fold
# keeps it viable when the source allows any of them (mirror of
# the source rolled-winner collapse). The roll is then pinned to
# the allowed member by `_restrict_menus_by_constraints`.
target_axes=_accepts_axes(payload),
)
if any_constraint_applied:
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
@@ -706,18 +965,51 @@ class WildcardHandler(ModuleHandler):
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
finally:
ctx["__wp_rng__"] = saved_rng_multi
_record_pick_multi(ctx, picks, sep)
# One roll per pick, in pick order, so `$outfit.SHOES` mirrors the
# shape of `$outfit` and `$outfit.1.SHOES` lines up with `$outfit.1`.
# Each pick's accepts menu is first restricted to the tags the fired
# constraints allow (A), then rolled; the winners feed BOTH the pick
# record (source view) and the accessor. Rolled before the record —
# the record draws no rng and the restriction consumes none + keeps
# one draw per axis, so downstream reproducibility is unchanged.
rolled_list = [
_roll_axes(
_restrict_menus_by_constraints(
_axis_menus(payload, o.get("sub_categories")),
applied_constraints,
),
rng,
)
for o in picks
]
_record_pick_multi(ctx, picks, sep, payload, rolled_list)
_record_axes(ctx, binding, rolled_list, payload)
return {binding: ListVar(items, sep)}
chosen = _pick_weighted(options, rng)
if chosen is None:
return {binding: ""}
# Roll the accepts axes AFTER the option draw (same rng, so a locked
# seed still reproduces the option) but BEFORE recording the pick, so
# the pick a downstream target reads carries the single rolled winner
# per axis (source fix). This wildcard's OWN menu is first restricted to
# tags the constraints that fired on it allow (A) — so a constrained
# `$target.AXIS` agrees with the source. The restriction consumes no rng
# and keeps one draw per axis, so downstream reproducibility holds.
rolled = _roll_axes(
_restrict_menus_by_constraints(
_axis_menus(payload, chosen.get("sub_categories")),
applied_constraints,
),
rng,
)
# Record this wildcard's pick so a downstream constraint-aware
# wildcard can read it. Done BEFORE resolve_text so even an
# empty-string-value pick is registered (matters if a
# constraint exception keys on the literal empty pick value).
_record_pick(ctx, chosen)
_record_pick(ctx, chosen, payload, rolled)
_record_axes(ctx, binding, rolled, payload)
value = str(chosen.get("value", ""))
if not value:
+59 -1
View File
@@ -248,6 +248,32 @@ def _module_seed_scope(module: object) -> str | None:
return None
def _module_binding(module: object) -> str | None:
"""Read the variable name a wildcard binds to, instance override first.
Mirrors the precedence in `WildcardHandler.resolve`. Needed because rolled
axes are keyed by BINDING — the namespace `$outfit.SHOES` addresses — while
picks are keyed by module id, so the two carry-forwards cannot share a key.
"""
inst = (
module.get("instance") if isinstance(module, dict)
else getattr(module, "instance", None)
)
if isinstance(inst, dict):
b = inst.get("variable_binding")
if isinstance(b, str) and b:
return b
payload = (
module.get("payload") if isinstance(module, dict)
else getattr(module, "payload", None)
)
if isinstance(payload, dict):
b = payload.get("var_binding")
if isinstance(b, str) and b:
return b
return None
class PipelineEngine:
"""Runs an ordered list of modules against a context dict."""
@@ -326,6 +352,29 @@ class PipelineEngine:
)
if _mid and _mid in _base_picks and _mid not in _real_picks:
_real_picks[_mid] = _base_picks[_mid]
# Same carry-forward for rolled axes, but keyed by BINDING rather
# than module id — that is the namespace `$outfit.SHOES` addresses.
# Restricted to held modules, and `not in` rather than assignment,
# so a wildcard that never asked to hold still re-rolls this
# iteration.
# The declared-axes table travels with the rolls it describes.
for _table in ("__wp_axes__", "__wp_axis_decl__"):
_base_axes = base_ctx.get(_table)
if not isinstance(_base_axes, dict):
continue
_real_axes = ctx.setdefault(_table, {})
if not isinstance(_real_axes, dict):
continue
for _m in modules:
if _module_seed_scope(_m) != "hold":
continue
_binding = _module_binding(_m)
if (
_binding
and _binding in _base_axes
and _binding not in _real_axes
):
_real_axes[_binding] = _base_axes[_binding]
_base_hits = base_ctx.get("__wp_constraint_hits__")
if isinstance(_base_hits, dict):
_real_hits = ctx.setdefault("__wp_constraint_hits__", {})
@@ -580,9 +629,18 @@ class PipelineEngine:
key = var.lstrip("$")
before = ctx.get(key)
ctx[key] = value
# Last-write-wins applies to the internal flag too: whichever
# module writes the var LAST decides whether it is internal. A
# public writer overriding an earlier internal one MUST clear
# the flag — otherwise the var stays hidden from the assembler
# forever, even though its final value came from a public
# module. (Mark $outfit internal, then override it with a
# public $outfit: the override should be visible.)
flags = ctx.setdefault("__wp_internal_flags__", {})
if mark_internal:
flags = ctx.setdefault("__wp_internal_flags__", {})
flags[key] = True
else:
flags.pop(key, None)
writes.append({
"variable": key,
"value": value,
+97 -1
View File
@@ -103,6 +103,13 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
),
)
return f"${name}"
# An axis read is a different lookup against a different bucket, so it
# branches BEFORE the var lookup — `$outfit.SHOES` must resolve even when
# nothing bound `$outfit` itself, and must not raise `unknown_var` on the
# way past.
axis = tok.meta.get("axis")
if axis:
return _resolve_axis(tok, ctx, str(axis))
value = ctx.get_var(name)
if value is None:
_push_warning(
@@ -125,6 +132,81 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
return deref_var_value(value, tok.meta.get("index"))
def _resolve_axis(tok: Token, ctx: ResolveContext, axis: str) -> str:
"""Render `$name.AXIS`, `$name.K.AXIS` and `$name.AXIS.K`.
Never re-rolls: the choice was made when the wildcard picked, and this
reads it back — that is what makes two modules, or two nodes, agree.
Deliberately does NOT route through `deref_var_value`. An axis value is not
a ListVar, and keeping it off that path leaves the SP2a accessor contract
untouched.
"""
name = str(tok.meta.get("name", ""))
got = ctx.get_axis(name, axis)
if got is None:
declared = ctx.declared_axes(name)
if declared is not None and axis in declared:
# The axis exists; this roll's option just carries no tag on it.
# Legitimate (the editor already advises on such options), so it
# is an info note, and asking whether the group is marked
# `accepts` would send the user to fix something that is fine.
_push_warning(
ctx,
type="axis_untagged_pick",
severity="info",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={"name": name, "axis": axis, "surface": ctx.surface},
message=(
f"${name}.{axis} is empty: the picked ${name} option "
f"has no '{axis}' tag"
),
)
return ""
if declared:
hint = (
f"its wildcard's accepts groups are {', '.join(declared)}. "
f"Is '{axis}' a classify group, or misspelled?"
)
elif declared is not None:
hint = (
"its wildcard has no accepts groups. Is that tag group "
"marked 'accepts'?"
)
else:
hint = "is that tag group marked 'accepts'?"
_push_warning(
ctx,
type="unknown_tag_axis",
severity="warn",
module_id="",
source_field="",
position=tok.start,
token_index=None,
detail={
"name": name, "axis": axis, "surface": ctx.surface,
"declared": declared,
},
message=f"${name} has no '{axis}' axis — {hint}",
)
return ""
index = tok.meta.get("index")
if isinstance(got, list):
if index is None:
return ", ".join(g for g in got if g)
if isinstance(index, int) and 0 <= index < len(got):
return got[index] or ""
return ""
# A single pick behaves as a one-element list, mirroring deref_var_value so
# `$x.0.AXIS` == `$x.AXIS` and any higher index is empty.
if index is not None:
return str(got) if index == 0 else ""
return str(got)
def _push_warning(ctx: ResolveContext, **fields) -> None:
"""Append a warning dict to ctx.warnings. Helper to keep call sites tidy."""
ctx.warnings.append(fields)
@@ -140,7 +222,13 @@ def _pick_weighted(options: list[dict], rng) -> dict | None:
return None
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
if total <= 0:
return options[0]
# Nothing is selectable — every option is either weight 0 (documented
# as "disable without deleting") or was excluded by a constraint.
# Returning options[0] here looked like a working pick: a real option,
# right shape, right place, and wrong. It stayed wrong silently until
# the list was reordered. Callers turn None into an empty binding,
# which is what the null option already means.
return None
r = rng.random() * total
acc = 0.0
for opt in options:
@@ -467,6 +555,7 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
apply_constraints_for_target,
warn_excludes_all,
)
from engine.modules.wildcard_handler import _accepts_axes
# SP3: share the ctx-resident hit counter so a nested-ref
# target occurrence counts toward the same first/next
# coverage as direct top-level instances. `carrier_ctx` is
@@ -484,6 +573,13 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
options, any_applied = apply_constraints_for_target(
options, uuid, constraints, get_picks(), ctx.warnings,
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
# TARGET-side accepts axes for a nested @{} target, so its
# multi-tag accepts option survives when the source allows any
# alternative (same as the top-level path).
target_axes=(
_accepts_axes(payload_dict)
if isinstance(payload_dict, dict) else {}
),
)
if any_applied:
warn_excludes_all(options, uuid, ctx.warnings)
+23 -6
View File
@@ -13,10 +13,21 @@ import re
from engine.syntax.types import Token, TokenKind
# `$name` or `$name.K` — identifier starts with letter or underscore, then
# word chars, with an optional `.K` list-index accessor (SP2a). Group 2 = the
# index digits when present.
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?")
# `$name` with an optional accessor pair: at most one pick index and at most
# one axis, in EITHER order (`$o.0.SHOES` == `$o.SHOES.0`). Two mirrored
# alternatives, so `.0.1` and `.SHOES.BELTS` simply do not match the second
# segment — the grammar rejects them without a validation pass, and the tail
# stays literal text.
# group 1 — name
# group 2 — index, written first ($o.0 / $o.0.SHOES)
# group 3 — axis after an index ($o.0.SHOES)
# group 4 — axis, written first ($o.SHOES / $o.SHOES.0)
# group 5 — index after an axis ($o.SHOES.0)
_VAR_RE = re.compile(
r"\$([A-Za-z_][A-Za-z0-9_]*)"
r"(?:\.(?:(\d+)(?:\.([A-Za-z_][A-Za-z0-9_]*))?"
r"|([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?))?"
)
# 4-segment ref grammar (fixed order): `@{8hex [#name] [:expr] [!null]}`.
# group 1 — uuid: exactly 8 lowercase hex chars.
# group 2 — optional `#name`: display label cached at write-time so a
@@ -186,8 +197,14 @@ def tokenize_text(text: str) -> list[Token]:
if m:
_flush_text(i)
var_meta: dict[str, object] = {"name": m.group(1)}
if m.group(2) is not None:
var_meta["index"] = int(m.group(2))
# Normalise both spellings here, so nothing downstream ever
# learns which order the user wrote.
_idx = m.group(2) or m.group(5)
_axis = m.group(3) or m.group(4)
if _idx is not None:
var_meta["index"] = int(_idx)
if _axis is not None:
var_meta["axis"] = _axis
out.append(Token(
kind=TokenKind.VAR,
raw=m.group(0),
+48
View File
@@ -93,6 +93,36 @@ def split_var_accessor(name: str) -> tuple[str, int | None]:
return (name, None)
_VAR_REF_RE = re.compile(
r"^([A-Za-z_][A-Za-z0-9_]*)"
r"(?:\.(?:(\d+)(?:\.([A-Za-z_][A-Za-z0-9_]*))?"
r"|([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?))?$"
)
def parse_var_reference(name: str) -> tuple[str, int | None, str | None]:
"""Split a bare var reference into ``(base, index, axis)``.
The string-path twin of the tokenizer's ``_VAR_RE`` — derivation conditions
store ``condition.var`` as text, not tokens. Kept separate from
:func:`split_var_accessor` so the SP2a list-accessor contract and its
existing callers are untouched.
Both accessor orders are accepted and normalise to the same triple:
``outfit.0.SHOES`` and ``outfit.SHOES.0`` are the same reference. A name
that matches nothing is returned whole with both accessors ``None``, so an
unusual ctx key resolves as itself rather than being truncated.
"""
if not isinstance(name, str):
return ("", None, None)
m = _VAR_REF_RE.match(name)
if not m:
return (name, None, None)
idx = m.group(2) or m.group(5)
axis = m.group(3) or m.group(4)
return (m.group(1), int(idx) if idx is not None else None, axis)
def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
"""Render a resolved var value to a string, honoring an optional ``.K``
list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` ->
@@ -134,6 +164,24 @@ class ResolveContext(Protocol):
binds a ``str``."""
...
def get_axis(self, name: str, axis: str) -> str | list[str | None] | None:
"""Return the tag rolled for `name`'s `axis`, or None if it has none.
A single-pick source yields a ``str``; a multi-select one yields a
list with one slot per pick in pick order (``None`` for a pick whose
option carries no tag on this axis, so indexes line up with the
variable's own). The value was rolled
when the wildcard picked — this only reads it back, which is what makes
two modules (or two nodes) agree.
"""
...
def declared_axes(self, name: str) -> list[str] | None:
"""Return the ``accepts`` axes the wildcard bound to `name` declares,
or None when that is not known. Lets an empty axis read tell "this
pick carries no tag on the axis" apart from "there is no such axis"."""
...
def get_module(self, uuid: str) -> dict[str, Any] | None:
"""Return the module catalog row for `uuid`, or None if missing.
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.13.2",
"version": "2.16.0",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
@@ -9,10 +9,11 @@
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev": "vite build --watch --mode extension",
"build:extension": "vite build --mode extension && pnpm docs:copy",
"build:manager": "vite build --mode manager",
"build:extension": "vite build --mode extension && pnpm docs:copy && pnpm fonts:copy && node scripts/write-asset-manifest.mjs js",
"build:manager": "vite build --mode manager && node scripts/write-asset-manifest.mjs web",
"build": "pnpm build:extension && pnpm build:manager",
"docs:copy": "node scripts/copy-help-docs.mjs",
"fonts:copy": "node scripts/copy-fonts.mjs",
"docs:figures": "node scripts/build-flow-figures.mjs",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,vue}\"",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.13.2"
version = "2.16.0"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Build the booru tag list the optional value autocomplete reads.
Output is the four-column shape `wp_api/_tag_index.py` parses:
name,category,post_count,"alias,alias"
Run by `.github/workflows/tag-list.yml`, not at install time. The result ships
as a release asset — never in the repository (it regenerates, and a 6 MB file
per refresh would bloat history forever) and never in the package (a usable
list dwarfs the whole gzipped JS budget).
WHY A POST-COUNT FLOOR AND NOT A PAGE COUNT
-------------------------------------------
`search[order]=count` sorts strictly descending ACROSS pages, verified against
the live API on 2026-08-06:
page 1: first 8,244,950 last 55,918
page 100: first 68 last 67
page 800: first 1 last 1
So a popular tag cannot hide on a later page, and stopping once counts fall
below the floor provably loses nothing — it is an early exit, not a filter.
Walking all 1000 allowed pages spends ~900 requests and a quarter of an hour
collecting single-post tags nobody will ever autocomplete.
For reference, ComfyUI-Custom-Scripts ships ~100k tags, which lands around a
floor of 68.
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import defaultdict
BASE = "https://danbooru.donmai.us"
# MUST stay in bare `product/version` form. Danbooru sits behind a bot filter
# that 403s anything else, measured 2026-08-06:
#
# wildcard-pipeline-taglist/1.0 -> 200
# wp-tag-list/1.0 -> 200
# ComfyUI-Wildcard-Pipeline -> 403 (no version)
# ComfyUI-Wildcard-Pipeline/1.0 -> 403
# wp-tag-list/1.0 (+https://github.com/DumiFlex/...) -> 403 (URL present)
#
# So do NOT "improve" this by adding a contact URL or a longer description.
# That is the normal courtesy for a scraper and it is exactly what gets this
# blocked; the version suffix is what makes it acceptable.
USER_AGENT = "wildcard-pipeline-taglist/1.0"
PAGE_LIMIT = 1000
# The API refuses page > 1000 outright (HTTP 410, "You cannot go beyond page
# 1000"), so this is the ceiling whatever the floor says.
MAX_PAGE = 1000
def fetch(path: str, params: dict[str, str], *, retries: int = 4) -> list[dict]:
"""GET a JSON array, retrying on transient failures.
The original script this replaces aborted on the first non-200, which for
a 100-request run means one blip discards everything collected so far.
"""
url = f"{BASE}{path}?{urllib.parse.urlencode(params)}"
delay = 2.0
for attempt in range(retries):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as err:
# 410 is the deep-pagination refusal: retrying cannot help.
if err.code == 410:
return []
if err.code not in (429, 500, 502, 503, 504) or attempt == retries - 1:
raise
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
if attempt == retries - 1:
raise
print(f" retry in {delay:.0f}s ...", flush=True)
time.sleep(delay)
delay *= 2
return []
def fetch_tags(min_count: int, pause: float) -> list[dict]:
"""Tags ordered by post count, stopping once they fall below `min_count`."""
out: list[dict] = []
for page in range(1, MAX_PAGE + 1):
rows = fetch("/tags.json", {
"limit": str(PAGE_LIMIT),
"search[hide_empty]": "yes",
"search[is_deprecated]": "no",
"search[order]": "count",
"page": str(page),
})
if not rows:
print(f" page {page}: empty, stopping", flush=True)
break
kept = [r for r in rows if r.get("post_count", 0) >= min_count]
out.extend(kept)
print(
f" page {page:>4}: +{len(kept):>4} "
f"(lowest {rows[-1].get('post_count', 0):,}) total {len(out):,}",
flush=True,
)
if len(kept) < len(rows):
# Descending order means everything after this point is below the
# floor too. Nothing worth having is left.
print(f" reached the floor of {min_count} posts", flush=True)
break
time.sleep(pause)
return out
def fetch_aliases(pause: float, max_pages: int) -> dict[str, list[str]]:
"""`canonical -> [alias, ...]`, from the separate aliases endpoint.
`tags.json` carries no aliases, and they are what let someone type `hires`
and land on `highres` — the single most valuable thing in the file after
the names themselves.
"""
mapping: dict[str, list[str]] = defaultdict(list)
for page in range(1, max_pages + 1):
rows = fetch("/tag_aliases.json", {
"limit": str(PAGE_LIMIT),
"search[status]": "active",
"page": str(page),
})
if not rows:
break
for row in rows:
antecedent = row.get("antecedent_name")
consequent = row.get("consequent_name")
if isinstance(antecedent, str) and isinstance(consequent, str):
mapping[consequent].append(antecedent)
print(f" aliases page {page:>3}: {len(mapping):,} tags covered", flush=True)
time.sleep(pause)
return mapping
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"--min-count", type=int, default=20,
help="stop once tags drop below this many posts (default: 20)",
)
ap.add_argument("--out", default="wildcard-pipeline-tags.csv")
ap.add_argument(
"--pause", type=float, default=1.0,
help="seconds between requests (default: 1.0)",
)
ap.add_argument(
"--alias-pages", type=int, default=60,
help="pages of aliases to fetch (default: 60)",
)
ap.add_argument("--no-aliases", action="store_true")
args = ap.parse_args()
print(f"Fetching tags with at least {args.min_count} posts ...", flush=True)
tags = fetch_tags(args.min_count, args.pause)
if not tags:
print("No tags fetched — refusing to write an empty list.", file=sys.stderr)
return 1
aliases: dict[str, list[str]] = {}
if not args.no_aliases:
print("Fetching aliases ...", flush=True)
aliases = fetch_aliases(args.pause, args.alias_pages)
with open(args.out, "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
for tag in tags:
name = tag.get("name")
if not isinstance(name, str) or not name:
continue
writer.writerow([
name,
tag.get("category", 0),
tag.get("post_count", 0),
",".join(aliases.get(name, [])),
])
print(
f"\nWrote {len(tags):,} tags to {args.out} "
f"({sum(len(v) for v in aliases.values()):,} aliases)",
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+15 -3
View File
@@ -247,7 +247,17 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// previous bump left 179 bytes free, which meant every subsequent change had
// to argue with the gate. Explicitly approved by the maintainer, who asked for
// the budget to be raised as needed rather than features trimmed to fit.
const TOTAL_LIMIT = 490 * 1024; // 490 KB
// LOWERED 490 -> 420 KB on 2026-08-06, after the webfonts stopped being bundled.
// Vite library mode ignores `assetsInlineLimit` and base64-inlined both woff2
// files into a JS chunk; they are now copied to js/assets/fonts/ and <link>ed at
// runtime (see src/extension/fonts.ts). Measured 492,407 -> 403,055 bytes.
//
// Tightened rather than banked, because the gate is what stops it silently
// regressing: re-inlining the fonts would add ~90 KB, which fits under 490 KB
// and would have passed unnoticed. It does not fit under 420 KB. The 17 KB of
// headroom left is deliberate — enough for ordinary work, not enough to hide a
// whole asset class coming back.
const TOTAL_LIMIT = 420 * 1024; // 420 KB
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
@@ -259,7 +269,7 @@ function allJsFiles(dir) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
out.push(...allJsFiles(full));
} else if (name.endsWith(".js")) {
} else if (name.endsWith(".js") || name.endsWith(".mjs")) {
out.push(full);
}
}
@@ -281,7 +291,9 @@ for (const file of files) {
// content edit and would otherwise show every chunk as added/removed.
// Normalize Windows backslashes so the manifest diffs cleanly against
// CI runs (which always emit forward slashes on Linux).
const stable = rel.replace(/-[A-Za-z0-9_-]{8,}\.js$/, ".js").replaceAll("\\", "/");
// Lazy chunks are `.mjs` (see vite.config.mts); report them under `.js` so
// manifests from before that switch still diff chunk-for-chunk.
const stable = rel.replace(/-[A-Za-z0-9_-]{8,}\.m?js$/, ".js").replaceAll("\\", "/");
manifest.files[stable] = size;
console.log(` ${rel.padEnd(40)} ${(size / 1024).toFixed(2).padStart(7)} KB`);
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
// Copy the webfont directory verbatim from src/assets/fonts/ to
// js/assets/fonts/, so ComfyUI serves it under
// /extensions/<package>/assets/fonts/.
//
// These files deliberately do NOT go through Vite. Library mode always
// base64-inlines assets — `build.assetsInlineLimit` is documented as ignored
// when `build.lib` is set — so a woff2 reachable from the module graph ends up
// ~33% larger, inside a JS chunk, parsed as JavaScript. Copying sidesteps the
// bundler entirely; `src/extension/fonts.ts` <link>s the stylesheet at runtime
// and the browser resolves the woff2 URLs relative to it.
//
// Run as part of `pnpm build:extension`, alongside copy-help-docs.mjs.
import { readdirSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const SRC = join(ROOT, "src", "assets", "fonts");
const DST = join(ROOT, "js", "assets", "fonts");
try {
rmSync(DST, { recursive: true, force: true });
mkdirSync(DST, { recursive: true });
const files = readdirSync(SRC);
if (!files.some((f) => f.endsWith(".css"))) {
// A missing stylesheet is silent at runtime — the <link> 404s and text
// quietly falls back to the system stack. Fail the build instead.
throw new Error(`no stylesheet in ${SRC}; fonts.ts expects wp-fonts.css`);
}
for (const name of files) {
copyFileSync(join(SRC, name), join(DST, name));
console.log(` src/assets/fonts/${name} → js/assets/fonts/${name}`);
}
console.log(`Copied ${files.length} font file(s).`);
} catch (err) {
console.error("[copy-fonts] failed:", err);
process.exit(1);
}
+10 -2
View File
@@ -61,12 +61,17 @@ _SAMPLES: dict[str, tuple[type, dict]] = {
{
"var_binding": "mood",
"sub_categories": ["soft", "bold"],
# Tag axes: an `accepts` group must survive the round-trip, so the
# sample carries both the group map and its kind. A stripped
# tag_group_kinds silently degrades the axis to classify.
"tag_groups": {"TEXTURE": ["soft", "bold"]},
"tag_group_kinds": {"TEXTURE": "accepts"},
"options": [
{"id": "opt00001", "value": "serene", "weight": 1, "sub_category": "soft"},
{"id": "opt00001", "value": "serene", "weight": 1, "sub_categories": ["soft"]},
# Null option: the is_null contract. Must round-trip
# through the validator or install rebuilds it as an
# invalid empty-value option.
{"id": "opt00002", "value": "", "weight": 1, "sub_category": None, "is_null": True},
{"id": "opt00002", "value": "", "weight": 1, "sub_categories": [], "is_null": True},
],
},
),
@@ -112,6 +117,9 @@ _SAMPLES: dict[str, tuple[type, dict]] = {
"target_wildcard_id": "tgt00001",
"exceptions": [],
"matrix": {"soft": {"bold": {"mode": "boost", "factor": 3}}},
# SP3 reach selector — must survive the round-trip, not strip back
# to the {mode: "all"} default.
"target_select": {"mode": "next", "count": 2},
},
),
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
// Record exactly which files a clean build produced, so the plugin can delete
// everything else on the next ComfyUI start.
//
// WHY THIS EXISTS. Our chunks are content-hashed, so every release emits new
// filenames. ComfyUI Manager updates by extracting the new package OVER the
// installed folder — it never removes files the new version dropped. The two
// behaviours are individually reasonable and together they leak: measured on a
// real 2.13.1 install (2026-08-06), `js/` held 237 chunks where 58 were live
// and `web/` held 1,256 files where 136 were, for 54.6 MB total.
//
// It goes unnoticed in development because `emptyOutDir` wipes the output
// directory on every local build. Only installs that UPDATE accumulate.
//
// The manifest lists paths relative to the directory it sits in. Written last,
// after the copy steps, so it captures docs and fonts as well as chunks.
//
// Usage: node scripts/write-asset-manifest.mjs <dir>
import { readdirSync, statSync, writeFileSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const target = process.argv[2];
if (!target) {
console.error("usage: write-asset-manifest.mjs <dir> (e.g. js, web)");
process.exit(1);
}
const dir = join(ROOT, target);
const MANIFEST = ".wp-assets.json";
function walk(current) {
const out = [];
for (const name of readdirSync(current)) {
const full = join(current, name);
if (statSync(full).isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
try {
const files = walk(dir)
// Always use forward slashes: the manifest is written on whatever machine
// builds the release and read on the user's, which may not be the same OS.
.map((p) => relative(dir, p).split(sep).join("/"))
.filter((p) => p !== MANIFEST)
.sort();
writeFileSync(
join(dir, MANIFEST),
`${JSON.stringify({ files }, null, 0)}\n`,
"utf8",
);
console.log(` ${target}/${MANIFEST} — ${files.length} files recorded`);
} catch (err) {
console.error("[write-asset-manifest] failed:", err);
process.exit(1);
}
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
/* Webfonts for the canvas extension — JetBrains Mono, latin subset,
* variable axis 100-900.
*
* NO INTER HERE, deliberately. ComfyUI's own frontend serves an Inter that is
* identical to the one we used to ship — same family name, same
* `font-weight: 100 900` variable axis, same latin woff2, same
* `font-display: swap` — and it declares it in its MAIN stylesheet, so it is
* already loaded on every page a canvas widget can appear on. We were shipping
* and downloading a 48 KB duplicate.
*
* The coupling this creates is on the FAMILY NAME, not on their URL: our
* `--wp-font-sans` stack names `Inter` and the browser resolves it against
* whatever `Inter` face is loaded, so their file being renamed, re-subset or
* re-hashed is invisible to us. Their copy also lives in `public/fonts/`,
* which Vite serves un-hashed precisely so the URL stays stable — hashed
* assets go to `assets/`.
*
* Evidence for the bet, since "rely on a host asset" deserves some: added
* 2025-09-16 in Comfy-Org/ComfyUI_frontend 4ff18fd ("Add Inter font used by
* Figma designs", #5535) and untouched since — one commit in its whole
* history. It is the typeface of their entire UI, so removing it means
* restyling their product, not dropping an optional extra.
*
* And if they do remove it, nothing breaks loudly: the stack falls through to
* `-apple-system, Segoe UI, Roboto, sans-serif`, which is the behaviour
* `font-display: swap` already shows during every cold load.
*
* JetBrains Mono stays. ComfyUI ships no monospace face.
*
* This file is NOT processed by Vite. `scripts/copy-fonts.mjs` copies this
* directory verbatim into `js/assets/fonts/`, and `src/extension/fonts.ts`
* injects a <link> to it at runtime.
*
* Why not an import: Vite library mode ALWAYS base64-inlines assets and
* ignores `build.assetsInlineLimit` entirely, so any woff2 reachable from a
* module graph becomes ~33% larger and lands inside a JS chunk — parsed as
* JavaScript, re-downloaded whenever that chunk's hash changes, and counted
* against the bundle-size gate. Keeping the fonts outside the graph is the
* only way to ship them as fonts.
*
* The `src` URLs below are relative to THIS FILE, so the browser resolves
* them against the stylesheet's own location. No path arithmetic in JS.
*
* font-display: swap renders the system stack first and re-paints when the
* woff2 lands, so text is never invisible during the fetch.
*/
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src:
url("./jetbrains-mono-latin.woff2") format("woff2-variations"),
url("./jetbrains-mono-latin.woff2") format("woff2");
}
@@ -452,3 +452,45 @@ describe("AssemblerHelper var-color rendering", () => {
expect(wrapper.find('[data-test="asm-save-template"]').attributes("disabled")).toBeDefined();
});
});
describe("AssemblerHelper $var.AXIS preview (tag axes)", () => {
const base = {
upstreamVars: ["outfit", "shoes"],
templateVars: ["outfit", "shoes"],
resolvedMap: { outfit: "robe", shoes: "red stilettos" },
varAxes: { outfit: { SHOES: "sandals" } },
};
const preview = (props: Record<string, unknown>) =>
mount(AssemblerHelper, { props: { ...base, ...props } as never })
.find('[data-test="asm-preview"]').text();
it("reads the tag the preview run rolled, not the axis's first tag", () => {
expect(preview({
template: "$outfit.SHOES | $outfit",
resolvedMap: { outfit: "summer dress", shoes: "red stilettos" },
rolledAxes: { outfit: { SHOES: "heels" } },
})).toBe("heels | summer dress");
});
it("renders empty when the picked option carries no tag on the axis", () => {
// The reported preview: "sandals | robe" while the prompt said "| robe".
expect(preview({ template: "$outfit.SHOES | $outfit", rolledAxes: { outfit: {} } }))
.toBe("| robe");
});
it("guesses the first tag only until the preview run answers", () => {
expect(preview({ template: "$outfit.SHOES" })).toBe("sandals");
});
it("keeps an undeclared axis raw so the gap stays visible", () => {
expect(preview({ template: "$outfit.BELTS", rolledAxes: { outfit: {} } }))
.toBe("$outfit.BELTS");
});
it("indexes a multi-pick positionally in either accessor order", () => {
const rolledAxes = { outfit: [{}, { SHOES: "boots" }] };
expect(preview({ template: "[$outfit.0.SHOES] [$outfit.1.SHOES] [$outfit.SHOES.1]", rolledAxes }))
.toBe("[] [boots] [boots]");
expect(preview({ template: "$outfit.SHOES", rolledAxes })).toBe("boots");
});
});
+65 -40
View File
@@ -1,9 +1,16 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { varColorClass } from "../shared/var-color";
import { modelSyntaxHtml } from "../../widgets/richTokenize";
import ContextMenu, { type ContextMenuItem } from "../shared/ContextMenu.vue";
import { kindIcon, type WpKind } from "../shared/kind-icons";
import { applyVarAccessor, type ResolvedValue } from "../../widgets/richTokenize";
import {
applyVarAccessor,
readRolledAxis,
varAccessorParts,
type ResolvedValue,
type RolledAxes,
} from "../../widgets/richTokenize";
const props = defineProps<{
/** Flat array of upstream variable names. Primary prop shape. */
@@ -17,6 +24,16 @@ const props = defineProps<{
* precedence over `resolved`.
*/
resolvedMap?: Record<string, ResolvedValue>;
/** `{ varName: { AXIS: firstTag } }`. A `$var.AXIS` read resolves against
* this, never against `resolvedMap` — the axis names a tag menu, not the
* variable's text. */
varAxes?: Record<string, Record<string, string>>;
/** What the preview run actually rolled, keyed by binding (the preview
* endpoint's `axes`). Preferred over `varAxes` for any axis the chain
* declares, because `varAxes` only knows the axis's first tag — it printed
* "sandals" beside an option with no shoes and beside a pick whose
* constrained target had rolled heels. */
rolledAxes?: Record<string, RolledAxes>;
/**
* Pre-resolved string — template with $var replaced by resolved values.
* Legacy fallback when `resolvedMap` not provided. Suffers from
@@ -85,7 +102,12 @@ const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
// SP2a: capture an optional `.K` list accessor (group 2) so `$mood.0` consumes
// the whole token — `m[0].length` then advances past `.K` (no stranded ".0"
// literal) and group 2 drives applyVarAccessor in the substitution below.
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/g;
// Groups: 1=name · 2=index · 3=axis. The accessor has to be part of the match
// or the preview prints the resolved value and strands ".SHOES" after it.
// Both accessor orders the engine accepts (`$o.0.SHOES`, `$o.SHOES.0`); the
// match is re-split by `varAccessorParts`, the one canonical grammar.
const TEMPLATE_VAR_RE =
/(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(?:\d+(?:\.[A-Za-z_][A-Za-z0-9_]*)?|[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?))?/g;
/** Variable names referenced in the template. */
const templateVarsInternal = computed(() => {
@@ -134,6 +156,22 @@ watch(
interface PreviewToken { kind: "literal" | "var"; text: string; varName?: string }
/** An axis read resolves to a TAG. Falling through to the value is what
* rendered "a white t-shirt and denim skirt.SHOES". An axis the chain does not
* declare stays raw so the gap is visible rather than silently dropped; a
* declared one reads what the preview run rolled (empty when the picked
* option carries no tag on it, exactly as the prompt will), and only guesses
* the axis's first tag until that run has answered. */
function axisText(name: string, axis: string, index: number | undefined, raw: string): string {
const guess = props.varAxes?.[name]?.[axis];
if (guess === undefined) return raw;
const rolled = props.rolledAxes;
if (rolled && Object.prototype.hasOwnProperty.call(rolled, name)) {
return readRolledAxis(rolled[name], axis, index);
}
return index != null && index > 0 ? "" : guess;
}
const previewTokens = computed<PreviewToken[]>(() => {
if (!props.template) return [];
@@ -145,17 +183,14 @@ const previewTokens = computed<PreviewToken[]>(() => {
for (const m of props.template.matchAll(TEMPLATE_VAR_RE)) {
const idx = m.index ?? 0;
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
const name = m[1];
const idxStr = m[2];
const index = idxStr != null ? parseInt(idxStr, 10) : undefined;
const raw = m[0];
const { base: name, index, axis } = varAccessorParts(raw);
const has = Object.prototype.hasOwnProperty.call(props.resolvedMap, name);
// Resolved → join (bare $name) or index (.K) via the shared accessor;
// unresolved → keep the raw `$name.K` so the user sees what's missing.
tokens.push({
kind: "var",
text: has
? applyVarAccessor(props.resolvedMap[name], index)
: `$${name}${idxStr != null ? "." + idxStr : ""}`,
text: axis
? axisText(name, axis, index, raw)
: (has ? applyVarAccessor(props.resolvedMap[name], index) : raw),
varName: name,
});
last = idx + m[0].length;
@@ -396,7 +431,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
@click="onLoadTemplate"
>
<i class="pi pi-folder-open" aria-hidden="true" />
<span>Load</span>
</button>
<button
v-if="onSaveTemplate"
@@ -409,7 +443,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
@click="onSaveTemplate"
>
<i class="pi pi-save" aria-hidden="true" />
<span>Save</span>
</button>
<button
v-if="onClearTemplate"
@@ -422,7 +455,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
@click="onClearTemplate"
>
<i class="pi pi-trash" aria-hidden="true" />
<span>Clear template</span>
</button>
</div>
<span class="wp-asm-section-stat">
@@ -490,18 +522,22 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
</div>
<div v-else key="tokens" class="wp-asm-preview__tokens">
<template v-for="(tok, i) in previewTokens" :key="i">
<span v-if="tok.kind === 'literal'" class="literal">{{ tok.text }}</span>
<!-- Literal preview text still carries `<lora:…>` and
`embedding:…`, which resolve to themselves rather than to a
variable — so without this they were the only place the two
syntaxes appeared uncoloured. Same function the editor uses,
so the two cannot drift; it escapes everything it emits. -->
<span
v-if="tok.kind === 'literal'"
class="literal"
v-html="modelSyntaxHtml(tok.text)"
></span>
<span v-else :class="['res', varColorClass(tok.varName ?? '')]">{{ tok.text }}</span>
</template>
</div>
</Transition>
</div>
<!-- hint -->
<div class="wp-asm-hint">
<span>click → insert <kbd>$var</kbd> · <kbd>Ctrl</kbd>+click → remove · right-click → more</span>
<span style="margin-left: auto;">click missing → remove</span>
</div>
</div>
</Transition>
@@ -588,11 +624,15 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
gap: 6px;
justify-content: center;
}
/* Icon-only. Each button already carried a `title` and an `aria-label`, so the
visible word was the only thing dropped — and three words across the row
were most of its width. Square-ish padding keeps the tap target honest. */
.wp-asm-toolbtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 3px 10px;
padding: 4px 7px;
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: 3px;
@@ -665,8 +705,11 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
.wp-asm-vars {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px;
/* On the project's own spacing scale rather than the bare 4/6 they were.
At 4px the chips ran together into one block of texture — the strip read
as a single control instead of a row of separate variables. */
gap: var(--wp-space-3);
padding: var(--wp-space-4);
background: var(--wp-bg-deep, var(--wp-bg));
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
@@ -774,22 +817,4 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
}
.wp-asm-preview__ghost-text { display: inline; }
.wp-asm-hint {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font: 11px/1.3 var(--wp-font-sans);
color: var(--wp-text-dim);
}
.wp-asm-hint kbd {
background: var(--wp-bg-1);
border: 1px solid var(--wp-border-soft);
border-bottom-width: 2px;
border-radius: 2px;
font: 10px/1 var(--wp-font-mono);
padding: 1px 4px;
color: var(--wp-text-muted);
}
</style>
@@ -198,9 +198,9 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
return calls?.[calls.length - 1]?.[0] as ReturnType<typeof emptyContextLoopConfig>;
};
it("shift-click locks the frame to the seed it would have used", async () => {
it("alt-click locks the frame to the seed it would have used", async () => {
const w = fw();
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
await w.find('[data-test="loop-frame-3"]').trigger("click", { altKey: true });
const next = lastEmit(w);
// Frame index 2, and the value must be a real derived seed — not a
// placeholder, or the lock would silently change the output it froze.
@@ -208,44 +208,44 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
expect(Number.isInteger(next.seed_locks!["2"])).toBe(true);
});
it("shift-click on a locked frame unlocks it", async () => {
it("alt-click on a locked frame unlocks it", async () => {
const w = fw({ seed_locks: { "2": 777 } });
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
await w.find('[data-test="loop-frame-3"]').trigger("click", { altKey: true });
expect(lastEmit(w).seed_locks).toEqual({});
});
it("the chip-lock value matches what the modal's Lock all would write", async () => {
const w = fw();
await w.find('[data-test="loop-frame-2"]').trigger("click", { shiftKey: true });
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
const chipValue = lastEmit(w).seed_locks!["1"];
expect(chipValue).toBe(deriveLoopSeeds(42, 4, "hash_index")[1]);
});
it("alt-click bypasses a frame, and again re-enables it", async () => {
it("ctrl-click bypasses a frame, and again re-enables it", async () => {
const w = fw();
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
await w.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
expect(lastEmit(w).bypass_frames).toEqual([1]);
const w2 = fw({ bypass_frames: [1] });
await w2.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
await w2.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
expect(lastEmit(w2).bypass_frames).toEqual([]);
});
it("keeps bypassed indices sorted so the config does not churn", async () => {
const w = fw({ bypass_frames: [3] });
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
await w.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
expect(lastEmit(w).bypass_frames).toEqual([1, 3]);
});
it("refuses to bypass the last running frame — a loop needs one", async () => {
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
await w.find('[data-test="loop-frame-4"]').trigger("click", { altKey: true });
await w.find('[data-test="loop-frame-4"]').trigger("click", { ctrlKey: true });
expect(w.emitted("update:modelValue")).toBeUndefined();
});
it("still lets the last running frame be re-enabled and locked", async () => {
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
await w.find('[data-test="loop-frame-1"]').trigger("click", { altKey: true });
await w.find('[data-test="loop-frame-1"]').trigger("click", { ctrlKey: true });
expect(lastEmit(w).bypass_frames).toEqual([1, 2]);
});
@@ -253,9 +253,9 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
// kind of surprise that makes people stop trusting a shortcut.
it("a modifier click never also moves the edit cursor", async () => {
const w = fw();
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
await w.find('[data-test="loop-frame-3"]').trigger("click", { altKey: true });
expect(currentFrame.value).toBe(null);
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
await w.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
expect(currentFrame.value).toBe(null);
});
@@ -268,8 +268,8 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
it("base takes no modifiers — it has no seed and nothing to bypass", async () => {
const w = fw();
await w.find('[data-test="loop-frame-base"]').trigger("click", { shiftKey: true });
await w.find('[data-test="loop-frame-base"]').trigger("click", { altKey: true });
await w.find('[data-test="loop-frame-base"]').trigger("click", { ctrlKey: true });
expect(w.emitted("update:modelValue")).toBeUndefined();
});
@@ -293,7 +293,7 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
it("advertises the combos in the widget, not just in tooltips", () => {
const w = fw();
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/shift/i);
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt-click locks/i);
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt|option/i);
});
@@ -86,10 +86,10 @@ function onBypassFrames(next: number[]): void {
* of frames is the common case; the modal earns its place for the bulk
* operations (lock all, paste a series, out-of-range locks) and keeps them.
*
* Modifier choice: Alt and Shift both survive the trip. Ctrl is out because on
* macOS Ctrl-click IS a right-click, so the combo would open a context menu
* instead. Alt for bypass follows the seed modal's existing Alt-click-for-a-
* variant idiom on its Copy button.
* Modifier choice: Alt-click locks a seed, Ctrl-click bypasses — the reflex
* pairing users reach for. On macOS Ctrl-click IS a right-click, so Cmd stands
* in for bypass there (see FrameChips `isBypassChord`); Alt/Option survives the
* trip on every platform.
* ------------------------------------------------------------------------- */
/** 0-based locked frame indices. Keys are strings in the config; anything
+45 -2
View File
@@ -795,6 +795,28 @@ const resolvedForEditing = computed<Record<string, ResolvedValue>>(() => {
* Built in the same pass as the name list so the two cannot disagree about
* which module owns a name.
*/
/** The `accepts` axes a wildcard payload declares, with the tags each rolls
* from. `hueIndex` counts EVERY group so a colour matches the wildcard
* editor's. Mirrors `acceptsAxesOf` in `manager/utils/library-suggestions`;
* duplicated rather than imported because that module pulls the whole module
* store into the canvas bundle, which the size gate will not carry. */
function acceptsAxesOfPayload(
payload: unknown,
): { axis: string; tags: string[]; hueIndex: number }[] | undefined {
const p = (payload ?? {}) as {
tag_groups?: Record<string, string[]>;
tag_group_kinds?: Record<string, string>;
};
if (!p.tag_group_kinds) return undefined;
const out: { axis: string; tags: string[]; hueIndex: number }[] = [];
Object.entries(p.tag_groups ?? {}).forEach(([axis, tags], hueIndex) => {
if (p.tag_group_kinds?.[axis] === "accepts") {
out.push({ axis, tags: tags ?? [], hueIndex });
}
});
return out.length > 0 ? out : undefined;
}
const siblingVarInfo = computed<{
names: string[];
producers: Record<string, VarProducerLike>;
@@ -804,7 +826,10 @@ const siblingVarInfo = computed<{
/** Last module to write each name, so repeat writes from one module (a
* derivation's branches) do not inflate the override count. */
const lastWriter = new Map<string, string>();
let owner: { kind: string; moduleName: string; moduleId: string } | null = null;
let owner: {
kind: string; moduleName: string; moduleId: string;
axes?: { axis: string; tags: string[]; hueIndex: number }[];
} | null = null;
function add(name: string | undefined | null): void {
const trimmed = (name ?? "").replace(/^\$+/, "").trim();
if (!trimmed) return;
@@ -825,6 +850,12 @@ const siblingVarInfo = computed<{
// would be noise when every sibling gives the same answer.
nodeLabel: "this node",
shadowed: prev ? (sameWriter ? prev.shadowed : prev.shadowed + 1) : 0,
// `accepts` axes, so `$var.AXIS` completion works for a variable written
// in THIS node. Same-node writers replace the upstream entry wholesale
// in `allVarProducers`, so axes attached only during the graph walk were
// dropped for exactly the common case: the wildcard and the template
// that reads it sitting in one Context.
...(owner.axes ? { axes: owner.axes } : {}),
};
}
const editingM = editingModule.value;
@@ -840,6 +871,10 @@ const siblingVarInfo = computed<{
kind: m.type,
moduleName: m.meta?.name ?? m.type,
moduleId: m.id,
// Read from the module's own payload snapshot — the node runs what it
// captured, so this is the truth for THIS node regardless of what the
// library says now.
...(m.type === "wildcard" ? { axes: acceptsAxesOfPayload(m.payload) } : {}),
};
for (const e of m.entries) add(e.variable_name);
const inst = (m.instance ?? {}) as {
@@ -900,8 +935,16 @@ const allVarProducers = computed<Record<string, VarProducerLike>>(() => {
const merged: Record<string, VarProducerLike> = { ...upstream };
for (const [name, sib] of Object.entries(siblingVarInfo.value.producers)) {
const up = upstream[name];
// Same carry-forward as the graph walk: a same-node writer that declares
// no axes must not erase the ones an upstream wildcard declared for this
// binding — the engine keys `__wp_axes__` by binding, so the rolled axis
// outlives a rebind.
merged[name] = up
? { ...sib, shadowed: sib.shadowed + up.shadowed + 1 }
? {
...sib,
...(sib.axes ?? up.axes ? { axes: sib.axes ?? up.axes } : {}),
shadowed: sib.shadowed + up.shadowed + 1,
}
: sib;
}
return merged;
+2
View File
@@ -1,5 +1,6 @@
import { ref, type Ref } from "vue";
import type { ModuleEntry } from "../../widgets/_shared";
import { checkInstalledVersion } from "./version-notice";
/**
* Shape of the SnapshotEntry returned by `POST /wp/api/modules/embed-bundle`
@@ -119,6 +120,7 @@ function sameModuleHashes(
async function fetchModuleHashes(): Promise<void> {
try {
const res = await fetch("/wp/api/modules/hashes");
checkInstalledVersion(res);
if (!res.ok) return;
const body = (await res.json()) as {
hashes?: Record<string, { type?: string; payload_hash: string }>;
@@ -29,6 +29,11 @@ export interface PreviewToken {
varName?: string;
/** SP2a `.K` list accessor on a var token (`$mood.0` -> index 0). */
index?: number;
/** Tag-axis accessor (`$outfit.SHOES` -> "SHOES"). Carried for the same
* reason `index` is: without it the axis segment is not part of the var
* token, so every consumer renders the variable and then the bare text
* ".SHOES" beside it. */
axis?: string;
refUuid?: string;
branches?: string[];
count?: number;
@@ -42,7 +47,11 @@ const REF_VALID_SURFACES = new Set<Surface>(["wildcard"]);
// Group 2 = optional `.K` list accessor (SP2a) so `$mood.0` is ONE var token
// (base name in m[1], index in m[2]) — mirrors engine tokenize.py +
// richTokenize.ts, so the `.K` never strands as a separate text token.
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/y;
// Groups: 1=name · 2=index-first · 3=axis-after-index · 4=axis-first ·
// 5=index-after-axis. Two mirrored alternatives because the accessor order is
// free (`$v.0.AXIS` and `$v.AXIS.0` both parse) — same shape as richTokenize.
const VAR_RE =
/\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?(?:\.([A-Za-z_][A-Za-z0-9_]*))?|\$([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?(?:\.(\d+))?/y;
// Optional `:subcat[,subcat]` per-call sub-category filter — empty
// filter is equivalent to no filter, sub-categories are stripped of
// whitespace + comma-separated.
@@ -90,6 +99,7 @@ export function tokenize(text: string, surface: Surface): PreviewToken[] {
raw: m[0],
varName: m[1],
index: m[2] !== undefined ? Number.parseInt(m[2], 10) : undefined,
axis: m[3],
invalid: !VAR_VALID_SURFACES.has(surface),
});
i += m[0].length;
@@ -146,6 +146,15 @@ interface ResolvedToken {
kind: "literal" | "var-resolved" | "var-unresolved";
varName?: string;
}
/** First tag of `axis` on whichever producer writes `name`, or undefined when
* that producer declares no such `accepts` axis — in which case the read
* stays unresolved rather than silently losing the accessor. */
function producerAxisTag(name: string, axis: string): string | undefined {
const axes = varProducerMap.value.get(name)?.axes ?? [];
const hit = axes.find((a: { axis: string; tags: string[] }) => a.axis === axis);
return hit && hit.tags.length > 0 ? hit.tags[0] : undefined;
}
const resolvedTokens = computed<ResolvedToken[]>(() => {
const tokens: ResolvedToken[] = [];
const map = props.upstreamResolved;
@@ -159,6 +168,21 @@ const resolvedTokens = computed<ResolvedToken[]>(() => {
// SP2a: resolve via the shared accessor so `$mood.0` indexes (and a
// bare `$mood` joins a list) — the `.K` never strands as literal text.
// Presence is keyed on the base name, not the value's type.
if (tok.axis) {
// An axis read resolves to a TAG, never to the variable's text.
// Resolving it as a plain `$var` and letting the tokenizer's axis
// segment fall through as literal is what produced
// "a white t-shirt and denim skirt.SHOES" in both preview lines.
// Runtime rolls one member per pick; with no rng here, show the
// first — same convention the rest of the static preview uses.
const tag = producerAxisTag(tok.varName, tok.axis);
tokens.push(
tag != null
? { text: tag, kind: "var-resolved", varName: tok.varName }
: { text: tok.raw, kind: "var-unresolved", varName: tok.varName },
);
break;
}
if (Object.prototype.hasOwnProperty.call(map, tok.varName)) {
tokens.push({
text: applyVarAccessor(map[tok.varName], tok.index),
@@ -3,22 +3,36 @@ import { describe, it, expect } from "vitest";
import CellRulePopover from "../../../../../src/components/context/editors/constraint/CellRulePopover.vue";
describe("CellRulePopover", () => {
it("renders four labeled state buttons", () => {
it("renders five labeled state buttons", () => {
const wrap = mount(CellRulePopover, {
props: { state: "neutral", factor: 1, srcLabel: "warm", tgtLabel: "positive_qa" },
});
const btns = wrap.findAll("button.pop-btn");
expect(btns).toHaveLength(4);
expect(btns).toHaveLength(5);
expect(btns.map((b) => b.text())).toEqual(
expect.arrayContaining([
expect.stringMatching(/neutral/i),
expect.stringMatching(/exclude/i),
expect.stringMatching(/boost/i),
expect.stringMatching(/reduce/i),
expect.stringMatching(/only/i),
]),
);
});
it("emits only and explains the row-wide effect when only is active", async () => {
const wrap = mount(CellRulePopover, {
props: { state: "neutral", factor: 1, srcLabel: "summer", tgtLabel: "open" },
});
expect(wrap.find(".pop-note").exists()).toBe(false);
await wrap.find("button.b-only").trigger("click");
expect(wrap.emitted("update:state")?.[0]).toEqual(["only"]);
await wrap.setProps({ state: "only" });
expect(wrap.find(".pop-note").text()).toMatch(/without a rule of its own is excluded when summer fires/);
// `only` weighs 1.0, so there is no factor to edit.
expect(wrap.find(".pop-factor").exists()).toBe(false);
});
it("marks the current state button as active", () => {
const wrap = mount(CellRulePopover, {
props: { state: "boost", factor: 1.8, srcLabel: "warm", tgtLabel: "positive_qa" },
@@ -2,7 +2,7 @@
/**
* CellRulePopover — body of the constraint-matrix cell editor.
*
* Renders four labeled state buttons (NEUTRAL/EXCLUDE/BOOST/REDUCE) plus
* Renders five labeled state buttons (NEUTRAL/EXCLUDE/BOOST/REDUCE/ONLY) plus
* a numeric factor input (boost / reduce only) and an optional "Reset"
* button shown when the caller signals there's an override to clear.
*
@@ -12,7 +12,7 @@
*/
import { computed, nextTick, onMounted, ref, watch } from "vue";
type RuleState = "neutral" | "exclude" | "boost" | "reduce";
type RuleState = "neutral" | "exclude" | "boost" | "reduce" | "only";
const props = withDefaults(
defineProps<{
@@ -165,6 +165,19 @@ function onReset(): void {
>
<span class="b-glyph">↓</span> Reduce
</button>
<button
type="button"
class="pop-btn b-only"
:class="{ active: state === 'only' }"
:aria-pressed="state === 'only'"
title="Link: when this source tag fires, only targets with a rule in this row can be picked"
@click="pickState('only')"
>
<span class="b-glyph">✓</span> Only
</button>
</div>
<div v-if="state === 'only'" class="pop-note">
Every target in this row without a rule of its own is excluded when {{ srcLabel }} fires.
</div>
<div v-if="showFactor" class="pop-factor">
@@ -274,6 +287,17 @@ function onReset(): void {
}
.pop-btn.b-exclude.active { background: color-mix(in srgb, var(--wp-danger, #ef4444) 22%, transparent); color: var(--wp-danger); border-color: color-mix(in srgb, var(--wp-danger, #ef4444) 50%, transparent); }
.pop-btn.b-boost.active { background: color-mix(in srgb, var(--wp-success, #22c55e) 22%, transparent); color: var(--wp-success); border-color: color-mix(in srgb, var(--wp-success, #22c55e) 50%, transparent); }
.pop-btn.b-only {
/* Fifth button spans the grid so the 2-column layout stays even. */
grid-column: 1 / -1;
}
.pop-btn.b-only.active { background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent); color: var(--wp-info, #3b82f6); border-color: color-mix(in srgb, var(--wp-info, #3b82f6) 50%, transparent); }
.pop-note {
margin: -2px 0 8px;
font: 10px var(--wp-font-sans, sans-serif);
color: var(--wp-text-muted, #8a8d99);
line-height: 1.4;
}
.pop-btn.b-reduce.active { background: color-mix(in srgb, var(--wp-warn, #f97316) 22%, transparent); color: var(--wp-warn); border-color: color-mix(in srgb, var(--wp-warn, #f97316) 50%, transparent); }
/* ── Factor input — wrap + stacked stepper, mirrors OptionRow's
@@ -101,6 +101,7 @@ interface WildcardPayload {
sub_categories?: string[];
options?: Array<{ value?: string }>;
tag_groups?: Record<string, string[]>;
tag_group_kinds?: Record<string, string>;
}
/** Normalised module shape the resolution lookups consume. Unifies the
@@ -307,6 +308,14 @@ const targetGroups = computed<Record<string, string[]>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return findWildcard(pl.target_wildcard_id)?.tag_groups ?? {};
});
const sourceGroupKinds = computed<Record<string, string>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return findWildcard(pl.source_wildcard_id)?.tag_group_kinds ?? {};
});
const targetGroupKinds = computed<Record<string, string>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return findWildcard(pl.target_wildcard_id)?.tag_group_kinds ?? {};
});
const sourceName = computed(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
@@ -715,6 +724,8 @@ function onSpaClick(): void {
:target-name="targetName"
:source-groups="sourceGroups"
:target-groups="targetGroups"
:source-group-kinds="sourceGroupKinds"
:target-group-kinds="targetGroupKinds"
:stranded="hasDangling"
@update="onUpdate"
/>
@@ -8,11 +8,11 @@ describe("MatrixLegend", () => {
expect(wrap.find(".legend-body").exists()).toBe(false);
});
it("clicking the toggle expands the body with four state rows", async () => {
it("clicking the toggle expands the body with five state rows", async () => {
const wrap = mount(MatrixLegend);
await wrap.find(".legend-toggle").trigger("click");
expect(wrap.find(".legend-body").exists()).toBe(true);
expect(wrap.findAll(".legend-row")).toHaveLength(4);
expect(wrap.findAll(".legend-row")).toHaveLength(5);
});
it("each row labels one state in plain language", async () => {
@@ -20,7 +20,7 @@ describe("MatrixLegend", () => {
await wrap.find(".legend-toggle").trigger("click");
const names = wrap.findAll(".legend-name").map((n) => n.text().toLowerCase());
expect(names).toEqual(
expect.arrayContaining(["neutral", "exclude", "boost", "reduce"]),
expect.arrayContaining(["neutral", "exclude", "boost", "reduce", "only"]),
);
const descs = wrap.findAll(".legend-desc").map((d) => d.text());
expect(descs.some((d) => /excluded entirely/i.test(d))).toBe(true);
@@ -6,6 +6,12 @@
*/
import { ref } from "vue";
defineProps<{
/** Show the accepts-axis row — only when this matrix actually has one, so the
* legend doesn't explain a mark that isn't on screen. */
showAccepts?: boolean;
}>();
const expanded = ref(false);
function toggle(): void {
@@ -46,12 +52,39 @@ function toggle(): void {
<span class="legend-name n-reduce">Reduce</span>
<span class="legend-desc">When the source sub-cat is picked, target values in this column are N times less likely.</span>
</div>
<div class="legend-row">
<span class="legend-sample s-only"><span class="glyph">✓</span></span>
<span class="legend-name n-only">Only</span>
<span class="legend-desc">Links picks. When the source sub-cat is picked, only target values with a rule in this row can be picked; everything else in the row is excluded.</span>
</div>
<div v-if="showAccepts" class="legend-row">
<span class="legend-sample s-axis"><i class="pi pi-sync glyph" aria-hidden="true" /></span>
<span class="legend-name n-axis">Accepts axis</span>
<span class="legend-desc">This group rolls one tag per pick and is readable as <code>$var.NAME</code>. Marked <i class="pi pi-sync" aria-hidden="true" /> on its band.</span>
</div>
</div>
</div>
</template>
<style scoped>
.legend { margin-top: 14px; }
.legend-sample.s-only {
color: var(--wp-info, #3b82f6);
background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent);
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 45%, transparent);
}
.legend-name.n-only { color: var(--wp-info, #3b82f6); }
.legend-sample.s-axis {
color: var(--wp-axis, #fbbf24);
background: color-mix(in srgb, var(--wp-axis, #fbbf24) 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--wp-axis, #fbbf24) 40%, transparent);
}
.legend-name.n-axis { color: var(--wp-axis, #fbbf24); }
.legend-desc code {
font-family: var(--wp-font-mono, monospace);
color: var(--wp-text-muted, #a1a1ad);
}
.legend-desc .pi-sync { color: var(--wp-axis, #fbbf24); font-size: 10px; }
.legend-toggle {
display: inline-flex;
align-items: center;
@@ -100,20 +100,24 @@ describe("constraint ExceptionsSection", () => {
expect(patch.instance?.exception_mode_overrides).toEqual({ '["red","black"]': "boost" });
});
it("mode cycle from reduce goes back to allow (4-state, no disabled)", async () => {
const w = mount(ExceptionsSection, {
props: {
module: makeModule({
instance: { exception_mode_overrides: { '["red","black"]': "reduce" } },
}),
sourceValues: SOURCE_VALUES,
targetValues: TARGET_VALUES,
},
});
await w.find('[data-test="ex-mode-0"]').trigger("click");
const updates = w.emitted("update")!;
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
expect(patch.instance?.exception_mode_overrides).toEqual({ '["red","black"]': "allow" });
it("mode cycle goes reduce → only → allow (5-state, no disabled)", async () => {
const cycleFrom = async (mode: "reduce" | "only") => {
const w = mount(ExceptionsSection, {
props: {
module: makeModule({
instance: { exception_mode_overrides: { '["red","black"]': mode } },
}),
sourceValues: SOURCE_VALUES,
targetValues: TARGET_VALUES,
},
});
await w.find('[data-test="ex-mode-0"]').trigger("click");
const updates = w.emitted("update")!;
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
return patch.instance?.exception_mode_overrides;
};
expect(await cycleFrom("reduce")).toEqual({ '["red","black"]': "only" });
expect(await cycleFrom("only")).toEqual({ '["red","black"]': "allow" });
});
it("factor input visible only when effective mode is boost or reduce", () => {
@@ -417,3 +421,37 @@ describe("constraint ExceptionsSection — stranded read-only", () => {
expect(w.find('[data-test="ex-add-extra"]').exists()).toBe(true);
});
});
describe("constraint ExceptionsSection — only-link note", () => {
const mountWith = (overrides: Partial<ModuleEntry>) =>
mount(ExceptionsSection, {
props: { module: makeModule(overrides), sourceValues: SOURCE_VALUES, targetValues: TARGET_VALUES },
});
it("hides the note when no exception is only", () => {
expect(mountWith({}).find('[data-test="ex-only-note"]').exists()).toBe(false);
});
it("names the source of a library only link", () => {
const w = mountWith({
payload: {
source_wildcard_id: "wc_color", target_wildcard_id: "wc_fabric", matrix: {},
exceptions: [{ source_value: "red", target_value: "black", mode: "only", factor: 1 }],
},
});
expect(w.find('[data-test="ex-only-note"]').text()).toContain("when red is picked");
});
it("follows instance overrides, disabled rows and extras", () => {
const w = mountWith({
instance: {
exception_mode_overrides: { '["blue","green"]': "only" },
disabled_exception_keys: ['["red","black"]'],
extra_exceptions: [{ source_value: "silver", target_value: "white", mode: "only", factor: 1 }],
},
});
const text = w.find('[data-test="ex-only-note"]').text();
expect(text).toContain("blue, silver are picked");
expect(text).not.toContain("red");
});
});
@@ -2,7 +2,7 @@
/**
* Constraint ExceptionsSection — library exception list + extras
* section. Library rows: checkbox toggles `disabled_exception_keys`,
* mode chip cycles 4 modes (no "disabled" — that's the row checkbox's
* mode chip cycles 5 modes (no "disabled" — that's the row checkbox's
* job), factor input visible on boost/reduce.
*
* Extras section: instance-only rows from `extra_exceptions`. Source
@@ -20,7 +20,7 @@ import { patchInstance } from "../../instance/patch";
import RichTextPreview from "../../../../../manager/components/RichTextPreview.vue";
import Select, { type SelectOption } from "../../../../../manager/components/ui/Select.vue";
type Mode = "allow" | "exclude" | "boost" | "reduce";
type Mode = "allow" | "exclude" | "boost" | "reduce" | "only";
/** Mode → glyph + label for the colored mode chips. Glyphs mirror the
* matrix's MODE_ICON / cellGlyph convention (boost ↑ / reduce ↓ /
@@ -32,6 +32,7 @@ const MODE_META: Record<Mode, { glyph: string; label: string }> = {
reduce: { glyph: "↓", label: "Reduce" },
exclude: { glyph: "×", label: "Exclude" },
allow: { glyph: "·", label: "Neutral" },
only: { glyph: "✓", label: "Only" },
};
/**
@@ -183,11 +184,28 @@ function effectiveFactor(exc: LibraryException): number {
return factorOverrides.value[libKey(exc)] ?? exc.factor;
}
/** Source values with an active `only` link (library rows that are enabled,
* after instance mode overrides, plus instance extras). Drives the
* "Linked" note, mirroring the SPA ConstraintEditor's `onlyLinkSources`. */
const onlyLinkSources = computed<string[]>(() => {
const out: string[] = [];
const push = (src: string) => { if (!out.includes(src)) out.push(src); };
for (const exc of libraryExceptions.value) {
if (disabledKeys.value.has(libKey(exc))) continue;
if (effectiveMode(exc) === "only") push(excSrc(exc, props.sourceOptionsById));
}
for (const exc of extras.value) {
if (exc.mode === "only") push(exc.source_value);
}
return out;
});
const MODE_CYCLE: Record<Mode, Mode> = {
allow: "exclude",
exclude: "boost",
boost: "reduce",
reduce: "allow",
reduce: "only",
only: "allow",
};
/**
@@ -608,6 +626,13 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
</div>
</div>
<p v-if="onlyLinkSources.length" class="ex__only-note" data-test="ex-only-note">
<span class="ex__only-note-glyph" aria-hidden="true">✓</span>
Linked: when {{ onlyLinkSources.map((s) => s || "⌀ null").join(", ") }}
{{ onlyLinkSources.length === 1 ? "is" : "are" }} picked, only target values listed here for
{{ onlyLinkSources.length === 1 ? "it" : "them" }} can be picked.
</p>
<button
v-if="!stranded"
type="button"
@@ -676,6 +701,10 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
border-left-color: color-mix(in srgb, var(--wp-warn, #f97316) 60%, transparent);
background: color-mix(in srgb, var(--wp-warn, #f97316) 5%, transparent);
}
.ex__row--only:not(.ex__row--extra) {
border-left-color: color-mix(in srgb, var(--wp-info, #3b82f6) 60%, transparent);
background: color-mix(in srgb, var(--wp-info, #3b82f6) 5%, transparent);
}
.ex__row--exclude:not(.ex__row--extra) {
border-left-color: color-mix(in srgb, var(--wp-danger, #ef4444) 60%, transparent);
background: color-mix(in srgb, var(--wp-danger, #ef4444) 5%, transparent);
@@ -805,6 +834,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
.ex__mode-chip--allow { background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent-text, var(--wp-text)); }
.ex__mode-chip--exclude { background: color-mix(in srgb, var(--wp-danger, #e05252) 22%, transparent); color: var(--wp-danger, #e05252); }
.ex__mode-chip--boost { background: color-mix(in srgb, var(--wp-success, #6bc96f) 22%, transparent); color: var(--wp-success, #6bc96f); }
.ex__mode-chip--only { background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent); color: var(--wp-info, #3b82f6); }
.ex__mode-chip--reduce { background: color-mix(in srgb, var(--wp-warn, #f59e0b) 22%, transparent); color: var(--wp-warn, #f59e0b); }
.ex__factor-wrap {
display: inline-flex;
@@ -911,6 +941,12 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
color: var(--wp-accent-text, var(--wp-text));
}
.ex__add-extra .pi { font-size: 10px; }
.ex__only-note {
margin: 8px 0 0;
font: 400 11px var(--wp-font-sans);
color: var(--wp-text-dim, var(--wp-text3));
}
.ex__only-note-glyph { color: var(--wp-info, #3b82f6); margin-right: 4px; }
.ex__readonly-hint {
margin: 8px 0 0;
font: 10px var(--wp-font-sans);
@@ -1,6 +1,6 @@
// Constraint MatrixSection — sub_cat × sub_cat grid.
//
// Click any cell opens CellRulePopover (4 labeled state buttons +
// Click any cell opens CellRulePopover (5 labeled state buttons +
// numeric factor input). The old 4-state click-cycle and cog-anchored
// factor popover are gone. `mode: "allow"` / `"disabled"` collapse to
// `"neutral"` on read; touching a legacy-disabled cell strips its
@@ -487,3 +487,70 @@ describe("constraint MatrixSection — keyboard", () => {
w.unmount();
});
});
describe("constraint MatrixSection — only (linked picks)", () => {
it("selecting neutral on a library boost stores `allow`, the engine's name", async () => {
// A stored "neutral" override failed the whole constraint at run time.
const updates: Array<Partial<ModuleEntry>> = [];
const w = mount(MatrixSection, {
props: {
module: makeModule(),
sourceSubs: SOURCE_SUBS,
targetSubs: TARGET_SUBS,
"onUpdate": (p: Partial<ModuleEntry>) => updates.push(p),
},
});
await w.find('[data-test="mx-cell-red-silk"]').trigger("click");
await popoverWrap(w).find("button.pop-btn.b-neutral").trigger("click");
const last = updates[updates.length - 1].instance as Record<string, unknown>;
expect(last.cell_mode_overrides).toEqual({ [RED_SILK]: "allow" });
});
it("reads a legacy `neutral` override as neutral", () => {
const w = mount(MatrixSection, {
props: {
module: makeModule({ instance: { cell_mode_overrides: { [RED_SILK]: "neutral" as unknown as "allow" } } }),
sourceSubs: SOURCE_SUBS,
targetSubs: TARGET_SUBS,
},
});
expect(w.find('[data-test="mx-cell-red-silk"]').classes()).toContain("s-neutral");
});
it("selecting only writes the override with no factor", async () => {
const updates: Array<Partial<ModuleEntry>> = [];
const w = mount(MatrixSection, {
props: {
module: makeModule(),
sourceSubs: SOURCE_SUBS,
targetSubs: TARGET_SUBS,
"onUpdate": (p: Partial<ModuleEntry>) => updates.push(p),
},
});
await w.find('[data-test="mx-cell-red-cotton"]').trigger("click");
await popoverWrap(w).find("button.pop-btn.b-only").trigger("click");
const last = updates[updates.length - 1].instance as Record<string, unknown>;
expect(last.cell_mode_overrides).toEqual({ [RED_COTTON]: "only" });
expect(last.cell_factor_overrides ?? null).toBeNull();
});
it("marks the neutral cells of an only row as implied-out", () => {
const w = mount(MatrixSection, {
props: {
module: makeModule({ instance: { cell_mode_overrides: { [BLUE_SILK]: "only" } } }),
sourceSubs: SOURCE_SUBS,
targetSubs: ["cotton", "silk", "wool"],
},
});
const silk = w.find('[data-test="mx-cell-blue-silk"]');
expect(silk.classes()).toContain("s-only");
expect(silk.text()).toContain("✓");
for (const tgt of ["cotton", "wool"]) {
const cell = w.find(`[data-test="mx-cell-blue-${tgt}"]`);
expect(cell.classes()).toContain("implied-out");
expect(cell.text()).toContain("×");
}
// Rows without an only rule are untouched.
expect(w.find('[data-test="mx-cell-red-cotton"]').classes()).not.toContain("implied-out");
});
});
@@ -29,7 +29,7 @@ import {
toGroups,
} from "../../../../shared/matrix-axis";
type Mode = "neutral" | "exclude" | "boost" | "reduce";
type Mode = "neutral" | "exclude" | "boost" | "reduce" | "only";
interface Cell { mode: Mode; factor: number }
@@ -46,6 +46,10 @@ const props = withDefaults(
* ConstraintMatrix. Empty → flat source/target-tinted headers. */
sourceGroups?: Record<string, string[]>;
targetGroups?: Record<string, string[]>;
/** axis name → kind. `"accepts"` axes get a marked band (see SPA
* ConstraintMatrix) — the matrix otherwise can't show accepts-ness. */
sourceGroupKinds?: Record<string, string>;
targetGroupKinds?: Record<string, string>;
/** Read-only recovery view: the source/target wildcard was deleted, so
* the configured rules are shown for understanding only. Cells don't
* open the rule popover; the grid frame + cells mute to a snapshot.
@@ -53,7 +57,8 @@ const props = withDefaults(
* ConstraintMatrix `readonly` prop / `wp-mx--readonly` treatment. */
stranded?: boolean;
}>(),
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}), stranded: false },
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}),
sourceGroupKinds: () => ({}), targetGroupKinds: () => ({}), stranded: false },
);
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
@@ -67,8 +72,8 @@ const instance = computed(() => props.module.instance ?? {});
const disabledCells = computed<Set<string>>(
() => new Set(instance.value.disabled_matrix_cells ?? []),
);
const cellModeOverrides = computed<Record<string, Mode>>(
() => (instance.value.cell_mode_overrides as Record<string, Mode> | null) ?? {},
const cellModeOverrides = computed<Record<string, string>>(
() => (instance.value.cell_mode_overrides as Record<string, string> | null) ?? {},
);
const cellFactorOverrides = computed<Record<string, number>>(
() => (instance.value.cell_factor_overrides as Record<string, number> | null) ?? {},
@@ -80,6 +85,22 @@ const orderedSources = computed(() => orderByGroups([...props.sourceSubs], props
const orderedTargets = computed(() => orderByGroups([...props.targetSubs], props.targetGroups));
const rowGroups = computed(() => toGroups(orderedSources.value));
const colGroups = computed(() => toGroups(orderedTargets.value));
const sourceAcceptsAxes = computed(() => acceptsSet(props.sourceGroupKinds));
const targetAcceptsAxes = computed(() => acceptsSet(props.targetGroupKinds));
function acceptsSet(kinds: Record<string, string>): Set<string> {
return new Set(Object.entries(kinds).filter(([, k]) => k === "accepts").map(([a]) => a));
}
function isAcceptsBand(grp: { axisName: string }, which: "source" | "target"): boolean {
const set = which === "source" ? sourceAcceptsAxes.value : targetAcceptsAxes.value;
return set.has(grp.axisName);
}
function acceptsTip(grp: { axisName: string }): string {
return `Accepts axis — rolls one tag per pick, readable as $var.${grp.axisName}`;
}
const hasAcceptsAxis = computed(
() => sourceAcceptsAxes.value.size > 0 || targetAcceptsAxes.value.size > 0,
);
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
@@ -114,7 +135,10 @@ function effectiveState(src: string, tgt: string): Mode {
const key = encodeKey([src, tgt]);
if (disabledCells.value.has(key)) return "neutral";
const override = cellModeOverrides.value[key];
if (override === "neutral" || override === "exclude" || override === "boost" || override === "reduce") {
// `allow` is how a neutral override is stored (the engine's name for it);
// `neutral` is what earlier builds wrote, still read for saved workflows.
if (override === "allow" || override === "neutral") return "neutral";
if (override === "exclude" || override === "boost" || override === "reduce" || override === "only") {
return override;
}
const lib = libCell(src, tgt);
@@ -140,20 +164,36 @@ function isOverridden(src: string, tgt: string): boolean {
function cellClasses(src: string, tgt: string): string[] {
const out = ["mx-cell", `s-${effectiveState(src, tgt)}`];
if (isOverridden(src, tgt)) out.push("mx-cell--mod");
if (isImpliedOut(src, tgt)) out.push("implied-out");
const open = openPopover.value;
if (open && open.src === src && open.tgt === tgt) out.push("open");
return out;
}
function cellGlyph(src: string, tgt: string): string {
if (isImpliedOut(src, tgt)) return "×";
switch (effectiveState(src, tgt)) {
case "neutral": return "·";
case "exclude": return "×";
case "boost": return "↑";
case "reduce": return "↓";
case "only": return "✓";
}
}
/** Source rows holding an `only` cell (effective, override-aware). Their
* neutral cells are shut out at runtime, so the grid marks them. */
const onlyRows = computed<Set<string>>(() => {
const out = new Set<string>();
for (const src of props.sourceSubs) {
if (props.targetSubs.some((tgt) => effectiveState(src, tgt) === "only")) out.add(src);
}
return out;
});
function isImpliedOut(src: string, tgt: string): boolean {
return onlyRows.value.has(src) && effectiveState(src, tgt) === "neutral";
}
function cellShowsFactor(src: string, tgt: string): boolean {
const s = effectiveState(src, tgt);
return s === "boost" || s === "reduce";
@@ -167,10 +207,11 @@ function cellFactorText(src: string, tgt: string): string {
function cellAriaLabel(src: string, tgt: string): string {
const s = effectiveState(src, tgt);
const factor = (s === "boost" || s === "reduce") ? ` ×${effectiveFactor(src, tgt).toFixed(1)}` : "";
const implied = isImpliedOut(src, tgt) ? " (excluded by an only rule in this row)" : "";
// Stranded → the grid is a read-only snapshot, so drop the "Click to
// edit" affordance from the label (the cell is inert).
const suffix = props.stranded ? " (read-only)" : ". Click to edit.";
return `Rule: ${src} → ${tgt}, current state ${s}${factor}${suffix}`;
return `Rule: ${src} → ${tgt}, current state ${s}${factor}${implied}${suffix}`;
}
// ── Popover ────────────────────────────────────────────────────
@@ -246,9 +287,11 @@ function commitMode(src: string, tgt: string, next: Mode): void {
const key = encodeKey([src, tgt]);
const lib = libCell(src, tgt);
const libMode: Mode = lib ? lib.mode : "neutral";
const modeMap = { ...cellModeOverrides.value };
const modeMap: Record<string, string> = { ...cellModeOverrides.value };
if (next === libMode) delete modeMap[key];
else modeMap[key] = next;
// Store neutral as `allow`, the engine's name for it — a stored "neutral"
// failed the whole constraint at run time.
else modeMap[key] = next === "neutral" ? "allow" : next;
const factorMap = { ...cellFactorOverrides.value };
// When leaving boost/reduce, drop the factor override too so the
// cell falls back fully to library / default.
@@ -388,8 +431,9 @@ onBeforeUnmount(() => {
:class="{ 'mx-th-band--bucket': isBucket(grp) }"
:colspan="grp.tags.length"
:style="{ '--ax': tagHue(grp, 'target', true) }"
:title="groupLabel(grp)"
><span class="chip">{{ groupLabel(grp) }}</span></th>
:title="isAcceptsBand(grp, 'target') ? acceptsTip(grp) : groupLabel(grp)"
><span class="chip">{{ groupLabel(grp)
}}<i v-if="isAcceptsBand(grp, 'target')" class="pi pi-sync mx-axis-mark" aria-hidden="true" /></span></th>
</template>
<!-- Flat cols: tag headers sit directly in the single header row. -->
<template v-else>
@@ -427,8 +471,9 @@ onBeforeUnmount(() => {
:class="{ 'mx-grp-head--bucket': isBucket(grp) }"
:colspan="orderedTargets.length + 1"
:style="{ '--ax': tagHue(grp, 'source', true) }"
:title="groupLabel(grp)"
><span class="chip">{{ groupLabel(grp) }}</span></th>
:title="isAcceptsBand(grp, 'source') ? acceptsTip(grp) : groupLabel(grp)"
><span class="chip">{{ groupLabel(grp)
}}<i v-if="isAcceptsBand(grp, 'source')" class="pi pi-sync mx-axis-mark" aria-hidden="true" /></span></th>
</tr>
<tr v-for="s in grp.tags" :key="s">
<th
@@ -440,7 +485,8 @@ onBeforeUnmount(() => {
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
>
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
<span class="eye">{{ grp.axisName }}</span>
<span class="eye">{{ grp.axisName
}}<i v-if="isAcceptsBand(grp, 'source')" class="pi pi-sync mx-axis-mark" :title="acceptsTip(grp)" aria-hidden="true" /></span>
<span class="v">{{ s }}</span>
</span>
<span v-else class="chip">{{ s }}</span>
@@ -474,7 +520,7 @@ onBeforeUnmount(() => {
Reattach a live wildcard to edit these rules.
</p>
<MatrixLegend />
<MatrixLegend :show-accepts="hasAcceptsAxis" />
<!-- Popover lives in a body-level portal so its hover state stays
isolated from the source cell, the cells in the row below
@@ -509,6 +555,7 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.mx-axis-mark { color: var(--wp-axis, #fbbf24); font-size: 9px; margin-left: 5px; vertical-align: baseline; }
.mx {
padding: 12px 16px;
background: var(--wp-bg2);
@@ -744,6 +791,17 @@ onBeforeUnmount(() => {
color: var(--wp-warn);
border: 1px solid color-mix(in srgb, var(--wp-warn, #f97316) 45%, transparent);
}
.mx-cell.s-only {
background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent);
color: var(--wp-info, #3b82f6);
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 45%, transparent);
}
/* A neutral cell in a row that has an Only rule: shut out at runtime. Faint
* danger hue on the dashed border so it reads as implied, not authored. */
.mx-cell.s-neutral.implied-out {
color: color-mix(in srgb, var(--wp-danger, #ef4444) 60%, var(--wp-text-dim, #595c66));
border-color: color-mix(in srgb, var(--wp-danger, #ef4444) 30%, transparent);
}
.mx-cell--mod {
outline: 1px dashed var(--wp-status-modified, #fb923c);
outline-offset: -1px;
@@ -781,6 +839,11 @@ onBeforeUnmount(() => {
border: 1px solid color-mix(in srgb, var(--wp-danger, #ef4444) 18%, transparent);
color: color-mix(in srgb, var(--wp-danger, #ef4444) 70%, var(--wp-text-dim));
}
.mx--readonly .mx-cell.s-only {
background: color-mix(in srgb, var(--wp-info, #3b82f6) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 18%, transparent);
color: color-mix(in srgb, var(--wp-info, #3b82f6) 70%, var(--wp-text-dim));
}
.mx__readonly-hint {
margin: 8px 0 0;
font: 10px var(--wp-font-sans);
@@ -3,65 +3,102 @@ import { mount } from "@vue/test-utils";
import ValueRow from "./ValueRow.vue";
import type { LibraryRow, DraftRow } from "../defaults";
// The value field is a RichTextInput now, so booru tag autocomplete works in
// it. Stubbed here: it is contenteditable with atom spans, which jsdom does
// not implement, and these tests are about ValueRow's wiring.
const RichTextInputStub = {
name: "RichTextInput",
props: {
modelValue: String,
surface: String,
multiline: Boolean,
rows: [Number, String],
disabled: Boolean,
ariaLabel: String,
},
emits: ["update:modelValue"],
template: `<div class="wp-rt-stub" data-test="row-value" :data-model-value="modelValue"></div>`,
};
const globalStubs = { RichTextInput: RichTextInputStub };
/** What the value field is showing. */
const valueOf = (w: any) => w.findComponent(RichTextInputStub).props("modelValue");
/** Type into the value field. */
async function typeValue(w: any, v: string): Promise<void> {
w.findComponent(RichTextInputStub).vm.$emit("update:modelValue", v);
await w.vm.$nextTick();
}
const lib: LibraryRow = { id: "v1", name: "lens", value: "85mm" };
const plainDraft: DraftRow = { id: "v1", name: "lens", value: "85mm", enabled: true, libraryId: "v1" };
describe("ValueRow", () => {
it("renders $-prefixed name + value inputs", () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-name-prefix"]').text()).toBe("$");
expect(w.find<HTMLInputElement>('[data-test="row-name"]').element.value).toBe("lens");
expect(w.find<HTMLInputElement>('[data-test="row-value"]').element.value).toBe("85mm");
expect(valueOf(w)).toBe("85mm");
});
it("value field is a growing textarea, not a single-line input", async () => {
it("value field grows rather than being a single line", async () => {
// Users routinely paste a whole sentence into a fixed value; a one-line
// <input> showed a sliver with no way to see the rest.
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const el = w.find('[data-test="row-value"]');
expect(el.element.tagName).toBe("TEXTAREA");
// field showed a sliver with no way to see the rest. The requirement is
// "multiline and grows", not "is a <textarea>" — it is a RichTextInput
// now, which carries the same `useGrowableField` auto-grow, overflow fade
// and drag grip, plus the tag autocomplete a textarea could not host.
const w = mount(ValueRow, {
props: { row: plainDraft, library: lib },
global: { stubs: globalStubs },
});
const rt = w.findComponent(RichTextInputStub);
expect(rt.exists()).toBe(true);
expect(rt.props("multiline")).toBe(true);
});
it("Enter inserts a newline like any textarea", async () => {
// Briefly swallowed for parity with the single-line <input> this replaced,
// which was parity for its own sake: the engine stores the value verbatim,
// multi-line round-trips fine, and the modal saves on Cmd/Ctrl+Enter.
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const el = w.find('[data-test="row-value"]');
const ev = new KeyboardEvent("keydown", { key: "Enter", cancelable: true, bubbles: true });
el.element.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(false);
it("offers tag autocomplete but not $ or @", () => {
// The engine treats fixed_values as a binding PRODUCER: it DEFINES what
// `$name` resolves to, so `$var` reads and `@{}` refs are gated off and
// render as literal text with a warning. The surface prop is what carries
// that rule into the editor.
const w = mount(ValueRow, {
props: { row: plainDraft, library: lib },
global: { stubs: globalStubs },
});
expect(w.findComponent(RichTextInputStub).props("surface")).toBe("fixed_values");
});
it("keeps a multi-line value intact on input", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const el = w.find<HTMLTextAreaElement>('[data-test="row-value"]');
el.element.value = "line one\nline two";
await el.trigger("input");
// Multi-line values round-trip: the engine stores the value verbatim, and
// the modal saves on Cmd/Ctrl+Enter so plain Enter stays free for a
// newline. Previously two tests — one drove a keydown to prove Enter was
// not swallowed, which only made sense against a real <textarea>.
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
await typeValue(w, "line one\nline two");
const updates = w.emitted("update") ?? [];
expect(updates[updates.length - 1]).toEqual(["v1", { value: "line one\nline two" }]);
});
it("checkbox is checked when enabled", () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-check"]').classes()).toContain("row__check--on");
});
it("checkbox unchecked + line-through when disabled", () => {
const draft = { ...plainDraft, enabled: false };
const w = mount(ValueRow, { props: { row: draft, library: lib } });
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-check"]').classes()).not.toContain("row__check--on");
expect(w.classes()).toContain("row--off");
});
it("emits toggle event with row id when checkbox clicked", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
await w.find('[data-test="row-check"]').trigger("click");
expect(w.emitted("toggle")?.[0]).toEqual(["v1"]);
});
it("emits update event with new name on name input", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
const input = w.find<HTMLInputElement>('[data-test="row-name"]');
input.element.value = "camera_lens";
await input.trigger("input");
@@ -69,17 +106,15 @@ describe("ValueRow", () => {
});
it("emits update event with new value on value input", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const input = w.find<HTMLInputElement>('[data-test="row-value"]');
input.element.value = "50mm";
await input.trigger("input");
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
await typeValue(w, "50mm");
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "50mm" }]);
});
it("strips comma + punctuation from the name on input (value names must be identifiers)", async () => {
// The name becomes a `$var`; only identifier chars allowed. The VALUE
// field (below) is free-form and must NOT be sanitized.
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
const input = w.find<HTMLInputElement>('[data-test="row-name"]');
input.element.value = "cam,era;lens!";
await input.trigger("input");
@@ -88,15 +123,13 @@ describe("ValueRow", () => {
});
it("does NOT strip punctuation from the value field (values are free-form)", async () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const input = w.find<HTMLInputElement>('[data-test="row-value"]');
input.element.value = "85mm, f/1.8!";
await input.trigger("input");
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
await typeValue(w, "85mm, f/1.8!");
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "85mm, f/1.8!" }]);
});
it("library row with no override is plain (no --mod, no reset button)", () => {
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-name-wrap"]').classes()).not.toContain("row__name-wrap--mod");
expect(w.find('[data-test="row-value-wrap"]').classes()).not.toContain("row__value-wrap--mod");
expect(w.find('[data-test="row-reset"]').exists()).toBe(false);
@@ -104,21 +137,21 @@ describe("ValueRow", () => {
it("library row with value override gets --mod class on value wrap + reset visible", () => {
const draft: DraftRow = { ...plainDraft, value: "50mm" };
const w = mount(ValueRow, { props: { row: draft, library: lib } });
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-value-wrap"]').classes()).toContain("row__value-wrap--mod");
expect(w.find('[data-test="row-reset"]').exists()).toBe(true);
});
it("library row with name override gets --mod class on name wrap", () => {
const draft: DraftRow = { ...plainDraft, name: "camera_lens" };
const w = mount(ValueRow, { props: { row: draft, library: lib } });
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
expect(w.find('[data-test="row-name-wrap"]').classes()).toContain("row__name-wrap--mod");
expect(w.find('[data-test="row-reset"]').exists()).toBe(true);
});
it("instance-added row (no library) shows --added + delete button (no reset)", () => {
const draft: DraftRow = { id: "fresh01", name: "mood", value: "cozy", enabled: true, libraryId: null };
const w = mount(ValueRow, { props: { row: draft, library: undefined } });
const w = mount(ValueRow, { props: { row: draft, library: undefined }, global: { stubs: globalStubs } });
expect(w.classes()).toContain("row--added");
expect(w.find('[data-test="row-delete"]').exists()).toBe(true);
expect(w.find('[data-test="row-reset"]').exists()).toBe(false);
@@ -126,14 +159,14 @@ describe("ValueRow", () => {
it("reset button click emits reset with row id", async () => {
const draft: DraftRow = { ...plainDraft, value: "50mm" };
const w = mount(ValueRow, { props: { row: draft, library: lib } });
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
await w.find('[data-test="row-reset"]').trigger("click");
expect(w.emitted("reset")?.[0]).toEqual(["v1"]);
});
it("delete button click emits delete with row id", async () => {
const draft: DraftRow = { id: "fresh01", name: "mood", value: "cozy", enabled: true, libraryId: null };
const w = mount(ValueRow, { props: { row: draft, library: undefined } });
const w = mount(ValueRow, { props: { row: draft, library: undefined }, global: { stubs: globalStubs } });
await w.find('[data-test="row-delete"]').trigger("click");
expect(w.emitted("delete")?.[0]).toEqual(["fresh01"]);
});
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useGrowableField } from "../../../../shared/useGrowableField";
import { computed } from "vue";
import RichTextInput from "../../../../../manager/components/RichTextInput.vue";
import {
rowOverrideKind,
type DraftRow,
@@ -62,12 +62,7 @@ function onNameInput(ev: Event): void {
if (el.value !== name) el.value = name;
emit("update", props.row.id, { name });
}
function onValueInput(ev: Event): void {
const el = ev.target as HTMLTextAreaElement;
emit("update", props.row.id, { value: el.value });
autosize();
updateOverflowHint();
}
/** Grow the value field to fit its content, capped by the CSS `max-height`
* (after which it scrolls). The field is a `<textarea>` purely to get this
@@ -89,19 +84,6 @@ function onValueInput(ev: Event): void {
* plain Enter is free — and users writing paragraph-length values want their
* line breaks. */
const valueEl = ref<HTMLTextAreaElement | null>(null);
/* Shared auto-grow + overflow-hint + grip-follow. See `useGrowableField` for
* why the three belong together — chiefly that auto-grow has to yield to the
* drag handle, which the local copy did not. */
const {
hasMoreBelow,
updateOverflowHint,
scheduleOverflowHint,
autosize,
attach,
startResize,
} = useGrowableField(() => valueEl.value);
/** True when the field is capped and still hiding content below the fold.
* Mirrors RichTextInput's hint — a capped box otherwise looks identical
@@ -112,23 +94,6 @@ const {
/* Size correctly on first paint + whenever the row's value changes from
outside (reset-to-library, undo). */
watch(
() => props.row.value,
() => void nextTick(() => {
autosize();
scheduleOverflowHint();
}),
{ immediate: true },
);
onMounted(() => {
autosize();
scheduleOverflowHint();
attach();
});
function onReset(): void {
@@ -197,33 +162,26 @@ function onDelete(): void {
class="row__value-wrap"
:class="{
'row__value-wrap--mod': valueOverridden,
'row__value-wrap--more': hasMoreBelow,
}"
data-test="row-value-wrap"
>
<textarea
ref="valueEl"
<!-- RichTextInput on the `fixed_values` surface. Tag autocomplete
works here; `$` and `@` do NOT, and that is the engine's rule not a
UI preference: a fixed value DEFINES what `$name` resolves to, so
`resolve_text` gates both off and renders such a token as literal
text with a warning. The producer/consumer default in
RichTextInput encodes exactly that, so nothing is passed here. -->
<RichTextInput
class="row__value"
data-test="row-value"
rows="1"
spellcheck="false"
:value="row.value"
:model-value="row.value"
surface="fixed_values"
multiline
:rows="1"
:disabled="!row.enabled"
:aria-label="`Value for row ${row.id}`"
@input="onValueInput"
@scroll="updateOverflowHint"
@update:model-value="(v: string) => emit('update', props.row.id, { value: v })"
/>
<!-- Resize grip, replacing `resize: vertical`. The native resizer
recomputes from an origin captured at pointerdown and never clamps
to min/max, so over-dragging banks invisible travel the user has to
walk back — measured at 13 of 23 dead frames. Ours applies each
move's delta to the current height. -->
<span
class="row__grip"
data-test="row-grip"
aria-hidden="true"
@pointerdown="startResize"
></span>
</span>
<span
v-if="hasNonTextToken"
@@ -344,71 +302,30 @@ function onDelete(): void {
}
.row__name:focus, .row__value:focus { outline: none; }
/* The value field is a <textarea> so a long value WRAPS and the box grows to
* fit (height driven by `autosizeValue`). Capped so a pasted paragraph can't
* push the rest of the modal off-screen; past the cap it scrolls. */
/* This class sits on the RichTextInput ROOT, and everything it used to declare
* about scrolling and sizing belonged to the <textarea> that used to be here.
*
* That textarea went in `ca87384b`. The rule stayed, and the two scroll
* declarations then nested: the component's own `.wp-rt__host` already caps
* and scrolls, so a capped, scrolling box sat inside another capped, scrolling
* box and the row showed TWO vertical scrollbars. `min-height`, `max-height`,
* `line-height` and `white-space` were the same mistake without a visible
* symptom — the component declares each of them for itself.
*
* Only `word-break` survives, because a long unbroken value is a row-layout
* concern rather than an editor one. */
.row__value {
/* Manual drag handle as well as the auto-grow, matching the derivation and
combine template inputs — auto-sizing picks a sensible height, the handle
lets the user override it. */
overscroll-behavior: contain;
overflow-y: auto;
/* Floor for the drag handle. Without it the computed `min-height` is `auto`,
which reads as 0 — so the field could be dragged away to a sliver with no
way to see what you were editing. One line plus its padding. */
min-height: 28px;
max-height: 12rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--wp-font-mono);
}
/* The wrapper centred a single-line input; with a growing field the label
* column should sit at the top instead of drifting down as it grows. */
.row__value-wrap { align-items: flex-start; position: relative; }
/* "More below" hint, matching RichTextInput. A <textarea> can't carry a
* dependable `::after`, so it hangs off the wrapper. Gradient runs to a
* TRANSPARENT accent so the text underneath still reads; the 1px accent line
* at the bottom is what catches the eye. */
/* Sits in the corner the overflow fade deliberately leaves clear. */
.row__grip {
position: absolute;
right: 0;
bottom: 0;
width: 14px;
height: 14px;
cursor: ns-resize;
z-index: 2;
background: repeating-linear-gradient(
135deg,
transparent 0 2px,
var(--wp-text3, #8a8a9a) 2px 3px
);
/* Clipped to a bottom-right triangle so the hatching runs parallel to the
hypotenuse — the shape the browser's own resizer draws, which is what
users already read as "drag me". A filled square read as a button. */
clip-path: polygon(100% 0, 100% 100%, 0 100%);
opacity: 0.55;
}
.row__grip:hover { opacity: 0.9; }
/* The overflow fade and the drag grip both live in RichTextInput now, and both
* were reimplemented here only because a <textarea> could not carry them. The
* markup for each went with the textarea in `ca87384b`; these rules outlived
* it, matching nothing. */
.row__value-wrap--more::after {
content: "";
position: absolute;
/* Right edge stops short of the resize grip. The band is as tall as the
handle and sat straight on top of it, so a resizable field's grip was
invisible — users aimed, missed, re-grabbed. */
inset: auto 16px 0 0;
height: 14px;
pointer-events: none;
background: linear-gradient(
to bottom,
transparent,
color-mix(in srgb, var(--wp-accent, #6366f1) 26%, transparent)
);
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--wp-accent, #6366f1) 60%, transparent);
}
.row--off { color: var(--wp-text-dim, var(--wp-text3)); }
.row--off .row__name-wrap, .row--off .row__value-wrap { opacity: 0.5; }
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it } from "vitest";
import { version } from "../../../package.json" with { type: "json" };
import { toasts } from "../shared/toast-store";
import { _resetVersionNoticeForTesting, checkInstalledVersion } from "./version-notice";
const res = (v?: string) =>
new Response(null, { status: 204, headers: v ? { "X-WP-Version": v } : {} });
describe("checkInstalledVersion", () => {
beforeEach(() => {
toasts.value = [];
_resetVersionNoticeForTesting();
});
it("does nothing when the installed version matches the loaded one", () => {
checkInstalledVersion(res(version));
checkInstalledVersion(res());
expect(toasts.value).toHaveLength(0);
});
it("asks once for a refresh when the pack was updated", () => {
checkInstalledVersion(res("99.0.0"));
checkInstalledVersion(res("99.0.0"));
expect(toasts.value).toHaveLength(1);
const t = toasts.value[0];
expect(t.message).toContain("updated to v99.0.0");
expect(t.severity).toBe("warning");
expect(t.lifeMs).toBe(0);
expect(t.action?.label).toBe("Refresh");
});
});
+31
View File
@@ -0,0 +1,31 @@
import { version as loadedVersion } from "../../../package.json" with { type: "json" };
import { pushToast } from "../shared/toast-store";
/**
* The canvas keeps running the extension JS it loaded, even after the pack
* is updated and ComfyUI restarts. Every `/wp/*` response carries the pack
* version installed on disk (`X-WP-Version`); when it differs from the
* version this bundle was built as, ask once for a page refresh.
*/
let notified = false;
export function checkInstalledVersion(res: Response): void {
if (notified) return;
const installed = res.headers.get("X-WP-Version");
if (!installed || installed === loadedVersion) return;
notified = true;
pushToast(
`Wildcard Pipeline was updated to v${installed}. Refresh the page to load it.`,
{
severity: "warning",
lifeMs: 0,
singletonKey: "wp-version-updated",
action: { label: "Refresh", onSelect: () => window.location.reload() },
},
);
}
/** Test-only: allow the notice to fire again. */
export function _resetVersionNoticeForTesting(): void {
notified = false;
}
@@ -4,6 +4,31 @@ import { nextTick } from "vue";
import InjectorBindingModal from "./InjectorBindingModal.vue";
import type { InjectorRow } from "../../widgets/_shared";
// The template field is a RichTextInput now, not a <textarea>: it needs the
// `$` handler, and the ref list that already fed the "+ $ref" menu is exactly
// the right suggestion pool. Stubbed the same way the combine editor's tests
// stub it — assert prop wiring and simulate emits rather than driving
// contenteditable in jsdom.
const RichTextInputStub = {
name: "RichTextInput",
props: ["modelValue", "varSuggestions", "multiline", "rows", "surface", "placeholder", "ariaLabel"],
emits: ["update:modelValue"],
template: `<div class="wp-rt-stub" data-test="ibm-template" :data-model-value="modelValue"></div>`,
};
const globalStubs = { RichTextInput: RichTextInputStub };
/** Read what the template field is currently showing. */
function templateValue(w: any): string | undefined {
return w.findComponent(RichTextInputStub).props("modelValue");
}
/** Type into the template field. */
async function typeTemplate(w: any, value: string): Promise<void> {
w.findComponent(RichTextInputStub).vm.$emit("update:modelValue", value);
await w.vm.$nextTick();
}
// The insert menu is `<Teleport to="body">` — it escapes the modal's scroll
// container so it can't be clipped, which means it is NOT inside the test
// wrapper's subtree. Query the document for it instead.
@@ -25,7 +50,18 @@ async function clickMenuItem(name: string): Promise<void> {
const mounted: { unmount: () => void }[] = [];
type MountArgs = Parameters<typeof mount<typeof InjectorBindingModal>>;
function mountModal(...args: MountArgs): ReturnType<typeof mount<typeof InjectorBindingModal>> {
const w = mount(...args);
// Stub RichTextInput for every mount here. It is contenteditable with atom
// spans, which jsdom does not implement, and these tests are about the
// modal's own wiring rather than the editor's internals.
const [component, options] = args as [unknown, Record<string, any> | undefined];
const merged = {
...(options ?? {}),
global: {
...((options?.global as Record<string, unknown>) ?? {}),
stubs: { ...globalStubs, ...((options?.global as any)?.stubs ?? {}) },
},
};
const w = mount(component as never, merged as never);
mounted.push(w);
return w;
}
@@ -77,13 +113,13 @@ describe("InjectorBindingModal — template section", () => {
const w = mountModal(InjectorBindingModal, {
props: { row: makeRow({ template: "i love $input_1" }) },
});
const ta = w.find<HTMLTextAreaElement>('[data-test="ibm-template"]');
expect(ta.element.value).toBe("i love $input_1");
const shown = templateValue(w);
expect(shown).toBe("i love $input_1");
});
it("Save emits a non-empty template string verbatim (no null collapse)", async () => {
const w = mountModal(InjectorBindingModal, { props: { row: makeRow() } });
await w.find<HTMLTextAreaElement>('[data-test="ibm-template"]').setValue("i love $input_2");
await typeTemplate(w, "i love $input_2");
await w.find('[data-test="ibm-save"]').trigger("click");
const updates = w.emitted("update")!;
const last = updates[updates.length - 1][0] as Partial<InjectorRow>;
@@ -95,7 +131,7 @@ describe("InjectorBindingModal — template section", () => {
const w = mountModal(InjectorBindingModal, {
props: { row: makeRow({ template: "hello" }) },
});
await w.find<HTMLTextAreaElement>('[data-test="ibm-template"]').setValue(" ");
await typeTemplate(w, " ");
await w.find('[data-test="ibm-save"]').trigger("click");
const updates = w.emitted("update")!;
const last = updates[updates.length - 1][0] as Partial<InjectorRow>;
@@ -142,8 +178,8 @@ describe("InjectorBindingModal — template section", () => {
props: { row: makeRow({ template: "hi" }) },
});
await w.find('[data-test="ibm-template-reset"]').trigger("click");
const ta = w.find<HTMLTextAreaElement>('[data-test="ibm-template"]');
expect(ta.element.value).toBe("");
const shown = templateValue(w);
expect(shown).toBe("");
});
it("typing a bare $ does NOT crash the tokenizer (regression)", () => {
@@ -252,7 +288,7 @@ describe("InjectorBindingModal — general (template) row", () => {
const w = mountModal(InjectorBindingModal, {
props: { row: generalRow({ binding: "combo", template: "$input_0" }), references: ["input_0", "test"] },
});
await w.find<HTMLTextAreaElement>('[data-test="ibm-template"]').setValue("$input_0 by $test");
await typeTemplate(w, "$input_0 by $test");
await w.find('[data-test="ibm-save"]').trigger("click");
const updates = w.emitted("update")!;
const last = updates[updates.length - 1][0] as Partial<InjectorRow>;
@@ -22,6 +22,7 @@
*/
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import type { InjectorRow } from "../../widgets/_shared";
import RichTextInput from "../../manager/components/RichTextInput.vue";
const props = withDefaults(
defineProps<{
@@ -144,7 +145,6 @@ const insertOptions = computed<InsertOption[]>(() => {
}];
});
const taRef = ref<HTMLTextAreaElement | null>(null);
const showInsertMenu = ref(false);
// Draft buffer — mirrors the module InstanceModal pattern. Edits land
@@ -175,33 +175,36 @@ function onBindingInput(ev: Event): void {
draftBinding.value = (ev.target as HTMLInputElement).value;
}
function onTemplateInput(ev: Event): void {
draftTemplate.value = (ev.target as HTMLTextAreaElement).value;
}
/**
* The `$` pool for this modal, and ONLY this modal's refs.
*
* A socket row can reference just its own slot; the general row can reference
* every socket. That is the same rule `insertOptions` already encodes for the
* "+ $ref" menu, so the autocomplete and the menu cannot drift apart — they
* read the same list. Deliberately not the library's variables: nothing here
* can resolve one.
*/
const refSuggestionNames = computed(() => insertOptions.value.map((o) => o.slotName));
function onResetTemplate(): void {
draftTemplate.value = "";
}
/**
* The "+ $ref" menu still appends, but no longer reaches into a textarea's
* selection to do it.
*
* RichTextInput owns its own caret — it is contenteditable with atom spans,
* not a plain text box, so `selectionStart` does not exist and the old
* splice-at-cursor path had nothing to splice into. Appending is honest about
* what it does; the menu is now the fallback anyway, since typing `$` opens
* the same list in place.
*/
function insertSlotRef(slotName: string): void {
const ta = taRef.value;
const insertion = `$${slotName}`;
const current = templateValue.value;
let next: string;
if (ta && typeof ta.selectionStart === "number") {
const start = ta.selectionStart;
const end = ta.selectionEnd ?? start;
next = current.slice(0, start) + insertion + current.slice(end);
const caret = start + insertion.length;
queueMicrotask(() => {
ta.focus();
ta.setSelectionRange(caret, caret);
});
} else {
next = current + insertion;
}
const sep = current && !current.endsWith(" ") ? " " : "";
closeInsertMenu();
draftTemplate.value = next;
draftTemplate.value = `${current}${sep}$${slotName}`;
}
function onSave(): void {
@@ -478,18 +481,26 @@ function onKeydown(ev: KeyboardEvent): void {
</div>
</div>
<textarea
ref="taRef"
<!-- RichTextInput rather than a bare <textarea>: the `$` handler it
brings is exactly what this field needs, and the ref list feeding
the "+ $ref" menu beside it was already the right suggestion pool
— it simply had nothing to feed. `assembler` surface because this
composes a template; it is NOT `wildcard`, which suppresses `$`
on purpose. -->
<RichTextInput
class="ibm__template"
:class="{ 'ibm__template--set': !!templateValue }"
data-test="ibm-template"
:value="templateValue"
:model-value="templateValue"
surface="assembler"
multiline
:rows="3"
:var-suggestions="refSuggestionNames"
:placeholder="isGeneral
? 'e.g. $input_0 by $test — composed after all socket rows'
: 'e.g. i love $input_0 — leave empty to pass the raw socket value'"
aria-label="Template"
rows="3"
@input="onTemplateInput"
@update:model-value="(v: string) => (draftTemplate = v)"
/>
<div v-if="templateValue" class="ibm__detected">
@@ -733,7 +744,9 @@ function onKeydown(ev: KeyboardEvent): void {
font: 11px/1.5 var(--wp-font-mono);
color: var(--wp-text);
min-height: 56px;
resize: vertical; overscroll-behavior: contain;
/* No `resize: vertical` here. This class is on the RichTextInput root, and
the component carries its own drag grip — the native resizer put a second
handle in the same corner. Left over from the <textarea> this replaced. */
}
.ibm__template:focus { outline: none; border-color: var(--wp-accent); }
.ibm__template--set {
@@ -114,10 +114,13 @@ describe("InjectorWidget — template persistence (Phase 6)", () => {
editItem!.click();
await w.vm.$nextTick();
const textarea = document.querySelector<HTMLTextAreaElement>('[data-test="ibm-template"]');
expect(textarea).not.toBeNull();
textarea!.value = "i love $input_0";
textarea!.dispatchEvent(new Event("input"));
// The template field is a RichTextInput (contenteditable) rather than a
// <textarea>, so drive the component instead of assigning `.value`.
// jsdom does not implement contenteditable editing, and this test cares
// that the value reaches the persisted JSON, not how the caret moved.
const rt = w.findComponent({ name: "RichTextInput" });
expect(rt.exists()).toBe(true);
rt.vm.$emit("update:modelValue", "i love $input_0");
await w.vm.$nextTick();
const saveBtn = document.querySelector<HTMLButtonElement>('[data-test="ibm-save"]');
@@ -263,7 +263,8 @@ describe("SeedListWidget frame chips", () => {
// writing to a different node.
it("refuses to change bypass — it is owned by the loop", async () => {
const w = fw({}, { bypassedFrames: [1] });
await w.find('[data-test="seedlist-frame-2"]').trigger("click", { altKey: true });
// Ctrl is the bypass chord now; here bypass is foreign, so it must no-op.
await w.find('[data-test="seedlist-frame-2"]').trigger("click", { ctrlKey: true });
expect(w.emitted("update:modelValue")).toBeUndefined();
});
@@ -78,6 +78,10 @@ const reduceMotion = ref<A11yMode>("auto");
const contrast = ref<A11yMode>("auto");
// Phase 2 — behavior axes
const validation = ref<ValidationMode>("strict");
const tagAutocomplete = ref<boolean>(false);
const loraAutocomplete = ref<boolean>(false);
const embeddingAutocomplete = ref<boolean>(false);
const autocompleteSeparator = ref<boolean>(false);
const toastLifetime = ref<ToastLifetime>("default");
const suppressInfoToasts = ref<boolean>(false);
const newModuleDisabled = ref<boolean>(false);
@@ -103,6 +107,10 @@ function syncFromStore(): void {
reduceMotion.value = asString(getSettingValue("reduceMotion"), "auto") as A11yMode;
contrast.value = asString(getSettingValue("contrast"), "auto") as A11yMode;
validation.value = asString(getSettingValue("validation"), "strict") as ValidationMode;
tagAutocomplete.value = getSettingValue("tagAutocomplete") === true;
loraAutocomplete.value = getSettingValue("loraAutocomplete") === true;
embeddingAutocomplete.value = getSettingValue("embeddingAutocomplete") === true;
autocompleteSeparator.value = getSettingValue("autocompleteSeparator") === true;
toastLifetime.value = asString(getSettingValue("toastLifetime"), "default") as ToastLifetime;
suppressInfoToasts.value = asBool(getSettingValue("suppressInfoToasts"), false);
newModuleDisabled.value = asBool(getSettingValue("newModuleDisabled"), false);
@@ -132,6 +140,10 @@ watch(focusMode, (v) => applySetting("focusMode", v));
watch(reduceMotion, (v) => applySetting("reduceMotion", v));
watch(contrast, (v) => applySetting("contrast", v));
watch(validation, (v) => applySetting("validation", v));
watch(tagAutocomplete, (v) => applySetting("tagAutocomplete", v));
watch(loraAutocomplete, (v) => applySetting("loraAutocomplete", v));
watch(embeddingAutocomplete, (v) => applySetting("embeddingAutocomplete", v));
watch(autocompleteSeparator, (v) => applySetting("autocompleteSeparator", v));
watch(toastLifetime, (v) => applySetting("toastLifetime", v));
watch(suppressInfoToasts, (v) => applySetting("suppressInfoToasts", v));
watch(newModuleDisabled, (v) => applySetting("newModuleDisabled", v));
@@ -152,6 +164,10 @@ interface Defaults {
reduceMotion: A11yMode;
contrast: A11yMode;
validation: ValidationMode;
tagAutocomplete: boolean;
loraAutocomplete: boolean;
embeddingAutocomplete: boolean;
autocompleteSeparator: boolean;
toastLifetime: ToastLifetime;
suppressInfoToasts: boolean;
newModuleDisabled: boolean;
@@ -173,6 +189,10 @@ const defaults: Defaults = {
reduceMotion: "auto",
contrast: "auto",
validation: "strict",
tagAutocomplete: false,
loraAutocomplete: false,
embeddingAutocomplete: false,
autocompleteSeparator: false,
toastLifetime: "default",
suppressInfoToasts: false,
newModuleDisabled: false,
@@ -369,6 +389,30 @@ onBeforeUnmount(() => {
<fieldset class="wp-pg__group">
<legend class="wp-pg__group-title">Runtime behavior</legend>
<!-- Behaviour, not visuals, so there is nothing to preview on
the right. It lives here anyway because the playground is
where the settings are expected to be mirrored, and a
setting that exists in the panel but not here is exactly the
divergence this modal keeps growing. -->
<label class="wp-pg__row">
<span class="wp-pg__row-label">Booru tag autocomplete</span>
<input v-model="tagAutocomplete" type="checkbox" class="wp-pg__check">
</label>
<!-- Separate switches on purpose: someone writing danbooru
prompts may have no LoRAs at all, and someone with 200 of
them may find the tag list noise. -->
<label class="wp-pg__row">
<span class="wp-pg__row-label">LoRA autocomplete</span>
<input v-model="loraAutocomplete" type="checkbox" class="wp-pg__check">
</label>
<label class="wp-pg__row">
<span class="wp-pg__row-label">Embedding autocomplete</span>
<input v-model="embeddingAutocomplete" type="checkbox" class="wp-pg__check">
</label>
<label class="wp-pg__row">
<span class="wp-pg__row-label">Append ", " after a completion</span>
<input v-model="autocompleteSeparator" type="checkbox" class="wp-pg__check">
</label>
<label class="wp-pg__row">
<span class="wp-pg__row-label">Validation strictness</span>
<select v-model="validation" class="wp-pg__select">
@@ -0,0 +1,73 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Every setting in ComfyUI's panel must also appear in the Display Playground.
*
* The playground is where users expect to see the settings mirrored, and
* keeping the two in step has been a manual chore — `playground-store.ts` even
* carries a comment asking the next person to "keep this routing in sync if a
* new key is added". A comment is not a mechanism: the tag-autocomplete setting
* was added to the panel under the wrong category, in a namespace matching none
* of the three the playground routes, and with no playground row at all. It
* took a screenshot to notice.
*
* This is that mechanism. It reads the two sources and fails when they drift.
*/
const read = (rel: string) =>
readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
const settingsSrc = read("../../../extension/settings.ts");
const playgroundSrc = read("../DisplayPlaygroundModal.vue");
const storeSrc = read("../playground-store.ts");
/**
* Setting ids registered into ComfyUI's panel.
*
* Matches the id STRINGS anywhere in the file rather than `id:` sites, because
* most entries reference a `SETTING_ID_*` constant declared further up. An
* earlier version of this regex looked only at `id:` and found exactly one
* setting — which the "finds them at all" assertion below caught immediately,
* and is why that assertion exists.
*/
function registeredIds(): string[] {
return [...new Set(
[...settingsSrc.matchAll(/"(wildcardPipeline\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+)"/g)]
.map((m) => m[1]),
)]
// Leading underscore marks a launcher/action row rather than a value —
// the playground's own "Open playground" button is one, and mirroring it
// inside the playground would be circular.
.filter((id) => !id.split(".").pop()!.startsWith("_"));
}
/** Namespaces the playground store knows how to route a key into. */
const ROUTED_NAMESPACES = ["display", "a11y", "behavior"];
describe("playground mirrors the settings panel", () => {
const ids = registeredIds();
it("finds the registered settings at all (guards the regex itself)", () => {
expect(ids.length).toBeGreaterThan(5);
});
it.each(ids)("%s uses a namespace the playground can route", (id) => {
const namespace = id.split(".")[1];
expect(ROUTED_NAMESPACES).toContain(namespace);
});
it.each(ids)("%s has a key the playground store recognises", (id) => {
const key = id.split(".").pop()!;
// Either listed in a routing set, or reachable as the default `display`
// namespace — both mean `settingId()` can resolve it.
expect(storeSrc).toContain(`"${key}"`);
});
it.each(ids)("%s is bound to a control in the playground", (id) => {
const key = id.split(".").pop()!;
// The modal reads with getSettingValue and writes with applySetting; a
// setting present in the panel but absent here is the drift we are after.
expect(playgroundSrc).toContain(`applySetting("${key}"`);
});
});
+9 -1
View File
@@ -60,7 +60,11 @@ export type BehaviorKey =
| "suppressInfoToasts"
| "newModuleDisabled"
| "confirmDestructiveBundle"
| "bundleMasterOffBehavior";
| "bundleMasterOffBehavior"
| "tagAutocomplete"
| "loraAutocomplete"
| "embeddingAutocomplete"
| "autocompleteSeparator";
export type SettingKey = DisplayKey | A11yKey | BehaviorKey;
@@ -72,6 +76,10 @@ const BEHAVIOR_KEYS = new Set<string>([
"newModuleDisabled",
"confirmDestructiveBundle",
"bundleMasterOffBehavior",
"tagAutocomplete",
"loraAutocomplete",
"embeddingAutocomplete",
"autocompleteSeparator",
]);
function settingId(key: SettingKey): string {
+9 -8
View File
@@ -29,20 +29,20 @@ describe("FrameChips", () => {
const sel = (extra = {}) =>
mount(FrameChips, { props: { count: 4, selectable: true, showBase: true, bypassInteractive: true, ...extra } });
it("plain click selects, shift locks, alt bypasses", async () => {
it("plain click selects, alt locks, ctrl bypasses", async () => {
const w = sel();
await at(w, 2).trigger("click");
expect(w.emitted("select")?.[0]).toEqual([1]);
await at(w, 3).trigger("click", { shiftKey: true });
await at(w, 3).trigger("click", { altKey: true });
expect(w.emitted("toggleLock")?.[0]).toEqual([2]);
await at(w, 4).trigger("click", { altKey: true });
await at(w, 4).trigger("click", { ctrlKey: true });
expect(w.emitted("toggleBypass")?.[0]).toEqual([3]);
});
it("a modifier click never also selects", async () => {
const w = sel();
await at(w, 2).trigger("click", { shiftKey: true });
await at(w, 3).trigger("click", { altKey: true });
await at(w, 2).trigger("click", { altKey: true });
await at(w, 3).trigger("click", { ctrlKey: true });
expect(w.emitted("select")).toBeUndefined();
});
@@ -67,9 +67,9 @@ describe("FrameChips", () => {
expect(w.emitted("select")).toBeUndefined();
});
it("alt does nothing when bypass belongs to another node", async () => {
it("ctrl does nothing when bypass belongs to another node", async () => {
const w = plain({ bypassed: [1] });
await at(w, 2).trigger("click", { altKey: true });
await at(w, 2).trigger("click", { ctrlKey: true });
expect(w.emitted("toggleBypass")).toBeUndefined();
expect(w.emitted("toggleLock")).toBeUndefined();
});
@@ -137,7 +137,8 @@ describe("FrameChips", () => {
it("prints the combos that apply to this mode", () => {
const loop = mount(FrameChips, { props: { count: 2, selectable: true } });
expect(loop.find('[data-test="loop-frames-hint"]').text()).toMatch(/shift/i);
expect(loop.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt-click locks/i);
expect(loop.find('[data-test="loop-frames-hint"]').text()).toMatch(/ctrl-click bypasses/i);
const list = mount(FrameChips, { props: { count: 2 } });
expect(list.find('[data-test="loop-frames-hint"]').text()).toMatch(/click a frame to lock/i);
expect(list.find('[data-test="loop-frames-hint"]').text()).not.toMatch(/shift/i);
+22 -10
View File
@@ -37,7 +37,7 @@ const props = withDefaults(
selectable?: boolean;
/** Render the `base` chip. Only the Loop has values every frame inherits. */
showBase?: boolean;
/** Alt-click may toggle bypass. False where bypass belongs to another node. */
/** Ctrl/Cmd-click may toggle bypass. False where bypass belongs to another node. */
bypassInteractive?: boolean;
/** Section heading. */
label?: string;
@@ -101,15 +101,26 @@ const summary = computed(() => {
return bits.join(" · ");
});
const IS_MAC =
typeof navigator !== "undefined" && /Mac|iP(hone|ad|od)/i.test(navigator.userAgent);
/** Option on a Mac keyboard, Alt everywhere else. `altKey` covers both; only
* the printed label differs. */
const ALT_LABEL =
typeof navigator !== "undefined" && /Mac|iP(hone|ad|od)/i.test(navigator.userAgent)
? "⌥"
: "Alt";
const ALT_LABEL = IS_MAC ? "⌥" : "Alt";
/** Bypass modifier: Cmd on macOS, Ctrl everywhere else. Cmd rather than Ctrl on
* Mac because a Ctrl-click there is a right-click and never reaches this
* handler as a plain click. */
const CTRL_LABEL = IS_MAC ? "⌘" : "Ctrl";
/** True when the event carries the bypass modifier for this platform. */
function isBypassChord(ev: MouseEvent): boolean {
return IS_MAC ? ev.metaKey : ev.ctrlKey;
}
function onChipClick(i: number, ev: MouseEvent): void {
if (ev.altKey) {
// Ctrl (Cmd on macOS) toggles bypass, wherever bypass is interactive.
if (isBypassChord(ev)) {
if (props.bypassInteractive) emit("toggleBypass", i);
return;
}
@@ -118,7 +129,8 @@ function onChipClick(i: number, ev: MouseEvent): void {
emit("toggleLock", i);
return;
}
if (ev.shiftKey) { emit("toggleLock", i); return; }
// Alt toggles the seed lock; a plain click is spoken for by the edit cursor.
if (ev.altKey) { emit("toggleLock", i); return; }
emit("select", i);
}
@@ -130,12 +142,12 @@ function chipTitle(i: number): string {
const lines = [`Frame ${i + 1}${state ? ` — ${state}` : ""}`];
const lockVerb = lockedSet.value.has(i) ? "Unlock" : "Lock";
if (props.selectable) {
lines.push("Click to edit this frame", `Shift-click to ${lockVerb.toLowerCase()} its seed`);
lines.push("Click to edit this frame", `${ALT_LABEL}-click to ${lockVerb.toLowerCase()} its seed`);
} else {
lines.push(`Click to ${lockVerb.toLowerCase()} its seed`);
}
if (props.bypassInteractive) {
lines.push(`${ALT_LABEL}-click to ${bypassedSet.value.has(i) ? "re-enable" : "bypass"} it`);
lines.push(`${CTRL_LABEL}-click to ${bypassedSet.value.has(i) ? "re-enable" : "bypass"} it`);
} else if (bypassedSet.value.has(i) && props.bypassReadonlyHint) {
lines.push(props.bypassReadonlyHint);
}
@@ -155,7 +167,7 @@ const uiId = computed(() => `${props.testId}s`);
* shortcut, and this row is where the reader is already looking. */
const hint = computed(() => {
if (!props.selectable) return "Click a frame to lock its seed";
return `Shift-click locks a seed · ${ALT_LABEL}-click bypasses`;
return `${ALT_LABEL}-click locks a seed · ${CTRL_LABEL}-click bypasses`;
});
</script>
@@ -198,7 +198,7 @@ describe("useGrowableField — the drag itself", () => {
}) as DOMRect;
api.startResize(gripDown(500));
const move = (y: number) => window.dispatchEvent(
Object.assign(new MouseEvent("pointermove", { clientY: y }), { pointerId: 1 }),
Object.assign(new MouseEvent("pointermove", { clientY: y, buttons: 1 }), { pointerId: 1 }),
);
move(520);
expect(node.style.height).toBe("120px");
@@ -236,9 +236,180 @@ describe("useGrowableField — the drag itself", () => {
}) as DOMRect;
api.startResize(gripDown(500));
window.dispatchEvent(
Object.assign(new MouseEvent("pointermove", { clientY: 400 }), { pointerId: 1 }),
Object.assign(new MouseEvent("pointermove", { clientY: 400, buttons: 1 }), { pointerId: 1 }),
);
expect(node.style.height).toBe("34px");
wrap.unmount();
});
});
describe("useGrowableField — re-measures after the webfonts land", () => {
/**
* These fields are typed in `--wp-font-mono`, a bundled webfont declared
* `font-display: swap`. The first paint uses the system fallback, the real
* face swaps in later, and the two stacks have different metrics — so the
* height measured at mount was taken against type about to be replaced.
*
* That is the "sometimes the bottom fade is wrong" report: wrong on a cold
* load, right on a warm cache, and fixed by accident by any later scroll or
* input. Deterministic once you know the font is the variable.
*/
it("re-runs the overflow hint once document.fonts.ready resolves", async () => {
let resolveFonts!: () => void;
const ready = new Promise<void>((res) => { resolveFonts = res; });
Object.defineProperty(document, "fonts", {
value: { ready }, configurable: true,
});
const { api, node, wrap } = mountField();
// Fallback metrics: content fits, so no fade.
Object.defineProperty(node, "scrollHeight", { value: 100, configurable: true });
Object.defineProperty(node, "clientHeight", { value: 100, configurable: true });
Object.defineProperty(node, "scrollTop", { value: 0, configurable: true });
api.updateOverflowHint();
expect(api.hasMoreBelow.value).toBe(false);
// The real face is taller — the same text now overflows.
Object.defineProperty(node, "scrollHeight", { value: 260, configurable: true });
resolveFonts();
await ready;
await Promise.resolve();
expect(api.hasMoreBelow.value).toBe(true);
wrap.unmount();
});
it("does not throw where the host exposes no font loading API", async () => {
Object.defineProperty(document, "fonts", { value: undefined, configurable: true });
const { wrap } = mountField();
await Promise.resolve();
wrap.unmount();
});
});
describe("useGrowableField — grip-follow only while a grip is held", () => {
/**
* `followGrip` keeps the resize handle under the cursor by scrolling the
* container by the overshoot. Ungated it also ran on content-driven growth,
* so typing a long enough value scrolled the page out from under the user —
* and it made the resize flush mutate scroll position, which moves the very
* element the observer is measuring.
*/
function mountInScroller(height: number) {
const scroller = document.createElement("div");
scroller.style.overflowY = "auto";
document.body.appendChild(scroller);
const { wrap, api, node } = mountField(height);
scroller.appendChild(node);
scroller.getBoundingClientRect = () => ({
height: 50, bottom: 50, top: 0, left: 0, right: 0, width: 100, x: 0, y: 0,
toJSON: () => ({}),
}) as DOMRect;
scroller.scrollTop = 0;
return { wrap, api, node, scroller };
}
it("does not scroll the container when the content simply grew", () => {
const { wrap, node, scroller } = mountInScroller(100);
// Element now overshoots the scroller's bottom edge by a long way.
node.getBoundingClientRect = () => ({
height: 400, bottom: 400, top: 0, left: 0, right: 0, width: 100, x: 0, y: 0,
toJSON: () => ({}),
}) as DOMRect;
observerCallback?.();
expect(scroller.scrollTop).toBe(0);
wrap.unmount();
scroller.remove();
});
it("does scroll while the handle is actually being dragged", () => {
const { wrap, api, node, scroller } = mountInScroller(100);
api.startResize({
clientY: 10, pointerId: 1, preventDefault() {}, currentTarget: null,
} as unknown as PointerEvent);
node.getBoundingClientRect = () => ({
height: 400, bottom: 400, top: 0, left: 0, right: 0, width: 100, x: 0, y: 0,
toJSON: () => ({}),
}) as DOMRect;
observerCallback?.();
expect(scroller.scrollTop).toBeGreaterThan(0);
wrap.unmount();
scroller.remove();
});
});
describe("useGrowableField — a drag can always end", () => {
/**
* Reported symptom: "the resize is linked to my mouse and resizes as I move
* my mouse around without holding, and can't be interrupted no matter what I
* do or press."
*
* That is one state — `dragging` stuck true — reachable whenever a single
* `pointerup` is missed. It was reachable two ways: `setPointerCapture`
* retargeted every later pointer event to the grip, so a re-render that
* replaced that element delivered the `pointerup` to a detached node where it
* never bubbled; and the listeners sat in bubble phase, so any
* `stopPropagation()` in between hid it too. Nothing then ever cleared the
* flag, which is why no key or click could stop it.
*/
function beginDrag(api: ReturnType<typeof useGrowableField>) {
api.startResize({
clientY: 100, pointerId: 1, preventDefault() {}, currentTarget: null,
} as unknown as PointerEvent);
}
const move = (y: number, buttons: number) =>
window.dispatchEvent(
Object.assign(new Event("pointermove", { bubbles: true }), { clientY: y, buttons }),
);
it("stops resizing after a pointerup, even one that never reached the window", () => {
const { api, node, wrap } = mountField(100);
beginDrag(api);
move(140, 1);
const grown = node.style.height;
expect(grown).not.toBe("");
// The pointerup is simply never delivered — the stranded-capture case.
// The next move reports no buttons held, which is the only honest signal
// left that the drag is over.
move(300, 0);
const afterRelease = node.style.height;
move(500, 0);
expect(node.style.height).toBe(afterRelease);
wrap.unmount();
});
it("ends on a pointerup seen in capture phase", () => {
const { api, node, wrap } = mountField(100);
beginDrag(api);
move(140, 1);
const held = node.style.height;
window.dispatchEvent(new Event("pointerup", { bubbles: true }));
move(400, 1); // button still reported down, but the drag ended
expect(node.style.height).toBe(held);
wrap.unmount();
});
it("ends on Escape, the key a stuck user actually presses", () => {
const { api, node, wrap } = mountField(100);
beginDrag(api);
move(140, 1);
const held = node.style.height;
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
move(400, 1);
expect(node.style.height).toBe(held);
wrap.unmount();
});
it("ends when the window loses focus", () => {
const { api, node, wrap } = mountField(100);
beginDrag(api);
move(140, 1);
const held = node.style.height;
window.dispatchEvent(new Event("blur"));
move(400, 1);
expect(node.style.height).toBe(held);
wrap.unmount();
});
});
@@ -24,3 +24,14 @@
.wp-rt-preview .wp-rt-dp-multi { color: var(--wp-rt-token-good); font-weight: 500; }
.wp-rt .wp-rt-dp-weight,
.wp-rt-preview .wp-rt-dp-weight { color: var(--wp-rt-token-mut); font-weight: 500; }
/* Var accessor tails — canvas mirror of manager/styles/rich-text.css. The AXIS
segment (`.SHOES`) is amber, the one part of the accessor that names a
library concept; the pick INDEX (`.0`) stays the variable's own colour,
dimmed, because it is positional and — being a runtime-only, multi-pick
concept — is nothing a static surface can validate or should flag. Keep in
sync with the SPA copy. */
.wp-rt .wp-rt-var__accessor,
.wp-rt-preview .wp-rt-var__accessor { color: var(--wp-axis, #fbbf24); font-weight: 600; }
.wp-rt .wp-rt-var__index,
.wp-rt-preview .wp-rt-var__index { opacity: 0.6; font-weight: 600; }
+84 -5
View File
@@ -165,7 +165,15 @@ export function useGrowableField(
// cursor. Deferring this to pointerup (an earlier attempt at the stall,
// which turned out to be the height cap) just made the field vanish off
// the bottom for the whole drag.
if (h > lastHeight) followGrip();
//
// Gated on `dragging`, which is what the name always implied. It exists to
// keep the GRIP under the CURSOR, and there is no grip being held unless a
// drag is in progress. Ungated it also ran on content-driven growth — so
// typing a long enough template scrolled the page out from under you — and
// it made this flush mutate scroll position, which moves the element the
// observer is measuring. A resize handler that can change its own input is
// the shape a runaway needs.
if (dragging && h > lastHeight) followGrip();
lastHeight = h;
}
@@ -221,15 +229,51 @@ export function useGrowableField(
el.style.maxHeight = "none";
capLifted = true;
}
(ev.currentTarget as HTMLElement | null)?.setPointerCapture?.(ev.pointerId);
window.addEventListener("pointermove", onResizeMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerUp);
/* No `setPointerCapture`.
*
* It reads like the right call and it is what stranded the drag. Capture
* retargets every subsequent pointer event to the grip — including
* `pointerup`. The editor re-renders while it resizes, and a captured
* element that gets replaced is detached: the `pointerup` is delivered to a
* node no longer in the tree, so it never bubbles to `window`, `dragging`
* stays true, and the box follows the cursor with no button held and no way
* to stop it. That is the reported symptom exactly.
*
* Capture bought nothing here anyway. The listeners below are on `window`,
* which sees the events regardless of what is under the cursor.
*
* CAPTURE PHASE on all three, so a `stopPropagation()` between the target
* and the window — ComfyUI's node layer does this to stop the canvas
* panning under a widget — cannot hide the end of the drag from us.
*/
window.addEventListener("pointermove", onResizeMove, true);
window.addEventListener("pointerup", onPointerUp, true);
window.addEventListener("pointercancel", onPointerUp, true);
// A fresh press means the previous drag is over however it ended.
window.addEventListener("pointerdown", onPointerUp, true);
window.addEventListener("blur", onPointerUp);
window.addEventListener("keydown", onDragEscape, true);
}
/** Escape ends a drag. Cheap, and it is the key a stuck user presses. */
function onDragEscape(ev: KeyboardEvent): void {
if (ev.key === "Escape") onPointerUp();
}
function onResizeMove(ev: PointerEvent): void {
const el = getEl();
if (!el || !dragging) return;
// Self-heal. Every pointermove reports which buttons are down, so a move
// with none held means the pointerup was lost — whatever swallowed it —
// and the drag should already be over. Without this the field follows the
// cursor forever and no key or click can stop it, because nothing else in
// here ever clears `dragging`. Belt and braces on top of the listener
// changes above: those address the causes we found, this ends the state
// regardless of a cause we did not.
if (ev.buttons === 0) {
onPointerUp();
return;
}
const dy = ev.clientY - lastPointerY;
lastPointerY = ev.clientY;
const cur = el.getBoundingClientRect().height;
@@ -239,6 +283,14 @@ export function useGrowableField(
function onPointerUp(): void {
if (!dragging) return;
dragging = false;
// Re-arm for the next drag. `capLifted` guards a one-time "pin the current
// height, release the max-height cap" step, and leaving it set meant the
// release survived for the lifetime of the field while the explicit height
// that bounded it did not — `applyAtoms` replaces the host element on a
// rebuild, which drops the inline height but not this flag. The next drag
// then found an uncapped, unpinned box. Each drag now re-establishes both
// against the element that actually exists.
capLifted = false;
window.removeEventListener("pointermove", onResizeMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
@@ -255,6 +307,33 @@ export function useGrowableField(
const el = getEl();
if (!el) return;
lastHeight = el.getBoundingClientRect().height;
/* Re-measure once the webfonts land.
*
* These fields are typed in `--wp-font-mono`, a bundled webfont declared
* `font-display: swap` — so the first paint uses the system fallback
* (Consolas / Monaco) and the real face swaps in later. The two stacks
* have different metrics, so the `scrollHeight` measured at mount is taken
* against type that is about to be replaced.
*
* That is the "sometimes the bottom fade is wrong" report: on a cold load
* the hint is computed against the fallback and never recomputed, and on a
* warm cache the font is already there so it looks fine. Any later scroll,
* input or resize also fixes it by accident, which is what made it look
* intermittent rather than deterministic.
*
* `document.fonts.ready` resolves once, after all pending faces settle.
* Cheap, and a no-op where the font was already cached.
*/
if (typeof document !== "undefined" && document.fonts?.ready) {
void document.fonts.ready.then(() => {
// The field may have unmounted during the load.
if (!getEl()) return;
updateOverflowHint();
autosize();
});
}
if (typeof ResizeObserver === "undefined") return;
obs = new ResizeObserver(schedule);
obs.observe(el);
+14
View File
@@ -90,3 +90,17 @@ export const nodeBadgePulseToken: WeakMap<object, number> = new WeakMap();
* persist across animations (last value retained), so `.has(node)`
* is true forever after the first collapse. */
export const nodeCollapseAnimating: WeakSet<object> = new WeakSet();
/** Caret-aware insert hook for a node's template editor, published by
* `widgets/templateEditor.ts` when it mounts and read by the assembler
* helper's chip strip.
*
* Needed because the helper and the editor are two SEPARATE widgets on
* the same node — the helper can reach the editor's VALUE through
* `widget.value`, but a value write replaces the whole string and loses
* the caret. Before Vue Nodes the helper spliced into `widget.inputEl`
* (a real textarea); that element is now detached and unrendered, so the
* only way to insert AT THE CARET is to ask the editor to do it. Absent
* entry ⇒ the editor hasn't mounted yet, and callers fall back to a
* value-level append. */
export const templateInsertAtCaret: WeakMap<object, (token: string) => void> = new WeakMap();
+85
View File
@@ -1562,3 +1562,88 @@ describe("scanConflicts — combine @{} templates are an intentional non-gap", (
expect(scanConflicts(value, [])).toEqual([]);
});
});
/** A wildcard writing `binding`, optionally declaring `accepts` axes. */
const wc = (
id: string,
binding: string,
kinds: Record<string, string> = {},
): ContextWidgetValue["modules"][number] => ({
id, type: "wildcard", enabled: true, meta: { name: "" }, entries: [],
payload: {
var_binding: binding,
sub_categories: ["sneakers"],
tag_groups: { SHOES: ["sneakers"] },
...(Object.keys(kinds).length > 0 ? { tag_group_kinds: kinds } : {}),
options: [{ id: "o1", value: "tee", weight: 1, sub_categories: ["sneakers"] }],
},
} as ContextWidgetValue["modules"][number]);
/** A combine reading `template`. */
const cmb = (id: string, template: string): ContextWidgetValue["modules"][number] => ({
id, type: "combine", enabled: true, meta: { name: "" }, entries: [],
payload: { template, output_var: "out" },
} as ContextWidgetValue["modules"][number]);
describe("scanConflicts — unknown_tag_axis", () => {
it("flags a $var.AXIS read when the producing wildcard declares no such axis", () => {
const value: ContextWidgetValue = {
version: 1,
modules: [wc("w1", "outfit", { SHOES: "accepts" }), cmb("c1", "wearing $outfit.BELTS")],
};
const out = scanConflicts(value, []);
expect(out).toContainEqual(expect.objectContaining({
type: "unknown_tag_axis", variable: "outfit.BELTS", severity: "warning",
}));
});
it("does not flag a declared axis", () => {
const value: ContextWidgetValue = {
version: 1,
modules: [wc("w1", "outfit", { SHOES: "accepts" }), cmb("c1", "wearing $outfit.SHOES")],
};
const out = scanConflicts(value, []);
expect(out.every((c) => c.type !== "unknown_tag_axis")).toBe(true);
});
it("flags an axis whose group exists but was never promoted", () => {
// The commonest mistake by far: the group is right there, it just still
// says classify.
const value: ContextWidgetValue = {
version: 1,
modules: [wc("w1", "outfit"), cmb("c1", "wearing $outfit.SHOES")],
};
const out = scanConflicts(value, []);
expect(out).toContainEqual(expect.objectContaining({ type: "unknown_tag_axis" }));
});
it("accepts either accessor order", () => {
const value: ContextWidgetValue = {
version: 1,
modules: [
wc("w1", "outfit", { SHOES: "accepts" }),
cmb("c1", "$outfit.0.SHOES and $outfit.SHOES.0"),
],
};
const out = scanConflicts(value, []);
expect(out.every((c) => c.type !== "unknown_tag_axis")).toBe(true);
});
it("stays silent when the producer is not in this node", () => {
// The scanner receives upstream vars as NAMES only, so it cannot see an
// upstream wildcard's groups. Warning here would fire on correct
// workflows; under-reporting beats a warning nobody can act on.
const value: ContextWidgetValue = { version: 1, modules: [cmb("c1", "$outfit.SHOES")] };
const out = scanConflicts(value, ["outfit"]);
expect(out.every((c) => c.type !== "unknown_tag_axis")).toBe(true);
});
it("reports one entry per distinct name.axis, not per occurrence", () => {
const value: ContextWidgetValue = {
version: 1,
modules: [wc("w1", "outfit"), cmb("c1", "$outfit.SHOES $outfit.SHOES $outfit.SHOES")],
};
const out = scanConflicts(value, []);
expect(out.filter((c) => c.type === "unknown_tag_axis")).toHaveLength(1);
});
});
+106
View File
@@ -149,6 +149,10 @@ export type ConflictType =
// collapse into the two `orphan_*` types below. `_missing` rules stay
// for the "uuid not in any catalog" case which is independent of
// position.
// A `$var.AXIS` read naming a group no in-node wildcard declares `accepts`.
// Commonest cause is a group that exists but was never promoted; second
// commonest is an axis renamed after the read was written.
| "unknown_tag_axis"
| "constraint_source_missing"
| "constraint_target_missing"
| "constraint_orphan_source" // no source instance upstream
@@ -190,6 +194,7 @@ export function labelFor(type: ConflictType): string {
if (type === "shadows_upstream") return "overrides upstream";
if (type === "duplicate_variable") return "duplicate";
if (type === "missing_template_variable") return "missing";
if (type === "unknown_tag_axis") return "unknown tag axis — is that group marked 'accepts'?";
if (type === "constraint_source_missing") return "source missing";
if (type === "constraint_target_missing") return "target missing";
if (type === "constraint_orphan_source") return "source missing — no upstream instance";
@@ -210,6 +215,7 @@ export function shortConflictLabel(type: ConflictType): string {
case "shadows_upstream": return "override";
case "duplicate_variable": return "duplicate";
case "missing_template_variable": return "missing var";
case "unknown_tag_axis": return "unknown axis";
case "constraint_source_missing": return "src missing";
case "constraint_target_missing": return "tgt missing";
case "constraint_orphan_source": return "no src upstream";
@@ -300,6 +306,45 @@ function templateVarsIn(template: string): string[] {
return out;
}
/** `$name.AXIS` references, either accessor order. Mirrors the accessor
* grammar in `engine/syntax/tokenize.py:_VAR_RE` — at most one pick index and
* one axis, so `.0.AXIS` and `.AXIS.0` both yield the same pair. */
const TEMPLATE_VAR_AXIS =
/(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(?:\d+\.([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*)(?:\.\d+)?))/g;
export interface AxisRef { name: string; axis: string }
function templateAxisRefsIn(template: string): AxisRef[] {
const out: AxisRef[] = [];
for (const m of template.matchAll(TEMPLATE_VAR_AXIS)) {
const axis = m[2] ?? m[3];
if (axis) out.push({ name: m[1], axis });
}
return out;
}
/** Axis references a derivation's CONDITIONS make. Conditions store `var` as
* bare text (no `$`), so the template regex does not apply. */
function conditionAxisRefsIn(m: ModuleEntry): AxisRef[] {
if (m.type !== "derivation") return [];
const out: AxisRef[] = [];
const rules = ((m.payload as { rules?: unknown[] } | undefined)?.rules ?? []) as Array<{
branches?: Array<{ condition?: { var?: unknown } }>;
}>;
for (const rule of rules) {
for (const br of rule.branches ?? []) {
const v = br.condition?.var;
if (typeof v !== "string") continue;
const mm = v.match(
/^([A-Za-z_][A-Za-z0-9_]*)(?:\.(?:\d+\.([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*)(?:\.\d+)?))$/,
);
const axis = mm?.[2] ?? mm?.[3];
if (mm && axis) out.push({ name: mm[1], axis });
}
}
return out;
}
/** Extract the templates a module's runtime resolver will consume against
* the ctx (`$var` lookups). Surfaces:
* - combine: `payload.template`
@@ -585,6 +630,37 @@ export function scanConflicts(
}
return out;
})();
// Which `accepts` axes each binding written IN THIS NODE declares. Built up
// front because a `$var.AXIS` read can sit above the wildcard that produces
// it in module order — the read is resolved by the runtime ctx, not by
// position, so judging it by position would be wrong.
//
// Only wildcards contribute: no other module type has tag groups. A binding
// written outside this node is absent from the map and reads against it are
// left alone (see `flagAxis`).
const axisProducers = ((): Map<string, Set<string>> => {
const out = new Map<string, Set<string>>();
for (const m of value.modules) {
if (m.type !== "wildcard") continue;
const payload = (m.payload ?? {}) as {
var_binding?: unknown;
tag_group_kinds?: Record<string, unknown>;
};
const inst = (m.instance ?? {}) as { variable_binding?: unknown };
const binding = typeof inst.variable_binding === "string" && inst.variable_binding
? inst.variable_binding
: (typeof payload.var_binding === "string" ? payload.var_binding : "");
if (!binding) continue;
const declared = out.get(binding) ?? new Set<string>();
for (const [axis, kind] of Object.entries(payload.tag_group_kinds ?? {})) {
if (kind === "accepts") declared.add(axis);
}
// Two wildcards can share a binding (last-write-wins at runtime); union
// their axes rather than letting the second erase the first's.
out.set(binding, declared);
}
return out;
})();
const written = new Set<string>();
// Track the kind of the FIRST module to write each name in this
// node. Used to distinguish intentional cross-kind overrides
@@ -657,6 +733,36 @@ export function scanConflicts(
for (const v of templateVarsIn(tpl)) flagMissing(v);
}
// 1b. `$var.AXIS` reads naming a group nothing declares `accepts`. The
// engine renders those empty, so without a signal here the only
// symptom is a silently missing word in the prompt.
//
// Deliberately conservative: this scanner runs PER NODE and receives
// upstream variables as names only, never payloads. So a read is
// checked only when a wildcard in THIS node writes that binding — if
// the producer lives in another node we cannot see its groups, and
// guessing would fire on correct workflows. Under-reporting beats a
// warning the user cannot act on.
const seenAxis = new Set<string>();
const flagAxis = ({ name, axis }: AxisRef): void => {
const producer = axisProducers.get(name);
if (!producer) return; // not written here — cannot judge
if (producer.has(axis)) return; // declared accepts, all good
const key = `${name}.${axis}`;
if (seenAxis.has(key)) return;
seenAxis.add(key);
out.push({
moduleId: m._uid ?? m.id,
variable: key,
type: "unknown_tag_axis",
severity: "warning",
});
};
for (const ref of conditionAxisRefsIn(m)) flagAxis(ref);
for (const tpl of templatesOf(m)) {
for (const ref of templateAxisRefsIn(tpl)) flagAxis(ref);
}
// 2. Writes from this module — order-dependent, so happens AFTER
// template scan so a combine can't satisfy its own `$var` refs.
for (const name of writesOf(m)) {
+48
View File
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* These fonts are served, not bundled — see the header of `fonts.ts`. The
* stylesheet is copied to `js/assets/fonts/` by `scripts/copy-fonts.mjs` and
* located at runtime relative to this chunk's own URL.
*
* Both failure modes here are SILENT in a browser: a wrong path 404s and text
* quietly falls back to the system stack, and a duplicated <link> costs a
* second request nobody notices. Neither shows up in any other test.
*/
describe("webfont linking", () => {
beforeEach(() => {
vi.resetModules();
document.head.querySelectorAll("#wp-webfonts").forEach((n) => n.remove());
});
it("appends one stylesheet link pointing at the copied CSS", async () => {
await import("./fonts");
const links = document.head.querySelectorAll<HTMLLinkElement>("link#wp-webfonts");
expect(links).toHaveLength(1);
expect(links[0].rel).toBe("stylesheet");
// Resolved against the module URL, so the leading segments differ between
// the test run and the built chunk. The tail is what has to hold: the CSS
// sits in a `fonts/` directory beside whatever chunk imports it.
expect(links[0].href).toMatch(/\/fonts\/wp-fonts\.css$/);
});
it("does not add a second link when imported again", async () => {
await import("./fonts");
await import("./fonts");
expect(document.head.querySelectorAll("link#wp-webfonts")).toHaveLength(1);
});
it("resolves the CSS beside the chunk, and the woff2 beside the CSS", () => {
// The whole scheme in one assertion: chunk URL -> stylesheet -> font file.
// If someone moves the copy target without moving the other end, this is
// the test that says so.
const chunk = "http://host/extensions/comfyui-wildcard-pipeline/assets/fonts-abc123.js";
const css = new URL(["fonts", "wp-fonts.css"].join("/"), chunk).href;
expect(css).toBe(
"http://host/extensions/comfyui-wildcard-pipeline/assets/fonts/wp-fonts.css",
);
expect(new URL("./inter-latin.woff2", css).href).toBe(
"http://host/extensions/comfyui-wildcard-pipeline/assets/fonts/inter-latin.woff2",
);
});
});
+31 -33
View File
@@ -1,10 +1,23 @@
// Webfonts (Inter + JetBrains Mono, latin variable axis 100-900) injected
// at extension load. We side-step `cssInjectedByJsPlugin`'s URL-inlining
// behavior by wiring the @font-face rules in JS — `new URL(path,
// import.meta.url)` keeps woff2 files as separate assets (NOT
// base64-inlined into the JS chunk) and resolves the URL at runtime
// relative to the loaded module's location, which ComfyUI serves at
// `/extensions/wildcard-pipeline/assets/...`.
// Webfonts (Inter + JetBrains Mono, latin variable axis 100-900) linked at
// extension load.
//
// The @font-face rules live in `src/assets/fonts/wp-fonts.css`, copied verbatim
// into `js/assets/fonts/` by `scripts/copy-fonts.mjs`. This module only points a
// <link> at them; the browser resolves each woff2 URL relative to the
// stylesheet, so there is no path arithmetic here.
//
// WHY NOT AN IMPORT. This file used to do
// new URL("../assets/fonts/inter-latin.woff2", import.meta.url)
// in the belief that it kept the woff2 as separate assets. It did not — Vite
// ignores `build.assetsInlineLimit` whenever `build.lib` is set and inlines
// every asset unconditionally. Both fonts were base64'd into the chunk: 88,660
// bytes of woff2 became a 118,896-byte JS file, downloaded and parsed as
// JavaScript before ComfyUI could finish registering the extension.
//
// Do NOT turn `CSS_HREF` below back into a string literal. Vite's asset
// transform fires on `new URL(<literal>, import.meta.url)` specifically, and
// making it a literal would silently re-inline the stylesheet — undoing all of
// this with no build error and no test failure.
//
// Trade-off vs. fontsource @import:
// - Single latin subset only — no cyrillic/greek/vietnamese coverage.
@@ -13,38 +26,23 @@
// - Variable axis covers every weight from 100..900 in one file each,
// so we don't pay per-weight asset cost.
const interUrl = new URL("../assets/fonts/inter-latin.woff2", import.meta.url).href;
const jetBrainsMonoUrl = new URL("../assets/fonts/jetbrains-mono-latin.woff2", import.meta.url).href;
/** Built from parts so Vite's `new URL(<literal>, ...)` transform cannot see a
* literal path to resolve. See the note above before "simplifying" this. */
const CSS_HREF = ["fonts", "wp-fonts.css"].join("/");
let injected = false;
function ensureFontFaces(): void {
if (injected || typeof document === "undefined") return;
injected = true;
const style = document.createElement("style");
style.id = "wp-webfonts";
// font-display: swap renders the system stack first then re-paints when
// the woff2 lands — avoids invisible text during the 100–300ms fetch on
// first load. font-weight `100 900` is the variable axis range.
style.textContent = `
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(${interUrl}) format('woff2-variations'),
url(${interUrl}) format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(${jetBrainsMonoUrl}) format('woff2-variations'),
url(${jetBrainsMonoUrl}) format('woff2');
}
`;
document.head.appendChild(style);
const link = document.createElement("link");
link.id = "wp-webfonts";
link.rel = "stylesheet";
// Resolved against this chunk's own URL — `/extensions/<package>/assets/` —
// so a renamed install directory still works. Hardcoding the package name
// would break for anyone who cloned to a different folder name.
link.href = new URL(CSS_HREF, import.meta.url).href;
document.head.appendChild(link);
}
ensureFontFaces();
+269
View File
@@ -4,6 +4,7 @@ import {
collectDownstreamWildcardUuids,
collectLocalResolvedForModule,
collectUpstreamProducers,
collectUpstreamRenderableVariables,
collectUpstreamResolved,
collectUpstreamVariables,
collectUpstreamWildcardUuids,
@@ -11,6 +12,7 @@ import {
findDownstreamAssemblers,
findRootGraph,
hasUpstreamLoopOverridingSeed,
internalVarNames,
resolveUpstreamLoopSeed,
type LiteGraphLike,
type LiteNodeLike,
@@ -148,6 +150,30 @@ describe("collectUpstreamProducers", () => {
expect(out.shared.shadowed).toBe(1);
});
it("keeps a wildcard's accepts axes when a later node rebinds the var", () => {
// The engine keys `__wp_axes__` by BINDING and writes it at pick time, so
// a derivation downstream that rewrites `$shared` does not clear the
// rolled axis — `$shared.SHOES` still resolves. The producer map replaced
// the record wholesale, so the axis rows disappeared as soon as anything
// downstream touched the name: true of essentially every real graph.
const up = ctxWriting(1, "Outfit", "shared");
const upMods = JSON.parse(String(up.widgets![0].value));
upMods.modules[0].payload.tag_groups = { SHOES: ["sneakers", "heels"] };
upMods.modules[0].payload.tag_group_kinds = { SHOES: "accepts" };
up.widgets![0].value = JSON.stringify(upMods);
const mid = ctxDerivation(2, "Rewrite", "shared", 4, 100);
const pov = ctxWriting(3, "Other", "unused", 101);
const out = collectUpstreamProducers(
chain([up, mid, pov], {
100: { origin_id: 1, target_id: 2 },
101: { origin_id: 2, target_id: 3 },
}),
pov,
);
expect(out.shared.moduleName).toBe("Rewrite");
expect(out.shared.axes?.map((a) => a.axis)).toEqual(["SHOES"]);
});
it("counts two DIFFERENT modules in one node as two writers", () => {
// They share `nodeId` and `kind`, so keying on those alone would collapse
// a real last-write-wins conflict into a single silent writer.
@@ -481,6 +507,56 @@ describe("collectDownstreamWildcardUuids", () => {
});
});
describe("collectUpstreamVariables — internal flag is last-write-wins", () => {
/** One Context node, two fixed_values both binding `name`, with the given
* internal flags in order. Mirrors the reported canvas setup. */
function twoWriters(
first: { value: string; internal: boolean },
second: { value: string; internal: boolean },
): LiteGraphLike {
const mod = (uid: string, value: string, internal: boolean) => ({
id: uid, _uid: uid, type: "fixed_values", enabled: true, meta: { name: uid },
payload: { values: [{ id: uid + "v", name: "outfit", value }] },
instance: { internal },
});
const ctx: LiteNodeLike = {
id: 1, type: "WP_Context",
inputs: [{ name: "upstream", link: null }],
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
widgets: [{ name: "wp_modules", value: JSON.stringify({
version: 1, modules: [mod("aaaaaaaa", first.value, first.internal),
mod("bbbbbbbb", second.value, second.internal)] }) }],
};
const asm: LiteNodeLike = {
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
};
return {
_nodes: [ctx, asm],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
};
}
it("REPORTED: a public writer AFTER an internal one makes the var renderable", () => {
// internal → public. The public override is the last write, so the
// assembler's PROMPT surface must see `outfit`. Add-only left it hidden
// forever. (The plain variable list always keeps it; it is the renderable
// list — the one the assembler's `$var` substitution honours — that must
// reflect the last writer.)
const g = twoWriters({ value: "hidden", internal: true },
{ value: "public", internal: false });
const asm = g.getNodeById(2)!;
expect(collectUpstreamRenderableVariables(g, asm)).toContain("outfit");
});
it("an internal writer AFTER a public one hides the var from the prompt", () => {
const g = twoWriters({ value: "public", internal: false },
{ value: "hidden", internal: true });
const asm = g.getNodeById(2)!;
expect(collectUpstreamRenderableVariables(g, asm)).not.toContain("outfit");
});
});
describe("collectUpstreamVariables — mute/bypass mode", () => {
function makeChain(modeOnA?: number) {
// a (writes "style") → b. If `modeOnA` is 2 (mute) or 4 (bypass),
@@ -699,6 +775,57 @@ function fakeWildcardContextNode(
};
}
describe("collectUpstreamResolved axis reads", () => {
beforeEach(() => _resetForTests());
/** Build a chain whose combine template reads `$outfit.SHOES`. */
function axisGraph(template: string) {
const ctx = fakeWildcardContextNode(1, [
{ id: "aaaaaaaa", binding: "$outfit", options: [{ value: "a white t-shirt" }] },
]);
const mods = JSON.parse(String(ctx.widgets![0].value));
mods.modules[0].payload.tag_groups = { SHOES: ["sneakers", "heels"], FIT: ["loose"] };
mods.modules[0].payload.tag_group_kinds = { SHOES: "accepts" };
mods.modules.push({
id: "bbbbbbbb", type: "combine", enabled: true, meta: { name: "" }, entries: [],
payload: { output_var: "scene", template },
});
ctx.widgets![0].value = JSON.stringify(mods);
const asm: LiteNodeLike = {
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
};
return {
_nodes: [ctx, asm],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id: number) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
} as LiteGraphLike;
}
it("REPORTED: an axis read previews as a TAG, not the variable text plus a stranded accessor", () => {
// Was "a white t-shirt.SHOES" — `$outfit` substituted, `.SHOES` left behind.
const out = collectUpstreamResolved(axisGraph("wearing $outfit.SHOES"), {
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
});
expect(out.scene).toBe("wearing sneakers");
});
it("leaves an unknown accessor verbatim rather than swallowing it", () => {
// FIT exists but is a classify group, so it is not readable — and BELTS
// does not exist at all. Consuming either would silently delete text.
const out = collectUpstreamResolved(axisGraph("wearing $outfit.BELTS"), {
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
});
expect(out.scene).toBe("wearing $outfit.BELTS");
});
it("still resolves a plain read", () => {
const out = collectUpstreamResolved(axisGraph("wearing $outfit"), {
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
});
expect(out.scene).toBe("wearing a white t-shirt");
});
});
describe("collectUpstreamResolved nested @{uuid} fallback", () => {
beforeEach(() => _resetForTests());
@@ -1347,3 +1474,145 @@ describe("findWildcardHomesElsewhere", () => {
expect(findWildcardHomesElsewhere(graphOf([self, asm]), self, "")).toEqual([]);
});
});
/**
* These walkers run inside `onConnectionsChange` (see `extension/reactive.ts`),
* so a throw does not degrade to an empty preview — it escapes into litegraph's
* connection handling and one malformed module breaks canvas interaction for
* the whole graph. Both shapes below are things a real library payload can be:
* `entries` is a widget-side mirror that only exists once a module has been
* edited inline, so a row installed straight from the library has `payload`
* and nothing else.
*/
describe("collectUpstreamResolved — malformed fixed_values rows", () => {
/** A fixed_values module with whatever `entries` the caller wants (or none). */
function fixedValuesGraph(module: Record<string, unknown>): {
graph: LiteGraphLike;
asm: LiteNodeLike;
} {
const ctx: LiteNodeLike = {
id: 1,
type: "WP_Context",
inputs: [{ name: "upstream", link: null }],
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
widgets: [{ name: "wp_modules", value: JSON.stringify({ version: 1, modules: [module] }) }],
};
const asm: LiteNodeLike = {
id: 2,
type: "WP_PromptAssembler",
inputs: [{ name: "context", link: 100 }],
};
return {
asm,
graph: {
_nodes: [ctx, asm],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
},
};
}
const payload = {
values: [
{ id: "v1", name: "style", value: "cinematic" },
{ id: "v2", name: "mood", value: "brooding" },
],
};
it("resolves from payload.values when the module has no `entries` key at all", () => {
const { graph, asm } = fixedValuesGraph({
id: "aa11bb22", type: "fixed_values", enabled: true, meta: { name: "looks" }, payload,
});
expect(collectUpstreamResolved(graph, asm)).toEqual({ style: "cinematic", mood: "brooding" });
});
it("skips an entry with no variable_name instead of throwing", () => {
const { graph, asm } = fixedValuesGraph({
id: "aa11bb22", type: "fixed_values", enabled: true, meta: { name: "looks" },
entries: [{ value: "orphaned" }, { variable_name: "$style", value: "noir" }],
payload: { values: [] },
});
expect(collectUpstreamResolved(graph, asm)).toEqual({ style: "noir" });
});
it("tolerates `entries` being a non-array", () => {
const { graph, asm } = fixedValuesGraph({
id: "aa11bb22", type: "fixed_values", enabled: true, meta: { name: "looks" },
entries: null, payload,
});
expect(collectUpstreamResolved(graph, asm)).toEqual({ style: "cinematic", mood: "brooding" });
});
});
/**
* WP_PromptAssembler runs `strip_internals` over the render context before
* resolving, so a `$var` naming a variable the user flagged internal
* substitutes to nothing. The flagged names still cross the socket — Combine
* and Derivation downstream read them — so the plain variable list keeps
* them, and only the PROMPT surface filters. Before this split the
* assembler's `$` autocomplete offered `$iteration` and the `*_bool` toggles,
* which outnumbered the usable names.
*/
describe("collectUpstreamRenderableVariables", () => {
/** A Context node with one plain fixed_values module and one flagged
* `instance.internal`. */
function graphWithInternalModule(): { graph: LiteGraphLike; asm: LiteNodeLike } {
const ctx: LiteNodeLike = {
id: 1,
type: "WP_Context",
inputs: [{ name: "upstream", link: null }],
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
widgets: [{
name: "wp_modules",
value: JSON.stringify({
version: 1,
modules: [
{
id: "aa11bb22", type: "fixed_values", enabled: true, meta: { name: "looks" },
entries: [], payload: { values: [{ id: "v1", name: "style", value: "cinematic" }] },
},
{
id: "cc33dd44", type: "fixed_values", enabled: true, meta: { name: "toggles" },
entries: [], instance: { internal: true },
payload: { values: [{ id: "v2", name: "style_bool", value: "1" }] },
},
],
}),
}],
};
const asm: LiteNodeLike = {
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
};
return {
asm,
graph: {
_nodes: [ctx, asm],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
},
};
}
it("drops internal-flagged names that the prompt would never substitute", () => {
const { graph, asm } = graphWithInternalModule();
expect(collectUpstreamRenderableVariables(graph, asm)).toEqual(["style"]);
});
it("keeps them in the plain variable list, which other surfaces still need", () => {
const { graph, asm } = graphWithInternalModule();
expect(collectUpstreamVariables(graph, asm).sort()).toEqual(["style", "style_bool"]);
});
it("names the internal keys from the flag blob", () => {
const { graph, asm } = graphWithInternalModule();
expect([...internalVarNames(collectUpstreamResolved(graph, asm))]).toEqual(["style_bool"]);
});
it("treats a malformed flag blob as no internals rather than throwing", () => {
// The blob crosses the socket as an ordinary string value, so nothing
// guarantees it parses. A broken map should cost a mis-styled chip, not
// an exception inside a connection-change handler.
expect([...internalVarNames({ __wp_internal_flags__: "{not json" })]).toEqual([]);
expect([...internalVarNames({})]).toEqual([]);
});
});
+170 -21
View File
@@ -477,6 +477,45 @@ export function collectUpstreamVariables(rootGraph: LiteGraphLike, node: LiteNod
.filter((k) => !k.startsWith("__"));
}
/**
* Names the user flagged "hide from prompt", read out of the engine's
* `__wp_internal_flags__` blob that rides along in a resolved map.
*
* The blob is JSON in a string because it crosses the PIPELINE_CONTEXT socket
* as an ordinary context value. Malformed ⇒ treat as empty; a broken flag map
* should cost the user a mis-styled chip, not an exception in a graph walk.
*/
export function internalVarNames(resolved: Record<string, ResolvedValue>): Set<string> {
const out = new Set<string>();
const blob = resolved["__wp_internal_flags__"];
if (typeof blob !== "string") return out;
try {
for (const [k, v] of Object.entries(JSON.parse(blob) as Record<string, boolean>)) {
if (v) out.add(k);
}
} catch { /* malformed, treat as empty */ }
return out;
}
/**
* Upstream variables that would actually SUBSTITUTE in a rendered prompt —
* `collectUpstreamVariables` minus the ones flagged internal.
*
* WP_PromptAssembler runs `strip_internals` over the render context before
* resolving, so a `$var` naming an internal variable resolves to nothing. The
* flagged names still travel the socket (Combine and Derivation downstream
* read them), which is why the plain variable list keeps them — it is the
* PROMPT surface specifically that must not offer them.
*/
export function collectUpstreamRenderableVariables(
rootGraph: LiteGraphLike,
node: LiteNodeLike,
): string[] {
const resolved = collectUpstreamResolved(rootGraph, node);
const internal = internalVarNames(resolved);
return Object.keys(resolved).filter((k) => !k.startsWith("__") && !internal.has(k));
}
/**
* Walk upstream from `node` and return true iff some WP_ContextLoop in
* the pipeline-context chain has `override_seed=true` in its widget
@@ -753,6 +792,14 @@ export function collectUpstreamKinds(
const kinds: Record<string, string> = {};
const internalKeys = new Set<string>();
// Internal-ness is last-write-wins, exactly like the value: the CLOSEST
// writer of a var decides. `add`-only left a var internal forever once any
// writer marked it so — mark $outfit internal, override with a public
// $outfit, and the public override stayed hidden from the assembler.
const flagInternal = (name: string, on: boolean): void => {
if (on) internalKeys.add(name);
else internalKeys.delete(name);
};
// Walk furthest-upstream → closest, so later writes override
// earlier ones (last-write-wins matches runtime).
for (let i = chain.length - 1; i >= 0; i--) {
@@ -768,7 +815,7 @@ export function collectUpstreamKinds(
const b = (row.binding ?? "").trim();
if (!b) continue;
kinds[b] = "injector";
if (row.internal === true) internalKeys.add(b);
flagInternal(b, row.internal === true);
}
continue;
}
@@ -788,8 +835,8 @@ export function collectUpstreamKinds(
const totalName = `${baseName}_total`;
kinds[baseName] = "loop";
kinds[totalName] = "loop";
if (cfg.iteration_internal === true) internalKeys.add(baseName);
if (cfg.total_internal === true) internalKeys.add(totalName);
flagInternal(baseName, cfg.iteration_internal === true);
flagInternal(totalName, cfg.total_internal === true);
continue;
}
if (n.type !== "WP_Context") continue;
@@ -819,7 +866,7 @@ export function collectUpstreamKinds(
if (!passes(val.id)) continue;
const name = (val.name ?? "").replace(/^\$/, "").trim();
if (name) kinds[name] = "fixed_values";
if (name && m.instance?.internal) internalKeys.add(name);
if (name) flagInternal(name, !!m.instance?.internal);
}
continue;
}
@@ -828,12 +875,12 @@ export function collectUpstreamKinds(
if (!passes(val.id)) continue;
const name = (val.name ?? "").replace(/^\$/, "").trim();
if (name) kinds[name] = "fixed_values";
if (name && m.instance?.internal) internalKeys.add(name);
if (name) flagInternal(name, !!m.instance?.internal);
}
for (const e of m.entries ?? []) {
const name = (e.variable_name ?? "").replace(/^\$/, "").trim();
if (name) kinds[name] = "fixed_values";
if (name && m.instance?.internal) internalKeys.add(name);
if (name) flagInternal(name, !!m.instance?.internal);
}
continue;
}
@@ -852,11 +899,11 @@ export function collectUpstreamKinds(
for (const branch of rule.branches ?? []) {
const name = (branch.action?.target_var ?? "").replace(/^\$/, "").trim();
if (name) kinds[name] = "derivation";
if (name && m.instance?.internal) internalKeys.add(name);
if (name) flagInternal(name, !!m.instance?.internal);
}
const elseName = (rule.else?.action?.target_var ?? "").replace(/^\$/, "").trim();
if (elseName) kinds[elseName] = "derivation";
if (elseName && m.instance?.internal) internalKeys.add(elseName);
if (elseName) flagInternal(elseName, !!m.instance?.internal);
}
continue;
}
@@ -867,7 +914,7 @@ export function collectUpstreamKinds(
const raw = inst.variable_binding ?? payload.var_binding ?? payload.output_var ?? "";
const name = raw.replace(/^\$/, "").trim();
if (name) kinds[name] = m.type;
if (name && m.instance?.internal) internalKeys.add(name);
if (name) flagInternal(name, !!m.instance?.internal);
}
}
// Stash internal-flag map on the reserved slot (parallels
@@ -890,6 +937,18 @@ export function collectUpstreamKinds(
* one. This carries the owning node and module through instead of discarding
* them.
*/
/** One `accepts` tag group a producing wildcard declares, with its member
* tags. A library fact, so it is safe to complete against; a pick index is
* not, because `pick_min`/`pick_max` live on the instance. */
export interface VarAxis {
axis: string;
tags: string[];
/** Position among ALL of the wildcard's tag groups, not among the accepts
* ones — `axisHueAt()` is indexed that way in the wildcard editor, so
* filtering first would give SHOES a different colour in the two places. */
hueIndex: number;
}
export interface VarProducer {
/** "wildcard" | "fixed_values" | "combine" | "derivation" | "injector" | "loop" */
kind: string;
@@ -903,6 +962,9 @@ export interface VarProducer {
/** Display name of the writing module. Absent for injector / loop, which
* write from node config rather than a library module. */
moduleName?: string;
/** `accepts` axes this variable exposes, for `$var.AXIS` completion.
* Wildcards only, and omitted when the wildcard declares none. */
axes?: VarAxis[];
/** 8-hex module id, when a module wrote it. */
moduleId?: string;
/** Flagged internal — resolves downstream but the assembler strips it from
@@ -991,6 +1053,14 @@ export function collectUpstreamProducers(
lastWriter.set(name, who);
out[name] = {
...info,
// Axes survive being overwritten. The engine records a rolled axis in
// `__wp_axes__` keyed by BINDING at pick time, and a later writer
// rebinding the same `$var` — a derivation, an injector row — replaces
// the text without touching that record, so `$outfit.SHOES` still
// resolves. Replacing the producer wholesale dropped the wildcard's
// axes the moment anything downstream wrote the same name, which is
// every real graph: the axis rows vanished one node along the chain.
...(info.axes ?? prev?.axes ? { axes: info.axes ?? prev?.axes } : {}),
shadowed: prev ? (sameWriter ? prev.shadowed : prev.shadowed + 1) : 0,
};
};
@@ -1093,9 +1163,30 @@ export function collectUpstreamProducers(
}
const inst = (m.instance ?? {}) as { variable_binding?: string | null };
const payload = (m.payload ?? {}) as { var_binding?: string; output_var?: string };
const payload = (m.payload ?? {}) as {
var_binding?: string;
output_var?: string;
tag_groups?: Record<string, string[]>;
tag_group_kinds?: Record<string, string>;
};
const raw = inst.variable_binding ?? payload.var_binding ?? payload.output_var ?? "";
write(raw.replace(/^\$/, "").trim(), base, writerKey);
// `accepts` axes ride along so the template editor can complete
// `$var.AXIS`. Read from the PAYLOAD because a group's kind is a library
// fact shared by every instance — unlike `pick_min`/`pick_max`, which are
// per-instance and therefore cannot be suggested from a library surface.
const axes: VarAxis[] = [];
if (m.type === "wildcard") {
const kinds = payload.tag_group_kinds ?? {};
// Index over EVERY group so the hue matches the wildcard editor's.
Object.entries(payload.tag_groups ?? {}).forEach(([axis, tags], hueIndex) => {
if (kinds[axis] === "accepts") axes.push({ axis, tags: tags ?? [], hueIndex });
});
}
write(
raw.replace(/^\$/, "").trim(),
axes.length > 0 ? { ...base, axes } : base,
writerKey,
);
}
}
return out;
@@ -1127,13 +1218,39 @@ export function collectUpstreamProducers(
// SP2a: optional `.K` list accessor (group 2) so `$mood.0` is consumed whole
// (no stranded ".0" literal) and the index drives applyVarAccessor below.
const VAR_REF_RE = /\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/g;
const VAR_REF_RE =
/\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?(?:\.([A-Za-z_][A-Za-z0-9_]*))?(?:\.(\d+))?/g;
const WC_REF_RE = /@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}/g;
const MAX_REF_DEPTH = 8;
interface MinimalWildcard {
options?: Array<{ value?: string; weight?: number }>;
var_binding?: string;
tag_groups?: Record<string, string[]>;
tag_group_kinds?: Record<string, string>;
}
/** The tag a `$var.AXIS` read previews as, or undefined when no wildcard in
* the chain binds `name` with an `accepts` group called `axis`.
*
* Runtime rolls one member of the group per pick; the static preview has no
* rng, so it shows the FIRST member — the same deterministic convention the
* `@{uuid}` expansion uses when it previews a wildcard as its first option.
* Returning undefined for an unknown axis is what keeps the caller from
* swallowing an accessor it cannot resolve: `$size.LARGE` where LARGE is not
* an axis stays verbatim rather than silently losing text. */
function previewAxisTag(
catalog: Map<string, MinimalWildcard>,
name: string,
axis: string,
): string | undefined {
for (const wc of catalog.values()) {
if ((wc.var_binding ?? "").replace(/^\$/, "") !== name) continue;
if (wc.tag_group_kinds?.[axis] !== "accepts") continue;
const tags = wc.tag_groups?.[axis] ?? [];
if (tags.length > 0) return tags[0];
}
return undefined;
}
function resolveChainStatic(chain: LiteNodeLike[]): Record<string, ResolvedValue> {
@@ -1223,6 +1340,7 @@ function resolveChainStatic(chain: LiteNodeLike[]): Record<string, ResolvedValue
if (!binding) continue;
ctx[binding] = `$${binding}`;
if (row.internal === true) internalKeys.add(binding);
else internalKeys.delete(binding);
}
continue;
}
@@ -1245,7 +1363,9 @@ function resolveChainStatic(chain: LiteNodeLike[]): Record<string, ResolvedValue
ctx[baseName] = "1";
ctx[totalName] = "1";
if (cfg.iteration_internal === true) internalKeys.add(baseName);
else internalKeys.delete(baseName);
if (cfg.total_internal === true) internalKeys.add(totalName);
else internalKeys.delete(totalName);
continue;
}
if (n.type !== "WP_Context") continue;
@@ -1256,11 +1376,17 @@ function resolveChainStatic(chain: LiteNodeLike[]): Record<string, ResolvedValue
const bundleEnabled = buildBundleEnabledMap(v.bundles);
for (const m of v.modules) {
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
const beforeKeys = new Set(Object.keys(ctx));
// Snapshot VALUES, not just keys: an override rebinds an existing key,
// so a key-presence check never sees it and the flag could not flip.
const beforeVals = new Map(Object.entries(ctx));
writeBindings(ctx, m, catalog);
if (m.instance?.internal) {
for (const k of Object.keys(ctx)) {
if (!beforeKeys.has(k)) internalKeys.add(k);
const isInternal = !!m.instance?.internal;
for (const k of Object.keys(ctx)) {
// Written by THIS module = new key or changed value. Last writer wins,
// so its internal flag replaces whatever an earlier writer set.
if (!beforeVals.has(k) || beforeVals.get(k) !== ctx[k]) {
if (isInternal) internalKeys.add(k);
else internalKeys.delete(k);
}
}
}
@@ -1322,7 +1448,16 @@ function writeBindings(
for (const v of libValues) {
if (typeof v.name === "string" && typeof v.id === "string") libNameToId.set(v.name, v.id);
}
for (const e of m.entries) {
// Both guards matter, and neither is theoretical. This walker runs inside
// `onConnectionsChange` (see `extension/reactive.ts`), so a throw here does
// not degrade to an empty preview — it escapes into litegraph's connection
// handling, and one malformed module breaks canvas interaction for the
// whole graph. A library payload that carries `payload.values` but no
// `entries` is enough to trigger it. The other two `entries` readers in
// this file (the kind scan and the producer walk) already use `?? []`;
// this one was the outlier.
for (const e of Array.isArray(m.entries) ? m.entries : []) {
if (typeof e?.variable_name !== "string") continue;
const name = e.variable_name.replace(/^\$/, "").trim();
if (!name) continue;
const libId = libNameToId.get(e.variable_name);
@@ -1507,10 +1642,24 @@ function expandValue(
// unknowns (incl. their accessor) intact. ctx values are strings in this
// static resolver, so applyVarAccessor treats `$s.0` as `$s` and `$s.K>0`
// as "" — never leaking the literal ".K" into the preview.
let out = raw.replace(VAR_REF_RE, (full, name, idx) =>
Object.prototype.hasOwnProperty.call(ctx, name)
? applyVarAccessor(ctx[name], idx != null ? parseInt(idx, 10) : undefined)
: full,
let out = raw.replace(
VAR_REF_RE,
(full, name, idxA, axis, idxB) => {
if (!Object.prototype.hasOwnProperty.call(ctx, name)) return full;
const idx = idxA ?? idxB;
const base = applyVarAccessor(
ctx[name],
idx != null ? parseInt(idx, 10) : undefined,
);
// An axis read resolves to a TAG, not to the variable's text. Without
// this the preview substituted `$outfit` and left `.SHOES` stranded, so
// both the preview and the resolved line read
// "a white t-shirt and denim skirt.SHOES" — the accessor looked broken
// in exactly the editor meant to teach it.
if (axis == null) return base;
const tag = previewAxisTag(catalog, name, axis);
return tag ?? full;
},
);
// 2. Substitute `@{8hex}` with the referenced wildcard's first option,
// recursively expanded so chains (`@{a}` → "@{b} hat" → "blue hat")
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { effectScope } from "vue";
import { reactiveFromGraph } from "./reactive";
/**
* The polling fallback's cost, which nothing made visible before.
*
* Measured on an 8-node Context chain carrying 96 variables: 0.59ms per
* refresh. Small alone, and not what it costs — every node walks its WHOLE
* upstream chain, so a series chain is quadratic in total work, and a series
* chain is the shape people build.
*
* The fallback exists for upstream edits that fire no event on this node.
* Neither of the gates below can hide such an edit for long, and both stop
* work at moments when no edit is possible at all.
*/
describe("reactiveFromGraph — the poll does not run flat out forever", () => {
let hidden = false;
beforeEach(() => {
vi.useFakeTimers();
hidden = false;
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => hidden,
});
});
afterEach(() => vi.useRealTimers());
/** A node whose computed value we control, with a counter for how often the
* compute actually ran. */
function mount(initial = "a") {
let value = initial;
let computes = 0;
const scope = effectScope();
scope.run(() => {
reactiveFromGraph({}, () => { computes += 1; return value; }, (x, y) => x === y);
});
return {
computes: () => computes,
set: (v: string) => { value = v; },
stop: () => scope.stop(),
};
}
it("does no work at all while the tab is hidden", () => {
const f = mount();
const baseline = f.computes();
hidden = true;
vi.advanceTimersByTime(400 * 20);
expect(f.computes()).toBe(baseline);
f.stop();
});
it("catches up once on the way back to visible", () => {
// An edit made in another tab must not stay invisible; the point of the
// gate is to skip work nobody can observe, not to miss it.
const f = mount();
hidden = true;
vi.advanceTimersByTime(400 * 10);
const beforeReturn = f.computes();
hidden = false;
vi.advanceTimersByTime(400);
expect(f.computes()).toBe(beforeReturn + 1);
f.stop();
});
it("backs off once the value stops changing", () => {
const f = mount();
// Ten unchanged polls take it off the base rate.
vi.advanceTimersByTime(400 * 10);
const afterFirstQuiet = f.computes();
// The next ten ticks are now spread across a 3x period, so far fewer of
// them do any work.
vi.advanceTimersByTime(400 * 10);
const during = f.computes() - afterFirstQuiet;
expect(during).toBeLessThan(10);
expect(during).toBeGreaterThan(0); // still polling, just less often
f.stop();
});
it("snaps back to full rate the moment something actually changes", () => {
const f = mount();
vi.advanceTimersByTime(400 * 30); // fully backed off
f.set("b");
// Give the slow rate a chance to notice the change...
vi.advanceTimersByTime(400 * 8);
const afterChange = f.computes();
// ...and from there it should be polling every tick again.
vi.advanceTimersByTime(400 * 5);
expect(f.computes() - afterChange).toBe(5);
f.stop();
});
it("snaps back on user activity, before any change is observed", () => {
// Someone who starts editing should not wait out the back-off ladder to
// see their own upstream edit land.
const f = mount();
vi.advanceTimersByTime(400 * 30);
document.dispatchEvent(new Event("pointerdown"));
vi.advanceTimersByTime(400); // resets, does not compute this tick
const afterReset = f.computes();
vi.advanceTimersByTime(400 * 5);
expect(f.computes() - afterReset).toBe(5);
f.stop();
});
});
+112 -3
View File
@@ -58,6 +58,86 @@ function watchNodeMode(node: ConnectableNode, cb: () => void): () => void {
return () => { slot!.subs.delete(cb); };
}
/* ── Shared activity clock ────────────────────────────────────────────────
*
* ONE document listener for the whole extension, not one per node. Every
* polling widget reads the same timestamp to decide whether the user is
* currently doing anything; N listeners for a single global fact would be the
* same mistake the per-node intervals already make.
*
* Capture phase and passive: this only observes, and must not be hidden by a
* `stopPropagation()` from ComfyUI's own canvas handling.
*/
let lastActivityAt = Date.now();
function markActivity(): void { lastActivityAt = Date.now(); }
if (typeof document !== "undefined") {
for (const ev of ["pointerdown", "keydown", "wheel"]) {
document.addEventListener(ev, markActivity, { capture: true, passive: true });
}
}
/**
* A polling fallback that stops working when nothing can be happening.
*
* Shared, because this project has two of these loops and they had drifted
* into having nothing in common but the bug. `tick` reports whether it
* observed a change, which is what drives the ladder back up.
*/
export function startAdaptivePoll(baseMs: number, tick: () => "changed" | "quiet"): () => void {
let quiet = 0;
let stepIdx = 0;
let ticks = 0;
let wasHidden = false;
let seenActivityAt = lastActivityAt;
const run = (): void => {
if (typeof document !== "undefined" && document.hidden) {
wasHidden = true;
return;
}
if (wasHidden) {
// Coming back to a tab that may have missed edits made elsewhere.
wasHidden = false;
quiet = 0;
stepIdx = 0;
tick();
return;
}
if (lastActivityAt !== seenActivityAt) {
seenActivityAt = lastActivityAt;
quiet = 0;
stepIdx = 0;
}
// Skip ticks to realise the current multiple, rather than tearing down and
// recreating the interval at every step.
ticks += 1;
if (ticks % BACKOFF_STEPS[stepIdx] !== 0) return;
if (tick() === "quiet") {
quiet += 1;
if (quiet >= STEP_AFTER_QUIET && stepIdx < BACKOFF_STEPS.length - 1) {
stepIdx += 1;
quiet = 0;
}
} else {
quiet = 0;
stepIdx = 0;
}
};
const id = window.setInterval(run, baseMs);
return () => window.clearInterval(id);
}
/** Back-off ladder for the polling fallback, in multiples of the base period.
* Idle canvases are the common case — a graph sits untouched while a queue
* runs — and the fallback exists for edits that fire no event, which cannot
* happen while nobody is editing. */
const BACKOFF_STEPS = [1, 3, 8];
/** Consecutive unchanged polls before stepping down the ladder. At the 400ms
* base that is ~4s of stillness per step, ~12s to the slowest rate. */
const STEP_AFTER_QUIET = 10;
export function reactiveFromGraph<T>(
node: ConnectableNode,
compute: () => T,
@@ -83,8 +163,37 @@ export function reactiveFromGraph<T>(
// its UI / re-walk the chain after the user keys M/Ctrl-M.
const unwatchMode = watchNodeMode(node, refresh);
// Polling fallback for upstream-chain edits that don't fire on this node.
const interval = window.setInterval(refresh, pollMs);
/* Polling fallback for upstream-chain edits that don't fire on this node.
*
* The naive version — `setInterval(refresh, 400)` — runs forever, per node,
* regardless of whether anything could have changed. Measured on an 8-node
* Context chain carrying 96 variables: 0.59ms per refresh. That is small
* alone and it is not what it costs, because EVERY node walks its whole
* upstream chain, so a series chain is quadratic in total work and this is
* the shape people build. Nine nodes already produce a visible long task.
*
* Two gates, both of which cost nothing when the user is actually working:
*
* 1. A hidden tab skips entirely. Nobody can observe a stale widget on a
* tab they are not looking at, and browsers already throttle background
* intervals — so this is work that was being done specifically at the
* moment it was least useful. A single catch-up refresh runs on the way
* back to visible.
* 2. A still canvas backs off. The fallback covers edits that fire no
* event, which cannot happen while nobody is editing, so after ~4s of no
* pointer, key or wheel activity AND no observed change, the period
* lengthens 3x, then 8x. Any real change or any user activity snaps it
* straight back to the base rate.
*
* The event paths — `onConnectionsChange`, the mode intercept, graph load —
* are untouched and still fire instantly. Only the fallback slows, and only
* while nothing is happening.
*/
const stopPoll = startAdaptivePoll(pollMs, () => {
const before = state.value;
refresh();
return equals(before, state.value) ? "quiet" : "changed";
});
// Re-sync on workflow load. Several ticks because ComfyUI restores widget
// values asynchronously through cascading callbacks; a single refresh can
@@ -99,7 +208,7 @@ export function reactiveFromGraph<T>(
});
onScopeDispose(() => {
window.clearInterval(interval);
stopPoll();
unsubscribe();
unwatchMode();
node.onConnectionsChange = orig;
+229
View File
@@ -19,6 +19,7 @@ import "../components/shared/vue-nodes.css";
// (the SPA's rich-text.css isn't loaded on the canvas — see the file header).
import "../components/shared/rich-text-canvas.css";
import { pushToast } from "../components/shared/toast-store";
import { notifyCompletionSettingsChanged } from "../manager/utils/tagSetting";
import { openPlayground } from "../components/settings/playground-store";
export type A11yMode = "auto" | "on" | "off";
@@ -84,6 +85,104 @@ export type ComfySettingCustomRenderer = (
attrs?: Record<string, unknown>,
) => HTMLElement;
/**
* A pill switch matching the one ComfyUI renders for a plain `type: "boolean"`.
*
* Only needed because this ONE setting cannot be a plain boolean: it disables
* itself when no tag list is installed, and a native boolean has no way to say
* "on is not available". Every other setting we own uses the real thing.
*
* EVERY dimension and colour reads PrimeVue's own `--p-toggleswitch-*` custom
* property first. Those are injected when their component first mounts — which
* has always happened by the time this renders, because our row sits in their
* settings dialog among their switches — so at render time this resolves to
* literally their pill, and follows their theme when it changes.
*
* The fallbacks are Aura's defaults (40x24 pill, 16px handle), used only if a
* token is missing. An earlier hand-picked 34x18 read as visibly smaller and
* duller than the switches directly beneath it.
*
* Handle travel is a `calc()` over the same tokens rather than a fixed 16px, so
* a differently-sized track still lands the knob flush against its end.
*
* Not reusing `.p-toggleswitch` itself: those classes are ComfyUI's internals,
* and borrowing them buys a perfect match today and a control that renders as
* nothing the day they restyle or upgrade PrimeVue.
*/
function buildSwitch(initial: boolean): {
root: HTMLElement;
setOn: (on: boolean) => void;
setDisabled: (off: boolean) => void;
} {
let on = initial;
let disabled = false;
const root = document.createElement("span");
root.setAttribute("role", "switch");
root.tabIndex = 0;
const knob = document.createElement("span");
root.appendChild(knob);
// Static styling is written ONCE. Rewriting `cssText` on every change — the
// first version did — replaces the `transition` declaration mid-flight, so
// the browser has nothing to animate between and the knob teleports instead
// of sliding. Only the properties that actually change are touched below.
root.style.cssText = [
"display:inline-flex", "align-items:center", "flex:0 0 auto",
"box-sizing:border-box",
"width:var(--p-toggleswitch-width, 40px)",
"height:var(--p-toggleswitch-height, 24px)",
"border-radius:var(--p-toggleswitch-border-radius, 30px)",
"padding:var(--p-toggleswitch-gap, 4px)",
"border:var(--p-toggleswitch-border-width, 1px) solid "
+ "var(--p-toggleswitch-border-color, transparent)",
"transition:background-color var(--p-transition-duration, .2s) ease",
"outline:none",
].join(";");
knob.style.cssText = [
"display:block",
"width:var(--p-toggleswitch-handle-size, 16px)",
"height:var(--p-toggleswitch-handle-size, 16px)",
"border-radius:var(--p-toggleswitch-handle-border-radius, 50%)",
"background:var(--p-toggleswitch-handle-background, #fff)",
"transition:transform var(--p-transition-duration, .2s) ease",
].join(";");
const paint = (): void => {
root.setAttribute("aria-checked", on ? "true" : "false");
root.style.background = on
? "var(--p-toggleswitch-checked-background, var(--p-primary-color, #60a5fa))"
: "var(--p-toggleswitch-background, var(--p-surface-600, #52525b))";
root.style.opacity = disabled ? "var(--p-disabled-opacity, .6)" : "1";
root.style.cursor = disabled ? "not-allowed" : "pointer";
// Travel is the track minus the handle minus both gaps, computed by the
// browser so it stays correct whatever `--p-toggleswitch-*` resolve to.
knob.style.transform = on
? "translateX(calc(var(--p-toggleswitch-width, 40px)"
+ " - var(--p-toggleswitch-handle-size, 16px)"
+ " - (var(--p-toggleswitch-gap, 4px) * 2)))"
: "translateX(0)";
};
paint();
const toggle = (): void => {
if (disabled) return;
on = !on;
paint();
root.dispatchEvent(new CustomEvent("wp-switch", { detail: on, bubbles: true }));
};
root.addEventListener("click", (e) => { e.preventDefault(); toggle(); });
root.addEventListener("keydown", (e) => {
if (e.key === " " || e.key === "Enter") { e.preventDefault(); toggle(); }
});
return {
root,
setOn: (next) => { on = next; paint(); },
setDisabled: (next) => { disabled = next; paint(); },
};
}
export interface ComfySetting {
id: string;
name: string;
@@ -758,6 +857,136 @@ export function buildSettings(_app: AppLike): ComfySetting[] {
tooltip: "Live preview of every display + a11y setting in one place.",
category: ["Wildcard Pipeline", "1. Playground", "Open"],
},
// Booru tag autocomplete — canvas half of the SPA setting, deliberately
// separate. Someone may want suggestions in the full manager and not while
// squinting at a node on a canvas, and a ComfyUI user looks for switches
// here rather than in our SPA.
//
// Rendered by hand so it can DISABLE itself when no tag list is installed.
// A plain boolean would happily switch on and do nothing, which is the
// failure this whole feature keeps having to design around: a control that
// implies a capability it does not have.
{
id: "wildcardPipeline.behavior.tagAutocomplete",
name: "Booru tag autocomplete",
type: (_name, setter, value, _attrs) => {
const wrap = document.createElement("label");
wrap.style.cssText =
"display:flex; align-items:center; justify-content:flex-end; gap:10px; cursor:pointer";
const { root: box, setOn, setDisabled } = buildSwitch(value === true);
const note = document.createElement("span");
// NOTE FIRST, then the switch. Appended the other way round the control
// floated mid-row while every neighbouring toggle sat flush right, and
// a row that does not line up reads as broken before it reads as ours.
note.style.cssText =
"font: 12px/1.3 var(--wp-font-sans, sans-serif); color: var(--descrip-text, #999)";
wrap.append(note, box);
// Ask the server whether a list exists. Until it answers, leave the
// control alone rather than flickering it disabled.
void fetch("/wp/api/tags/status")
.then((r) => r.json())
.then((s: { available?: boolean; tag_count?: number }) => {
if (s.available) {
note.textContent = `${(s.tag_count ?? 0).toLocaleString()} tags installed`;
return;
}
setOn(false);
setDisabled(true);
wrap.style.cursor = "not-allowed";
wrap.title =
"No tag list installed. Open the Wildcard Pipeline manager → "
+ "Settings → Tag autocomplete and press Download to enable this.";
note.textContent = "No tag list — download one in the manager's Settings";
})
.catch(() => {
note.textContent = "";
});
box.addEventListener("wp-switch", (e) => {
setter((e as CustomEvent<boolean>).detail);
// Editors read this straight out of ComfyUI's settings store, which
// Vue cannot track — without the nudge the change took a page reload
// to have any effect.
notifyCompletionSettingsChanged();
});
return wrap;
},
defaultValue: false,
tooltip:
"Suggest danbooru tag names while typing in Wildcard Pipeline node "
+ "editors. Requires a tag list, installed from the manager's Settings. "
+ "Only affects this extension's own inputs.",
category: ["Wildcard Pipeline", "7. Runtime behavior", "Tag autocomplete"],
},
// The other two completion sources. Same hand-built control as the tag
// setting above, for the same reason it exists: a count beside the switch.
// "50 found" answers the question a bare toggle raises — is there anything
// for this to suggest? — and it is the question that sends people to the
// manager to check.
...(
[
{ id: "wildcardPipeline.behavior.loraAutocomplete", name: "LoRA autocomplete",
kind: "lora", noun: "LoRAs",
tooltip:
"Suggest installed LoRA names while typing in Wildcard Pipeline node "
+ "editors, inserting the full <lora:name:1.0> syntax. Reads the models "
+ "ComfyUI already knows about. Only affects this extension's own inputs." },
{ id: "wildcardPipeline.behavior.embeddingAutocomplete", name: "Embedding autocomplete",
kind: "embedding", noun: "embeddings",
tooltip:
"Suggest installed embedding names while typing in Wildcard Pipeline "
+ "node editors, inserting the full embedding:name syntax. Reads the "
+ "models ComfyUI already knows about. Only affects this extension's "
+ "own inputs." },
] as const
).map((src) => ({
id: src.id,
name: src.name,
type: ((_name: string, setter: (v: unknown) => void, value: unknown) => {
const wrap = document.createElement("label");
wrap.style.cssText =
"display:flex; align-items:center; justify-content:flex-end; gap:10px; cursor:pointer";
const { root: box } = buildSwitch(value === true);
const note = document.createElement("span");
note.style.cssText =
"font: 12px/1.3 var(--wp-font-sans, sans-serif); color: var(--descrip-text, #999)";
// Note first so the switch lands flush right, level with every
// neighbouring row's control.
wrap.append(note, box);
void fetch("/wp/api/models/status")
.then((r) => r.json())
.then((s: { sources?: { kind: string; count: number }[] }) => {
const count = (s.sources ?? []).find((x) => x.kind === src.kind)?.count ?? 0;
note.textContent = count > 0
? `${count.toLocaleString()} ${src.noun} found`
: `No ${src.noun} found`;
})
.catch(() => { note.textContent = ""; });
box.addEventListener("wp-switch", (e) => {
setter((e as CustomEvent<boolean>).detail);
notifyCompletionSettingsChanged();
});
return wrap;
}) as ComfySettingCustomRenderer,
defaultValue: false,
tooltip: src.tooltip,
category: ["Wildcard Pipeline", "7. Runtime behavior", src.name],
})),
{
id: "wildcardPipeline.behavior.autocompleteSeparator",
name: "Append \", \" after a completion",
type: "boolean",
defaultValue: false,
tooltip:
"Insert a comma and a space after picking a suggestion, so the next tag "
+ "can be typed straight away. Never applied inside a <lora:…> or "
+ "embedding:… reference, where a comma would end the reference.",
category: ["Wildcard Pipeline", "7. Runtime behavior", "Autocomplete separator"],
onChange: () => notifyCompletionSettingsChanged(),
},
// Visual axes — sizing, embellishment, identity
{
id: SETTING_ID_DENSITY,
+14 -5
View File
@@ -1,4 +1,5 @@
import { walkAllNodes, collectUpstreamVariables, type LiteGraphLike, type LiteNodeLike } from "./graph";
import { startAdaptivePoll } from "./reactive";
import { scanConflicts, scanTemplateConflicts, labelFor, type Conflict, type Severity } from "./conflicts";
import { parseWidgetJson, type ContextWidgetValue } from "../widgets/_shared";
import { onGraphLoaded } from "./graph-events";
@@ -285,8 +286,10 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
return;
}
function recompute() {
if (!node.subgraph || !Ctor) return;
/** Returns whether anything actually moved, which is what lets the shared
* adaptive poll back off on a still canvas. */
function recompute(): "changed" | "quiet" {
if (!node.subgraph || !Ctor) return "quiet";
const next = computeBadgeState(rootGraph, node.subgraph);
// Cache key includes both severity AND text — same severity with a new
// variable name (e.g. user fixed $foo but $bar is still missing) needs a
@@ -295,7 +298,7 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
const sameAsBefore =
(next === null && prevSeverity === null) ||
(!!next && next.severity === prevSeverity && next.text === readBadge(node)?.text);
if (sameAsBefore) return;
if (sameAsBefore) return "quiet";
// Pulse triggers: badge appears (null -> something) or severity worsens.
// Same-severity text changes (e.g. $foo -> $bar still warning) get a
// silent re-render — the wording change is informational, not urgent.
@@ -311,6 +314,7 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
if (badge && isAppear) pulseBadge(node, badge, 0.4, 1.12);
else if (badge && isEscalation) pulseBadge(node, badge, 1.0, 1.18);
}
return "changed";
}
// Initial compute happens on next tick — workflow may still be loading
@@ -319,7 +323,12 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
// Polling fallback. 800ms is coarse but the badge isn't latency-critical;
// the user is already used to seeing conflict dots within ~1s elsewhere.
const interval = window.setInterval(recompute, 800);
//
// Shared with `reactive.ts` rather than a second bare `setInterval`: this
// one scans the whole subgraph on every tick, per SubgraphNode, forever —
// including on a hidden tab, where nobody can see a badge, and on a still
// canvas, where nothing can have changed the conflicts it reports.
const stopPoll = startAdaptivePoll(800, recompute);
// Recompute on connection changes — wires added/removed inside the
// subgraph or to/from the SubgraphNode itself.
@@ -340,7 +349,7 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
// its observers cleanly. We chain so other extensions still run.
const origRemoved = node.onRemoved;
const cleanup = () => {
window.clearInterval(interval);
stopPoll();
unsubGraphLoaded();
node.onConnectionsChange = origCC;
node.onRemoved = origRemoved;
+16 -3
View File
@@ -12,6 +12,7 @@ const [
ctxMod,
dbgMod,
asmMod,
tmplMod,
injMod,
cleanerMod,
varPickerMod,
@@ -31,6 +32,7 @@ const [
import("./widgets/context"),
import("./widgets/debug"),
import("./widgets/assembler"),
import("./widgets/templateEditor"),
import("./widgets/injector"),
import("./widgets/cleaner"),
import("./widgets/var_picker"),
@@ -46,11 +48,14 @@ const [
import("./extension/topbar"),
import("./components/settings/DisplayPlaygroundModal.vue"),
import("./components/settings/playground-store"),
// Webfonts (Inter 400/600 + JetBrains Mono 400) — side-effect import,
// the module just owns the @font-face CSS chunk.
import("./extension/fonts"),
]);
// Webfonts — deliberately NOT awaited above. The module only appends a
// <link> for @font-face rules, and `font-display: swap` means text renders in
// the system stack until they land. Nothing about registering a node type
// depends on a font, so making registration wait on one was pure latency.
void import("./extension/fonts");
// Singleton toast container — one Vue app mounted to a body-level div renders
// every toast pushed via shared/toast-store. Each Context node's Vue app
// imports the same store module, so toasts surface from anywhere.
@@ -89,6 +94,7 @@ type CleanerCreateNode = Parameters<typeof cleanerMod.create>[0];
type VarPickerCreateNode = Parameters<typeof varPickerMod.create>[0];
type CtxLoopCreateNode = Parameters<typeof ctxLoopMod.create>[0];
type SeedListCreateNode = Parameters<typeof seedListMod.create>[0];
type TemplateEditorNode = Parameters<typeof tmplMod.create>[0];
type AssemblerHelperNode = Parameters<typeof asmMod.mountHelper>[0];
interface NodeData { name: string }
@@ -194,6 +200,13 @@ app.registerExtension({
ctxLoopMod.create(node, inputName),
WP_SEED_LIST_CONFIG: (node: SeedListCreateNode, inputName: string) =>
seedListMod.create(node, inputName),
// The assembler's `template`. Unlike every other entry here, this key
// is NOT the input's socket type — the socket stays STRING so links
// still work, and the node ships `widgetType` in the input spec's
// extra dict purely to route the lookup here. See
// `widgets/templateEditor.ts`.
WP_TEMPLATE_EDITOR: (node: TemplateEditorNode, inputName: string) =>
tmplMod.create(node, inputName),
};
},
@@ -290,3 +290,76 @@ describe("ConstraintMatrix.vue", () => {
wrap.unmount();
});
});
describe("ConstraintMatrix.vue — accepts-axis mark", () => {
const groups = { SHOES: ["sneakers", "heels"], REGISTER: ["casual", "formal"] };
it("marks an accepts source band with the amber pi-sync, classify bands unmarked", () => {
const wrap = mount(ConstraintMatrix, {
props: {
rows: ["sneakers", "heels", "casual", "formal"],
cols: ["warm", "cool"],
modelValue: {},
sourceGroups: groups,
sourceGroupKinds: { SHOES: "accepts" },
},
attachTo: document.body,
});
const marks = wrap.findAll(".wp-mx-axis-mark");
expect(marks.length).toBe(1);
// it sits on the SHOES band, not REGISTER
const shoesHead = wrap.findAll(".wp-mx-grp-head").find((h) => h.text().includes("SHOES"));
expect(shoesHead?.find(".wp-mx-axis-mark").exists()).toBe(true);
const registerHead = wrap.findAll(".wp-mx-grp-head").find((h) => h.text().includes("REGISTER"));
expect(registerHead?.find(".wp-mx-axis-mark").exists()).toBe(false);
wrap.unmount();
});
it("marks an accepts TARGET column band too", () => {
const wrap = mount(ConstraintMatrix, {
props: {
rows: ["warm", "cool"],
cols: ["sneakers", "heels"],
modelValue: {},
targetGroups: { SHOES: ["sneakers", "heels"] },
targetGroupKinds: { SHOES: "accepts" },
},
attachTo: document.body,
});
expect(wrap.find(".wp-mx-th-band .wp-mx-axis-mark").exists()).toBe(true);
wrap.unmount();
});
it("shows nothing when no group is accepts", () => {
const wrap = mount(ConstraintMatrix, {
props: {
rows: ["sneakers", "heels"], cols: ["warm", "cool"], modelValue: {},
sourceGroups: { SHOES: ["sneakers", "heels"] },
sourceGroupKinds: { SHOES: "classify" },
},
attachTo: document.body,
});
expect(wrap.findAll(".wp-mx-axis-mark").length).toBe(0);
wrap.unmount();
});
it("picking ONLY writes mode=only with factor 1", async () => {
const wrap = mountGrid({}, ["red"], ["warm"]);
await wrap.find('[data-test="cell-red-warm"]').trigger("click");
await popover(wrap).find("button.pop-btn.b-only").trigger("click");
expect(lastEmitted(wrap)?.red?.warm).toEqual({ mode: "only", factor: 1 });
wrap.unmount();
});
it("marks the neutral cells of an only row as shut out, and only that row", () => {
const wrap = mountGrid({ red: { warm: { mode: "only", factor: 1 } } }, ["red", "blue"], ["warm", "cool"]);
const only = wrap.find('[data-test="cell-red-warm"]');
expect(only.classes()).toContain("s-only");
const shut = wrap.find('[data-test="cell-red-cool"]');
expect(shut.classes()).toContain("implied-out");
expect(shut.text()).toContain("×");
expect(shut.attributes("aria-label")).toMatch(/excluded by an Only rule/);
expect(wrap.find('[data-test="cell-blue-cool"]').classes()).not.toContain("implied-out");
wrap.unmount();
});
});
+53 -1
View File
@@ -1,5 +1,6 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { nextTick } from "vue";
import { describe, expect, it, vi } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import EditorFrame from "../components/EditorFrame.vue";
import type { ModuleHistoryEntry } from "../api/types";
@@ -93,3 +94,54 @@ describe("EditorFrame.vue", () => {
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(true);
});
});
describe("EditorFrame.vue — failed-save auto-scroll", () => {
it("scrolls the first error's field into view when errors first appear", async () => {
// The offending section must exist in the DOM to be scrolled to.
const section = document.createElement("div");
section.id = "editor-section-options";
const input = document.createElement("input");
section.appendChild(input);
document.body.appendChild(section);
// jsdom has no scrollIntoView — define it as a spy.
const spy = vi.fn();
(section as unknown as { scrollIntoView: unknown }).scrollIntoView = spy;
const wrap = mount(EditorFrame, {
props: { title: "T", backRoute: "/wildcards", backLabel: "W", errors: [] },
slots: { default: "<div/>" },
global: { plugins: [makeRouter()] },
});
// Empty -> populated is the "tried to save while invalid" edge.
await wrap.setProps({
errors: [{ field: "editor-section-options", label: "Options", message: "Required" }],
});
await nextTick();
expect(spy).toHaveBeenCalledOnce();
section.remove();
});
it("does not re-scroll while the error list only shrinks/changes", async () => {
const section = document.createElement("div");
section.id = "editor-section-identity";
document.body.appendChild(section);
const spy = vi.fn();
(section as unknown as { scrollIntoView: unknown }).scrollIntoView = spy;
const err = (field: string) => ({ field, label: "X", message: "bad" });
const wrap = mount(EditorFrame, {
props: { title: "T", backRoute: "/wildcards", backLabel: "W",
errors: [err("editor-section-identity"), err("editor-section-options")] },
slots: { default: "<div/>" },
global: { plugins: [makeRouter()] },
});
await nextTick();
spy.mockClear();
// Fixing one error keeps the list non-empty — must NOT yank the scroll.
await wrap.setProps({ errors: [err("editor-section-identity")] });
await nextTick();
expect(spy).not.toHaveBeenCalled();
section.remove();
});
});
@@ -0,0 +1,313 @@
/**
* QA sweep over `$var.AXIS` authoring in the template editor.
*
* Written from a real session against the SPA combine editor, where typing an
* axis reference misbehaved in several separate ways at once. Each `it` here is
* one reported (or adjacent, suspected) interaction, so a regression names the
* behaviour that broke rather than "the editor".
*/
import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { nextTick } from "vue";
import RichTextInput from "../components/RichTextInput.vue";
const PRODUCERS = new Map([
["outfit", {
kind: "wildcard", shadowed: 0, moduleName: "QA outfit",
axes: [
{ axis: "SHOES", tags: ["sneakers", "high_heels", "sandals"], hueIndex: 1 },
{ axis: "EXPOSES", tags: ["navel"], hueIndex: 2 },
],
}],
["shoes", { kind: "wildcard", shadowed: 0, moduleName: "QA shoes" }],
]);
function mountEditor(modelValue = "") {
return mount(RichTextInput, {
props: {
modelValue,
varSuggestions: ["outfit", "shoes"],
varProducers: PRODUCERS,
multiline: true,
},
attachTo: document.body,
});
}
/** Type `text` into the host's trailing text span and fire `input`, which is
* how a browser delivers a keystroke to a contenteditable. */
async function typeInto(wrap: ReturnType<typeof mountEditor>, text: string) {
const host = wrap.find(".wp-rt__host");
const spans = (host.element as HTMLElement).querySelectorAll(".wp-rt__text");
const target = spans[spans.length - 1] ?? host.element;
target.textContent = (target.textContent ?? "") + text;
await host.trigger("input");
await nextTick();
return host;
}
/** Open the `$` popover the way the component exposes for tests. It sets the
* trigger without a query, so every suggestion is listed — which is what we
* want when asserting that the axis rows EXIST and are shaped correctly.
* Query-narrowing is covered at the probe level, where it is deterministic. */
async function openVarPopover(wrap: ReturnType<typeof mountEditor>) {
await (wrap.vm as unknown as {
__triggerAutocompleteForTest: (t: "@" | "$") => Promise<void>;
}).__triggerAutocompleteForTest("$");
await flushPromises();
}
describe("axis authoring — the popover", () => {
it("lists each variable's axes as their own rows", async () => {
const wrap = mountEditor();
await openVarPopover(wrap);
const labels = [...document.querySelectorAll(".wp-rt-suggestions__label")]
.map((n) => n.textContent ?? "");
expect(labels.some((l) => l.includes("outfit"))).toBe(true);
expect(labels.some((l) => l.includes("outfit.SHOES"))).toBe(true);
expect(labels.some((l) => l.includes("outfit.EXPOSES"))).toBe(true);
wrap.unmount();
});
it("REPORTED: the axis row is indented under its variable", async () => {
// A later `padding` SHORTHAND on the base rule was resetting the
// modifier's padding-left, so the hierarchy these rows exist to show
// rendered flat.
const wrap = mountEditor();
await openVarPopover(wrap);
const axisRow = [...document.querySelectorAll(".wp-rt-suggestions__item")]
.find((n) => (n.textContent ?? "").includes("outfit.SHOES")) as HTMLElement | undefined;
expect(axisRow).toBeDefined();
expect(axisRow!.className).toContain("wp-rt-suggestions__item--axis");
wrap.unmount();
});
it("shows the member tags, which are what tell two axes apart", async () => {
const wrap = mountEditor();
await openVarPopover(wrap);
const axisRow = [...document.querySelectorAll(".wp-rt-suggestions__item")]
.find((n) => (n.textContent ?? "").includes("outfit.SHOES"));
expect(axisRow?.textContent).toContain("sneakers");
wrap.unmount();
});
it("a variable with no axes contributes exactly one row", async () => {
const wrap = mountEditor();
await openVarPopover(wrap);
const shoesRows = [...document.querySelectorAll(".wp-rt-suggestions__label")]
.filter((n) => (n.textContent ?? "").includes("shoes"));
expect(shoesRows).toHaveLength(1);
wrap.unmount();
});
});
describe("axis authoring — the chip", () => {
it("REPORTED: $outfit.SHOES renders as ONE var atom, not inert text", async () => {
const wrap = mountEditor("wearing $outfit.SHOES today");
await nextTick();
const host = wrap.find(".wp-rt__host").element as HTMLElement;
const vars = host.querySelectorAll(".wp-refchip--var, .wp-rt-var");
expect(vars.length).toBeGreaterThan(0);
expect(host.textContent).toContain("$outfit.SHOES");
wrap.unmount();
});
it("keeps the whole reference in one atom so it deletes as a unit", async () => {
const wrap = mountEditor("$outfit.SHOES");
await nextTick();
const host = wrap.find(".wp-rt__host").element as HTMLElement;
// Two atoms would mean backspace eats `.SHOES` and leaves a bare `$outfit`
// that silently means something else.
const atoms = host.querySelectorAll(".wp-refchip--var, .wp-rt-var");
expect(atoms.length).toBe(1);
wrap.unmount();
});
it("round-trips the value unchanged", async () => {
const wrap = mountEditor("a $outfit.SHOES b");
await nextTick();
await wrap.find(".wp-rt__host").trigger("input");
const events = wrap.emitted("update:modelValue") ?? [];
if (events.length) {
expect(events[events.length - 1]?.[0]).toBe("a $outfit.SHOES b");
}
wrap.unmount();
});
});
describe("axis authoring — editing an existing reference", () => {
it("REPORTED: backspace deletes a character without committing a chip", async () => {
const wrap = mountEditor();
await typeInto(wrap, "$outfi");
await flushPromises();
const host = wrap.find(".wp-rt__host");
// The component handles Backspace itself and preventDefaults, so the test
// must NOT also remove the character — doing both deletes twice and
// "reproduces" a bug that is purely the harness.
await host.trigger("keydown", { key: "Backspace" });
await flushPromises();
// Mid-word text must stay editable text: Backspace must never be the thing
// that seals a chip, or a typo costs the whole reference.
const chips = (host.element as HTMLElement).querySelectorAll(".wp-refchip");
expect(chips.length).toBe(0);
wrap.unmount();
});
it("an undeclared axis is marked rather than silently empty", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "$outfit.BELTS",
varSuggestions: ["outfit", "shoes"],
varProducers: PRODUCERS,
graphAware: true,
},
attachTo: document.body,
});
await nextTick();
const html = (wrap.find(".wp-rt__host").element as HTMLElement).innerHTML;
// The engine renders an undeclared axis as "", so the only symptom without
// a mark is a word missing from the prompt.
expect(html).toContain("wp-refchip__accessor--unknown");
wrap.unmount();
});
it("a declared axis is NOT marked as an error", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "$outfit.SHOES",
varSuggestions: ["outfit", "shoes"],
varProducers: PRODUCERS,
graphAware: true,
},
attachTo: document.body,
});
await nextTick();
const html = (wrap.find(".wp-rt__host").element as HTMLElement).innerHTML;
expect(html).not.toContain("wp-refchip__accessor--unknown");
wrap.unmount();
});
});
describe("echo guard — the cause behind the Backspace bug", () => {
it("a parent that echoes modelValue back must not re-parse it into chips", async () => {
// Found in a real browser, invisible to jsdom: the atom-direct edit paths
// sync the host DOM imperatively AFTER updating the model, so the watcher's
// `readHostAsText() === next` guard still saw the PRE-edit string, missed,
// and re-parsed through parseForSurface — which chipifies. One Backspace
// mid-word therefore sealed `$mo` into a chip, closed the popover and threw
// the caret onto the host root: three reported symptoms, one echo.
const wrap = mountEditor("start");
await nextTick();
// Emit, then echo the emitted value back exactly as a controlled parent does.
const host = wrap.find(".wp-rt__host");
const span = (host.element as HTMLElement).querySelector(".wp-rt__text");
if (span) span.textContent = "start $mo";
await host.trigger("input");
await flushPromises();
const ev = wrap.emitted("update:modelValue") ?? [];
const emitted = ev[ev.length - 1]?.[0] as string;
await wrap.setProps({ modelValue: emitted });
await flushPromises();
// Mid-word text stays text; the echo must be a no-op.
expect((host.element as HTMLElement).querySelectorAll(".wp-refchip").length).toBe(0);
wrap.unmount();
});
});
describe("stale node snapshots must not look like errors", () => {
it("stays silent when the producer declares no axes at all", async () => {
// A canvas node carries its own payload SNAPSHOT, so a wildcard added
// before its group was promoted has tag_groups but no tag_group_kinds.
// That is indistinguishable from "declares none", and marking either put a
// squiggle under a reference that resolves fine once the node refreshes.
const wrap = mount(RichTextInput, {
props: {
modelValue: "$shoes.SHOES",
varSuggestions: ["outfit", "shoes"],
varProducers: PRODUCERS, // `shoes` has no axes
graphAware: true,
},
attachTo: document.body,
});
await nextTick();
const html = (wrap.find(".wp-rt__host").element as HTMLElement).innerHTML;
expect(html).not.toContain("wp-refchip__accessor--unknown");
wrap.unmount();
});
it("still warns when the producer declares axes and this is not one", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "$outfit.BELTS",
varSuggestions: ["outfit", "shoes"],
varProducers: PRODUCERS, // `outfit` declares SHOES + EXPOSES
graphAware: true,
},
attachTo: document.body,
});
await nextTick();
const html = (wrap.find(".wp-rt__host").element as HTMLElement).innerHTML;
expect(html).toContain("wp-refchip__accessor--unknown");
wrap.unmount();
});
});
describe("the host swap must not read as the user leaving the field", () => {
/** Committing a suggestion re-mounts the host element (see the `hostEpoch`
* bump in `applyAtoms`). In a real browser that removes the element the
* caret lives in, and the browser fires `blur` on the way out — which
* `onHostBlur` used to treat as a commit: it read the already-detached
* host as "", saw that differ from the atoms it had just applied, and
* re-applied the empty parse. Every `$var` commit erased the whole field.
* jsdom does not fire blur on removal, so this dispatches the blur the
* browser would, in the window before the swap settles. */
it("REPORTED: committing a $var suggestion does not erase the field", async () => {
const wrap = mountEditor("x ");
await nextTick();
const vm = wrap.vm as unknown as {
__triggerAutocompleteForTest: (t: "@" | "$") => void;
__applyAutocompleteForTest: (label: string) => void;
};
const host = wrap.find(".wp-rt__host");
vm.__triggerAutocompleteForTest("$");
vm.__applyAutocompleteForTest("outfit");
// Synchronous, like the blur the element removal fires during the patch —
// BEFORE the nextTick that clears the swap flag.
await host.trigger("blur");
await flushPromises();
// Re-query: the swap replaced the element `host` was captured from.
expect(wrap.find(".wp-rt__host").text()).toContain("$outfit");
wrap.unmount();
});
it("commits an axis reference whole", async () => {
const wrap = mountEditor("x ");
await nextTick();
const vm = wrap.vm as unknown as {
__triggerAutocompleteForTest: (t: "@" | "$") => void;
__applyAutocompleteForTest: (label: string) => void;
};
const host = wrap.find(".wp-rt__host");
vm.__triggerAutocompleteForTest("$");
vm.__applyAutocompleteForTest("outfit.SHOES");
await host.trigger("blur");
await flushPromises();
expect(wrap.find(".wp-rt__host").text()).toContain("$outfit.SHOES");
wrap.unmount();
});
it("a genuine blur still settles pending text into chips", async () => {
// The guard must be narrow: only the swap's own blur is ignored.
const wrap = mountEditor("");
await nextTick();
const host = wrap.find(".wp-rt__host");
const span = (host.element as HTMLElement).querySelector(".wp-rt__text");
if (span) span.textContent = "$outfit";
await host.trigger("input");
await host.trigger("blur");
await flushPromises();
expect((host.element as HTMLElement).querySelectorAll(".wp-refchip").length)
.toBeGreaterThan(0);
wrap.unmount();
});
});
@@ -2011,4 +2011,49 @@ describe("RichTextInput — cut and copy", () => {
expect(ev.defaultPrevented).toBe(false);
w.unmount();
});
// Ctrl+A Ctrl+X on a field that is ALREADY empty selects nothing but the
// ZWSP caret pad. Left to the browser, that cut put the invisible pad on the
// clipboard, overwriting whatever the user had just cut — so the second
// Ctrl+X of the "cut, cut again, paste" repro silently lost their text.
// Measured in Firefox 153 against 2.14.0: the paste landed U+200B.
it.each(["cut", "copy"] as const)(
"a %s over an empty field keeps the clipboard as it was",
async (type) => {
const w = mount(RichTextInput, {
props: { modelValue: "" },
attachTo: document.body,
});
await flushPromises();
const host = w.get('[contenteditable="true"]').element as HTMLElement;
selectAll(host);
const ev = clipEvent(type);
host.dispatchEvent(ev);
await flushPromises();
expect(ev.defaultPrevented).toBe(true);
expect(ev.__data["text/plain"]).toBeUndefined();
expect(w.emitted("update:modelValue")).toBeUndefined();
w.unmount();
},
);
it("paste drops caret-pad ZWSPs instead of writing them into the value", async () => {
// Clipboards already holding a pad (cut by an older build, or copied from
// another field's DOM) must not smuggle an invisible character into the
// prompt text.
const w = mount(RichTextInput, {
props: { modelValue: "" },
attachTo: document.body,
});
await flushPromises();
const host = w.get('[contenteditable="true"]').element as HTMLElement;
selectAll(host);
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { getData: () => "​red​ dress" } });
host.dispatchEvent(paste);
await flushPromises();
const emitted = w.emitted("update:modelValue");
expect(emitted?.[emitted.length - 1]).toEqual(["red dress"]);
w.unmount();
});
});
+12 -1
View File
@@ -1,4 +1,5 @@
import { mount, flushPromises } from "@vue/test-utils";
import { createMemoryHistory, createRouter } from "vue-router";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ModuleRow } from "../api/types";
@@ -105,8 +106,18 @@ afterEach(() => {
vi.clearAllMocks();
});
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/test", name: "test", component: { template: "<div/>" } },
],
});
}
function mountRunner() {
return mount(TestRunner, { global: { plugins: [] } });
return mount(TestRunner, { global: { plugins: [makeRouter()] } });
}
describe("TestRunner.vue", () => {
@@ -66,4 +66,66 @@ describe("useBulkSelection", () => {
const s = useBulkSelection(() => rows.value);
expect(s.allSelected.value).toBe(false);
});
/**
* Select-all under an active filter.
*
* Before the second argument existed, `toggleAll` selected `getIds()` — every
* row, filtered or not. Filtering 40 values down to 3 and clicking the header
* checkbox therefore selected all 40, and the delete that usually follows
* removed 37 rows that were never on screen. These are the tests that stop
* that coming back.
*/
describe("with a filtered view", () => {
const all = ["a", "b", "c", "d"];
it("selects only the visible rows", () => {
const visible = ref(["a", "b"]);
const s = useBulkSelection(() => all, () => visible.value);
s.toggleAll();
expect(s.selectedIds()).toEqual(["a", "b"]);
expect(s.count.value).toBe(2);
});
it("reports allSelected against the visible rows, not every row", () => {
const visible = ref(["a", "b"]);
const s = useBulkSelection(() => all, () => visible.value);
s.toggleAll();
// Every VISIBLE row is selected, so the header checkbox is checked —
// even though c and d are not selected.
expect(s.allSelected.value).toBe(true);
});
it("deselects only the visible rows, leaving a hidden selection intact", () => {
const visible = ref(["a", "b"]);
const s = useBulkSelection(() => all, () => visible.value);
s.toggle("d"); // selected while it was visible earlier
s.toggleAll(); // + a, b
expect(s.selectedIds().sort()).toEqual(["a", "b", "d"]);
s.toggleAll(); // all visible selected → remove just those
expect(s.selectedIds()).toEqual(["d"]);
});
it("keeps a selection made before the filter changed", () => {
const visible = ref(["a", "b"]);
const s = useBulkSelection(() => all, () => visible.value);
s.toggleAll();
// User retypes the filter; c is now the only match.
visible.value = ["c"];
// a and b stay selected and stay deletable — the count is the honest
// total, which is what the bulk toolbar shows.
expect(s.count.value).toBe(2);
expect(s.selectedIds().sort()).toEqual(["a", "b"]);
expect(s.allSelected.value).toBe(false);
});
it("behaves exactly as before when no visible-getter is passed", () => {
const rows = ref(["a", "b", "c"]);
const s = useBulkSelection(() => rows.value);
s.toggleAll();
expect(s.count.value).toBe(3);
s.toggleAll();
expect(s.count.value).toBe(0);
});
});
});
@@ -7,11 +7,14 @@ import {
} from "@/manager/import-export/single-row-publish";
import { parsePayload } from "@/manager/import-export/parse";
import {
CONSTRAINT_ONLY_SCHEMA_VERSION,
CURRENT_SCHEMA_VERSION,
SP2B_SCHEMA_VERSION,
SP3_REACH_SCHEMA_VERSION,
TAG_AXES_SCHEMA_VERSION,
} from "@/manager/import-export/migrations";
import wildcardFixture from "@/validators/fixtures/v1/module-wildcard.json";
import axisWildcardRow from "@/validators/fixtures/engine-parity/wildcard.json";
/** Minimal engine-row constraint module carrying a `target_select`. Carries
* every `moduleRowBase` required field so it also passes strict v2
@@ -281,4 +284,111 @@ describe("publish body stamping", () => {
});
expect(body.schema_version).toBe(SP3_REACH_SCHEMA_VERSION);
});
// --- Tag axes: stamp catalog v5 ONLY when a wildcard marks a tag group
// `accepts`. An absent or all-classify `tag_group_kinds` map is the
// pre-axes behaviour and keeps the lower stamp. ---
function axisRow(kinds?: Record<string, string>): Record<string, unknown> {
const row = JSON.parse(JSON.stringify(axisWildcardRow)) as {
payload: Record<string, unknown>;
};
if (kinds === undefined) delete row.payload.tag_group_kinds;
else row.payload.tag_group_kinds = kinds;
return row as unknown as Record<string, unknown>;
}
it("stamps TAG_AXES (5) for a wildcard with an accepts tag group", () => {
expect(schemaVersionForPayload(axisRow({ TEXTURE: "accepts" }))).toBe(
TAG_AXES_SCHEMA_VERSION,
);
});
it("keeps CURRENT for an all-classify or absent tag_group_kinds map", () => {
expect(schemaVersionForPayload(axisRow({ TEXTURE: "classify" }))).toBe(
CURRENT_SCHEMA_VERSION,
);
expect(schemaVersionForPayload(axisRow())).toBe(CURRENT_SCHEMA_VERSION);
});
it("finds an accepts axis on a wildcard nested in a bundle child", () => {
const bundle = {
id: "bd-002abc",
name: "axis-bundle",
children: [constraintRow(), axisRow({ TEXTURE: "accepts" })],
} as Record<string, unknown>;
expect(schemaVersionForPayload(bundle)).toBe(TAG_AXES_SCHEMA_VERSION);
});
it("stamps TAG_AXES (5) over non-default reach when both are present", () => {
const bundle = {
id: "bd-003abc",
name: "both-bundle",
children: [constraintRow({ mode: "next", count: 2 }), axisRow({ TEXTURE: "accepts" })],
} as Record<string, unknown>;
expect(schemaVersionForPayload(bundle)).toBe(TAG_AXES_SCHEMA_VERSION);
});
it("buildPublishBody stamps TAG_AXES for a published wildcard with an accepts axis", () => {
const body = buildPublishBody({
payload: axisRow({ TEXTURE: "accepts" }),
name: "demo",
description: "",
});
expect(body.schema_version).toBe(TAG_AXES_SCHEMA_VERSION);
});
// --- Linked picks: stamp catalog v6 ONLY when a constraint rule uses the
// `only` mode, wherever it lives (library matrix/exceptions, instance
// extra exceptions, instance mode overrides). ---
function withPayload(extra: Record<string, unknown>): Record<string, unknown> {
const row = constraintRow() as { payload: Record<string, unknown> };
return { ...row, payload: { ...row.payload, ...extra } };
}
it("stamps CONSTRAINT_ONLY (6) for an `only` exception", () => {
const row = withPayload({
exceptions: [{ source_value: "maid", target_value: "apron", mode: "only", factor: 1 }],
});
expect(schemaVersionForPayload(row)).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
});
it("stamps CONSTRAINT_ONLY (6) for an `only` matrix cell", () => {
const row = withPayload({ matrix: { summer: { open: { mode: "only", factor: 1 } } } });
expect(schemaVersionForPayload(row)).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
});
it("finds `only` in instance overrides and extra exceptions", () => {
const withInstance = (instance: Record<string, unknown>) => ({ ...constraintRow(), instance });
expect(schemaVersionForPayload(withInstance({ cell_mode_overrides: { k: "only" } })))
.toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
expect(schemaVersionForPayload(withInstance({ exception_mode_overrides: { k: "only" } })))
.toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
expect(schemaVersionForPayload(withInstance({
extra_exceptions: [{ source: "a", target: "b", mode: "only", factor: 1 }],
}))).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
expect(schemaVersionForPayload(withInstance({ cell_mode_overrides: { k: "exclude" } })))
.toBe(CURRENT_SCHEMA_VERSION);
});
it("keeps the lower stamp when no rule uses `only`", () => {
const row = withPayload({
matrix: { summer: { open: { mode: "boost", factor: 2 } } },
exceptions: [{ source_value: "a", target_value: "b", mode: "exclude", factor: 0 }],
});
expect(schemaVersionForPayload(row)).toBe(CURRENT_SCHEMA_VERSION);
});
it("stamps CONSTRAINT_ONLY (6) over an accepts axis in the same bundle", () => {
const bundle = {
id: "bd-004abc",
name: "linked-bundle",
children: [
withPayload({ exceptions: [{ source_value: "a", target_value: "b", mode: "only", factor: 1 }] }),
axisRow({ TEXTURE: "accepts" }),
],
} as Record<string, unknown>;
expect(schemaVersionForPayload(bundle)).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
});
});
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import {
CURRENT_SCHEMA_VERSION,
MAX_KNOWN_SCHEMA_VERSION,
SP3_REACH_SCHEMA_VERSION,
CONSTRAINT_ONLY_SCHEMA_VERSION,
} from "@/manager/import-export/migrations";
import { MAX_KNOWN_SCHEMA_VERSION as MAX_KNOWN_VIA_INSTALL } from "@/manager/import-export/install";
import { schemaVersionForPayload } from "@/manager/import-export/single-row-publish";
@@ -11,23 +11,23 @@ import { schemaVersionForPayload } from "@/manager/import-export/single-row-publ
* Pins the deliberate fork between the two schema constants (see
* `docs/superpowers/specs/2026-06-14-schema-gate-max-known-version-design.md`):
*
* - CURRENT_SCHEMA_VERSION = 2 — head of the migration chain (v2→v3/v3→v4
* are no-ops, so the chain genuinely stops at 2).
* - MAX_KNOWN_SCHEMA_VERSION = 4 — highest version this runtime can READ +
* - CURRENT_SCHEMA_VERSION = 2 — head of the migration chain (v2→v3
* through v5→v6 are no-ops, so the chain genuinely stops at 2).
* - MAX_KNOWN_SCHEMA_VERSION = 6 — highest version this runtime can READ +
* WRITE; the value advertised to the community publish-gate / boot
* catalog-probe.
*
* The community gate predicate is `hostSchema < minCompatible` where
* `minCompatible = max(breaking versions) = 3`. Advertising CURRENT (2)
* fails the gate (`2 < 3`); advertising MAX_KNOWN (4) clears it.
* fails the gate (`2 < 3`); advertising MAX_KNOWN (6) clears it.
*/
describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
it("CURRENT_SCHEMA_VERSION is the migration-chain head (2)", () => {
expect(CURRENT_SCHEMA_VERSION).toBe(2);
});
it("MAX_KNOWN_SCHEMA_VERSION is the support ceiling (4)", () => {
expect(MAX_KNOWN_SCHEMA_VERSION).toBe(4);
it("MAX_KNOWN_SCHEMA_VERSION is the support ceiling (6)", () => {
expect(MAX_KNOWN_SCHEMA_VERSION).toBe(6);
});
it("the ceiling is strictly above the chain head (the whole point of the fork)", () => {
@@ -40,7 +40,7 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
// whatever install.ts re-exports. Pin that it is MAX_KNOWN, identical to
// the source-of-truth constant.
expect(MAX_KNOWN_VIA_INSTALL).toBe(MAX_KNOWN_SCHEMA_VERSION);
expect(MAX_KNOWN_VIA_INSTALL).toBe(4);
expect(MAX_KNOWN_VIA_INSTALL).toBe(6);
});
it("a plain v2-content payload still stamps at the chain head (unchanged)", () => {
@@ -53,8 +53,8 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
it("drift-guard: MAX_KNOWN covers the highest version schemaVersionForPayload can stamp", () => {
// Build a payload exercising the highest feature the runtime knows how to
// content-stamp today: an sp3 constraint with a non-default `target_select`
// reach selector → stamps SP3_REACH_SCHEMA_VERSION (4).
// content-stamp today: a constraint with an `only` rule → stamps
// CONSTRAINT_ONLY_SCHEMA_VERSION (6).
const highestFeaturePayload = {
id: "cn-001abc",
type: "constraint",
@@ -62,12 +62,11 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
source_wildcard_id: "wc-aaa",
target_wildcard_id: "wc-bbb",
matrix: {},
exceptions: [],
target_select: { mode: "next", count: 2 },
exceptions: [{ source_value: "maid", target_value: "apron", mode: "only", factor: 1 }],
},
};
const stamped = schemaVersionForPayload(highestFeaturePayload);
expect(stamped).toBe(SP3_REACH_SCHEMA_VERSION);
expect(stamped).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
// The mechanical maintenance contract from the spec: if someone teaches
// schemaVersionForPayload a higher stamp without bumping MAX_KNOWN, update
+14 -2
View File
@@ -70,12 +70,24 @@ describe("pickWeightedOption", () => {
expect(aHits / N).toBeLessThan(0.96);
});
it("falls back to first when all weights are zero", () => {
it("picks nothing when all weights are zero", () => {
// Weight 0 means "disabled", so all of them zero means nothing is
// selectable. Returning the first option showed the user a plausible
// pick the engine would never actually produce.
const opts: WildcardOption[] = [
{ id: "a", value: "a", weight: 0, sub_categories: [] },
{ id: "b", value: "b", weight: 0, sub_categories: [] },
];
expect(pickWeightedOption(opts)?.value).toBe("a");
expect(pickWeightedOption(opts)).toBeNull();
});
it("still picks the one live option among disabled ones", () => {
const opts: WildcardOption[] = [
{ id: "a", value: "a", weight: 0, sub_categories: [] },
{ id: "b", value: "b", weight: 2, sub_categories: [] },
{ id: "c", value: "c", weight: 0, sub_categories: [] },
];
expect(pickWeightedOption(opts)?.value).toBe("b");
});
});
+20
View File
@@ -78,3 +78,23 @@ describe("Button variants × states", () => {
});
}
});
describe("Button — icon-only hover tooltip", () => {
it("mirrors aria-label into title when there is no visible text", () => {
const wrap = mount(Button, { props: { icon: "pi-pencil", ariaLabel: "Edit" } });
expect(wrap.get("button").attributes("title")).toBe("Edit");
});
it("does NOT set a title when the button has visible text", () => {
const wrap = mount(Button, {
props: { icon: "pi-pencil", ariaLabel: "Edit" },
slots: { default: "Edit" },
});
expect(wrap.get("button").attributes("title")).toBeUndefined();
});
it("sets no title when there is no aria-label to mirror", () => {
const wrap = mount(Button, { props: { icon: "pi-pencil" } });
expect(wrap.get("button").attributes("title")).toBeUndefined();
});
});
+28
View File
@@ -88,3 +88,31 @@ describe("uiStore — theme cycle", () => {
expect(ui.themeMode).toBe("auto");
});
});
describe("uiStore · subcatDefault", () => {
beforeEach(() => {
localStorage.clear();
setActivePinia(createPinia());
});
it("defaults to 'populated'", () => {
const ui = useUiStore();
expect(ui.subcatDefault).toBe("populated");
});
it("persists a chosen value to localStorage", () => {
const ui = useUiStore();
ui.setSubcatDefault("always");
expect(ui.subcatDefault).toBe("always");
expect(localStorage.getItem("wp-subcat-default")).toBe("always");
});
it("reads a stored value on init, ignoring garbage", () => {
localStorage.setItem("wp-subcat-default", "never");
setActivePinia(createPinia());
expect(useUiStore().subcatDefault).toBe("never");
localStorage.setItem("wp-subcat-default", "bogus");
setActivePinia(createPinia());
expect(useUiStore().subcatDefault).toBe("populated");
});
});
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
// Fresh module per test: the client keeps its startup-id / version cache
// at module scope.
async function loadClient() {
vi.resetModules();
return (await import("../client")).api;
}
function respond(headers: Record<string, string>) {
return new Response(JSON.stringify({ items: [], total: 0 }), {
status: 200,
headers: { "content-type": "application/json", ...headers },
});
}
describe("api client — installed version check", () => {
afterEach(() => { vi.unstubAllGlobals(); });
it("flags the page stale once, with the new version, when the pack was updated", async () => {
const fetchMock = vi.fn().mockImplementation(async () =>
respond({ "X-WP-Startup-Id": "a", "X-WP-Version": "9.9.9" }));
vi.stubGlobal("fetch", fetchMock);
const seen: unknown[] = [];
const onEvent = (e: Event) => seen.push((e as CustomEvent).detail);
window.addEventListener("wp:server-restarted", onEvent);
const api = await loadClient();
await api.categories.list();
await api.categories.list();
window.removeEventListener("wp:server-restarted", onEvent);
expect(seen).toEqual([{ version: "9.9.9" }]);
});
it("stays quiet when the installed version matches this build", async () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () =>
respond({ "X-WP-Startup-Id": "a", "X-WP-Version": __APP_VERSION__ })));
const onEvent = vi.fn();
window.addEventListener("wp:server-restarted", onEvent);
const api = await loadClient();
await api.categories.list();
window.removeEventListener("wp:server-restarted", onEvent);
expect(onEvent).not.toHaveBeenCalled();
});
});
+72 -1
View File
@@ -3,6 +3,8 @@ import type {
BundleCreateInput, BundleListResponse, BundleRow, BundleUpdateInput,
CategoryCreateInput, CategoryRow,
DatabaseConfig, DatabaseConfigUpdate,
TagStatus, TagSuggestResponse, TagDownloadResult,
ModelKind, ModelSourceStatus, ModelSuggestResponse,
DatabaseInfo, MaintenanceOp, MaintenanceResult,
EmbedBundle,
MatchRequest, MatchResponse,
@@ -30,7 +32,17 @@ export class ApiError extends Error {
*/
let cachedStartupId: string | null = null;
let versionMismatchSent = false;
function checkStartupId(resp: Response): void {
// `X-WP-Version` is the pack version installed on disk. When it differs
// from the version this page was built as, the pack was updated under us:
// flag the page stale even if we never saw the restart itself.
const version = resp.headers.get("X-WP-Version") ?? "";
if (version && version !== __APP_VERSION__ && !versionMismatchSent) {
versionMismatchSent = true;
window.dispatchEvent(new CustomEvent("wp:server-restarted", { detail: { version } }));
}
const sid = resp.headers.get("X-WP-Startup-Id");
if (!sid) return;
if (cachedStartupId === null) {
@@ -39,7 +51,7 @@ function checkStartupId(resp: Response): void {
}
if (sid !== cachedStartupId) {
cachedStartupId = sid;
window.dispatchEvent(new CustomEvent("wp:server-restarted"));
window.dispatchEvent(new CustomEvent("wp:server-restarted", { detail: { version } }));
}
}
@@ -229,6 +241,65 @@ export const api = {
return request<void>(`/wp/api/categories/${id}`, { method: "DELETE" });
},
},
models: {
/** How many LoRAs and embeddings ComfyUI knows about. Drives the settings
* note — a source with zero files is worth saying so before the user
* wonders why nothing appears. */
status() {
return request<ModelSourceStatus>("/wp/api/models/status", { method: "GET" });
},
/** Prefix search across the requested kinds only, so a source the user
* switched off costs nothing on the wire. */
/** `browseAll` asks for the first N of each kind when `q` is empty. Only
* set inside a `<lora:`/`embedding:` reference, where the marker itself
* is the request — for a bare word an empty query must stay empty. */
suggest(q: string, kinds: ModelKind[], limit = 10, browseAll = false) {
const query = `?q=${encodeURIComponent(q)}&kinds=${kinds.join(",")}&limit=${limit}`
+ (browseAll ? "&all=1" : "");
return request<ModelSuggestResponse>(`/wp/api/models/suggest${query}`, {
method: "GET",
});
},
/** Re-read both folders. ComfyUI caches its own file lists, so a model
* added while the server runs is invisible until something asks again. */
refresh() {
return request<ModelSourceStatus>("/wp/api/models/refresh", { method: "POST" });
},
},
tags: {
/** Whether a tag list is installed, and what it contains. Drives Settings:
* the toggle is meaningless without a file, so "off" and "impossible"
* have to be distinguishable. */
status() {
return request<TagStatus>("/wp/api/tags/status", { method: "GET" });
},
/** Prefix search. The list itself is never sent to the browser — it is
* several megabytes — so matching happens server-side and this returns
* one screenful. */
suggest(q: string, limit = 20) {
const query = `?q=${encodeURIComponent(q)}&limit=${limit}`;
return request<TagSuggestResponse>(`/wp/api/tags/suggest${query}`, {
method: "GET",
});
},
/** Fetch the list from our GitHub release. Deliberately takes no
* arguments: the URL and the destination are both fixed server-side so
* there is nothing a caller can steer. See wp_api/_tag_download.py. */
download() {
return request<TagDownloadResult>("/wp/api/tags/download", {
method: "POST",
});
},
/** Delete the installed list. Takes no arguments for the same reason
* `download` does not: the path is fixed server-side. */
remove() {
return request<{ removed: boolean; path: string }>("/wp/api/tags", {
method: "DELETE",
});
},
},
database: {
info() {
return request<DatabaseInfo>("/wp/api/database/info", { method: "GET" });
+82 -1
View File
@@ -45,6 +45,16 @@ export interface WildcardPayload {
* default "ungrouped/other" box. See design §2.2 / §4.3.
*/
tag_groups?: Record<string, string[]>;
/**
* Per-group meaning. Absent (and `classify`) is today's behaviour: tags
* describe what an option IS and fold with AND in the constraint matrix.
* `accepts` marks the group's tags as alternatives the option offers — an
* OR-set the fold reads with max, and which `$var.AXIS` rolls one winner
* from. Only `accepts` entries are ever stored, so an untouched payload
* stays byte-identical. An `accepts` group name must be a valid identifier
* so `$var.AXIS` parses.
*/
tag_group_kinds?: Record<string, "accepts">;
/**
* The `$varname` other modules use to read this wildcard's resolved value.
* Optional — defaults to `slug(name)` when missing/blank. User-editable so
@@ -115,7 +125,9 @@ export interface DerivationPayload {
rules: DerivationRule[];
}
export type ConstraintMode = "allow" | "exclude" | "boost" | "reduce";
/** `only` (linked picks) turns its row into an allow-list: when the source
* fires, every target without a rule of its own is excluded. */
export type ConstraintMode = "allow" | "exclude" | "boost" | "reduce" | "only";
export interface ConstraintCell {
mode: ConstraintMode;
@@ -559,3 +571,72 @@ export interface DatabaseConfigUpdate {
/** Omit to leave unchanged; pass `null` to explicitly clear. */
pending_move?: PendingMove | null;
}
/* ── Tag autocomplete ─────────────────────────────────────────────────── */
/** Danbooru's numeric tag categories. `null` when the installed file is the
* two-column `name,count` shape, which carries no category at all. */
export type TagCategoryName =
| "general" | "artist" | "copyright" | "character" | "meta";
export interface TagSuggestion {
/** The tag that gets INSERTED — always the canonical one, even when the
* query matched an alias. */
name: string;
/** What the query actually matched. Differs from `name` only for an alias,
* which is how the row can explain why it appeared. */
matched: string;
count: number;
category: number | null;
category_name: TagCategoryName | null;
}
export interface TagSuggestResponse {
available?: boolean;
tags: TagSuggestion[];
}
/** One installed LoRA or embedding, as an autocomplete row. */
export interface ModelSuggestion {
/** Shown on screen — filename without folder or extension. */
name: string;
/** What gets INSERTED. The full relative path as ComfyUI knows it: two
* folders can hold the same filename and ComfyUI resolves by path, so
* inserting the display name would silently pick the wrong file. */
path: string;
/** Folder prefix, or "" at the root. The row's subtitle, so two same-named
* files in different folders are tellable apart. */
folder: string;
}
export type ModelKind = "lora" | "embedding";
export interface ModelSuggestResponse {
/** Keyed by kind. A kind the caller did not ask for is simply absent. */
results: Partial<Record<ModelKind, ModelSuggestion[]>>;
}
export interface ModelSourceStatus {
sources: Array<{ kind: ModelKind; count: number }>;
}
export interface TagStatus {
/** A file is installed AND parsed. The user setting is separate — the two
* together are what decide whether suggestions appear. */
available: boolean;
/** Where the file is looked for, even when nothing is there yet. */
path: string | null;
tag_count: number;
/** False for a two-column file. The UI drops the colour column entirely
* rather than rendering it grey and meaningless. */
has_categories: boolean;
}
export interface TagDownloadResult {
path: string;
bytes: number;
tag_count: number;
has_categories: boolean;
/** Echoed back so the UI can name exactly where the file came from. */
source: string;
}
@@ -34,6 +34,15 @@ describe("community-posts", () => {
}));
expect(dl.payload).toEqual({ id: "aabb0001", type: "wildcard", name: "Subject", payload: {} });
expect(dl.version_number).toBe(1);
// No stamp in the response → the community's pre-versioning default (v1).
expect(dl.schema_version).toBe(1);
});
it("downloadCommunityVersion carries the version's schema_version", async () => {
const dl = await downloadCommunityVersion("author/axes", jsonFetch({
data: { payload_json: { id: "aabb0002" }, version_number: 3, schema_version: 5 },
}));
expect(dl.schema_version).toBe(5);
});
it("throws on a non-ok response", async () => {
+10 -1
View File
@@ -22,8 +22,14 @@ export interface CommunityPostDetail {
export interface CommunityDownload {
payload: Record<string, unknown>;
version_number: number;
/** The shape version the community stamped at publish. */
schema_version: number;
}
/** The server stamps every version; a row that somehow lacks one predates
* schema versioning, which the community's own grace default treats as v1. */
export const UNSTAMPED_SCHEMA_VERSION = 1;
/** An `Error` that also carries the HTTP status that produced it, so callers
* (e.g. `communityPostExists`) can distinguish a definite not-found (404/410)
* from a transient failure (5xx) without re-parsing the message string. */
@@ -92,10 +98,13 @@ export async function downloadCommunityVersion(
): Promise<CommunityDownload> {
const r = await fetchFn(`${WPC_API_URL}/api/v1/posts/${slug}/download`);
if (!r.ok) throw new Error(`community download ${slug}: HTTP ${r.status}`);
const j = (await r.json()) as { data?: { payload_json?: Record<string, unknown>; version_number?: number } };
const j = (await r.json()) as {
data?: { payload_json?: Record<string, unknown>; version_number?: number; schema_version?: number };
};
const d = j.data ?? {};
return {
payload: (d.payload_json ?? {}) as Record<string, unknown>,
version_number: typeof d.version_number === "number" ? d.version_number : 0,
schema_version: typeof d.schema_version === "number" ? d.schema_version : UNSTAMPED_SCHEMA_VERSION,
};
}
@@ -9,6 +9,7 @@ describe("downloadDepsForDangling", () => {
const download = vi.fn(async () => ({
payload: { id: "aabb0001", type: "wildcard", name: "Subject", payload: { options: [] } },
version_number: 1,
schema_version: 3,
}));
const install = vi.fn(async () => ({ ok: true }));
@@ -22,8 +23,10 @@ describe("downloadDepsForDangling", () => {
expect(out.pulled).toEqual(["author/subject"]);
expect(install).toHaveBeenCalledTimes(1);
// Origin-stamped so the installed dep gets the community badge + update tracking.
// Stamped with the download's own schema_version so install migrates it
// from where it actually is (was a hard-coded 4).
expect(install).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ schema_version: 3, wildcards: [expect.objectContaining({ id: "aabb0001" })] }),
expect.anything(),
{ post_slug: "author/subject", version_number: 1 },
);
@@ -46,7 +49,7 @@ describe("downloadDepsForDangling", () => {
danglingUuid: "aabb0001",
constraintDeps: [{ slug: "author/subject", module_id: "aabb0001" }],
fetchDetail: vi.fn(async (slug: string) => ({ slug, latest_version_number: 1, dependencies: [] })),
download: vi.fn(async () => ({ payload: { id: "aabb0001", type: "wildcard", name: "S", payload: {} }, version_number: 1 })),
download: vi.fn(async () => ({ payload: { id: "aabb0001", type: "wildcard", name: "S", payload: {} }, version_number: 1, schema_version: 2 })),
install: vi.fn(async () => ({ ok: false, error: { message: "boom" } })),
});
expect(out.ok).toBe(false);
@@ -46,8 +46,10 @@ export interface DownloadDepsResult {
}
/** Group ONE engine-row module payload into the install envelope shape
* (mirror of Feature 4's buildExtractEnvelope / install.test.ts RawPayload). */
function envelopeFor(payload: Record<string, unknown>): unknown {
* (mirror of Feature 4's buildExtractEnvelope / install.test.ts RawPayload),
* stamped with the downloaded version's own `schema_version` so install
* migrates it from where it actually is. */
function envelopeFor(payload: Record<string, unknown>, schemaVersion: number): unknown {
const type = typeof payload.type === "string" ? payload.type : "";
const bucket: Record<string, string> = {
wildcard: "wildcards", fixed_values: "fixed_values", combine: "combines",
@@ -60,7 +62,7 @@ function envelopeFor(payload: Record<string, unknown>): unknown {
const key = bucket[type];
if (key) buckets[key].push(payload);
else if (typeof payload.children !== "undefined") buckets.bundles.push(payload);
return { schema_version: 4, ...buckets };
return { schema_version: schemaVersion, ...buckets };
}
export async function downloadDepsForDangling(
@@ -92,7 +94,7 @@ export async function downloadDepsForDangling(
const deps: InstallDependencyEdge[] = detail.dependencies
.filter((e) => typeof e.module_id === "string" && e.module_id)
.map((e) => ({ module_id: e.module_id, slug: e.slug }));
const res = await args.install(envelopeFor(dl.payload), deps, {
const res = await args.install(envelopeFor(dl.payload, dl.schema_version), deps, {
post_slug: slug,
version_number: dl.version_number,
});
@@ -38,6 +38,7 @@ import { appendSnapshot, stripHistory } from "../utils/history";
import { installEnvelope } from "../import-export/install";
import { api } from "../api/client";
import { wrapAsEngineExport } from "./engine-export-wrap";
import { UNSTAMPED_SCHEMA_VERSION } from "../community/community-posts";
interface UpdateEntry {
entity_id: string;
@@ -77,13 +78,23 @@ const oldName = computed(() => {
* downloads are open for everyone (anon downloads still increment the
* post counter, which is the desired behaviour anyway).
*/
async function fetchLatestPayload(slug: string): Promise<{ payload: Record<string, unknown>; version: number }> {
async function fetchLatestPayload(
slug: string,
): Promise<{ payload: Record<string, unknown>; version: number; schemaVersion: number }> {
const resp = await fetch(`${WPC_API_URL}/api/v1/posts/${slug}/download`);
if (!resp.ok) {
throw new Error(`Failed to fetch latest version (HTTP ${resp.status})`);
}
const body = await resp.json() as { data: { payload_json: Record<string, unknown>; version_number: number } };
return { payload: body.data.payload_json, version: body.data.version_number };
const body = await resp.json() as {
data: { payload_json: Record<string, unknown>; version_number: number; schema_version?: number };
};
return {
payload: body.data.payload_json,
version: body.data.version_number,
schemaVersion: typeof body.data.schema_version === "number"
? body.data.schema_version
: UNSTAMPED_SCHEMA_VERSION,
};
}
/**
@@ -163,6 +174,7 @@ async function onUpdateInPlace() {
kind: props.entry.entity_kind,
subtype,
payload: entityPayload,
schema_version: fetched.schemaVersion,
});
const localEntityId = props.entry.entity_id;
const result = await installEnvelope(
@@ -237,6 +249,7 @@ async function onInstallAsNew() {
kind: kindGuess,
subtype: subtype as string | null,
payload: fetched.payload,
schema_version: fetched.schemaVersion,
});
const result = await installEnvelope(
{ envelope },
+97 -10
View File
@@ -31,7 +31,7 @@ import {
toGroups,
} from "../../components/shared/matrix-axis";
type RuleState = "neutral" | "exclude" | "boost" | "reduce";
type RuleState = "neutral" | "exclude" | "boost" | "reduce" | "only";
interface Props {
rows: string[]; // source sub-categories
@@ -46,6 +46,12 @@ interface Props {
* tinted headers as before. */
sourceGroups?: Record<string, string[]>;
targetGroups?: Record<string, string[]>;
/** axis name → kind for each wildcard. An `"accepts"` axis rolls one tag per
* pick and is readable as `$var.AXIS`; the matrix marks those bands so a
* reader can tell an axis from an ordinary tag group (which the editor shows
* via its accepts button, but the matrix otherwise cannot). */
sourceGroupKinds?: Record<string, string>;
targetGroupKinds?: Record<string, string>;
// Read-only recovery view: the source/target wildcard was deleted, so the
// configured rules are shown for understanding only. Cells don't open the
// rule popover; reattach a live wildcard to edit.
@@ -56,6 +62,8 @@ const props = withDefaults(defineProps<Props>(), {
targetName: "",
sourceGroups: () => ({}),
targetGroups: () => ({}),
sourceGroupKinds: () => ({}),
targetGroupKinds: () => ({}),
readonly: false,
});
const emit = defineEmits<{ "update:modelValue": [value: ConstraintMatrix] }>();
@@ -65,18 +73,21 @@ const MODE_DEFAULT_FACTOR: Record<ConstraintMode, number> = {
exclude: 0,
boost: 1.5,
reduce: 0.5,
only: 1,
};
const MODE_ICON: Record<ConstraintMode, string> = {
allow: "·",
exclude: "×",
boost: "↑",
reduce: "↓",
only: "✓",
};
const MODE_LABEL: Record<ConstraintMode, string> = {
allow: "Neutral",
exclude: "Exclude",
boost: "Boost",
reduce: "Reduce",
only: "Only",
};
/** Storage uses "allow"; popover speaks "neutral". Translate at boundary. */
@@ -100,6 +111,19 @@ function cellAt(row: string, col: string): ConstraintCell {
return { mode, factor };
}
/** Rows holding an `only` cell. Their neutral cells are shut out at runtime,
* so the grid marks them instead of letting them read as "no rule". */
const onlyRows = computed(() => {
const out = new Set<string>();
for (const [row, byCol] of Object.entries(props.modelValue ?? {})) {
if (Object.values(byCol ?? {}).some((c) => c?.mode === "only")) out.add(row);
}
return out;
});
function isImpliedOut(row: string, col: string): boolean {
return onlyRows.value.has(row) && cellAt(row, col).mode === "allow";
}
function cloneMatrix(src: ConstraintMatrix): ConstraintMatrix {
const out: ConstraintMatrix = {};
for (const [r, byCol] of Object.entries(src ?? {})) {
@@ -139,6 +163,26 @@ const orderedRows = computed(() => orderByGroups(props.rows, props.sourceGroups)
const orderedCols = computed(() => orderByGroups(props.cols, props.targetGroups));
const rowGroups = computed(() => toGroups(orderedRows.value));
const colGroups = computed(() => toGroups(orderedCols.value));
/** Axis names marked `accepts` on each side. */
const sourceAcceptsAxes = computed(() => acceptsSet(props.sourceGroupKinds));
const targetAcceptsAxes = computed(() => acceptsSet(props.targetGroupKinds));
function acceptsSet(kinds: Record<string, string>): Set<string> {
return new Set(Object.entries(kinds).filter(([, k]) => k === "accepts").map(([a]) => a));
}
/** Whether a band's axis is an accepts axis on the given side. */
function isAcceptsBand(grp: { axisName: string }, which: "source" | "target"): boolean {
const set = which === "source" ? sourceAcceptsAxes.value : targetAcceptsAxes.value;
return set.has(grp.axisName);
}
/** Hover text for the accepts mark. */
function acceptsTip(grp: { axisName: string }): string {
return `Accepts axis — rolls one tag per pick, readable as $var.${grp.axisName}`;
}
/** Any accepts axis at all, so the legend can hide its row otherwise. */
const hasAcceptsAxis = computed(
() => sourceAcceptsAxes.value.size > 0 || targetAcceptsAxes.value.size > 0,
);
/** True when at least one column / row axis is a NAMED group — then the header
* grows a band/chip and the leftover bucket is worth labelling. A fully-flat
* matrix keeps the legacy source/target tint and grows no band/chip. */
@@ -159,7 +203,8 @@ watch(colGroups, () => void nextTick(measureBands));
function cellAriaLabel(row: string, col: string): string {
const c = cellAt(row, col);
const factor = (c.mode === "boost" || c.mode === "reduce") ? ` ×${fmtFactor(c.factor)}` : "";
return `Rule: ${row} → ${col}, current state ${MODE_LABEL[c.mode]}${factor}. Click to edit.`;
const implied = isImpliedOut(row, col) ? " (excluded by an Only rule in this row)" : "";
return `Rule: ${row} → ${col}, current state ${MODE_LABEL[c.mode]}${factor}${implied}. Click to edit.`;
}
// ── Popover state ───────────────────────────────────────────────
@@ -318,9 +363,14 @@ defineExpose({ cellAt });
:class="{ 'wp-mx-th-band--bucket': isBucket(grp) }"
:colspan="grp.tags.length"
:style="{ '--ax': tagHue(grp, 'target', true) }"
:title="groupLabel(grp)"
:title="isAcceptsBand(grp, 'target') ? acceptsTip(grp) : groupLabel(grp)"
>
<span class="chip">{{ groupLabel(grp) }}</span>
<span class="chip">{{ groupLabel(grp)
}}<i
v-if="isAcceptsBand(grp, 'target')"
class="pi pi-sync wp-mx-axis-mark"
aria-hidden="true"
/></span>
</th>
</template>
<!-- Flat cols: tag headers sit directly in the single header row. -->
@@ -359,8 +409,13 @@ defineExpose({ cellAt });
:class="{ 'wp-mx-grp-head--bucket': isBucket(grp) }"
:colspan="orderedCols.length + 1"
:style="{ '--ax': tagHue(grp, 'source', true) }"
:title="groupLabel(grp)"
><span class="chip">{{ groupLabel(grp) }}</span></th>
:title="isAcceptsBand(grp, 'source') ? acceptsTip(grp) : groupLabel(grp)"
><span class="chip">{{ groupLabel(grp)
}}<i
v-if="isAcceptsBand(grp, 'source')"
class="pi pi-sync wp-mx-axis-mark"
aria-hidden="true"
/></span></th>
</tr>
<tr v-for="tag in grp.tags" :key="tag">
<th
@@ -372,7 +427,13 @@ defineExpose({ cellAt });
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
>
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
<span class="eye">{{ grp.axisName }}</span>
<span class="eye">{{ grp.axisName
}}<i
v-if="isAcceptsBand(grp, 'source')"
class="pi pi-sync wp-mx-axis-mark"
:title="acceptsTip(grp)"
aria-hidden="true"
/></span>
<span class="v">{{ tag }}</span>
</span>
<span v-else class="chip">{{ tag }}</span>
@@ -382,7 +443,7 @@ defineExpose({ cellAt });
class="wp-mx-cell"
:class="[
`s-${toState(cellAt(tag, c.tag).mode)}`,
{ open: isOpenAt(tag, c.tag) },
{ open: isOpenAt(tag, c.tag), 'implied-out': isImpliedOut(tag, c.tag) },
]"
:data-mode="cellAt(tag, c.tag).mode"
:data-test="`cell-${tag}-${c.tag}`"
@@ -393,7 +454,7 @@ defineExpose({ cellAt });
@keydown.enter.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
@keydown.space.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
>
<span class="glyph">{{ MODE_ICON[cellAt(tag, c.tag).mode] }}</span>
<span class="glyph">{{ isImpliedOut(tag, c.tag) ? "×" : MODE_ICON[cellAt(tag, c.tag).mode] }}</span>
<span
v-if="cellAt(tag, c.tag).mode === 'boost' || cellAt(tag, c.tag).mode === 'reduce'"
class="factor"
@@ -419,7 +480,7 @@ defineExpose({ cellAt });
Reattach the source to edit these rules.
</p>
<MatrixLegend />
<MatrixLegend :show-accepts="hasAcceptsAxis" />
<!-- Popover teleports to body so its hover state stays isolated
from the source cell + any table-level clipping. -->
@@ -567,6 +628,16 @@ defineExpose({ cellAt });
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-1));
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
}
/* Accepts-axis mark — a small amber pi-sync after the band/eyebrow label. The
band keeps its own group hue (so it never collides with an amber group); the
mark is a distinct glyph. Meaning lives in the tooltip + the legend row. */
.wp-mx-axis-mark {
color: var(--wp-axis, #fbbf24);
font-size: 9px;
margin-left: 5px;
vertical-align: baseline;
}
/* The uncategorized bucket reads as a catch-all, not a real axis: same neutral
* tint (via --ax) but a dashed edge instead of the solid axis accent. */
.wp-mx-th-band--bucket .chip { border-bottom-style: dashed; }
@@ -715,6 +786,17 @@ defineExpose({ cellAt });
color: var(--wp-warn);
border: 1px solid color-mix(in srgb, var(--wp-warn, #f97316) 45%, transparent);
}
.wp-mx-cell.s-only {
background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent);
color: var(--wp-info, #3b82f6);
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 45%, transparent);
}
/* A neutral cell in a row that has an Only rule: shut out at runtime. Faint
* danger hue + dashed border so it reads as "implied", not authored. */
.wp-mx-cell.s-neutral.implied-out {
color: color-mix(in srgb, var(--wp-danger, #ef4444) 60%, var(--wp-text-dim, #595c66));
border-color: color-mix(in srgb, var(--wp-danger, #ef4444) 30%, transparent);
}
.glyph { font-size: 14px; line-height: 1; }
.factor { font-size: 11px; font-weight: 700; }
@@ -748,6 +830,11 @@ defineExpose({ cellAt });
border: 1px solid color-mix(in srgb, var(--wp-danger, #ef4444) 18%, transparent);
color: color-mix(in srgb, var(--wp-danger, #ef4444) 70%, var(--wp-text-dim));
}
.wp-mx--readonly .wp-mx-cell.s-only {
background: color-mix(in srgb, var(--wp-info, #3b82f6) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 18%, transparent);
color: color-mix(in srgb, var(--wp-info, #3b82f6) 70%, var(--wp-text-dim));
}
.wp-mx-readonly-hint {
margin: 8px 0 0;
font-size: var(--wp-text-xs);
+18 -1
View File
@@ -17,7 +17,7 @@
* `wp-page__*`, `wp-breadcrumb`, `wp-card`, `wp-footer-bar` tokens that
* already live in `tokens.css`.
*/
import { computed, ref } from "vue";
import { computed, ref, watch, nextTick } from "vue";
import { RouterLink } from "vue-router";
import Button from "./ui/Button.vue";
import HistoryPanel from "./HistoryPanel.vue";
@@ -118,6 +118,23 @@ function scrollToField(id: string): void {
: el.querySelector<HTMLInputElement>("input, textarea, select");
input?.focus();
}
/** A failed Save is the moment the editor flips `errors` from empty to
* populated (they gate the rollup behind a "tried to save" flag). The Save
* button and the error banner are at opposite ends of a long editor, so
* clicking Save on a wildcard with 30 options did nothing the user could see —
* they clicked it five times before finding the banner. Jump to the first
* offending field on that transition, so the failure is where the eyes are.
* Length-guarded to the empty→non-empty edge so re-validation on each
* keystroke while fixing it doesn't yank the scroll around. */
watch(
() => props.errors.length,
(now, before) => {
if (now > 0 && (before ?? 0) === 0) {
void nextTick(() => scrollToField(props.errors[0].field));
}
},
);
</script>
<template>
@@ -0,0 +1,63 @@
/**
* A var chip's accessor is TWO different things: a pick index (`.0`) and a
* tag axis (`.SHOES`). They must not share styling — the index is positional
* and, being a runtime-only concept, is nothing the editor can validate, so
* tinting it the axis accent read to users as "your index is flagged".
*/
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import RefChip from "./RefChip.vue";
const AXES = [{ axis: "SHOES", tags: ["sneakers"], hueIndex: 0 }];
describe("RefChip — accessor index vs axis styling", () => {
it("renders the pick index in its own NEUTRAL span, not the axis accent", () => {
const w = mount(RefChip, {
props: { kind: "var", name: "outfit", resolved: true, index: 0 },
});
expect(w.find(".wp-refchip__index").exists()).toBe(true);
expect(w.find(".wp-refchip__index").text()).toBe(".0");
// No axis part → no amber accessor span at all.
expect(w.find(".wp-refchip__accessor").exists()).toBe(false);
});
it("renders index and axis as SEPARATE spans for `$outfit.0.SHOES`", () => {
const w = mount(RefChip, {
props: {
kind: "var", name: "outfit", resolved: true,
index: 0, axis: "SHOES", axisKnown: true,
},
});
expect(w.find(".wp-refchip__index").text()).toBe(".0");
expect(w.find(".wp-refchip__accessor").text()).toBe(".SHOES");
// The index is NOT inside the amber accessor span.
expect(w.find(".wp-refchip__accessor").text()).not.toContain(".0");
});
it("marks only the axis unknown — never the index", () => {
const w = mount(RefChip, {
props: {
kind: "var", name: "outfit", resolved: true,
index: 0, axis: "BELTS", axisKnown: false,
},
});
expect(w.find(".wp-refchip__accessor--unknown").exists()).toBe(true);
// A bare index with no axis is never unknown-styled.
const w2 = mount(RefChip, {
props: { kind: "var", name: "outfit", resolved: true, index: 0 },
});
expect(w2.find(".wp-refchip__accessor--unknown").exists()).toBe(false);
});
it("still shows the axis alone (no index) in the amber span", () => {
const w = mount(RefChip, {
props: {
kind: "var", name: "outfit", resolved: true, axis: "SHOES", axisKnown: true,
},
});
expect(w.find(".wp-refchip__index").exists()).toBe(false);
expect(w.find(".wp-refchip__accessor").text()).toBe(".SHOES");
});
void AXES;
});

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