Compare commits

...
42 Commits
Author SHA1 Message Date
semantic-release-bot 31b011b8fb chore(release): 2.10.2 [skip ci] 2026-07-21 22:16:47 +00:00
Dumitru Picalau a2e93fe7e4 Merge pull request #10 from DumiFlex/fix/updater-downgrade
fix: updater downgrade + pill reactivity + readable release notes
2026-07-22 00:16:09 +02:00
DumiFlex 1e42f9b5aa fix(manager): prevent updater downgrade, fix pill reactivity + release notes
Four fixes to the in-app updater shipped in 2.10.0:

- DOWNGRADE (critical): Update Now sent ComfyUI Manager selected_version:'latest' + mode:'cache', so 'latest' resolved against Manager's STALE cached catalog and could install an OLDER version over a newer one. Now pins the exact target version the check found (e.g. 2.10.1), uses mode:'remote' for a fresh catalog, and hard-guards against installing anything not strictly newer than what's running.
- Check Now now lights the topbar pill immediately. useReleaseCheck kept per-consumer refs, so a manual check in Settings never reached the topbar until reload; the reactive state is now a module-level singleton shared by every consumer.
- Release notes render properly: the renderer unwraps <details>/<summary>/<b> into markdown instead of showing literal tags (raw HTML still escaped).
- Changelog is human-readable: .releaserc.js now prepends a curated, plain-language highlights section (docs/release-notes/next.md) above the collapsed per-commit list, cleared automatically on each release.
2026-07-22 01:11:29 +03:00
semantic-release-bot 67199b5dba chore(release): 2.10.1 [skip ci] 2026-07-21 18:38:03 +00:00
Dumitru Picalau 74c7331b7e Merge pull request #9 from DumiFlex/fix/subcat-rename-validation
fix: validate sub-category & variable names on rename (#7)
2026-07-21 20:37:20 +02:00
DumiFlex 00b735d32f fix(manager): validate names on rename via shared validators (#7)
Sub-category rename hand-rolled a weaker check than the add path, so a name with a space (or other invalid char) slipped through the cascade rename dialog until save. Combine output-var renames weren't validated at all. Root cause: each name-entry surface reached for its own validator/regex and they drifted.

Add a single canonical validator module (src/manager/validation/names.ts) exposing validateSubcatName (re-exported from the engine-parity subcatFilter), validateRefGrammarName, and validateVariableName/isValidVariableName. Route every name-entry site through it: subcategory add+rename, wildcard/module display-name ref-grammar check (was triplicated inline), combine output_var save+rename, fixed-value var names, var binding, and validateModule. Add + rename now share the exact same function and cannot diverge again.
2026-07-21 20:56:48 +03:00
semantic-release-bot a1fe6956d1 chore(release): 2.10.0 [skip ci] 2026-07-21 17:31:59 +00:00
Dumitru Picalau fe2423f5b4 Merge pull request #8 from DumiFlex/feat/manager-qol
Manager QOL backlog + loop bypass frames + in-app update panel
2026-07-21 19:31:16 +02:00
DumiFlex 6c20f53cc6 feat(manager): add Updates settings card with check-on-launch + check now
Harden applyLatest against malformed/legacy cache blobs and a missing __APP_VERSION__ so a bad cache degrades to no-update instead of throwing; mock useReleaseCheck in the pre-existing Settings suite.
2026-07-21 20:19:31 +03:00
DumiFlex 936a13913e feat(manager): open UpdateDialog from the topbar update pill 2026-07-21 20:03:49 +03:00
DumiFlex 290e7a81f5 feat(manager): add UpdateDialog with changelog + Manager-driven update 2026-07-21 19:57:37 +03:00
DumiFlex 327e7cc762 feat(manager): add ComfyUI Manager update composable 2026-07-21 17:21:59 +03:00
DumiFlex 830776efd1 feat(manager): add safe markdown renderer for release notes 2026-07-21 17:17:05 +03:00
DumiFlex bdcf85ccd4 feat(manager): rework release check to launch-once + manual, expose release notes 2026-07-21 17:12:23 +03:00
DumiFlex d42b7ca1d8 feat(manager): add checkOnLaunch update preference to uiStore 2026-07-21 17:05:52 +03:00
DumiFlex fcde3b31d6 feat(manager): per-rule branch collapse + collapse rules/branches by default
- Each rule card gets inline collapse-all / expand-all buttons for its own
  conditions (IF/ELIF/ELSE branches), shown while the card is open.
- Branches now start COLLAPSED on open (newly-added branches still open
  expanded for immediate editing), and rules are collapsed by default
  regardless of count — so opening a derivation shows a compact overview.
2026-07-21 15:29:38 +03:00
DumiFlex ea4ed059c3 feat(loop): purple wash + dashed bar on bypassed seed rows 2026-07-21 15:13:52 +03:00
DumiFlex f670c3db76 feat(loop): mirror the loop's bypassed frames in the seed list UI
The WP_SeedList already emits only kept frames; now its widget shows WHICH.
The host glue reads the wired loop's bypass_frames (when Override count from
loop is on) and passes them to the widget: a "N bypassed" badge on the seeds
button + read-only dimmed/struck rows in the per-iteration modal (no toggle —
bypass is owned by the loop). Adds a `bypassReadonly` mode to the shared
SeedListModal so the same modal is interactive for the loop, read-only here.
2026-07-21 15:07:21 +03:00
DumiFlex af95f08ef4 fix(loop): seed list mirrors the loop's bypassed frames
A WP_SeedList wired to a bypassing loop still emitted the full `count`
seeds, so the sampler ran 18 times against the loop's 3 kept prompts.
WP_ContextLoop now publishes `kept_indices` (the 0-based frames it actually
emits) in its loop_config side output; WP_SeedList, when taking its count
from the loop (override_count), emits one seed per kept frame — drawn from
the full series so per-frame locks still land. Backward compatible: a
loop_config without kept_indices (or a full/empty list) leaves the emit
unchanged.
2026-07-21 14:49:26 +03:00
DumiFlex 70283ce4d3 feat(loop): dashed border on bypassed edit-frame chips 2026-07-21 14:40:32 +03:00
DumiFlex bab87cd3c2 fix(loop): parse parity + edge coverage from final review
Addresses the minor findings from the whole-branch review:
- TS bypass_frames parse now type-guards before accepting (no Number()
  coercion), so it drops true/"3"/null identically to the Python parser
  (the spec requires the two to agree on corrupt input).
- Add execute tests for bypassing frame 0 and for an out-of-range index
  re-applying once count grows past it (both spec-listed behaviors).
- Document seedAt's i>=1 precondition; pass :previous to inactive bypass
  rows so their "lock previous" button matches the active rows.
2026-07-21 03:06:20 +03:00
DumiFlex 7545ade87d feat(loop): wire per-frame bypass through the loop widget 2026-07-21 02:55:40 +03:00
DumiFlex 651ca65228 feat(loop): opt-in bypass column in the per-iteration modal 2026-07-21 02:48:52 +03:00
DumiFlex d524d142b5 feat(loop): add optional bypass toggle to seed-lock row 2026-07-21 02:43:13 +03:00
DumiFlex da2239dca5 feat(loop): mirror bypass_frames in the ts config type
Adds `bypass_frames: number[]` to ContextLoopConfig + its recovery parse
(sorted, deduped, non-negative ints, out-of-range kept — mirrors the Python
_parse_config). Bumps the js/ gzip budget 450 -> 453 KB: the config field
alone landed 34 bytes over 450, and the headroom covers the loop-bypass UI
added in the following tasks.
2026-07-21 02:38:07 +03:00
DumiFlex 9d372b1d8f feat(loop): emit only non-bypassed frames with pinned identity 2026-07-21 02:28:24 +03:00
DumiFlex 7aab09d6bc feat(loop): parse bypass_frames in the loop config 2026-07-21 02:21:09 +03:00
DumiFlex 2295ce0f81 fix(manager): count templates in the import loaded-bar total
`payloadEntityCount` summed seven buckets but omitted templates, so the
Import tab's "Loaded · N entities" bar under-reported any payload containing
templates (a template-only import read "0 entities"). Add the templates
bucket to the sum.
2026-07-21 01:42:08 +03:00
DumiFlex 8ea15719bd feat(manager): per-branch collapse + collapse-all for derivation rules (#9)
Building on the existing rule-card collapse (rules default-collapsed when
>1):

- Each IF / ELIF / ELSE branch now has its own collapse chevron. Collapsing a
  branch hides its condition/action editors and shows a one-line
  `$cond → $target` peek in the branch head, so a rule with many branches
  stays scannable.
- The Rules card gains Collapse-all / Expand-all buttons (shown when >1
  rule). They broadcast a `collapseCommand` (nonce + state) that every rule
  card adopts; individual cards can still be toggled afterwards.

Verified live: per-branch chevron hides the body + shows the peek;
Collapse-all / Expand-all toggle every rule card.
2026-07-21 00:21:50 +03:00
DumiFlex d03936478c feat(manager): move Target reach above the constraint rule matrix (#10a)
Section order is now Identity → Wildcards → Target reach → Rule matrix →
Exceptions. The "how many downstream targets does this constraint cover"
decision now reads before the per-pair rule grid, matching the top-down
authoring flow (pick wildcards → decide reach → fill the matrix).
2026-07-20 18:48:53 +03:00
DumiFlex 8dcef0f3d9 fix(manager): resolve inline multi-pick {N-M$$sep$$…} in the test runner
The JS resolver's `expandInlineChoices` only understood single-pick
`{a|b|c}`. A multi-pick block like `{1-2$$, $$smirk|smug|half-closed eyes}`
matched the brace regex but got split naively on `|`, so the `1-2$$, $$`
prefix stuck to the first branch and leaked into renders as raw
`1-2$$, $$smirk` (the Test Runner samples + histogram).

Mirror the engine's `_resolve_multi_pick` (engine/syntax/resolve.py):
detect the `N$$sep$$` / `N-M~$$sep$$` prefix, parse the count range, the
`~` independent flag (draw WITH replacement vs unique), and the separator;
then pick k weighted branches (via a `pickKUnique` port of the engine's
`pick_k_unique`) and join by sep. A fixed count skips the RNG draw, matching
the engine's seed behaviour. Single-pick `{a|b|c}` is unchanged.

Tests cover fixed/range counts, empty separator, independent-with-repeats,
and the exact user repro string.
2026-07-20 18:41:36 +03:00
DumiFlex 8912937a9a feat(manager): per-kind quick-select presets + import-tab mirror (#1)
Export quick-select bar now has a preset per kind (Bundles, Wildcards, Fixed
values, Combines, Derivations, Constraints, Categories, Templates) alongside
Full library + Favorites, via a generic `presetKindOnly` (replaces the lone
"Wildcards only"). Each per-kind button is disabled when the library has none
of that kind.

The Import picker now mirrors the bar after a payload loads from file/paste:
"All" + the same per-kind presets, operating on the loaded payload's
entities (disabled per empty bucket). The `.wp-preset-btn` atom moved to
tokens.css so both bars share it; each bar keeps its own layout wrapper.

Verified live: Export "Wildcards" → 56 of 132; Import "Wildcards" → 3 of 5,
"All" → 5 of 5, with empty-kind buttons correctly disabled on both.
2026-07-20 18:14:19 +03:00
DumiFlex d4e6d0c112 feat(manager): drag-and-drop file import (#5)
The Import tab now accepts a dropped .json export file, not just a file-pick
or paste. The pick/paste buttons sit inside a dashed dropzone that advertises
"or drag & drop a .json export file here"; dragging a file over it lights the
frame accent and switches the hint to "Drop to import"; dropping reads the
file and runs it through the same parse pipeline as the file picker (a shared
`readAndParse` helper). Non-JSON drops fall through to the existing inline
"Invalid payload" error, so there's no silent failure.

Verified live: the dropzone highlights on drag-over; unit tests cover the
drop→payload-ready path and the highlight toggle.
2026-07-20 17:57:48 +03:00
DumiFlex 696711dca1 fix(manager): fresh dates for imported adds (#12) + live counts after import (#7)
#12 — imported modules kept the exporter's original creation date (shown
"1mo old" instead of just-imported). The engine importer's insert paths use
`entity.get("created_at", now)`, so a client-supplied date is copied
verbatim. The rename path already stripped lifecycle fields for this reason;
extract that into a shared `stripLifecycle` helper and apply it to the `add`
path too, so the server stamps a fresh created_at (identity/`id` preserved).

#7 — the sidebar template + category count badges went stale after an
import, because the post-commit / post-undo refresh in ImportExport only
reloaded the module + bundle stores. Refresh `templateStore.fetchCatalog()`
and `categoryStore.fetchAll()` alongside them so the counts update live.
2026-07-20 17:31:03 +03:00
DumiFlex b54302d546 fix(manager): detect bundle collisions on import instead of blind-adding (#6)
Bundles were excluded from import collision detection on the premise that a
`bundle-fingerprint.ts` MOD-detection flow handled them downstream — but that
file was never built. So an imported bundle whose id already existed:
  - never appeared in the conflict modal (missing from the conflict count),
  - fell through to a blind `add` decision,
  - was rejected by the server with 400 "id collision on add",
  - which failed the whole commit and the bundles never imported.

Bundles live in the same id→snapshot_fingerprint library map as modules, so
route them through the SAME `detectCollisions`: identical → silent-skip,
present-but-changed / no library fingerprint → conflict / exists-unknown,
both of which surface in the conflict modal for Replace / Rename / Skip —
exactly like a colliding module. Fixed in `buildSelectedEntities`
(the commit orchestrator) and mirrored in `ImportPicker`'s inline badges so
an existing bundle reads MODIFIED / EXISTING / DUPLICATE / NEW consistently.
Removed the stale `bundle-fingerprint.ts` references from the comments.

Verified live: re-importing an existing bundle now opens the conflict modal
("1 existing" → Skip / Replace / Import as new) instead of 400-ing.
2026-07-20 17:15:42 +03:00
DumiFlex 0bd4382cf0 fix(manager): sticky-header subpixel bleed + confusing weight validation
Two polish fixes flagged before starting Batch C:

- Sticky header bleed: the editor scroll container's top can land on a
  fractional device pixel (measured 152.39 at dpr 1), so pinning the sticky
  region exactly at top:0 left a sub-pixel sliver above the opaque header
  through which the scrolled content behind showed as a faint ghost line.
  Overlap the scroll edge by 1px (top:-1px, clipped by the container overflow
  and absorbed by the header's 12px top padding) so the bg covers the seam at
  any DPR; the sticky <thead> tracks the same -1px to stay flush.

- Confusing weight tooltip: the option-weight input carried `min="0.01"
  step="0.1"`, so a default weight of 1 is off the native step grid and the
  browser popped up "Please select a valid value. The two nearest valid
  values are 0.91 and 1.01." The native step is validation-only here — arrow
  keys, the wheel, and the custom chevrons all route through bump(), which
  reads props.step itself. Input now emits native step="any" for number
  inputs (no grid validation), while bump() keeps stepping by props.step.
2026-07-20 16:43:32 +03:00
DumiFlex ed10b881b7 feat(manager): bulk select + delete for exceptions + derivation rules (#11)
Extends the shared useBulkSelection + BulkDeleteToolbar pattern to the last
two editors:

- ConstraintEditor exceptions (a table): a "Bulk edit" toggle adds a
  checkbox column + select-all to the editable exceptions table and a delete
  toolbar in the sticky subheader. Exceptions carry no persisted id, so
  selection keys off a WeakMap-backed transient uid (stable across edits and
  single-row removals, never written to the payload).

- DerivationEditor rules (a card list): the checkbox rides to the left of
  each rule card; select-all + the delete toolbar sit in the sticky
  subheader. Rules already have a stable id, so selection keys off it.

The bulk-edit toggle stays reachable while bulk mode is active even after
every row is deleted, so the user can always leave bulk mode.
2026-07-20 10:51:23 +03:00
DumiFlex cbf102b8a0 feat(manager): bulk select + delete for fixed-values (#11)
Adds a shared `useBulkSelection` composable + lean `BulkDeleteToolbar`
(count + Delete + Clear) for editors whose rows have nothing to bulk-set,
then wires them into FixedEditor: a "Bulk edit" toggle reveals a per-row
checkbox column + select-all, and the sticky subheader shows the delete
toolbar once ≥1 row is picked. Deletion is in-memory (marks the editor
dirty; committed on Save), and the existing bulk-ADD panel is untouched.
2026-07-20 10:36:34 +03:00
DumiFlex be70a5f149 feat(manager): relevant + colour-coded bulk sub-category menus
Bulk-edit Apply/Remove sub-category menus (#3, #4):
- Apply now lists only tags that would change something — every tag EXCEPT
  those already on all selected rows (commonTags).
- Remove now lists only tags the selection actually carries (presentTags,
  the union across selected rows); empty selection-tags → empty menu.
- Menu items show an axis-hue swatch via the shared `tagHue`, so a
  sub-category reads with the same colour here as in the pills / row chips.

Dropdown scroll-trap (#3):
- `overscroll-behavior: contain` on the menu list stops wheel scroll at its
  top/bottom edge from chaining to the editor body ("nudged the page").
- Scrolling the page behind an open menu now closes it (capture-phase scroll
  listener that ignores scrolls originating inside the menu's own list).

WildcardEditor computes commonSelectedTags / presentSelectedTags /
selectedTagHues and passes them to SelectionToolbar. New props are optional
and fall back to the old all-tags behaviour, so nothing else breaks.
2026-07-20 10:23:35 +03:00
DumiFlex 7d2bd63912 test(engine): un-skip pipeline trace tests after handler migration
The four tests skipped pending the tasks 15-17 handler migration now assert
the current dispatcher-based trace shape:
- writes always carry an `overwrite` flag (first write = False)
- disabled modules emit a `skipped_disabled` trace row (writing nothing)
- unknown module types emit a `skipped_unknown_type` row + a warning log
- rng is threaded via `ctx["__wp_rng__"]` (HANDLERS dict removed), seeded
  deterministically from the `seed` arg
2026-07-20 10:09:50 +03:00
DumiFlex 6f69304796 fix(manager): keep bulk-edit toolbar stuck with sticky card header
Batch A stuck only the card header; the bulk-edit toolbar (BulkAddPanel +
SelectionToolbar) lived below it in the card body as a separate flow
element, so scrolling a long option list left it behind. Wrap the header
and a new `subheader` slot in one `.wp-card__sticky` region and stick that
region as a unit. Card.vue now measures the whole region for
`--wp-card-header-h`, so the sticky <thead> auto-offsets below it as the
toolbar appears/disappears. Move the `.wpc-bulk-controls` block into the
subheader slot in WildcardEditor + FixedEditor.
2026-07-20 10:00:40 +03:00
DumiFlex 6802f11497 feat(manager): sticky list headers across editors
Long option / value / rule / exception lists scrolled their toolbar and
column header out of view, so "Add option" (and friends) were unreachable at
the bottom -- exactly where new rows land. Add a reusable `sticky-header`
option to Card: it pins the card header to the top of the editor scroll
region and publishes the measured header height (`--wp-card-header-h`, via
ResizeObserver) so a sticky <thead> can offset right below it. Chromium wont
scroll-anchor a table cell here, so the <thead> ELEMENT is stuck instead.

Applied to Wildcard options, Fixed-values, Derivation rules, and Constraint
exceptions. Verified live: header + column header pin, Add option reachable.
2026-07-20 00:10:16 +03:00
72 changed files with 3798 additions and 500 deletions
+3
View File
@@ -52,6 +52,9 @@ Thumbs.db
/docs/*
!/docs/help/
!/docs/import-export-contract.md
# Curated release highlights — read by .releaserc.js at release time, so it
# must be tracked (the release step commits the cleared file back).
!/docs/release-notes/
# Semantic release
.releaserc.local.js
+24 -2
View File
@@ -18,6 +18,21 @@
* notes shrink to just the meaningful changes (typically <50 entries
* per release).
*/
import fs from "node:fs";
// Curated, human-readable highlights for the NEXT release. A maintainer
// writes plain-language "what changed for you" bullets here before merging a
// release; if the file is empty/absent the release body simply falls back to
// the auto-generated grouped changelog. The file is read at release time (in
// CI) and cleared by the prepare step below so highlights never repeat.
const HIGHLIGHTS_FILE = "docs/release-notes/next.md";
let highlights = "";
try {
highlights = fs.readFileSync(HIGHLIGHTS_FILE, "utf8").trim();
} catch {
/* no curated highlights for this release */
}
export default {
branches: ["main"],
plugins: [
@@ -80,6 +95,10 @@ export default {
"💬 [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)\n\n" +
// Curated, plain-language highlights (when provided) render FIRST
// — this is what most readers see. The raw per-commit changelog
// stays below, collapsed.
(highlights ? `### ✨ What's new\n\n${highlights}\n\n---\n\n` : "") +
"<details>\n" +
"<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>\n\n",
// footerPartial closes the <details> wrapper so the wall of
@@ -102,14 +121,17 @@ export default {
// @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.
// 3. Clear the consumed highlights file so the next release starts
// 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 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','')\" && node scripts/pack-release.mjs ${nextRelease.version}",
},
],
[
"@semantic-release/git",
{
assets: ["CHANGELOG.md", "package.json", "pyproject.toml"],
assets: ["CHANGELOG.md", "package.json", "pyproject.toml", "docs/release-notes/next.md"],
// Plain message — `${nextRelease.notes}` here would expand to
// the entire changelog, which on a divergent history (where
// semantic-release can't find the last tag in main's first-
+83
View File
@@ -1,3 +1,86 @@
## 🎉 Wildcard Pipeline 2.10.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)
### ✨ What's new
A bug-fix release for the in-app updater and name validation.
- **Fixed an updater downgrade.** On 2.10.0, clicking **Update Now** could install an *older* version — it asked ComfyUI Manager for "latest" against a stale cache. The updater now installs the exact new version and refuses to ever move backwards.
- **The update badge appears right away** after **Check now** — no page refresh needed.
- **Cleaner release notes** in the update dialog (proper formatting instead of raw `<details>` tags).
- **Names are validated as you rename them.** Renaming a sub-category or a variable now enforces the same rules as creating one, so invalid names (spaces, reserved characters) are caught immediately instead of failing later on save.
---
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #10 from DumiFlex/fix/updater-downgrade ([a2e93fe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a2e93fe)), closes [#10](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10)
* fix(manager): prevent updater downgrade, fix pill reactivity + release notes ([1e42f9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1e42f9b))
</details>
## 🎉 Wildcard Pipeline 2.10.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)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #9 from DumiFlex/fix/subcat-rename-validation ([74c7331](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/74c7331)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
* fix(manager): validate names on rename via shared validators (#7) ([00b735d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/00b735d)), closes [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
</details>
## 🎉 Wildcard Pipeline 2.10.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)
<details>
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
* Merge pull request #8 from DumiFlex/feat/manager-qol ([fe2423f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fe2423f)), closes [#8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/8)
* feat(loop): add optional bypass toggle to seed-lock row ([d524d14](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d524d14))
* feat(loop): dashed border on bypassed edit-frame chips ([70283ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/70283ce))
* feat(loop): emit only non-bypassed frames with pinned identity ([9d372b1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9d372b1))
* feat(loop): mirror bypass_frames in the ts config type ([da2239d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/da2239d))
* feat(loop): mirror the loop's bypassed frames in the seed list UI ([f670c3d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f670c3d))
* feat(loop): opt-in bypass column in the per-iteration modal ([651ca65](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/651ca65))
* feat(loop): parse bypass_frames in the loop config ([7aab09d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7aab09d))
* feat(loop): purple wash + dashed bar on bypassed seed rows ([ea4ed05](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ea4ed05))
* feat(loop): wire per-frame bypass through the loop widget ([7545ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7545ade))
* feat(manager): add checkOnLaunch update preference to uiStore ([d42b7ca](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d42b7ca))
* feat(manager): add ComfyUI Manager update composable ([327e7cc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/327e7cc))
* feat(manager): add safe markdown renderer for release notes ([830776e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/830776e))
* feat(manager): add UpdateDialog with changelog + Manager-driven update ([290e7a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/290e7a8))
* feat(manager): add Updates settings card with check-on-launch + check now ([6c20f53](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6c20f53))
* feat(manager): bulk select + delete for exceptions + derivation rules (#11) ([ed10b88](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ed10b88)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
* feat(manager): bulk select + delete for fixed-values (#11) ([cbf102b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cbf102b)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
* feat(manager): drag-and-drop file import (#5) ([d4e6d0c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d4e6d0c)), closes [#5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/5)
* feat(manager): move Target reach above the constraint rule matrix (#10a) ([d039364](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d039364)), closes [#10a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/10a)
* feat(manager): open UpdateDialog from the topbar update pill ([936a139](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/936a139))
* feat(manager): per-branch collapse + collapse-all for derivation rules (#9) ([8ea1571](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8ea1571)), closes [#9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/9)
* feat(manager): per-kind quick-select presets + import-tab mirror (#1) ([8912937](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8912937)), closes [#1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/1)
* feat(manager): per-rule branch collapse + collapse rules/branches by default ([fcde3b3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fcde3b3))
* feat(manager): relevant + colour-coded bulk sub-category menus ([be70a5f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/be70a5f)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3) [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4) [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
* feat(manager): rework release check to launch-once + manual, expose release notes ([bdcf85c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bdcf85c))
* feat(manager): sticky list headers across editors ([6802f11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6802f11))
* fix(loop): parse parity + edge coverage from final review ([bab87cd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bab87cd))
* fix(loop): seed list mirrors the loop's bypassed frames ([af95f08](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/af95f08))
* fix(manager): count templates in the import loaded-bar total ([2295ce0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2295ce0))
* fix(manager): detect bundle collisions on import instead of blind-adding (#6) ([b54302d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b54302d)), closes [#6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/6)
* fix(manager): fresh dates for imported adds (#12) + live counts after import (#7) ([696711d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/696711d)), closes [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7) [#12](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/12) [#7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/7)
* fix(manager): keep bulk-edit toolbar stuck with sticky card header ([6f69304](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f69304))
* fix(manager): resolve inline multi-pick {N-M$$sep$$…} in the test runner ([8dcef0f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcef0f))
* fix(manager): sticky-header subpixel bleed + confusing weight validation ([0bd4382](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bd4382))
* test(engine): un-skip pipeline trace tests after handler migration ([7d2bd63](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7d2bd63))
</details>
## 🎉 Wildcard Pipeline 2.9.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)
View File
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "2.9.0",
"version": "2.10.2",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "2.9.0"
version = "2.10.2"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
+6 -1
View File
@@ -221,7 +221,12 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
// base-only with a frame-active disabled hint) in the lazy ContextWidget
// chunk; entry stays put. Measured 459909 bytes (133 over the 449 ceiling);
// minimal bump.
const TOTAL_LIMIT = 450 * 1024; // 450 KB
// Bumped 450 -> 453 KB on 2026-07-21 for loop bypass frames (#13): the
// `bypass_frames` config field + the SeedLockRow bypass toggle, the
// SeedListModal opt-in bypass column, and the ContextLoopWidget "N bypassed"
// badge. Config field alone landed 34 bytes over 450; the ~3 KB headroom
// covers the UI additions in the same feature.
const TOTAL_LIMIT = 453 * 1024; // 453 KB
function gzipSize(path) {
return gzipSync(readFileSync(path)).length;
@@ -148,3 +148,33 @@ describe("ContextLoopWidget frame selector", () => {
expect(currentFrame.value).toBe(null);
});
});
describe("ContextLoopWidget bypass (#13)", () => {
it("shows an N-bypassed badge on the seeds button", () => {
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
expect(w.find('[data-test="loop-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
});
it("marks bypassed edit-frame chips with a dashed-border class", () => {
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [1, 3] };
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 5 } });
// frame index 1 -> chip #2 (data-test loop-frame-2)
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-loop__chip--bypassed");
expect(w.find('[data-test="loop-frame-4"]').classes()).toContain("wp-loop__chip--bypassed");
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-loop__chip--bypassed");
});
it("has no bypass badge when none are bypassed", () => {
const w = mount(ContextLoopWidget, { props: { modelValue: emptyContextLoopConfig(), count: 5 } });
expect(w.find('[data-test="loop-seeds-bypass-badge"]').exists()).toBe(false);
});
it("writes bypass changes from the modal back into the config", async () => {
const cfg = { ...emptyContextLoopConfig(), bypass_frames: [] };
const w = mount(ContextLoopWidget, { props: { modelValue: cfg, count: 4 } });
await w.get('[data-test="loop-seeds-btn"]').trigger("click");
w.findComponent(SeedListModal).vm.$emit("update:bypassFrames", [2]);
const calls = w.emitted("update:modelValue")!;
const last = calls[calls.length - 1][0] as typeof cfg;
expect(last.bypass_frames).toEqual([2]);
});
});
@@ -66,6 +66,14 @@ function onSeedLocks(next: Record<string, number>): void {
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
}
const bypassedCount = computed(() => (props.modelValue.bypass_frames ?? []).length);
/** 0-based bypassed frame indices — drives the dashed border on edit-frame
* chips so bypassed frames read at a glance without opening the modal. */
const bypassSet = computed(() => new Set(props.modelValue.bypass_frames ?? []));
function onBypassFrames(next: number[]): void {
emit("update:modelValue", { ...props.modelValue, bypass_frames: next });
}
function pickStrategy(s: LoopStrategy): void {
if (props.modelValue.strategy === s) return;
emit("update:modelValue", { ...props.modelValue, strategy: s });
@@ -133,6 +141,7 @@ function toggleTotalInternal(): void {
Per-iteration seeds
<span class="wp-loop__seedbtn-fill" />
<span v-if="lockedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-badge">{{ lockedCount }} locked</span>
<span v-if="bypassedCount" class="wp-loop__seedbtn-badge" data-test="loop-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
<svg class="wp-loop__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
</button>
@@ -142,8 +151,9 @@ function toggleTotalInternal(): void {
<button type="button" class="wp-loop__chip" :class="{ 'wp-loop__chip--active': currentFrame === null }"
data-test="loop-frame-base" role="radio" :aria-checked="currentFrame === null" @click="setFrame(null)">base</button>
<button v-for="i in frameChips" :key="i" type="button" class="wp-loop__chip"
:class="{ 'wp-loop__chip--active': currentFrame === i }"
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i" @click="setFrame(i)">#{{ i + 1 }}</button>
:class="{ 'wp-loop__chip--active': currentFrame === i, 'wp-loop__chip--bypassed': bypassSet.has(i) }"
:data-test="`loop-frame-${i + 1}`" role="radio" :aria-checked="currentFrame === i"
:title="bypassSet.has(i) ? `Frame ${i + 1} is bypassed` : undefined" @click="setFrame(i)">#{{ i + 1 }}</button>
</div>
</div>
@@ -224,8 +234,9 @@ function toggleTotalInternal(): void {
<SeedListModal v-if="seedsOpen" :node-name="'WP Context Loop'" :base-seed="baseSeed"
:count="count" :strategy="modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
:previous-seeds="previousSeeds"
:bypass-frames="modelValue.bypass_frames ?? []"
:override-hint="!modelValue.override_seed ? 'These seeds apply only when Override Context seed is on.' : ''"
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
@update:seed-locks="onSeedLocks" @update:bypass-frames="onBypassFrames" @close="seedsOpen = false" />
</div>
</template>
@@ -274,6 +285,14 @@ function toggleTotalInternal(): void {
border-color: var(--wp-accent, #c4b5fd);
color: var(--wp-accent, #c4b5fd);
}
/* Bypassed frame — interrupted (dashed) border + dimmed so it reads at a
* glance on the node. Composes with --active (dashed overrides the solid). */
.wp-loop__chip--bypassed {
border-style: dashed;
border-color: var(--wp-border-strong, #4a4d55);
opacity: 0.5;
}
.wp-loop__chip--bypassed:hover { opacity: 0.8; }
.wp-loop__row {
display: flex;
+22
View File
@@ -31,6 +31,7 @@ describe("parseContextLoopConfig", () => {
iteration_internal: false,
total_internal: false,
seed_locks: {},
bypass_frames: [],
};
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -44,6 +45,7 @@ describe("parseContextLoopConfig", () => {
iteration_internal: true,
total_internal: true,
seed_locks: {},
bypass_frames: [],
};
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
});
@@ -68,6 +70,7 @@ describe("parseContextLoopConfig", () => {
iteration_internal: false,
total_internal: true,
seed_locks: {},
bypass_frames: [],
};
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
expect(round).toEqual(cfg);
@@ -89,3 +92,22 @@ describe("ContextLoopConfig seed_locks", () => {
expect(parseContextLoopConfig(serializeContextLoopConfig(cfg)).seed_locks).toEqual({ "0": 7 });
});
});
describe("bypass_frames parse", () => {
it("defaults to empty", () => {
expect(emptyContextLoopConfig().bypass_frames).toEqual([]);
expect(parseContextLoopConfig("{}").bypass_frames).toEqual([]);
});
it("dedups + sorts, keeps out-of-range, drops junk + negatives", () => {
expect(parseContextLoopConfig('{"bypass_frames":[3,1,3,0]}').bypass_frames).toEqual([0, 1, 3]);
expect(parseContextLoopConfig('{"bypass_frames":[2,-1,"x",2,999]}').bypass_frames).toEqual([2, 999]);
});
it("drops coercible non-numbers, not coerce them (Python parity)", () => {
// true/"3"/null would coerce to 1/3/0 under Number() — must be dropped.
expect(parseContextLoopConfig('{"bypass_frames":[true,"3",null,2]}').bypass_frames).toEqual([2]);
});
it("malformed collapses to empty", () => {
expect(parseContextLoopConfig('{"bypass_frames":"nope"}').bypass_frames).toEqual([]);
expect(parseContextLoopConfig("not json").bypass_frames).toEqual([]);
});
});
+17
View File
@@ -23,6 +23,10 @@ export interface ContextLoopConfig {
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
* indices re-derive from base+strategy. Empty by default. */
seed_locks: Record<string, number>;
/** 0-based iteration indices to bypass (skip generation + overrides).
* Sorted, deduped, non-negative. Out-of-range (>= count) entries are
* kept and re-apply if count grows. Empty by default. */
bypass_frames: number[];
}
const STRATEGIES = new Set<LoopStrategy>(["sequential", "hash_index", "prime_stride"]);
@@ -36,6 +40,7 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
iteration_internal: true,
total_internal: true,
seed_locks: {},
bypass_frames: [],
};
}
@@ -71,6 +76,18 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
}
out.seed_locks = locks;
}
if (Array.isArray(obj.bypass_frames)) {
const frames = new Set<number>();
for (const x of obj.bypass_frames) {
// Type-guard before accepting — do NOT coerce. Mirrors the Python
// `_parse_config` (which rejects non-int via isinstance), so both
// parsers degrade corrupt widget JSON identically (`true`, `"3"`,
// `null` are dropped on both sides, not coerced to 1/3/0).
if (typeof x !== "number" || !Number.isInteger(x) || x < 0) continue;
frames.add(x);
}
out.bypass_frames = [...frames].sort((a, b) => a - b);
}
return out;
}
@@ -186,3 +186,18 @@ describe("SeedListWidget seeds button", () => {
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
});
});
describe("SeedListWidget bypass mirror (#13)", () => {
it("shows an N-bypassed badge from the loop's bypassedFrames", () => {
const w = mount(SeedListWidget, {
props: { modelValue: emptySeedListConfig(), bypassedFrames: [1, 3] },
});
expect(w.find('[data-test="seedlist-seeds-bypass-badge"]').text()).toMatch(/2 bypassed/i);
});
it("no bypass badge when the loop bypasses nothing", () => {
const w = mount(SeedListWidget, {
props: { modelValue: emptySeedListConfig(), bypassedFrames: [] },
});
expect(w.find('[data-test="seedlist-seeds-bypass-badge"]').exists()).toBe(false);
});
});
+9 -1
View File
@@ -37,8 +37,13 @@ const props = withDefaults(
* executed `loop_seeds` UI payload by the host glue. Drives the seed
* modal's per-frame "lock previous" button. Null until a run lands. */
previousSeeds?: number[] | null;
/** 0-based frames the wired loop bypasses (resolved by the host glue —
* only when Override count from loop is on). Shown READ-ONLY in the
* modal (dimmed rows + "N bypassed" badge); bypass is owned by the loop.
* Empty when no loop is wired or none are bypassed. */
bypassedFrames?: number[];
}>(),
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null },
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null, bypassedFrames: () => [] },
);
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
@@ -73,6 +78,7 @@ const isBypassed = computed<boolean>(() => props.nodeMode === 4);
const seedsOpen = ref(false);
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
const bypassedCount = computed(() => props.bypassedFrames.length);
function onSeedLocks(next: Record<string, number>): void {
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
@@ -153,6 +159,7 @@ function toggleOverrideStrategy(): void {
Per-iteration seeds
<span class="wp-seedlist__seedbtn-fill" />
<span v-if="lockedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-badge">{{ lockedCount }} locked</span>
<span v-if="bypassedCount" class="wp-seedlist__seedbtn-badge" data-test="seedlist-seeds-bypass-badge">{{ bypassedCount }} bypassed</span>
<svg class="wp-seedlist__seedbtn-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M9 6l6 6-6 6" /></svg>
</button>
@@ -206,6 +213,7 @@ function toggleOverrideStrategy(): void {
<SeedListModal v-if="seedsOpen" :node-name="'WP Seed List'" :base-seed="baseSeed"
:count="count" :strategy="previewStrategy ?? modelValue.strategy" :seed-locks="modelValue.seed_locks ?? {}"
:previous-seeds="previousSeeds"
:bypass-frames="bypassedFrames.length ? bypassedFrames : undefined" :bypass-readonly="true"
:override-hint="modelValue.override_seed ? 'Base seed comes from the wired loop while Override base seed from loop is on.' : ''"
@update:seed-locks="onSeedLocks" @close="seedsOpen = false" />
</div>
+63 -2
View File
@@ -21,8 +21,19 @@ const props = defineProps<{
* short → that frame's button greys out. Distinct from the computed
* `derived` series, which is the UPCOMING seeds from the current base. */
previousSeeds?: number[] | null;
/** 0-based bypassed frame indices. When PASSED, bypassed rows dim + strike.
* Absent → no bypass display at all. */
bypassFrames?: number[];
/** Read-only bypass (Seed List mirroring a loop): show bypassed rows dimmed
* but WITHOUT an interactive toggle — bypass is owned by the loop node.
* Default false (Context Loop = interactive toggles). */
bypassReadonly?: boolean;
}>();
const emit = defineEmits<{
"update:seedLocks": [next: Record<string, number>];
"update:bypassFrames": [next: number[]];
close: [];
}>();
const emit = defineEmits<{ "update:seedLocks": [next: Record<string, number>]; close: [] }>();
const derived = computed(() => deriveLoopSeeds(props.baseSeed, Math.max(1, props.count), props.strategy));
const lockedCount = computed(() => Object.keys(props.seedLocks).length);
@@ -41,6 +52,41 @@ const inactiveLocks = computed(() => {
.sort((a, b) => a - b);
});
const bypassEnabled = computed(() => props.bypassFrames !== undefined);
/** Interactive toggle only when bypass is enabled AND not read-only. In
* read-only mode rows still show the bypassed (dim + strike) state — that's
* the `:bypassed` prop, independent of `:bypassable`. */
const bypassInteractive = computed(() => bypassEnabled.value && !props.bypassReadonly);
const bypassSet = computed(() => new Set(props.bypassFrames ?? []));
const bypassCount = computed(() => (props.bypassFrames ?? []).length);
/** Number of in-range frames NOT bypassed. */
const activeFrameCount = computed(() => {
let n = 0;
for (let i = 0; i < derived.value.length; i++) if (!bypassSet.value.has(i)) n++;
return n;
});
/** Bypass toggle is disabled for an ACTIVE frame when it's the last one left. */
function bypassDisabledFor(i: number): boolean {
return !bypassSet.value.has(i) && activeFrameCount.value <= 1;
}
/** Bypassed indices beyond the current count — dimmed, re-apply if count grows. */
const inactiveBypass = computed(() =>
[...bypassSet.value].filter((i) => i >= derived.value.length).sort((a, b) => a - b),
);
/** Real derived seed for any index (out-of-range inactive rows compute it on
* the fly since `derived` only spans the active count). PRECONDITION: `i >= 1`
* — only ever called for inactive rows where `i >= derived.length >= 1`. At
* `i === 0` deriveLoopSeeds hits its `count <= 1` branch and returns the base
* seed, NOT the series' derived[0], so do not reuse this for in-range index 0. */
function seedAt(i: number): number {
return deriveLoopSeeds(props.baseSeed, i + 1, props.strategy)[i] ?? 0;
}
function onBypass(p: { index: number; bypassed: boolean }): void {
const next = new Set(bypassSet.value);
if (p.bypassed) next.add(p.index);
else next.delete(p.index);
emit("update:bypassFrames", [...next].sort((a, b) => a - b));
}
function onRow(p: { index: number; seed: number | null }): void {
const next = { ...props.seedLocks };
if (p.seed == null) delete next[String(p.index)];
@@ -115,6 +161,7 @@ onBeforeUnmount(() => {
<span class="sm__count">{{ Math.max(1, count) }} iterations</span>
<span class="sm__strat">{{ strategy }}</span>
<span v-if="lockedCount" class="sm__lockcount" data-test="mx-seed-lockcount">{{ lockedCount }} locked</span>
<span v-if="bypassEnabled && bypassCount" class="sm__lockcount" data-test="mx-bypass-count">{{ bypassCount }} bypassed</span>
<span class="sm__bar-spacer" />
<button class="ghost" data-test="mx-seed-lockall" @click="lockAll">Lock all</button>
<button class="ghost" data-test="mx-seed-unlockall" @click="unlockAll">Unlock all</button>
@@ -133,7 +180,21 @@ onBeforeUnmount(() => {
<div class="sm__list">
<SeedLockRow v-for="(s, i) in derived" :key="i" :index="i" :derived="s"
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null" @update="onRow" />
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null"
:bypassable="bypassInteractive" :bypassed="bypassSet.has(i)"
:bypass-disabled="bypassDisabledFor(i)"
@update="onRow" @bypass="onBypass" />
<template v-if="inactiveBypass.length">
<div class="sm__inactive-label" data-test="mx-bypass-inactive">
Bypassed · won't apply at count {{ derived.length }}
</div>
<SeedLockRow v-for="i in inactiveBypass" :key="`inbypass-${i}`" :index="i"
:derived="seedAt(i)"
:locked="Object.prototype.hasOwnProperty.call(seedLocks, String(i))"
:seed="seedLocks[String(i)] ?? null" :previous="previousSeeds?.[i] ?? null"
:inactive="true" :bypassable="bypassInteractive" :bypassed="true"
@update="onRow" @bypass="onBypass" />
</template>
<template v-if="inactiveLocks.length">
<div class="sm__inactive-label" data-test="mx-seed-inactive">
Inactive · won't apply at count {{ derived.length }}
+36 -2
View File
@@ -15,8 +15,17 @@ const props = defineProps<{
* frame — the "lock previous" button greys out. Distinct from `derived`,
* which is the UPCOMING seed computed from the current base. */
previous?: number | null;
/** When true, render a per-frame bypass toggle (Context Loop only). */
bypassable?: boolean;
/** Whether this frame is currently bypassed. */
bypassed?: boolean;
/** Disable the bypass toggle (e.g. it's the last active frame). */
bypassDisabled?: boolean;
}>();
const emit = defineEmits<{
update: [payload: { index: number; seed: number | null }];
bypass: [payload: { index: number; bypassed: boolean }];
}>();
const emit = defineEmits<{ update: [payload: { index: number; seed: number | null }] }>();
const SEED_MAX = (2 ** 50) - 1; // MAX_SAFE_SEED (matches engine)
function randomSeed(): number {
@@ -40,8 +49,16 @@ function bump(d: 1 | -1) {
</script>
<template>
<div class="srow" :class="{ 'srow--locked': locked, 'srow--inactive': inactive }">
<div class="srow" :class="{ 'srow--locked': locked, 'srow--inactive': inactive, 'srow--bypassed': bypassed }">
<span class="srow__idx" data-test="seedrow-idx">#{{ index + 1 }}</span>
<button v-if="bypassable" type="button" class="toggle toggle--bypass"
:class="{ 'toggle--on': bypassed }" :disabled="bypassDisabled || undefined"
data-test="seedrow-bypass" role="switch" :aria-checked="bypassed"
:title="bypassed ? 'Frame bypassed — click to re-enable' : 'Bypass this frame (skip its generation)'"
@click="emit('bypass', { index, bypassed: !bypassed })">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9" /><path d="M5.6 5.6l12.8 12.8" stroke-linecap="round" /></svg>
{{ bypassed ? "Bypassed" : "Bypass" }}
</button>
<button type="button" class="toggle" :class="{ 'toggle--on': locked }"
data-test="seedrow-lock" role="switch" :aria-checked="locked" @click="onLock">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -90,6 +107,23 @@ function bump(d: 1 | -1) {
/* out-of-range lock: dimmed ("deemed down") but still interactive — hover lifts it. */
.srow--inactive { opacity: .5; }
.srow--inactive:hover { opacity: .85; }
/* Bypassed row — purple-dimmed wash + a dashed left accent bar so it reads
* clearly even when the struck-through seed alone is too faint. The dashed
* bar echoes the loop node's dashed bypass chips. */
.srow--bypassed {
background:
repeating-linear-gradient(
to bottom,
var(--wp-accent, #c4b5fd) 0 4px,
transparent 4px 8px
) left / 3px 100% no-repeat,
color-mix(in srgb, var(--wp-accent, #c4b5fd) 11%, transparent);
border-radius: 5px;
}
.srow--bypassed .derived__val { text-decoration: line-through; opacity: .65; }
.srow--bypassed .srow__idx { color: var(--wp-accent-text, #c4b5fd); opacity: .75; }
.toggle--bypass { color: var(--wp-text-dim, var(--wp-text3)); }
.toggle--bypass.toggle--on { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.10); }
.srow__idx { flex-shrink: 0; width: 30px; text-align: right; font: 600 11px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #7a7d88); }
.srow--locked .srow__idx { color: var(--wp-accent-text, #c4b5fd); }
.srow__fill { flex: 1; }
@@ -123,3 +123,38 @@ describe("SeedListModal", () => {
expect(writes[writes.length - 1]).toBe("#2: 999");
});
});
describe("SeedListModal bypass column (#13)", () => {
it("shows no bypass toggles when bypassFrames prop is absent (Seed List parity)", () => {
const w = modal({ seedLocks: {} });
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(0);
});
it("renders a bypass toggle per active row when bypassFrames is passed", () => {
const w = modal({ seedLocks: {}, bypassFrames: [] });
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(4);
});
it("emits update:bypassFrames when a row is bypassed", async () => {
const w = modal({ seedLocks: {}, bypassFrames: [] });
await w.findAll('[data-test="seedrow-bypass"]')[1].trigger("click");
expect(w.emitted("update:bypassFrames")![0]).toEqual([[1]]);
});
it("disables the last active frame's bypass toggle", () => {
const w = modal({ seedLocks: {}, bypassFrames: [0, 1, 2] });
const toggles = w.findAll('[data-test="seedrow-bypass"]');
expect(toggles[3].attributes("disabled")).toBeDefined();
expect(toggles[0].attributes("disabled")).toBeUndefined();
});
it("renders out-of-range bypassed frames as inactive rows", () => {
const w = modal({ seedLocks: {}, bypassFrames: [9] });
expect(w.find('[data-test="mx-bypass-inactive"]').exists()).toBe(true);
});
});
describe("SeedListModal read-only bypass (#13 seed-list mirror)", () => {
it("dims bypassed rows without an interactive toggle + shows the badge", () => {
const w = modal({ seedLocks: {}, bypassFrames: [1], bypassReadonly: true });
expect(w.findAll('[data-test="seedrow-bypass"]').length).toBe(0); // no toggles
expect(w.findAll(".srow")[1].classes()).toContain("srow--bypassed");
expect(w.find('[data-test="mx-bypass-count"]').text()).toMatch(/1 bypassed/i);
});
});
@@ -49,3 +49,21 @@ describe("SeedLockRow", () => {
expect(row().find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined(); // absent → disabled
});
});
describe("SeedLockRow bypass (#13)", () => {
it("hides the bypass toggle unless bypassable", () => {
expect(row().find('[data-test="seedrow-bypass"]').exists()).toBe(false);
expect(row({ bypassable: true }).find('[data-test="seedrow-bypass"]').exists()).toBe(true);
});
it("emits bypass with the toggled state", async () => {
const w = row({ bypassable: true, bypassed: false });
await w.get('[data-test="seedrow-bypass"]').trigger("click");
expect(w.emitted("bypass")![0]).toEqual([{ index: 0, bypassed: true }]);
});
it("marks the row bypassed + respects bypassDisabled", () => {
const on = row({ bypassable: true, bypassed: true });
expect(on.get(".srow").classes()).toContain("srow--bypassed");
const dis = row({ bypassable: true, bypassed: false, bypassDisabled: true });
expect(dis.get('[data-test="seedrow-bypass"]').attributes("disabled")).toBeDefined();
});
});
+33
View File
@@ -1,10 +1,12 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { createMemoryHistory, createRouter, type Router } from "vue-router";
import AppTopbar from "../layout/AppTopbar.vue";
import { useUiStore } from "../stores/uiStore";
import * as releaseCheck from "../composables/useReleaseCheck";
// Community status pill / sign-in / user-menu surfaces moved to the
// `feat/community-tab` branch alongside the views that need them. Topbar
@@ -109,4 +111,35 @@ describe("AppTopbar.vue", () => {
expect(backSpy).toHaveBeenCalledOnce();
backSpy.mockRestore();
});
it("clicking the update pill opens the dialog instead of navigating", async () => {
// Force an available update so the pill renders, and stub the dialog
// to a marker that only appears while open.
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
current: "2.9.0",
latestVersion: ref("2.10.0"),
hasUpdate: ref(true),
severity: ref("minor"),
releaseBody: ref("- notes"),
releaseUrl: ref("https://x/r"),
lastChecked: ref(null),
checking: ref(false),
checkNow: vi.fn(),
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
const router = makeRouter();
await router.isReady();
const wrap = mount(AppTopbar, {
global: {
plugins: [router],
stubs: { UpdateDialog: { template: '<div data-test="dlg-stub" v-if="open" />', props: ["open"] } },
},
});
await flushPromises();
expect(wrap.find('[data-test="dlg-stub"]').exists()).toBe(false);
await wrap.find('[data-test="topbar-update-indicator"]').trigger("click");
expect(wrap.find('[data-test="dlg-stub"]').exists()).toBe(true);
expect(router.currentRoute.value.path).not.toBe("/dashboard");
});
});
@@ -423,4 +423,49 @@ describe("DerivationRuleCard.vue", () => {
const wrap = mountCard(makeRule(), 0);
expect(wrap.find("datalist").exists()).toBe(false);
});
describe("collapse (#9)", () => {
// v-show toggles an inline `display: none`; assert on that directly since
// isVisible() is unreliable under jsdom for v-show.
const hidden = (w: { attributes: (n: string) => string | undefined }) =>
(w.attributes("style") ?? "").includes("display: none");
it("branches start collapsed; the chevron toggles the body + peek", async () => {
const wrap = mountCard(makeRule(), 0);
const body = () => wrap.find('[data-test="branch-0-0"] .branch-body');
// Collapsed by default → body hidden, peek shown.
expect(hidden(body())).toBe(true);
expect(wrap.find('[data-test="branch-peek-0-0"]').text()).toBe("$x → $out");
// Expand.
await wrap.get('[data-test="toggle-branch-0-0"]').trigger("click");
expect(hidden(body())).toBe(false);
expect(wrap.find('[data-test="branch-peek-0-0"]').exists()).toBe(false);
});
it("per-rule collapse/expand-all-branches buttons toggle every branch", async () => {
const wrap = mountCard(makeRule(), 0);
const body = () => wrap.find('[data-test="branch-0-0"] .branch-body');
// Expand all first (default is collapsed), then collapse all again.
await wrap.get('[data-test="expand-branches-0"]').trigger("click");
expect(hidden(body())).toBe(false);
await wrap.get('[data-test="collapse-branches-0"]').trigger("click");
expect(hidden(body())).toBe(true);
});
it("adopts the collapseCommand broadcast (Collapse all / Expand all)", async () => {
const wrap = mount(DerivationRuleCard, {
props: {
modelValue: makeRule(),
index: 0,
collapseCommand: { nonce: 0, collapsed: false },
},
});
const branches = () => wrap.find(".branches");
expect(hidden(branches())).toBe(false);
await wrap.setProps({ collapseCommand: { nonce: 1, collapsed: true } });
expect(hidden(branches())).toBe(true);
await wrap.setProps({ collapseCommand: { nonce: 2, collapsed: false } });
expect(hidden(branches())).toBe(false);
});
});
});
+17
View File
@@ -1,5 +1,6 @@
import { mount } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Settings from "../views/Settings.vue";
@@ -15,6 +16,22 @@ vi.mock("../api/client", () => ({
ApiError: class ApiError extends Error {},
}));
// The Updates card calls useReleaseCheck, which otherwise fires a real
// GitHub fetch on mount. Stub it — these tests cover the other cards.
vi.mock("../composables/useReleaseCheck", () => ({
useReleaseCheck: () => ({
current: "2.9.0",
latestVersion: ref(null),
hasUpdate: ref(false),
severity: ref(null),
releaseBody: ref(null),
releaseUrl: ref(null),
lastChecked: ref(null),
checking: ref(false),
checkNow: vi.fn(),
}),
}));
const reloadMock = vi.fn();
beforeEach(() => {
@@ -59,4 +59,47 @@ describe("SelectionToolbar.vue", () => {
expect(wrap.emitted("delete-selected")).toBeTruthy();
expect(wrap.emitted("clear")).toBeTruthy();
});
it("Apply menu hides tags already on every selected row (commonTags)", async () => {
const wrap = mount(SelectionToolbar, {
props: { count: 2, tags: ["warm", "cool"], commonTags: ["warm"] },
});
await clickBar(wrap, "Apply sub-category");
const labels = wrap.findAll(".wpc-seltoolbar__menuitem").map((b) => b.text());
expect(labels).toContain("cool");
expect(labels).not.toContain("warm");
});
it("Remove menu lists only tags present on the selection", async () => {
const wrap = mount(SelectionToolbar, {
props: { count: 2, tags: ["warm", "cool", "neon"], presentTags: ["neon"] },
});
await clickBar(wrap, "Remove sub-category");
const labels = wrap.findAll(".wpc-seltoolbar__menuitem").map((b) => b.text());
expect(labels).toEqual(["neon"]);
});
it("Remove menu is empty when the selection carries no tags", async () => {
const wrap = mount(SelectionToolbar, {
props: { count: 1, tags: ["warm", "cool"], presentTags: [] },
});
await clickBar(wrap, "Remove sub-category");
expect(wrap.findAll(".wpc-seltoolbar__menuitem")).toHaveLength(0);
expect(wrap.find(".wpc-seltoolbar__menuempty").exists()).toBe(true);
});
it("menu chips carry their tag's axis hue as --chip-hue", async () => {
const wrap = mount(SelectionToolbar, {
props: {
count: 1,
tags: ["warm"],
tagHues: { warm: "hsl(1 2% 3%)" },
},
});
await clickBar(wrap, "Apply sub-category");
const item = wrap
.findAll(".wpc-seltoolbar__menuitem")
.find((b) => b.text() === "warm");
expect(item!.attributes("style")).toContain("--chip-hue: hsl(1 2% 3%)");
});
});
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { ref } from "vue";
import UpdateDialog from "../../components/UpdateDialog.vue";
import * as managerUpdate from "../../composables/useComfyManagerUpdate";
import * as releaseCheck from "../../composables/useReleaseCheck";
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
function stubRelease(over: Partial<ReturnType<typeof releaseCheck.useReleaseCheck>> = {}) {
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
current: "2.9.0",
latestVersion: ref("2.10.0"),
hasUpdate: ref(true),
severity: ref("minor"),
releaseBody: ref("## What's new\n- Cool thing"),
releaseUrl: ref("https://github.com/x/releases/2.10.0"),
lastChecked: ref(new Date().toISOString()),
checking: ref(false),
checkNow: vi.fn(),
...over,
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
}
function stubManager(over: Partial<ReturnType<typeof managerUpdate.useComfyManagerUpdate>> = {}) {
const base = {
phase: ref<managerUpdate.UpdatePhase>("idle"),
errorKind: ref<managerUpdate.UpdateErrorKind>(null),
errorMessage: ref<string | null>(null),
probe: vi.fn().mockResolvedValue("available" as const),
runUpdate: vi.fn(),
reboot: vi.fn(),
managerUiUrl: "/manager",
};
const merged = { ...base, ...over };
vi.spyOn(managerUpdate, "useComfyManagerUpdate").mockReturnValue(
merged as ReturnType<typeof managerUpdate.useComfyManagerUpdate>,
);
return merged;
}
function mountDialog() {
return mount(UpdateDialog, {
props: { open: true },
global: { stubs: { teleport: true } },
});
}
beforeEach(() => {
setActivePinia(createPinia());
vi.restoreAllMocks();
});
describe("UpdateDialog", () => {
it("shows the version delta and rendered notes", async () => {
stubRelease();
stubManager();
const wrap = mountDialog();
await flushPromises();
expect(wrap.text()).toContain("2.9.0");
expect(wrap.text()).toContain("2.10.0");
expect(wrap.find(".wpc-relnotes").html()).toContain("<li>Cool thing</li>");
});
it("Update Now button calls runUpdate when available + idle", async () => {
stubRelease();
const mgr = stubManager();
const wrap = mountDialog();
await flushPromises();
await wrap.get('[data-test="update-now"]').trigger("click");
expect(mgr.runUpdate).toHaveBeenCalled();
});
it("shows Restart button only in staged phase and calls reboot", async () => {
stubRelease();
const mgr = stubManager({ phase: ref("staged") });
const wrap = mountDialog();
await flushPromises();
expect(wrap.find('[data-test="update-now"]').exists()).toBe(false);
await wrap.get('[data-test="update-restart"]').trigger("click");
expect(mgr.reboot).toHaveBeenCalled();
});
it("shows the fallback card when Manager is absent", async () => {
stubRelease();
stubManager({ probe: vi.fn().mockResolvedValue("absent" as const) });
const wrap = mountDialog();
await flushPromises();
expect(wrap.find('[data-test="update-fallback"]').exists()).toBe(true);
expect(wrap.find('[data-test="update-now"]').exists()).toBe(false);
});
it("shows the fallback card on a forbidden error", async () => {
stubRelease();
stubManager({ phase: ref("error"), errorKind: ref("forbidden") });
const wrap = mountDialog();
await flushPromises();
expect(wrap.find('[data-test="update-fallback"]').exists()).toBe(true);
});
it("emits close from the Later button", async () => {
stubRelease();
stubManager();
const wrap = mountDialog();
await flushPromises();
await wrap.get('[data-test="update-later"]').trigger("click");
expect(wrap.emitted("close")).toBeTruthy();
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { ref } from "vue";
import { useBulkSelection } from "../../composables/useBulkSelection";
describe("useBulkSelection", () => {
it("toggles individual ids", () => {
const rows = ref(["a", "b", "c"]);
const s = useBulkSelection(() => rows.value);
expect(s.count.value).toBe(0);
s.toggle("a");
s.toggle("c");
expect(s.count.value).toBe(2);
expect(s.isSelected("a")).toBe(true);
expect(s.isSelected("b")).toBe(false);
s.toggle("a");
expect(s.isSelected("a")).toBe(false);
expect(s.count.value).toBe(1);
});
it("select-all / clear via toggleAll", () => {
const rows = ref(["a", "b"]);
const s = useBulkSelection(() => rows.value);
expect(s.allSelected.value).toBe(false);
s.toggleAll();
expect(s.allSelected.value).toBe(true);
expect(s.count.value).toBe(2);
s.toggleAll(); // all selected → clears
expect(s.count.value).toBe(0);
});
it("someSelected drives the indeterminate state", () => {
const rows = ref(["a", "b"]);
const s = useBulkSelection(() => rows.value);
expect(s.someSelected.value).toBe(false);
s.toggle("a");
expect(s.someSelected.value).toBe(true);
expect(s.allSelected.value).toBe(false);
});
it("ignores ids no longer present (count + selectedIds prune)", () => {
const rows = ref(["a", "b", "c"]);
const s = useBulkSelection(() => rows.value);
s.toggleAll();
expect(s.count.value).toBe(3);
// A row is removed elsewhere; its stale id must stop counting.
rows.value = ["a", "b"];
expect(s.count.value).toBe(2);
expect(s.selectedIds().sort()).toEqual(["a", "b"]);
expect(s.allSelected.value).toBe(true);
});
it("toggleMode enters/leaves and leaving clears the selection", () => {
const rows = ref(["a"]);
const s = useBulkSelection(() => rows.value);
s.toggleMode();
expect(s.active.value).toBe(true);
s.toggle("a");
expect(s.count.value).toBe(1);
s.toggleMode(); // leave
expect(s.active.value).toBe(false);
expect(s.count.value).toBe(0);
});
it("allSelected is false for an empty list", () => {
const rows = ref<string[]>([]);
const s = useBulkSelection(() => rows.value);
expect(s.allSelected.value).toBe(false);
});
});
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useComfyManagerUpdate } from "../../composables/useComfyManagerUpdate";
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
beforeEach(() => vi.restoreAllMocks());
/** Route a fetch mock by URL + method. `status` route returns `done`
* immediately unless overridden. */
function mockManager(opts: {
install?: { ok: boolean; status?: number; text?: string };
start?: { ok: boolean };
status?: unknown;
reboot?: () => Promise<Response>;
} = {}) {
return vi.fn(async (url: string, init?: RequestInit) => {
const method = init?.method ?? "GET";
if (url.includes("/manager/queue/install")) {
const o = opts.install ?? { ok: true };
return { ok: o.ok, status: o.status ?? (o.ok ? 200 : 500), text: async () => o.text ?? "" } as Response;
}
if (url.includes("/manager/queue/start")) {
const o = opts.start ?? { ok: true };
return { ok: o.ok, status: o.ok ? 200 : 500, text: async () => "" } as Response;
}
if (url.includes("/manager/queue/status")) {
const s = opts.status ?? { total_count: 1, done_count: 1, in_progress: false };
return { ok: true, status: 200, json: async () => s } as Response;
}
if (url.includes("/manager/reboot")) {
if (opts.reboot) return opts.reboot();
return { ok: true, status: 200, text: async () => "" } as Response;
}
void method;
throw new Error(`unexpected url ${url}`);
});
}
describe("useComfyManagerUpdate", () => {
it("probe returns available on 200 status", async () => {
vi.stubGlobal("fetch", mockManager());
const u = useComfyManagerUpdate();
expect(await u.probe()).toBe("available");
});
it("probe returns absent on network error", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("no manager")));
const u = useComfyManagerUpdate();
expect(await u.probe()).toBe("absent");
});
it("runUpdate happy path: idle -> installing -> staged", async () => {
const fetchMock = mockManager();
vi.stubGlobal("fetch", fetchMock);
const u = useComfyManagerUpdate();
const p = u.runUpdate("2.10.0"); // newer than __APP_VERSION__ 2.9.0
expect(u.phase.value).toBe("installing");
await p;
expect(u.phase.value).toBe("staged");
// Pins the exact target + uses the fresh (remote) catalog, never "latest"/"cache".
const installCall = fetchMock.mock.calls.find((c) => String(c[0]).includes("/manager/queue/install"));
const body = JSON.parse((installCall![1] as RequestInit).body as string);
expect(body.selected_version).toBe("2.10.0");
expect(body.mode).toBe("remote");
});
it("runUpdate refuses a downgrade / non-newer target (2.10.0 bug guard)", async () => {
const fetchMock = mockManager();
vi.stubGlobal("fetch", fetchMock);
const u = useComfyManagerUpdate();
await u.runUpdate("2.8.0"); // older than installed 2.9.0
expect(u.phase.value).toBe("error");
expect(u.errorKind.value).toBe("failed");
// Never hit the install endpoint.
expect(fetchMock.mock.calls.some((c) => String(c[0]).includes("/manager/queue/install"))).toBe(false);
});
it("runUpdate maps install 403 to forbidden error", async () => {
vi.stubGlobal("fetch", mockManager({ install: { ok: false, status: 403, text: "security" } }));
const u = useComfyManagerUpdate();
await u.runUpdate("2.10.0");
expect(u.phase.value).toBe("error");
expect(u.errorKind.value).toBe("forbidden");
});
it("runUpdate maps a non-403 install failure to failed error", async () => {
vi.stubGlobal("fetch", mockManager({ install: { ok: false, status: 500, text: "boom" } }));
const u = useComfyManagerUpdate();
await u.runUpdate("2.10.0");
expect(u.phase.value).toBe("error");
expect(u.errorKind.value).toBe("failed");
});
it("reboot sets restarting and treats a dropped connection as soft success", async () => {
vi.stubGlobal("fetch", mockManager({ reboot: () => Promise.reject(new Error("socket closed")) }));
const u = useComfyManagerUpdate();
await u.reboot();
expect(u.phase.value).toBe("restarting");
expect(u.errorKind.value).toBeNull();
});
});
@@ -1,20 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { useReleaseCheck, type UpdateSeverity } from "../../composables/useReleaseCheck";
import { useReleaseCheck, resetReleaseCheckSession } from "../../composables/useReleaseCheck";
import { useUiStore } from "../../stores/uiStore";
// __APP_VERSION__ is injected by vite at build time; vitest doesn't
// apply the define plugin, so we wire it onto globalThis here.
// __APP_VERSION__ is injected by vite at build time; vitest doesn't apply
// the define plugin, so we wire it onto globalThis here.
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "1.7.0";
const STORAGE_KEY = "wp.releaseCheck";
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
type CheckResult = ReturnType<typeof useReleaseCheck>;
let lastResult: CheckResult | null = null;
@@ -24,112 +21,144 @@ function host() {
setup() {
const r = useReleaseCheck();
lastResult = r;
return () => h("span", { "data-test": "version" }, r.current);
return () => h("span", r.current);
},
});
}
/** Flush onMounted → await fetch → applyLatest → render. */
async function settle() {
await nextTick();
await Promise.resolve();
await Promise.resolve();
await nextTick();
}
function releaseResponse(tag: string, body = "notes", url = "https://x/releases/v") {
return { ok: true, json: async () => ({ tag_name: tag, body, html_url: url }) };
}
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
setActivePinia(createPinia());
resetReleaseCheckSession();
});
describe("useReleaseCheck", () => {
it("flags hasUpdate when GitHub returns a newer tag", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: "v1.8.0" }),
});
it("fetches on launch and surfaces version, body, url, lastChecked", async () => {
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0", "## New", "https://x/r/1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
// Two ticks: onMounted -> await fetch -> applyLatest mutation -> render.
await nextTick();
await Promise.resolve();
await Promise.resolve();
await nextTick();
expect(fetchMock).toHaveBeenCalled();
// Verify it persisted to localStorage so a re-mount uses the cache.
const cached = JSON.parse(localStorage.getItem(STORAGE_KEY)!) as {
latest_version: string;
};
expect(cached.latest_version).toBe("1.8.0");
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(lastResult!.hasUpdate.value).toBe(true);
expect(lastResult!.latestVersion.value).toBe("1.8.0");
expect(lastResult!.releaseBody.value).toBe("## New");
expect(lastResult!.releaseUrl.value).toBe("https://x/r/1.8.0");
expect(lastResult!.lastChecked.value).not.toBeNull();
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).latest_version).toBe("1.8.0");
wrap.unmount();
});
it("uses cached value within TTL — no fetch", async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
checked_at: new Date().toISOString(),
latest_version: "1.6.5",
}),
);
const fetchMock = vi.fn();
it("does NOT fetch on launch when checkOnLaunch is off", async () => {
setActivePinia(createPinia());
useUiStore().setCheckOnLaunch(false);
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await settle();
expect(fetchMock).not.toHaveBeenCalled();
expect(lastResult!.hasUpdate.value).toBe(false);
wrap.unmount();
});
it("refetches once cache is older than 24h", async () => {
const stale = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ checked_at: stale, latest_version: "1.6.5" }),
);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: "v1.7.0" }),
});
it("paints cached value immediately, then refreshes", async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
checked_at: new Date().toISOString(),
latest_version: "1.6.5",
body: "old",
url: "https://x/r/1.6.5",
}));
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.9.0", "fresh", "https://x/r/1.9.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick(); // cache painted before fetch resolves
expect(lastResult!.latestVersion.value).toBe("1.6.5");
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1); // no 24h suppression
expect(lastResult!.latestVersion.value).toBe("1.9.0");
wrap.unmount();
});
it("session guard: a second mount does not refetch", async () => {
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const a = mount(host());
await settle();
const b = mount(host());
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
a.unmount(); b.unmount();
});
it("checkNow() refetches even within the same session", async () => {
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await settle();
expect(fetchMock).toHaveBeenCalledTimes(1);
await lastResult!.checkNow();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(lastResult!.checking.value).toBe(false);
wrap.unmount();
});
it("a legacy/malformed cache blob (no latest_version) does not throw", async () => {
localStorage.setItem(STORAGE_KEY, '{"v":"1.0"}'); // pre-rework shape
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v1.8.0"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await Promise.resolve();
expect(fetchMock).toHaveBeenCalled();
// Painting the bad cache must not crash; it degrades to no update.
expect(lastResult!.hasUpdate.value).toBe(false);
expect(lastResult!.latestVersion.value).toBeNull();
await settle();
// The launch refresh still recovers a real version.
expect(lastResult!.latestVersion.value).toBe("1.8.0");
wrap.unmount();
});
it("silently no-ops when GitHub is unreachable", async () => {
it("shares state across consumers: checkNow on one lights the other's pill", async () => {
// Two independent consumers (like the topbar + Settings). checkOnLaunch
// off so only the manual check drives state.
setActivePinia(createPinia());
useUiStore().setCheckOnLaunch(false);
const fetchMock = vi.fn().mockResolvedValue(releaseResponse("v2.10.1"));
vi.stubGlobal("fetch", fetchMock);
const topbar = mount(host());
const topbarResult = lastResult!;
const settings = mount(host());
const settingsResult = lastResult!;
await settle();
expect(topbarResult.hasUpdate.value).toBe(false);
// Manual check from the "settings" consumer...
await settingsResult.checkNow();
// ...must reflect in the "topbar" consumer without a remount/reload.
expect(topbarResult.hasUpdate.value).toBe(true);
expect(topbarResult.latestVersion.value).toBe("2.10.1");
topbar.unmount(); settings.unmount();
});
it("network failure leaves hasUpdate false and does not throw", async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error("offline"));
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await Promise.resolve();
// No exception bubbled; no cache written.
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
wrap.unmount();
});
it.each<[string, UpdateSeverity]>([
["2.0.0", "major"],
["1.8.0", "minor"],
["1.7.1", "patch"],
])("classifies %s over 1.7.0 as %s", async (latest, expectedSeverity) => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: `v${latest}` }),
});
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await Promise.resolve();
await Promise.resolve();
await nextTick();
expect(lastResult).not.toBeNull();
expect(lastResult!.hasUpdate.value).toBe(true);
expect(lastResult!.severity.value).toBe(expectedSeverity);
wrap.unmount();
});
it("severity is null when no update available", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: "v1.6.0" }),
});
vi.stubGlobal("fetch", fetchMock);
const wrap = mount(host());
await nextTick();
await Promise.resolve();
await Promise.resolve();
await nextTick();
await settle();
expect(lastResult!.hasUpdate.value).toBe(false);
expect(lastResult!.severity.value).toBeNull();
wrap.unmount();
});
});
+20
View File
@@ -28,6 +28,26 @@ describe("Input.vue", () => {
expect(wrap.get("input").attributes("aria-invalid")).toBeUndefined();
});
it("emits native step=any for number inputs (no off-grid validation popup)", () => {
// Regression: `min="0.01" step="0.1"` made the browser reject a default
// weight of 1 ("nearest valid values are 0.91 and 1.01"). The native
// step is validation-only here (arrows/wheel/chevrons use bump()), so it
// must be "any" to never grid-validate. bump() still uses props.step.
const wrap = mount(Input, {
props: { modelValue: 1, type: "number", step: 0.1, min: 0.01 },
});
const el = wrap.get("input").element as HTMLInputElement;
expect(el.getAttribute("step")).toBe("any");
expect(el.validity.stepMismatch).toBe(false);
});
it("leaves step untouched on non-number inputs", () => {
const wrap = mount(Input, { props: { modelValue: "x", step: 5 } });
// Non-number inputs never grid-validate, and `step` is meaningless on
// them, so nativeStep passes props.step straight through.
expect(wrap.get("input").attributes("step")).toBe("5");
});
it("numeric stepper rounds to step precision (no float fuzz)", async () => {
// Regression: clicking the up chevron on value=1 with step=0.1
// historically emitted 1.0000000000000002 (JS float math), which
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { renderReleaseNotes } from "../../utils/releaseNotes";
describe("renderReleaseNotes", () => {
it("escapes raw HTML so scripts cannot execute", () => {
const html = renderReleaseNotes("<script>alert(1)</script>");
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("renders headings", () => {
expect(renderReleaseNotes("## Features")).toContain("<h2>Features</h2>");
});
it("renders unordered lists", () => {
const html = renderReleaseNotes("- one\n- two");
expect(html).toContain("<ul>");
expect(html).toContain("<li>one</li>");
expect(html).toContain("<li>two</li>");
});
it("renders bold and inline code", () => {
const html = renderReleaseNotes("**big** and `code`");
expect(html).toContain("<strong>big</strong>");
expect(html).toContain("<code>code</code>");
});
it("renders links with safe rel/target and escapes the href", () => {
const html = renderReleaseNotes("[docs](https://example.com/a)");
expect(html).toContain('href="https://example.com/a"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener nofollow"');
});
it("neutralizes an href that tries to break out of the attribute", () => {
const html = renderReleaseNotes('[x](https://e.com/"><script>alert(1)</script>)');
expect(html).not.toContain("<script>");
});
it("renders fenced code blocks as escaped preformatted text", () => {
const html = renderReleaseNotes("```\nconst a = 1 < 2;\n```");
expect(html).toContain("<pre><code>");
expect(html).toContain("1 &lt; 2");
});
it("returns an empty-notes marker for blank input", () => {
expect(renderReleaseNotes("")).toContain("No release notes");
});
it("unwraps <details>/<summary>/<b> instead of showing literal tags", () => {
const md = "<details>\n<summary><b>📋 Full changelog</b> — details</summary>\n\n- one\n</details>";
const html = renderReleaseNotes(md);
expect(html).not.toContain("&lt;details&gt;");
expect(html).not.toContain("&lt;summary&gt;");
expect(html).not.toContain("<details>");
// Summary becomes a bold lead line; the list still renders.
expect(html).toContain("<strong>");
expect(html).toContain("Full changelog");
expect(html).toContain("<li>one</li>");
});
it("still neutralizes non-allowlisted raw HTML", () => {
const html = renderReleaseNotes("<img src=x onerror=alert(1)>");
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
});
it("renders a · separated link header as links", () => {
const html = renderReleaseNotes("[Docs](https://e.com/d) · [Discord](https://e.com/x)");
expect(html).toContain('href="https://e.com/d"');
expect(html).toContain('href="https://e.com/x"');
expect(html).toContain("·");
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
validateSubcatName,
validateRefGrammarName,
validateVariableName,
isValidVariableName,
} from "../../validation/names";
describe("validation/names", () => {
describe("validateSubcatName (re-exported engine-parity rule)", () => {
it("accepts a clean tag", () => {
expect(validateSubcatName("warm")).toBeNull();
});
it("rejects whitespace", () => {
expect(validateSubcatName("warm tone")).toMatch(/whitespace/i);
});
it("rejects a reserved word", () => {
expect(validateSubcatName("null")).toBeTruthy();
});
it("rejects a disallowed char", () => {
expect(validateSubcatName("a,b")).toBeTruthy();
});
});
describe("validateRefGrammarName", () => {
it("allows spaces (display names)", () => {
expect(validateRefGrammarName("My Wildcard")).toBeNull();
});
it("rejects each reserved ref-grammar char", () => {
for (const c of ["{", "}", ":", "#", "@", ","]) {
expect(validateRefGrammarName(`a${c}b`)).toContain(c);
}
});
});
describe("variable name", () => {
it("accepts a valid identifier", () => {
expect(isValidVariableName("my_var2")).toBe(true);
expect(validateVariableName("my_var2")).toBeNull();
});
it("rejects spaces / leading digit / empty", () => {
expect(isValidVariableName("my var")).toBe(false);
expect(validateVariableName("my var")).toMatch(/letters, digits/i);
expect(isValidVariableName("2fast")).toBe(false);
expect(isValidVariableName("")).toBe(false);
});
});
});
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { ref } from "vue";
import Settings from "../../views/Settings.vue";
import * as releaseCheck from "../../composables/useReleaseCheck";
import { useUiStore } from "../../stores/uiStore";
(globalThis as unknown as { __APP_VERSION__: string }).__APP_VERSION__ = "2.9.0";
(globalThis as unknown as { __APP_LICENSE__: string }).__APP_LICENSE__ = "MIT";
const checkNow = vi.fn();
beforeEach(() => {
localStorage.clear();
setActivePinia(createPinia());
vi.restoreAllMocks();
checkNow.mockClear();
vi.spyOn(releaseCheck, "useReleaseCheck").mockReturnValue({
current: "2.9.0",
latestVersion: ref("2.10.0"),
hasUpdate: ref(true),
severity: ref("minor"),
releaseBody: ref(null),
releaseUrl: ref(null),
lastChecked: ref("2026-07-21T10:00:00.000Z"),
checking: ref(false),
checkNow,
} as ReturnType<typeof releaseCheck.useReleaseCheck>);
});
function mountSettings() {
return mount(Settings, {
global: { stubs: { Icon: true, BrowserPrefsCard: true, DatabaseCard: true } },
});
}
describe("Settings Updates card", () => {
it("toggling check-on-launch calls setCheckOnLaunch", async () => {
const wrap = mountSettings();
const ui = useUiStore();
const spy = vi.spyOn(ui, "setCheckOnLaunch");
// The Toggle's clickable control is an inner <button role="switch">;
// the data-test attribute falls through to the outer <label>.
await wrap.get('[data-test="settings-check-on-launch"]').get("button").trigger("click");
expect(spy).toHaveBeenCalledWith(false);
});
it("Check now button calls checkNow", async () => {
const wrap = mountSettings();
await wrap.get('[data-test="settings-check-now"]').trigger("click");
await flushPromises();
expect(checkNow).toHaveBeenCalled();
});
it("shows an update-available status line", () => {
const wrap = mountSettings();
expect(wrap.get('[data-test="settings-update-status"]').text()).toContain("2.10.0");
});
});
+13 -11
View File
@@ -7,6 +7,7 @@ import Checkbox from "../components/ui/Checkbox.vue";
import Input from "../components/ui/Input.vue";
import Modal from "../components/ui/Modal.vue";
import { useCascadeApply, type CascadeApplyRequest } from "./useCascadeApply";
import { validateSubcatName, validateRefGrammarName, validateVariableName } from "../validation/names";
interface Props {
open: boolean;
@@ -33,18 +34,19 @@ const affected = ref<Array<{ kind: string; id: string; name: string }>>([]);
const loading = ref(true);
const error = ref<string>("");
/** Names that flow into the @{uuid#name:subcat} grammar must avoid
* the structural chars (and comma, since subcats are comma-listed).
* Mirrors `wp_api/_validators.py:REF_GRAMMAR_FORBIDDEN_CHARS`. */
/** Validate the typed new name against the SAME canonical validator its
* matching "add"/"create" surface uses (see `validation/names`), keyed by
* what kind of thing is being renamed. Reusing the shared validators keeps
* rename and create in lockstep — previously rename hand-rolled a weaker
* check that let invalid sub-category names (e.g. with spaces) through
* until save (issue #7), and variable renames weren't validated at all. */
const nameError = computed<string | null>(() => {
if (props.kind !== "wildcard" && props.kind !== "subcategory") return null;
if (props.kind === "subcategory" && newName.value === "null") {
return "\"null\" is reserved by the @{uuid:subcat} filter";
}
const bad = newName.value.match(/[{}:#@,]/);
if (bad) {
return `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`;
}
// Empty is gated by `canConfirm` (Rename button stays disabled) — don't
// surface an error while the field is simply blank.
if (!newName.value) return null;
if (props.kind === "subcategory") return validateSubcatName(newName.value);
if (props.kind === "combine_output_var") return validateVariableName(newName.value);
if (props.kind === "wildcard") return validateRefGrammarName(newName.value);
return null;
});
@@ -139,6 +139,52 @@ describe("CascadeRenameDialog", () => {
wrap.unmount();
});
it("blocks a subcategory rename to a name containing a space (issue #7)", async () => {
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
const wrap = mount(CascadeRenameDialog, {
props: { open: true, kind: "subcategory", id: "11111111", initialName: "warm",
extra: { subcat_name: "warm" } },
attachTo: document.body,
});
await flushPromises();
const input = findNameInput();
input!.value = "warm tone"; // contains a space — invalid subcat name
input!.dispatchEvent(new Event("input"));
await flushPromises();
// Validation error is surfaced.
const err = document.body.querySelector(".wp-cascade-rename__error");
expect(err?.textContent ?? "").toContain("whitespace");
// Confirm is disabled and clicking it does not apply the rename.
const btn = document.body.querySelector("[data-test='cascade-rename-confirm']") as HTMLButtonElement;
expect(btn.disabled || btn.getAttribute("aria-disabled") === "true").toBe(true);
btn.click();
await flushPromises();
expect(mockComposable.apply).not.toHaveBeenCalled();
wrap.unmount();
});
it("blocks a combine output-var rename to a non-identifier name", async () => {
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
const wrap = mount(CascadeRenameDialog, {
props: { open: true, kind: "combine_output_var", id: "22222222", initialName: "outfit",
extra: { old_name: "outfit" } },
attachTo: document.body,
});
await flushPromises();
const input = findNameInput();
input!.value = "out fit"; // space — invalid $identifier
input!.dispatchEvent(new Event("input"));
await flushPromises();
const err = document.body.querySelector(".wp-cascade-rename__error");
expect(err?.textContent ?? "").toContain("letters, digits");
const btn = document.body.querySelector("[data-test='cascade-rename-confirm']") as HTMLButtonElement;
expect(btn.disabled || btn.getAttribute("aria-disabled") === "true").toBe(true);
btn.click();
await flushPromises();
expect(mockComposable.apply).not.toHaveBeenCalled();
wrap.unmount();
});
it("rename button disabled when name empty", async () => {
mockComposable.dryRun.mockResolvedValue({ ok: true, affected_count: 0, affected_entities: [] });
const wrap = mount(CascadeRenameDialog, {
@@ -0,0 +1,59 @@
<script setup lang="ts">
/**
* BulkDeleteToolbar — minimal bulk-action bar for editors whose rows have
* nothing to bulk-set (fixed-values, constraint exceptions, derivation
* rules): just "N selected" + Delete + Clear. The richer wildcard bar
* (apply/remove sub-category, set weight) is SelectionToolbar.
*
* Presentational: emits `delete-selected` / `clear`; the host mutates rows +
* tracks dirty.
*/
import Button from "./ui/Button.vue";
defineProps<{
count: number;
/** Plural noun for the Delete label, e.g. "values" → "Delete 3 values". */
noun?: string;
}>();
const emit = defineEmits<{
(e: "delete-selected"): void;
(e: "clear"): void;
}>();
</script>
<template>
<div class="wpc-deltoolbar" role="toolbar" aria-label="Bulk actions">
<span class="wpc-deltoolbar__count">{{ count }} selected</span>
<div class="wpc-deltoolbar__spacer"></div>
<Button
variant="danger"
size="sm"
icon="pi-trash"
:disabled="count === 0"
data-test="bulk-delete"
@click="emit('delete-selected')"
>Delete{{ noun ? ` ${count} ${noun}` : "" }}</Button>
<Button variant="ghost" size="sm" @click="emit('clear')">Clear</Button>
</div>
</template>
<style scoped>
.wpc-deltoolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border: 1px solid color-mix(in oklab, var(--wp-accent) 40%, transparent);
border-radius: 8px;
background: color-mix(in oklab, var(--wp-accent) 12%, var(--wp-bg-2));
flex-wrap: wrap;
}
.wpc-deltoolbar__count {
font-size: 13px;
font-weight: 600;
color: var(--wp-text);
white-space: nowrap;
}
.wpc-deltoolbar__spacer { flex: 1; }
</style>
+136 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import Card from "./ui/Card.vue";
import Button from "./ui/Button.vue";
import Select from "./ui/Select.vue";
@@ -40,6 +40,10 @@ interface Props {
uuidToTagGroups?: Map<string, Record<string, string[]>>;
/** Default-collapsed when many rules exist (set by editor). */
defaultCollapsed?: boolean;
/** Broadcast from the editor's Collapse-all / Expand-all buttons. When
* `nonce` changes, the card adopts `collapsed` — but the user can still
* toggle any card individually afterwards. */
collapseCommand?: { nonce: number; collapsed: boolean };
}
const props = withDefaults(defineProps<Props>(), {
@@ -57,6 +61,50 @@ const props = withDefaults(defineProps<Props>(), {
const collapsed = ref<boolean>(props.defaultCollapsed);
function toggleCollapsed() { collapsed.value = !collapsed.value; }
// Editor-driven Collapse-all / Expand-all: adopt the broadcast state whenever
// its nonce ticks (individual toggles still work afterwards).
watch(
() => props.collapseCommand?.nonce,
() => {
if (props.collapseCommand) collapsed.value = props.collapseCommand.collapsed;
},
);
// Per-branch collapse. Branch index → collapsed; the ELSE clause is keyed as
// -1. Collapsing hides the condition/action editors and shows a one-line
// summary in the branch head.
/** Every branch key for this rule (IF/ELIF indices + `-1` for ELSE). */
function allBranchKeys(): number[] {
const r = props.modelValue;
const keys = r.branches.map((_, i) => i);
if (r.else) keys.push(-1);
return keys;
}
// Branches start COLLAPSED on open (matches the request for a compact
// editor). Newly-added branches aren't in this set, so they open expanded
// for immediate editing.
const collapsedBranches = ref<Set<number>>(new Set(allBranchKeys()));
function toggleBranch(bi: number): void {
const next = new Set(collapsedBranches.value);
if (next.has(bi)) next.delete(bi);
else next.add(bi);
collapsedBranches.value = next;
}
function isBranchCollapsed(bi: number): boolean {
return collapsedBranches.value.has(bi);
}
/** Collapse / expand every branch in THIS rule (the inline per-rule control). */
function collapseAllBranches(): void {
collapsedBranches.value = new Set(allBranchKeys());
}
function expandAllBranches(): void {
collapsedBranches.value = new Set();
}
/** Compact `$cond → $target` peek for a collapsed branch head. */
function branchPeek(cvar: string, tvar: string): string {
return `${cvar ? "$" + cvar : "$?"} → ${tvar ? "$" + tvar : "$?"}`;
}
const emit = defineEmits<{
"update:modelValue": [value: DerivationRule];
remove: [];
@@ -287,6 +335,26 @@ const branchCount = computed(() => rule.value.branches.length);
<span v-if="rule.else"> + ELSE</span>
</span>
<span class="spacer" />
<!-- Inline per-rule collapse/expand of THIS rule's conditions. Only
useful when the rule card itself is open. -->
<span v-if="!collapsed" class="rule-head__branch-ctrls">
<Button
icon="pi-angle-double-up"
variant="ghost"
size="sm"
:aria-label="`Collapse all conditions in rule ${ruleNumber}`"
:data-test="`collapse-branches-${index}`"
@click.stop="collapseAllBranches"
/>
<Button
icon="pi-angle-double-down"
variant="ghost"
size="sm"
:aria-label="`Expand all conditions in rule ${ruleNumber}`"
:data-test="`expand-branches-${index}`"
@click.stop="expandAllBranches"
/>
</span>
<Button
icon="pi-trash"
variant="ghost"
@@ -306,9 +374,24 @@ const branchCount = computed(() => rule.value.branches.length);
:data-test="`branch-${index}-${bi}`"
>
<div class="branch-head">
<button
type="button"
class="branch-collapse"
:aria-expanded="!isBranchCollapsed(bi)"
:aria-label="`${isBranchCollapsed(bi) ? 'Expand' : 'Collapse'} branch ${bi + 1} of rule ${ruleNumber}`"
:data-test="`toggle-branch-${index}-${bi}`"
@click="toggleBranch(bi)"
>
<i :class="isBranchCollapsed(bi) ? 'pi pi-chevron-right' : 'pi pi-chevron-down'" aria-hidden="true" />
</button>
<span class="branch-tag" :data-kind="bi === 0 ? 'if' : 'elif'">
{{ bi === 0 ? "IF" : "ELIF" }}
</span>
<span
v-if="isBranchCollapsed(bi)"
class="branch-peek"
:data-test="`branch-peek-${index}-${bi}`"
>{{ branchPeek(branch.condition.var, branch.action.target_var) }}</span>
<span class="spacer" />
<Button
v-if="bi > 0"
@@ -321,6 +404,7 @@ const branchCount = computed(() => rule.value.branches.length);
/>
</div>
<div v-show="!isBranchCollapsed(bi)" class="branch-body">
<!-- Compact grid (Proposal B, 2026-05-09 cycle):
row 1 = WHEN var + op on a single line so the prose
reads "When $age equals". Row 2 = condition value below
@@ -476,12 +560,28 @@ const branchCount = computed(() => rule.value.branches.length);
<div class="dvr-hint" :data-test="`act-hint-${index}-${bi}`">
{{ SUPPORTED_SYNTAX_HINT }}
</div>
</div>
</div>
<!-- ELSE branch -->
<div v-if="rule.else" class="branch branch--else" data-kind="else" :data-test="`branch-else-${index}`">
<div class="branch-head">
<button
type="button"
class="branch-collapse"
:aria-expanded="!isBranchCollapsed(-1)"
:aria-label="`${isBranchCollapsed(-1) ? 'Expand' : 'Collapse'} ELSE branch of rule ${ruleNumber}`"
:data-test="`toggle-branch-else-${index}`"
@click="toggleBranch(-1)"
>
<i :class="isBranchCollapsed(-1) ? 'pi pi-chevron-right' : 'pi pi-chevron-down'" aria-hidden="true" />
</button>
<span class="branch-tag" data-kind="else">ELSE</span>
<span
v-if="isBranchCollapsed(-1)"
class="branch-peek"
:data-test="`branch-peek-else-${index}`"
>→ {{ rule.else.action.target_var ? "$" + rule.else.action.target_var : "$?" }}</span>
<span class="spacer" />
<Button
icon="pi-times"
@@ -492,6 +592,7 @@ const branchCount = computed(() => rule.value.branches.length);
@click="removeElse"
/>
</div>
<div v-show="!isBranchCollapsed(-1)" class="branch-body">
<div class="dvr-grid dvr-grid--then">
<span class="dvr-label">Then</span>
<div class="dvr-var-wrap" :data-test="`else-var-wrap-${index}`">
@@ -541,6 +642,7 @@ const branchCount = computed(() => rule.value.branches.length);
<div class="dvr-hint" :data-test="`else-hint-${index}`">
{{ SUPPORTED_SYNTAX_HINT }}
</div>
</div>
</div>
</div>
@@ -600,6 +702,7 @@ const branchCount = computed(() => rule.value.branches.length);
color: var(--wp-text-muted, #9ca3af);
}
.spacer { flex: 1; }
.rule-head__branch-ctrls { display: inline-flex; gap: 2px; }
.branches {
display: flex;
@@ -627,6 +730,38 @@ const branchCount = computed(() => rule.value.branches.length);
align-items: center;
gap: var(--wp-space-4);
}
/* Per-branch collapse chevron — mirrors the rule-card head toggle. */
.branch-collapse {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
background: transparent;
color: var(--wp-text-muted);
cursor: pointer;
border-radius: 4px;
}
.branch-collapse:hover { background: var(--wp-bg-3); color: var(--wp-text); }
.branch-collapse:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
.branch-collapse i { font-size: var(--wp-text-xs); }
/* One-line peek shown in a collapsed branch head. */
.branch-peek {
font-family: var(--wp-font-mono, ui-monospace, monospace);
font-size: var(--wp-text-xs);
color: var(--wp-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.branch-body {
display: flex;
flex-direction: column;
gap: var(--wp-space-4);
margin-top: var(--wp-space-4);
}
.branch-tag {
font-family: var(--wp-font-mono, ui-monospace, monospace);
+77 -11
View File
@@ -8,17 +8,31 @@
* `tags` is a new sub-category — emitted via apply-tag too; the host decides to
* auto-create it (in Ungrouped).
*/
import { computed, ref } from "vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import Button from "./ui/Button.vue";
import Input from "./ui/Input.vue";
import { UNGROUPED_HUE } from "../../components/shared/axis-color";
interface Props {
count: number;
/** Existing sub-category tags, for the apply / remove menus. */
tags: string[];
/** Tags already on EVERY selected row — hidden from the Apply menu since
* applying one there is a no-op. Absent (undefined) = exclude nothing. */
commonTags?: string[];
/** Tags present on ≥1 selected row — the Remove menu's candidate list.
* Absent (undefined) falls back to `tags`; an explicit empty array means
* the selection carries no tags, so Remove is empty. */
presentTags?: string[];
/** tag → axis hue, so menu chips match the pill/chip colours elsewhere. */
tagHues?: Record<string, string>;
}
const props = defineProps<Props>();
function hueOf(tag: string): string {
return props.tagHues?.[tag] ?? UNGROUPED_HUE;
}
const emit = defineEmits<{
(e: "apply-tag", tag: string): void;
(e: "remove-tag", tag: string): void;
@@ -40,11 +54,38 @@ function toggle(menu: Exclude<Menu, null>) {
}
function close() { openMenu.value = null; }
const filteredTags = computed(() => {
// Close the menu when the page scrolls behind it. The toolbar lives in a
// sticky header, so an open menu would otherwise hover in place while the
// option list scrolls under it — the "can't scroll the page" trap. Scrolls
// that originate INSIDE the menu's own list are ignored (that's the list
// scrolling itself; `overscroll-behavior: contain` keeps it from chaining).
function onDocScroll(e: Event): void {
const t = e.target;
if (t instanceof Element && t.closest(".wpc-seltoolbar__menulist")) return;
close();
}
watch(openMenu, (open) => {
if (open) document.addEventListener("scroll", onDocScroll, true);
else document.removeEventListener("scroll", onDocScroll, true);
});
onBeforeUnmount(() => document.removeEventListener("scroll", onDocScroll, true));
// Apply menu: every tag EXCEPT those already on all selected rows, so the
// list only offers tags that would actually change something. Then narrowed
// by the type-to-filter box.
const applyList = computed(() => {
const excluded = new Set(props.commonTags ?? []);
const q = applyFilter.value.trim().toLowerCase();
const list = [...props.tags].sort((a, b) => a.localeCompare(b));
const list = [...props.tags]
.filter((t) => !excluded.has(t))
.sort((a, b) => a.localeCompare(b));
return q ? list.filter((t) => t.toLowerCase().includes(q)) : list;
});
// Remove menu: only tags the selection actually carries. `presentTags`
// absent (undefined) falls back to all tags; an explicit [] means none.
const removeList = computed(() =>
[...(props.presentTags ?? props.tags)].sort((a, b) => a.localeCompare(b)),
);
// "new tag" = typed name not matching any existing tag (case-insensitive).
const newTagIsNovel = computed(() => {
const n = newTag.value.trim();
@@ -96,13 +137,14 @@ function applyWeight() {
</button>
<div class="wpc-seltoolbar__menulist">
<button
v-for="t in filteredTags"
v-for="t in applyList"
:key="t"
type="button"
class="wpc-seltoolbar__menuitem"
:style="{ '--chip-hue': hueOf(t) }"
@click="applyTag(t)"
>{{ t }}</button>
<div v-if="!filteredTags.length && !newTagIsNovel" class="wpc-seltoolbar__menuempty">No sub-categories yet</div>
><span class="wpc-seltoolbar__swatch" aria-hidden="true"></span>{{ t }}</button>
<div v-if="!applyList.length && !newTagIsNovel" class="wpc-seltoolbar__menuempty">Every selected row already has all sub-categories</div>
</div>
</div>
</div>
@@ -115,13 +157,14 @@ function applyWeight() {
<div v-if="openMenu === 'remove'" class="wpc-seltoolbar__menu" role="menu">
<div class="wpc-seltoolbar__menulist">
<button
v-for="t in filteredTags"
v-for="t in removeList"
:key="t"
type="button"
class="wpc-seltoolbar__menuitem"
:style="{ '--chip-hue': hueOf(t) }"
@click="removeTag(t)"
>{{ t }}</button>
<div v-if="!filteredTags.length" class="wpc-seltoolbar__menuempty">No sub-categories</div>
><span class="wpc-seltoolbar__swatch" aria-hidden="true"></span>{{ t }}</button>
<div v-if="!removeList.length" class="wpc-seltoolbar__menuempty">Selection has no sub-categories</div>
</div>
</div>
</div>
@@ -208,8 +251,16 @@ function applyWeight() {
flex-direction: column;
max-height: 220px;
overflow-y: auto;
/* Keep wheel/trackpad scrolling inside the list — at the top/bottom edge
* it must NOT chain to the editor body behind (that "nudged the page and
* you couldn't recenter"). */
overscroll-behavior: contain;
}
.wpc-seltoolbar__menuitem {
--chip-hue: var(--wp-text-dim, var(--wp-text3));
display: flex;
align-items: center;
gap: 8px;
text-align: left;
padding: 6px 8px;
border: none;
@@ -219,8 +270,23 @@ function applyWeight() {
font-size: 13px;
cursor: pointer;
}
.wpc-seltoolbar__menuitem:hover { background: var(--wp-bg-3); }
.wpc-seltoolbar__menuitem--new { color: var(--wp-accent); font-weight: 600; }
/* Colour swatch mirroring the axis hue of the pills / option-row chips so a
* sub-category reads with the same colour in the menu as everywhere else. */
.wpc-seltoolbar__swatch {
flex: 0 0 auto;
width: 10px;
height: 10px;
border-radius: 3px;
border: 1px solid color-mix(in srgb, var(--chip-hue) 55%, var(--wp-border));
background: color-mix(in srgb, var(--chip-hue) 40%, transparent);
}
.wpc-seltoolbar__menuitem:hover {
background: color-mix(in srgb, var(--chip-hue) 14%, var(--wp-bg-3));
}
.wpc-seltoolbar__menuitem--new {
color: var(--wp-accent);
font-weight: 600;
}
.wpc-seltoolbar__menuempty { padding: 6px 8px; font-size: 12px; color: var(--wp-text-muted); }
.wpc-seltoolbar__backdrop {
position: fixed;
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
/**
* Update-available dialog. Opened from the topbar update pill. Shows the
* current → latest version, a rendered changelog, and drives the update
* through ComfyUI Manager (Update Now → installing → staged → explicit
* Restart). Falls back to guidance when Manager is absent or blocks the
* call (403). Reboot is always a separate, explicit user click.
*/
import { computed, ref, watch } from "vue";
import Modal from "./ui/Modal.vue";
import Button from "./ui/Button.vue";
import { useReleaseCheck } from "../composables/useReleaseCheck";
import { useComfyManagerUpdate, type ManagerAvailability } from "../composables/useComfyManagerUpdate";
import { renderReleaseNotes } from "../utils/releaseNotes";
import { GITHUB_REPO } from "../config/links";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: "close"): void }>();
const { current, latestVersion, severity, releaseBody, releaseUrl } = useReleaseCheck();
const { phase, errorKind, runUpdate, reboot, probe, managerUiUrl } = useComfyManagerUpdate();
const availability = ref<ManagerAvailability | null>(null);
// Probe Manager once each time the dialog opens so we know whether to
// show Update Now or the guidance fallback.
watch(
() => props.open,
async (isOpen) => {
if (isOpen && availability.value === null) {
availability.value = await probe();
}
},
{ immediate: true },
);
const notesHtml = computed(() => renderReleaseNotes(releaseBody.value ?? ""));
const fullChangelogUrl = computed(() => releaseUrl.value ?? `${GITHUB_REPO}/releases`);
const severityLabel = computed(() =>
severity.value === "major" ? "Major update"
: severity.value === "minor" ? "Minor update"
: "Patch update",
);
const severityClass = computed(() =>
severity.value === "major" ? "wpc-upd__badge--warn" : "wpc-upd__badge--accent",
);
// Fallback whenever Manager is missing OR the update errored with a
// forbidden/failed kind.
const showFallback = computed(
() => availability.value === "absent" || phase.value === "error",
);
function onClose(): void { emit("close"); }
function onModalOpen(v: boolean): void { if (!v) onClose(); }
/** Kick off the update to the exact version the check found. Passing the
* concrete target (not "latest") lets the composable pin it + guard against
* a downgrade. */
function onUpdateNow(): void {
if (latestVersion.value) runUpdate(latestVersion.value);
}
</script>
<template>
<Modal
:open="open"
title="Update available"
size="md"
:close-on-backdrop="phase !== 'installing'"
@update:open="onModalOpen"
>
<div class="wpc-upd">
<div class="wpc-upd__head">
<span class="wpc-upd__ver">v{{ current }}</span>
<span class="wpc-upd__arrow" aria-hidden="true">→</span>
<span class="wpc-upd__ver wpc-upd__ver--new">v{{ latestVersion }}</span>
<span class="wpc-upd__badge" :class="severityClass">{{ severityLabel }}</span>
</div>
<!-- Rendered release notes. `notesHtml` is produced by
renderReleaseNotes, which escapes before transforming, so this
is the one sanctioned v-html. -->
<div class="wpc-relnotes" data-test="update-notes" v-html="notesHtml" />
<a
class="wpc-upd__full"
:href="fullChangelogUrl"
target="_blank"
rel="noopener"
data-test="update-full-changelog"
>Full changelog →</a>
<!-- Guidance fallback: Manager missing or blocked. -->
<div v-if="showFallback" class="wpc-upd__fallback" data-test="update-fallback">
<p class="wpc-upd__fallback-title">Update via ComfyUI Manager</p>
<p class="wpc-upd__fallback-body">
{{ errorKind === "forbidden"
? "ComfyUI Manager blocked the in-app update (its security level). Update it from the Manager UI, or pull manually:"
: "Automatic update isn't available here. Update from the ComfyUI Manager UI, or pull manually:" }}
</p>
<a class="wpc-upd__manager-link" :href="managerUiUrl" target="_blank" rel="noopener">Open ComfyUI Manager</a>
<pre class="wpc-upd__cmd"><code>cd custom_nodes/ComfyUI-Wildcard-Pipeline
git pull
# then restart ComfyUI</code></pre>
</div>
<p v-else-if="phase === 'staged'" class="wpc-upd__staged" data-test="update-staged">
Update staged. Restart ComfyUI to apply.
</p>
<p v-else-if="phase === 'restarting'" class="wpc-upd__staged" data-test="update-restarting">
Restarting ComfyUI… reopen the browser tab in a moment.
</p>
</div>
<template #footer>
<template v-if="showFallback">
<Button variant="secondary" data-test="update-later" @click="onClose">Close</Button>
</template>
<template v-else-if="phase === 'idle'">
<Button variant="secondary" data-test="update-later" @click="onClose">Later</Button>
<Button variant="primary" data-test="update-now" @click="onUpdateNow">Update Now</Button>
</template>
<template v-else-if="phase === 'installing'">
<Button variant="primary" :loading="true" :disabled="true" data-test="update-installing">Updating…</Button>
</template>
<template v-else-if="phase === 'staged'">
<Button variant="secondary" data-test="update-later" @click="onClose">Later</Button>
<Button variant="primary" data-test="update-restart" @click="reboot">Restart ComfyUI</Button>
</template>
<template v-else-if="phase === 'restarting'">
<Button variant="secondary" data-test="update-later" @click="onClose">Close</Button>
</template>
</template>
</Modal>
</template>
<style scoped>
.wpc-upd { display: flex; flex-direction: column; gap: var(--wp-space-4); }
.wpc-upd__head { display: flex; align-items: center; gap: var(--wp-space-3); flex-wrap: wrap; }
.wpc-upd__ver { font: 600 var(--wp-text-sm)/1 var(--wp-font-mono, monospace); color: var(--wp-text-muted); }
.wpc-upd__ver--new { color: var(--wp-accent-text); }
.wpc-upd__arrow { color: var(--wp-text-dim); }
.wpc-upd__badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; padding: 2px 7px; border-radius: 999px; }
.wpc-upd__badge--accent { color: var(--wp-accent-text); background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent); }
.wpc-upd__badge--warn { color: var(--wp-warn, #f59e0b); background: color-mix(in oklab, var(--wp-warn, #f59e0b) 18%, transparent); }
.wpc-relnotes { max-height: 320px; overflow-y: auto; font-size: var(--wp-text-sm); line-height: 1.55; color: var(--wp-text-muted); background: var(--wp-bg-2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-4); }
.wpc-relnotes :deep(h1), .wpc-relnotes :deep(h2), .wpc-relnotes :deep(h3) { color: var(--wp-text); margin: var(--wp-space-3) 0 var(--wp-space-2); font-size: var(--wp-text-sm); font-weight: 700; }
.wpc-relnotes :deep(ul) { margin: var(--wp-space-2) 0; padding-left: var(--wp-space-6); }
.wpc-relnotes :deep(li) { margin: 2px 0; }
.wpc-relnotes :deep(code) { font-family: var(--wp-font-mono, monospace); background: var(--wp-bg-1); padding: 1px 4px; border-radius: 3px; }
.wpc-relnotes :deep(pre) { background: var(--wp-bg-1); padding: var(--wp-space-3); border-radius: var(--wp-radius); overflow-x: auto; }
.wpc-relnotes :deep(a) { color: var(--wp-accent-text); }
.wpc-upd__full { font-size: var(--wp-text-sm); color: var(--wp-accent-text); text-decoration: none; }
.wpc-upd__full:hover { color: var(--wp-text); }
.wpc-upd__fallback { border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-4); display: flex; flex-direction: column; gap: var(--wp-space-3); }
.wpc-upd__fallback-title { font-weight: 700; color: var(--wp-text); margin: 0; }
.wpc-upd__fallback-body { font-size: var(--wp-text-sm); color: var(--wp-text-muted); margin: 0; }
.wpc-upd__manager-link { font-size: var(--wp-text-sm); color: var(--wp-accent-text); text-decoration: none; }
.wpc-upd__cmd { background: var(--wp-bg-1); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); padding: var(--wp-space-3); font-size: var(--wp-text-sm); overflow-x: auto; margin: 0; }
.wpc-upd__staged { font-size: var(--wp-text-sm); color: var(--wp-accent-text); margin: 0; }
</style>
+41 -11
View File
@@ -1,26 +1,56 @@
<script setup lang="ts">
import { useSlots } from "vue";
import { useSlots, ref, onMounted, onBeforeUnmount } from "vue";
interface Props {
title?: string;
subtitle?: string;
padding?: boolean;
/** Stick the card header (and its `subheader` slot, e.g. a bulk-edit
* toolbar) to the top of the nearest scroll container so its actions stay
* reachable when scrolling a long list — new rows always land at the
* bottom, so the toolbar must follow. Publishes the measured height of the
* whole stuck region as `--wp-card-header-h` so a sticky `<thead>` inside
* the body can offset itself right below it (updates as the subheader
* toolbar appears/disappears or density changes). */
stickyHeader?: boolean;
}
withDefaults(defineProps<Props>(), { padding: true });
const props = withDefaults(defineProps<Props>(), { padding: true, stickyHeader: false });
const slots = useSlots();
const root = ref<HTMLElement | null>(null);
const stickyEl = ref<HTMLElement | null>(null);
let ro: ResizeObserver | null = null;
function syncStickyHeight(): void {
if (!root.value || !stickyEl.value) return;
const h = Math.round(stickyEl.value.getBoundingClientRect().height);
root.value.style.setProperty("--wp-card-header-h", `${h}px`);
}
onMounted(() => {
if (!props.stickyHeader || !stickyEl.value) return;
syncStickyHeight();
if (typeof ResizeObserver !== "undefined") {
ro = new ResizeObserver(syncStickyHeight);
ro.observe(stickyEl.value);
}
});
onBeforeUnmount(() => { ro?.disconnect(); ro = null; });
</script>
<template>
<section class="wp-card">
<header v-if="title || slots.actions" class="wp-card__header">
<div class="wp-card__title-wrap">
<h2 v-if="title" class="wp-card__title">{{ title }}</h2>
<p v-if="subtitle" class="wp-card__subtitle">{{ subtitle }}</p>
</div>
<span class="wp-spacer" />
<slot name="actions" />
</header>
<section ref="root" class="wp-card" :class="{ 'wp-card--sticky-header': stickyHeader }">
<div ref="stickyEl" class="wp-card__sticky">
<header v-if="title || slots.actions" class="wp-card__header">
<div class="wp-card__title-wrap">
<h2 v-if="title" class="wp-card__title">{{ title }}</h2>
<p v-if="subtitle" class="wp-card__subtitle">{{ subtitle }}</p>
</div>
<span class="wp-spacer" />
<slot name="actions" />
</header>
<slot name="subheader" />
</div>
<div :class="padding ? 'wp-card__body' : ''">
<slot />
</div>
+12 -1
View File
@@ -35,6 +35,17 @@ const inputClasses = computed(() => [
]);
const isNumber = computed(() => props.type === "number");
/** Native `step` here is ONLY used by the browser for HTML validation —
* arrow keys, the wheel, and the custom chevrons all route through `bump()`
* (native spin buttons are hidden via CSS), and `bump()` reads `props.step`
* itself. A concrete step (e.g. `0.1`) combined with an off-grid `min` or
* value makes the browser reject legitimate input: a default weight of `1`
* with `min="0.01" step="0.1"` popped up "Please select a valid value. The
* two nearest valid values are 0.91 and 1.01." So emit `step="any"` for
* number inputs — the browser never grid-validates, while `bump()` keeps
* stepping by `props.step`. */
const nativeStep = computed(() => (isNumber.value ? "any" : props.step));
const wrapped = computed(() => Boolean(props.icon || props.addon) || isNumber.value);
function onInput(e: Event) {
@@ -122,7 +133,7 @@ function bump(direction: 1 | -1) {
:aria-invalid="error || undefined"
:min="min"
:max="max"
:step="step"
:step="nativeStep"
@input="onInput"
@keydown="onKeydown"
@wheel="onWheel"
@@ -0,0 +1,93 @@
import { computed, ref, type ComputedRef, type Ref } from "vue";
/**
* Generic multi-row selection for an editor's bulk mode (select + delete).
*
* The editor owns its rows; this owns only the "which ids are picked" state
* plus the on/off `active` flag. Pass a `getIds` that returns the CURRENT
* selectable row ids — every derived value reads through it, so selection
* stays correct even as rows are added or removed (a deleted id simply stops
* counting; no stale-id bookkeeping needed).
*
* Mirrors the hand-rolled selection in WildcardEditor so the three simpler
* editors (fixed-values, constraint exceptions, derivation rules) share one
* implementation instead of copying it three times.
*/
export interface BulkSelection {
/** Bulk mode on/off. */
active: Ref<boolean>;
/** How many currently-present rows are selected. */
count: ComputedRef<number>;
/** True when every selectable row is selected (drives the header check). */
allSelected: ComputedRef<boolean>;
/** True when ≥1 (but maybe not all) rows are selected (indeterminate dash). */
someSelected: ComputedRef<boolean>;
isSelected: (id: string) => boolean;
toggle: (id: string) => void;
/** Select all when not all selected; otherwise clear. */
toggleAll: () => void;
clear: () => void;
/** Enter/leave bulk mode; leaving clears the selection. */
toggleMode: () => void;
exit: () => void;
/** The selected ids that still exist, for the delete handler. */
selectedIds: () => string[];
}
export function useBulkSelection(getIds: () => string[]): BulkSelection {
const active = ref(false);
const selected = ref<Set<string>>(new Set());
const count = computed(
() => getIds().filter((id) => selected.value.has(id)).length,
);
const allSelected = computed(() => {
const ids = getIds();
return ids.length > 0 && ids.every((id) => selected.value.has(id));
});
const someSelected = computed(() =>
getIds().some((id) => selected.value.has(id)),
);
function isSelected(id: string): boolean {
return selected.value.has(id);
}
function toggle(id: string): void {
const next = new Set(selected.value);
if (next.has(id)) next.delete(id);
else next.add(id);
selected.value = next;
}
function toggleAll(): void {
if (allSelected.value) selected.value = new Set();
else selected.value = new Set(getIds());
}
function clear(): void {
selected.value = new Set();
}
function exit(): void {
active.value = false;
clear();
}
function toggleMode(): void {
if (active.value) exit();
else active.value = true;
}
function selectedIds(): string[] {
return getIds().filter((id) => selected.value.has(id));
}
return {
active,
count,
allSelected,
someSelected,
isSelected,
toggle,
toggleAll,
clear,
toggleMode,
exit,
selectedIds,
};
}
@@ -0,0 +1,164 @@
/**
* Drives an in-place update through ComfyUI Manager's same-origin HTTP
* API instead of reimplementing git. Flow: queue an install-to-latest
* for our registry id, start the queue, poll status to completion, then
* (on an explicit user click) reboot ComfyUI.
*
* Security note: `/manager/queue/install` and `/manager/reboot` are gated
* by ComfyUI Manager's `security_level` (default `normal` → allowed;
* `strong` → 403). A GET probe can't see that gate, so a 403 surfaces as
* `errorKind: "forbidden"` from `runUpdate`; the dialog shows the same
* guidance fallback it shows when Manager is absent.
*/
import { ref } from "vue";
import { COMFY_REGISTRY_ID } from "../config/links";
export type ManagerAvailability = "available" | "absent";
export type UpdatePhase = "idle" | "installing" | "staged" | "restarting" | "error";
export type UpdateErrorKind = "forbidden" | "failed" | null;
const STATUS_POLL_MS = 1000;
const STATUS_MAX_POLLS = 120; // ~2 min ceiling
interface QueueStatus {
total_count?: number;
done_count?: number;
in_progress?: boolean;
}
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** Parse a `1.2.3` version into numeric segments (prerelease ignored). */
function parseSemver(v: string): number[] {
return v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
}
/** True when `target` is a strictly newer version than `current`. Used as a
* downgrade guard so a mis-resolved target can never install an older
* build over a newer one. */
function isStrictlyNewer(target: string, current: string): boolean {
const t = parseSemver(target);
const c = parseSemver(current);
for (let i = 0; i < 3; i++) {
const diff = (t[i] ?? 0) - (c[i] ?? 0);
if (diff !== 0) return diff > 0;
}
return false;
}
export function useComfyManagerUpdate(): {
phase: ReturnType<typeof ref<UpdatePhase>>;
errorKind: ReturnType<typeof ref<UpdateErrorKind>>;
errorMessage: ReturnType<typeof ref<string | null>>;
probe: () => Promise<ManagerAvailability>;
runUpdate: (targetVersion: string) => Promise<void>;
reboot: () => Promise<void>;
managerUiUrl: string;
} {
const phase = ref<UpdatePhase>("idle");
const errorKind = ref<UpdateErrorKind>(null);
const errorMessage = ref<string | null>(null);
async function probe(): Promise<ManagerAvailability> {
try {
const res = await fetch("/manager/queue/status", { method: "GET" });
return res.ok ? "available" : "absent";
} catch {
return "absent";
}
}
function fail(kind: Exclude<UpdateErrorKind, null>, message: string): void {
phase.value = "error";
errorKind.value = kind;
errorMessage.value = message;
}
async function pollUntilDone(): Promise<boolean> {
for (let i = 0; i < STATUS_MAX_POLLS; i++) {
try {
const res = await fetch("/manager/queue/status", { method: "GET" });
if (res.ok) {
const s = (await res.json()) as QueueStatus;
const total = s.total_count ?? 0;
const done = s.done_count ?? 0;
const running = s.in_progress ?? false;
if (!running && (total === 0 || done >= total)) return true;
}
} catch {
/* transient — keep polling until the ceiling */
}
await delay(STATUS_POLL_MS);
}
return false;
}
async function runUpdate(targetVersion: string): Promise<void> {
phase.value = "installing";
errorKind.value = null;
errorMessage.value = null;
// Downgrade guard. `targetVersion` is the exact release the update check
// found (e.g. "2.10.1"). Never proceed unless it is strictly newer than
// what's installed. This is the fix for the 2.10.0 downgrade bug, where
// `selected_version:"latest"` + `mode:"cache"` let ComfyUI Manager
// resolve "latest" against its STALE cached catalog and install an older
// version over a newer one.
if (!targetVersion || !isStrictlyNewer(targetVersion, __APP_VERSION__)) {
fail("failed", `Refusing to install "${targetVersion || "unknown"}" — it is not newer than the installed ${__APP_VERSION__}.`);
return;
}
try {
const installRes = await fetch("/manager/queue/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: COMFY_REGISTRY_ID,
version: __APP_VERSION__,
// Pin the EXACT target version, not "latest" — we already know it.
selected_version: targetVersion,
channel: "default",
// "remote" fetches fresh registry data so the pinned version
// resolves against the live catalog, not Manager's stale cache.
mode: "remote",
}),
});
if (!installRes.ok) {
const text = await installRes.text().catch(() => "");
if (installRes.status === 403) {
fail("forbidden", text || "ComfyUI Manager blocked the update (security level).");
} else {
fail("failed", text || `Install request failed (${installRes.status}).`);
}
return;
}
const startRes = await fetch("/manager/queue/start", { method: "POST" });
if (!startRes.ok) {
fail("failed", `Could not start the update queue (${startRes.status}).`);
return;
}
const done = await pollUntilDone();
if (!done) {
fail("failed", "The update did not finish in time. Check ComfyUI's console.");
return;
}
phase.value = "staged";
} catch (e) {
fail("failed", e instanceof Error ? e.message : "Unexpected update error.");
}
}
async function reboot(): Promise<void> {
phase.value = "restarting";
try {
await fetch("/manager/reboot", { method: "POST" });
} catch {
// The server drops the socket while restarting — expected. Stay in
// `restarting`; the dialog tells the user to reopen ComfyUI.
}
}
const managerUiUrl = "/manager";
return { phase, errorKind, errorMessage, probe, runUpdate, reboot, managerUiUrl };
}
+120 -41
View File
@@ -1,29 +1,101 @@
/**
* Release check - once-per-day GitHub Releases poll for "update available".
* Release check - GitHub Releases lookup for "update available".
*
* Hits `https://api.github.com/repos/<owner>/<repo>/releases/latest`
* anonymously (unauthenticated requests get 60/hr per IP, plenty for a
* single-user dev tool). The result + a timestamp are cached in
* localStorage; subsequent calls return the cached value until the TTL
* expires, so a topbar re-mount doesn't burn a request per page nav.
* single-user dev tool). Cadence is launch-once + manual: on mount we
* paint any cached result immediately, then refresh once per app session
* IF the user opted into launch checks (`uiStore.checkOnLaunch`). There is
* no timed re-poll; `checkNow()` triggers an on-demand refresh. The
* localStorage cache exists only to paint instantly before the network
* call resolves — it is not a re-check suppressor.
*
* The composable returns a reactive `hasUpdate` flag + `latestVersion`
* so AppTopbar can paint an accent dot without owning the polling
* machinery. Network failure (offline, rate limit, 404 on private repo)
* fails silently - `hasUpdate` stays false; we never block the UI.
* The composable returns reactive `hasUpdate` / `latestVersion` /
* `severity` so AppTopbar can paint an accent pill, plus the release
* `body` / `html_url` / `lastChecked` for the Update dialog + Settings.
* Network failure (offline, rate limit, 404 on private repo) fails
* silently - `hasUpdate` stays false; we never block the UI.
*/
import { onMounted, ref } from "vue";
import { GITHUB_REPO } from "../config/links";
import { useUiStore } from "../stores/uiStore";
const STORAGE_KEY = "wp.releaseCheck";
const TTL_MS = 24 * 60 * 60 * 1000;
interface CachedRelease {
/** ISO timestamp of when the check ran. */
checked_at: string;
/** GitHub release `tag_name` minus a leading `v`. */
latest_version: string;
body?: string | null;
url?: string | null;
}
/** Module-level singleton state so EVERY consumer (topbar pill + Settings
* card) shares ONE reactive source. Without this, each `useReleaseCheck()`
* call got its own refs — so a manual "Check now" in Settings updated only
* Settings' copy and the topbar pill stayed dark until a page reload. */
const latestVersion = ref<string | null>(null);
const hasUpdate = ref<boolean>(false);
const severity = ref<UpdateSeverity | null>(null);
const releaseBody = ref<string | null>(null);
const releaseUrl = ref<string | null>(null);
const lastChecked = ref<string | null>(null);
const checking = ref<boolean>(false);
/** Guard so the once-per-session launch fetch fires at most once across all
* consumers. `checkNow` ignores it. */
let sessionFetched = false;
/** Test-only: reset the shared state + session guard between mounts. */
export function resetReleaseCheckSession(): void {
sessionFetched = false;
latestVersion.value = null;
hasUpdate.value = false;
severity.value = null;
releaseBody.value = null;
releaseUrl.value = null;
lastChecked.value = null;
checking.value = false;
}
/** Apply a candidate latest version to the shared refs. Reads
* `__APP_VERSION__` lazily (not a cached const) so tests that assign it
* after import see the right value. Guards a missing/malformed version so a
* bad cache degrades to "no update" instead of throwing in semverCompare. */
function applyLatest(v: string | null): void {
const current = __APP_VERSION__;
const valid = typeof v === "string" && v.length > 0 && typeof current === "string";
latestVersion.value = valid ? v : null;
const newer = valid && semverCompare(v as string, current) > 0;
hasUpdate.value = newer;
severity.value = newer ? classifyBump(current, v as string) : null;
}
/** Fetch fresh, persist, apply to the shared refs. Shared by the launch
* fetch + `checkNow`. */
async function refresh(): Promise<void> {
checking.value = true;
try {
const fresh = await fetchLatestRelease();
if (fresh) {
sessionFetched = true;
writeCache(fresh);
releaseBody.value = fresh.body;
releaseUrl.value = fresh.url;
lastChecked.value = new Date().toISOString();
applyLatest(fresh.version);
}
} finally {
checking.value = false;
}
}
/** Manual check — always refetches, ignores the session guard. Because the
* refs are shared, this lights the topbar pill + Settings simultaneously. */
async function checkNow(): Promise<void> {
await refresh();
}
/**
@@ -84,28 +156,28 @@ function classifyBump(current: string, latest: string): UpdateSeverity | null {
}
/**
* Parse the cached blob if it's still fresh. Returns null on cache
* miss, parse failure, or TTL expiry - caller refetches on null.
* Parse the cached blob. Returns null on cache miss or parse failure.
* There is no TTL expiry — the cache is paint-first, never a re-check
* suppressor.
*/
function readCache(): CachedRelease | null {
if (typeof localStorage === "undefined") return null;
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as CachedRelease;
const age = Date.now() - new Date(parsed.checked_at).getTime();
if (Number.isNaN(age) || age > TTL_MS) return null;
return parsed;
return JSON.parse(raw) as CachedRelease;
} catch {
return null;
}
}
function writeCache(latestVersion: string): void {
function writeCache(rel: { version: string; body: string | null; url: string | null }): void {
if (typeof localStorage === "undefined") return;
const payload: CachedRelease = {
checked_at: new Date().toISOString(),
latest_version: latestVersion,
latest_version: rel.version,
body: rel.body,
url: rel.url,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
@@ -124,7 +196,7 @@ function repoSlug(): string | null {
return `${match[1]}/${match[2]}`;
}
async function fetchLatestRelease(): Promise<string | null> {
async function fetchLatestRelease(): Promise<{ version: string; body: string | null; url: string | null } | null> {
const slug = repoSlug();
if (!slug) return null;
try {
@@ -132,50 +204,57 @@ async function fetchLatestRelease(): Promise<string | null> {
headers: { Accept: "application/vnd.github+json" },
});
if (!res.ok) return null;
const body = (await res.json()) as { tag_name?: unknown };
const body = (await res.json()) as { tag_name?: unknown; body?: unknown; html_url?: unknown };
if (typeof body.tag_name !== "string") return null;
return normalizeTag(body.tag_name);
return {
version: normalizeTag(body.tag_name),
body: typeof body.body === "string" ? body.body : null,
url: typeof body.html_url === "string" ? body.html_url : null,
};
} catch {
return null;
}
}
/**
* Vue composable. Call from a setup() block - the underlying GitHub
* fetch runs on mount, once per 24h. The returned `current` /
* `latestVersion` / `hasUpdate` refs stay reactive across the lifetime
* of the consumer.
* Vue composable. Call from a setup() block — the underlying GitHub fetch
* runs once on mount (when `checkOnLaunch` is on and no other consumer has
* fetched this session). The returned refs stay reactive across the
* lifetime of the consumer. Requires an active Pinia (reads uiStore).
*/
export function useReleaseCheck(): {
current: string;
latestVersion: ReturnType<typeof ref<string | null>>;
hasUpdate: ReturnType<typeof ref<boolean>>;
severity: ReturnType<typeof ref<UpdateSeverity | null>>;
releaseBody: ReturnType<typeof ref<string | null>>;
releaseUrl: ReturnType<typeof ref<string | null>>;
lastChecked: ReturnType<typeof ref<string | null>>;
checking: ReturnType<typeof ref<boolean>>;
checkNow: () => Promise<void>;
} {
const current = __APP_VERSION__;
const latestVersion = ref<string | null>(null);
const hasUpdate = ref<boolean>(false);
const severity = ref<UpdateSeverity | null>(null);
const ui = useUiStore();
function applyLatest(v: string | null): void {
latestVersion.value = v;
const newer = v !== null && semverCompare(v, current) > 0;
hasUpdate.value = newer;
severity.value = newer && v ? classifyBump(current, v) : null;
}
onMounted(async () => {
onMounted(() => {
// Paint the cached result immediately (if any) so the pill/dialog
// aren't blank while the network call is in flight.
const cached = readCache();
if (cached) {
releaseBody.value = cached.body ?? null;
releaseUrl.value = cached.url ?? null;
lastChecked.value = cached.checked_at ?? null;
applyLatest(cached.latest_version);
return;
}
const fresh = await fetchLatestRelease();
if (fresh) {
writeCache(fresh);
applyLatest(fresh);
// Then refresh once per session, only when the user opted into launch
// checks. Fire-and-forget; failures are swallowed in refresh.
if (ui.checkOnLaunch && !sessionFetched) {
void refresh();
}
});
return { current, latestVersion, hasUpdate, severity };
return {
current, latestVersion, hasUpdate, severity,
releaseBody, releaseUrl, lastChecked, checking, checkNow,
};
}
+8
View File
@@ -19,6 +19,14 @@
*/
export const GITHUB_REPO = "https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline";
/**
* ComfyUI Registry node id for this pack — the `id` passed to ComfyUI
* Manager's `/manager/queue/install` to update-in-place. Must match
* `pyproject.toml` `name` (publisher `dumiflex`). A wrong id silently
* no-ops the install.
*/
export const COMFY_REGISTRY_ID = "comfyui-wildcard-pipeline";
/**
* Wiki landing page. Branch-aware so a wiki-restructure that moves
* the entry point updates here rather than at five call sites.
+58 -63
View File
@@ -622,41 +622,47 @@ function clearAll() {
// ---------- Quick filter presets ----------
/**
* Select every entity across all 7 buckets. Equivalent to clicking the
* select-all checkbox in each section, but in one click. Does NOT walk
* the dep graph — the user already has everything, so closure is a
* no-op.
*/
function presetFullLibrary(): void {
selection.value = {
bundle: new Set(bundles.value.map((b) => b.id)),
wildcard: new Set(modulesForBucket("wildcard").map((m) => m.id)),
fixed_values: new Set(modulesForBucket("fixed_values").map((m) => m.id)),
combine: new Set(modulesForBucket("combine").map((m) => m.id)),
derivation: new Set(modulesForBucket("derivation").map((m) => m.id)),
constraint: new Set(modulesForBucket("constraint").map((m) => m.id)),
category: new Set(categories.value.map((c) => c.id)),
template: new Set(templates.value.map((t) => t.id)),
function emptySelection(): Record<BucketKey, Set<string>> {
return {
bundle: new Set(),
wildcard: new Set(),
fixed_values: new Set(),
combine: new Set(),
derivation: new Set(),
constraint: new Set(),
category: new Set(),
template: new Set(),
};
}
/** Every id in one bucket, from whichever ref backs it. */
function idsForBucket(b: BucketKey): string[] {
if (b === "bundle") return bundles.value.map((x) => x.id);
if (b === "category") return categories.value.map((x) => x.id);
if (b === "template") return templates.value.map((x) => x.id);
return modulesForBucket(b).map((x) => x.id);
}
/**
* Clear everything and select only wildcard modules. Common quick path
* when the user wants to share just their prompt vocabulary without
* combines/derivations/constraints attached.
* Select every entity across all 8 buckets. Equivalent to clicking the
* select-all checkbox in each section, but in one click. Does NOT walk
* the dep graph — the user already has everything, so closure is a no-op.
*/
function presetWildcardsOnly(): void {
selection.value = {
bundle: new Set(),
wildcard: new Set(modulesForBucket("wildcard").map((m) => m.id)),
fixed_values: new Set(),
combine: new Set(),
derivation: new Set(),
constraint: new Set(),
category: new Set(),
template: new Set(),
};
function presetFullLibrary(): void {
const next = emptySelection();
for (const b of BUCKETS) next[b.key] = new Set(idsForBucket(b.key));
selection.value = next;
}
/**
* Clear everything and select only one kind — powers the per-kind quick
* presets ("Wildcards", "Templates", …). Lets the user share, say, just
* their prompt vocabulary without combines/derivations/constraints.
*/
function presetKindOnly(b: BucketKey): void {
const next = emptySelection();
next[b] = new Set(idsForBucket(b));
selection.value = next;
}
/**
@@ -695,18 +701,24 @@ function presetFavoritesOnly(): void {
type="button"
@click="presetFullLibrary"
><i class="pi pi-database" /> Full library</button>
<button
class="wp-preset-btn"
data-test="preset-wildcards"
type="button"
@click="presetWildcardsOnly"
><i class="pi pi-sparkles" /> Wildcards only</button>
<button
class="wp-preset-btn"
data-test="preset-favorites"
type="button"
@click="presetFavoritesOnly"
><i class="pi pi-star-fill" /> Favorites only</button>
><i class="pi pi-star-fill" /> Favorites</button>
<span class="wp-export-presets__sep" aria-hidden="true"></span>
<!-- Per-kind presets: clear everything, select only this kind. Disabled
when the library has none of that kind. -->
<button
v-for="b in BUCKETS"
:key="b.key"
class="wp-preset-btn"
:data-test="`preset-kind-${b.key}`"
type="button"
:disabled="idsForBucket(b.key).length === 0"
@click="presetKindOnly(b.key)"
>{{ b.title }}</button>
</div>
<div class="wp-export-tab__sections">
@@ -814,6 +826,7 @@ function presetFavoritesOnly(): void {
.wp-export-presets {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 9px 12px;
background: var(--wp-bg-2);
@@ -821,6 +834,12 @@ function presetFavoritesOnly(): void {
border-radius: var(--wp-radius);
margin-bottom: 10px;
}
.wp-export-presets__sep {
width: 1px;
align-self: stretch;
margin: 2px 4px;
background: var(--wp-border);
}
.wp-export-presets__label {
font-size: var(--wp-text-xs);
color: var(--wp-text-dim);
@@ -829,32 +848,8 @@ function presetFavoritesOnly(): void {
text-transform: uppercase;
font-weight: 600;
}
.wp-preset-btn {
background: var(--wp-bg-3);
border: 1px solid var(--wp-border);
color: var(--wp-text);
font-family: var(--wp-font);
font-size: var(--wp-text-sm);
padding: 4px 11px;
border-radius: var(--wp-radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
.wp-preset-btn:hover {
background: var(--wp-bg-4);
border-color: var(--wp-border-strong);
}
.wp-preset-btn .pi {
font-size: 10px;
color: var(--wp-text-muted);
}
.wp-preset-btn:focus-visible {
outline: 2px solid var(--wp-accent-500);
outline-offset: 2px;
}
/* `.wp-preset-btn` itself is a shared atom in tokens.css — used by the
* export presets bar AND the import picker's mirror. */
.wp-export-tab__sections {
display: flex;
+97 -26
View File
@@ -270,11 +270,13 @@ const warningIds = computed<Set<string>>(() => {
* is an empty record when `libraryRows` is absent (unit tests or
* payloads loaded before the library finished loading).
*
* Bundles + categories are intentionally NOT fed to `detectCollisions`
* — bundles have their own MOD-detection flow downstream
* (bundle-fingerprint.ts) and categories merge by name. The picker
* surfaces a placeholder id-presence conflict for bundles separately
* in `badgesForEntity`; categories never get a conflict badge here.
* Bundles ARE fed to `detectCollisions` (they share the id→fingerprint
* library map with modules) so an existing bundle reads as MODIFIED /
* DUPLICATE / EXISTING inline, consistent with how the commit orchestrator
* now resolves them. (They were once excluded on the assumption of a
* separate `bundle-fingerprint.ts` flow that was never built, which left
* existing bundles as blind adds that 400'd on commit.) Categories are still
* excluded — they merge by name server-side, no id collision concept.
*
* Templates run a SEPARATE pass (`detectTemplateCollisions`) because
* they collide on a `templateFingerprint` rather than the module
@@ -284,10 +286,10 @@ const warningIds = computed<Set<string>>(() => {
const collisionStates = computed<Record<string, CollisionState>>(() => {
if (!props.libraryRows) return {};
const incoming: Array<FingerprintModuleRow & { id: string }> = [];
const moduleBuckets: BucketKey[] = [
"wildcards", "fixed_values", "combines", "derivations", "constraints",
const idBuckets: BucketKey[] = [
"bundles", "wildcards", "fixed_values", "combines", "derivations", "constraints",
];
for (const bk of moduleBuckets) {
for (const bk of idBuckets) {
const arr = props.payload[bk] as unknown as Array<
FingerprintModuleRow & { id: string }
>;
@@ -315,19 +317,17 @@ const collisionStates = computed<Record<string, CollisionState>>(() => {
* badge. Every other bucket runs through `verifyOne` and can carry one.
*
* Phase-5 collision-state → badge mapping:
* - Module buckets:
* - Module + bundle buckets (same id→fingerprint detector):
* - `no-collision` → NEW (green) — clean import, no decision needed.
* - `conflict` → MODIFIED (orange) — uuid match, fingerprint diff.
* - `exists-unknown` → EXISTING (drift / amber) — library row present
* but stored fingerprint absent (legacy row
* pre-fingerprint-backfill). Distinct from
* pre-fingerprint-backfill, or a bundle whose
* server fingerprint isn't set). Distinct from
* MODIFIED so users aren't misled.
* - `silent-skip` → (no badge) — true duplicate, auto-excluded at
* commit time; badging it would be misleading noise.
* - Bundles: id-presence check only — present in library →
* EXISTING (drift / amber). Bundle MOD detection runs separately
* downstream via bundle-fingerprint.ts so the inline picker badge
* never claims "MODIFIED" without proof.
* - `silent-skip` → DUPLICATE (dim) — true duplicate, auto-excluded
* at commit time; the badge tells the user the row
* is already in their library.
* - Categories: never get a collision badge (name-merge semantics on
* server, no id collision possible).
*
@@ -344,16 +344,9 @@ function badgesForEntity(entity: PayloadEntity, bucket: BucketKey): StatusBadge[
label: `MIGRATED v${entity.migrated_from}→${CURRENT_SCHEMA_VERSION}`,
});
}
if (bucket === "bundles") {
if (props.libraryRows?.has(entity.id) === true) {
// Id-presence only — we never compute bundle fingerprints in this
// picker, so route through the same EXISTING badge as
// exists-unknown rather than overclaiming MODIFIED.
badges.push({ variant: "drift", label: "EXISTING" });
}
// Bundles never get a NEW badge here — id-presence semantics only;
// the orchestrator's bundle MOD pass surfaces the actual state.
} else if (bucket !== "categories") {
if (bucket !== "categories") {
// Bundles + the five module buckets + templates all carry a
// collision state now (categories merge by name → no badge).
const state = collisionStates.value[entity.id];
if (state === "no-collision") {
badges.push({ variant: "new", label: "NEW" });
@@ -487,6 +480,28 @@ function deselectAll(): void {
selected.value = new Set();
}
// ---------- Quick-select presets (mirror of the export bar) ----------
/** Select every entity in the loaded payload, across all buckets. */
function presetSelectAll(): void {
const next = new Set<string>();
for (const b of BUCKETS) {
for (const e of entitiesForBucket(b.key)) {
if (typeof e.id === "string" && e.id.length > 0) next.add(e.id);
}
}
selected.value = next;
}
/** Clear everything and select only the entities of one kind. */
function presetKindOnly(bucket: BucketKey): void {
const next = new Set<string>();
for (const e of entitiesForBucket(bucket)) {
if (typeof e.id === "string" && e.id.length > 0) next.add(e.id);
}
selected.value = next;
}
function emitContinue(): void {
if (selected.value.size === 0) return;
// Phase 10: no dep-warning confirm dialog on import. Missing-dep
@@ -541,6 +556,34 @@ function emitContinue(): void {
integrity warnings.
</div>
<!-- Quick-select presets — mirror of the Export tab bar, so the same
one-click "select only this kind" affordance exists after a payload
is loaded from file/paste (#1). -->
<div
class="wp-import-presets"
role="group"
aria-label="Quick selection presets"
>
<span class="wp-import-presets__label">Quick select</span>
<button
class="wp-preset-btn"
data-test="import-preset-all"
type="button"
:disabled="totalEntityCount === 0"
@click="presetSelectAll"
><i class="pi pi-database" /> All</button>
<span class="wp-import-presets__sep" aria-hidden="true"></span>
<button
v-for="b in BUCKETS"
:key="b.key"
class="wp-preset-btn"
:data-test="`import-preset-kind-${b.key}`"
type="button"
:disabled="entitiesForBucket(b.key).length === 0"
@click="presetKindOnly(b.key)"
>{{ b.title }}</button>
</div>
<div class="wp-import-picker__sections">
<PickerSection
v-for="bucket in BUCKETS"
@@ -629,6 +672,34 @@ function emitContinue(): void {
gap: 6px;
}
/* Quick-select presets bar (mirror of ExportTab's). The button atom
* (`.wp-preset-btn`) is global in tokens.css; only the bar layout is here. */
.wp-import-presets {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 9px 12px;
background: var(--wp-bg-2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
margin-bottom: 10px;
}
.wp-import-presets__label {
font-size: var(--wp-text-xs);
color: var(--wp-text-dim);
letter-spacing: 0.06em;
margin-right: 6px;
text-transform: uppercase;
font-weight: 600;
}
.wp-import-presets__sep {
width: 1px;
align-self: stretch;
margin: 2px 4px;
background: var(--wp-border);
}
.wp-import-picker__notice {
padding: var(--wp-space-4) var(--wp-space-5);
margin-bottom: 10px;
+121 -28
View File
@@ -87,15 +87,23 @@ const fileSourceLabel = computed<string>(() =>
lastSource.value === "paste" ? "From paste" : "From file",
);
async function onFilePick(ev: Event): Promise<void> {
const target = ev.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
/** Read a picked/dropped file and feed its text into the parse pipeline.
* Shared by the file-input and the drag-and-drop path. */
async function readAndParse(file: File): Promise<void> {
try {
const text = await file.text();
handleParse(text, "file");
} catch (err) {
errorMsg.value = err instanceof Error ? err.message : String(err);
}
}
async function onFilePick(ev: Event): Promise<void> {
const target = ev.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
try {
await readAndParse(file);
} finally {
// Reset the input value so picking the same file twice in a row
// still fires a `change` event.
@@ -103,6 +111,36 @@ async function onFilePick(ev: Event): Promise<void> {
}
}
/** True while a file is being dragged over the dropzone — drives the
* highlight. */
const isDragging = ref<boolean>(false);
function onDragOver(ev: DragEvent): void {
// preventDefault is required on BOTH dragover and drop for the browser to
// treat this element as a drop target (otherwise it navigates to the file).
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "copy";
isDragging.value = true;
}
function onDragLeave(ev: DragEvent): void {
// Ignore leaves into a descendant — only clear when the pointer actually
// exits the dropzone, so the highlight doesn't flicker over child elements.
const zone = ev.currentTarget as HTMLElement;
const to = ev.relatedTarget as Node | null;
if (to && zone.contains(to)) return;
isDragging.value = false;
}
async function onDrop(ev: DragEvent): Promise<void> {
ev.preventDefault();
isDragging.value = false;
const file = ev.dataTransfer?.files?.[0];
// Non-JSON files fall through to parsePayload, which rejects them with a
// clear inline "Invalid payload" message — no need to gate on extension.
if (file) await readAndParse(file);
}
function openPaste(): void {
pasteOpen.value = true;
pasteText.value = "";
@@ -140,7 +178,7 @@ function onReplaceFile(): void {
}
/**
* Sum of all seven entity buckets on a freshly-parsed payload. Used to
* Sum of all eight entity buckets on a freshly-parsed payload. Used to
* populate the compact-bar entity count so it agrees with the picker
* header. `RawPayload`'s buckets all default to empty arrays after
* migration so the iteration is safe even on minimal payloads.
@@ -154,6 +192,7 @@ function payloadEntityCount(payload: RawPayload): number {
+ payload.derivations.length
+ payload.constraints.length
+ payload.categories.length
+ (payload.templates?.length ?? 0)
);
}
@@ -230,29 +269,44 @@ function handleParse(raw: string, source: "file" | "paste"): void {
Import a Wildcard Pipeline export file or paste an export payload below.
</p>
<div class="wp-import-tab__entry">
<button
type="button"
class="wp-import-tab__btn wp-import-tab__btn--primary"
data-test="import-file-btn"
@click="pickFile"
>Pick file…</button>
<input
ref="fileInput"
type="file"
accept=".json,application/json"
class="wp-import-tab__file-hidden"
aria-hidden="true"
tabindex="-1"
data-test="import-file-input"
@change="onFilePick"
/>
<button
type="button"
class="wp-import-tab__btn"
data-test="import-paste-btn"
@click="openPaste"
>Paste JSON…</button>
<div
class="wp-import-tab__dropzone"
:class="{ 'wp-import-tab__dropzone--active': isDragging }"
data-test="import-dropzone"
@dragover="onDragOver"
@dragenter="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
>
<div class="wp-import-tab__entry">
<button
type="button"
class="wp-import-tab__btn wp-import-tab__btn--primary"
data-test="import-file-btn"
@click="pickFile"
>Pick file…</button>
<input
ref="fileInput"
type="file"
accept=".json,application/json"
class="wp-import-tab__file-hidden"
aria-hidden="true"
tabindex="-1"
data-test="import-file-input"
@change="onFilePick"
/>
<button
type="button"
class="wp-import-tab__btn"
data-test="import-paste-btn"
@click="openPaste"
>Paste JSON…</button>
</div>
<p class="wp-import-tab__drophint" aria-hidden="true">
<i class="pi pi-download" />
<span v-if="isDragging">Drop to import</span>
<span v-else>or drag &amp; drop a <code>.json</code> export file here</span>
</p>
</div>
<div
@@ -325,12 +379,51 @@ function handleParse(raw: string, source: "file" | "paste"): void {
font-size: var(--wp-text-sm);
}
/* Drag-and-drop target wrapping the pick/paste buttons. The whole area
* accepts a dropped .json export file; the dashed frame + hint advertise it,
* and `--active` lights up while a file is dragged over. */
.wp-import-tab__dropzone {
display: flex;
flex-direction: column;
gap: var(--wp-space-5);
padding: var(--wp-space-6);
border: 1.5px dashed var(--wp-border);
border-radius: var(--wp-radius);
background: transparent;
transition: border-color 0.12s ease, background-color 0.12s ease;
}
.wp-import-tab__dropzone--active {
border-color: var(--wp-accent);
background: color-mix(in oklab, var(--wp-accent) 10%, transparent);
}
.wp-import-tab__entry {
display: flex;
flex-wrap: wrap;
gap: var(--wp-space-5);
}
.wp-import-tab__drophint {
display: flex;
align-items: center;
gap: var(--wp-space-3);
margin: 0;
color: var(--wp-text-muted);
font-size: var(--wp-text-sm);
pointer-events: none;
}
.wp-import-tab__dropzone--active .wp-import-tab__drophint {
color: var(--wp-accent);
font-weight: 600;
}
.wp-import-tab__drophint code {
font-family: var(--wp-font-mono, monospace);
font-size: var(--wp-text-xs);
padding: 1px 4px;
border-radius: 4px;
background: var(--wp-bg-3);
}
.wp-import-tab__btn {
height: var(--wp-btn-h);
padding: 0 var(--wp-space-6);
@@ -170,16 +170,15 @@ describe("ExportTab.vue", () => {
const label = wrap.get(".wp-export-presets__label");
expect(label.text()).toBe("Quick select");
expect(label.text()).not.toContain(":");
// Each preset button carries its expected pi icon.
// The two headline presets carry their pi icon; per-kind presets are
// text-only (one button per bucket).
expect(
wrap.get('[data-test="preset-full"]').find("i.pi.pi-database").exists(),
).toBe(true);
expect(
wrap.get('[data-test="preset-wildcards"]').find("i.pi.pi-sparkles").exists(),
).toBe(true);
expect(
wrap.get('[data-test="preset-favorites"]').find("i.pi.pi-star-fill").exists(),
).toBe(true);
expect(wrap.get('[data-test="preset-kind-wildcard"]').text()).toBe("Wildcards");
});
it("renders a footer bar with all action buttons + counter", async () => {
@@ -660,7 +659,7 @@ describe("ExportTab.vue", () => {
}
});
it("'Wildcards only' preset clears non-wildcard buckets and selects all wildcards", async () => {
it("per-kind 'Wildcards' preset clears non-wildcard buckets and selects all wildcards", async () => {
apiAny.modules.list.mockResolvedValue({
items: [
mkModule({ id: "w1", type: "wildcard", name: "$one" }),
@@ -686,7 +685,7 @@ describe("ExportTab.vue", () => {
await wrap.get('[data-test="export-tab-row-bundle-b1"] button[role="checkbox"]').trigger("click");
await flushPromises();
await wrap.get('[data-test="preset-wildcards"]').trigger("click");
await wrap.get('[data-test="preset-kind-wildcard"]').trigger("click");
await flushPromises();
// Total = 2 wildcards. Bundle/fixed/combine/category all empty.
@@ -571,6 +571,89 @@ describe("ImportPicker.vue", () => {
expect(dup!.variant).toBe("duplicate");
});
// Bundles now run through the same id→fingerprint collision detector as
// modules (they were once excluded → existing bundles imported as blind
// adds that 400'd on commit). These lock in the badge states.
it("adds a NEW badge for a bundle absent from the library", async () => {
const wrap = mountPicker({
payload: makePayload({ bundles: [{ id: "b1", name: "B1" }] }),
libraryRows: new Map(),
});
await flushPromises();
await expandSection(wrap, "bundles");
const row = wrap.findAllComponents(PickerRow)[0]!;
const badges = row.props("statusBadges") as Array<{ label: string; variant: string }>;
expect(badges.find((b) => b.label === "NEW")).toBeDefined();
});
it("adds an EXISTING badge for a bundle present with no library fingerprint", async () => {
const wrap = mountPicker({
payload: makePayload({ bundles: [{ id: "b1", name: "B1" }] }),
libraryRows: new Map([["b1", {}]]),
});
await flushPromises();
await expandSection(wrap, "bundles");
const row = wrap.findAllComponents(PickerRow)[0]!;
const badges = row.props("statusBadges") as Array<{ label: string; variant: string }>;
const existing = badges.find((b) => b.label === "EXISTING");
expect(existing).toBeDefined();
expect(existing!.variant).toBe("drift");
});
it("adds a MODIFIED badge for a bundle whose fingerprint differs from the library", async () => {
const wrap = mountPicker({
payload: makePayload({
bundles: [{ id: "b1", name: "B1", description: "", tags: [], payload_hash: "aaa" }],
}),
libraryRows: new Map([["b1", { snapshot_fingerprint: "a-different-fingerprint" }]]),
});
await flushPromises();
await expandSection(wrap, "bundles");
const row = wrap.findAllComponents(PickerRow)[0]!;
const badges = row.props("statusBadges") as Array<{ label: string; variant: string }>;
const mod = badges.find((b) => b.label === "MODIFIED");
expect(mod).toBeDefined();
expect(mod!.variant).toBe("mod");
});
// Quick-select presets mirrored from the export bar (#1).
it("per-kind preset selects only that kind from the payload (#1)", async () => {
const wrap = mountPicker({
payload: makePayload({
wildcards: [{ id: "w1", name: "W1" }, { id: "w2", name: "W2" }],
templates: [{ id: "t1", name: "T1" }],
}),
});
await flushPromises();
await wrap.get('[data-test="import-preset-kind-wildcards"]').trigger("click");
await flushPromises();
expect(wrap.get('[data-test="import-picker-selected-count"]').text()).toContain("2 of 3");
});
it("'All' preset selects every payload entity (#1)", async () => {
const wrap = mountPicker({
payload: makePayload({
wildcards: [{ id: "w1", name: "W1" }],
templates: [{ id: "t1", name: "T1" }],
}),
});
await flushPromises();
await wrap.get('[data-test="import-preset-all"]').trigger("click");
await flushPromises();
expect(wrap.get('[data-test="import-picker-selected-count"]').text()).toContain("2 of 2");
});
it("per-kind preset button is disabled for an empty bucket (#1)", async () => {
const wrap = mountPicker({
payload: makePayload({ wildcards: [{ id: "w1", name: "W1" }] }),
});
await flushPromises();
// No templates in the payload → the Templates preset is disabled.
expect(
wrap.get('[data-test="import-preset-kind-templates"]').attributes("disabled"),
).toBeDefined();
});
it("passes showId=true to PickerRow", async () => {
const payload = makePayload({
wildcards: [{ id: "abcd1234", name: "a" }],
@@ -146,6 +146,33 @@ describe("ImportTab.vue", () => {
expect(payload.schema_version).toBe(2);
});
it("drag-and-drop — parses a dropped File and emits payload-ready (#5)", async () => {
const wrap = mount(ImportTab);
const file = new File([makeValidPayloadJson()], "test.json", {
type: "application/json",
});
const zone = wrap.find("[data-test='import-dropzone']");
await zone.trigger("drop", { dataTransfer: { files: [file] } });
await flushPromises();
const emitted = wrap.emitted("payload-ready");
expect(emitted).toBeTruthy();
expect((emitted?.[0]?.[0] as RawPayload).schema_version).toBe(2);
});
it("drag-over lights up the dropzone, drag-leave clears it (#5)", async () => {
const wrap = mount(ImportTab);
const zone = wrap.find("[data-test='import-dropzone']");
const active = "wp-import-tab__dropzone--active";
await zone.trigger("dragover", { dataTransfer: {} });
expect(zone.classes()).toContain(active);
// relatedTarget outside the zone → highlight clears.
await zone.trigger("dragleave", { relatedTarget: document.body });
expect(zone.classes()).not.toContain(active);
});
it("paste cancel closes the pane without emitting", async () => {
const wrap = mount(ImportTab);
await wrap.find("[data-test='import-paste-btn']").trigger("click");
@@ -222,6 +249,28 @@ describe("ImportTab.vue", () => {
expect(wrap.find("[data-test='import-tab-replace']").exists()).toBe(true);
});
it("counts templates in the loaded-bar entity total (#7 follow-up)", async () => {
const payload = JSON.stringify({
schema_version: 2,
bundles: [], wildcards: [], fixed_values: [], combines: [],
derivations: [], constraints: [], categories: [],
templates: [
{ id: "t1", name: "Hero", template_string: "$one" },
{ id: "t2", name: "Scene", template_string: "$two" },
],
});
const wrap = mount(ImportTab, { props: { payloadLoaded: false } });
await wrap.find("[data-test='import-paste-btn']").trigger("click");
await flushPromises();
await wrap.find("textarea").setValue(payload);
await wrap.find("[data-test='import-paste-confirm']").trigger("click");
await flushPromises();
await wrap.setProps({ payloadLoaded: true });
await flushPromises();
// 2 templates were previously excluded from the count → bar read "0".
expect(wrap.find("[data-test='import-tab-loaded']").text()).toMatch(/2\s+entities/i);
});
it("compact bar labels source as 'From file' after a file-pick parse", async () => {
const wrap = mount(ImportTab, { props: { payloadLoaded: false } });
const file = new File([makeValidPayloadJson()], "test.json", {
@@ -62,6 +62,36 @@ describe("buildCommitPayload", () => {
expect((result.renames[0]?.content as { id: string }).id).toBe("w3_new");
});
it("strips server-stamped lifecycle fields from an add so the import reads as fresh (#12)", () => {
const sel = emptySelection();
sel.wildcards = [
{
entity: {
id: "w1",
name: "alpha",
payload: { options: [] },
tags: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-02T00:00:00Z",
version: 7,
snapshot_fingerprint: "abc",
payload_hash: "def",
},
decision: { kind: "add" },
},
];
const add = buildCommitPayload(sel).adds[0]!.entity as Record<string, unknown>;
// Identity + content survive; the server-owned lifecycle fields are gone
// so engine/importer.py falls back to now() + recomputes the fingerprint.
expect(add.id).toBe("w1");
expect(add.name).toBe("alpha");
expect(add.created_at).toBeUndefined();
expect(add.updated_at).toBeUndefined();
expect(add.version).toBeUndefined();
expect(add.snapshot_fingerprint).toBeUndefined();
expect(add.payload_hash).toBeUndefined();
});
it("partitions all three decision kinds for templates", () => {
const sel = emptySelection();
sel.templates = [
+35 -15
View File
@@ -157,6 +157,32 @@ export interface CommitOk {
* preserves identity by design — the user explicitly opted to insert
* under the original id.
*/
/**
* Server-stamped lifecycle fields. Stripping them makes the engine importer's
* insert paths (`_insert_module` / `_insert_bundle`) fall back to `now()` for
* the dates and recompute the fingerprint, instead of copying the SOURCE
* library's values verbatim (`entity.get("created_at", now)` etc.). The
* server recomputes the fingerprint on insert regardless, so stripping those
* is a no-op there — but stripping the dates is what makes an imported row
* read as freshly added.
*/
const LIFECYCLE_FIELDS = [
"created_at",
"updated_at",
"version",
"snapshot_fingerprint",
"payload_hash",
"template_fingerprint",
] as const;
function stripLifecycle(
entity: Record<string, unknown> & { id: string },
): Record<string, unknown> {
const out: Record<string, unknown> = { ...entity };
for (const f of LIFECYCLE_FIELDS) delete out[f];
return out;
}
function partition(
kind: EntityKind,
list: ReadonlyArray<{ entity: Record<string, unknown> & { id: string }; decision: Decision }>,
@@ -165,28 +191,22 @@ function partition(
for (const r of list) {
switch (r.decision.kind) {
case "add":
out.adds.push({ kind, entity: r.entity });
// Strip lifecycle fields so the server stamps a fresh created_at:
// an imported entity must read as "just added to your library", not
// carry the exporter's original date (which made imports sort as
// weeks-old — bug #12). Identity (`id`) is preserved by design.
out.adds.push({ kind, entity: stripLifecycle(r.entity) });
break;
case "replace":
// Replace overwrites an EXISTING library row; the server's
// `_update_module` never touches `created_at` and always stamps
// `updated_at = now()`, so the live row keeps its own dates.
out.replaces.push({ kind, id: r.entity.id, new_content: r.entity });
break;
case "rename": {
const newContent: Record<string, unknown> = { ...r.entity };
const newContent = stripLifecycle(r.entity);
newContent.id = r.decision.new_id;
newContent.name = r.decision.new_name;
// Strip lifecycle fields so the server's now() defaults +
// fingerprint/hash recomputation kick in. Leaving stale values
// would mis-sort the imported row and cache an inappropriate
// fingerprint against the freshly-allocated new_id.
delete newContent.created_at;
delete newContent.updated_at;
delete newContent.version;
delete newContent.snapshot_fingerprint;
delete newContent.payload_hash;
// Templates carry a computed `template_fingerprint` (not the
// module `snapshot_fingerprint`); strip it too so a renamed
// template can't smuggle a fingerprint keyed to the old id.
delete newContent.template_fingerprint;
out.renames.push({
kind,
old_id: r.entity.id,
+24 -11
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import Icon from "../components/ui/Icon.vue";
import UpdateDialog from "../components/UpdateDialog.vue";
import { GITHUB_REPO } from "../config/links";
import { useBrowserHistory } from "../composables/useBrowserHistory";
import { useReleaseCheck } from "../composables/useReleaseCheck";
@@ -14,7 +15,7 @@ const tweaks = useTweaksStore();
// Version is injected at build time from package.json via vite's
// `define` config (see vite.config.mts). The release check composable
// hits GitHub once per 24h (cached in localStorage) and surfaces a
// looks up the latest GitHub release once per app session and surfaces a
// `hasUpdate` flag + severity classification (major/minor/patch).
const { current: version, latestVersion, hasUpdate, severity } = useReleaseCheck();
@@ -35,10 +36,14 @@ const versionTooltip = computed(() =>
? `v${version} — update v${latestVersion.value} available on GitHub`
: `v${version} — open repository on GitHub`,
);
const releasesUrl = `${GITHUB_REPO}/releases/latest`;
const repoUrl = GITHUB_REPO;
const logoSrc = `${import.meta.env.BASE_URL}images/favicon.svg`;
// The update pill opens the in-app Update dialog (changelog + one-click
// update via ComfyUI Manager) rather than linking straight to GitHub.
const updateDialogOpen = ref(false);
function openUpdateDialog(): void { updateDialogOpen.value = true; }
/** Theme button icon — mirrors prototype (current state, not next). */
const themeIcon = computed(() => {
switch (ui.themeMode) {
@@ -103,18 +108,16 @@ function openPalette(): void {
data-test="topbar-version"
@click.stop
>v{{ version }}</a>
<a
<button
v-if="hasUpdate && latestVersion"
type="button"
class="wp-topbar__version wp-topbar__update-pill"
:class="updatePillClass"
:href="releasesUrl"
:title="updateTooltip"
target="_blank"
rel="noopener"
:data-severity="severity ?? undefined"
data-test="topbar-update-indicator"
@click.stop
><span class="wp-topbar__update-arrow" aria-hidden="true">↑</span>v{{ latestVersion }}</a>
@click.stop.prevent="openUpdateDialog"
><span class="wp-topbar__update-arrow" aria-hidden="true">↑</span>v{{ latestVersion }}</button>
</RouterLink>
<div class="wp-topbar__spacer" />
@@ -174,6 +177,8 @@ function openPalette(): void {
>
<Icon name="pi-cog" />
</RouterLink>
<UpdateDialog :open="updateDialogOpen" @close="updateDialogOpen = false" />
</header>
</template>
@@ -248,15 +253,23 @@ a.wp-topbar__version:hover {
* - minor/patch → accent purple (safe to upgrade)
* Clicking opens releases/latest in a new tab; click.stop on the
* anchor keeps the dashboard nav (outer RouterLink) from firing first. */
a.wp-topbar__update-pill {
a.wp-topbar__update-pill,
button.wp-topbar__update-pill {
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
transition: filter 120ms ease;
font: inherit;
}
a.wp-topbar__update-pill:hover {
button.wp-topbar__update-pill {
/* Reset native button chrome; tonal border comes from the
--accent/--warn modifier classes below. */
border: 1px solid transparent;
}
a.wp-topbar__update-pill:hover,
button.wp-topbar__update-pill:hover {
filter: brightness(1.15);
}
.wp-topbar__update-arrow {
@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useUiStore } from "../uiStore";
describe("uiStore checkOnLaunch", () => {
beforeEach(() => {
localStorage.clear();
setActivePinia(createPinia());
});
it("defaults to true when unset", () => {
const ui = useUiStore();
expect(ui.checkOnLaunch).toBe(true);
});
it("persists and reloads from localStorage", () => {
const ui = useUiStore();
ui.setCheckOnLaunch(false);
expect(ui.checkOnLaunch).toBe(false);
expect(localStorage.getItem("wp-update-check-on-launch")).toBe("false");
// Fresh store instance reads the stored value.
setActivePinia(createPinia());
const ui2 = useUiStore();
expect(ui2.checkOnLaunch).toBe(false);
});
});
+20
View File
@@ -10,6 +10,7 @@ export type DensityMode = "comfortable" | "compact";
const STORAGE_KEY = "wp-theme-mode";
const STORAGE_KEY_DENSITY = "wp-density-mode";
const STORAGE_KEY_MAX_REF_DEPTH = "wp-wildcard-max-ref-depth";
const STORAGE_KEY_CHECK_ON_LAUNCH = "wp-update-check-on-launch";
const FLASH_SUPPRESS_MS = 120;
const DEFAULT_MAX_REF_DEPTH = 8;
const MIN_MAX_REF_DEPTH = 1;
@@ -50,6 +51,17 @@ function readStoredMaxRefDepth(): number {
return DEFAULT_MAX_REF_DEPTH;
}
function readStoredCheckOnLaunch(): boolean {
try {
const v = localStorage.getItem(STORAGE_KEY_CHECK_ON_LAUNCH);
if (v === "true") return true;
if (v === "false") return false;
} catch {
/* localStorage unavailable */
}
return true; // default: check on launch
}
function systemPrefersDark(): boolean {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
@@ -61,6 +73,7 @@ export const useUiStore = defineStore("ui", () => {
const density = ref<DensityMode>(readStoredDensity());
const sidebarCollapsed = ref(false);
const maxRefDepth = ref<number>(readStoredMaxRefDepth());
const checkOnLaunch = ref<boolean>(readStoredCheckOnLaunch());
/** Resolved theme — `"auto"` collapsed to dark/light via OS preference. */
const resolvedTheme = computed<"dark" | "light">(() =>
@@ -131,17 +144,24 @@ export const useUiStore = defineStore("ui", () => {
sidebarCollapsed.value = !sidebarCollapsed.value;
}
function setCheckOnLaunch(v: boolean): void {
checkOnLaunch.value = v;
try { localStorage.setItem(STORAGE_KEY_CHECK_ON_LAUNCH, String(v)); } catch { /* ignore */ }
}
return {
themeMode,
resolvedTheme,
density,
sidebarCollapsed,
maxRefDepth,
checkOnLaunch,
cycleTheme,
setThemeMode,
setDensity,
toggleDensity,
setMaxRefDepth,
setCheckOnLaunch,
initializeTheme,
toggleSidebar,
};
+68
View File
@@ -598,6 +598,74 @@ input, textarea, select { font: inherit; }
font-weight: 400;
}
.wp-card__body { padding: 14px; }
/* Sticky-header cards pin their header + `subheader` slot (e.g. a bulk-edit
* toolbar) to the top of the scroll region so a long list's actions (Add
* option, Bulk edit, the selection toolbar, …) stay reachable at the bottom.
* The whole `.wp-card__sticky` region sticks as one so its sub-toolbar can't
* be left behind. Card.vue publishes `--wp-card-header-h` (the region's
* measured height) so a sticky <thead> below offsets right under it. Needs an
* opaque bg (header/subheader are transparent). */
.wp-card--sticky-header > .wp-card__sticky {
position: sticky;
/* -1px, not 0: the scroll container's top can land on a fractional device
* pixel (measured e.g. 152.39 at dpr 1), so pinning exactly at top:0 leaves
* a sub-pixel sliver above the opaque header through which the scrolled
* content behind bleeds as a faint ghost line. Overlapping the scroll edge
* by 1px (the extra px is clipped by the container's overflow and absorbed
* by the header's 12px top padding) guarantees the bg covers that seam at
* any DPR. The sticky <thead> below tracks the same -1px. */
top: -1px;
z-index: 5;
background: var(--wp-bg-1);
}
/* Sticky column header inside a sticky-header card — pins right under the
* toolbar. Chromium won't scroll-anchor a table CELL here, but sticking the
* <thead> element does; th gets an opaque bg so rows don't bleed through.
* Shared by the wildcard + fixed-values option tables. The -1px mirrors the
* header's overlap above so the two stay flush (no gap for rows to bleed). */
.wp-card--sticky-header .wp-options-table thead {
position: sticky;
top: calc(var(--wp-card-header-h, 53px) - 1px);
z-index: 4;
}
.wp-card--sticky-header .wp-options-table thead th {
background: var(--wp-bg-1);
box-shadow: inset 0 -1px 0 var(--wp-border);
}
/* Quick-select preset chip — shared by the import/export presets bars
* (ExportTab + ImportPicker). Layout of the bar itself stays per-component;
* this is just the button atom. */
.wp-preset-btn {
background: var(--wp-bg-3);
border: 1px solid var(--wp-border);
color: var(--wp-text);
font-family: var(--wp-font);
font-size: var(--wp-text-sm);
padding: 4px 11px;
border-radius: var(--wp-radius-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
.wp-preset-btn:hover:not(:disabled) {
background: var(--wp-bg-4);
border-color: var(--wp-border-strong);
}
.wp-preset-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.wp-preset-btn .pi {
font-size: 10px;
color: var(--wp-text-muted);
}
.wp-preset-btn:focus-visible {
outline: 2px solid var(--wp-accent-500);
outline-offset: 2px;
}
/* ---------- Buttons ---------- */
.wp-btn {
+114
View File
@@ -0,0 +1,114 @@
/**
* Minimal, safe Markdown → HTML for GitHub release notes.
*
* Deliberately NOT a general markdown engine — we avoid a `marked`
* dependency to keep the manager bundle within the size gate. It supports
* the subset GitHub release bodies actually use: ATX headings (#..###),
* `-`/`*` bullet lists, **bold**, `inline code`, fenced ```code``` blocks,
* and [text](url) links.
*
* Safety model: EVERY line is HTML-escaped FIRST, then a small set of
* transforms re-introduce a whitelisted tag set on the already-escaped
* text. Raw HTML in the source therefore renders as inert text; the only
* tags in the output are the ones this function emits. The result is the
* sole sanctioned input to a `v-html` (the `.wpc-relnotes` container).
*/
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Inline transforms applied to an already-escaped line. */
function renderInline(escaped: string): string {
// Links: [text](url). `url` is validated to http(s) only; because the
// whole line is pre-escaped, a `"` in the source is already `&quot;`,
// so it cannot terminate the href attribute.
let out = escaped.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
(_m, text: string, url: string) =>
`<a href="${url}" target="_blank" rel="noopener nofollow">${text}</a>`,
);
// Inline code (before bold so ** inside code isn't bolded).
out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
// Bold.
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
return out;
}
/**
* Normalize the small set of HTML tags that GitHub release bodies use —
* `<details>`/`<summary>` collapsibles and `<b>` — into markdown BEFORE the
* escape pass, so they render instead of appearing as literal `<details>`
* text. Everything else still falls through to escaping (rendered inert), so
* this stays safe: it only recognises a fixed allow-list of formatting tags.
*/
function normalizeReleaseHtml(md: string): string {
return md
// <summary>…</summary> → a bold lead line (drop any inner tags).
.replace(/<summary>([\s\S]*?)<\/summary>/gi, (_m, s: string) =>
`**${s.replace(/<\/?[a-z][^>]*>/gi, "").trim()}**\n`)
// Drop the <details> wrapper markers — the content flows inline.
.replace(/<\/?details[^>]*>/gi, "")
// Bold.
.replace(/<\/?(?:b|strong)>/gi, "**");
}
export function renderReleaseNotes(md: string): string {
if (!md || !md.trim()) {
return '<p class="wpc-relnotes__empty">No release notes.</p>';
}
const lines = normalizeReleaseHtml(md).replace(/\r\n/g, "\n").split("\n");
const html: string[] = [];
let inList = false;
let inCode = false;
const codeBuf: string[] = [];
const closeList = () => { if (inList) { html.push("</ul>"); inList = false; } };
for (const rawLine of lines) {
// Fenced code toggles.
if (/^```/.test(rawLine.trim())) {
if (inCode) {
html.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
codeBuf.length = 0;
inCode = false;
} else {
closeList();
inCode = true;
}
continue;
}
if (inCode) { codeBuf.push(rawLine); continue; }
const line = rawLine.trimEnd();
const escaped = escapeHtml(line);
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
if (heading) {
closeList();
const level = heading[1].length;
html.push(`<h${level}>${renderInline(escapeHtml(heading[2]))}</h${level}>`);
continue;
}
const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
if (bullet) {
if (!inList) { html.push("<ul>"); inList = true; }
html.push(`<li>${renderInline(escapeHtml(bullet[1]))}</li>`);
continue;
}
if (line.trim() === "") { closeList(); continue; }
closeList();
html.push(`<p>${renderInline(escaped)}</p>`);
}
if (inCode) { html.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`); }
closeList();
return html.join("\n");
}
+59
View File
@@ -32,3 +32,62 @@ describe("expandInlineChoices — branch weights (engine parity)", () => {
expect(expandInlineChoices("{x::y|x::y}")).toBe("x::y");
});
});
describe("expandInlineChoices — multi-pick {N-M$$sep$$…} (engine parity)", () => {
it("fixed count picks exactly N unique branches joined by sep", () => {
for (let i = 0; i < 50; i++) {
const out = expandInlineChoices("{2$$, $$a|b|c}");
const parts = out.split(", ");
expect(parts).toHaveLength(2);
expect(new Set(parts).size).toBe(2); // unique (no replacement)
for (const p of parts) expect(["a", "b", "c"]).toContain(p);
expect(out).not.toContain("$$");
}
});
it("range count picks between min and max branches", () => {
const counts = new Set<number>();
for (let i = 0; i < 100; i++) {
const out = expandInlineChoices("{1-2$$, $$a|b|c}");
const parts = out.split(", ");
expect(parts.length).toBeGreaterThanOrEqual(1);
expect(parts.length).toBeLessThanOrEqual(2);
counts.add(parts.length);
expect(out).not.toContain("$$");
}
// Over 100 draws a 1-2 range should exercise both counts.
expect(counts.has(1) && counts.has(2)).toBe(true);
});
it("empty separator concatenates the picks", () => {
for (let i = 0; i < 30; i++) {
const out = expandInlineChoices("{2$$$$a|b}");
expect(["ab", "ba"]).toContain(out);
}
});
it("independent (~) draws WITH replacement — repeats allowed", () => {
let sawRepeat = false;
for (let i = 0; i < 100; i++) {
const out = expandInlineChoices("{2~$$, $$a|b}");
const parts = out.split(", ");
expect(parts).toHaveLength(2);
for (const p of parts) expect(["a", "b"]).toContain(p);
if (parts[0] === parts[1]) sawRepeat = true;
}
expect(sawRepeat).toBe(true); // replacement makes a|a / b|b possible
});
it("user repro: {1-2$$, $$smirk|smug|half-closed eyes} never leaks the raw prefix", () => {
for (let i = 0; i < 50; i++) {
const out = expandInlineChoices(
"dark brown eyes, {1-2$$, $$smirk|smug|half-closed eyes}",
);
expect(out).not.toContain("$$");
expect(out).not.toContain("1-2");
expect(out.startsWith("dark brown eyes, ")).toBe(true);
const picked = out.slice("dark brown eyes, ".length).split(", ");
for (const p of picked) expect(["smirk", "smug", "half-closed eyes"]).toContain(p);
}
});
});
+85 -19
View File
@@ -72,11 +72,71 @@ function stripBranchWeight(branch: string): string {
return branch.slice(i + 2);
}
/** Inclusive random integer in [min, max]. */
function randInt(min: number, max: number): number {
return min + Math.floor(Math.random() * (max - min + 1));
}
/** One weighted pick over raw `N::branch` strings → the resolved value.
* Unweighted (no `N::` on any branch) picks uniformly. */
function pickOneWeighted(rawBranches: string[]): string {
const values = rawBranches.map(stripBranchWeight);
const hasWeights = values.some((v, i) => v !== rawBranches[i]);
if (!hasWeights) {
return values[Math.floor(Math.random() * values.length)];
}
const weights = rawBranches.map(parseBranchWeight);
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
if (total <= 0) return values[0];
let r = Math.random() * total;
for (let i = 0; i < values.length; i++) {
r -= Math.max(0, weights[i]);
if (r <= 0) return values[i];
}
return values[values.length - 1];
}
/** Pick `k` items without replacement, weighted, in pick order. Clamps `k`
* to `values.length`. Mirrors the engine's `pick_k_unique`. */
function pickKUnique(values: string[], weights: number[], k: number): string[] {
const n = Math.min(Math.max(0, k), values.length);
const idx = values.map((_, i) => i);
const w = weights.slice();
const chosen: string[] = [];
for (let c = 0; c < n && idx.length > 0; c++) {
const total = w.reduce((a, x) => a + Math.max(0, x), 0);
let j = 0;
if (total > 0) {
let r = Math.random() * total;
for (; j < w.length; j++) {
r -= Math.max(0, w[j]);
if (r <= 0) break;
}
if (j >= w.length) j = w.length - 1;
}
chosen.push(values[idx[j]]);
idx.splice(j, 1);
w.splice(j, 1);
}
return chosen;
}
// Multi-pick prefix inside a brace block: `N$$sep$$` or `N-M~$$sep$$`.
// Group 1 = count (fixed `N` or range `N-M`), group 2 = optional `~`
// independent flag (repeats allowed; absent = unique), group 3 = separator
// (may be empty), group 4 = the branch source. Mirror of the engine's
// `_MULTI_PREFIX_RE` in engine/syntax/tokenize.py (SP2b).
const MULTI_PREFIX_RE = /^(\d+(?:-\d+)?)(~?)\$\$([\s\S]*?)\$\$([\s\S]*)$/;
/**
* Expand inline `{a|b|c}` choices in a value string. Each occurrence is
* resolved to one of its `|`-separated branches at random (weighted when a
* branch carries an `N::` prefix). Nested braces are resolved innermost-first
* via a fixpoint loop.
* Expand inline choices in a value string:
* - single-pick `{a|b|c}` → one `|`-branch at random (weighted via `N::`).
* - multi-pick `{N-M$$sep$$a|b|c}` → pick N..M branches, joined by `sep`.
* `~` after the count draws WITH replacement; default is unique. Mirrors
* the engine's `_resolve_multi_pick` so the Test Runner preview matches
* the actual pipeline (previously the `N-M$$sep$$` prefix was treated as
* part of the first branch, leaking raw `1-2$$, $$smirk` into renders).
* Nested braces resolve innermost-first via a fixpoint loop.
*
* Important — this function MUST NOT touch `@{...}` refs. The previous
* implementation delegated to `resolveTokens` which tokenized `@{8hex}`
@@ -90,7 +150,7 @@ function stripBranchWeight(branch: string): string {
export function expandInlineChoices(text: string): string {
if (!text) return text;
let out = String(text);
// Repeatedly resolve innermost `{a|b|c}` braces — `[^{}]` rules out
// Repeatedly resolve innermost `{...}` braces — `[^{}]` rules out
// nested braces, the `\|` in the alternation rules out single-branch
// `{abc}` (which has no `|` to split on, intentionally preserved).
// Loop until no replacement happens to handle nested choices.
@@ -98,21 +158,27 @@ export function expandInlineChoices(text: string): string {
const next = out.replace(
/\{([^{}]*\|[^{}]*)\}/g,
(_, content: string) => {
const raw = content.split("|");
const values = raw.map(stripBranchWeight);
const hasWeights = values.some((v, i) => v !== raw[i]);
if (!hasWeights) {
return values[Math.floor(Math.random() * values.length)];
const mp = content.match(MULTI_PREFIX_RE);
if (mp) {
const [minStr, maxStr] = mp[1].split("-");
const cmin = Number(minStr);
const cmax = maxStr !== undefined ? Number(maxStr) : cmin;
const independent = mp[2] === "~";
const sep = mp[3];
const rawBranches = mp[4].split("|");
// A fixed count (min==max) skips the RNG draw, matching the engine.
const count = cmin === cmax ? cmin : randInt(cmin, cmax);
if (count <= 0) return "";
if (independent) {
const picks: string[] = [];
for (let c = 0; c < count; c++) picks.push(pickOneWeighted(rawBranches));
return picks.join(sep);
}
const values = rawBranches.map(stripBranchWeight);
const weights = rawBranches.map(parseBranchWeight);
return pickKUnique(values, weights, Math.min(count, rawBranches.length)).join(sep);
}
const weights = raw.map(parseBranchWeight);
const total = weights.reduce((a, w) => a + Math.max(0, w), 0);
if (total <= 0) return values[0];
let r = Math.random() * total;
for (let i = 0; i < values.length; i++) {
r -= Math.max(0, weights[i]);
if (r <= 0) return values[i];
}
return values[values.length - 1];
return pickOneWeighted(content.split("|"));
},
);
if (next === out) break;
+2 -2
View File
@@ -20,7 +20,7 @@
import type { BundleRow, ModuleRow } from "../api/types";
import { tokenizeRich, varBaseName, type RichToken } from "../../widgets/richTokenize";
import { validateExpression } from "../parsing/subcatFilter";
import { VALID_IDENTIFIER_RE } from "./slug";
import { isValidVariableName } from "../validation/names";
/** A produced-var NAME must be a clean `$varname` identifier
* (`[A-Za-z_][A-Za-z0-9_]*`). Anything else — a comma, space, or other
@@ -30,7 +30,7 @@ import { VALID_IDENTIFIER_RE } from "./slug";
* payload check (Python ≡ TS). Returns true when a NON-EMPTY name is
* invalid (the empty case is reported separately as a "missing" warn). */
function isInvalidVarName(name: string): boolean {
return name.length > 0 && !VALID_IDENTIFIER_RE.test(name);
return name.length > 0 && !isValidVariableName(name);
}
/** Shared error message for an invalid produced-var name. */
+57
View File
@@ -0,0 +1,57 @@
/**
* Canonical name validators — the SINGLE surface every UI site imports so
* that "add", "rename", and any other name-entry point enforce identical
* rules and cannot drift apart. (This module exists because sub-category
* *add* validated with `validateSubcatName` while *rename* hand-rolled a
* weaker check that missed spaces — issue #7. Centralizing removes the
* class of bug, not just the instance.)
*
* Each validator returns a human-readable error message, or `null` when the
* name is valid. There are three grammar surfaces (see the engine's
* `engine/syntax` + `docs`):
*
* - **Sub-category tags** → `validateSubcatName` (whitespace / reserved
* word / disallowed char). The rule lives in `parsing/subcatFilter.ts`
* because it is parity-tested against the engine's shared fixtures; we
* re-export it here so callers still import from one place.
* - **Produced-variable / `$identifier` names** → `validateVariableName`
* (or the boolean `isValidVariableName` for terse per-row surfaces).
* - **Display names embeddable in an `@{uuid#name:subcat}` ref** →
* `validateRefGrammarName` (rejects the structural + comma chars).
*/
import { validateSubcatName } from "../parsing/subcatFilter";
import { VALID_IDENTIFIER_RE } from "../utils/slug";
export { validateSubcatName };
/** Reserved characters for names that flow into the `@{uuid#name:subcat}`
* ref grammar (structural braces/colon/hash/at + the comma that separates
* sub-categories). Mirrors `wp_api/_validators.py`. */
const REF_GRAMMAR_FORBIDDEN_RE = /[{}:#@,]/;
/** Validate a display name that may be embedded in an `@{uuid#name:subcat}`
* ref. Returns an error message or `null`. Whitespace is allowed here —
* wildcard/module display names legitimately contain spaces; only the
* structural characters are forbidden. */
export function validateRefGrammarName(name: string): string | null {
const bad = name.match(REF_GRAMMAR_FORBIDDEN_RE);
if (bad) {
return `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`;
}
return null;
}
/** True when `name` is a valid produced-variable identifier (letter or
* underscore first, then letters/digits/underscores; no spaces). Use this
* for terse per-row surfaces that supply their own message. */
export function isValidVariableName(name: string): boolean {
return VALID_IDENTIFIER_RE.test(name);
}
/** Validate a produced-variable / `$identifier` name. Returns an error
* message or `null`. Empty is treated as invalid — callers that allow an
* empty value (e.g. "use the library default") gate that before calling. */
export function validateVariableName(name: string): string | null {
if (isValidVariableName(name)) return null;
return "Use letters, digits, underscores; must not start with a digit.";
}
+5 -3
View File
@@ -32,7 +32,8 @@ import { useReturnTo } from "../composables/useReturnTo";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { useRecentStore } from "../stores/recentStore";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import { toIdentifier } from "../utils/slug";
import { validateVariableName } from "../validation/names";
import { buildUuidToName } from "../utils/wildcardSyntax";
import { collectLibraryVarHints, type VarHint } from "../utils/library-suggestions";
import { appendSnapshot, readHistory } from "../utils/history";
@@ -373,8 +374,9 @@ async function save() {
return;
}
const finalOutput = outputVar.value.trim() || toIdentifier(name.value);
if (!VALID_IDENTIFIER_RE.test(finalOutput)) {
outputVarError.value = "Use letters, digits, underscores; must not start with a digit.";
const outputVarErr = validateVariableName(finalOutput);
if (outputVarErr) {
outputVarError.value = outputVarErr;
toast.push({ severity: "warn", summary: "Invalid output variable" });
return;
}
+152 -60
View File
@@ -5,8 +5,10 @@
* Sections:
* 1. Identity
* 2. Source / Target wildcards
* 3. Rule matrix (ConstraintMatrix)
* 4. Exceptions table
* 3. Target reach (default `target_select`) — sits above the matrix so the
* "how many targets" decision reads before the per-pair rule grid.
* 4. Rule matrix (ConstraintMatrix)
* 5. Exceptions table
*/
import { computed, onMounted, ref, watch } from "vue";
import type { BreadcrumbItem } from "../components/Breadcrumb.types";
@@ -20,6 +22,9 @@ import CommunityRowActions from "../components/CommunityRowActions.vue";
import DraftBanner from "../components/DraftBanner.vue";
import Input from "../components/ui/Input.vue";
import Select from "../components/ui/Select.vue";
import Checkbox from "../components/ui/Checkbox.vue";
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
import { useBulkSelection } from "../composables/useBulkSelection";
import RichTextPreview from "../components/RichTextPreview.vue";
import ConstraintMatrixGrid from "../components/ConstraintMatrix.vue";
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
@@ -852,6 +857,46 @@ function removeException(idx: number) {
exceptions.value.splice(idx, 1);
}
/* ── Bulk select + delete ────────────────────────────────────────────────
* Exceptions have no persisted id, and adding a payload field is off-limits
* (the engine reads this shape). So key selection by object identity via a
* WeakMap-backed transient uid — stable across edits/adds, never persisted,
* and unaffected by index shifts when a single row is removed mid-selection. */
const exUids = new WeakMap<object, string>();
let exUidSeq = 0;
function exUid(ex: object): string {
let u = exUids.get(ex);
if (!u) {
u = `ex_${exUidSeq++}`;
exUids.set(ex, u);
}
return u;
}
const exBulk = useBulkSelection(() => exceptions.value.map((ex) => exUid(ex)));
const {
active: exBulkActive,
count: exBulkCount,
allSelected: exBulkAllSelected,
someSelected: exBulkSomeSelected,
isSelected: exBulkIsSelected,
toggle: exBulkToggle,
toggleAll: exBulkToggleAll,
toggleMode: exBulkToggleMode,
clear: exBulkClear,
} = exBulk;
function deleteSelectedExceptions(): void {
const ids = new Set(exBulk.selectedIds());
if (ids.size === 0) return;
const removed = ids.size;
exceptions.value = exceptions.value.filter((ex) => !ids.has(exUid(ex)));
exBulkClear();
toast.push({
severity: "success",
summary: `${removed} exception${removed === 1 ? "" : "s"} deleted`,
life: 2500,
});
}
/** Resolve a value-string to its current option `id` on a wildcard.
*
* Returns `undefined` if no match (lets the exception keep source_id
@@ -1217,63 +1262,6 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
</Card>
</div>
<div id="editor-section-matrix">
<Card title="Rule matrix">
<template #actions>
<!-- Stranded ref → the grid is a read-only snapshot, so swap the
"click to edit" affordance for a lock pill. Reattaching a live
wildcard (banner above) is the only way back to editing. -->
<span
v-if="refMissing"
class="cn-lock-pill"
data-test="matrix-readonly-pill"
>
<i class="pi pi-lock" aria-hidden="true" /> Read-only
</span>
<span v-else class="wp-card__hint">Click a cell to edit rule + factor</span>
</template>
<div
v-if="!sourceWildcardId || !targetWildcardId"
class="wp-empty-card"
data-test="matrix-empty"
>
Pick a source and target wildcard to populate the matrix.
</div>
<div
v-else-if="sourceSubCategories.length === 0 || targetSubCategories.length === 0"
class="wp-empty-card"
data-test="matrix-need-subs"
>
<i class="pi pi-info-circle" />
<!-- With the keys-fallback filling the axes for a stranded ref, this
branch only fires when the reconstructed axes are ALSO empty —
i.e. a deleted wildcard whose constraint has no saved rules. -->
<template v-if="refMissing">
The source or target wildcard was deleted and this constraint has no
saved rules — reattach a live wildcard to restore the matrix.
</template>
<template v-else>
<span v-if="sourceSubCategories.length === 0">Source wildcard needs at least one sub-category. </span>
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
Add them on the wildcard editor to define rules.
</template>
</div>
<ConstraintMatrixGrid
v-else
:rows="sourceSubCategories"
:cols="targetSubCategories"
:model-value="matrix"
:source-name="sourceWildcard?.name ?? (sourceWildcardName ?? '')"
:target-name="targetWildcard?.name ?? (targetWildcardName ?? '')"
:source-groups="sourceGroups"
:target-groups="targetGroups"
:readonly="refMissing"
data-test="matrix-grid"
@update:model-value="onMatrixUpdate"
/>
</Card>
</div>
<div id="editor-section-reach">
<Card title="Target reach">
<template #actions>
@@ -1335,16 +1323,91 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
</Card>
</div>
<div id="editor-section-matrix">
<Card title="Rule matrix">
<template #actions>
<!-- Stranded ref → the grid is a read-only snapshot, so swap the
"click to edit" affordance for a lock pill. Reattaching a live
wildcard (banner above) is the only way back to editing. -->
<span
v-if="refMissing"
class="cn-lock-pill"
data-test="matrix-readonly-pill"
>
<i class="pi pi-lock" aria-hidden="true" /> Read-only
</span>
<span v-else class="wp-card__hint">Click a cell to edit rule + factor</span>
</template>
<div
v-if="!sourceWildcardId || !targetWildcardId"
class="wp-empty-card"
data-test="matrix-empty"
>
Pick a source and target wildcard to populate the matrix.
</div>
<div
v-else-if="sourceSubCategories.length === 0 || targetSubCategories.length === 0"
class="wp-empty-card"
data-test="matrix-need-subs"
>
<i class="pi pi-info-circle" />
<!-- With the keys-fallback filling the axes for a stranded ref, this
branch only fires when the reconstructed axes are ALSO empty —
i.e. a deleted wildcard whose constraint has no saved rules. -->
<template v-if="refMissing">
The source or target wildcard was deleted and this constraint has no
saved rules — reattach a live wildcard to restore the matrix.
</template>
<template v-else>
<span v-if="sourceSubCategories.length === 0">Source wildcard needs at least one sub-category. </span>
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
Add them on the wildcard editor to define rules.
</template>
</div>
<ConstraintMatrixGrid
v-else
:rows="sourceSubCategories"
:cols="targetSubCategories"
:model-value="matrix"
:source-name="sourceWildcard?.name ?? (sourceWildcardName ?? '')"
:target-name="targetWildcard?.name ?? (targetWildcardName ?? '')"
:source-groups="sourceGroups"
:target-groups="targetGroups"
:readonly="refMissing"
data-test="matrix-grid"
@update:model-value="onMatrixUpdate"
/>
</Card>
</div>
<div id="editor-section-exceptions">
<Card :title="`Exceptions (${exceptions.length})`" :padding="false">
<Card :title="`Exceptions (${exceptions.length})`" :padding="false" sticky-header>
<template #actions>
<!-- Editing requires a live wildcard — the value pickers are empty
when the ref is missing, so authoring a new exception is hidden
in the read-only recovery view. -->
<Button
v-if="!refMissing && (exceptions.length || exBulkActive)"
size="sm"
:variant="exBulkActive ? 'secondary' : 'ghost'"
icon="pi-check-square"
data-test="cn-ex-bulk-toggle"
@click="exBulkToggleMode"
>{{ exBulkActive ? "Done" : "Bulk edit" }}</Button>
<Button v-if="!refMissing" size="sm" variant="primary" icon="pi-plus" data-test="add-exception" @click="addException">
Add exception
</Button>
</template>
<template v-if="!refMissing" #subheader>
<div v-if="exBulkActive && exBulkCount > 0" class="cn-ex-bulkbar">
<BulkDeleteToolbar
:count="exBulkCount"
noun="exceptions"
@delete-selected="deleteSelectedExceptions"
@clear="exBulkClear"
/>
</div>
</template>
<div v-if="!exceptions.length" class="wp-empty-card">
<i class="pi pi-info-circle" />
Per-pair overrides for specific option values that the matrix doesn't cover.
@@ -1396,6 +1459,15 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
<table v-else class="wp-table wp-options-table cn-ex-table">
<thead>
<tr>
<th v-if="exBulkActive" scope="col" class="cn-ex-col-check">
<Checkbox
:model-value="exBulkAllSelected"
:indeterminate="exBulkSomeSelected"
aria-label="Select all exceptions"
data-test="cn-ex-select-all"
@update:model-value="exBulkToggleAll"
/>
</th>
<th class="cn-ex-th-src">Source value</th>
<th class="cn-ex-th-tgt">Target value</th>
<th class="cn-col-mode">Mode</th>
@@ -1408,8 +1480,25 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
v-for="(ex, idx) in exceptions"
:key="idx"
class="cn-ex-row"
:class="{ 'cn-ex-row--selected': exBulkActive && exBulkIsSelected(exUid(ex)) }"
:style="{ '--cn-mode-var': `var(${modeMeta(ex.mode).cssVar})` }"
>
<td v-if="exBulkActive" class="cn-ex-col-check">
<button
type="button"
class="wp-check"
role="checkbox"
:aria-checked="exBulkIsSelected(exUid(ex))"
:data-checked="exBulkIsSelected(exUid(ex)) ? 'true' : 'false'"
:aria-label="`Select exception ${idx + 1}`"
:data-test="`cn-ex-check-${idx}`"
@click="exBulkToggle(exUid(ex))"
>
<svg v-if="exBulkIsSelected(exUid(ex))" viewBox="0 0 12 12" fill="none" style="display:block">
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</td>
<td>
<Select
:model-value="ex.source"
@@ -1673,6 +1762,9 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
.cn-ex-row {
background: color-mix(in srgb, var(--cn-mode-var, transparent) 5%, transparent);
}
.cn-ex-col-check { width: 34px; text-align: center; }
.cn-ex-row--selected > td { background: color-mix(in oklab, var(--wp-accent) 10%, transparent); }
.cn-ex-bulkbar { padding: 12px 16px 14px; }
/* Mode chip — icon glyph + label, tinted by --cn-mode-var. Editable +
* read-only share the base; the read-only modifier mutes it to the same
+151 -17
View File
@@ -17,6 +17,9 @@ import Button from "../components/ui/Button.vue";
import CommunityRowActions from "../components/CommunityRowActions.vue";
import DraftBanner from "../components/DraftBanner.vue";
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
import Checkbox from "../components/ui/Checkbox.vue";
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
import { useBulkSelection } from "../composables/useBulkSelection";
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
import { useToast } from "../composables/useToast";
import { useUnsavedGuard } from "../composables/useUnsavedGuard";
@@ -293,6 +296,42 @@ function updateRule(idx: number, value: DerivationRule) {
rules.value = rules.value.map((r, i) => (i === idx ? value : r));
}
// Collapse-all / Expand-all broadcast to every rule card. Bumping the nonce
// makes each card adopt `collapsed`; cards can still be toggled individually
// afterwards (#9).
const collapseCommand = ref({ nonce: 0, collapsed: false });
function collapseAllRules(collapsed: boolean): void {
collapseCommand.value = { nonce: collapseCommand.value.nonce + 1, collapsed };
}
/* ── Bulk select + delete ────────────────────────────────────────────────
* Rules are a card list (not a table), so the checkbox rides alongside each
* card. Rules carry a stable `id`, so selection keys off that directly. */
const ruleBulk = useBulkSelection(() => rules.value.map((r) => r.id));
const {
active: ruleBulkActive,
count: ruleBulkCount,
allSelected: ruleBulkAllSelected,
someSelected: ruleBulkSomeSelected,
isSelected: ruleBulkIsSelected,
toggle: ruleBulkToggle,
toggleAll: ruleBulkToggleAll,
toggleMode: ruleBulkToggleMode,
clear: ruleBulkClear,
} = ruleBulk;
function deleteSelectedRules(): void {
const ids = new Set(ruleBulk.selectedIds());
if (ids.size === 0) return;
const removed = ids.size;
rules.value = rules.value.filter((r) => !ids.has(r.id));
ruleBulkClear();
toast.push({
severity: "success",
summary: `${removed} rule${removed === 1 ? "" : "s"} deleted`,
life: 2500,
});
}
function applyRestore(entry: ModuleHistoryEntry): void {
name.value = entry.name;
description.value = entry.description ?? "";
@@ -445,12 +484,58 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
@update:content-rating="(v) => (contentRating = v)"
/>
<Card :title="`Rules (${rules.length})`">
<Card :title="`Rules (${rules.length})`" sticky-header>
<template #actions>
<Button
v-if="rules.length > 1"
size="sm"
variant="ghost"
icon="pi-angle-double-up"
data-test="drv-collapse-all"
@click="collapseAllRules(true)"
>Collapse all</Button>
<Button
v-if="rules.length > 1"
size="sm"
variant="ghost"
icon="pi-angle-double-down"
data-test="drv-expand-all"
@click="collapseAllRules(false)"
>Expand all</Button>
<Button
v-if="rules.length || ruleBulkActive"
size="sm"
:variant="ruleBulkActive ? 'secondary' : 'ghost'"
icon="pi-check-square"
data-test="drv-bulk-toggle"
@click="ruleBulkToggleMode"
>{{ ruleBulkActive ? "Done" : "Bulk edit" }}</Button>
<Button size="sm" variant="primary" icon="pi-plus" data-test="add-rule" @click="addRule">
Add rule
</Button>
</template>
<template #subheader>
<div v-if="ruleBulkActive" class="drv-bulkbar">
<label class="drv-bulkbar__all">
<Checkbox
:model-value="ruleBulkAllSelected"
:indeterminate="ruleBulkSomeSelected"
aria-label="Select all rules"
data-test="drv-select-all"
@update:model-value="ruleBulkToggleAll"
/>
Select all
</label>
<BulkDeleteToolbar
v-if="ruleBulkCount > 0"
class="drv-bulkbar__toolbar"
:count="ruleBulkCount"
noun="rules"
@delete-selected="deleteSelectedRules"
@clear="ruleBulkClear"
/>
</div>
</template>
<p class="wp-card__hint">
Each rule runs independently. Inside a rule, branches evaluate top-to-bottom — the first matching IF/ELIF wins; the optional ELSE only fires when no branch matched.
</p>
@@ -460,24 +545,46 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
</div>
<div v-else class="rules-stack" data-test="rules-stack">
<DerivationRuleCard
<div
v-for="(rule, idx) in rules"
:key="rule.id"
:model-value="rule"
:index="idx"
:var-suggestions="varSuggestions"
:uuid-to-name="uuidToName"
:ref-suggestions="refSuggestions"
:uuid-to-sub-categories="refData.uuidToSubCategories"
:uuid-to-has-null="refData.uuidToHasNull"
:uuid-to-options-count="refData.uuidToOptionsCount"
:uuid-to-option-tag-sets="refData.uuidToOptionTagSets"
:uuid-to-tag-groups="refData.uuidToTagGroups"
:default-collapsed="rules.length > 1"
:data-test="`rule-${idx}`"
@update:model-value="(v) => updateRule(idx, v)"
@remove="removeRule(idx)"
/>
class="rule-row"
:class="{ 'rule-row--selected': ruleBulkActive && ruleBulkIsSelected(rule.id) }"
>
<button
v-if="ruleBulkActive"
type="button"
class="wp-check rule-row__check"
role="checkbox"
:aria-checked="ruleBulkIsSelected(rule.id)"
:data-checked="ruleBulkIsSelected(rule.id) ? 'true' : 'false'"
:aria-label="`Select rule ${idx + 1}`"
:data-test="`drv-check-${idx}`"
@click="ruleBulkToggle(rule.id)"
>
<svg v-if="ruleBulkIsSelected(rule.id)" viewBox="0 0 12 12" fill="none" style="display:block">
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<DerivationRuleCard
class="rule-row__card"
:model-value="rule"
:index="idx"
:var-suggestions="varSuggestions"
:uuid-to-name="uuidToName"
:ref-suggestions="refSuggestions"
:uuid-to-sub-categories="refData.uuidToSubCategories"
:uuid-to-has-null="refData.uuidToHasNull"
:uuid-to-options-count="refData.uuidToOptionsCount"
:uuid-to-option-tag-sets="refData.uuidToOptionTagSets"
:uuid-to-tag-groups="refData.uuidToTagGroups"
:default-collapsed="true"
:collapse-command="collapseCommand"
:data-test="`rule-${idx}`"
@update:model-value="(v) => updateRule(idx, v)"
@remove="removeRule(idx)"
/>
</div>
</div>
</Card>
<!-- CascadeConfirmDialog: shown when entity has downstream refs. -->
@@ -518,4 +625,31 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
flex-direction: column;
gap: var(--wp-space-6);
}
/* Each rule row: an optional bulk checkbox rides to the left of the card. */
.rule-row { display: flex; align-items: flex-start; gap: 10px; }
.rule-row__card { flex: 1 1 auto; min-width: 0; }
.rule-row__check { margin-top: 14px; flex: 0 0 auto; }
.rule-row--selected .rule-row__card {
outline: 2px solid color-mix(in oklab, var(--wp-accent) 45%, transparent);
outline-offset: 2px;
border-radius: var(--wp-radius, 8px);
}
/* Bulk bar in the sticky subheader: select-all + (delete/clear when any). */
.drv-bulkbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px 12px;
flex-wrap: wrap;
}
.drv-bulkbar__all {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--wp-text);
cursor: pointer;
}
.drv-bulkbar__toolbar { flex: 1 1 auto; }
</style>
+81 -5
View File
@@ -20,6 +20,9 @@ import CommunityRowActions from "../components/CommunityRowActions.vue";
import DraftBanner from "../components/DraftBanner.vue";
import RichTextInput from "../components/RichTextInput.vue";
import BulkAddPanel from "../components/BulkAddPanel.vue";
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
import Checkbox from "../components/ui/Checkbox.vue";
import { useBulkSelection } from "../composables/useBulkSelection";
import type { ParsedFixedValue } from "../utils/bulkParse";
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
import { useToast } from "../composables/useToast";
@@ -30,7 +33,7 @@ import { useReturnTo } from "../composables/useReturnTo";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { useRecentStore } from "../stores/recentStore";
import { VALID_IDENTIFIER_RE } from "../utils/slug";
import { isValidVariableName } from "../validation/names";
import { appendSnapshot, readHistory } from "../utils/history";
import { useCascadeStore } from "../cascade/cascade-store";
import { useCascadeApply } from "../cascade/useCascadeApply";
@@ -224,6 +227,34 @@ function commitBulkValues(parsed: ParsedFixedValue[]): void {
toast.push({ severity: "success", summary: `${added} added, ${updated} updated`, life: 2500 });
}
/* ── Bulk select + delete ────────────────────────────────────────────────
* Multi-select value rows to delete many at once (the bulk-ADD panel above
* stays independent). Selection keys off each row's stable `id`. */
const bulk = useBulkSelection(() => values.value.map((v) => v.id));
const {
active: bulkActive,
count: bulkCount,
allSelected: bulkAllSelected,
someSelected: bulkSomeSelected,
isSelected: bulkIsSelected,
toggle: bulkToggle,
toggleAll: bulkToggleAll,
toggleMode: bulkToggleMode,
clear: bulkClear,
} = bulk;
function deleteSelectedValues(): void {
const ids = new Set(bulk.selectedIds());
if (ids.size === 0) return;
const removed = ids.size;
values.value = values.value.filter((v) => !ids.has(v.id));
bulkClear();
toast.push({
severity: "success",
summary: `${removed} value${removed === 1 ? "" : "s"} deleted`,
life: 2500,
});
}
function onVarInput(idx: number, raw: string) {
values.value[idx].name = (raw ?? "").replace(/[^a-zA-Z0-9_]/g, "");
}
@@ -238,7 +269,7 @@ const rowErrors = computed<string[]>(() => {
for (const v of values.value) {
const n = v.name.trim();
if (!n) { errs.push("Required"); continue; }
if (!VALID_IDENTIFIER_RE.test(n)) { errs.push("Invalid identifier"); continue; }
if (!isValidVariableName(n)) { errs.push("Invalid identifier"); continue; }
if ((counts.get(n) ?? 0) > 1) { errs.push("Duplicate name"); continue; }
errs.push("");
}
@@ -402,8 +433,15 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
@update:content-rating="(v) => (contentRating = v)"
/>
<Card :title="`Values (${values.length})`" :padding="false">
<Card :title="`Values (${values.length})`" :padding="false" sticky-header>
<template #actions>
<Button
size="sm"
:variant="bulkActive ? 'secondary' : 'ghost'"
icon="pi-check-square"
data-test="fv-bulk-toggle"
@click="bulkToggleMode"
>{{ bulkActive ? "Done" : "Bulk edit" }}</Button>
<Button
size="sm"
:variant="bulkAddOpen ? 'secondary' : 'ghost'"
@@ -415,17 +453,36 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
Add value
</Button>
</template>
<div v-if="bulkAddOpen" class="wpc-bulk-controls">
<template #subheader>
<div v-if="bulkAddOpen || (bulkActive && bulkCount > 0)" class="wpc-bulk-controls">
<BulkAddPanel
v-if="bulkAddOpen"
mode="values"
:existing-values="existingValueNames"
@commit-values="commitBulkValues"
@cancel="bulkAddOpen = false"
/>
<BulkDeleteToolbar
v-if="bulkActive && bulkCount > 0"
:count="bulkCount"
noun="values"
@delete-selected="deleteSelectedValues"
@clear="bulkClear"
/>
</div>
</template>
<table class="wp-table wp-options-table">
<thead>
<tr>
<th v-if="bulkActive" scope="col" class="fv-col-check">
<Checkbox
:model-value="bulkAllSelected"
:indeterminate="bulkSomeSelected"
aria-label="Select all values"
data-test="fv-bulk-select-all"
@update:model-value="bulkToggleAll"
/>
</th>
<th class="fv-col-var">Variable</th>
<th>Value</th>
<th class="fv-col-trash" />
@@ -437,7 +494,24 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
:key="v.id"
:data-test="`fv-row-${idx}`"
:data-invalid="rowErrors[idx] ? 'true' : 'false'"
:class="{ 'fv-row--selected': bulkActive && bulkIsSelected(v.id) }"
>
<td v-if="bulkActive" class="fv-col-check">
<button
type="button"
class="wp-check"
role="checkbox"
:aria-checked="bulkIsSelected(v.id)"
:data-checked="bulkIsSelected(v.id) ? 'true' : 'false'"
:aria-label="`Select value ${idx + 1}`"
:data-test="`fv-check-${idx}`"
@click="bulkToggle(v.id)"
>
<svg v-if="bulkIsSelected(v.id)" viewBox="0 0 12 12" fill="none" style="display:block">
<path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</td>
<td>
<div class="wp-input-group">
<span class="wp-input-group__addon">$</span>
@@ -479,7 +553,7 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
</td>
</tr>
<tr v-if="!values.length">
<td colspan="3" class="opt-empty">No values yet.</td>
<td :colspan="bulkActive ? 4 : 3" class="opt-empty">No values yet.</td>
</tr>
</tbody>
</table>
@@ -518,6 +592,8 @@ const breadcrumb = computed<BreadcrumbItem[]>(() => [
color: var(--wp-text-muted);
}
.fv-col-var { width: 220px; }
.fv-col-check { width: 34px; text-align: center; }
.fv-row--selected > td { background: color-mix(in oklab, var(--wp-accent) 8%, transparent); }
.wpc-bulk-controls {
display: flex;
flex-direction: column;
+36
View File
@@ -6,6 +6,8 @@ import { useToast } from "../composables/useToast";
import { useResolveWarnings } from "../composables/useResolveWarnings";
import { useModuleStore } from "../stores/moduleStore";
import { useBundleStore } from "../stores/bundleStore";
import { useTemplateStore } from "../stores/templateStore";
import { useCategoryStore } from "../stores/categoryStore";
import { api, ApiError } from "../api/client";
import ExportTab from "../import-export/ExportTab.vue";
import ImportTab from "../import-export/ImportTab.vue";
@@ -52,6 +54,8 @@ type Mode = "export" | "import";
const toast = useToast();
const moduleStore = useModuleStore();
const bundleStore = useBundleStore();
const templateStore = useTemplateStore();
const categoryStore = useCategoryStore();
const mode = ref<Mode>("export");
@@ -264,6 +268,23 @@ function buildSelectedEntities(
}
const templateColl: Record<string, SelectedEntity["collision"]> =
templateRows.length > 0 ? detectTemplateCollisions(templateRows, library) : {};
// Bundles were historically excluded from collision detection (deferred to
// a `bundle-fingerprint.ts` MOD-detection flow that was never built), so an
// existing bundle fell through to a blind `add` decision — the server then
// 400'd the whole commit with "id collision on add" and the bundles never
// imported. Bundles live in the same id→fingerprint library map as modules
// (buildLibraryMap), so route them through the SAME detector: identical →
// silent-skip, present-but-changed / no library fingerprint → conflict /
// exists-unknown, both of which surface in the conflict modal for the user
// to Replace / Rename / Skip, exactly like a colliding module.
const bundleRows: Array<FingerprintModuleRow & { id: string }> = [];
for (const id of selection) {
const hit = idIndex.get(id);
if (!hit || hit.kind !== "bundle") continue;
bundleRows.push(hit.entity as unknown as FingerprintModuleRow & { id: string });
}
const bundleColl: Record<string, SelectedEntity["collision"]> =
bundleRows.length > 0 ? detectCollisions(bundleRows, library) : {};
for (const id of selection) {
const hit = idIndex.get(id);
if (!hit) continue;
@@ -272,7 +293,10 @@ function buildSelectedEntities(
collision = templateColl[id] ?? "no-collision";
} else if (MODULE_KINDS.has(hit.kind)) {
collision = collisionByKind[hit.kind]?.[id] ?? "no-collision";
} else if (hit.kind === "bundle") {
collision = bundleColl[id] ?? "no-collision";
} else {
// Categories merge by name server-side — no id collision concept.
collision = "no-collision";
}
result.push({ kind: hit.kind, entity: hit.entity, collision });
@@ -650,6 +674,12 @@ async function runCommit(resolution: {
loadLibrary(),
moduleStore.fetchCatalog(),
bundleStore.fetchCatalog(),
// Templates + categories drive their own sidebar count badges; refresh
// them too so the counts update live after an import/undo (#7) — an
// import that adds templates or categories used to leave a stale count
// until the next route change forced a re-fetch.
templateStore.fetchCatalog(),
categoryStore.fetchAll(),
]);
const libraryIds = new Set<string>();
for (const m of localModules.value) libraryIds.add(m.id);
@@ -703,6 +733,12 @@ async function undoImport(undoEntryId: string): Promise<void> {
loadLibrary(),
moduleStore.fetchCatalog(),
bundleStore.fetchCatalog(),
// Templates + categories drive their own sidebar count badges; refresh
// them too so the counts update live after an import/undo (#7) — an
// import that adds templates or categories used to leave a stale count
// until the next route change forced a re-fetch.
templateStore.fetchCatalog(),
categoryStore.fetchAll(),
]);
resolveWarningsStore.clearByType("broken_ref_on_import");
toast.push({ severity: "info", summary: "Import undone", life: 4000 });
+59
View File
@@ -8,18 +8,41 @@
* with dot-prefix fix) and DatabaseCard (server-side SQLite info +
* maintenance ops).
*/
import { computed } from "vue";
import Card from "../components/ui/Card.vue";
import Field from "../components/ui/Field.vue";
import Icon from "../components/ui/Icon.vue";
import Input from "../components/ui/Input.vue";
import Toggle from "../components/ui/Toggle.vue";
import Button from "../components/ui/Button.vue";
import BrowserPrefsCard from "../components/settings/BrowserPrefsCard.vue";
import DatabaseCard from "../components/settings/DatabaseCard.vue";
import { useUiStore, type ThemeMode } from "../stores/uiStore";
import { useReleaseCheck } from "../composables/useReleaseCheck";
import { GITHUB_REPO } from "../config/links";
const uiStore = useUiStore();
const { latestVersion, hasUpdate, lastChecked, checking, checkNow } = useReleaseCheck();
const updateStatus = computed(() =>
hasUpdate.value && latestVersion.value
? `Update v${latestVersion.value} available`
: "Up to date",
);
const lastCheckedLabel = computed(() => {
if (!lastChecked.value) return "never checked";
const then = new Date(lastChecked.value).getTime();
if (Number.isNaN(then)) return "never checked";
const mins = Math.round((Date.now() - then) / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins} min ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs} h ago`;
return `${Math.round(hrs / 24)} d ago`;
});
const repoUrl = GITHUB_REPO;
// Build-time injected via vite `define` (see vite.config.mts) — source
// of truth is package.json, which semantic-release stamps on every
@@ -67,6 +90,32 @@ function setTheme(mode: ThemeMode) {
</div>
</Card>
<Card title="Updates">
<Field
label="Check for updates on launch"
hint="Look for a newer release when the manager opens. Turn off to check only manually."
>
<Toggle
:model-value="uiStore.checkOnLaunch"
label="Check on launch"
data-test="settings-check-on-launch"
@update:model-value="uiStore.setCheckOnLaunch($event)"
/>
</Field>
<div class="wp-settings__update-row">
<div>
<p class="wp-settings__update-status" data-test="settings-update-status">{{ updateStatus }}</p>
<p class="wp-dim wp-settings__update-sub">Last checked · {{ lastCheckedLabel }}</p>
</div>
<Button
variant="secondary"
:loading="checking"
data-test="settings-check-now"
@click="checkNow"
>Check now</Button>
</div>
</Card>
<Card title="Theme">
<p class="wp-dim wp-settings__hint">
Choose dark, light, or follow the OS preference.
@@ -158,6 +207,16 @@ function setTheme(mode: ThemeMode) {
margin: 0 0 var(--wp-space-5);
}
.wp-settings__update-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--wp-space-4);
margin-top: var(--wp-space-4);
}
.wp-settings__update-status { margin: 0; font-size: var(--wp-text-sm); color: var(--wp-text); font-weight: 600; }
.wp-settings__update-sub { margin: 2px 0 0; font-size: var(--wp-text-sm); }
.wp-settings__radio {
display: inline-flex;
gap: var(--wp-space-3);
+44 -12
View File
@@ -38,8 +38,8 @@ import { useReturnTo } from "../composables/useReturnTo";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import { useRecentStore } from "../stores/recentStore";
import { toIdentifier, VALID_IDENTIFIER_RE } from "../utils/slug";
import { validateSubcatName } from "@/manager/parsing/subcatFilter";
import { toIdentifier } from "../utils/slug";
import { validateSubcatName, validateRefGrammarName, isValidVariableName } from "@/manager/validation/names";
import {
buildWildcardRefData,
collectLibraryWildcardRefs,
@@ -950,6 +950,34 @@ const someSelected = computed(() =>
selectableOptions.value.some((o) => selectedIds.value.has(o.id as string)),
);
/** Sub-categories present on ≥1 selected row (union), in registry order —
* the Remove menu's candidate list, so it only offers tags the selection
* actually carries (removing a tag no row has would be a no-op). */
const presentSelectedTags = computed<string[]>(() => {
const present = new Set<string>();
for (const o of options.value) {
if (!isSelected(o.id)) continue;
for (const t of o.sub_categories ?? []) present.add(t);
}
return subCategories.value.filter((t) => present.has(t));
});
/** Sub-categories already on EVERY selected row (intersection) — excluded
* from the Apply menu since applying one there changes nothing. */
const commonSelectedTags = computed<string[]>(() => {
const sel = options.value.filter((o) => isSelected(o.id));
if (sel.length === 0) return [];
return subCategories.value.filter((t) =>
sel.every((o) => (o.sub_categories ?? []).includes(t)),
);
});
/** tag → axis hue, so the Apply/Remove menu chips read with the same colour
* as the pills and option-row chips (shared `tagHue`). */
const selectedTagHues = computed<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const t of subCategories.value) m[t] = tagHue(t);
return m;
});
function toggleBulkMode(): void {
bulkMode.value = !bulkMode.value;
if (!bulkMode.value) {
@@ -1156,7 +1184,7 @@ async function save() {
// Update varBindingError synchronously so the rollup picks it up
// in the same tick. The validation computed reads this ref.
const finalBinding = varBinding.value.trim() || toIdentifier(name.value);
if (varBinding.value.trim() && !VALID_IDENTIFIER_RE.test(finalBinding)) {
if (varBinding.value.trim() && !isValidVariableName(finalBinding)) {
varBindingError.value = "Use letters, digits, underscores; must not start with a digit.";
} else {
varBindingError.value = "";
@@ -1261,17 +1289,16 @@ const validationErrors = computed<EditorFieldError[]>(() => {
if (!name.value.trim()) {
out.push({ field: "editor-section-identity", label: "Name", message: "Required" });
} else {
// Wildcard names become the `#name` segment of nested refs.
// Forbidding the grammar-reserved chars here mirrors
// `wp_api/_validators.py:REF_GRAMMAR_FORBIDDEN_CHARS` so the
// server never has to reject a save the editor already accepted.
const REF_FORBIDDEN = /[{}:#@,]/;
const bad = name.value.match(REF_FORBIDDEN);
if (bad) {
// Wildcard names become the `#name` segment of nested refs. The
// canonical rule (mirrors `wp_api/_validators.py`) lives in
// `validation/names` so the editor rejects exactly what the rename
// dialog + server reject.
const refErr = validateRefGrammarName(name.value);
if (refErr) {
out.push({
field: "editor-section-identity",
label: "Name",
message: `Cannot contain "${bad[0]}" (reserved by the @{uuid#name:subcat} ref grammar)`,
message: refErr,
});
}
}
@@ -1515,7 +1542,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
</div>
<div id="editor-section-options">
<Card :title="`Options (${options.length})`" :padding="false">
<Card :title="`Options (${options.length})`" :padding="false" sticky-header>
<template #actions>
<Button
size="sm"
@@ -1544,6 +1571,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
Add option
</Button>
</template>
<template #subheader>
<div v-if="bulkMode && (bulkAddOpen || selectedCount > 0 || bulkNote)" class="wpc-bulk-controls">
<BulkAddPanel
v-if="bulkAddOpen"
@@ -1557,6 +1585,9 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
v-if="selectedCount > 0"
:count="selectedCount"
:tags="subCategories"
:common-tags="commonSelectedTags"
:present-tags="presentSelectedTags"
:tag-hues="selectedTagHues"
@apply-tag="applyTagToSelected"
@remove-tag="removeTagFromSelected"
@set-weight="setWeightSelected"
@@ -1565,6 +1596,7 @@ defineExpose({ historyEntries, applyRestore, options, subCategories, tagGroups }
/>
<p v-if="bulkNote" class="wpc-bulk-note" role="status">{{ bulkNote }}</p>
</div>
</template>
<table class="wp-table wp-options-table">
<thead>
<tr>
+22 -5
View File
@@ -64,12 +64,17 @@ export function create(node: SeedListHostNode, inputName: string) {
Object.is,
);
// Loop series params + the loop's bypassed frames (for the read-only
// bypass mirror in the Seed List modal).
type LoopSeries = SeriesParams & { bypassFrames: number[] };
// Walk the `loop_config` input to its upstream WP_ContextLoop node and
// read its stock `seed` + `count` widgets plus `wp_context_loop_config`
// strategy. Returns null when unwired. Reactive so the preview updates
// strategy + bypass_frames. Returns null when unwired. Reactive so the
// preview updates
// whenever the wire or upstream widgets change (same poll+event model
// as the baseSeed/count reads above).
function readLoopSeries(): SeriesParams | null {
function readLoopSeries(): LoopSeries | null {
const inputs = (node as { inputs?: Array<{ name?: string; link?: number | null }> }).inputs;
if (!inputs) return null;
const slot = inputs.find((i) => i.name === "loop_config");
@@ -85,13 +90,18 @@ export function create(node: SeedListHostNode, inputName: string) {
const loopCount = Number(widgets.find((w) => w.name === "count")?.value ?? 1);
const rawCfg = widgets.find((w) => w.name === "wp_context_loop_config")?.value;
const loopCfg = parseContextLoopConfig(typeof rawCfg === "string" ? rawCfg : null);
return { base: loopBase, count: loopCount, strategy: loopCfg.strategy };
return {
base: loopBase, count: loopCount, strategy: loopCfg.strategy,
bypassFrames: loopCfg.bypass_frames,
};
}
function loopParamsEqual(a: SeriesParams | null, b: SeriesParams | null): boolean {
function loopParamsEqual(a: LoopSeries | null, b: LoopSeries | null): boolean {
if (a === null && b === null) return true;
if (a === null || b === null) return false;
return a.base === b.base && a.count === b.count && a.strategy === b.strategy;
return a.base === b.base && a.count === b.count && a.strategy === b.strategy
&& a.bypassFrames.length === b.bypassFrames.length
&& a.bypassFrames.every((v, i) => v === b.bypassFrames[i]);
}
const loopSeries = reactiveFromGraph(node, readLoopSeries, loopParamsEqual);
@@ -147,6 +157,12 @@ export function create(node: SeedListHostNode, inputName: string) {
override_strategy: config.value.override_strategy,
};
const effective = resolveSeedListPreview(localSeries, flags, loopSeries.value);
// Mirror the loop's bypass ONLY when the count comes from the loop —
// that's when this node's frames align 1:1 with the loop's.
const loopBypass =
config.value.override_count && loopSeries.value
? loopSeries.value.bypassFrames
: [];
return h(SeedListWidget, {
modelValue: config.value,
nodeMode: nodeMode.value,
@@ -154,6 +170,7 @@ export function create(node: SeedListHostNode, inputName: string) {
count: effective.count,
previewStrategy: effective.strategy,
previousSeeds: previousSeeds.value,
bypassedFrames: loopBypass,
"onUpdate:modelValue": onUpdate,
});
};
+5 -2
View File
@@ -19,6 +19,7 @@ _DEFAULTS = {
"iteration_internal": True,
"total_internal": True,
"seed_locks": {},
"bypass_frames": [],
}
@@ -35,14 +36,14 @@ _DEFAULTS = {
{"strategy": "sequential", "override_seed": True,
"iteration_var_name": "idx", "bypass": True,
"iteration_internal": True, "total_internal": True,
"seed_locks": {}},
"seed_locks": {}, "bypass_frames": []},
),
(
'{"strategy": "wat", "override_seed": true}',
{"strategy": "hash_index", "override_seed": True,
"iteration_var_name": "iteration", "bypass": False,
"iteration_internal": True, "total_internal": True,
"seed_locks": {}},
"seed_locks": {}, "bypass_frames": []},
),
('{"iteration_var_name": " "}', _DEFAULTS),
(
@@ -194,6 +195,8 @@ def test_execute_emits_loop_config_payload_on_second_output():
loop_config = values[1]
assert loop_config == {
"count": 3,
"active_count": 3,
"kept_indices": [0, 1, 2],
"strategy": "sequential",
"base_seed": 42,
"override_seed": True,
+35 -29
View File
@@ -4,8 +4,6 @@ from __future__ import annotations
import random
import pytest
from engine.modules import FixedValueEntry, FixedValueModule
from engine.pipeline import PipelineEngine
@@ -25,9 +23,6 @@ class TestPipelineRun:
ctx = PipelineEngine().run([module], seed=0)
assert ctx["style"] == "photoreal"
# TODO(syntax-task-15): trace writes now always include `overwrite` and
# `status` fields; old assertion expects legacy shape without these keys.
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: trace shape changed")
def test_trace_records_new_write(self):
module = FixedValueModule(
id="m1",
@@ -40,8 +35,17 @@ class TestPipelineRun:
assert entry["id"] == "m1"
assert entry["type"] == "fixed_values"
assert entry["enabled"] is True
assert entry["status"] == "ok"
assert entry["error"] is None
# Every write now carries an `overwrite` flag; a first write into a
# fresh variable is not an overwrite.
assert entry["writes"] == [
{"variable": "style", "value": "photoreal", "source": "fixed_values"}
{
"variable": "style",
"value": "photoreal",
"source": "fixed_values",
"overwrite": False,
}
]
def test_trace_records_overwrite_flag(self):
@@ -58,18 +62,23 @@ class TestPipelineRun:
}
]
# TODO(syntax-task-15): disabled modules now emit a trace entry with
# status="skipped_disabled"; old test expects disabled modules absent from trace.
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: disabled trace entry added")
def test_disabled_module_skipped(self):
m1 = FixedValueModule(
id="m1", enabled=False, entries=[FixedValueEntry("x", "1")]
)
m2 = FixedValueModule(id="m2", entries=[FixedValueEntry("y", "2")])
ctx = PipelineEngine().run([m1, m2], seed=0)
# A disabled module still writes nothing to ctx...
assert "x" not in ctx
assert ctx["y"] == "2"
assert [e["id"] for e in ctx["__wp_trace__"]] == ["m2"]
# ...but now leaves a trace row so the debug viewer can show it was
# skipped (rather than silently vanishing).
trace = ctx["__wp_trace__"]
assert [e["id"] for e in trace] == ["m1", "m2"]
assert trace[0]["status"] == "skipped_disabled"
assert trace[0]["enabled"] is False
assert trace[0]["writes"] == []
assert trace[1]["status"] == "ok"
def test_inherits_upstream_ctx(self):
upstream = {"upstream_var": "hello"}
@@ -88,9 +97,6 @@ class TestPipelineRun:
ids = [e["id"] for e in ctx["__wp_trace__"]]
assert ids == ["prev", "m1"]
# TODO(syntax-task-15): unknown types now emit a skipped_unknown_type trace
# entry; old test expects empty trace on unknown type.
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: unknown types emit trace")
def test_unknown_type_is_skipped(self, caplog):
class Weird:
id = "weird"
@@ -98,7 +104,14 @@ class TestPipelineRun:
enabled = True
ctx = PipelineEngine().run([Weird()], seed=0) # type: ignore[list-item]
assert ctx["__wp_trace__"] == []
# An unknown module type is skipped but now leaves a trace row so the
# debug viewer surfaces the unrecognised module instead of hiding it.
trace = ctx["__wp_trace__"]
assert len(trace) == 1
assert trace[0]["id"] == "weird"
assert trace[0]["type"] == "does_not_exist"
assert trace[0]["status"] == "skipped_unknown_type"
assert trace[0]["writes"] == []
assert any(
"Unknown module type" in rec.message for rec in caplog.records
)
@@ -192,21 +205,14 @@ class TestPipelineRun:
assert trace[1]["id"] == "fv1"
assert trace[1]["seed"] == 4242
# TODO(syntax-task-15): HANDLERS dict removed; pipeline now delegates to
# dispatcher.resolve_module. Stub-handler injection via HANDLERS no longer works.
@pytest.mark.skip(reason="awaits handler migration in tasks 15-17: HANDLERS dict removed")
def test_seed_deterministic_rng_passed_to_handlers(self):
captured: list[random.Random] = []
def stub_handler(module, ctx, rng):
captured.append(rng)
return ctx
engine = PipelineEngine()
engine.HANDLERS = {**engine.HANDLERS, "fixed_values": stub_handler}
module = FixedValueModule(id="m1", entries=[])
engine.run([module], seed=12345)
assert len(captured) == 1
rng = captured[0]
# The pipeline no longer owns a HANDLERS dict; it delegates to
# dispatcher.resolve_module and threads randomness by seeding
# `ctx["__wp_rng__"]`, which handlers read off the context. Verify the
# pipeline seeds that rng deterministically from the `seed` arg. Run
# with no modules so nothing consumes the rng before we inspect it.
ctx = PipelineEngine().run([], seed=12345)
rng = ctx["__wp_rng__"]
assert isinstance(rng, random.Random)
other = random.Random(12345)
assert rng.random() == other.random()
+88
View File
@@ -43,3 +43,91 @@ def test_execute_emits_empty_ui_seeds_when_override_off():
raw = json.dumps({"strategy": "sequential", "override_seed": False})
out = WPContextLoop.execute(seed=42, count=4, wp_context_loop_config=raw)
assert out.ui == {"loop_seeds": [[]]}
def test_parse_config_bypass_frames_clean():
cfg = _parse_config('{"bypass_frames": [3, 1, 3, 0]}')
# deduped + sorted, 0-based
assert cfg["bypass_frames"] == [0, 1, 3]
def test_parse_config_bypass_frames_drops_junk_keeps_out_of_range():
# non-ints + negatives dropped; large (out-of-range at any count) KEPT
cfg = _parse_config('{"bypass_frames": [2, -1, "x", 2.0, 999]}')
assert cfg["bypass_frames"] == [2, 999]
def test_parse_config_bypass_frames_default_and_malformed():
assert _parse_config("{}")["bypass_frames"] == []
assert _parse_config('{"bypass_frames": "nope"}')["bypass_frames"] == []
assert _parse_config("not json")["bypass_frames"] == []
def _run(seed, count, cfg):
out = WPContextLoop.execute(
seed=seed, count=count, wp_context_loop_config=json.dumps(cfg)
)
return out.values[0], out.values[1] # (payloads, loop_config)
def test_bypass_frames_emits_kept_with_pinned_index():
# count 5, bypass frames 1 and 3 (0-based) -> kept 0,2,4
payloads, loop_config = _run(0, 5, {"override_seed": True, "bypass_frames": [1, 3]})
idxs = [p.internals["__wp_loop_index__"] for p in payloads]
assert idxs == [0, 2, 4]
# iteration counter is the ORIGINAL 1-based number; total = configured count
assert payloads[1].context["iteration"] == "3" # frame index 2 -> "3"
assert all(p.context["iteration_total"] == "5" for p in payloads)
assert loop_config["count"] == 5
assert loop_config["active_count"] == 3
def test_bypass_pins_kept_frame_seeds():
# A kept frame's override seed is identical with and without a DIFFERENT
# frame bypassed (pinned identity).
full, _ = _run(0, 5, {"override_seed": True, "bypass_frames": []})
partial, _ = _run(0, 5, {"override_seed": True, "bypass_frames": [1]})
seed_by_idx = {
p.internals["__wp_loop_index__"]: p.internals["__wp_seed_override__"]
for p in full
}
for p in partial:
i = p.internals["__wp_loop_index__"]
assert p.internals["__wp_seed_override__"] == seed_by_idx[i]
def test_bypass_all_floors_to_full_run():
payloads, loop_config = _run(0, 3, {"bypass_frames": [0, 1, 2]})
idxs = [p.internals["__wp_loop_index__"] for p in payloads]
assert idxs == [0, 1, 2]
assert loop_config["active_count"] == 3
def test_out_of_range_bypass_is_inert():
# frame index 9 does not exist at count 3 -> ignored, all 3 emitted
payloads, _ = _run(0, 3, {"bypass_frames": [9]})
assert [p.internals["__wp_loop_index__"] for p in payloads] == [0, 1, 2]
def test_whole_loop_bypass_still_single_run():
payloads, loop_config = _run(0, 8, {"bypass": True, "bypass_frames": [2, 4]})
assert len(payloads) == 1
assert payloads[0].internals["__wp_loop_index__"] == 0
assert loop_config["count"] == 1
assert loop_config["active_count"] == 1
def test_bypass_frame_zero():
# Frame 0 is bypassable — seed-hold's base pass runs internally regardless.
payloads, _ = _run(0, 3, {"bypass_frames": [0]})
assert [p.internals["__wp_loop_index__"] for p in payloads] == [1, 2]
def test_out_of_range_bypass_reapplies_when_count_grows():
# Index 9 is inert at count 3 but takes effect once count grows past it.
at3, _ = _run(0, 3, {"bypass_frames": [9]})
assert [p.internals["__wp_loop_index__"] for p in at3] == [0, 1, 2]
at10, _ = _run(0, 10, {"bypass_frames": [9]})
idxs = [p.internals["__wp_loop_index__"] for p in at10]
assert 9 not in idxs
assert idxs == [0, 1, 2, 3, 4, 5, 6, 7, 8]
+44
View File
@@ -543,3 +543,47 @@ def test_execute_output_reflects_seed_locks():
)
seeds = _seeds(out)
assert seeds == [10, 77, 12]
def test_execute_override_count_respects_loop_kept_indices():
# Loop bypassed frames 1 + 3 of 5 -> kept_indices [0,2,4]. The seed list
# must emit those 3 seeds (drawn from the full 5-seed series), not 5.
loop_config = {
"count": 5, "strategy": "hash_index", "base_seed": 42,
"kept_indices": [0, 2, 4],
}
out = WPSeedList.execute(
base_seed=42, count=99,
wp_seed_list_config=_cfg(strategy="hash_index", override_count=True),
loop_config=loop_config,
)
full = derive_loop_seeds(42, 5, "hash_index")
assert _seeds(out) == [full[0], full[2], full[4]]
def test_execute_kept_indices_ignored_when_override_count_off():
# Not mirroring the loop's count -> the loop's kept_indices don't apply.
loop_config = {
"count": 5, "strategy": "hash_index", "base_seed": 42,
"kept_indices": [0, 2, 4],
}
out = WPSeedList.execute(
base_seed=42, count=4,
wp_seed_list_config=_cfg(strategy="hash_index", override_count=False),
loop_config=loop_config,
)
assert _seeds(out) == derive_loop_seeds(42, 4, "hash_index")
def test_execute_full_kept_indices_is_noop():
# A full kept list (no bypass) leaves the emit unchanged.
loop_config = {
"count": 3, "strategy": "hash_index", "base_seed": 42,
"kept_indices": [0, 1, 2],
}
out = WPSeedList.execute(
base_seed=42, count=99,
wp_seed_list_config=_cfg(strategy="hash_index", override_count=True),
loop_config=loop_config,
)
assert _seeds(out) == derive_loop_seeds(42, 3, "hash_index")
+66 -19
View File
@@ -40,6 +40,7 @@ def _parse_config(raw: str) -> dict[str, object]:
"iteration_internal": True,
"total_internal": True,
"seed_locks": {},
"bypass_frames": [],
}
if not raw or not isinstance(raw, str):
return defaults
@@ -68,6 +69,21 @@ def _parse_config(raw: str) -> dict[str, object]:
except (TypeError, ValueError):
continue
out["seed_locks"] = locks
frames_raw = parsed.get("bypass_frames", [])
frames: set[int] = set()
if isinstance(frames_raw, list):
for x in frames_raw:
if isinstance(x, bool): # bool is an int subclass — exclude
continue
if isinstance(x, int):
n = x
elif isinstance(x, float) and x.is_integer():
n = int(x)
else:
continue
if n >= 0:
frames.add(n)
out["bypass_frames"] = sorted(frames)
return out
@@ -147,27 +163,56 @@ class WPContextLoop(io.ComfyNode):
iteration_internal = bool(cfg["iteration_internal"])
total_internal = bool(cfg["total_internal"])
seed_locks: dict[int, int] = cfg["seed_locks"] # type: ignore[assignment]
bypass_frames: list[int] = cfg["bypass_frames"] # type: ignore[assignment]
# Resolve effective count. `bypass` collapses to single-run while
# preserving the list-shape output contract — downstream still
# sees an `is_list=True` socket with one element.
effective_count = 1 if bypass else max(1, int(count))
# Resolve which frames to emit + the seed series they draw from.
#
# - Whole-loop `bypass` (existing behavior) collapses to a single
# run while preserving the list-shape output contract.
# - Per-frame bypass (`bypass_frames`, 0-based) skips individual
# iterations WITHOUT renumbering the kept ones: each kept frame
# keeps its original index as `__wp_loop_index__`, so it draws
# `derived[idx]` and its per-frame overrides still key correctly.
# Out-of-range indices are ignored; an all-bypassed config floors
# to a full run so the `is_output_list` socket is never empty.
# The seed series is derived for the FULL count (not the reduced
# one) so kept frame N always draws `derived[N]`.
has_override = override_seed
configured_count = max(1, int(count))
if bypass:
total_count = 1
kept = [0]
else:
in_range_bypassed = {
i for i in bypass_frames if 0 <= i < configured_count
}
kept = [i for i in range(configured_count) if i not in in_range_bypassed]
if not kept:
kept = list(range(configured_count))
total_count = configured_count
derived = (
apply_seed_locks(
derive_loop_seeds(int(seed), effective_count, strategy),
derive_loop_seeds(int(seed), total_count, strategy),
seed_locks,
)
if has_override else None
)
# Build the loop_config payload once so the same dict drives both
# the per-iteration internals AND the side output. `count` here
# is the EFFECTIVE count (post-bypass) so downstream sees what
# the loop is actually running, not the raw widget value.
# `base_seed` carries the user's `seed` input verbatim so
# WP_SeedList can re-derive the same series locally.
# the per-iteration internals AND the side output. `count` is the
# configured/effective total (so WP_SeedList + the seed modal
# re-derive the identical full series); `active_count` is how many
# frames actually emit after per-frame bypass. `base_seed` carries
# the user's `seed` input verbatim so WP_SeedList can re-derive the
# same series locally.
loop_config_payload: dict[str, object] = {
"count": effective_count,
"count": total_count,
"active_count": len(kept),
# The 0-based indices this loop actually emits (== range(count)
# for a normal run). A wired WP_SeedList mirrors this so it emits
# ONE seed per kept frame — otherwise it would derive the full
# `count` seeds and the sampler would run more times than the
# loop has prompts.
"kept_indices": list(kept),
"strategy": strategy,
"base_seed": int(seed),
"override_seed": has_override,
@@ -186,16 +231,18 @@ class WPContextLoop(io.ComfyNode):
internal_flags[total_var_name] = True
payloads = []
for idx in range(effective_count):
# User-facing iteration counter is 1-based — reads naturally
# in PromptAssembler templates (`iteration 1 of 4`) and in
# the debug viewer. The internal `__wp_loop_index__` below
# stays 0-based because `derive_loop_seeds[idx]` indexes the
# derived series + `effective_chain_seed` math assumes
# zero-origin (loop_index=0 means "no XOR shift").
for idx in kept:
# User-facing iteration counter is the frame's ORIGINAL 1-based
# number (bypass keeps identity — frame 5 stays "5 of 18", not
# renumbered). The internal `__wp_loop_index__` below stays the
# 0-based original index because `derived[idx]` indexes the
# full series + `effective_chain_seed` math assumes zero-origin
# (loop_index=0 means "no XOR shift"). `total` is the configured
# count so bypassing a frame never rewrites the other frames'
# "of N" text (which would bust their downstream caches).
context_vars = {
iteration_var_name: str(idx + 1),
total_var_name: str(effective_count),
total_var_name: str(total_count),
}
internals: dict[str, object] = {
"__wp_loop_index__": idx,
+20 -1
View File
@@ -247,10 +247,29 @@ class WPSeedList(io.ComfyNode):
override_strategy=override_strategy,
loop_config=loop_config,
)
seeds = apply_seed_locks(
full_series = apply_seed_locks(
derive_loop_seeds(resolved_base, resolved_count, resolved_strategy),
cfg["seed_locks"],
)
# Mirror the loop's per-frame bypass: when the count comes FROM the
# loop (override_count), emit only the frames the loop actually kept
# (its `kept_indices`), drawing each kept frame's seed from the full
# series. Without this the loop bypasses 15/18 frames (3 prompts) but
# this node still emits 18 seeds, so the sampler runs 18 times. Locks
# are applied over the FULL series first, so a lock on a kept frame
# still lands and a lock on a bypassed frame is simply dropped.
seeds = full_series
if override_count and isinstance(loop_config, dict):
raw_kept = loop_config.get("kept_indices")
if isinstance(raw_kept, list):
kept = [
i for i in raw_kept
if isinstance(i, int) and not isinstance(i, bool)
and 0 <= i < len(full_series)
]
# Only narrow — a full/empty/malformed list leaves emit as-is.
if kept and len(kept) < len(full_series):
seeds = [full_series[i] for i in kept]
# Surface the per-iteration series to the node UI so the frontend can
# offer "lock previous" in the seed modal (captured in seed_list.ts).
return io.NodeOutput(seeds, ui={"loop_seeds": [[int(s) for s in seeds]]})