Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b011b8fb | ||
|
|
a2e93fe7e4 | ||
|
|
1e42f9b5aa | ||
|
|
67199b5dba | ||
|
|
74c7331b7e | ||
|
|
00b735d32f | ||
|
|
a1fe6956d1 | ||
|
|
fe2423f5b4 | ||
|
|
6c20f53cc6 | ||
|
|
936a13913e | ||
|
|
290e7a81f5 | ||
|
|
327e7cc762 | ||
|
|
830776efd1 | ||
|
|
bdcf85ccd4 | ||
|
|
d42b7ca1d8 | ||
|
|
fcde3b31d6 | ||
|
|
ea4ed059c3 | ||
|
|
f670c3db76 | ||
|
|
af95f08ef4 | ||
|
|
70283ce4d3 | ||
|
|
bab87cd3c2 | ||
|
|
7545ade87d | ||
|
|
651ca65228 | ||
|
|
d524d142b5 | ||
|
|
da2239dca5 | ||
|
|
9d372b1d8f | ||
|
|
7aab09d6bc | ||
|
|
2295ce0f81 | ||
|
|
8ea15719bd | ||
|
|
d03936478c | ||
|
|
8dcef0f3d9 | ||
|
|
8912937a9a | ||
|
|
d4e6d0c112 | ||
|
|
696711dca1 | ||
|
|
b54302d546 | ||
|
|
0bd4382cf0 | ||
|
|
ed10b881b7 | ||
|
|
cbf102b8a0 | ||
|
|
be70a5f149 | ||
|
|
7d2bd63912 | ||
|
|
6f69304796 | ||
|
|
6802f11497 | ||
|
|
f888acdcd3 | ||
|
|
41fae1e41b | ||
|
|
bf61f649fa | ||
|
|
fc9d8a85a2 | ||
|
|
6f664ce9e9 | ||
|
|
5d715fba99 | ||
|
|
e98aca30b3 | ||
|
|
df76abdbb0 | ||
|
|
8568aa2c1e | ||
|
|
aba4e8c8ac | ||
|
|
ff6825cedd | ||
|
|
0ede205530 | ||
|
|
a738618dd3 | ||
|
|
8dcebdcc55 | ||
|
|
1b3e4aa61f | ||
|
|
0281b8e321 | ||
|
|
58c5b27922 | ||
|
|
4a34c9bbdd | ||
|
|
06c380fd01 | ||
|
|
c48aafb143 | ||
|
|
ff4d8faa1f | ||
|
|
c7c867e531 | ||
|
|
2fb853fa81 | ||
|
|
e1885a3115 | ||
|
|
f38a316ccb | ||
|
|
e56fd22bc9 | ||
|
|
b33afd8797 | ||
|
|
67a09df6d6 | ||
|
|
1f164df0c5 | ||
|
|
91b5f7581a | ||
|
|
3527ada397 | ||
|
|
25388b4650 | ||
|
|
6eb4b730cb | ||
|
|
942834d16a | ||
|
|
03796c56ba | ||
|
|
65683aed7c | ||
|
|
8a9dc9efbd | ||
|
|
8f696d6eec | ||
|
|
1822297a9a | ||
|
|
21f285033d | ||
|
|
bb5e84d3b2 | ||
|
|
b292699487 | ||
|
|
39fdb6f187 | ||
|
|
3c492f1cb9 | ||
|
|
79cf3857ad | ||
|
|
e93f7ba06e | ||
|
|
4e71e043e7 | ||
|
|
7b416ad0ed | ||
|
|
ece6d49929 | ||
|
|
6dc033d57e | ||
|
|
9663636d10 | ||
|
|
8910b98a86 | ||
|
|
10dcac4100 | ||
|
|
15c1ade3ef | ||
|
|
8871a2806d | ||
|
|
072e4e7836 | ||
|
|
0f7aaabe3c | ||
|
|
f1451baf40 | ||
|
|
589f45eb74 | ||
|
|
fa73750ca0 | ||
|
|
b49a579b33 | ||
|
|
34ec18feff | ||
|
|
44d8ab9575 | ||
|
|
68910bfc18 | ||
|
|
b5262c22d5 | ||
|
|
fb19e3cef8 | ||
|
|
43d775ec8f | ||
|
|
86eb15cfa3 | ||
|
|
0f0cd47018 | ||
|
|
1ad7e8e415 | ||
|
|
5a3e834c42 | ||
|
|
cc64ca3ab8 | ||
|
|
fd2cdcbf08 | ||
|
|
48845981a1 | ||
|
|
5da64f8d78 |
@@ -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
@@ -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-
|
||||
|
||||
+188
@@ -1,3 +1,191 @@
|
||||
## 🎉 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)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(derivation-modal): absolute max-height for the rules scroll (revert flex) ([5d715fb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5d715fb))
|
||||
* fix(derivation-modal): pin rule cards so the rule list actually scrolls ([fc9d8a8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fc9d8a8))
|
||||
* fix(derivation-modal): scroll the rule LIST, not the whole modal ([6f664ce](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6f664ce))
|
||||
* fix(derivation-modal): scroll the rules region, pin header + footer ([e98aca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e98aca3))
|
||||
* fix(engine): derivation hold freezes each branch roll, not the branch choice ([df76abd](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/df76abd))
|
||||
* fix(engine): hold the resolved VALUE across iterations, not just the seed ([58c5b27](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/58c5b27))
|
||||
* fix(injector): keep custom pin labels with the socket on drag-reorder ([41fae1e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/41fae1e))
|
||||
* fix(injector): persist custom pin labels durably across collapse + reload ([8568aa2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8568aa2))
|
||||
* fix(injector): reconstruct collapsed-wire labels from data, not the dead stash ([0ede205](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0ede205))
|
||||
* fix(injector): repair collapse-wire labels corrupted across a reload ([0281b8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0281b8e))
|
||||
* fix(injector): right-align the type pill on label-less ref/slot rows ([bf61f64](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bf61f64))
|
||||
* docs(spa): describe hold as a value freeze (constrained picks + nested refs) ([1b3e4aa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b3e4aa))
|
||||
* docs(spa): document per-iteration overrides, seed scope + lock-previous ([4a34c9b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4a34c9b))
|
||||
* docs(spa): mention the right-click Hold toggle shortcut ([a738618](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a738618))
|
||||
* docs(spa): note derivation supports Hold across run ([aba4e8c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/aba4e8c))
|
||||
* feat(context): quick Hold toggle in the module row context menu ([8dcebdc](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8dcebdc))
|
||||
* feat(engine): derivation honors seed_scope=hold (freeze frame-0 output) ([ff6825c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff6825c))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.8.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>
|
||||
|
||||
|
||||
* fix(context): add hover states to the per-kind edit modal footer buttons ([b292699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b292699))
|
||||
* fix(context): capture the loop's per-frame seeds so the lock pins the previous roll ([b33afd8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b33afd8))
|
||||
* fix(context): clear redundant frame lock-null override when the base unlocks ([f38a316](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/f38a316))
|
||||
* fix(context): coerce disabled_frames to int set and guard frame toggle ([65683ae](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/65683ae))
|
||||
* fix(context): modal lock button pins the rolled seed via injected resolver ([e56fd22](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e56fd22))
|
||||
* fix(context): name the disabled toggles in the per-frame runtime hint + wrap loop frame chips ([2fb853f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/2fb853f))
|
||||
* fix(context): per-frame enable overrides base enabled (frame_enabled) ([6eb4b73](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6eb4b73))
|
||||
* fix(context): per-frame seed lock captures that frame's own rolled seed ([25388b4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/25388b4))
|
||||
* fix(context): per-frame seed lock computes the frame's real seed from the loop ([1f164df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1f164df))
|
||||
* fix(context): restrict per-frame overrides to an allowlist of meaningful fields ([bb5e84d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5e84d))
|
||||
* fix(context): resync editor draft on frame revert + restyle the banner for readability ([39fdb6f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/39fdb6f))
|
||||
* fix(context): scope the wrapping grid to the frame chips only ([c48aafb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c48aafb))
|
||||
* fix(context): stack the frame banner above the editor card instead of beside it ([3c492f1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3c492f1))
|
||||
* fix(context): sweep redundant frame lock-null when base unlocks via the modal ([e1885a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e1885a3))
|
||||
* fix(engine): apply iteration_overrides for generic-object modules ([942834d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/942834d))
|
||||
* fix(engine): include disabled_frames in the generic-object snapshot keys ([03796c5](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/03796c5))
|
||||
* fix(engine): stop double-shifting the per-iteration override seed ([3527ada](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3527ada))
|
||||
* fix(extension): read loop seed and count as numbers for per-frame seed lock ([67a09df](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/67a09df))
|
||||
* feat(context-loop): add edit-frame selector chip row ([ece6d49](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ece6d49))
|
||||
* feat(context-loop): add session frame-cursor singleton ([6dc033d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6dc033d))
|
||||
* feat(context): add hold-across-run toggle to the runtime sections ([9663636](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9663636))
|
||||
* feat(context): add lock-previous-seed to the iteration-seed modals ([c7c867e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c7c867e))
|
||||
* feat(context): add pure frame-override helpers ([7b416ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/7b416ad))
|
||||
* feat(context): disable run-level controls while editing a frame ([1822297](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1822297))
|
||||
* feat(context): frame banner with revert-to-base in the module editor ([79cf385](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/79cf385))
|
||||
* feat(context): held and per-frame override row badges ([e93f7ba](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e93f7ba))
|
||||
* feat(context): make the row quick-lock act on the active frame ([21f2850](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/21f2850))
|
||||
* feat(context): per-frame enable/disable via disabled_frames ([8a9dc9e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8a9dc9e))
|
||||
* feat(context): redirect edits into iteration_overrides when a frame is active ([4e71e04](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4e71e04))
|
||||
* feat(engine): apply per-frame iteration_overrides onto module instance ([10dcac4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/10dcac4))
|
||||
* feat(engine): combine honors hold seed_scope ([8871a28](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8871a28))
|
||||
* feat(engine): fixed_values honors hold seed_scope ([15c1ade](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/15c1ade))
|
||||
* feat(engine): thread a hold seed and loop index into the pipeline run ([0f7aaab](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f7aaab))
|
||||
* feat(engine): wildcard honors hold seed_scope, locked_seed still wins ([072e4e7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/072e4e7))
|
||||
* feat(manager): snapshot source/target tag_groups into published constraints ([ff4d8fa](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ff4d8fa))
|
||||
* feat(types): add seed_scope and iteration_overrides to module config ([8910b98](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8910b98))
|
||||
* revert(context): drop per-frame seed capture, superseded by double-shift fix ([91b5f75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/91b5f75))
|
||||
* refactor(context): unify frame-hint markup and add false-case coverage ([8f696d6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/8f696d6))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.7.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>
|
||||
|
||||
|
||||
* feat(engine): add apply_seed_locks for per-iteration seed overrides ([5da64f8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5da64f8))
|
||||
* feat(loop): apply per-iteration seed locks in the context loop node ([4884598](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4884598))
|
||||
* feat(loop): per-iteration seeds button + modal; shrink iteration-var buttons ([86eb15c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/86eb15c))
|
||||
* feat(seedlist): add paste-to-replace locks from copied text ([fa73750](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fa73750))
|
||||
* feat(seedlist): alt-click copy to grab only the locked seeds ([589f45e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/589f45e))
|
||||
* feat(seedlist): apply per-iteration seed locks to the output series ([fd2cdcb](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd2cdcb))
|
||||
* feat(seedlist): flash a copied indicator on the modal copy button ([34ec18f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/34ec18f))
|
||||
* feat(seedlist): per-iteration seeds button + modal ([43d775e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/43d775e))
|
||||
* feat(seedlist): preview effective base/count/strategy from the wired loop ([68910bf](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/68910bf))
|
||||
* feat(seedlist): show out-of-range locks dimmed but still editable ([b49a579](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b49a579))
|
||||
* feat(shared): add per-iteration view/set/lock row (SeedLockRow) ([1ad7e8e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1ad7e8e))
|
||||
* feat(shared): ts mirror of loop seed derivation with py-ts parity corpus ([cc64ca3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/cc64ca3))
|
||||
* feat(shared): view/set/lock per-iteration seeds modal ([0f0cd47](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f0cd47))
|
||||
* feat(widgets): add seed_locks field to loop + seed-list configs ([5a3e834](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a3e834))
|
||||
* feat(widgets): feed stock base-seed + count into the seed modal ([fb19e3c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fb19e3c))
|
||||
* feat(widgets): seed-list override hint + capitalize loop bypass label ([44d8ab9](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d8ab9))
|
||||
* test(widgets): restore parse-recovery coverage dropped alongside seed_locks tests ([b5262c2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b5262c2))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.6.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)
|
||||
|
||||
@@ -101,7 +101,16 @@ class CombineHandler(ModuleHandler):
|
||||
# - chain seed otherwise → re-rolls each queue
|
||||
# Both feed derive_module_rng(seed, output_var) so locking with
|
||||
# locked_seed = chain_seed reproduces the unlocked roll exactly.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse the frame-0 resolved value verbatim (from the
|
||||
# pipeline's base pass) so a held combine's alternation stays
|
||||
# frozen across the whole run, nested refs included.
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict) and output_var in _hb:
|
||||
return {output_var: _hb[output_var]}
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
|
||||
@@ -295,8 +295,30 @@ class DerivationHandler(ModuleHandler):
|
||||
# action.value across runs; without lock the chain seed
|
||||
# propagates per-queue. Falls back to the host ctx's RNG when
|
||||
# the ctx wasn't built with `__wp_rng__` (legacy callers).
|
||||
# seed_scope == "hold" freezes the DICE per branch, NOT which branch
|
||||
# fires. The conditions still pick a branch live each iteration, so a
|
||||
# varying input can switch IF→ELIF and we use THAT branch — but every
|
||||
# branch's inline {a|b} / @{} roll is frozen to its frame-0 value via
|
||||
# the constant hold seed, keyed PER BRANCH (see _branch_resolve_ctx).
|
||||
# So each branch's value is identical every iteration it fires and
|
||||
# equals the value it WOULD have had at frame 0, independent of which
|
||||
# other branches fired. (Contrast wildcard/combine/fixed_values hold,
|
||||
# which freeze the whole resolved VALUE because a constraint can
|
||||
# reshape their pool; a derivation branch's pool is fixed, so a frozen
|
||||
# seed alone is enough — and freezing the branch SELECTION would be
|
||||
# wrong: the user wants the chosen branch to follow the inputs.)
|
||||
_hold = False
|
||||
effective_seed = 0
|
||||
module_id = "derivation"
|
||||
ctx_local: dict[str, Any] = {}
|
||||
if isinstance(ctx, dict) and "__wp_rng__" in ctx:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
_hold = instance.get("seed_scope") == "hold"
|
||||
if _hold:
|
||||
chain_seed = int(
|
||||
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
|
||||
)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
effective_seed = (
|
||||
int(locked_seed) if isinstance(locked_seed, (int, float))
|
||||
@@ -309,6 +331,19 @@ class DerivationHandler(ModuleHandler):
|
||||
else:
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
|
||||
|
||||
def _branch_resolve_ctx(branch_key: str) -> Any:
|
||||
# Non-hold (or no rng): the shared rng stream — existing
|
||||
# behaviour, branch values draw in fire order. Hold: an
|
||||
# INDEPENDENT rng per branch (keyed by rule:branch) so its value
|
||||
# is frozen to its frame-0 roll regardless of which other
|
||||
# branches drew before it in the shared stream.
|
||||
if not _hold:
|
||||
return resolve_ctx
|
||||
rng_b = derive_module_rng(effective_seed, f"{module_id}::{branch_key}")
|
||||
return build_resolve_ctx(
|
||||
{**ctx_local, "__wp_rng__": rng_b}, surface="derivation"
|
||||
)
|
||||
|
||||
# Tier-D instance overrides (2026-05-10 cycle).
|
||||
disabled_rule_ids = set(instance.get("disabled_rule_ids") or [])
|
||||
disabled_branch_keys = set(instance.get("disabled_branch_keys") or [])
|
||||
@@ -364,7 +399,9 @@ class DerivationHandler(ModuleHandler):
|
||||
action = {**action, "value": action_override}
|
||||
|
||||
pair = _apply_action(
|
||||
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, bi)
|
||||
action, ctx,
|
||||
_branch_resolve_ctx(branch_carrier_key(rule_id, bi)),
|
||||
carrier_key=branch_carrier_key(rule_id, bi),
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
@@ -387,7 +424,9 @@ class DerivationHandler(ModuleHandler):
|
||||
if isinstance(action_override, str):
|
||||
action = {**action, "value": action_override}
|
||||
pair = _apply_action(
|
||||
action, ctx, resolve_ctx, carrier_key=branch_carrier_key(rule_id, "else")
|
||||
action, ctx,
|
||||
_branch_resolve_ctx(branch_carrier_key(rule_id, "else")),
|
||||
carrier_key=branch_carrier_key(rule_id, "else"),
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
|
||||
@@ -150,7 +150,20 @@ class FixedValuesHandler(ModuleHandler):
|
||||
return out
|
||||
|
||||
# Effective seed selection (mirrors WildcardHandler + CombineHandler):
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse each name's frame-0 resolved value verbatim (from the
|
||||
# pipeline's base pass) so a held fixed-values alternation stays
|
||||
# frozen across the whole run.
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict):
|
||||
_names = [n for n in ((v.get("name") or "").strip() for v in values) if n]
|
||||
if _names and all(n in _hb for n in _names):
|
||||
return {n: _hb[n] for n in _names}
|
||||
chain_seed = int(
|
||||
ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0
|
||||
)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if isinstance(locked_seed, (int, float)):
|
||||
effective_seed = int(locked_seed)
|
||||
|
||||
@@ -645,7 +645,17 @@ class WildcardHandler(ModuleHandler):
|
||||
# unlocked pick exactly. This is the contract the lock UX
|
||||
# relies on: the user sees a roll, locks it, captures the
|
||||
# current chain seed, future runs reproduce the same roll.
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
if instance.get("seed_scope") == "hold":
|
||||
# Held: reuse the frame-0 fully-resolved value verbatim (from the
|
||||
# pipeline's base pass). Skips the pick AND nested-ref resolution,
|
||||
# so a constrained or @{}-bearing wildcard stays frozen across the
|
||||
# whole run — "green jeans" on frame 1 stays "green jeans".
|
||||
_hb = ctx.get("__wp_hold_base_ctx__")
|
||||
if isinstance(_hb, dict) and binding in _hb:
|
||||
return {binding: _hb[binding]}
|
||||
chain_seed = int(ctx.get("__wp_node_seed_hold__", ctx.get("__wp_node_seed__", 0)) or 0)
|
||||
else:
|
||||
chain_seed = int(ctx.get("__wp_node_seed__", 0) or 0)
|
||||
locked_seed = instance.get("locked_seed")
|
||||
if locked_seed is not None:
|
||||
try:
|
||||
|
||||
+105
-8
@@ -236,6 +236,18 @@ def _extract_static_meta(
|
||||
return meta
|
||||
|
||||
|
||||
def _module_seed_scope(module: object) -> str | None:
|
||||
"""Read a module's base ``instance.seed_scope`` ("hold"/"vary"/None).
|
||||
Works for both dict and object module shapes. seed_scope is a base-only
|
||||
setting (disabled in per-frame edit mode), so the base instance is
|
||||
authoritative."""
|
||||
inst = module.get("instance") if isinstance(module, dict) else getattr(module, "instance", None)
|
||||
if isinstance(inst, dict):
|
||||
s = inst.get("seed_scope")
|
||||
return s if isinstance(s, str) else None
|
||||
return None
|
||||
|
||||
|
||||
class PipelineEngine:
|
||||
"""Runs an ordered list of modules against a context dict."""
|
||||
|
||||
@@ -244,10 +256,14 @@ class PipelineEngine:
|
||||
modules: list[Module],
|
||||
ctx: Context | None = None,
|
||||
seed: int = 0,
|
||||
hold_seed: int | None = None,
|
||||
loop_index: int = 0,
|
||||
) -> Context:
|
||||
"""Execute modules top-to-bottom, mutating ctx, returning ctx."""
|
||||
ctx = {} if ctx is None else ctx
|
||||
ctx["__wp_node_seed__"] = seed
|
||||
ctx["__wp_node_seed_hold__"] = hold_seed if hold_seed is not None else seed
|
||||
ctx["__wp_loop_index__"] = loop_index
|
||||
ctx["__wp_rng__"] = random.Random(seed)
|
||||
ctx.setdefault("__wp_warnings__", [])
|
||||
ctx.setdefault("__wp_trace__", [])
|
||||
@@ -259,6 +275,32 @@ class PipelineEngine:
|
||||
# one-shot consumed-set.
|
||||
ctx.setdefault("__wp_constraint_hits__", {})
|
||||
|
||||
# "Hold the value" base pass. A module on seed_scope=hold must resolve
|
||||
# to its frame-0 (loop_index=0) value on EVERY iteration — INCLUDING
|
||||
# nested @{} refs, which resolve_text bakes into the stored value.
|
||||
# Re-resolving each iteration (the old seed-swap) failed for
|
||||
# constrained wildcards (the constraint reshapes the pool per
|
||||
# iteration) and for nested refs. So when a loop is driving
|
||||
# (loop_index != 0) and any module holds, resolve the chain ONCE at
|
||||
# loop_index=0 with the constant hold seed; held handlers copy their
|
||||
# resolved value from this base context verbatim. The deepcopy keeps
|
||||
# the throwaway pass's warnings/trace/constraint-state off the real
|
||||
# ctx. loop_index=0 here means the recursive run skips its own base
|
||||
# pass — no infinite recursion.
|
||||
_any_hold = any(_module_seed_scope(m) == "hold" for m in modules)
|
||||
if loop_index != 0 and _any_hold:
|
||||
import copy as _copy
|
||||
|
||||
_hb = int(ctx.get("__wp_node_seed_hold__", 0) or 0)
|
||||
base_ctx = PipelineEngine().run(
|
||||
modules,
|
||||
ctx=_copy.deepcopy(ctx),
|
||||
seed=_hb,
|
||||
hold_seed=_hb,
|
||||
loop_index=0,
|
||||
)
|
||||
ctx["__wp_hold_base_ctx__"] = base_ctx
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
# Normalise id/type/enabled reads to work for both dicts and objects.
|
||||
if isinstance(module, dict):
|
||||
@@ -296,18 +338,52 @@ class PipelineEngine:
|
||||
_module_name = getattr(module, "name", "") or ""
|
||||
_module_bundle_origin = getattr(module, "bundle_origin", "") or ""
|
||||
|
||||
if not _module_enabled:
|
||||
# Disabled modules don't run — but the trace row still
|
||||
# surfaces what the module would have written, so the
|
||||
# debug viewer can label it `$varname (disabled)`
|
||||
# instead of falling back to an opaque short-uuid.
|
||||
_k = int(ctx.get("__wp_loop_index__", 0))
|
||||
# Per-frame enable override. `frame_enabled[str(k)]` overrides the
|
||||
# base `enabled` flag for frame k in EITHER direction: a base-off
|
||||
# module can be turned ON for select frames, a base-on module OFF
|
||||
# for others. effective = frame_enabled.get(k, base_enabled).
|
||||
# Legacy `disabled_frames` (a one-way blocklist) is still honoured —
|
||||
# each listed frame reads as frame_enabled[k]=False.
|
||||
_frame_enabled_map = (
|
||||
module.get("frame_enabled")
|
||||
if isinstance(module, dict)
|
||||
else getattr(module, "frame_enabled", None)
|
||||
)
|
||||
_legacy_disabled = (
|
||||
module.get("disabled_frames")
|
||||
if isinstance(module, dict)
|
||||
else getattr(module, "disabled_frames", None)
|
||||
)
|
||||
_frame_override: bool | None = None
|
||||
if isinstance(_frame_enabled_map, dict) and str(_k) in _frame_enabled_map:
|
||||
_frame_override = bool(_frame_enabled_map[str(_k)])
|
||||
elif isinstance(_legacy_disabled, (list, tuple)) and _k in {
|
||||
int(f)
|
||||
for f in _legacy_disabled
|
||||
if isinstance(f, (int, float)) and not isinstance(f, bool)
|
||||
}:
|
||||
_frame_override = False
|
||||
_effective_enabled = (
|
||||
_frame_override if _frame_override is not None else bool(_module_enabled)
|
||||
)
|
||||
|
||||
if not _effective_enabled:
|
||||
# Module is suppressed this frame. Distinguish a per-frame
|
||||
# disable (a frame override forced it off) from a global
|
||||
# disable (base off, no frame override) so the debug viewer
|
||||
# can label `skipped_frame` vs `skipped_disabled`. Either way
|
||||
# the trace row still surfaces what it WOULD have written so
|
||||
# the debug viewer can label `$varname (disabled)` rather than
|
||||
# falling back to an opaque short-uuid.
|
||||
_frame_forced_off = _frame_override is False
|
||||
meta = _extract_static_meta(module)
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": _module_id,
|
||||
"_uid": _module_uid,
|
||||
"type": _module_type_raw,
|
||||
"enabled": False,
|
||||
"status": "skipped_disabled",
|
||||
"enabled": bool(_module_enabled) if _frame_forced_off else False,
|
||||
"status": "skipped_frame" if _frame_forced_off else "skipped_disabled",
|
||||
"writes": [],
|
||||
"error": None,
|
||||
**meta,
|
||||
@@ -325,17 +401,38 @@ class PipelineEngine:
|
||||
else:
|
||||
# Generic object: pull known snapshot keys via getattr so
|
||||
# class-level attributes (common in tests) are visible.
|
||||
# `iteration_overrides` is load-bearing — the per-frame
|
||||
# override patch below reads it back off `raw`. `disabled_frames`
|
||||
# is listed for snapshot honesty only (the skip gate already
|
||||
# read it module-direct above).
|
||||
raw = {
|
||||
k: getattr(module, k)
|
||||
for k in (
|
||||
"id", "type", "enabled", "payload", "entries",
|
||||
"meta", "library_id", "library_snapshot_at",
|
||||
"library_version_at_snapshot", "name", "category_id",
|
||||
"instance",
|
||||
"instance", "iteration_overrides",
|
||||
"disabled_frames", "frame_enabled",
|
||||
)
|
||||
if hasattr(module, k)
|
||||
}
|
||||
snapshot = coerce_legacy_module(raw)
|
||||
# Per-frame iteration override: patch snapshot["instance"]
|
||||
# for the current loop index BEFORE the handler runs.
|
||||
# iteration_overrides is a dict keyed by stringified index;
|
||||
# the matching partial dict is shallow-merged into instance.
|
||||
_ov = (
|
||||
raw.get("iteration_overrides")
|
||||
if isinstance(raw, dict)
|
||||
else getattr(module, "iteration_overrides", None)
|
||||
)
|
||||
if isinstance(_ov, dict):
|
||||
_frame_key = str(ctx.get("__wp_loop_index__", 0))
|
||||
_patch = _ov.get(_frame_key)
|
||||
if isinstance(_patch, dict) and isinstance(
|
||||
snapshot.get("instance"), dict
|
||||
):
|
||||
snapshot["instance"] = {**snapshot["instance"], **_patch}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to coerce module %r at index %s: %s", module, index, e
|
||||
|
||||
@@ -33,6 +33,10 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"category_filter": "string",
|
||||
"exclude_null": "boolean",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: "hold" pins RNG to the run base seed (same
|
||||
# across loop iterations, re-rolls per run); "vary" (default/absent)
|
||||
# rides the per-iteration seed. `locked_seed` takes priority when set.
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
# SP2a multi-select: per-instance count range (pick_min..pick_max; a
|
||||
# range other than 1..1 resolves N options without replacement into a
|
||||
@@ -59,6 +63,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
# resolution gives fixed_values seed-lock parity with wildcard
|
||||
# + combine. Locked seed pins the alternation outcome.
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
},
|
||||
"combine": {
|
||||
# v2 single-pane modal exposes a richer override surface:
|
||||
@@ -69,6 +75,8 @@ INSTANCE_SCHEMAS: dict[str, dict[str, InstanceFieldType]] = {
|
||||
"template_override": "string",
|
||||
"variable_binding": "string",
|
||||
"locked_seed": "number",
|
||||
# Iteration seed scope: hold/vary control (parity with wildcard).
|
||||
"seed_scope": "string",
|
||||
"internal": "boolean",
|
||||
},
|
||||
"derivation": {
|
||||
|
||||
+23
-3
@@ -41,6 +41,19 @@ def derive_loop_seeds(base: int, count: int, strategy: str) -> list[int]:
|
||||
raise ValueError(f"unknown loop seed strategy: {strategy!r}")
|
||||
|
||||
|
||||
def apply_seed_locks(derived: list[int], locks: dict[int, int]) -> list[int]:
|
||||
"""Overlay per-index locked seeds on a derived series.
|
||||
|
||||
`locks` maps a 0-based iteration index to a pinned seed. Locked
|
||||
indices replace the derived value (masked to MAX_SAFE_SEED);
|
||||
out-of-range indices are ignored. Pure — no ComfyUI imports.
|
||||
"""
|
||||
return [
|
||||
(locks[i] & MAX_SAFE_SEED) if i in locks else seed
|
||||
for i, seed in enumerate(derived)
|
||||
]
|
||||
|
||||
|
||||
def effective_chain_seed(
|
||||
*,
|
||||
widget_seed: int,
|
||||
@@ -53,8 +66,10 @@ def effective_chain_seed(
|
||||
1. ``seed_override`` (from ContextLoop) replaces ``widget_seed`` as
|
||||
base when present.
|
||||
2. Otherwise ``widget_seed`` is the base.
|
||||
3. ``loop_index`` is mixed via XOR with a stable hash. ``loop_index=0``
|
||||
→ no-op (backwards-compat for chains without ContextLoop).
|
||||
3. ``loop_index`` is mixed via XOR with a stable hash ONLY when there is
|
||||
no override (to vary a constant ``widget_seed`` per iteration). An
|
||||
override is already per-iteration (the ContextLoop derived series) so
|
||||
it is used as-is. ``loop_index=0`` → no-op.
|
||||
|
||||
Locked-module precedence lives in the per-module handlers, NOT here.
|
||||
Locked modules read ``instance.locked_seed`` directly and ignore the
|
||||
@@ -64,7 +79,12 @@ def effective_chain_seed(
|
||||
matches the visual shape of ComfyUI's frontend-randomised seeds.
|
||||
"""
|
||||
base = seed_override if seed_override is not None else widget_seed
|
||||
if loop_index == 0:
|
||||
# An override IS the loop's already-per-iteration derived seed → use it
|
||||
# verbatim. The loop_index XOR exists only to vary a CONSTANT widget seed
|
||||
# (the override-OFF path); re-mixing an override that already differs per
|
||||
# iteration is a double-shift that made the actual seed diverge from the
|
||||
# derived series the preview + per-iteration seed-modal locks show.
|
||||
if loop_index == 0 or seed_override is not None:
|
||||
return base & MAX_SAFE_SEED
|
||||
digest = hashlib.sha256(f"loop:{loop_index}".encode()).digest()
|
||||
shift = int.from_bytes(digest[:8], "big")
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.6.0",
|
||||
"version": "2.10.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.6.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"
|
||||
|
||||
@@ -178,7 +178,55 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// plus the shared components/shared/matrix-axis module the SPA matrix now
|
||||
// imports too. ~1.1 KB net gzip in the lazy ContextWidget chunk; entry stays
|
||||
// put. Approved with the matrix-parity feature request.
|
||||
const TOTAL_LIMIT = 435 * 1024; // 435 KB
|
||||
// Bumped 435 -> 442 KB on 2026-06-23 for per-iteration seeds modal
|
||||
// (T8+T9). ContextLoopWidget gains the SeedListModal + SeedLockRow lazy
|
||||
// body (v-if gated) — adds ~5.5 KB net gzip to the ContextLoopWidget
|
||||
// lazy chunk. SeedListWidget gains a matching button in T9. Headroom
|
||||
// covers both T8 and T9 landings without per-task bump friction.
|
||||
// Bumped 442 -> 443 KB on 2026-06-23 for T9 (SeedListWidget seeds
|
||||
// button). The SeedListWidget chunk grew ~0.7 KB gzip adding the button
|
||||
// template + ref/computed script + seedbtn CSS; T8's headroom estimate
|
||||
// was tight. Minimal bump to the actual measured ceiling.
|
||||
// Bumped 443 -> 444 KB on 2026-06-23 for T12 (seed-list preview
|
||||
// effective base/count/strategy from wired loop). Adds preview.ts pure
|
||||
// resolver + graph-walk glue in seed_list.ts + previewStrategy prop in
|
||||
// SeedListWidget.vue. ~0.4 KB net gzip in the seed_list widget chunk.
|
||||
// Bumped 444 -> 445 KB on 2026-06-23 for T16 (seed modal paste-to-replace).
|
||||
// Adds parseSeedLocks in seed-derive + pasteAll/Paste button in SeedListModal.
|
||||
// Measured 454900 bytes (244 over the 444 ceiling); minimal bump.
|
||||
// Bumped 445 -> 446 KB on 2026-06-24 for UI-C (wire ContextWidget frame edits).
|
||||
// Adds imports of frame-cursor / frame-overrides / INSTANCE_FIELDS_PER_KIND into
|
||||
// the ContextWidget lazy chunk + the merge-on-open / write-redirect logic.
|
||||
// Measured 455772 bytes (92 over the 445 ceiling); minimal bump.
|
||||
// Bumped 446 -> 447 KB on 2026-06-24 for RF2 (frame-aware row quick-lock).
|
||||
// Adds toggleFrameLock helper in frame-overrides.ts + effectiveLockedSeed +
|
||||
// frame branch in toggleLockOnCard + frame-aware isLocked. Net ~168 bytes
|
||||
// gzip in the lazy ContextWidget chunk; entry stays put.
|
||||
// Bumped 447 -> 448 KB on 2026-06-24 for the symmetric per-frame enable
|
||||
// redesign (a `frame_enabled` override map replaces the `disabled_frames`
|
||||
// blocklist so a base-off module can be turned ON for select frames). Adds
|
||||
// frameEnabledAt / effectiveEnabled / frameEnableOverride / toggleFrameEnabled
|
||||
// to frame-overrides.ts, the effective-enabled checkbox + bidirectional
|
||||
// on-#k / off-#k badge in ModuleRow, and the .wp-mod-badge--on CSS. Measured
|
||||
// 457868 bytes (140 over the 447 ceiling); minimal bump.
|
||||
// Bumped 448 -> 449 KB on 2026-06-25 for the per-frame seed-lock correctness
|
||||
// work: the Loop's derived-series capture (loop_seeds UI payload + onExecuted
|
||||
// store + frameSeedReader preference, so the lock pins the PREVIOUS run's
|
||||
// per-frame seed under control_after_generate=randomize) plus the modal
|
||||
// Lock-button resolver (lock-seed-ctx provide/inject into SeedLockControls).
|
||||
// Landed exactly at the 448 ceiling (458752/458752); bump for headroom.
|
||||
// Bumped 449 -> 450 KB on 2026-06-28 for the row context-menu Hold toggle.
|
||||
// Adds HOLD_KINDS + isHoldable + toggleHold to ContextWidget plus the quick
|
||||
// "Hold across run" / "Release hold" menu item (2-click seed_scope toggle,
|
||||
// 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.
|
||||
// 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;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ContextLoopWidget from "./ContextLoopWidget.vue";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { emptyContextLoopConfig } from "./types";
|
||||
import { currentFrame } from "./frame-cursor";
|
||||
|
||||
describe("ContextLoopWidget", () => {
|
||||
it("renders defaults — hash chip active, both switches off, internal toggles on", () => {
|
||||
@@ -89,3 +91,90 @@ describe("ContextLoopWidget", () => {
|
||||
expect(w.find('[data-test="loop-iteration-internal"]').classes()).toContain("wp-loop__pi-btn--on");
|
||||
});
|
||||
});
|
||||
|
||||
function ws(extra: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: { ...emptyContextLoopConfig(), ...extra }, baseSeed: 42, count: 4 },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("ContextLoopWidget seeds button", () => {
|
||||
it("renders the Per-iteration seeds button", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-btn"]').exists()).toBe(true);
|
||||
});
|
||||
it("badge shows the locked count when >0", () => {
|
||||
expect(ws({ seed_locks: { "1": 9, "2": 8 } }).find('[data-test="loop-seeds-badge"]').text()).toMatch(/2 locked/i);
|
||||
});
|
||||
it("hides the badge when nothing is locked", () => {
|
||||
expect(ws().find('[data-test="loop-seeds-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("clicking opens the modal", async () => {
|
||||
const wr = ws();
|
||||
await wr.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
|
||||
});
|
||||
it("capitalizes the bypass row label", () => {
|
||||
expect(ws().text()).toContain("Bypass loop");
|
||||
});
|
||||
it("passes the override hint to the modal only when Override Context seed is off", async () => {
|
||||
const off = ws(); // override_seed false by default → nudge to turn it on
|
||||
await off.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(off.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
|
||||
|
||||
const on = ws({ override_seed: true });
|
||||
await on.find('[data-test="loop-seeds-btn"]').trigger("click");
|
||||
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopWidget frame selector", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
function fw(count = 3) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: emptyContextLoopConfig(), count },
|
||||
attachTo: document.body, global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
it("renders base + one chip per iteration", () => {
|
||||
expect(fw(3).findAll('[data-test^="loop-frame-"]')).toHaveLength(4);
|
||||
});
|
||||
it("clicking a frame chip sets the shared cursor; base clears it", async () => {
|
||||
const wr = fw(3);
|
||||
await wr.find('[data-test="loop-frame-2"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(1);
|
||||
await wr.find('[data-test="loop-frame-base"]').trigger("click");
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
* host glue (`src/widgets/context_loop.ts`) serializes to JSON and
|
||||
* pushes via `host.setValue` so ComfyUI's widget value matches.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import type { ContextLoopConfig, LoopStrategy } from "./types";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,8 +23,16 @@ const props = withDefaults(
|
||||
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
|
||||
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
|
||||
nodeMode?: number;
|
||||
/** Base seed forwarded from the host ComfyUI INT widget. */
|
||||
baseSeed?: number;
|
||||
/** Iteration count forwarded from the host ComfyUI INT widget. */
|
||||
count?: number;
|
||||
/** The seed each frame used on the PREVIOUS run, captured from the node's
|
||||
* 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;
|
||||
}>(),
|
||||
{ nodeMode: 0 },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previousSeeds: null },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: ContextLoopConfig] }>();
|
||||
@@ -45,6 +55,24 @@ const OVERRIDE_SEED_TOOLTIP =
|
||||
|
||||
const isMuted = computed<boolean>(() => props.nodeMode === 2);
|
||||
const isBypassed = computed<boolean>(() => props.nodeMode === 4);
|
||||
const frameChips = computed(() =>
|
||||
Array.from({ length: Math.max(1, props.count ?? 1) }, (_, i) => i),
|
||||
);
|
||||
|
||||
const seedsOpen = ref(false);
|
||||
const lockedCount = computed(() => Object.keys(props.modelValue.seed_locks ?? {}).length);
|
||||
|
||||
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;
|
||||
@@ -108,6 +136,27 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="wp-loop__seedbtn" data-test="loop-seeds-btn" @click="seedsOpen = true">
|
||||
<span class="wp-loop__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
|
||||
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>
|
||||
|
||||
<div class="wp-loop__section">
|
||||
<div class="wp-loop__label">edit frame</div>
|
||||
<div class="wp-loop__chips wp-loop__chips--frames" role="radiogroup" aria-label="Edit frame">
|
||||
<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, '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>
|
||||
|
||||
<div
|
||||
class="wp-loop__row"
|
||||
:title="OVERRIDE_SEED_TOOLTIP"
|
||||
@@ -169,7 +218,7 @@ function toggleTotalInternal(): void {
|
||||
</div>
|
||||
|
||||
<div class="wp-loop__row">
|
||||
<span class="wp-loop__row-label">bypass loop</span>
|
||||
<span class="wp-loop__row-label">Bypass loop</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-loop__switch"
|
||||
@@ -181,6 +230,13 @@ function toggleTotalInternal(): void {
|
||||
<span class="wp-loop__switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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" @update:bypass-frames="onBypassFrames" @close="seedsOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -204,9 +260,18 @@ function toggleTotalInternal(): void {
|
||||
}
|
||||
|
||||
.wp-loop__chips { display: flex; gap: 4px; }
|
||||
/* Frame chips ONLY (the strategy row has 3 fixed chips that must stay one
|
||||
row + show full labels): grid so 15+ frame chips WRAP instead of
|
||||
overflowing the node. auto-fill keeps a uniform cell width; the last row
|
||||
grows to 1fr to fill it. */
|
||||
.wp-loop__chips--frames {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(38px, 1fr));
|
||||
}
|
||||
.wp-loop__chip {
|
||||
flex: 1;
|
||||
padding: 4px 6px;
|
||||
text-align: center;
|
||||
background: var(--wp-bg-deep, var(--wp-bg, #0e1015));
|
||||
border: 1px solid var(--wp-border, #353841);
|
||||
border-radius: 3px;
|
||||
@@ -220,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;
|
||||
@@ -289,8 +362,8 @@ function toggleTotalInternal(): void {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: 2px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
@@ -306,8 +379,15 @@ function toggleTotalInternal(): void {
|
||||
border-color: var(--wp-accent, #c4b5fd);
|
||||
color: var(--wp-accent, #c4b5fd);
|
||||
}
|
||||
.wp-loop__pi-btn .pi { font-size: 11px; }
|
||||
.wp-loop__pi-btn-label { font: 600 12px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
.wp-loop__pi-btn .pi { font-size: 9px; }
|
||||
.wp-loop__pi-btn-label { font: 600 10px var(--wp-font-mono, monospace); line-height: 1; }
|
||||
|
||||
.wp-loop__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-loop__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.wp-loop__seedbtn-ico { color: var(--wp-accent); display: flex; }
|
||||
.wp-loop__seedbtn-fill { flex: 1; }
|
||||
.wp-loop__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
.wp-loop__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
|
||||
/* Bypass-on visually dims the strategy/override/var rows since the loop
|
||||
* is effectively off. The bypass row itself stays bright. */
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
|
||||
describe("frame-cursor", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
it("defaults to null (base)", () => { expect(currentFrame.value).toBe(null); });
|
||||
it("setFrame updates the shared ref", () => {
|
||||
setFrame(2);
|
||||
expect(currentFrame.value).toBe(2);
|
||||
setFrame(null);
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
// Module-scoped reactive singleton: the "current editing frame" cursor, shared
|
||||
// across every ContextWidget + ContextLoopWidget Vue app on the page. Each node
|
||||
// mounts its own app, so provide/inject can't span them — a shared module import
|
||||
// is the cross-app channel (same pattern as context/drag-store.ts). `null` = base
|
||||
// (no frame). Session-only — never serialized into the workflow.
|
||||
export const currentFrame = ref<number | null>(null);
|
||||
|
||||
export function setFrame(frame: number | null): void {
|
||||
currentFrame.value = frame;
|
||||
}
|
||||
@@ -30,6 +30,8 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: false,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -42,6 +44,8 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
expect(parseContextLoopConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -65,8 +69,45 @@ describe("parseContextLoopConfig", () => {
|
||||
bypass: true,
|
||||
iteration_internal: false,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
const round = parseContextLoopConfig(serializeContextLoopConfig(cfg));
|
||||
expect(round).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextLoopConfig seed_locks", () => {
|
||||
it("defaults seed_locks to {}", () => {
|
||||
expect(emptyContextLoopConfig().seed_locks).toEqual({});
|
||||
});
|
||||
it("parses seed_locks", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
|
||||
});
|
||||
it("drops non-numeric lock values", () => {
|
||||
expect(parseContextLoopConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
|
||||
});
|
||||
it("round-trips through serialize", () => {
|
||||
const cfg = { ...emptyContextLoopConfig(), seed_locks: { "0": 7 } };
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface ContextLoopConfig {
|
||||
iteration_internal: boolean;
|
||||
/** Same idea for `$<iteration_var_name>_total`. */
|
||||
total_internal: boolean;
|
||||
/** 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"]);
|
||||
@@ -32,6 +39,8 @@ export function emptyContextLoopConfig(): ContextLoopConfig {
|
||||
bypass: false,
|
||||
iteration_internal: true,
|
||||
total_internal: true,
|
||||
seed_locks: {},
|
||||
bypass_frames: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +69,25 @@ export function parseContextLoopConfig(raw: string | null | undefined): ContextL
|
||||
if (typeof obj.bypass === "boolean") out.bypass = obj.bypass;
|
||||
if (typeof obj.iteration_internal === "boolean") out.iteration_internal = obj.iteration_internal;
|
||||
if (typeof obj.total_internal === "boolean") out.total_internal = obj.total_internal;
|
||||
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
|
||||
const locks: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { flushPromises, mount } from "@vue/test-utils";
|
||||
import ContextWidget from "./ContextWidget.vue";
|
||||
import { baseCodename } from "../../extension/node-codename";
|
||||
import { _resetForTests as resetDriftStore } from "./drift-store";
|
||||
import { currentFrame } from "../context-loop/frame-cursor";
|
||||
|
||||
beforeEach(() => {
|
||||
// Phase 5.5.4 — the picker now fetches the library on mount. Stub
|
||||
@@ -728,6 +729,124 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget Hold toggle via ctxmenu", () => {
|
||||
beforeEach(() => {
|
||||
// Hold is base-level; pin the shared frame cursor to base so the
|
||||
// menu item isn't disabled by a leftover edit-frame from another test.
|
||||
currentFrame.value = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
currentFrame.value = null;
|
||||
});
|
||||
|
||||
it("Hold across run stamps seed_scope=hold; reopening offers Release hold which flips back to vary", async () => {
|
||||
resetDriftStore();
|
||||
const onChange = vi.fn();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 410, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
// Un-held module → item reads "Hold across run" and is enabled.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
let items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item should appear for a wildcard row").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(false);
|
||||
hold!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterHold = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterHold).modules[0].instance.seed_scope).toBe("hold");
|
||||
|
||||
// Held module → label swaps to "Release hold"; clicking it varies again.
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const release = items.find((el) => el.textContent?.includes("Release hold"));
|
||||
expect(release, "Held row should offer Release hold").toBeTruthy();
|
||||
release!.click();
|
||||
await flushPromises();
|
||||
|
||||
const afterRelease = onChange.mock.calls[onChange.mock.calls.length - 1][0] as string;
|
||||
expect(JSON.parse(afterRelease).modules[0].instance.seed_scope).toBe("vary");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("disables the Hold item when a loop edit-frame is active (hold is base-only)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aaaaaaaa", type: "wildcard", enabled: true,
|
||||
meta: { name: "Hair" }, entries: [],
|
||||
payload: { options: [], var_binding: "hair" }, payload_hash: "h-A",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 412, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
currentFrame.value = 0; // user is editing frame #0 of a loop
|
||||
|
||||
await wrapper.find('[data-module-id="aaaaaaaa"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const hold = items.find((el) => el.textContent?.includes("Hold across run"));
|
||||
expect(hold, "Hold item still present while a frame is active").toBeTruthy();
|
||||
expect(hold!.classList.contains("wp-ctxmenu__item--disabled")).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("omits the Hold item for a constraint row (seed_scope not honored)", async () => {
|
||||
resetDriftStore();
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "aaaaaaaa", type: "wildcard", enabled: true, meta: { name: "Hair" }, entries: [], payload: { options: [], var_binding: "hair" } },
|
||||
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "Outfit" }, entries: [], payload: { options: [], var_binding: "outfit" } },
|
||||
{
|
||||
id: "cccccccc", type: "constraint", enabled: true,
|
||||
meta: { name: "Hair × Outfit" }, entries: [],
|
||||
payload: {
|
||||
source_wildcard_id: "aaaaaaaa", target_wildcard_id: "bbbbbbbb",
|
||||
matrix: { blonde: { casual: { mode: "allow", factor: 1 } } }, exceptions: [],
|
||||
}, payload_hash: "h",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 411, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('[data-module-id="cccccccc"]').trigger("contextmenu");
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
expect(items.find((el) => el.textContent?.includes("Hold across run"))).toBeUndefined();
|
||||
expect(items.find((el) => el.textContent?.includes("Release hold"))).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget summaryFor — constraint", () => {
|
||||
it("renders $src → $tgt · NxM with sibling lookup", async () => {
|
||||
resetDriftStore();
|
||||
|
||||
@@ -28,6 +28,7 @@ import BundleFrame from "./bundles/BundleFrame.vue";
|
||||
import { BundleFrameCtxKey, type BundleFrameCtx } from "./bundles/bundle-frame-ctx";
|
||||
import ModuleRow from "./ModuleRow.vue";
|
||||
import { ModuleRowCtxKey, type ModuleRowCtx } from "./module-row-ctx";
|
||||
import { LockSeedKey } from "./lock-seed-ctx";
|
||||
import { buildBundleInsertion, type BundleLibraryEntry } from "./bundles/insert";
|
||||
import { isEnabled, type WildcardOption, type InstanceLike } from "./editors/wildcard/probability";
|
||||
import { buildLibraryChildrenWithIntegrity, toChildSnapshot } from "./bundles/save";
|
||||
@@ -74,7 +75,9 @@ import { dragState, queueHandoff, pendingHandoffs, takeHandoffsFor } from "./dra
|
||||
import { nextBindingSuffix } from "./duplicates/binding-suffix";
|
||||
import { pushToast } from "../shared/toast-store";
|
||||
import { kindIcon } from "../shared/kind-icons";
|
||||
import { KIND_TITLE } from "./editors/_shell";
|
||||
import { KIND_TITLE, FRAME_OVERRIDABLE_FIELDS } from "./editors/_shell";
|
||||
import { currentFrame } from "../context-loop/frame-cursor";
|
||||
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled as feEffectiveEnabled, frameEnableOverride as feFrameEnableOverride } from "./frame-overrides";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import wpLogoSvg from "../shared/wp-logo.svg?raw";
|
||||
import {
|
||||
@@ -151,6 +154,10 @@ const props = withDefaults(defineProps<{
|
||||
nodeMode?: number;
|
||||
/** Last-run seed reader keyed by module id. Null = user hasn't queued. */
|
||||
lastUsedSeedReader?: (moduleId?: string) => number | null;
|
||||
/** Per-frame seed reader — the seed THIS node rolls at iteration frame #k,
|
||||
* computed from the upstream Loop without a run. Drives the per-frame seed
|
||||
* lock so it pins the seed the module actually uses on that frame. */
|
||||
frameSeedReader?: (frame: number) => number | null;
|
||||
onChange: (json: string) => void;
|
||||
/** Called whenever the formula-computed minimum node width changes
|
||||
* (a conflict appears, a state badge gets attached). Mount glue
|
||||
@@ -679,9 +686,11 @@ function onBundlePushClosed(): void {
|
||||
pushBundleTargetUid.value = null;
|
||||
pushBundleCascadeScan.value = null;
|
||||
}
|
||||
const editingModule = computed<ModuleEntry | null>(() =>
|
||||
editingIdx.value != null ? (value.value.modules[editingIdx.value] ?? null) : null,
|
||||
);
|
||||
const editingModule = computed<ModuleEntry | null>(() => {
|
||||
if (editingIdx.value == null) return null;
|
||||
const m = value.value.modules[editingIdx.value] ?? null;
|
||||
return m ? withFrameInstance(m, currentFrame.value) : null;
|
||||
});
|
||||
|
||||
/** Per-occurrence pair lookup for the currently-edited CARRIER module.
|
||||
* Map keys are the carrier's `via.optionIds` — wildcard option ids for a
|
||||
@@ -2880,11 +2889,35 @@ function isInternalable(m: ModuleEntry): boolean {
|
||||
}
|
||||
|
||||
function isLocked(m: ModuleEntry): boolean {
|
||||
return typeof m.instance?.locked_seed === "number";
|
||||
return typeof withFrameInstance(m, currentFrame.value).instance?.locked_seed === "number";
|
||||
}
|
||||
function isInternal(m: ModuleEntry): boolean {
|
||||
return !!m.instance?.internal;
|
||||
}
|
||||
/** Kinds whose handlers honor `seed_scope: hold` — wildcard, combine,
|
||||
* fixed_values, and derivation (which freezes its frame-0 output across a
|
||||
* loop, constrained picks + nested @{} refs included). Now equal to
|
||||
* SEED_LOCKABLE_KINDS, but kept distinct on purpose: lock-the-seed and
|
||||
* hold-the-value are different concepts that could diverge again. Gates
|
||||
* the row context-menu's quick Hold item + which RuntimeSections render
|
||||
* the toggle. */
|
||||
const HOLD_KINDS: ReadonlySet<string> = new Set([
|
||||
"wildcard", "combine", "fixed_values", "derivation",
|
||||
]);
|
||||
function isHoldable(m: ModuleEntry): boolean {
|
||||
return HOLD_KINDS.has(m.type);
|
||||
}
|
||||
function isHeld(m: ModuleEntry): boolean {
|
||||
return m.instance?.seed_scope === "hold";
|
||||
}
|
||||
function isOverriddenOnFrame(m: ModuleEntry): boolean {
|
||||
const k = currentFrame.value;
|
||||
return k != null && !!m.iteration_overrides?.[String(k)];
|
||||
}
|
||||
function effectiveLockedSeed(m: ModuleEntry): number | undefined {
|
||||
const v = withFrameInstance(m, currentFrame.value).instance?.locked_seed;
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
|
||||
// ── Formula-driven minWidth ────────────────────────────────────────
|
||||
// Pull-based pattern: createDomWidgetHost reads this through a
|
||||
@@ -2938,6 +2971,22 @@ watch(
|
||||
function toggleLockOnCard(idx: number) {
|
||||
const m = value.value.modules[idx];
|
||||
if (!m) return;
|
||||
const k = currentFrame.value;
|
||||
if (k != null) {
|
||||
// Prefer the seed THIS module actually rolls at frame #k (computed from the
|
||||
// upstream Loop), so the lock pins the frame's real seed — not the single
|
||||
// representative seed a run surfaced. Falls back to last-used / stored when
|
||||
// no Loop drives the node.
|
||||
const frameSeed = props.frameSeedReader?.(k);
|
||||
const lastUsed = props.lastUsedSeedReader?.(m._uid ?? m.id);
|
||||
const fallback = typeof frameSeed === "number" ? frameSeed
|
||||
: typeof lastUsed === "number" ? lastUsed
|
||||
: (typeof m.instance?._ui?.last_locked_seed === "number" ? m.instance._ui.last_locked_seed : 0);
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = toggleFrameLock(m, k, fallback);
|
||||
commitModules(list);
|
||||
return;
|
||||
}
|
||||
const inst = m.instance ?? {};
|
||||
let nextInst: NonNullable<ModuleEntry["instance"]>;
|
||||
if (typeof inst.locked_seed === "number") {
|
||||
@@ -2980,7 +3029,9 @@ function toggleLockOnCard(idx: number) {
|
||||
// map-by-id pass would toggle every sibling at once. Indexing into
|
||||
// the array hits the specific instance the user clicked.
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = { ...m, instance: nextInst };
|
||||
// Unlocking base makes any frame `locked_seed: null` override redundant —
|
||||
// sweep it so the row stops showing a stale "override #k". No-op on lock.
|
||||
list[idx] = dropRedundantFrameLockNulls({ ...m, instance: nextInst });
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
@@ -3697,11 +3748,32 @@ function moveToEdge(idx: number, edge: "top" | "bottom") {
|
||||
|
||||
function toggleEnabled(idx: number) {
|
||||
if (idx < 0 || idx >= value.value.modules.length) return;
|
||||
const k = currentFrame.value;
|
||||
if (k != null) {
|
||||
// Frame-active: flip the module's per-frame enable override (symmetric —
|
||||
// base-off turns ON for this frame, base-on turns OFF). Pure logic lives in
|
||||
// frame-overrides.toggleFrameEnabled; base `enabled` is never touched here.
|
||||
const m = value.value.modules[idx];
|
||||
if (!m) return;
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = toggleFrameEnabled(m, k);
|
||||
commitModules(list);
|
||||
return;
|
||||
}
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = { ...list[idx], enabled: !list[idx].enabled };
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
// Thin wrappers binding the pure frame-overrides enable helpers to the reactive
|
||||
// `currentFrame` cursor — exposed to ModuleRow via moduleRowCtx.
|
||||
function effectiveEnabled(m: ModuleEntry): boolean {
|
||||
return feEffectiveEnabled(m, currentFrame.value);
|
||||
}
|
||||
function frameEnableOverride(m: ModuleEntry): "on" | "off" | null {
|
||||
return feFrameEnableOverride(m, currentFrame.value);
|
||||
}
|
||||
|
||||
function toggleCollapsed(idx: number) {
|
||||
const target = value.value.modules[idx];
|
||||
if (!target) return;
|
||||
@@ -3718,6 +3790,29 @@ function toggleCollapsed(idx: number) {
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
// Quick "Hold across run" toggle from the row context menu — flips base
|
||||
// `instance.seed_scope` between hold/vary without opening the editor
|
||||
// (the 4-click → 2-click ask). Base-level only: when a loop edit-frame
|
||||
// is active, hold applies to every frame, so we no-op here exactly as
|
||||
// the RuntimeSection disables its toggle. Caller gates on isHoldable;
|
||||
// re-checked here so a stray call can't stamp seed_scope on a kind
|
||||
// (constraint/derivation) whose handler ignores it.
|
||||
function toggleHold(idx: number): void {
|
||||
if (currentFrame.value != null) return;
|
||||
const target = value.value.modules[idx];
|
||||
if (!target || !isHoldable(target)) return;
|
||||
const held = target.instance?.seed_scope === "hold";
|
||||
// Annotate so the literal doesn't widen to `string` in the object
|
||||
// literal — instance.seed_scope is the "vary" | "hold" union.
|
||||
const next: "vary" | "hold" = held ? "vary" : "hold";
|
||||
const list = value.value.modules.map((m, i) =>
|
||||
i === idx
|
||||
? { ...m, instance: { ...(m.instance ?? {}), seed_scope: next } }
|
||||
: m,
|
||||
);
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
/** Bulk collapse/expand. Used by the section-header chevron — one
|
||||
* click flips every card to the same state. Idempotent if all
|
||||
* cards already match the target state (the deep watcher will
|
||||
@@ -3815,7 +3910,22 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
const cleaned: ModuleEntry = { ...updated };
|
||||
delete (cleaned as { _originalId?: string })._originalId;
|
||||
const list = [...value.value.modules];
|
||||
list[targetIdx] = cleaned;
|
||||
const k = currentFrame.value;
|
||||
if (k == null) {
|
||||
list[targetIdx] = cleaned; // base edit — unchanged behavior
|
||||
} else {
|
||||
const baseModule = value.value.modules[targetIdx]; // base: payload + base instance intact
|
||||
const fields = FRAME_OVERRIDABLE_FIELDS[baseModule.type] ?? [];
|
||||
const delta = diffInstance(baseModule.instance, cleaned.instance, fields);
|
||||
// base payload + base instance preserved; the frame's override = the instance delta (replace, not merge)
|
||||
let next = clearFrameOverride(baseModule, k);
|
||||
if (Object.keys(delta).length) next = setFrameOverride(next, k, delta);
|
||||
list[targetIdx] = next;
|
||||
}
|
||||
// A base-unlock (here via the modal, or via the row toggle) makes any frame
|
||||
// `locked_seed: null` override redundant — sweep it so the row stops showing
|
||||
// a stale "override #k". No-op while the base stays locked.
|
||||
list[targetIdx] = dropRedundantFrameLockNulls(list[targetIdx]);
|
||||
commitModules(list);
|
||||
if (updated._originalId && updated._originalId !== updated.id) {
|
||||
nextTick(() => {
|
||||
@@ -3831,6 +3941,18 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
editingIdx.value = null;
|
||||
}
|
||||
|
||||
/** Revert the currently-edited module's frame-#k override back to the
|
||||
* base instance (i.e. remove the iteration_overrides[k] entry entirely).
|
||||
* Called from the frame banner's "Revert to base" button in ModuleEditModal. */
|
||||
function revertEditingFrame(): void {
|
||||
const idx = editingIdx.value;
|
||||
const k = currentFrame.value;
|
||||
if (idx == null || k == null) return;
|
||||
const list = [...value.value.modules];
|
||||
list[idx] = clearFrameOverride(value.value.modules[idx], k);
|
||||
commitModules(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the BundleInstanceModal for `uid`. Snapshots the live
|
||||
* BundleInstance into `bundleDraft` so the user's typing in
|
||||
@@ -4034,6 +4156,26 @@ function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
|
||||
const items: ContextMenuItem[] = [
|
||||
{ label: "Edit", icon: "pi-pencil", onSelect: () => openEditModal(idx) },
|
||||
];
|
||||
// Quick Hold toggle — kept at the top so it's a 2-click reach
|
||||
// (right-click → Hold) instead of opening the editor and digging into
|
||||
// Runtime. Only for kinds that honor seed_scope. Hold is base-level;
|
||||
// when a loop edit-frame is active it applies to every frame, so we
|
||||
// surface it disabled with a hint rather than silently writing it.
|
||||
if (isHoldable(m)) {
|
||||
const held = isHeld(m);
|
||||
const frameActive = currentFrame.value != null;
|
||||
items.push({
|
||||
label: held ? "Release hold" : "Hold across run",
|
||||
icon: "pi-link",
|
||||
disabled: frameActive,
|
||||
subtitle: frameActive
|
||||
? "Applies to every frame — switch to base to change"
|
||||
: held
|
||||
? undefined
|
||||
: "Freeze this value for the whole batch",
|
||||
onSelect: () => toggleHold(idx),
|
||||
});
|
||||
}
|
||||
// Refresh + Save are mutually exclusive in normal use (a module is
|
||||
// either drifted OR missing OR clean), so hiding the inactive entry
|
||||
// beats greying it — matches Save's existing conditional-push pattern
|
||||
@@ -4720,8 +4862,22 @@ const moduleRowCtx: ModuleRowCtx = {
|
||||
pairingFor,
|
||||
viaInboundFor,
|
||||
contributorsFor,
|
||||
currentFrame,
|
||||
isHeld,
|
||||
isOverriddenOnFrame,
|
||||
effectiveLockedSeed,
|
||||
effectiveEnabled,
|
||||
frameEnableOverride,
|
||||
};
|
||||
provide(ModuleRowCtxKey, moduleRowCtx);
|
||||
// Seed source for the edit-modal's Lock button — resolves the seed the module
|
||||
// actually rolled (frame #k's captured seed when a frame is active, else the
|
||||
// last-run seed) so locking pins what the user saw, not a random number.
|
||||
provide(LockSeedKey, (m: ModuleEntry): number | null => {
|
||||
const k = currentFrame.value;
|
||||
if (k != null) return props.frameSeedReader?.(k) ?? null;
|
||||
return props.lastUsedSeedReader?.(m._uid ?? m.id) ?? null;
|
||||
});
|
||||
|
||||
const bundleFrameCtx: BundleFrameCtx = {
|
||||
bundleChildDriftCount,
|
||||
@@ -4986,8 +5142,10 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
:chain-modules="chainModules"
|
||||
:via-option-pairs="editingModuleViaOptionPairs"
|
||||
:last-used-seed-reader="lastUsedSeedReader"
|
||||
:current-frame="currentFrame"
|
||||
@save="saveEditedModule"
|
||||
@close="editingIdx = null"
|
||||
@revert-frame="revertEditingFrame"
|
||||
/>
|
||||
|
||||
<!-- Bundle edit modal — sibling to ModuleEditModal, dispatched
|
||||
|
||||
@@ -590,6 +590,26 @@ describe("ModuleEditModal — derivation v2 dispatcher", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Frame banner + revert-to-base (Phase E.1) ───────────────────────────
|
||||
|
||||
describe("ModuleEditModal — frame banner", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
|
||||
it("shows the frame banner + emits revert-frame when a frame is active", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: 1 } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(true);
|
||||
await w.find('[data-test="modal-frame-revert"]').trigger("click");
|
||||
expect(w.emitted("revert-frame")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("no frame banner on base (currentFrame null)", async () => {
|
||||
const w = mount(ModuleEditModal, { ...mountOpts, props: { visible: true, module: makeWildcard(), currentFrame: null } });
|
||||
await nextTick();
|
||||
expect(w.find('[data-test="modal-frame-banner"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Constraint v2 dispatcher branch ────────────────────────────────────
|
||||
|
||||
describe("ModuleEditModal — constraint v2 dispatcher", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from "vue";
|
||||
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
|
||||
import { type ResolvedValue } from "../../widgets/richTokenize";
|
||||
@@ -51,11 +52,15 @@ const props = defineProps<{
|
||||
* generic chain seed.
|
||||
*/
|
||||
lastUsedSeedReader?: (moduleId?: string) => number | null;
|
||||
/** When a frame override context is active, the 0-based frame index.
|
||||
* Null / undefined = editing the base (no banner shown). */
|
||||
currentFrame?: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save", value: ModuleEntry): void;
|
||||
(e: "close"): void;
|
||||
(e: "revert-frame"): void;
|
||||
}>();
|
||||
|
||||
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
|
||||
@@ -63,8 +68,13 @@ const emit = defineEmits<{
|
||||
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
|
||||
const draft = ref<ModuleEntry | null>(null);
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
if (v && props.module) {
|
||||
watch([() => props.visible, () => props.module], () => {
|
||||
if (props.visible && props.module) {
|
||||
// Re-clone on open AND whenever the source module changes under an open
|
||||
// modal — e.g. "Revert to base" clears the frame override, so editingModule
|
||||
// recomputes and the draft must resync to the base instance. (During normal
|
||||
// editing the draft is mutated locally; props.module identity is stable, so
|
||||
// this does not clobber in-progress edits.)
|
||||
draft.value = JSON.parse(JSON.stringify(props.module));
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
} else {
|
||||
@@ -382,6 +392,23 @@ function cancel() {
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="cancel">
|
||||
<!-- Column so the frame banner stacks ABOVE the per-kind modal card
|
||||
(each per-kind modal renders a plain card; ModalShell's wrapper is
|
||||
a centered flex ROW, so a bare banner sibling would land beside the
|
||||
card). align-self:stretch on the banner spans it to the card width. -->
|
||||
<div class="wp-frame-edit-body">
|
||||
<!-- Frame-context banner — shown when the user is editing overrides
|
||||
for a specific iteration frame (#k). Sits above all per-kind
|
||||
modal bodies. Invisible on base editing (currentFrame == null). -->
|
||||
<div
|
||||
v-if="currentFrame != null"
|
||||
class="wp-modal-frame-banner"
|
||||
data-test="modal-frame-banner"
|
||||
>
|
||||
<span>Editing frame #{{ currentFrame + 1 }} — changes affect only this frame.</span>
|
||||
<button type="button" data-test="modal-frame-revert" @click="emit('revert-frame')">Revert #{{ currentFrame + 1 }} to base</button>
|
||||
</div>
|
||||
|
||||
<!-- v2 wildcard branch — single-pane tailored modal renders its
|
||||
own header/footer/body. No v1 chrome (no .wp-medit wrapper,
|
||||
no tab strip, no LibraryRoundTripActions footer). -->
|
||||
@@ -392,6 +419,7 @@ function cancel() {
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -404,6 +432,7 @@ function cancel() {
|
||||
v-else-if="draft && draft.type === 'fixed_values'"
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -421,6 +450,7 @@ function cancel() {
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-resolved="upstreamResolved"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -442,6 +472,7 @@ function cancel() {
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -460,13 +491,14 @@ function cancel() {
|
||||
:is-modified="instanceModified"
|
||||
:sibling-modules="siblingModules"
|
||||
:chain-modules="chainModules"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@save-to-library="onSaveToLibraryClick"
|
||||
@clear-all-overrides="onClearAllOverrides"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</ModalShell>
|
||||
|
||||
<!-- Themed confirm dialog — replaces window.confirm() for any
|
||||
@@ -503,5 +535,42 @@ function cancel() {
|
||||
* (WildcardInstanceModal, FixedValuesInstanceModal, etc.). The
|
||||
* `.wp-medit` chrome was the v1 tabbed shell — removed alongside
|
||||
* the v1 dispatch in the 2026-05-10 cleanup. */
|
||||
|
||||
.wp-frame-edit-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
}
|
||||
.wp-modal-frame-banner {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 11px;
|
||||
border-radius: 6px;
|
||||
/* solid surface (amber mixed into the modal bg, not over the canvas) so the
|
||||
* text stays readable; amber = the override/warning accent used elsewhere. */
|
||||
background: color-mix(in oklab, var(--wp-amber) 14%, var(--wp-bg2));
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 38%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 500 11px var(--wp-font-sans);
|
||||
}
|
||||
.wp-modal-frame-banner button {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in oklab, var(--wp-amber) 55%, transparent);
|
||||
background: color-mix(in oklab, var(--wp-amber) 20%, transparent);
|
||||
color: var(--wp-amber);
|
||||
font: 600 10px var(--wp-font-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-modal-frame-banner button:hover {
|
||||
background: color-mix(in oklab, var(--wp-amber) 32%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ function makeCtx(over: Partial<ModuleRowCtx> = {}): ModuleRowCtx {
|
||||
pairingFor: () => null,
|
||||
viaInboundFor: () => [],
|
||||
contributorsFor: () => [],
|
||||
currentFrame: ref<number | null>(null),
|
||||
isHeld: () => false,
|
||||
isOverriddenOnFrame: () => false,
|
||||
effectiveLockedSeed: () => undefined,
|
||||
effectiveEnabled: (m) => m.enabled !== false,
|
||||
frameEnableOverride: () => null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -131,3 +137,77 @@ describe("ModuleRow.vue — contributor cluster", () => {
|
||||
expect(wrapper.find(".wp-pair-badge").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — iteration-aware badges", () => {
|
||||
it("shows a held badge when seed_scope is hold", () => {
|
||||
const mod = module({ instance: { seed_scope: "hold" } as ModuleEntry["instance"] });
|
||||
const ctx = makeCtx({ isHeld: (m) => m.instance?.seed_scope === "hold" });
|
||||
const wrapper = mountRow(ctx, mod);
|
||||
expect(wrapper.find('[data-test="mod-held"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an override badge labelled with the current frame", () => {
|
||||
const frame = ref<number | null>(1);
|
||||
const mod = module({ iteration_overrides: { "1": { weight: 2 } } as ModuleEntry["iteration_overrides"] });
|
||||
const ctx = makeCtx({
|
||||
currentFrame: frame,
|
||||
isOverriddenOnFrame: (m) => {
|
||||
const k = frame.value;
|
||||
return k != null && !!m.iteration_overrides?.[String(k)];
|
||||
},
|
||||
});
|
||||
const wrapper = mountRow(ctx, mod);
|
||||
const badge = wrapper.find('[data-test="mod-override"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleRow.vue — per-frame enable/disable", () => {
|
||||
it("checkbox is unchecked when effectiveEnabled is false on the active frame", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => false });
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect(checkbox.exists()).toBe(true);
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("checkbox is checked when effectiveEnabled is true (base-off but on for this frame)", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, effectiveEnabled: () => true });
|
||||
const wrapper = mountRow(ctx, module({ enabled: false }));
|
||||
const checkbox = wrapper.find('input[type="checkbox"]');
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an off-#k badge when frameEnableOverride is 'off'", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "off" });
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
const badge = wrapper.find('[data-test="mod-frame-disabled"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#3"); // frame index 2 (0-based) → "#3"
|
||||
});
|
||||
|
||||
it("shows an on-#k badge when frameEnableOverride is 'on'", () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const ctx = makeCtx({ currentFrame: frame, frameEnableOverride: () => "on" });
|
||||
const wrapper = mountRow(ctx, module({ enabled: false }));
|
||||
const badge = wrapper.find('[data-test="mod-frame-enabled"]');
|
||||
expect(badge.exists()).toBe(true);
|
||||
expect(badge.text()).toContain("#3");
|
||||
});
|
||||
|
||||
it("checkbox change calls ctx.toggleEnabled with the row index", async () => {
|
||||
const frame = ref<number | null>(2);
|
||||
const calls: number[] = [];
|
||||
const ctx = makeCtx({
|
||||
currentFrame: frame,
|
||||
toggleEnabled: (idx) => { calls.push(idx); },
|
||||
});
|
||||
const wrapper = mountRow(ctx, module({ enabled: true }));
|
||||
await wrapper.find('input[type="checkbox"]').trigger("change");
|
||||
expect(calls).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ const {
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
:class="{
|
||||
'wp-disabled': !module.enabled,
|
||||
'wp-disabled': !(ctx?.effectiveEnabled(module) ?? (module.enabled !== false)),
|
||||
'wp-conflict-error': severityFor(module._uid ?? module.id) === 'error',
|
||||
'wp-conflict-warning': severityFor(module._uid ?? module.id) === 'warning',
|
||||
'wp-conflict-info': severityFor(module._uid ?? module.id) === 'info',
|
||||
@@ -74,8 +74,12 @@ const {
|
||||
:title="isCollapsed(module) ? 'Expand' : 'Collapse'" @click="toggleCollapsed(idx)">
|
||||
<i :class="['pi', isCollapsed(module) ? 'pi-caret-right' : 'pi-caret-down']" aria-hidden="true"></i>
|
||||
</button>
|
||||
<label class="wp-toggle" draggable="false" :title="module.enabled ? 'Disable' : 'Enable'">
|
||||
<input type="checkbox" :checked="module.enabled" :aria-label="`enable ${module.meta.name}`" @change="toggleEnabled(idx)" />
|
||||
<label class="wp-toggle" draggable="false"
|
||||
:title="ctx && ctx.currentFrame.value != null ? (ctx.effectiveEnabled(module) ? 'Disable on this frame' : 'Enable on this frame') : (module.enabled ? 'Disable' : 'Enable')">
|
||||
<input type="checkbox"
|
||||
:checked="ctx ? ctx.effectiveEnabled(module) : module.enabled"
|
||||
:aria-label="`enable ${module.meta.name}`"
|
||||
@change="toggleEnabled(idx)" />
|
||||
<span class="wp-toggle-mark"></span>
|
||||
</label>
|
||||
<span class="wp-row-type-icon" :title="module.type" aria-hidden="true">
|
||||
@@ -149,11 +153,15 @@ const {
|
||||
class="wp-conflict-badge"
|
||||
:class="`wp-conflict-badge--${severityFor(module._uid ?? module.id)}`"
|
||||
:title="conflictTooltip(module._uid ?? module.id)">{{ conflictBadgeText(module._uid ?? module.id) }}</span>
|
||||
<span v-if="ctx?.isHeld(module)" class="wp-mod-badge wp-mod-badge--clash" data-test="mod-held" title="Held across the run">held</span>
|
||||
<span v-if="ctx?.isOverriddenOnFrame(module)" class="wp-conflict-badge wp-conflict-badge--warning" data-test="mod-override" title="Overridden on this frame">override #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
<span v-if="ctx?.frameEnableOverride(module) === 'off'" class="wp-mod-badge wp-mod-badge--missing" data-test="mod-frame-disabled" title="Disabled on this frame">off #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
<span v-else-if="ctx?.frameEnableOverride(module) === 'on'" class="wp-mod-badge wp-mod-badge--on" data-test="mod-frame-enabled" title="Enabled on this frame">on #{{ (ctx.currentFrame.value ?? 0) + 1 }}</span>
|
||||
</span>
|
||||
<div class="wp-mod-actions" draggable="false">
|
||||
<button v-if="isSeedLockable(module)" type="button" class="wp-btn--icon-sm wp-btn--warn"
|
||||
:class="{ 'is-locked': isLocked(module) }" data-test="row-action-lock"
|
||||
:title="isLocked(module) ? `Locked seed: ${module.instance?.locked_seed}. Click to unlock.` : 'Lock seed'"
|
||||
:title="isLocked(module) ? `Locked seed: ${ctx?.effectiveLockedSeed(module)}. Click to unlock.` : 'Lock seed'"
|
||||
:aria-label="isLocked(module) ? 'Unlock seed' : 'Lock seed'"
|
||||
@click.stop="toggleLockOnCard(idx)"><i class="pi pi-lock" /></button>
|
||||
<button v-if="module.type === 'wildcard' || module.type === 'fixed_values' || module.type === 'combine' || module.type === 'derivation'"
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
* `runtime-seed-up`, `runtime-seed-down` — so the existing test
|
||||
* suites pass without modification.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, inject } from "vue";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import { LockSeedKey } from "../../lock-seed-ctx";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -36,6 +37,10 @@ const props = withDefaults(
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
// The seed the module actually rolled — provided by ContextWidget. Used as the
|
||||
// default when locking so "Lock" pins what the user saw, not a fresh random.
|
||||
const lockSeedFor = inject(LockSeedKey, null);
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
const locked = computed(() => typeof instance.value.locked_seed === "number");
|
||||
const seedValue = computed(() =>
|
||||
@@ -71,7 +76,11 @@ function onLockClick(): void {
|
||||
} else {
|
||||
const ui = instance.value._ui ?? {};
|
||||
const remembered = typeof ui.last_locked_seed === "number" ? ui.last_locked_seed : null;
|
||||
const seed = remembered ?? randomSeed();
|
||||
// Prefer the seed the module ACTUALLY rolled (frame #k's captured seed, or
|
||||
// the last-run seed) so locking reproduces what the user saw; then the
|
||||
// remembered seed; a fresh random only as a last resort.
|
||||
const resolved = lockSeedFor?.(props.module);
|
||||
const seed = typeof resolved === "number" ? resolved : (remembered ?? randomSeed());
|
||||
emitInstance({
|
||||
locked_seed: seed,
|
||||
_ui: { ...ui, last_locked_seed: seed },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { INSTANCE_FIELDS_PER_KIND } from "./_shell";
|
||||
import { FRAME_OVERRIDABLE_FIELDS, INSTANCE_FIELDS_PER_KIND } from "./_shell";
|
||||
|
||||
describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
it("lists exactly the fields each kind exposes per spec §5.5", () => {
|
||||
@@ -7,7 +7,7 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
// v2 dropped `mode` + `pinned_option_id` — see
|
||||
// docs/superpowers/specs/2026-05-08-instance-overrides-v2-design.md §6.1.
|
||||
"variable_binding", "enabled_options",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
|
||||
// SP2a multi-select (2026-06-08): per-instance count range + separator.
|
||||
// SP2c: pick_independent (allow repeats / with replacement).
|
||||
"pick_min", "pick_max", "pick_separator", "pick_independent",
|
||||
@@ -16,15 +16,17 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
// `locked_seed` added in the combine + fixed_values syntax-parity
|
||||
// cycle (2026-05-08): fixed_values now resolves `{a|b|c}` per
|
||||
// value, so it gains seed-lock parity with wildcard + combine.
|
||||
"values_overrides", "enabled_options", "locked_seed",
|
||||
// `seed_scope` added for iteration-aware hold/vary control.
|
||||
"values_overrides", "enabled_options", "locked_seed", "seed_scope",
|
||||
]);
|
||||
expect(INSTANCE_FIELDS_PER_KIND.combine).toEqual([
|
||||
// v2 single-pane modal exposes per-instance overrides:
|
||||
// - template_override (combine v2, this cycle)
|
||||
// - variable_binding (matches wildcard's identity-section UX)
|
||||
// - locked_seed (parity with wildcard + fixed_values)
|
||||
// - seed_scope (iteration-aware hold/vary control)
|
||||
// - internal (existing hide-from-prompt toggle)
|
||||
"template_override", "variable_binding", "locked_seed", "internal",
|
||||
"template_override", "variable_binding", "locked_seed", "seed_scope", "internal",
|
||||
]);
|
||||
expect(INSTANCE_FIELDS_PER_KIND.derivation).toEqual([
|
||||
// Tier-D modal expansion (2026-05-10): per-rule + per-branch
|
||||
@@ -54,3 +56,16 @@ describe("INSTANCE_FIELDS_PER_KIND registry", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FRAME_OVERRIDABLE_FIELDS", () => {
|
||||
const EXCLUDED = ["seed_scope", "variable_binding", "internal"] as const;
|
||||
for (const kind of Object.keys(FRAME_OVERRIDABLE_FIELDS) as Array<keyof typeof FRAME_OVERRIDABLE_FIELDS>) {
|
||||
it(`${kind}: excludes run-level/structural fields`, () => {
|
||||
for (const ex of EXCLUDED) expect(FRAME_OVERRIDABLE_FIELDS[kind]).not.toContain(ex);
|
||||
});
|
||||
it(`${kind}: is a subset of INSTANCE_FIELDS_PER_KIND`, () => {
|
||||
const sup = new Set(INSTANCE_FIELDS_PER_KIND[kind]);
|
||||
for (const f of FRAME_OVERRIDABLE_FIELDS[kind]) expect(sup.has(f)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export type InstanceFieldKey =
|
||||
| "variable_binding" | "enabled_options" | "option_weights" | "category_filter"
|
||||
| "exclude_null"
|
||||
| "pick_min" | "pick_max" | "pick_separator" | "pick_independent"
|
||||
| "mode" | "pinned_option_id" | "locked_seed" | "internal" | "values_overrides"
|
||||
| "mode" | "pinned_option_id" | "locked_seed" | "seed_scope" | "internal" | "values_overrides"
|
||||
| "template_override"
|
||||
| "disabled_rule_ids"
|
||||
// Derivation tier-D modal expansion (2026-05-10 cycle)
|
||||
@@ -46,13 +46,13 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
|
||||
// implicit in pool state. Engine handler still reads them on
|
||||
// legacy snapshots; UI never writes them.
|
||||
"variable_binding", "enabled_options",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "internal",
|
||||
"option_weights", "category_filter", "exclude_null", "locked_seed", "seed_scope", "internal",
|
||||
// SP2a multi-select: count range + join separator (per-instance, local).
|
||||
// SP2c: pick_independent (allow repeats / with replacement).
|
||||
"pick_min", "pick_max", "pick_separator", "pick_independent",
|
||||
],
|
||||
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
|
||||
combine: ["template_override", "variable_binding", "locked_seed", "internal"],
|
||||
fixed_values: ["values_overrides", "enabled_options", "locked_seed", "seed_scope"],
|
||||
combine: ["template_override", "variable_binding", "locked_seed", "seed_scope", "internal"],
|
||||
derivation: [
|
||||
// Tier-D modal expansion (2026-05-10):
|
||||
// - rule-group overrides (cleared by Reset btn)
|
||||
@@ -83,3 +83,19 @@ export const INSTANCE_FIELDS_PER_KIND: Record<ModuleEntryKind, readonly Instance
|
||||
"target_select",
|
||||
],
|
||||
};
|
||||
|
||||
// Subset of INSTANCE_FIELDS_PER_KIND that is meaningful to override PER-FRAME.
|
||||
// Excludes run-level (seed_scope), structural/reference (variable_binding), and
|
||||
// internal (base-only; per-frame visibility unsupported in v1). Used only by the
|
||||
// per-frame save-diff so toggling Hold/binding/Hide on a frame records no override.
|
||||
export const FRAME_OVERRIDABLE_FIELDS: Record<ModuleEntryKind, readonly InstanceFieldKey[]> = {
|
||||
wildcard: ["enabled_options", "option_weights", "category_filter", "exclude_null",
|
||||
"locked_seed", "pick_min", "pick_max", "pick_separator", "pick_independent"],
|
||||
fixed_values: ["values_overrides", "enabled_options", "locked_seed"],
|
||||
combine: ["template_override", "locked_seed"],
|
||||
derivation: ["disabled_rule_ids", "disabled_branch_keys", "action_value_overrides",
|
||||
"condition_value_overrides", "rule_order_override", "locked_seed"],
|
||||
constraint: ["disabled_exception_keys", "disabled_matrix_cells", "cell_mode_overrides",
|
||||
"cell_factor_overrides", "exception_mode_overrides", "exception_factor_overrides",
|
||||
"extra_exceptions", "target_select"],
|
||||
};
|
||||
|
||||
@@ -186,6 +186,8 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
.wp-bdm__hint kbd { font: 9px var(--wp-font-mono); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); padding: 1px 4px; border-radius: 2px; color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-bdm__btn { padding: 5px 12px; border: 1px solid var(--wp-border); border-radius: 3px; background: transparent; color: var(--wp-text-muted, var(--wp-text2)); font: 11px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-bdm__btn--primary { border-color: var(--wp-accent); background: var(--wp-accent); color: white; }
|
||||
.wp-bdm__btn:hover { border-color: var(--wp-border-strong, var(--wp-border2)); color: var(--wp-text); }
|
||||
.wp-bdm__btn--primary:hover { border-color: var(--wp-accent2, var(--wp-accent)); background: var(--wp-accent2, var(--wp-accent)); color: white; }
|
||||
.wp-bdm__btn--quiet { border-color: transparent; color: var(--wp-text-dim, var(--wp-text3)); display: inline-flex; align-items: center; gap: 5px; font-size: 10px; }
|
||||
.wp-bdm__btn--quiet:hover { border-color: var(--wp-border); color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.wp-bdm__btn--quiet .pi { font-size: 10px; }
|
||||
|
||||
@@ -35,6 +35,9 @@ const props = withDefaults(
|
||||
/** Sibling var names produced by other modules in this same Context
|
||||
* node. Combined with upstreamVars to populate the dropdown. */
|
||||
siblingVars?: string[];
|
||||
/** When true, a frame override context is active. Run-level and
|
||||
* structural controls are disabled in frame mode. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isDrifted: false,
|
||||
@@ -100,6 +103,7 @@ function onSpaClick(): void {
|
||||
:module="module"
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="frameActive"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<TemplateSection
|
||||
@@ -110,7 +114,7 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-cbm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-cbm__foot">
|
||||
<a
|
||||
@@ -220,6 +224,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-cbm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-cbm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-cbm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// R3: IdentitySection frame-active gating — combine variant.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import IdentitySection from "./IdentitySection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "cb12cd34",
|
||||
type: "combine",
|
||||
enabled: true,
|
||||
meta: { name: "final_prompt" },
|
||||
entries: [],
|
||||
payload: { output_var: "final_prompt", template: "$style portrait" },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("combine IdentitySection — frameActive gating on variable binding", () => {
|
||||
it("binding input is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input IS disabled when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="id-binding"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows binding frame-active hint when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show binding frame-active hint when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,9 @@ const props = withDefaults(
|
||||
upstreamVars?: string[];
|
||||
/** Names produced by other modules in the SAME Context node. */
|
||||
siblingVars?: string[];
|
||||
/** When true, a frame is active — the variable binding input is
|
||||
* structural (not per-frame) so it is disabled with a hint. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ upstreamVars: () => [], siblingVars: () => [] },
|
||||
);
|
||||
@@ -157,6 +160,7 @@ function onResetBinding(): void {
|
||||
:value="bindingValue"
|
||||
:placeholder="libraryBinding"
|
||||
aria-label="Variable binding"
|
||||
:disabled="frameActive || undefined"
|
||||
@input="onBindingInput"
|
||||
/>
|
||||
</div>
|
||||
@@ -171,6 +175,11 @@ function onResetBinding(): void {
|
||||
><i class="pi pi-replay" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="id__frame-hint"
|
||||
data-test="id-binding-frame-hint"
|
||||
>Applies to every frame — switch to base to change.</p>
|
||||
<div
|
||||
v-if="collidesWith !== null"
|
||||
class="id__collision"
|
||||
@@ -303,4 +312,9 @@ function onResetBinding(): void {
|
||||
border: 1px solid color-mix(in srgb, var(--wp-accent) 35%, transparent);
|
||||
}
|
||||
.id__collision .pi { font-size: 11px; margin-top: 1px; flex-shrink: 0; }
|
||||
.id__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// R3: RuntimeSection frame-active gating — combine variant.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuntimeSection from "./RuntimeSection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "cb12cd34",
|
||||
type: "combine",
|
||||
enabled: true,
|
||||
meta: { name: "final_prompt" },
|
||||
entries: [],
|
||||
payload: { output_var: "final_prompt", template: "$style portrait" },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("combine RuntimeSection — frameActive gating", () => {
|
||||
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hold toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows frame-active hint when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show frame-active hint when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -112,4 +112,21 @@ describe("combine RuntimeSection", () => {
|
||||
await w.find('[data-test="runtime-hide"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.internal).toBe(true);
|
||||
});
|
||||
|
||||
it("seed-scope toggle is off (vary) by default", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import SeedLockControls from "../../_shared/SeedLockControls.vue";
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
@@ -21,6 +21,11 @@ const internal = computed(() => instance.value.internal === true);
|
||||
function onHideClick(): void {
|
||||
emit("update", { instance: { ...instance.value, internal: !internal.value } });
|
||||
}
|
||||
|
||||
const held = computed(() => instance.value.seed_scope === "hold");
|
||||
function onHoldClick(): void {
|
||||
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,11 +39,30 @@ function onHideClick(): void {
|
||||
data-test="runtime-hide"
|
||||
role="switch"
|
||||
:aria-checked="internal"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHideClick"
|
||||
>
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ 'toggle--on': held }"
|
||||
data-test="runtime-hold"
|
||||
role="switch"
|
||||
:aria-checked="held"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHoldClick"
|
||||
>
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
Hold across run
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -78,4 +102,13 @@ function onHideClick(): void {
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
}
|
||||
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.runtime__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -65,6 +65,11 @@ const props = withDefaults(
|
||||
* Optional — undefined in headless/legacy mounts falls back to
|
||||
* `siblingModules` alone. */
|
||||
chainModules?: ChainModule[];
|
||||
/** When true, a frame override context is active. Accepted for
|
||||
* interface uniformity; constraint has no RuntimeSection and its
|
||||
* IdentitySection carries no variable-binding input, so nothing
|
||||
* is forwarded to a child here. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ isDrifted: false, isModified: false, siblingModules: () => [] },
|
||||
);
|
||||
@@ -847,6 +852,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-cnm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-cnm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-cnm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
@@ -42,6 +42,9 @@ const props = withDefaults(
|
||||
* engine branch key (`${rule_id}:${bi}` / `${rule_id}:else`). Forwarded
|
||||
* verbatim to RulesSection, which renders the inline `↪#N` badge. */
|
||||
viaOptionPairs?: Map<string, readonly PairingBadge[]>;
|
||||
/** When true, a frame override context is active. Run-level controls
|
||||
* are disabled in frame mode. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isDrifted: false,
|
||||
@@ -161,7 +164,7 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-dvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-dvm__foot">
|
||||
<a
|
||||
@@ -213,7 +216,17 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
/* The MODAL does not scroll — only the RULES list inside does (it caps at
|
||||
* an absolute max-height and scrolls in place). overflow:hidden here forces
|
||||
* that: it's the only overflow path, so header + identity stay pinned at
|
||||
* the top and the dock at the bottom while the rules scroll between them.
|
||||
* (overflow-y:auto on the modal instead let the WHOLE modal scroll, sliding
|
||||
* the bottom rules under the sticky dock — the "can't see the full rules"
|
||||
* bug. An absolute inner max-height — not flex:1, which collapses because
|
||||
* the overlay parents carry only max-height:100% — is the load-bearing
|
||||
* part; RulesSection sizes it to 80vh minus this fixed chrome so nothing
|
||||
* clips.) */
|
||||
overflow: hidden;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
@@ -225,6 +238,7 @@ function onSpaClick(): void {
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-dvm__foot {
|
||||
display: flex;
|
||||
@@ -270,6 +284,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-dvm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-dvm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-dvm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
@@ -693,10 +693,34 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
/* Long rule lists scroll in place so identity / footer stay visible. */
|
||||
max-height: 44vh;
|
||||
/* Long rule lists scroll IN PLACE so identity / runtime / footer stay put.
|
||||
* Absolute max-height (NOT flex:1) — same load-bearing trick as the
|
||||
* wildcard's .pool__opts — because the modal's overlay parents carry only
|
||||
* max-height:100% (no definite height), so a flex-fill child collapses or
|
||||
* clips here. The cap = the modal budget (80vh, matching .dvm) MINUS the
|
||||
* fixed chrome (header + identity + rules head + runtime/footer dock,
|
||||
* measured ≈ 15rem, padded to 20rem so the dock never clips under .dvm's
|
||||
* overflow:hidden). list + chrome stays under 80vh, so the modal never
|
||||
* scrolls and THIS list owns the only scrollbar. */
|
||||
max-height: calc(80vh - 20rem);
|
||||
overflow-y: auto;
|
||||
/* Keep the scrollbar CLEARLY visible — long rule lists weren't obviously
|
||||
* scrollable before, so users thought rules were truncated. Stronger than
|
||||
* the app's subtle token default on purpose. */
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.38) transparent;
|
||||
}
|
||||
.rules__list::-webkit-scrollbar { width: 12px; }
|
||||
.rules__list::-webkit-scrollbar-track {
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border-radius: 6px;
|
||||
}
|
||||
.rules__list::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.38);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--wp-bg-deep, var(--wp-bg));
|
||||
}
|
||||
.rules__list::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.55); }
|
||||
|
||||
/* Rule card */
|
||||
.rule-card {
|
||||
@@ -704,6 +728,13 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
/* THE load-bearing fix for "rules clipped, no scrollbar": the cards are
|
||||
* flex children of the flex-column .rules__list, so by default they SHRINK
|
||||
* to fit its max-height and `overflow:hidden` then clips their branches —
|
||||
* the list never overflows, so it never scrolls and no scrollbar appears.
|
||||
* Pinning flex-shrink:0 keeps each card at full height; the list then truly
|
||||
* overflows its max-height and scrolls (with its scrollbar). */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rule-card--off { opacity: 0.55; }
|
||||
.rule-card--dragging { opacity: 0.5; }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// R3: RuntimeSection frame-active gating — derivation variant.
|
||||
// Derivation RuntimeSection only has runtime-hide (no runtime-hold),
|
||||
// so only the Hide from prompt toggle needs disabling.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuntimeSection from "./RuntimeSection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "dv012345",
|
||||
type: "derivation",
|
||||
enabled: true,
|
||||
meta: { name: "mood-rules" },
|
||||
entries: [],
|
||||
payload: { rules: [] },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("derivation RuntimeSection — frameActive gating", () => {
|
||||
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows frame-active hint when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show frame-active hint when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,33 @@ describe("derivation RuntimeSection", () => {
|
||||
expect(w.find('[data-test="runtime-hide"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the Hold across run toggle, off by default", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
const hold = w.find('[data-test="runtime-hold"]');
|
||||
expect(hold.exists()).toBe(true);
|
||||
expect(hold.text()).toContain("Hold across run");
|
||||
expect(hold.classes()).not.toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("Hold toggle reflects seed_scope=hold as on", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
expect(w.find('[data-test="runtime-hold"]').classes()).toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("clicking Hold emits seed_scope = hold; clicking when held emits vary", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
|
||||
const held = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
await held.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(held).instance?.seed_scope).toBe("vary");
|
||||
});
|
||||
|
||||
it("Hold toggle is disabled while a loop edit-frame is active", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("seed input hidden when not locked", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-seed"]').exists()).toBe(false);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Derivation RuntimeSection — composes the shared `<SeedLockControls />`
|
||||
* (default "Lock seed" label) plus the Hide-from-prompt toggle.
|
||||
* Mirrors combine's RuntimeSection 1:1 because the surface semantics
|
||||
* are the same: derivation's `action.value` resolves `{a|b|c}`
|
||||
* alternations, so locking the seed pins each pick across queues.
|
||||
* (default "Lock seed" label) plus the Hide-from-prompt and
|
||||
* Hold-across-run toggles. Mirrors combine's RuntimeSection 1:1 because
|
||||
* the surface semantics are the same: derivation's `action.value`
|
||||
* resolves `{a|b|c}` alternations (+ nested `@{}` refs), so locking the
|
||||
* seed pins each pick across queues and Hold freezes the resolved output
|
||||
* across a loop run.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import SeedLockControls from "../../_shared/SeedLockControls.vue";
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
@@ -19,6 +21,11 @@ const internal = computed(() => instance.value.internal === true);
|
||||
function onHideClick(): void {
|
||||
emit("update", { instance: { ...instance.value, internal: !internal.value } });
|
||||
}
|
||||
|
||||
const held = computed(() => instance.value.seed_scope === "hold");
|
||||
function onHoldClick(): void {
|
||||
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -32,11 +39,30 @@ function onHideClick(): void {
|
||||
data-test="runtime-hide"
|
||||
role="switch"
|
||||
:aria-checked="internal"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHideClick"
|
||||
>
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ 'toggle--on': held }"
|
||||
data-test="runtime-hold"
|
||||
role="switch"
|
||||
:aria-checked="held"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHoldClick"
|
||||
>
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
Hold across run
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -76,4 +102,13 @@ function onHideClick(): void {
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
}
|
||||
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.runtime__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,9 @@ const props = withDefaults(
|
||||
/** True when draft has unsaved instance edits. Gates "Save to library"
|
||||
* visibility — pushing an unmodified payload back is a no-op. */
|
||||
isModified?: boolean;
|
||||
/** When true, a frame override context is active. Run-level controls
|
||||
* are disabled in frame mode. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ isDrifted: false, isModified: false },
|
||||
);
|
||||
@@ -67,7 +70,7 @@ function onSpaClick(): void {
|
||||
<IdentitySection :module="module" @update="onUpdate" />
|
||||
<ValuesSection :module="module" @update="onUpdate" />
|
||||
<div class="wp-fvm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-fvm__foot">
|
||||
<a
|
||||
@@ -177,6 +180,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-fvm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-fvm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-fvm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// R3: RuntimeSection frame-active gating — fixed-values variant.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuntimeSection from "./RuntimeSection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "fv012345",
|
||||
type: "fixed_values",
|
||||
enabled: true,
|
||||
meta: { name: "presets" },
|
||||
entries: [],
|
||||
payload: { values: [] },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("fixed-values RuntimeSection — frameActive gating", () => {
|
||||
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hide"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hold toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-hold"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows frame-active hint when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show frame-active hint when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,11 @@ function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function lastPatch(w: ReturnType<typeof mount>): Partial<ModuleEntry> {
|
||||
const updates = w.emitted("update")! as unknown[][];
|
||||
return updates[updates.length - 1][0] as Partial<ModuleEntry>;
|
||||
}
|
||||
|
||||
describe("fixed-values RuntimeSection", () => {
|
||||
it("renders Hide-from-prompt toggle", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
@@ -80,4 +85,21 @@ describe("fixed-values RuntimeSection", () => {
|
||||
});
|
||||
expect(on.find('[data-test="runtime-seed"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("seed-scope toggle is off (vary) by default", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Combine RuntimeSection — composes the shared `<SeedLockControls />`
|
||||
* (default "Lock seed" label; combine pins inline `{a|b|c}`
|
||||
* alternation resolution, not an option pick) plus the
|
||||
* Hide-from-prompt toggle.
|
||||
*
|
||||
* Slimmed during the 2026-05-09 extraction — previously held a
|
||||
* verbatim copy of wildcard's lock UI.
|
||||
* FixedValues RuntimeSection — composes the shared `<SeedLockControls />`
|
||||
* plus the Hide-from-prompt and Hold-across-run toggles.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import SeedLockControls from "../../_shared/SeedLockControls.vue";
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
@@ -21,6 +16,11 @@ const internal = computed(() => instance.value.internal === true);
|
||||
function onHideClick(): void {
|
||||
emit("update", { instance: { ...instance.value, internal: !internal.value } });
|
||||
}
|
||||
|
||||
const held = computed(() => instance.value.seed_scope === "hold");
|
||||
function onHoldClick(): void {
|
||||
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,11 +34,30 @@ function onHideClick(): void {
|
||||
data-test="runtime-hide"
|
||||
role="switch"
|
||||
:aria-checked="internal"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHideClick"
|
||||
>
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ 'toggle--on': held }"
|
||||
data-test="runtime-hold"
|
||||
role="switch"
|
||||
:aria-checked="held"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHoldClick"
|
||||
>
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
Hold across run
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -78,4 +97,13 @@ function onHideClick(): void {
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
}
|
||||
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.runtime__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,9 @@ const props = withDefaults(
|
||||
* (one of its options contains a nested `@{uuid}` that resolves to a
|
||||
* downstream constraint's target). Keyed by option id. */
|
||||
viaOptionPairs?: Map<string, PairingBadge[]>;
|
||||
/** When true, a frame override context is active. Run-level and
|
||||
* structural controls are disabled in frame mode. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ isDrifted: false, isModified: false, upstreamVars: () => [], siblingVars: () => [], viaOptionPairs: () => new Map() },
|
||||
);
|
||||
@@ -98,11 +101,12 @@ function onSpaClick(): void {
|
||||
:module="module"
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="frameActive"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<PoolSection :module="module" :via-option-pairs="viaOptionPairs" @update="onUpdate" />
|
||||
<div class="wp-wcm__dock">
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-wcm__foot">
|
||||
<a
|
||||
@@ -223,6 +227,15 @@ function onSpaClick(): void {
|
||||
background: var(--wp-accent);
|
||||
color: white;
|
||||
}
|
||||
.wp-wcm__btn:hover {
|
||||
border-color: var(--wp-border-strong, var(--wp-border2));
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-wcm__btn--primary:hover {
|
||||
border-color: var(--wp-accent2, var(--wp-accent));
|
||||
background: var(--wp-accent2, var(--wp-accent));
|
||||
color: white;
|
||||
}
|
||||
.wp-wcm__btn--quiet {
|
||||
border-color: transparent;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// R3: IdentitySection frame-active gating — wildcard variant.
|
||||
// variable_binding is a structural field that is meaningless to change
|
||||
// on a per-frame basis, so the binding input must be disabled when
|
||||
// frameActive=true.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import IdentitySection from "./IdentitySection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "ab12cd34",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "outfit" },
|
||||
entries: [],
|
||||
payload: { var_binding: "outfit", options: [] },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("wildcard IdentitySection — frameActive gating on variable binding", () => {
|
||||
it("binding input is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule() } });
|
||||
const input = w.find('[data-test="id-binding"]');
|
||||
expect(input.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
const input = w.find('[data-test="id-binding"]');
|
||||
expect(input.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("binding input IS disabled when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
const input = w.find('[data-test="id-binding"]');
|
||||
expect(input.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows binding frame-active hint when frameActive=true", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show binding frame-active hint when frameActive=false", () => {
|
||||
const w = mount(IdentitySection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="id-binding-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,9 @@ const props = withDefaults(
|
||||
upstreamVars?: string[];
|
||||
/** Names produced by other modules in the SAME Context node. */
|
||||
siblingVars?: string[];
|
||||
/** When true, a frame is active — the variable binding input is
|
||||
* structural (not per-frame) so it is disabled with a hint. */
|
||||
frameActive?: boolean;
|
||||
}>(),
|
||||
{ upstreamVars: () => [], siblingVars: () => [] },
|
||||
);
|
||||
@@ -162,6 +165,7 @@ function onResetBinding(): void {
|
||||
:value="bindingValue"
|
||||
:placeholder="libraryBinding"
|
||||
aria-label="Variable binding"
|
||||
:disabled="frameActive || undefined"
|
||||
@input="onBindingInput"
|
||||
/>
|
||||
</div>
|
||||
@@ -176,6 +180,11 @@ function onResetBinding(): void {
|
||||
><i class="pi pi-replay" aria-hidden="true" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="id__frame-hint"
|
||||
data-test="id-binding-frame-hint"
|
||||
>Applies to every frame — switch to base to change.</p>
|
||||
<div
|
||||
v-if="collidesWith !== null"
|
||||
class="id__collision"
|
||||
@@ -287,4 +296,9 @@ function onResetBinding(): void {
|
||||
border: 1px solid color-mix(in srgb, var(--wp-accent) 35%, transparent);
|
||||
}
|
||||
.id__collision .pi { font-size: 11px; margin-top: 1px; flex-shrink: 0; }
|
||||
.id__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// R3: RuntimeSection frame-active gating — wildcard variant.
|
||||
// Hide from prompt + Hold across run are run-level controls that apply
|
||||
// to every frame, so they must be disabled (not interactive) when the
|
||||
// user is editing a per-frame override context. Tests assert:
|
||||
// - controls get `disabled` attribute when frameActive=true
|
||||
// - controls are NOT disabled when frameActive is absent/false
|
||||
// - a hint message is shown in frame-active mode
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuntimeSection from "./RuntimeSection.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return {
|
||||
id: "ab12cd34",
|
||||
type: "wildcard",
|
||||
enabled: true,
|
||||
meta: { name: "outfit" },
|
||||
entries: [],
|
||||
payload: { var_binding: "outfit", options: [] },
|
||||
instance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("wildcard RuntimeSection — frameActive gating", () => {
|
||||
it("hide toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
const btn = w.find('[data-test="runtime-hide"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled in base mode (frameActive absent)", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
const btn = w.find('[data-test="runtime-hold"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
const btn = w.find('[data-test="runtime-hide"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hold toggle is NOT disabled when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
const btn = w.find('[data-test="runtime-hold"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hide toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
const btn = w.find('[data-test="runtime-hide"]');
|
||||
expect(btn.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hold toggle IS disabled when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
const btn = w.find('[data-test="runtime-hold"]');
|
||||
expect(btn.attributes("disabled")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows frame-active hint when frameActive=true", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: true } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show frame-active hint when frameActive=false", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule(), frameActive: false } });
|
||||
expect(w.find('[data-test="runtime-frame-hint"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -176,4 +176,21 @@ describe("RuntimeSection", () => {
|
||||
expect(seed).toBeGreaterThanOrEqual(0);
|
||||
expect(seed).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
it("seed-scope toggle is off (vary) by default", () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
expect(w.find('[data-test="runtime-hold"]').classes()).not.toContain("toggle--on");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle emits patch with seed_scope = hold", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule() } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("hold");
|
||||
});
|
||||
|
||||
it("clicking seed-scope toggle when held emits seed_scope = vary", async () => {
|
||||
const w = mount(RuntimeSection, { props: { module: makeModule({ instance: { seed_scope: "hold" } }) } });
|
||||
await w.find('[data-test="runtime-hold"]').trigger("click");
|
||||
expect(lastPatch(w).instance?.seed_scope).toBe("vary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import SeedLockControls from "../../_shared/SeedLockControls.vue";
|
||||
|
||||
const props = defineProps<{ module: ModuleEntry }>();
|
||||
const props = defineProps<{ module: ModuleEntry; frameActive?: boolean }>();
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
@@ -20,6 +20,11 @@ const internal = computed(() => instance.value.internal === true);
|
||||
function onHideClick(): void {
|
||||
emit("update", { instance: { ...instance.value, internal: !internal.value } });
|
||||
}
|
||||
|
||||
const held = computed(() => instance.value.seed_scope === "hold");
|
||||
function onHoldClick(): void {
|
||||
emit("update", { instance: { ...instance.value, seed_scope: held.value ? "vary" : "hold" } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,11 +38,30 @@ function onHideClick(): void {
|
||||
data-test="runtime-hide"
|
||||
role="switch"
|
||||
:aria-checked="internal"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHideClick"
|
||||
>
|
||||
<i class="pi pi-eye-slash" aria-hidden="true" />
|
||||
Hide from prompt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ 'toggle--on': held }"
|
||||
data-test="runtime-hold"
|
||||
role="switch"
|
||||
:aria-checked="held"
|
||||
:disabled="frameActive || undefined"
|
||||
@click="onHoldClick"
|
||||
>
|
||||
<i class="pi pi-link" aria-hidden="true" />
|
||||
Hold across run
|
||||
</button>
|
||||
<p
|
||||
v-if="frameActive"
|
||||
class="runtime__frame-hint"
|
||||
data-test="runtime-frame-hint"
|
||||
>Hide from prompt and Hold across run apply to every frame — switch to base to change.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -77,4 +101,13 @@ function onHideClick(): void {
|
||||
background: rgba(99, 102, 241, 0.10);
|
||||
}
|
||||
.toggle--on .pi { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.runtime__frame-hint {
|
||||
margin: 4px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { withFrameInstance, diffInstance, setFrameOverride, clearFrameOverride, toggleFrameLock, toggleFrameEnabled, dropRedundantFrameLockNulls, effectiveEnabled, frameEnableOverride } from "./frame-overrides";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
function mod(extra: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
return { id: "m1", type: "wildcard", enabled: true, meta: { name: "x" }, entries: [], instance: {}, ...extra };
|
||||
}
|
||||
|
||||
describe("frame-overrides", () => {
|
||||
it("withFrameInstance overlays the frame patch onto the base instance", () => {
|
||||
const m = mod({
|
||||
instance: { variable_binding: "x" },
|
||||
iteration_overrides: { "2": { pinned_option_id: "0", mode: "pinned" } },
|
||||
});
|
||||
const r = withFrameInstance(m, 2);
|
||||
expect(r.instance).toEqual({ variable_binding: "x", pinned_option_id: "0", mode: "pinned" });
|
||||
expect(withFrameInstance(m, null)).toBe(m);
|
||||
expect(withFrameInstance(m, 1)).toBe(m);
|
||||
});
|
||||
it("diffInstance captures only changed declared fields", () => {
|
||||
const base = { variable_binding: "x", pinned_option_id: null };
|
||||
const edited = { variable_binding: "x", pinned_option_id: "0", mode: "pinned" };
|
||||
expect(diffInstance(base, edited, ["pinned_option_id", "mode", "variable_binding"]))
|
||||
.toEqual({ pinned_option_id: "0", mode: "pinned" });
|
||||
});
|
||||
it("setFrameOverride writes merged and clearFrameOverride removes", () => {
|
||||
const m = mod();
|
||||
const a = setFrameOverride(m, 2, { pinned_option_id: "0" });
|
||||
expect(a.iteration_overrides).toEqual({ "2": { pinned_option_id: "0" } });
|
||||
const b = clearFrameOverride(a, 2);
|
||||
expect(b.iteration_overrides).toBeUndefined();
|
||||
});
|
||||
it("toggleFrameLock: locks an unlocked frame with the fallback", () => {
|
||||
const m = mod();
|
||||
const r = toggleFrameLock(m, 2, 999);
|
||||
expect(r.iteration_overrides?.["2"]).toEqual({ locked_seed: 999 });
|
||||
});
|
||||
it("toggleFrameLock: unlocking a frame-only lock drops the key", () => {
|
||||
const m = mod({ iteration_overrides: { "2": { locked_seed: 999 } } });
|
||||
const r = toggleFrameLock(m, 2, 0);
|
||||
expect(r.iteration_overrides).toBeUndefined();
|
||||
});
|
||||
it("toggleFrameLock: unlocking a frame writes null when base is locked", () => {
|
||||
const m = mod({ instance: { locked_seed: 5 } });
|
||||
const r = toggleFrameLock(m, 2, 0);
|
||||
expect(r.iteration_overrides?.["2"]).toEqual({ locked_seed: null });
|
||||
});
|
||||
|
||||
// ── per-frame enable override (frame_enabled) ──────────────────────────
|
||||
it("effectiveEnabled: frame override wins, else base", () => {
|
||||
expect(effectiveEnabled(mod({ enabled: true }), null)).toBe(true);
|
||||
expect(effectiveEnabled(mod({ enabled: true }), 2)).toBe(true); // no override → base
|
||||
const off = mod({ enabled: true, frame_enabled: { "2": false } });
|
||||
expect(effectiveEnabled(off, 2)).toBe(false);
|
||||
expect(effectiveEnabled(off, 1)).toBe(true);
|
||||
const on = mod({ enabled: false, frame_enabled: { "2": true } });
|
||||
expect(effectiveEnabled(on, 2)).toBe(true); // base off, frame on
|
||||
expect(effectiveEnabled(on, 1)).toBe(false); // base off elsewhere
|
||||
expect(effectiveEnabled(on, null)).toBe(false); // base view
|
||||
});
|
||||
it("effectiveEnabled: folds legacy disabled_frames", () => {
|
||||
const m = mod({ enabled: true, disabled_frames: [2] });
|
||||
expect(effectiveEnabled(m, 2)).toBe(false);
|
||||
expect(effectiveEnabled(m, 0)).toBe(true);
|
||||
});
|
||||
it("frameEnableOverride: reports on / off / null vs base", () => {
|
||||
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": false } }), 2)).toBe("off");
|
||||
expect(frameEnableOverride(mod({ enabled: false, frame_enabled: { "2": true } }), 2)).toBe("on");
|
||||
expect(frameEnableOverride(mod({ enabled: true }), 2)).toBeNull();
|
||||
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": true } }), 2)).toBeNull(); // equals base
|
||||
expect(frameEnableOverride(mod({ enabled: true, frame_enabled: { "2": false } }), null)).toBeNull();
|
||||
});
|
||||
it("toggleFrameEnabled: a base-OFF module turns ON for the frame (the bug fix)", () => {
|
||||
const r = toggleFrameEnabled(mod({ enabled: false }), 2);
|
||||
expect(r.frame_enabled).toEqual({ "2": true });
|
||||
expect(r.enabled).toBe(false); // base untouched
|
||||
});
|
||||
it("toggleFrameEnabled: a base-ON module turns OFF for the frame", () => {
|
||||
const r = toggleFrameEnabled(mod({ enabled: true }), 2);
|
||||
expect(r.frame_enabled).toEqual({ "2": false });
|
||||
});
|
||||
it("toggleFrameEnabled: toggling back to base drops the override + the field", () => {
|
||||
const r = toggleFrameEnabled(mod({ enabled: true, frame_enabled: { "2": false } }), 2);
|
||||
expect(r.frame_enabled).toBeUndefined();
|
||||
});
|
||||
it("toggleFrameEnabled: migrates legacy disabled_frames into frame_enabled", () => {
|
||||
const r = toggleFrameEnabled(mod({ enabled: true, disabled_frames: [1] }), 2);
|
||||
expect(r.disabled_frames).toBeUndefined();
|
||||
expect(r.frame_enabled).toEqual({ "1": false, "2": false });
|
||||
});
|
||||
it("toggleFrameEnabled: recovers the user's stuck state (base off, stray disabled_frames)", () => {
|
||||
// Repro of the QA bug: base disabled with a leftover blocklist entry;
|
||||
// enabling frame #2 should run ONLY #2 and clear the noise.
|
||||
const r = toggleFrameEnabled(mod({ enabled: false, disabled_frames: [1] }), 2);
|
||||
expect(r.disabled_frames).toBeUndefined();
|
||||
expect(r.frame_enabled).toEqual({ "2": true });
|
||||
});
|
||||
|
||||
// ── dropRedundantFrameLockNulls ────────────────────────────────────────
|
||||
it("dropRedundantFrameLockNulls: clears a frame locked_seed:null once base is unlocked", () => {
|
||||
// Repro of the QA bug: lock #4, lock base, unlock #4 (writes null override),
|
||||
// unlock base → the null override is now redundant and must be swept.
|
||||
const m = mod({ instance: {}, iteration_overrides: { "3": { locked_seed: null } } });
|
||||
expect(dropRedundantFrameLockNulls(m).iteration_overrides).toBeUndefined();
|
||||
});
|
||||
it("dropRedundantFrameLockNulls: KEEPS the null override while base is locked", () => {
|
||||
const m = mod({ instance: { locked_seed: 5 }, iteration_overrides: { "3": { locked_seed: null } } });
|
||||
expect(dropRedundantFrameLockNulls(m).iteration_overrides).toEqual({ "3": { locked_seed: null } });
|
||||
});
|
||||
it("dropRedundantFrameLockNulls: keeps a frame's real lock + other override fields", () => {
|
||||
const m = mod({
|
||||
instance: {},
|
||||
iteration_overrides: { "1": { locked_seed: 99 }, "2": { locked_seed: null, pinned_option_id: "0" } },
|
||||
});
|
||||
expect(dropRedundantFrameLockNulls(m).iteration_overrides)
|
||||
.toEqual({ "1": { locked_seed: 99 }, "2": { pinned_option_id: "0" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
type Instance = NonNullable<ModuleEntry["instance"]>;
|
||||
type IterationOverrides = NonNullable<ModuleEntry["iteration_overrides"]>;
|
||||
|
||||
/** Effective module at frame `k`: base instance overlaid with the frame's
|
||||
* override patch. Returns the same reference when there's nothing to overlay. */
|
||||
export function withFrameInstance(module: ModuleEntry, frame: number | null): ModuleEntry {
|
||||
if (frame == null) return module;
|
||||
const patch = module.iteration_overrides?.[String(frame)];
|
||||
if (!patch) return module;
|
||||
return { ...module, instance: { ...(module.instance ?? {}), ...patch } };
|
||||
}
|
||||
|
||||
/** Changed override fields between base and edited instances, scoped to `fields`.
|
||||
* Per-field JSON compare (values are small + JSON-able). Accepts loose
|
||||
* Record type so callers can pass plain object literals without casting. */
|
||||
export function diffInstance(
|
||||
base: Record<string, unknown> | Instance | undefined,
|
||||
edited: Record<string, unknown> | Instance | undefined,
|
||||
fields: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const b = (base ?? {}) as Record<string, unknown>;
|
||||
const e = (edited ?? {}) as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
if (JSON.stringify(b[f]) !== JSON.stringify(e[f])) out[f] = e[f];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge `patch` into the module's frame override (clone). Empty result drops
|
||||
* the frame key; empty map drops `iteration_overrides` entirely. */
|
||||
export function setFrameOverride(
|
||||
module: ModuleEntry, frame: number, patch: Record<string, unknown>,
|
||||
): ModuleEntry {
|
||||
const map: IterationOverrides = { ...(module.iteration_overrides ?? {}) };
|
||||
const key = String(frame);
|
||||
const merged = { ...(map[key] ?? {}), ...patch } as Partial<Instance>;
|
||||
if (Object.keys(merged).length === 0) {
|
||||
delete map[key];
|
||||
} else {
|
||||
map[key] = merged;
|
||||
}
|
||||
const out: ModuleEntry = { ...module, iteration_overrides: map };
|
||||
if (Object.keys(map).length === 0) delete out.iteration_overrides;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Remove a frame's override entirely (clone). */
|
||||
export function clearFrameOverride(module: ModuleEntry, frame: number): ModuleEntry {
|
||||
if (!module.iteration_overrides) return module;
|
||||
const map: IterationOverrides = { ...module.iteration_overrides };
|
||||
delete map[String(frame)];
|
||||
const out: ModuleEntry = { ...module, iteration_overrides: map };
|
||||
if (Object.keys(map).length === 0) delete out.iteration_overrides;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Toggle locked_seed for ONE frame (the row quick-lock when a frame is active).
|
||||
* Locks with `fallback` if currently unlocked at that frame; otherwise unlocks —
|
||||
* writing an explicit `null` when the BASE is locked (to override it on this frame)
|
||||
* or dropping the frame's locked_seed when the base is already unlocked. */
|
||||
export function toggleFrameLock(module: ModuleEntry, frame: number, fallback: number): ModuleEntry {
|
||||
const eff = withFrameInstance(module, frame).instance ?? {};
|
||||
const baseLocked = typeof module.instance?.locked_seed === "number";
|
||||
if (typeof eff.locked_seed === "number") {
|
||||
if (baseLocked) return setFrameOverride(module, frame, { locked_seed: null });
|
||||
const rest = { ...(module.iteration_overrides?.[String(frame)] ?? {}) } as Record<string, unknown>;
|
||||
delete rest.locked_seed;
|
||||
let next = clearFrameOverride(module, frame);
|
||||
if (Object.keys(rest).length) next = setFrameOverride(next, frame, rest);
|
||||
return next;
|
||||
}
|
||||
return setFrameOverride(module, frame, { locked_seed: fallback });
|
||||
}
|
||||
|
||||
/** Per-frame enabled state at frame `k`: the explicit `frame_enabled[k]`
|
||||
* override if present, else the legacy `disabled_frames` blocklist folded in
|
||||
* (a listed frame reads as `false`), else `undefined` — meaning "no override,
|
||||
* use base `enabled`". */
|
||||
export function frameEnabledAt(module: ModuleEntry, k: number): boolean | undefined {
|
||||
const fe = module.frame_enabled?.[String(k)];
|
||||
if (typeof fe === "boolean") return fe;
|
||||
if ((module.disabled_frames ?? []).includes(k)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Effective enabled state in the current view: `frame_enabled[k] ?? base
|
||||
* enabled` when a frame is active, base `enabled` otherwise. */
|
||||
export function effectiveEnabled(module: ModuleEntry, frame: number | null): boolean {
|
||||
const base = module.enabled !== false;
|
||||
if (frame == null) return base;
|
||||
return frameEnabledAt(module, frame) ?? base;
|
||||
}
|
||||
|
||||
/** Per-frame enable override vs base at `frame`: "on" (forced on while base
|
||||
* off), "off" (forced off while base on), or null (no frame active / no
|
||||
* override / equals base). Drives the on-#k / off-#k row badge. */
|
||||
export function frameEnableOverride(
|
||||
module: ModuleEntry, frame: number | null,
|
||||
): "on" | "off" | null {
|
||||
if (frame == null) return null;
|
||||
const fe = frameEnabledAt(module, frame);
|
||||
if (fe == null) return null;
|
||||
const base = module.enabled !== false;
|
||||
if (fe === base) return null;
|
||||
return fe ? "on" : "off";
|
||||
}
|
||||
|
||||
/** Toggle a module's per-frame enable state for ONE frame (the row checkbox when
|
||||
* a frame is active). Sets `frame_enabled[frame]` to the opposite of the current
|
||||
* effective state — symmetric, so a base-off module turns ON for the frame and a
|
||||
* base-on module turns OFF. Migrates any legacy `disabled_frames` into the map,
|
||||
* then drops entries that match base so only real overrides persist. Base
|
||||
* `enabled` is never touched. */
|
||||
export function toggleFrameEnabled(module: ModuleEntry, frame: number): ModuleEntry {
|
||||
const base = module.enabled !== false;
|
||||
const next = !(frameEnabledAt(module, frame) ?? base);
|
||||
const map: Record<string, boolean> = { ...(module.frame_enabled ?? {}) };
|
||||
for (const df of module.disabled_frames ?? []) map[String(df)] = false;
|
||||
map[String(frame)] = next;
|
||||
for (const key of Object.keys(map)) {
|
||||
if (map[key] === base) delete map[key];
|
||||
}
|
||||
const out: ModuleEntry = { ...module };
|
||||
delete out.disabled_frames;
|
||||
if (Object.keys(map).length === 0) delete out.frame_enabled;
|
||||
else out.frame_enabled = map;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop redundant frame `locked_seed: null` overrides. A frame's
|
||||
* `locked_seed: null` exists ONLY to force that frame UNLOCKED over a BASE
|
||||
* lock; once the base is itself unlocked it's meaningless, so it's swept (and
|
||||
* the frame's override entry removed when nothing else remains). No-op while
|
||||
* the base stays locked. Without this, unlocking base after unlocking a frame
|
||||
* leaves a stale entry so the row keeps showing "override #k". */
|
||||
export function dropRedundantFrameLockNulls(module: ModuleEntry): ModuleEntry {
|
||||
const ov = module.iteration_overrides;
|
||||
if (!ov || typeof module.instance?.locked_seed === "number") return module;
|
||||
const next: IterationOverrides = {};
|
||||
let changed = false;
|
||||
for (const [k, patch] of Object.entries(ov)) {
|
||||
if (patch && (patch as Record<string, unknown>).locked_seed === null) {
|
||||
const rest = { ...patch } as Record<string, unknown>;
|
||||
delete rest.locked_seed;
|
||||
changed = true;
|
||||
if (Object.keys(rest).length) next[k] = rest as Partial<Instance>;
|
||||
} else {
|
||||
next[k] = patch;
|
||||
}
|
||||
}
|
||||
if (!changed) return module;
|
||||
const out: ModuleEntry = { ...module, iteration_overrides: next };
|
||||
if (Object.keys(next).length === 0) delete out.iteration_overrides;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { InjectionKey } from "vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
/**
|
||||
* Resolves the seed to pin when the user locks a module's seed — the seed the
|
||||
* module ACTUALLY rolled: frame #k's captured seed when an iteration frame is
|
||||
* active, else the module's last-run seed. Provided by ContextWidget (which
|
||||
* owns the frame cursor + the run-seed readers) and injected by
|
||||
* SeedLockControls, which lives deep inside the per-kind edit modal. Returns
|
||||
* null when unavailable (cold start, no run yet) → the control falls back to
|
||||
* its remembered seed, then a fresh random.
|
||||
*/
|
||||
export type LockSeedResolver = (module: ModuleEntry) => number | null;
|
||||
|
||||
export const LockSeedKey: InjectionKey<LockSeedResolver> = Symbol("wpLockSeed");
|
||||
@@ -53,6 +53,25 @@ export interface ModuleRowCtx {
|
||||
* single `↥×N` chip. Empty on rows no constraint covers (every
|
||||
* constraint / source / non-target row). */
|
||||
contributorsFor: (id: string) => PairingBadge[];
|
||||
/** Reactive cursor: the frame currently open in the override editor,
|
||||
* or `null` when no frame is active (base view). */
|
||||
currentFrame: Ref<number | null>;
|
||||
/** True when the module's seed is held constant across all loop
|
||||
* iterations (`instance.seed_scope === "hold"`). */
|
||||
isHeld: (m: ModuleEntry) => boolean;
|
||||
/** True when the module has an override entry for `currentFrame`. */
|
||||
isOverriddenOnFrame: (m: ModuleEntry) => boolean;
|
||||
/** The effective locked_seed for this module at `currentFrame` (or base when
|
||||
* no frame is active). `undefined` when the effective instance is unlocked. */
|
||||
effectiveLockedSeed: (m: ModuleEntry) => number | undefined;
|
||||
/** Effective enabled state for the current view: `frame_enabled[k] ?? base
|
||||
* enabled` when a frame is active, base `enabled` otherwise. Drives the row
|
||||
* checkbox + the disabled (hatched) row styling. */
|
||||
effectiveEnabled: (m: ModuleEntry) => boolean;
|
||||
/** Per-frame enable override vs base at `currentFrame`: "on" (forced on while
|
||||
* base off), "off" (forced off while base on), or null (no frame active / no
|
||||
* override / equals base). Drives the on-#k / off-#k row badge. */
|
||||
frameEnableOverride: (m: ModuleEntry) => "on" | "off" | null;
|
||||
}
|
||||
|
||||
export const ModuleRowCtxKey: InjectionKey<ModuleRowCtx> = Symbol("moduleRowCtx");
|
||||
|
||||
@@ -622,7 +622,13 @@ function onKeydown(ev: KeyboardEvent): void {
|
||||
background: color-mix(in srgb, var(--wp-text3) 18%, transparent);
|
||||
color: var(--wp-text2);
|
||||
flex-shrink: 0;
|
||||
/* Right-align the type pill even on rows WITHOUT a label (the label
|
||||
* carries the `margin-left:auto` for labelled rows). When a label IS
|
||||
* present it precedes the type, so reset the auto there and let the
|
||||
* flex gap space them — keeping label + type grouped at the right. */
|
||||
margin-left: auto;
|
||||
}
|
||||
.ibm__menu-label + .ibm__menu-type { margin-left: 0; }
|
||||
|
||||
/* Reset button styling shared via _modal-template-ctrls.css. */
|
||||
|
||||
|
||||
@@ -54,6 +54,41 @@ describe("InjectorWidget — lifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InjectorWidget — durable custom slot labels", () => {
|
||||
const labelledRow = (over: Record<string, unknown> = {}): string => JSON.stringify({
|
||||
version: 1,
|
||||
rows: [{ _uid: "uid_a", slot_name: "input_0", binding: "phrase", enabled: true, internal: false, ...over }],
|
||||
});
|
||||
|
||||
it("renders the durable row.slot_label as the pin tag, even with an empty slotLabels prop", () => {
|
||||
const w = mount(InjectorWidget, {
|
||||
props: { nodeId: 7, initialJson: labelledRow({ slot_label: "test_label_1" }), connectedSlots: ["input_0"], slotLabels: {} },
|
||||
});
|
||||
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("test_label_1");
|
||||
});
|
||||
|
||||
it("durable slot_label wins over a live slotLabels entry", () => {
|
||||
const w = mount(InjectorWidget, {
|
||||
props: { nodeId: 7, initialJson: labelledRow({ slot_label: "durable" }), connectedSlots: ["input_0"], slotLabels: { input_0: "live" } },
|
||||
});
|
||||
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("durable");
|
||||
});
|
||||
|
||||
it("falls back to the live slotLabels entry when no durable slot_label", () => {
|
||||
const w = mount(InjectorWidget, {
|
||||
props: { nodeId: 7, initialJson: labelledRow(), connectedSlots: ["input_0"], slotLabels: { input_0: "live_label" } },
|
||||
});
|
||||
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("live_label");
|
||||
});
|
||||
|
||||
it("falls back to slot_name when neither durable nor live label is set", () => {
|
||||
const w = mount(InjectorWidget, {
|
||||
props: { nodeId: 7, initialJson: labelledRow(), connectedSlots: ["input_0"], slotLabels: {} },
|
||||
});
|
||||
expect(w.find('[data-test="inj-row-slot"]').text()).toBe("input_0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InjectorWidget — template persistence (Phase 6)", () => {
|
||||
const ONE_ROW = JSON.stringify({
|
||||
version: 1,
|
||||
|
||||
@@ -812,7 +812,7 @@ function openRowContextMenu(ev: MouseEvent, idx: number): void {
|
||||
? `$${row.binding}`
|
||||
: isGeneral
|
||||
? "template row"
|
||||
: (props.slotLabels[row.slot_name] ?? row.slot_name);
|
||||
: (row.slot_label ?? props.slotLabels[row.slot_name] ?? row.slot_name);
|
||||
ctxMenu.value = {
|
||||
visible: true,
|
||||
x: Math.max(8, x),
|
||||
@@ -880,7 +880,7 @@ defineExpose({ addRow, addGeneralRow, removeRow });
|
||||
:reorderable="row.kind === 'general' ? generalRowCount > 1 : socketRowCount > 1"
|
||||
:is-connected="row.kind === 'general' ? true : isConnected(row.slot_name)"
|
||||
:value-type="row.kind === 'general' ? undefined : slotTypes[row.slot_name]"
|
||||
:display-label="row.kind === 'general' ? undefined : slotLabels[row.slot_name]"
|
||||
:display-label="row.kind === 'general' ? undefined : (row.slot_label ?? slotLabels[row.slot_name])"
|
||||
:conflict-severity="conflictByUid[row._uid]?.severity"
|
||||
:conflict-label="conflictByUid[row._uid]?.label"
|
||||
:drop-indicator="dropIndicatorFor(row._uid)"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import SeedListWidget from "./SeedListWidget.vue";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
import { emptySeedListConfig } from "./types";
|
||||
|
||||
describe("SeedListWidget", () => {
|
||||
@@ -142,3 +143,61 @@ describe("SeedListWidget", () => {
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function ws(extra = {}) {
|
||||
return mount(SeedListWidget, {
|
||||
props: { modelValue: { ...emptySeedListConfig(), ...extra }, baseSeed: 7, count: 4 },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
describe("SeedListWidget seeds button", () => {
|
||||
it("renders the Per-iteration seeds button", () => {
|
||||
expect(ws().find('[data-test="seedlist-seeds-btn"]').exists()).toBe(true);
|
||||
});
|
||||
it("badge shows the locked count when >0", () => {
|
||||
expect(ws({ seed_locks: { "0": 9, "2": 8, "3": 1 } }).find('[data-test="seedlist-seeds-badge"]').text()).toMatch(/3 locked/i);
|
||||
});
|
||||
it("hides the badge when nothing is locked", () => {
|
||||
expect(ws().find('[data-test="seedlist-seeds-badge"]').exists()).toBe(false);
|
||||
});
|
||||
it("clicking opens the modal", async () => {
|
||||
const wr = ws();
|
||||
await wr.find('[data-test="seedlist-seeds-btn"]').trigger("click");
|
||||
expect(wr.findComponent(SeedListModal).exists()).toBe(true);
|
||||
});
|
||||
it("previewStrategy is passed to modal when provided", async () => {
|
||||
const wr = mount(SeedListWidget, {
|
||||
props: { modelValue: emptySeedListConfig(), baseSeed: 7, count: 4, previewStrategy: "hash_index" },
|
||||
attachTo: document.body,
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
await wr.find('[data-test="seedlist-seeds-btn"]').trigger("click");
|
||||
expect(wr.findComponent(SeedListModal).props("strategy")).toBe("hash_index");
|
||||
});
|
||||
it("passes an override hint to the modal only when override_seed is on", async () => {
|
||||
const on = ws({ override_seed: true });
|
||||
await on.find('[data-test="seedlist-seeds-btn"]').trigger("click");
|
||||
expect(on.findComponent(SeedListModal).props("overrideHint")).toBeTruthy();
|
||||
|
||||
const off = ws(); // override_seed false by default
|
||||
await off.find('[data-test="seedlist-seeds-btn"]').trigger("click");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* host glue (`src/widgets/seed_list.ts`) serializes to JSON and
|
||||
* pushes via `host.setValue` so ComfyUI's widget value matches.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import type { SeedListConfig, SeedListStrategy } from "./types";
|
||||
import SeedListModal from "../shared/SeedListModal.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,8 +22,28 @@ const props = withDefaults(
|
||||
/** Litegraph mode: 0 = ALWAYS (live), 2 = NEVER (muted), 4 = BYPASS.
|
||||
* Drives the dim overlay so canvas-side mute/bypass reads visually. */
|
||||
nodeMode?: number;
|
||||
/** Effective base seed for the modal preview (already resolved by the host
|
||||
* glue — either local base_seed or the wired loop's seed, depending on
|
||||
* override_seed + whether a loop is connected). */
|
||||
baseSeed?: number;
|
||||
/** Effective iteration count for the modal preview (resolved by host glue). */
|
||||
count?: number;
|
||||
/** Effective strategy for the modal preview. When supplied (loop wired +
|
||||
* override_strategy on), the modal uses this instead of modelValue.strategy.
|
||||
* The strategy CHIPS still reflect modelValue.strategy (the local config).
|
||||
* Undefined = use modelValue.strategy (no loop / override off). */
|
||||
previewStrategy?: SeedListStrategy;
|
||||
/** The seed each frame used on the PREVIOUS run, captured from the node's
|
||||
* 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 },
|
||||
{ nodeMode: 0, baseSeed: 0, count: 1, previewStrategy: undefined, previousSeeds: null, bypassedFrames: () => [] },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ "update:modelValue": [next: SeedListConfig] }>();
|
||||
@@ -55,6 +76,14 @@ const STRATEGY_LOCKED_TOOLTIP =
|
||||
const isMuted = computed<boolean>(() => props.nodeMode === 2);
|
||||
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 });
|
||||
}
|
||||
|
||||
/** True while strategy comes from the upstream loop_config wire.
|
||||
* Mirrors what `setStockWidgetDisabled("count", ...)` does for the count
|
||||
* stock widget in the host glue — chips can't live as a stock widget so
|
||||
@@ -125,6 +154,15 @@ function toggleOverrideStrategy(): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="wp-seedlist__seedbtn" data-test="seedlist-seeds-btn" @click="seedsOpen = true">
|
||||
<span class="wp-seedlist__seedbtn-ico"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
|
||||
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>
|
||||
|
||||
<div class="wp-seedlist__row" :title="OVERRIDE_SEED_TOOLTIP">
|
||||
<span class="wp-seedlist__row-label">Override base seed from loop</span>
|
||||
<button
|
||||
@@ -172,6 +210,12 @@ function toggleOverrideStrategy(): void {
|
||||
<span class="wp-seedlist__switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -263,4 +307,11 @@ function toggleOverrideStrategy(): void {
|
||||
/* Mute / bypass dim — same convention as WP_VarTo* and WP_ContextLoop. */
|
||||
.wp-seedlist--muted { opacity: 0.45; pointer-events: none; }
|
||||
.wp-seedlist--bypassed { opacity: 0.65; }
|
||||
|
||||
.wp-seedlist__seedbtn { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 9px; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); font: 600 10.5px var(--wp-font-sans); cursor: pointer; }
|
||||
.wp-seedlist__seedbtn:hover { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.wp-seedlist__seedbtn-ico { color: var(--wp-accent); display: flex; }
|
||||
.wp-seedlist__seedbtn-fill { flex: 1; }
|
||||
.wp-seedlist__seedbtn-badge { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 6px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.14); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
.wp-seedlist__seedbtn-chev { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveSeedListPreview } from "./preview";
|
||||
|
||||
const local = { base: 5, count: 3, strategy: "sequential" as const };
|
||||
const loop = { base: 100, count: 8, strategy: "hash_index" as const };
|
||||
const NONE = { override_seed: false, override_count: false, override_strategy: false };
|
||||
|
||||
describe("resolveSeedListPreview", () => {
|
||||
it("no loop wired → all local (even if toggles on)", () => {
|
||||
expect(resolveSeedListPreview(local, { override_seed: true, override_count: true, override_strategy: true }, null))
|
||||
.toEqual({ baseSeed: 5, count: 3, strategy: "sequential" });
|
||||
});
|
||||
it("no overrides → all local", () => {
|
||||
expect(resolveSeedListPreview(local, NONE, loop)).toEqual({ baseSeed: 5, count: 3, strategy: "sequential" });
|
||||
});
|
||||
it("override_count only → loop count, local base+strategy", () => {
|
||||
expect(resolveSeedListPreview(local, { ...NONE, override_count: true }, loop))
|
||||
.toEqual({ baseSeed: 5, count: 8, strategy: "sequential" });
|
||||
});
|
||||
it("all overrides → all loop", () => {
|
||||
expect(resolveSeedListPreview(local, { override_seed: true, override_count: true, override_strategy: true }, loop))
|
||||
.toEqual({ baseSeed: 100, count: 8, strategy: "hash_index" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SeedListStrategy } from "./types";
|
||||
|
||||
export interface SeriesParams { base: number; count: number; strategy: SeedListStrategy }
|
||||
export interface OverrideFlags { override_seed: boolean; override_count: boolean; override_strategy: boolean }
|
||||
|
||||
/** Mirror of wp_nodes/seed_list.py::_resolve_config for the modal preview:
|
||||
* an override flag pulls from the wired loop ONLY when a loop is present;
|
||||
* otherwise the local widget value wins. */
|
||||
export function resolveSeedListPreview(
|
||||
local: SeriesParams,
|
||||
flags: OverrideFlags,
|
||||
loop: SeriesParams | null,
|
||||
): { baseSeed: number; count: number; strategy: SeedListStrategy } {
|
||||
return {
|
||||
baseSeed: flags.override_seed && loop ? loop.base : local.base,
|
||||
count: flags.override_count && loop ? loop.count : local.count,
|
||||
strategy: flags.override_strategy && loop ? loop.strategy : local.strategy,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ describe("parseSeedListConfig", () => {
|
||||
override_seed: true,
|
||||
override_count: true,
|
||||
override_strategy: true,
|
||||
seed_locks: {},
|
||||
};
|
||||
expect(parseSeedListConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -38,6 +39,7 @@ describe("parseSeedListConfig", () => {
|
||||
override_seed: false,
|
||||
override_count: true,
|
||||
override_strategy: false,
|
||||
seed_locks: {},
|
||||
};
|
||||
expect(parseSeedListConfig(JSON.stringify(cfg))).toEqual(cfg);
|
||||
});
|
||||
@@ -64,6 +66,7 @@ describe("parseSeedListConfig", () => {
|
||||
override_seed: false,
|
||||
override_count: false,
|
||||
override_strategy: false,
|
||||
seed_locks: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,8 +92,25 @@ describe("parseSeedListConfig", () => {
|
||||
override_seed: false,
|
||||
override_count: true,
|
||||
override_strategy: false,
|
||||
seed_locks: {},
|
||||
};
|
||||
const round = parseSeedListConfig(serializeSeedListConfig(cfg));
|
||||
expect(round).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeedListConfig seed_locks", () => {
|
||||
it("defaults seed_locks to {}", () => {
|
||||
expect(emptySeedListConfig().seed_locks).toEqual({});
|
||||
});
|
||||
it("parses seed_locks", () => {
|
||||
expect(parseSeedListConfig(JSON.stringify({ seed_locks: { "1": 999 } })).seed_locks).toEqual({ "1": 999 });
|
||||
});
|
||||
it("drops non-numeric lock values", () => {
|
||||
expect(parseSeedListConfig(JSON.stringify({ seed_locks: { "1": "x", "2": 5 } })).seed_locks).toEqual({ "2": 5 });
|
||||
});
|
||||
it("round-trips through serialize", () => {
|
||||
const cfg = { ...emptySeedListConfig(), seed_locks: { "0": 7 } };
|
||||
expect(parseSeedListConfig(serializeSeedListConfig(cfg)).seed_locks).toEqual({ "0": 7 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface SeedListConfig {
|
||||
override_count: boolean;
|
||||
/** Take `strategy` from the wired loop_config. */
|
||||
override_strategy: boolean;
|
||||
/** 0-based iteration index (stringified) -> pinned seed. Unlocked
|
||||
* indices re-derive from base+strategy. Empty by default. */
|
||||
seed_locks: Record<string, number>;
|
||||
}
|
||||
|
||||
const STRATEGIES = new Set<SeedListStrategy>(["hash_index", "sequential", "prime_stride"]);
|
||||
@@ -28,6 +31,7 @@ export function emptySeedListConfig(): SeedListConfig {
|
||||
override_seed: false,
|
||||
override_count: false,
|
||||
override_strategy: false,
|
||||
seed_locks: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +73,13 @@ export function parseSeedListConfig(raw: string | null | undefined): SeedListCon
|
||||
if (!hasNewCount) out.override_count = legacy;
|
||||
if (!hasNewStrategy) out.override_strategy = legacy;
|
||||
}
|
||||
if (obj.seed_locks && typeof obj.seed_locks === "object" && !Array.isArray(obj.seed_locks)) {
|
||||
const locks: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(obj.seed_locks as Record<string, unknown>)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) locks[k] = v;
|
||||
}
|
||||
out.seed_locks = locks;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<script setup lang="ts">
|
||||
/** Per-iteration seed modal — shared by the Context Loop + Seed List
|
||||
* node widgets. Computes the derived list locally (seed-derive mirror)
|
||||
* and overlays seed_locks; edits re-emit the full merged lock map. */
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { deriveLoopSeeds, parseSeedLocks, type SeedStrategy } from "./seed-derive";
|
||||
import SeedLockRow from "./SeedLockRow.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
nodeName: string;
|
||||
baseSeed: number;
|
||||
count: number;
|
||||
strategy: SeedStrategy;
|
||||
seedLocks: Record<string, number>;
|
||||
/** Yellow override notice text; empty/undefined hides it. Context Loop
|
||||
* shows it when "Override Context seed" is OFF (a nudge); Seed List shows
|
||||
* it when "Override base seed from loop" is ON (base comes from the loop). */
|
||||
overrideHint?: string;
|
||||
/** The seed each frame used on the PREVIOUS run (captured loop series),
|
||||
* indexed by frame. Drives the per-row "lock previous" button; absent or
|
||||
* 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 derived = computed(() => deriveLoopSeeds(props.baseSeed, Math.max(1, props.count), props.strategy));
|
||||
const lockedCount = computed(() => Object.keys(props.seedLocks).length);
|
||||
const copied = ref(false);
|
||||
const pasted = ref(false);
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pastedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
/** Locked indices beyond the current count — shown dimmed below the active
|
||||
* rows so a forgotten out-of-range lock is visible (it re-applies if count
|
||||
* grows back). */
|
||||
const inactiveLocks = computed(() => {
|
||||
const active = derived.value.length;
|
||||
return Object.keys(props.seedLocks)
|
||||
.map(Number)
|
||||
.filter((i) => Number.isInteger(i) && i >= active)
|
||||
.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)];
|
||||
else next[String(p.index)] = p.seed;
|
||||
emit("update:seedLocks", next);
|
||||
}
|
||||
function lockAll(): void {
|
||||
const next: Record<string, number> = {};
|
||||
derived.value.forEach((s, i) => { next[String(i)] = s; });
|
||||
emit("update:seedLocks", next);
|
||||
}
|
||||
function unlockAll(): void { emit("update:seedLocks", {}); }
|
||||
/** Build the `#N: seed` clipboard text. lockedOnly → just the locked rows
|
||||
* (round-trips through Paste to re-lock exactly those); else every iteration
|
||||
* with locks overlaid. */
|
||||
function copyText(lockedOnly: boolean): string {
|
||||
if (lockedOnly) {
|
||||
return Object.keys(props.seedLocks)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map((i) => `#${i + 1}: ${props.seedLocks[String(i)]}`)
|
||||
.join("\n");
|
||||
}
|
||||
return derived.value.map((s, i) => `#${i + 1}: ${props.seedLocks[String(i)] ?? s}`).join("\n");
|
||||
}
|
||||
function copyAll(ev: MouseEvent): void {
|
||||
void navigator.clipboard?.writeText(copyText(ev.altKey)); // Alt-click → locked seeds only
|
||||
copied.value = true;
|
||||
if (copiedTimer) clearTimeout(copiedTimer);
|
||||
copiedTimer = setTimeout(() => { copied.value = false; }, 1500);
|
||||
}
|
||||
/** Read the clipboard, parse the `#N: seed` format Copy produces, and REPLACE
|
||||
* the whole lock map with it. No-op on empty/garbage so a stray paste can't
|
||||
* wipe existing locks. Pasted indices beyond count land as inactive rows. */
|
||||
async function pasteAll(): Promise<void> {
|
||||
let text = "";
|
||||
try { text = (await navigator.clipboard?.readText()) ?? ""; } catch { text = ""; }
|
||||
const next = parseSeedLocks(text);
|
||||
if (Object.keys(next).length === 0) return;
|
||||
emit("update:seedLocks", next);
|
||||
pasted.value = true;
|
||||
if (pastedTimer) clearTimeout(pastedTimer);
|
||||
pastedTimer = setTimeout(() => { pasted.value = false; }, 1500);
|
||||
}
|
||||
function onKeydown(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") { ev.preventDefault(); emit("close"); }
|
||||
}
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
if (copiedTimer) clearTimeout(copiedTimer);
|
||||
if (pastedTimer) clearTimeout(pastedTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="sm-overlay" @click="emit('close')">
|
||||
<div class="sm" role="dialog" aria-modal="true" data-test="seed-modal" @click.stop>
|
||||
<div class="sm__head">
|
||||
<span class="sm__head-icon"><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="20" cy="18" r="1.4" fill="currentColor" stroke="none" /></svg></span>
|
||||
<div class="sm__title-block">
|
||||
<div class="sm__title-row"><span class="sm__name">{{ nodeName }}</span><span class="sm__chip">Iteration Seeds</span></div>
|
||||
<div class="sm__sub">base {{ baseSeed }} · {{ strategy }} · {{ Math.max(1, count) }} iterations</div>
|
||||
</div>
|
||||
<button class="sm__close" data-test="seed-modal-close" aria-label="Close" @click="emit('close')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M5 5l14 14M19 5L5 19" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sm__bar">
|
||||
<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>
|
||||
<button class="ghost" :class="{ 'ghost--copied': copied }" data-test="mx-seed-copy"
|
||||
title="Copy all seeds · Alt-click to copy only the locked ones" @click="copyAll">
|
||||
<svg v-if="copied" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
|
||||
{{ copied ? "Copied" : "Copy" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="overrideHint" class="sm__hint" data-test="mx-seed-hint">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9" /><path d="M12 11v5" stroke-linecap="round" /><circle cx="12" cy="7.5" r="1" fill="currentColor" stroke="none" /></svg>
|
||||
<span>{{ overrideHint }}</span>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
: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 }}
|
||||
</div>
|
||||
<SeedLockRow v-for="i in inactiveLocks" :key="`inact-${i}`" :index="i"
|
||||
:derived="seedLocks[String(i)]" :locked="true" :seed="seedLocks[String(i)]"
|
||||
:previous="previousSeeds?.[i] ?? null" :inactive="true" @update="onRow" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sm__foot">
|
||||
<span class="sm__foot-hint"><kbd>Esc</kbd> to close · locks save with the node</span>
|
||||
<span class="sm__foot-spacer" />
|
||||
<button class="ghost" :class="{ 'ghost--copied': pasted }" data-test="mx-seed-paste"
|
||||
title="Replace all locks from copied text" @click="pasteAll">
|
||||
<svg v-if="pasted" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7" /></svg>
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2.5" width="8" height="4" rx="1" /><path d="M8 4.5H6a2 2 0 0 0-2 2V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6.5a2 2 0 0 0-2-2h-2" /></svg>
|
||||
{{ pasted ? "Pasted" : "Paste" }}
|
||||
</button>
|
||||
<button class="btn" data-test="seed-modal-done" @click="emit('close')">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "./theme.css";
|
||||
.sm-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; padding: 20px; background: rgba(0,0,0,.62); font-family: var(--wp-font-sans, sans-serif); }
|
||||
.sm { width: 486px; max-width: 100%; max-height: 86vh; display: flex; flex-direction: column; background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: 6px; overflow: hidden; color: var(--wp-text); font-size: 12px; box-shadow: 0 18px 50px rgba(0,0,0,.55); }
|
||||
.sm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); background: linear-gradient(180deg, color-mix(in srgb, var(--wp-accent) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); }
|
||||
.sm__head-icon { color: var(--wp-accent); width: 24px; display: flex; justify-content: center; }
|
||||
.sm__title-block { flex: 1; min-width: 0; }
|
||||
.sm__title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.sm__name { font: 600 13px var(--wp-font-sans); color: var(--wp-text); }
|
||||
.sm__chip { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .06em; padding: 2px 6px; border-radius: 2px; background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent); }
|
||||
.sm__sub { font: 400 10px var(--wp-font-mono); color: var(--wp-text-dim, var(--wp-text3)); margin-top: 3px; }
|
||||
.sm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; display: flex; }
|
||||
.sm__close:hover { color: var(--wp-text); }
|
||||
.sm__bar { display: flex; align-items: center; gap: 8px; padding: 9px 14px; flex-shrink: 0; border-bottom: 1px solid var(--wp-border); }
|
||||
.sm__count { font: 600 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); }
|
||||
.sm__strat { font: 600 9px var(--wp-font-mono); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 3px; color: var(--wp-text-muted, var(--wp-text2)); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); }
|
||||
.sm__lockcount { font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px; border-radius: 3px; color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.12); border: 1px solid color-mix(in srgb, var(--wp-accent) 40%, transparent); }
|
||||
.sm__bar-spacer { flex: 1; }
|
||||
.ghost { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 3px; border: 1px solid var(--wp-border); background: transparent; color: var(--wp-text-muted, var(--wp-text2)); font: 11px var(--wp-font-sans); cursor: pointer; }
|
||||
.ghost:hover { border-color: var(--wp-border-strong, var(--wp-border2)); color: var(--wp-text); }
|
||||
.ghost--copied, .ghost--copied:hover { border-color: var(--wp-green, #6bc96f); color: var(--wp-green, #6bc96f); }
|
||||
.sm__hint { display: flex; align-items: center; gap: 7px; padding: 7px 14px; flex-shrink: 0; font: 11px var(--wp-font-sans); color: color-mix(in srgb, var(--wp-amber, #fbbf24) 75%, var(--wp-text-muted)); background: color-mix(in srgb, var(--wp-amber, #fbbf24) 9%, transparent); border-bottom: 1px solid var(--wp-border); }
|
||||
.sm__hint svg { flex-shrink: 0; color: var(--wp-amber, #fbbf24); }
|
||||
.sm__list { overflow-y: auto; padding: 6px; flex: 1; min-height: 0; }
|
||||
.sm__inactive-label { padding: 8px 8px 4px; margin-top: 4px; border-top: 1px dashed var(--wp-border); font: 600 9px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .08em; color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.sm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; flex-shrink: 0; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
|
||||
.sm__foot-hint { font: 10px var(--wp-font-sans); color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.sm__foot-hint kbd { font: 9px var(--wp-font-mono); background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); padding: 1px 4px; border-radius: 2px; }
|
||||
.sm__foot-spacer { flex: 1; }
|
||||
.btn { padding: 6px 14px; border: 1px solid var(--wp-accent); border-radius: 3px; background: var(--wp-accent); color: #fff; font: 11px var(--wp-font-sans); cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
/** One per-iteration row in SeedListModal — a per-row mirror of the
|
||||
* module SeedLockControls. Unlocked shows the derived seed; locking
|
||||
* captures it; locked exposes a seed input + ±spinners + roll. */
|
||||
const props = defineProps<{
|
||||
index: number; // 0-based
|
||||
derived: number; // computed seed for this index (preview when unlocked)
|
||||
locked: boolean;
|
||||
seed: number | null; // locked seed value when locked
|
||||
/** Locked index beyond the current count — dimmed but still editable;
|
||||
* re-activates if count grows back. */
|
||||
inactive?: boolean;
|
||||
/** The seed this frame ACTUALLY used on the previous run (captured from the
|
||||
* node's loop series). null/undefined when there's no prior run for this
|
||||
* 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 SEED_MAX = (2 ** 50) - 1; // MAX_SAFE_SEED (matches engine)
|
||||
function randomSeed(): number {
|
||||
const hi = Math.floor(Math.random() * 0x40000); // 18 bits
|
||||
const lo = Math.floor(Math.random() * 0x100000000); // 32 bits
|
||||
return (hi * 0x100000000 + lo) % (SEED_MAX + 1);
|
||||
}
|
||||
function set(seed: number | null) { emit("update", { index: props.index, seed }); }
|
||||
function onLock() { set(props.locked ? null : props.derived); }
|
||||
/** Fill + lock the seed this frame used on the previous run. No-op (button is
|
||||
* disabled) when there's no captured previous for this frame. */
|
||||
function lockPrevious() { if (props.previous != null) set(props.previous); }
|
||||
function onInput(ev: Event) {
|
||||
const n = Number((ev.target as HTMLInputElement).value.trim());
|
||||
if (Number.isFinite(n) && Number.isInteger(n)) set(n);
|
||||
}
|
||||
function bump(d: 1 | -1) {
|
||||
if (!props.locked || props.seed == null) return;
|
||||
set(Math.max(0, Math.min(SEED_MAX, props.seed + d)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<rect x="5" y="11" width="14" height="9" rx="1.5" /><path :d="locked ? 'M8 11V8a4 4 0 0 1 8 0v3' : 'M8 11V8a4 4 0 0 1 8 0'" />
|
||||
</svg>
|
||||
{{ locked ? "Locked" : "Lock" }}
|
||||
</button>
|
||||
<button type="button" class="toggle toggle--prev" data-test="seedrow-lockprev"
|
||||
:disabled="previous == null || undefined"
|
||||
:title="previous == null
|
||||
? 'No previous run captured for this frame'
|
||||
: `Lock the seed this frame used last run (${previous})`"
|
||||
@click="lockPrevious">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3v5h5" /><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" /><path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
<span class="srow__fill" />
|
||||
<span v-if="!locked" class="derived">
|
||||
<span class="derived__val" data-test="seedrow-derived">{{ derived }}</span>
|
||||
<span class="derived__tag">derived</span>
|
||||
</span>
|
||||
<span v-else class="seed-controls">
|
||||
<span class="seed-input-wrap">
|
||||
<input class="seed-input" data-test="seedrow-input" type="number" step="1"
|
||||
:value="seed ?? ''" aria-label="Locked seed" @input="onInput" />
|
||||
<span class="seed-spin">
|
||||
<button type="button" class="seed-spin-btn" tabindex="-1" aria-label="Increase" @click="bump(1)">
|
||||
<svg width="6" height="4" viewBox="0 0 8 5"><path d="M0 5 L4 0 L8 5 Z" fill="currentColor" /></svg></button>
|
||||
<button type="button" class="seed-spin-btn" tabindex="-1" aria-label="Decrease" @click="bump(-1)">
|
||||
<svg width="6" height="4" viewBox="0 0 8 5"><path d="M0 0 L4 5 L8 0 Z" fill="currentColor" /></svg></button>
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" class="seed-roll" data-test="seedrow-roll" title="Random seed"
|
||||
aria-label="Random seed" @click="set(randomSeed())">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 4v5h-5" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.srow { display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 5px; }
|
||||
.srow--locked { background: rgba(99,102,241,0.07); box-shadow: inset 2px 0 0 var(--wp-accent, #6366f1); }
|
||||
/* 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; }
|
||||
.toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; border: 1px solid var(--wp-border); border-radius: 3px; font: 11px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); cursor: pointer; background: var(--wp-bg-deep, var(--wp-bg)); flex-shrink: 0; }
|
||||
.toggle:hover { border-color: var(--wp-border-strong, var(--wp-border2)); }
|
||||
.toggle svg { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.toggle--on { border-color: var(--wp-accent); color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.10); }
|
||||
.toggle--on svg { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
/* "Lock previous run's seed" — secondary action; greys out when the frame has
|
||||
no captured previous (no prior run). */
|
||||
.toggle--prev { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.toggle:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.toggle:disabled:hover { border-color: var(--wp-border); color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.derived { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.derived__val { font: 11px var(--wp-font-mono, monospace); color: var(--wp-text4, #888); }
|
||||
.derived__tag { font: 600 8px var(--wp-font-sans); text-transform: uppercase; letter-spacing: .07em; color: var(--wp-text-dim, var(--wp-text3)); border: 1px solid var(--wp-border); border-radius: 3px; padding: 1px 5px; }
|
||||
.seed-controls { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.seed-input-wrap { display: inline-flex; align-items: stretch; background: var(--wp-bg-deep, var(--wp-bg)); border: 1px solid var(--wp-border); border-radius: 3px; height: 24px; width: 178px; overflow: hidden; }
|
||||
.seed-input-wrap:focus-within { border-color: var(--wp-accent); }
|
||||
.seed-input { flex: 1; background: transparent; border: 0; padding: 0 7px; font: 11px var(--wp-font-mono, monospace); color: var(--wp-text); text-align: right; min-width: 0; -moz-appearance: textfield; }
|
||||
.seed-input::-webkit-outer-spin-button, .seed-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.seed-input:focus { outline: none; }
|
||||
.seed-spin { display: flex; flex-direction: column; width: 14px; flex-shrink: 0; border-left: 1px solid var(--wp-border); background: rgba(99,102,241,.04); }
|
||||
.seed-spin-btn { flex: 1; display: flex; align-items: center; justify-content: center; background: transparent; border: 0; padding: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; line-height: 0; }
|
||||
.seed-spin-btn + .seed-spin-btn { border-top: 1px solid var(--wp-border); }
|
||||
.seed-spin-btn:hover { color: var(--wp-accent-text, var(--wp-text)); background: rgba(99,102,241,.12); }
|
||||
.seed-roll { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 2px 4px; display: flex; }
|
||||
.seed-roll:hover { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import SeedListModal from "../SeedListModal.vue";
|
||||
|
||||
// jsdom has no real clipboard; defineProperty so it survives a read-only getter.
|
||||
// Returns the array capturing every writeText payload.
|
||||
function stubClipboard(readValue = "") {
|
||||
const writes: string[] = [];
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
readText: () => Promise.resolve(readValue),
|
||||
writeText: (t: string) => { writes.push(t); return Promise.resolve(); },
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
function modal(props = {}) {
|
||||
return mount(SeedListModal, {
|
||||
props: { nodeName: "WP Context Loop", baseSeed: 42, count: 4, strategy: "sequential",
|
||||
seedLocks: { "1": 999 }, overrideHint: "Test hint.", ...props },
|
||||
attachTo: document.body,
|
||||
// The modal body renders inside `<Teleport to="body">`; stubbing teleport
|
||||
// renders the children in place so the wrapper's `find()` reaches them
|
||||
// (repo convention — see RemapRefPopup.test.ts, ConfirmDialog.test.ts).
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
// ES2020 lib — no Array.prototype.at; read the last emit by index.
|
||||
function lastEmit(w: ReturnType<typeof modal>) {
|
||||
const ev = w.emitted("update:seedLocks")!;
|
||||
return ev[ev.length - 1][0];
|
||||
}
|
||||
|
||||
describe("SeedListModal", () => {
|
||||
it("renders one row per iteration (count)", () => {
|
||||
expect(modal().findAll('[data-test="seedrow-idx"]')).toHaveLength(4);
|
||||
});
|
||||
it("shows the locked-count pill", () => {
|
||||
expect(modal().find('[data-test="mx-seed-lockcount"]').text()).toMatch(/1 locked/i);
|
||||
});
|
||||
it("shows the override hint with the provided text; hidden when empty", () => {
|
||||
const shown = modal({ overrideHint: "Heads up: override is on." });
|
||||
expect(shown.find('[data-test="mx-seed-hint"]').exists()).toBe(true);
|
||||
expect(shown.find('[data-test="mx-seed-hint"]').text()).toContain("Heads up: override is on.");
|
||||
expect(modal({ overrideHint: "" }).find('[data-test="mx-seed-hint"]').exists()).toBe(false);
|
||||
});
|
||||
it("a row update re-emits update:seedLocks with the merged map", async () => {
|
||||
const w = modal({ seedLocks: {} });
|
||||
await w.findAll('[data-test="seedrow-lock"]')[0].trigger("click"); // lock #1 (index 0), sequential derived = 42
|
||||
expect(lastEmit(w)).toEqual({ "0": 42 });
|
||||
});
|
||||
it("threads previousSeeds → each row's lock-previous button (click locks the previous)", async () => {
|
||||
const w = modal({ seedLocks: {}, previousSeeds: [111, 222, 333, 444] });
|
||||
const prevBtns = w.findAll('[data-test="seedrow-lockprev"]');
|
||||
expect(prevBtns).toHaveLength(4);
|
||||
await prevBtns[2].trigger("click"); // frame #3 → its previous seed 333
|
||||
expect(lastEmit(w)).toEqual({ "2": 333 });
|
||||
});
|
||||
it("greys the lock-previous button for frames with no captured previous", () => {
|
||||
const w = modal({ seedLocks: {}, previousSeeds: [111] }); // only frame 0 has a previous
|
||||
const prevBtns = w.findAll('[data-test="seedrow-lockprev"]');
|
||||
expect(prevBtns[0].attributes("disabled")).toBeUndefined();
|
||||
expect(prevBtns[1].attributes("disabled")).toBeDefined();
|
||||
expect(prevBtns[3].attributes("disabled")).toBeDefined();
|
||||
});
|
||||
it("Lock all locks every iteration to its derived seed", async () => {
|
||||
const w = modal({ seedLocks: {} });
|
||||
await w.find('[data-test="mx-seed-lockall"]').trigger("click");
|
||||
expect(lastEmit(w)).toEqual({ "0": 42, "1": 43, "2": 44, "3": 45 });
|
||||
});
|
||||
it("Unlock all emits an empty map", async () => {
|
||||
const w = modal({ seedLocks: { "1": 999 } });
|
||||
await w.find('[data-test="mx-seed-unlockall"]').trigger("click");
|
||||
expect(lastEmit(w)).toEqual({});
|
||||
});
|
||||
it("Copy flashes a 'Copied' indicator on click", async () => {
|
||||
const w = modal();
|
||||
const btn = () => w.find('[data-test="mx-seed-copy"]');
|
||||
expect(btn().classes()).not.toContain("ghost--copied");
|
||||
await btn().trigger("click");
|
||||
expect(btn().classes()).toContain("ghost--copied");
|
||||
expect(btn().text()).toContain("Copied");
|
||||
w.unmount(); // clears the 1.5s reset timer via onBeforeUnmount
|
||||
});
|
||||
it("renders locks beyond count as a dimmed inactive group", () => {
|
||||
const w = modal({ count: 3, seedLocks: { "1": 11, "5": 55 } });
|
||||
expect(w.find('[data-test="mx-seed-inactive"]').exists()).toBe(true);
|
||||
// 3 active rows (#1-#3) + the out-of-range lock shown as #6
|
||||
expect(w.findAll('[data-test="seedrow-idx"]').map((n) => n.text())).toEqual(["#1", "#2", "#3", "#6"]);
|
||||
});
|
||||
it("no inactive group when every lock is within count", () => {
|
||||
const w = modal({ count: 3, seedLocks: { "1": 11 } });
|
||||
expect(w.find('[data-test="mx-seed-inactive"]').exists()).toBe(false);
|
||||
expect(w.findAll('[data-test="seedrow-idx"]')).toHaveLength(3);
|
||||
});
|
||||
it("Paste replaces the whole lock map from the copied text", async () => {
|
||||
stubClipboard("#2: 77\n#3: 88"); // 1-based display → indices 1 and 2
|
||||
const w = modal({ seedLocks: { "0": 1 } }); // existing lock must be replaced, not merged
|
||||
await w.find('[data-test="mx-seed-paste"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(lastEmit(w)).toEqual({ "1": 77, "2": 88 });
|
||||
expect(w.find('[data-test="mx-seed-paste"]').text()).toContain("Pasted");
|
||||
});
|
||||
it("Paste with no parseable lines leaves locks untouched", async () => {
|
||||
stubClipboard("garbage\nnot a seed line");
|
||||
const w = modal({ seedLocks: { "0": 1 } });
|
||||
await w.find('[data-test="mx-seed-paste"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(w.emitted("update:seedLocks")).toBeUndefined();
|
||||
});
|
||||
it("plain-click Copy writes every seed with locks overlaid", async () => {
|
||||
const writes = stubClipboard();
|
||||
const w = modal({ count: 3, seedLocks: { "1": 999 } }); // sequential base 42 → 42,43,44
|
||||
await w.find('[data-test="mx-seed-copy"]').trigger("click");
|
||||
expect(writes[writes.length - 1]).toBe("#1: 42\n#2: 999\n#3: 44");
|
||||
});
|
||||
it("Alt+click Copy writes only the locked seeds", async () => {
|
||||
const writes = stubClipboard();
|
||||
const w = modal({ count: 4, seedLocks: { "1": 999 } });
|
||||
await w.find('[data-test="mx-seed-copy"]').trigger("click", { altKey: true });
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import SeedLockRow from "../SeedLockRow.vue";
|
||||
|
||||
function row(props = {}) {
|
||||
return mount(SeedLockRow, { props: { index: 0, derived: 12345, locked: false, seed: null, ...props } });
|
||||
}
|
||||
|
||||
describe("SeedLockRow", () => {
|
||||
it("unlocked: shows derived value + a Lock toggle, no input", () => {
|
||||
const w = row();
|
||||
expect(w.find('[data-test="seedrow-derived"]').text()).toContain("12345");
|
||||
expect(w.find('[data-test="seedrow-input"]').exists()).toBe(false);
|
||||
});
|
||||
it("locking emits update with the derived value captured", async () => {
|
||||
const w = row();
|
||||
await w.find('[data-test="seedrow-lock"]').trigger("click");
|
||||
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: 12345 });
|
||||
});
|
||||
it("unlocking emits update with null", async () => {
|
||||
const w = row({ locked: true, seed: 999 });
|
||||
await w.find('[data-test="seedrow-lock"]').trigger("click");
|
||||
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
|
||||
});
|
||||
it("editing the seed input emits update with the typed value", async () => {
|
||||
const w = row({ locked: true, seed: 999 });
|
||||
await w.find('[data-test="seedrow-input"]').setValue("4242");
|
||||
const updates = w.emitted("update")!;
|
||||
expect(updates[updates.length - 1][0]).toEqual({ index: 0, seed: 4242 });
|
||||
});
|
||||
it("displays #N (1-based) from the 0-based index", () => {
|
||||
expect(row({ index: 2 }).find('[data-test="seedrow-idx"]').text()).toBe("#3");
|
||||
});
|
||||
it("inactive dims the row but stays interactive (unlock still emits)", async () => {
|
||||
const w = row({ locked: true, seed: 999, inactive: true });
|
||||
expect(w.find(".srow").classes()).toContain("srow--inactive");
|
||||
await w.find('[data-test="seedrow-lock"]').trigger("click");
|
||||
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: null });
|
||||
});
|
||||
it("lock-previous: fills + locks the captured previous seed", async () => {
|
||||
const w = row({ previous: 777 });
|
||||
const btn = w.find('[data-test="seedrow-lockprev"]');
|
||||
expect(btn.attributes("disabled")).toBeUndefined();
|
||||
await btn.trigger("click");
|
||||
expect(w.emitted("update")![0][0]).toEqual({ index: 0, seed: 777 });
|
||||
});
|
||||
it("lock-previous: disabled when the frame has no previous seed", () => {
|
||||
expect(row({ previous: null }).find('[data-test="seedrow-lockprev"]').attributes("disabled")).toBeDefined();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import corpus from "../../../../tests/fixtures/seed-derive-corpus.json";
|
||||
import { deriveLoopSeeds, applySeedLocks, parseSeedLocks, effectiveChainSeed } from "../seed-derive";
|
||||
|
||||
describe("seed-derive (Py≡TS parity)", () => {
|
||||
it("matches the engine corpus for every (base, count, strategy)", () => {
|
||||
for (const c of corpus.derive) {
|
||||
expect(deriveLoopSeeds(c.base, c.count, c.strategy as never)).toEqual(c.derived);
|
||||
}
|
||||
});
|
||||
it("applySeedLocks matches the engine", () => {
|
||||
const a = corpus.applyLocks;
|
||||
const locks: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(a.locks)) locks[Number(k)] = v as number;
|
||||
expect(applySeedLocks(a.derived, locks)).toEqual(a.locked);
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveChainSeed (Py≡TS parity)", () => {
|
||||
it("uses an override verbatim — no loop_index XOR (engine no-double-shift rule)", () => {
|
||||
expect(effectiveChainSeed(42, 999, 0)).toBe(999);
|
||||
expect(effectiveChainSeed(42, 999, 3)).toBe(999);
|
||||
});
|
||||
it("XORs loop_index into a constant widget seed only when there is no override", () => {
|
||||
expect(effectiveChainSeed(42, null, 0)).toBe(42);
|
||||
expect(effectiveChainSeed(42, null, 1)).toBe(482072580190692);
|
||||
expect(effectiveChainSeed(42, null, 5)).toBe(343395897700430);
|
||||
expect(effectiveChainSeed(2 ** 60, null, 3)).toBe(385286345697257);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSeedLocks (Paste — inverse of the modal copy format)", () => {
|
||||
it("parses #N: seed lines into a 0-based lock map", () => {
|
||||
expect(parseSeedLocks("#1: 42\n#2: 43\n#3: 44")).toEqual({ "0": 42, "1": 43, "2": 44 });
|
||||
});
|
||||
it("ignores blank, malformed, and #0 lines; tolerates stray whitespace", () => {
|
||||
expect(parseSeedLocks("\n#2: 7\ngarbage\n #4 : 9 \n#0: 5\n#: x")).toEqual({ "1": 7, "3": 9 });
|
||||
});
|
||||
it("returns an empty map when nothing parses", () => {
|
||||
expect(parseSeedLocks("nope\n123\nfoo: bar")).toEqual({});
|
||||
});
|
||||
it("masks values to 50 bits — negatives normalize like the engine", () => {
|
||||
expect(parseSeedLocks("#1: -1")).toEqual({ "0": (2 ** 50) - 1 });
|
||||
});
|
||||
});
|
||||
@@ -343,7 +343,8 @@
|
||||
.wp-mod-badge--mod,
|
||||
.wp-mod-badge--drift,
|
||||
.wp-mod-badge--missing,
|
||||
.wp-mod-badge--clash {
|
||||
.wp-mod-badge--clash,
|
||||
.wp-mod-badge--on {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-mod-badge--mod {
|
||||
@@ -358,6 +359,13 @@
|
||||
background: color-mix(in oklab, var(--wp-danger) 18%, transparent);
|
||||
color: var(--wp-danger);
|
||||
}
|
||||
/* `--on` variant: per-frame ENABLE override — module is off on base but turned
|
||||
* ON for this frame. Green (--wp-success) to read as "running here", the
|
||||
* positive inverse of the `--missing` red used for the off-#k badge. */
|
||||
.wp-mod-badge--on {
|
||||
background: color-mix(in oklab, var(--wp-success) 18%, transparent);
|
||||
color: var(--wp-success);
|
||||
}
|
||||
/* `--clash` variant: id reused by a DIFFERENT kind (shared id-space).
|
||||
* Violet (--wp-accent) — distinct from MOD orange / DRIFT amber / MISSING
|
||||
* red so a cross-kind collision is unmistakable. */
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* TS mirror of engine/seed_derive.py — lets the seed modal preview the
|
||||
* per-iteration seed list without running the graph. Parity is guarded
|
||||
* by tests/fixtures/seed-derive-corpus.json. Values masked to 50 bits.
|
||||
*/
|
||||
export type SeedStrategy = "sequential" | "hash_index" | "prime_stride";
|
||||
|
||||
const MASK = (1n << 50n) - 1n;
|
||||
|
||||
export function deriveLoopSeeds(base: number, count: number, strategy: SeedStrategy): number[] {
|
||||
if (count <= 1) return [Number(BigInt(base) & MASK)];
|
||||
if (strategy === "sequential") {
|
||||
return Array.from({ length: count }, (_, i) => Number((BigInt(base) + BigInt(i)) & MASK));
|
||||
}
|
||||
if (strategy === "prime_stride") {
|
||||
return Array.from({ length: count }, (_, i) => Number((BigInt(base) + BigInt(i) * 1000003n) & MASK));
|
||||
}
|
||||
if (strategy === "hash_index") {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const digest = sha256Bytes(`${base}:${i}`);
|
||||
let v = 0n;
|
||||
for (let b = 0; b < 8; b++) v = (v << 8n) | BigInt(digest[b]);
|
||||
return Number(v & MASK);
|
||||
});
|
||||
}
|
||||
throw new Error(`unknown loop seed strategy: ${strategy}`);
|
||||
}
|
||||
|
||||
export function applySeedLocks(derived: number[], locks: Record<number, number>): number[] {
|
||||
return derived.map((s, i) =>
|
||||
Object.prototype.hasOwnProperty.call(locks, i) ? Number(BigInt(locks[i]) & MASK) : s,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TS mirror of engine/seed_derive.py `effective_chain_seed` — the seed a
|
||||
* WP_Context node rolls at iteration `loopIndex`. An override (the loop's
|
||||
* already-per-iteration derived seed) is used verbatim; only a constant
|
||||
* widget seed (no override) gets the loop_index XOR. Mirrors the engine's
|
||||
* no-double-shift rule so a per-frame seed lock pins the seed the module
|
||||
* actually rolls at that frame.
|
||||
*/
|
||||
export function effectiveChainSeed(
|
||||
widgetSeed: number, seedOverride: number | null, loopIndex: number,
|
||||
): number {
|
||||
const base = seedOverride != null ? BigInt(seedOverride) : BigInt(widgetSeed);
|
||||
if (loopIndex === 0 || seedOverride != null) return Number(base & MASK);
|
||||
const digest = sha256Bytes(`loop:${loopIndex}`);
|
||||
let shift = 0n;
|
||||
for (let b = 0; b < 8; b++) shift = (shift << 8n) | BigInt(digest[b]);
|
||||
return Number((base ^ shift) & MASK);
|
||||
}
|
||||
|
||||
const LOCK_LINE = /^\s*#(\d+)\s*:\s*(-?\d+)\s*$/;
|
||||
|
||||
/**
|
||||
* Parse the `#N: seed` lines produced by the seed modal's Copy (1-based
|
||||
* display index) back into a 0-based seed_locks map. Non-matching lines are
|
||||
* ignored; values are masked to 50 bits like the engine (negatives normalize
|
||||
* deterministically). UI-only — no Python mirror, so no parity corpus.
|
||||
*/
|
||||
export function parseSeedLocks(text: string): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const m = line.match(LOCK_LINE);
|
||||
if (!m) continue;
|
||||
const display = Number(m[1]);
|
||||
if (display < 1) continue; // #N is 1-based; #0 / #-1 are invalid
|
||||
out[String(display - 1)] = Number(BigInt(m[2]) & MASK);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sha256Bytes(msg: string): Uint8Array {
|
||||
const K = [
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
||||
let h0=0x6a09e667,h1=0xbb67ae85,h2=0x3c6ef372,h3=0xa54ff53a,h4=0x510e527f,h5=0x9b05688c,h6=0x1f83d9ab,h7=0x5be0cd19;
|
||||
const bytes = new TextEncoder().encode(msg);
|
||||
const bitLen = bytes.length * 8;
|
||||
const padded = new Uint8Array((Math.ceil((bytes.length + 1 + 8) / 64)) * 64);
|
||||
padded.set(bytes); padded[bytes.length] = 0x80;
|
||||
const dv = new DataView(padded.buffer);
|
||||
dv.setUint32(padded.length - 4, bitLen >>> 0);
|
||||
dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
|
||||
const w = new Uint32Array(64);
|
||||
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
|
||||
for (let off = 0; off < padded.length; off += 64) {
|
||||
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = rotr(w[i-15],7) ^ rotr(w[i-15],18) ^ (w[i-15] >>> 3);
|
||||
const s1 = rotr(w[i-2],17) ^ rotr(w[i-2],19) ^ (w[i-2] >>> 10);
|
||||
w[i] = (w[i-16] + s0 + w[i-7] + s1) | 0;
|
||||
}
|
||||
let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,hh=h7;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
|
||||
const S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + maj) | 0;
|
||||
hh=g; g=f; f=e; e=(d + t1)|0; d=c; c=b; b=a; a=(t1 + t2)|0;
|
||||
}
|
||||
h0=(h0+a)|0; h1=(h1+b)|0; h2=(h2+c)|0; h3=(h3+d)|0; h4=(h4+e)|0; h5=(h5+f)|0; h6=(h6+g)|0; h7=(h7+hh)|0;
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
const odv = new DataView(out.buffer);
|
||||
[h0,h1,h2,h3,h4,h5,h6,h7].forEach((hv, i) => odv.setUint32(i*4, hv >>> 0));
|
||||
return out;
|
||||
}
|
||||
@@ -300,6 +300,72 @@ describe("collapse-connections — unified label + resize side effects", () => {
|
||||
expect(slotLabelStashed.has(node.inputs[1])).toBe(false);
|
||||
});
|
||||
|
||||
it("expandedLabel heals a reloaded node: placeholder baked into .label, empty stash → name on expand", () => {
|
||||
// Repro of the reload bug: a node saved collapsed serialized the
|
||||
// placeholders ("inputs ×N" on the first matched pin, " " on the rest)
|
||||
// into the workflow JSON, so it reloads with them baked into .label and
|
||||
// an EMPTY in-memory stash. The expandedLabel resolver reconstructs the
|
||||
// name-fallback from data instead of restoring the dead/placeholder
|
||||
// stash — no pre-attach repair needed.
|
||||
const node = makeNode({
|
||||
inputs: [
|
||||
{ name: "context_in", label: "context" },
|
||||
{ name: "input_0", label: "inputs ×3" }, // ← stale placeholder
|
||||
{ name: "input_1", label: " " },
|
||||
{ name: "input_2", label: " " },
|
||||
],
|
||||
inputPositions: [[0, 0], [0, 20], [0, 40], [0, 60]],
|
||||
size: [240, 80],
|
||||
});
|
||||
node.properties.collapse_connections = true;
|
||||
attachCollapsableConnections(node, {
|
||||
matchInput,
|
||||
collapsedInputLabel: () => "inputs ×3",
|
||||
expandedLabel: () => undefined,
|
||||
});
|
||||
expect(node.inputs[1].label).toBe("inputs ×3"); // unified on first matched
|
||||
expect(node.inputs[2].label).toBe(" "); // rest blanked
|
||||
setCollapsed(node, false);
|
||||
expect(node.inputs[1].label).toBeUndefined(); // healed: name-fallback, NOT placeholder
|
||||
expect(node.inputs[2].label).toBeUndefined();
|
||||
expect(node.inputs[3].label).toBeUndefined();
|
||||
expect(node.inputs[0].label).toBe("context"); // non-matched fixed input untouched
|
||||
});
|
||||
|
||||
it("expandedLabel mode never stashes, so collapse→expand reconstructs cleanly", () => {
|
||||
const node = makeNode({
|
||||
inputs: [{ name: "input_0" }, { name: "input_1" }, { name: "input_2" }],
|
||||
inputPositions: [[0, 0], [0, 20], [0, 40]],
|
||||
});
|
||||
attachCollapsableConnections(node, {
|
||||
matchInput,
|
||||
collapsedInputLabel: () => "inputs ×3",
|
||||
expandedLabel: () => undefined,
|
||||
});
|
||||
setCollapsed(node, true);
|
||||
expect(node.inputs[0].label).toBe("inputs ×3");
|
||||
expect(slotLabelStashed.has(node.inputs[0])).toBe(false); // reconstruction owns the label
|
||||
setCollapsed(node, false);
|
||||
expect(node.inputs[0].label).toBeUndefined();
|
||||
expect(node.inputs[1].label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expandedLabel returning a string sets that label on expand", () => {
|
||||
const node = makeNode({
|
||||
inputs: [{ name: "input_0", label: " " }, { name: "input_1", label: " " }],
|
||||
inputPositions: [[0, 0], [0, 20]],
|
||||
});
|
||||
node.properties.collapse_connections = true;
|
||||
attachCollapsableConnections(node, {
|
||||
matchInput,
|
||||
collapsedInputLabel: () => "inputs",
|
||||
expandedLabel: (s) => s.name,
|
||||
});
|
||||
setCollapsed(node, false);
|
||||
expect(node.inputs[0].label).toBe("input_0");
|
||||
expect(node.inputs[1].label).toBe("input_1");
|
||||
});
|
||||
|
||||
it("collapsedInputLabel undefined keeps the first slot's original label", () => {
|
||||
const node = makeInjectorLike();
|
||||
attachCollapsableConnections(node, { matchInput });
|
||||
|
||||
@@ -64,6 +64,20 @@ export interface CollapseConfig {
|
||||
collapsedInputLabel?: string | ((node: CollapseTargetNode) => string);
|
||||
/** Mirror of collapsedInputLabel for outputs. */
|
||||
collapsedOutputLabel?: string | ((node: CollapseTargetNode) => string);
|
||||
/** Resolver for a matched slot's EXPANDED label, reconstructed from
|
||||
* durable data rather than the in-memory stash. When provided, the
|
||||
* module skips the stash entirely: collapse shows the placeholder,
|
||||
* and expand calls this to recompute each matched slot's label
|
||||
* (return `undefined` to clear `.label` and fall back to the slot
|
||||
* name). This is robust across a reload — the stash is an in-memory
|
||||
* WeakMap that doesn't survive serialization, so a reloaded collapsed
|
||||
* node would otherwise restore the serialized placeholder ("inputs
|
||||
* ×N") instead of the real label. Use for nodes whose expanded label
|
||||
* is derivable from data they already persist (e.g. the injector's
|
||||
* `input_N` pins, whose label is just the slot name). Omit to keep
|
||||
* the default stash-based restore (preserves arbitrary custom labels
|
||||
* within a session). */
|
||||
expandedLabel?: (slot: Slot, index: number) => string | undefined;
|
||||
}
|
||||
|
||||
/** Internal — minimal node surface used by the patch. Kept generic
|
||||
@@ -191,19 +205,43 @@ export function applyCollapsedLabels(node: CollapseTargetNode): void {
|
||||
// once has its label stashed (even if as `undefined`) —
|
||||
// re-stashing would overwrite it with our placeholder (" ")
|
||||
// and corrupt the next restore.
|
||||
if (!slotLabelStashed.has(slot as object)) {
|
||||
//
|
||||
// SKIP the stash entirely when an expandedLabel resolver is
|
||||
// configured — that mode reconstructs the expanded label from
|
||||
// durable data, and stashing a reloaded node's live .label
|
||||
// (which IS the serialized placeholder) would corrupt the very
|
||||
// restore the resolver exists to prevent.
|
||||
if (!cfg.expandedLabel && !slotLabelStashed.has(slot as object)) {
|
||||
slotOrigLabel.set(slot as object, slot.label);
|
||||
slotLabelStashed.add(slot as object);
|
||||
}
|
||||
const stashed = slotOrigLabel.get(slot as object);
|
||||
if (i === first) {
|
||||
// Unified label if configured; otherwise restore the
|
||||
// original (handles cases where the caller doesn't supply
|
||||
// a label but still wants other slots blanked).
|
||||
slot.label = label !== undefined ? label : stashed;
|
||||
// Unified label if configured; otherwise fall back to the
|
||||
// stashed original (stash mode only — handles callers that
|
||||
// blank siblings without supplying a unified label).
|
||||
const fallback = cfg.expandedLabel ? undefined : slotOrigLabel.get(slot as object);
|
||||
slot.label = label !== undefined ? label : fallback;
|
||||
} else {
|
||||
slot.label = " ";
|
||||
}
|
||||
} else if (cfg.expandedLabel) {
|
||||
// EXPANDED, data-reconstruction mode. Recompute the label from
|
||||
// durable data for EVERY matched slot — stashed or not — so a
|
||||
// reloaded node (in-memory stash gone, placeholder baked into
|
||||
// the serialized .label) heals correctly. `undefined` clears
|
||||
// .label so litegraph's name-fallback shows the slot name.
|
||||
const canonical = cfg.expandedLabel(slot, i);
|
||||
if (canonical === undefined) {
|
||||
delete slot.label;
|
||||
} else {
|
||||
slot.label = canonical;
|
||||
}
|
||||
// Drop any legacy stash (from a build before expandedLabel) so
|
||||
// it can't shadow the reconstruction on a later collapse pass.
|
||||
if (slotLabelStashed.has(slot as object)) {
|
||||
slotOrigLabel.delete(slot as object);
|
||||
slotLabelStashed.delete(slot as object);
|
||||
}
|
||||
} else if (slotLabelStashed.has(slot as object)) {
|
||||
// Restore. If the original was undefined, delete the .label
|
||||
// key so litegraph's name-fallback resumes — assigning
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findDownstreamAssemblers,
|
||||
findRootGraph,
|
||||
hasUpstreamLoopOverridingSeed,
|
||||
resolveUpstreamLoopSeed,
|
||||
type LiteGraphLike,
|
||||
type LiteNodeLike,
|
||||
} from "./graph";
|
||||
@@ -36,6 +37,55 @@ function fakeContextNode(id: number, vars: string[], upstreamLink?: number): Lit
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveUpstreamLoopSeed", () => {
|
||||
function loopNode(seed: number, count: number, cfg: object): LiteNodeLike {
|
||||
return {
|
||||
id: 1,
|
||||
type: "WP_ContextLoop",
|
||||
outputs: [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [
|
||||
{ name: "seed", value: seed },
|
||||
{ name: "count", value: count },
|
||||
{ name: "wp_context_loop_config", value: JSON.stringify(cfg) },
|
||||
],
|
||||
} as LiteNodeLike;
|
||||
}
|
||||
function mkGraph(loop: LiteNodeLike, ctx: LiteNodeLike): LiteGraphLike {
|
||||
return {
|
||||
_nodes: [loop, ctx],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: loop, 2: ctx } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
it("reads the Loop's NUMERIC seed + count (regression: widgetValue is string-only)", () => {
|
||||
const loop = loopNode(175588593219397, 4, { override_seed: true, strategy: "hash_index", seed_locks: {} });
|
||||
const ctx = fakeContextNode(2, [], 100);
|
||||
expect(resolveUpstreamLoopSeed(mkGraph(loop, ctx), ctx)).toEqual({
|
||||
overrideSeed: true,
|
||||
baseSeed: 175588593219397,
|
||||
count: 4,
|
||||
strategy: "hash_index",
|
||||
seedLocks: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no Loop is upstream", () => {
|
||||
const ctx = fakeContextNode(2, [], undefined);
|
||||
const graph: LiteGraphLike = { _nodes: [ctx], links: {}, getNodeById: () => null };
|
||||
expect(resolveUpstreamLoopSeed(graph, ctx)).toBeNull();
|
||||
});
|
||||
|
||||
it("carries override_seed=false + the Loop's per-iteration seed_locks", () => {
|
||||
const loop = loopNode(10, 3, { override_seed: false, strategy: "sequential", seed_locks: { "1": 999 } });
|
||||
const ctx = fakeContextNode(2, [], 100);
|
||||
const got = resolveUpstreamLoopSeed(mkGraph(loop, ctx), ctx);
|
||||
expect(got?.overrideSeed).toBe(false);
|
||||
expect(got?.strategy).toBe("sequential");
|
||||
expect(got?.seedLocks).toEqual({ "1": 999 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUpstreamVariables", () => {
|
||||
it("returns the upstream chain's writes only — never the starting node's own", () => {
|
||||
const a = fakeContextNode(1, ["style"]);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type WildcardOption,
|
||||
} from "../components/context/editors/wildcard/probability";
|
||||
import { ensure as ensurePreviewLookup, lookup as previewLookup } from "./preview-resolver";
|
||||
import type { SeedStrategy } from "../components/shared/seed-derive";
|
||||
|
||||
// ── Subgraph boundary primer ────────────────────────────────────────────
|
||||
// ComfyUI subgraphs are nested LGraph instances attached to a parent node
|
||||
@@ -100,6 +101,14 @@ function widgetValue(node: LiteNodeLike, name: string): string {
|
||||
return typeof w?.value === "string" ? w.value : "";
|
||||
}
|
||||
|
||||
/** Numeric widget read — `widgetValue` is string-only (returns "" for Int /
|
||||
* Float / combo widgets), so a numeric widget like the Loop's `seed`/`count`
|
||||
* must be read here. Coerces the raw value; NaN when absent/unparseable. */
|
||||
function widgetNumber(node: LiteNodeLike, name: string): number {
|
||||
const w = node.widgets?.find((x) => x.name === name);
|
||||
return typeof w?.value === "number" ? w.value : Number(w?.value);
|
||||
}
|
||||
|
||||
|
||||
function isSubgraphNode(n: LiteNodeLike): boolean {
|
||||
if (typeof n.isSubgraphNode === "function") {
|
||||
@@ -430,6 +439,63 @@ export function hasUpstreamLoopOverridingSeed(
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface UpstreamLoopSeed {
|
||||
overrideSeed: boolean;
|
||||
baseSeed: number;
|
||||
count: number;
|
||||
strategy: SeedStrategy;
|
||||
/** 0-based iteration index (stringified) -> pinned seed (the Loop's own
|
||||
* per-iteration seed-modal locks). Overlaid on the derived series. */
|
||||
seedLocks: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upstream to the first WP_ContextLoop and return its seed-deriving
|
||||
* config — base seed + count + strategy + override flag + per-iteration locks —
|
||||
* or null when no Loop drives this node. Lets a downstream WP_Context compute
|
||||
* the seed each iteration actually rolls (for the per-frame seed lock). Same
|
||||
* chain walk as `hasUpstreamLoopOverridingSeed`.
|
||||
*/
|
||||
export function resolveUpstreamLoopSeed(
|
||||
rootGraph: LiteGraphLike,
|
||||
node: LiteNodeLike,
|
||||
): UpstreamLoopSeed | null {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
|
||||
while (cur && !seen.has(locator(cur.graph, cur.node))) {
|
||||
seen.add(locator(cur.graph, cur.node));
|
||||
if (cur.node.type === "WP_ContextLoop" && !isSkippedMode(cur.node)) {
|
||||
const cfg = parseWidgetJson<{
|
||||
strategy?: string;
|
||||
override_seed?: boolean;
|
||||
seed_locks?: Record<string, number>;
|
||||
}>(widgetValue(cur.node, "wp_context_loop_config"), {});
|
||||
const strategy: SeedStrategy =
|
||||
cfg.strategy === "sequential" || cfg.strategy === "prime_stride"
|
||||
? cfg.strategy
|
||||
: "hash_index";
|
||||
const rawSeed = widgetNumber(cur.node, "seed");
|
||||
const rawCount = widgetNumber(cur.node, "count");
|
||||
const seedLocks: Record<string, number> = {};
|
||||
if (cfg.seed_locks && typeof cfg.seed_locks === "object") {
|
||||
for (const [k, v] of Object.entries(cfg.seed_locks)) {
|
||||
if (typeof v === "number" && Number.isFinite(v)) seedLocks[k] = v;
|
||||
}
|
||||
}
|
||||
return {
|
||||
overrideSeed: cfg.override_seed === true,
|
||||
baseSeed: Number.isFinite(rawSeed) ? rawSeed : 0,
|
||||
count: Number.isFinite(rawCount) && rawCount >= 1 ? Math.floor(rawCount) : 1,
|
||||
strategy,
|
||||
seedLocks,
|
||||
};
|
||||
}
|
||||
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable bindings contributed by every `WP_ContextInjector` upstream
|
||||
* of `node`. Used by the assembler so the in-template preview shows
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,3 +227,73 @@ describe("buildModulePublishable — constraint axis-name backfill", () => {
|
||||
expect(inner).not.toHaveProperty("target_wildcard_name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildModulePublishable — constraint axis-group backfill", () => {
|
||||
// The grouping (tag_groups) lives on the WILDCARDS, not the constraint. These
|
||||
// are the source/target wildcards as they sit in the live module catalog.
|
||||
const moodWc = {
|
||||
id: "mood0001", type: "wildcard", name: "mood",
|
||||
payload: { var_binding: "mood", tag_groups: { temperament: ["bold", "serene"] } },
|
||||
} as unknown as ModuleRow;
|
||||
const colorWc = {
|
||||
id: "color001", type: "wildcard", name: "color",
|
||||
payload: {
|
||||
var_binding: "color",
|
||||
tag_groups: { saturation: ["vivid", "muted", "pastel"], tone: ["light", "medium", "dark"] },
|
||||
},
|
||||
} as unknown as ModuleRow;
|
||||
// Single-axis wildcard: no tag_groups → nothing to snapshot.
|
||||
const flatWc = {
|
||||
id: "flat0001", type: "wildcard", name: "flat", payload: { var_binding: "flat" },
|
||||
} as unknown as ModuleRow;
|
||||
const catalog: ModuleRow[] = [moodWc, colorWc, flatWc];
|
||||
|
||||
function constraintRow(payload: Record<string, unknown>): ModuleRow {
|
||||
return {
|
||||
id: "cn000002", type: "constraint", name: "Mood x Color",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload, payload_hash: "h".repeat(64), version: 1,
|
||||
created_at: "2026-01-01", updated_at: "2026-01-01", content_rating: "safe" as const,
|
||||
} as unknown as ModuleRow;
|
||||
}
|
||||
|
||||
it("snapshots the source/target wildcards' tag_groups into the published constraint", () => {
|
||||
const row = constraintRow({
|
||||
source_wildcard_id: "mood0001", target_wildcard_id: "color001", matrix: {}, exceptions: [],
|
||||
});
|
||||
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
|
||||
expect(inner.source_groups).toEqual({ temperament: ["bold", "serene"] });
|
||||
expect(inner.target_groups).toEqual({
|
||||
saturation: ["vivid", "muted", "pastel"], tone: ["light", "medium", "dark"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT overwrite groups already embedded on the payload", () => {
|
||||
const row = constraintRow({
|
||||
source_wildcard_id: "mood0001", target_wildcard_id: "color001",
|
||||
source_groups: { custom: ["x"] }, matrix: {}, exceptions: [],
|
||||
});
|
||||
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
|
||||
expect(inner.source_groups).toEqual({ custom: ["x"] });
|
||||
expect(inner.target_groups).toBeDefined(); // target still backfilled
|
||||
});
|
||||
|
||||
it("leaves groups absent for a single-axis wildcard or a dangling ref", () => {
|
||||
const row = constraintRow({
|
||||
source_wildcard_id: "flat0001", target_wildcard_id: "gone9999", matrix: {}, exceptions: [],
|
||||
});
|
||||
const inner = buildModulePublishable(row, catalog).payload.payload as Record<string, unknown>;
|
||||
expect(inner).not.toHaveProperty("source_groups");
|
||||
expect(inner).not.toHaveProperty("target_groups");
|
||||
});
|
||||
|
||||
it("a constraint carrying snapshotted groups still passes strict validation", () => {
|
||||
const row = constraintRow({
|
||||
source_wildcard_id: "mood0001", target_wildcard_id: "color001", matrix: {}, exceptions: [],
|
||||
});
|
||||
const pub = buildModulePublishable(row, catalog);
|
||||
expect(() =>
|
||||
buildPublishBody({ payload: pub.payload, name: pub.name, description: pub.description }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("<script>");
|
||||
});
|
||||
|
||||
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 < 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("<details>");
|
||||
expect(html).not.toContain("<summary>");
|
||||
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("<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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user