Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27cbf5fa26 | ||
|
|
fc920b4d3c | ||
|
|
d604deb6a5 | ||
|
|
8cf40241cf | ||
|
|
72e96ec73f | ||
|
|
5d593a1700 | ||
|
|
81495ea530 | ||
|
|
494b53192d | ||
|
|
8c0f3e418e | ||
|
|
d2f548d0e2 | ||
|
|
7e36bb02c6 | ||
|
|
3a5e46cd9d | ||
|
|
757116dc54 | ||
|
|
05334d3e05 | ||
|
|
2d68c61f4a | ||
|
|
c6d8834a42 | ||
|
|
99d3b21334 | ||
|
|
e9a9d51ea3 | ||
|
|
0572eec472 | ||
|
|
c7f4d91a10 | ||
|
|
45a9092bbc | ||
|
|
2dfed26c1e | ||
|
|
5585583765 | ||
|
|
c4a761d071 | ||
|
|
736e152167 | ||
|
|
a2756f6c0a | ||
|
|
15c3474519 | ||
|
|
87382e650e | ||
|
|
f240aeaf05 | ||
|
|
911e9839a4 | ||
|
|
cc6d76da3f | ||
|
|
6bd6140ae9 | ||
|
|
433930d005 | ||
|
|
d381cc4b06 | ||
|
|
f52d4271dc | ||
|
|
6cf4990513 | ||
|
|
ffd0a5e709 | ||
|
|
845e03c877 | ||
|
|
de361d3fab | ||
|
|
548b63edc9 | ||
|
|
51286b3273 | ||
|
|
157606b082 | ||
|
|
2f40c921eb | ||
|
|
4b69068ace | ||
|
|
0e1b36f6e4 | ||
|
|
dafb7d879c | ||
|
|
6332ed1e4f | ||
|
|
d9e4a7c262 | ||
|
|
0765df3579 | ||
|
|
2b00a5970c | ||
|
|
22b6597ec7 | ||
|
|
c421675102 | ||
|
|
021b411e3c | ||
|
|
312e1575ae | ||
|
|
9e8cd23fe5 | ||
|
|
a769c0e496 | ||
|
|
99125a63d7 | ||
|
|
2768570660 | ||
|
|
12d6f99104 | ||
|
|
97a50ff452 | ||
|
|
b712394648 | ||
|
|
72d9a17f89 | ||
|
|
01de4f0f10 | ||
|
|
7f4b7b93f1 | ||
|
|
a3a0e3f95b | ||
|
|
890d868e16 | ||
|
|
530714e62b | ||
|
|
dd1d5d429f | ||
|
|
1f07d6eb13 | ||
|
|
e5b3edba43 | ||
|
|
4b53aca434 | ||
|
|
6981314fb5 | ||
|
|
b3c4270f6c | ||
|
|
54b525cfa0 | ||
|
|
06bb1b39d7 | ||
|
|
77fc32d760 | ||
|
|
d221ea479f | ||
|
|
b09400ecd6 | ||
|
|
685d6593fd | ||
|
|
4fc3cd84d0 | ||
|
|
88e75ac60c | ||
|
|
7bf39d9337 | ||
|
|
ea188af729 | ||
|
|
385e7b7778 | ||
|
|
e5cc122341 | ||
|
|
09227b216c | ||
|
|
8f388adc55 | ||
|
|
2f08c138ac | ||
|
|
6cbff32827 | ||
|
|
7e26211fa5 | ||
|
|
61288c5c2e | ||
|
|
22f7dedcf2 | ||
|
|
96c65a7d1b | ||
|
|
1224d35010 | ||
|
|
78f831ad69 | ||
|
|
acb5848da3 | ||
|
|
2c6de11c8c | ||
|
|
fe6f6ec451 | ||
|
|
0f4ba28105 | ||
|
|
727bd089a4 | ||
|
|
470133f953 | ||
|
|
388af28a2b | ||
|
|
7c18fcc208 | ||
|
|
4cf497b507 | ||
|
|
51eabadc36 | ||
|
|
96b4141aed | ||
|
|
963c36fc18 | ||
|
|
59e51d34c0 | ||
|
|
4e746ce2aa | ||
|
|
8d6d4e4b10 | ||
|
|
7f07f99951 | ||
|
|
e35ebb25a7 | ||
|
|
693f7191d0 | ||
|
|
2896e11d26 | ||
|
|
dd793c536e | ||
|
|
6c4589315a | ||
|
|
3f6b856eec | ||
|
|
bbc7556af2 | ||
|
|
5064f084a3 | ||
|
|
67761fbba8 | ||
|
|
630129bc32 | ||
|
|
ba170939e8 | ||
|
|
fd3c3805b6 | ||
|
|
7cb2da2468 | ||
|
|
aa1ae66821 | ||
|
|
f922a872cc | ||
|
|
23486ff1ef | ||
|
|
2fa4618ef9 | ||
|
|
50211e510b | ||
|
|
d7a72d7434 | ||
|
|
7d443ba00b | ||
|
|
1787b6041c | ||
|
|
fd4c501871 | ||
|
|
9fb653f7ee | ||
|
|
3f45b26396 | ||
|
|
44d0ca2261 | ||
|
|
699dedade0 | ||
|
|
a64e56d083 | ||
|
|
5a83e61fd1 | ||
|
|
c268e75c20 | ||
|
|
e63be54081 | ||
|
|
ecebd55654 | ||
|
|
78297c4179 | ||
|
|
bb5f6b8658 | ||
|
|
a059b48635 | ||
|
|
9ef1fb698a | ||
|
|
1b32a8a110 | ||
|
|
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 | ||
|
|
fe0d702e75 | ||
|
|
d3e25b72ad | ||
|
|
40980093e8 | ||
|
|
538db06efc | ||
|
|
eac852fbda | ||
|
|
0bbf150ca3 | ||
|
|
c0f354cb8f | ||
|
|
b3537ad6c5 | ||
|
|
a77e60b402 | ||
|
|
c1f45a3fd2 | ||
|
|
11d205bf61 | ||
|
|
d628a0687d | ||
|
|
02cc9f766f | ||
|
|
a1d051172d | ||
|
|
5b5c635be6 | ||
|
|
ccb8392219 | ||
|
|
d8c20728cb | ||
|
|
0e9e5db486 | ||
|
|
a9516996b4 | ||
|
|
02a046321f | ||
|
|
6a5a8d318e | ||
|
|
a19e5f4500 | ||
|
|
c43bff0488 | ||
|
|
a3a3dbe2df | ||
|
|
6e8696fa85 | ||
|
|
959cfbe179 | ||
|
|
325c90818f | ||
|
|
0f5166e7d1 |
@@ -1,8 +1,12 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
# Every branch, not just main. The pre-commit hook now scopes its checks to
|
||||
# the files that changed (see .husky/pre-commit), which is only safe because
|
||||
# the full suite runs here on every push — previously a feature branch got no
|
||||
# remote verification at all until a PR was opened.
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Discord announce
|
||||
|
||||
# Two channels, deliberately asymmetric.
|
||||
#
|
||||
# staff — posts AUTOMATICALLY when semantic-release publishes a release.
|
||||
# The post carries a link back to this workflow, so the review and
|
||||
# the publish button are the same artefact.
|
||||
# public — only ever from a manual run. A release can never announce itself
|
||||
# to everyone without someone having read it first.
|
||||
#
|
||||
# Deliberately a SEPARATE workflow from release.yml: a Discord outage, a
|
||||
# rotated webhook or a rate limit must never fail a release that already
|
||||
# shipped. Re-run this job instead.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Where to post."
|
||||
required: true
|
||||
type: choice
|
||||
default: staff
|
||||
options: [staff, public]
|
||||
since:
|
||||
description: "Roll up every release after this tag (e.g. v2.10.2). Blank = latest release only."
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Print the message instead of posting it."
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
announce:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
# setup-node sniffs the lockfile and tries to prime a pnpm cache,
|
||||
# which fails because pnpm is never installed here. It is not needed:
|
||||
# the announce script has no dependencies at all — plain ESM and the
|
||||
# global fetch built into Node 18+. Nothing is installed, so there is
|
||||
# nothing to cache.
|
||||
package-manager-cache: false
|
||||
|
||||
# A `release` event always posts, and always to staff. A dispatch honours
|
||||
# both inputs, with dry_run defaulting to true so a manual run cannot
|
||||
# surprise anyone.
|
||||
- name: Announce
|
||||
env:
|
||||
DISCORD_WEBHOOK_STAFF: ${{ secrets.DISCORD_WEBHOOK_STAFF }}
|
||||
DISCORD_WEBHOOK_PUBLIC: ${{ secrets.DISCORD_WEBHOOK_PUBLIC }}
|
||||
# Kept so an existing single-webhook setup keeps working as staff.
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_ROLE_ID: ${{ secrets.DISCORD_ROLE_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARGS=""
|
||||
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
ARGS="--version ${{ github.event.release.tag_name }} --channel staff --post"
|
||||
else
|
||||
ARGS="--channel ${{ inputs.channel }}"
|
||||
if [ -n "${{ inputs.since }}" ]; then
|
||||
ARGS="$ARGS --since ${{ inputs.since }}"
|
||||
fi
|
||||
if [ "${{ inputs.dry_run }}" = "false" ]; then
|
||||
ARGS="$ARGS --post"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Running: discord-announce.mjs $ARGS"
|
||||
node scripts/discord-announce.mjs $ARGS
|
||||
@@ -3,6 +3,25 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Tooling-only pushes must not be able to cut a release, whatever their
|
||||
# commit type says.
|
||||
#
|
||||
# 2.13.0 shipped as an empty release because three commits touching only
|
||||
# `scripts/` and `.github/` were typed `feat(announce)` / `fix(announce)`.
|
||||
# Both are release-triggering, so semantic-release did exactly what it was
|
||||
# told — every user on 2.12.0 was offered an update with a blank changelog.
|
||||
# Relying on remembering to type those `ci:` had already failed once.
|
||||
#
|
||||
# Neither path is part of what a user installs: `scripts/` is dev tooling
|
||||
# and `.github/` is CI. GitHub skips the run only when EVERY changed file
|
||||
# matches, so a push that touches shipped code alongside a workflow still
|
||||
# releases correctly.
|
||||
#
|
||||
# `docs/**` is deliberately NOT here — `docs(readme)` is configured to bump
|
||||
# a patch, and `docs/release-notes/next.md` is the release body itself.
|
||||
paths-ignore:
|
||||
- 'scripts/**'
|
||||
- '.github/**'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -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
|
||||
@@ -62,3 +65,4 @@ Thumbs.db
|
||||
# DB location control — per-contributor sidecar config + root-location DB dir
|
||||
db-config.json
|
||||
/db/
|
||||
.tmp-check/
|
||||
|
||||
+64
-14
@@ -1,18 +1,68 @@
|
||||
# Lint + typecheck + frontend tests
|
||||
pnpm lint || exit 1
|
||||
pnpm typecheck || exit 1
|
||||
pnpm test || exit 1
|
||||
# Pre-commit gate, scoped to what actually changed.
|
||||
#
|
||||
# This used to run the whole CI suite on every commit — lint, typecheck, the
|
||||
# full 4000-test vitest run, a build, the size gate, ruff and the full pytest
|
||||
# suite — about 4.5 minutes regardless of whether the commit touched one Vue
|
||||
# file or one markdown file. CI (.github/workflows/ci.yml) now runs on every
|
||||
# push, not just main, so the local hook's job is to catch mistakes fast rather
|
||||
# than re-prove the whole tree.
|
||||
#
|
||||
# Measured on this machine:
|
||||
# lint 11s · typecheck 18s · full vitest 150s · build 10s · size 2s
|
||||
# ruff 0s · full pytest 76s · vitest scoped to changed files ~30s
|
||||
#
|
||||
# So a frontend-only commit drops from ~4.5min to ~70s, a Python-only commit
|
||||
# skips the JS half, and a docs-only commit exits immediately.
|
||||
#
|
||||
# Escape hatch: `WP_FULL=1 git commit …` runs everything, unscoped.
|
||||
|
||||
# Bundle size budget — build the extension chunk and run the size check so
|
||||
# regressions get caught locally before the CI flags them on PR. Skips when
|
||||
# js/ output already matches the source by mtime to keep repeat commits fast.
|
||||
pnpm build:extension || exit 1
|
||||
pnpm size || exit 1
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACMR)
|
||||
[ -z "$staged" ] && exit 0
|
||||
|
||||
# Python checks — only if ruff/pytest reachable on PATH
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
ruff check . || exit 1
|
||||
has() { echo "$staged" | grep -qE "$1"; }
|
||||
|
||||
FRONT=0
|
||||
PY=0
|
||||
# Anything that can change the bundle or its types.
|
||||
has '^(src/|scripts/|package\.json|pnpm-lock\.yaml|vite\.|tsconfig|eslint)' && FRONT=1
|
||||
# Anything the Python suites cover.
|
||||
has '(\.py$|^(engine/|wp_nodes/|wp_api/|tests/|pyproject\.toml))' && PY=1
|
||||
|
||||
if [ "$WP_FULL" = "1" ]; then
|
||||
FRONT=1
|
||||
PY=1
|
||||
fi
|
||||
if command -v pytest >/dev/null 2>&1; then
|
||||
pytest -q || exit 1
|
||||
|
||||
if [ "$FRONT" = "1" ]; then
|
||||
pnpm lint || exit 1
|
||||
pnpm typecheck || exit 1
|
||||
|
||||
# Only the tests reachable from the staged sources. `vitest related` walks
|
||||
# the import graph, so a change to a shared module still pulls in every
|
||||
# dependent suite — narrower than the full run, not weaker for the code
|
||||
# being committed. CI still runs the whole suite with coverage.
|
||||
if [ "$WP_FULL" = "1" ]; then
|
||||
pnpm test || exit 1
|
||||
else
|
||||
changed_src=$(echo "$staged" | grep -E '^src/.*\.(ts|vue)$' | tr '\n' ' ')
|
||||
if [ -n "$changed_src" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
pnpm exec vitest related --run $changed_src || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Bundle budget — cheap, and a regression here stays invisible until release.
|
||||
pnpm build:extension || exit 1
|
||||
pnpm size || exit 1
|
||||
fi
|
||||
|
||||
if [ "$PY" = "1" ]; then
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
ruff check . || exit 1
|
||||
fi
|
||||
if command -v pytest >/dev/null 2>&1; then
|
||||
pytest -q || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
+38
-14
@@ -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: [
|
||||
@@ -69,24 +84,30 @@ export default {
|
||||
],
|
||||
},
|
||||
writerOpts: {
|
||||
// headerPartial puts a fixed "TL;DR + useful links" block at
|
||||
// the very top of every release body, so the first ~600
|
||||
// chars GitHub renders above the "Read more" fold on the
|
||||
// releases list view stay useful no matter how long the
|
||||
// changelog gets. {{version}} expands to the new tag.
|
||||
// The body IS the curated notes. See docs/release-notes/next.md for
|
||||
// the structure and the rules that keep it short.
|
||||
//
|
||||
// The per-commit list used to be inlined here inside a <details>.
|
||||
// It was dropped: with the hidden-type filter already applied it
|
||||
// said the same things as the curated notes, only worse, and it grew
|
||||
// linearly with the release (81 entries for this one). A compare
|
||||
// link is strictly more useful -- diffable, searchable, one line.
|
||||
headerPartial:
|
||||
"## 🎉 Wildcard Pipeline {{version}}\n\n" +
|
||||
"📖 [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)\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
|
||||
// commits stays collapsed by default. GitHub renders
|
||||
// <details> natively in markdown — readers click to expand
|
||||
// when they actually need the per-commit log.
|
||||
footerPartial: "\n</details>",
|
||||
(highlights ? `${highlights}\n\n` : ""),
|
||||
// Compare link instead of the commit dump.
|
||||
footerPartial:
|
||||
"\n---\n\n**Full changelog:** " +
|
||||
"[{{previousTag}}…{{currentTag}}]" +
|
||||
"(https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/{{previousTag}}...{{currentTag}})\n",
|
||||
// Render no commit sections. The curated notes above ARE the release
|
||||
// notes; commits still drive the version bump (see commit-analyzer)
|
||||
// and still live in git -- they are simply not restated here.
|
||||
transform: () => false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -102,14 +123,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-
|
||||
|
||||
+627
@@ -1,3 +1,630 @@
|
||||
## 🎉 Wildcard Pipeline 2.13.2
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### Firefox: the editor no longer dies when you insert a nested reference
|
||||
|
||||
Picking a wildcard from the `@` autocomplete could take down the whole editor —
|
||||
the field stopped accepting text, Backspace did nothing, and Save and Cancel
|
||||
stopped responding until the page was reloaded. Select-all then cut inside an
|
||||
option value did the same thing.
|
||||
|
||||
Both were the same cause. Firefox removes the empty text nodes that Vue uses to
|
||||
mark where each chip and text run begins and ends, and once they are gone any
|
||||
edit that adds or removes a chip runs off the end of the field's contents.
|
||||
Chromium keeps those markers, which is why this only ever happened on Firefox
|
||||
and never reproduced during development.
|
||||
|
||||
The editor now notices when those markers have been removed and rebuilds the
|
||||
field instead of tripping over them. This affects every rich text field —
|
||||
option values, combine templates, derivation rules and injector bindings, on
|
||||
the canvas and in the manager alike.
|
||||
|
||||
### Your library is protected against version mismatches
|
||||
|
||||
If a database has been through migrations that this build does not recognise —
|
||||
after downgrading, hand-installing an older copy, or switching versions in
|
||||
ComfyUI Manager — Wildcard Pipeline now stops with a clear message instead of
|
||||
quietly reading a library it does not understand. Previously it carried on, and
|
||||
every later edit was written through assumptions that no longer held.
|
||||
|
||||
### Release archives now include the interface translations
|
||||
|
||||
The release zip was missing `locales/`, so node names, descriptions and setting
|
||||
labels fell back to raw identifiers for anyone installing from it. Installs
|
||||
from ComfyUI Manager and the Comfy Registry were unaffected.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.13.1…v2.13.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.1...v2.13.2)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.13.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)
|
||||
|
||||
A security fix for the database location setting. Update when you can.
|
||||
|
||||
> **Already on 2.13.0?** This is the same release. 2.13.0 reached GitHub but not the
|
||||
> ComfyUI Registry, so it never arrived for anyone installing through ComfyUI Manager.
|
||||
> 2.13.1 is that same build under a version number the registry will accept — nothing
|
||||
> in it differs.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
|
||||
|
||||
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
|
||||
|
||||
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
|
||||
|
||||
<!--
|
||||
HOW TO WRITE THIS FILE
|
||||
|
||||
Order, and nothing else:
|
||||
|
||||
Headline one sentence, what this release is about
|
||||
### Highlights 4-5 max
|
||||
<!- /modal -> the update dialog stops rendering here
|
||||
<details> Also in this… grouped one-liners, collapsed
|
||||
### Upgrade notes only when the reader must act; delete otherwise
|
||||
— keep this OUTSIDE the <details>, it needs seeing
|
||||
|
||||
This file IS the release body — the compare link is appended automatically and
|
||||
the per-commit list is deliberately not included. It is consumed at release
|
||||
time and cleared afterwards, so anything left here ships.
|
||||
|
||||
RULES
|
||||
|
||||
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
|
||||
6-commit release and an 80-commit release roughly the same height. If a
|
||||
sixth feels essential, something else was not a highlight.
|
||||
|
||||
2. A highlight passes one of three tests:
|
||||
- new capability — you can do something you could not before
|
||||
- wall removed — something that blocked you now works
|
||||
- surprising — behaviour changed in a way you should not meet alone
|
||||
Polish, truncation, layout and invisible perf fail all three.
|
||||
|
||||
3. Commit count is NOT a test. The categories icon picker was two commits and a
|
||||
real feature; `ui` was nine commits and almost entirely polish.
|
||||
|
||||
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
|
||||
names are free-form — use whatever the release actually contains. It sits in
|
||||
a <details> so a long release does not double the page; put the count in the
|
||||
summary so the reader knows what they are opening. That number is written by
|
||||
hand here and can drift — the update dialog computes its own from the same
|
||||
bullets, so if the two disagree, this one is wrong. Note also that collapsed
|
||||
text is not reliably reachable by browser find-in-page, which is the cost.
|
||||
|
||||
5. Write what changed for the user, not what changed in the code. Name the
|
||||
symptom, not the fix: "the field went blank" beats "guarded a null deref".
|
||||
|
||||
6. Perf belongs in the tail WITH its numbers, so the people it affects
|
||||
recognise themselves. "Performance improvements" tells nobody anything.
|
||||
|
||||
7. Security fixes say what an attacker could have done, in plain language, and
|
||||
whether the reader must act. Never ship one as "hardening" or "internal
|
||||
improvements" — someone deciding whether to update tonight needs the facts,
|
||||
and vagueness reads as a cover-up once the diff is public anyway.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.13.0…v2.13.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.0...v2.13.1)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.13.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)
|
||||
|
||||
A security fix for the database location setting. Update when you can.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **The "move the database" setting could be abused to touch other files.** Changing the database location schedules a file copy or move that runs the next time ComfyUI starts. The two paths were only checked for being absolute, not for being database locations — so a request sent to your ComfyUI port could have scheduled a copy or move between any two files on the machine, including reading a file by copying it somewhere this plugin serves over HTTP. ComfyUI's API has no authentication, so anything that can reach the port could do this. Both paths are now restricted to the three locations the database is actually allowed to live in. Moving your database between those locations works exactly as before.
|
||||
|
||||
- **What's new now shows the releases you skipped.** The page only ever showed the newest release, so updating across two versions meant the middle one's notes were simply gone — 2.12.0 was current for about an hour before this release replaced it. The last few releases are now listed underneath, collapsed, each with its own notes. **All releases** also goes to the releases index now instead of to the single release it was labelled against.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
Nothing to do beyond updating. No settings change, and any database move you had queued still runs normally.
|
||||
|
||||
If you expose ComfyUI beyond your own machine, this one is worth taking promptly — the affected setting is reachable without a login on every install of 2.11.0 and 2.12.0.
|
||||
|
||||
<!--
|
||||
HOW TO WRITE THIS FILE
|
||||
|
||||
Order, and nothing else:
|
||||
|
||||
Headline one sentence, what this release is about
|
||||
### Highlights 4-5 max
|
||||
<!- /modal -> the update dialog stops rendering here
|
||||
<details> Also in this… grouped one-liners, collapsed
|
||||
### Upgrade notes only when the reader must act; delete otherwise
|
||||
— keep this OUTSIDE the <details>, it needs seeing
|
||||
|
||||
This file IS the release body — the compare link is appended automatically and
|
||||
the per-commit list is deliberately not included. It is consumed at release
|
||||
time and cleared afterwards, so anything left here ships.
|
||||
|
||||
RULES
|
||||
|
||||
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
|
||||
6-commit release and an 80-commit release roughly the same height. If a
|
||||
sixth feels essential, something else was not a highlight.
|
||||
|
||||
2. A highlight passes one of three tests:
|
||||
- new capability — you can do something you could not before
|
||||
- wall removed — something that blocked you now works
|
||||
- surprising — behaviour changed in a way you should not meet alone
|
||||
Polish, truncation, layout and invisible perf fail all three.
|
||||
|
||||
3. Commit count is NOT a test. The categories icon picker was two commits and a
|
||||
real feature; `ui` was nine commits and almost entirely polish.
|
||||
|
||||
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
|
||||
names are free-form — use whatever the release actually contains. It sits in
|
||||
a <details> so a long release does not double the page; put the count in the
|
||||
summary so the reader knows what they are opening. That number is written by
|
||||
hand here and can drift — the update dialog computes its own from the same
|
||||
bullets, so if the two disagree, this one is wrong. Note also that collapsed
|
||||
text is not reliably reachable by browser find-in-page, which is the cost.
|
||||
|
||||
5. Write what changed for the user, not what changed in the code. Name the
|
||||
symptom, not the fix: "the field went blank" beats "guarded a null deref".
|
||||
|
||||
6. Perf belongs in the tail WITH its numbers, so the people it affects
|
||||
recognise themselves. "Performance improvements" tells nobody anything.
|
||||
|
||||
7. Security fixes say what an attacker could have done, in plain language, and
|
||||
whether the reader must act. Never ship one as "hardening" or "internal
|
||||
improvements" — someone deciding whether to update tonight needs the facts,
|
||||
and vagueness reads as a cover-up once the diff is public anyway.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.12.0…v2.13.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.12.0...v2.13.0)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.12.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)
|
||||
|
||||
Full support for ComfyUI's Nodes 2.0 renderer, and a pass over everything to do with knowing where a value came from.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Every node works under Nodes 2.0.** ComfyUI's new Vue renderer changed how nodes are laid out, sized and scrolled, and our widgets were built against the old one. Nodes now render, resize, scroll and merge their wires correctly under both renderers — including switching between them with a workflow already open.
|
||||
- **See where a variable or reference comes from.** Hover any `$var` or `@{ref}` chip and the card names the module that writes it, the node it lives in, and — for references — the pool it resolves against and how many options currently match. Variables produced by a Context Injector, by a sibling module, or by another node are all attributed instead of reading "binds at runtime".
|
||||
- **The `@` and `$` pickers were rebuilt.** Rows carry an identity so two similarly-named entries are tellable apart, pools supplied by a node are marked as such, and the sub-category filter panel is redesigned — chips reflect the parsed expression, and exception values get a picker of real option values instead of a free-text box.
|
||||
- **Wildcard options and fixed values became editable at scale.** A pinned search bar, tag filtering, drag-to-reorder, and bulk edit that can move rows to top or bottom and add or remove sub-categories across a selection.
|
||||
- **Text editing in options no longer breaks.** Cut, paste and select-all could leave a field stuck, mangled, or typing into the wrong place, and a selection would skip over chips. Keystrokes also no longer escape a modal into ComfyUI's global shortcuts — pressing a plain letter could trigger a node-definition refresh and freeze the page.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
<details>
|
||||
<summary><b>Also in this release</b> — 26 smaller changes</summary>
|
||||
|
||||
**Sequences and seeds**
|
||||
|
||||
- Lock a frame's seed or bypass it straight from its chip on the Context Loop — shift-click to lock, alt-click to bypass — instead of opening the per-iteration seeds dialog
|
||||
- The Seed List gets the same frame chips; clicking one locks its seed, and frames bypassed by a wired Context Loop are shown but not editable there, since that setting belongs to the loop
|
||||
- The frame grid collapses, and scrolls once it outgrows four rows, so a hundred-frame loop no longer owns the canvas. Collapsed state is saved with the workflow, and the header keeps reporting how many frames are locked or bypassed
|
||||
|
||||
**Library and organisation**
|
||||
|
||||
- Categories get an icon picker, and the icon follows the category everywhere it appears
|
||||
- Export and import pickers gain cross-bucket search and per-row detail (`$variable`, option counts)
|
||||
- Copy payload emits an importable envelope, stamped with its schema version
|
||||
- Re-link matches bundles on child content rather than only a stored hash, and offers itself for modules that have no hash at all
|
||||
- An unlinked bundle now reads as missing rather than modified, and reset-to-snapshot refuses to empty one
|
||||
|
||||
**Chips and references**
|
||||
|
||||
- Nested `@{uuid}` references are discovered even when written outside a wildcard payload
|
||||
- Reference hovers name the pool they counted and flag drift against the library
|
||||
- Wildcard option chips get the same hover card as every other surface
|
||||
- Sub-category filter state is derived from the parsed expression, so the chips match what the filter actually does
|
||||
- A reference to a module you have deleted from the library now reads as broken. It used to keep the deleted module's name and colour, and its hover card quoted the option count the module had before it was deleted — the node badge said the reference was broken while the chip inside the editor disagreed
|
||||
|
||||
**The manager**
|
||||
|
||||
- **What's new** moved to its own page, reached from the topbar with a dot when there is something unread. It is also the only place to read the notes for the version you are running — the update pill only exists while a newer one is available
|
||||
- The update check no longer burns through GitHub's hourly allowance. It was making one request per page load per consumer, so a few dozen reloads could exhaust the quota for everything else on that connection; results are now reused for six hours and a refusal is honoured rather than retried
|
||||
|
||||
**Editing and layout**
|
||||
|
||||
- Fixed-value and combine fields grow with their content, with a corner grip that stays reachable and a manual drag that overrides the auto height
|
||||
- A scrollable field no longer hands its scroll to the page behind it
|
||||
- Rule previews truncate with an ellipsis instead of being clipped or faded, and long values wrap
|
||||
- Tag menus stay inside the viewport, keep their own scroll, and fold by axis
|
||||
- Dropdowns say "type to filter", which they have always supported
|
||||
- The save shortcut reads `Ctrl` on Windows and Linux instead of a Mac-only glyph
|
||||
- Test-runner histogram rows are banded so a row reads across its columns, and its module picker survives duplicate names
|
||||
|
||||
**Performance and correctness**
|
||||
|
||||
- Canvas polling is roughly eight times cheaper on a large graph — the walkers now share one parse instead of re-parsing a 294 KB widget value ten times per node every cycle (~21% of a core down to ~2.6%)
|
||||
- Wildcard snapshots refresh when the window regains focus instead of waiting out a timer, and expire so the canvas stops drifting from the library
|
||||
- The Prompt Cleaner no longer rewrites `embedding:name` or `<lora:name:1.0>` references — punctuation stripping renamed embeddings ending in `_`, and fuzzy dedupe dropped one of two versioned files
|
||||
- Variable override counts report writers rather than write statements, so a module that creates a variable is no longer described as overriding writes that never happened
|
||||
|
||||
</details>
|
||||
|
||||
<!--
|
||||
HOW TO WRITE THIS FILE
|
||||
|
||||
Order, and nothing else:
|
||||
|
||||
Headline one sentence, what this release is about
|
||||
### Highlights 4-5 max
|
||||
<!- /modal -> the update dialog stops rendering here
|
||||
<details> Also in this… grouped one-liners, collapsed
|
||||
### Upgrade notes only when the reader must act; delete otherwise
|
||||
— keep this OUTSIDE the <details>, it needs seeing
|
||||
|
||||
This file IS the release body — the compare link is appended automatically and
|
||||
the per-commit list is deliberately not included. It is consumed at release
|
||||
time and cleared afterwards, so anything left here ships.
|
||||
|
||||
RULES
|
||||
|
||||
1. Highlights are capped at 4-5. The cap is the mechanism: it is what keeps a
|
||||
6-commit release and an 80-commit release roughly the same height. If a
|
||||
sixth feels essential, something else was not a highlight.
|
||||
|
||||
2. A highlight passes one of three tests:
|
||||
- new capability — you can do something you could not before
|
||||
- wall removed — something that blocked you now works
|
||||
- surprising — behaviour changed in a way you should not meet alone
|
||||
Polish, truncation, layout and invisible perf fail all three.
|
||||
|
||||
3. Commit count is NOT a test. The categories icon picker was two commits and a
|
||||
real feature; `ui` was nine commits and almost entirely polish.
|
||||
|
||||
4. The tail is grouped by area, one line each, no bold-lead paragraphs. Group
|
||||
names are free-form — use whatever the release actually contains. It sits in
|
||||
a <details> so a long release does not double the page; put the count in the
|
||||
summary so the reader knows what they are opening. That number is written by
|
||||
hand here and can drift — the update dialog computes its own from the same
|
||||
bullets, so if the two disagree, this one is wrong. Note also that collapsed
|
||||
text is not reliably reachable by browser find-in-page, which is the cost.
|
||||
|
||||
5. Write what changed for the user, not what changed in the code. Name the
|
||||
symptom, not the fix: "the field went blank" beats "guarded a null deref".
|
||||
|
||||
6. Perf belongs in the tail WITH its numbers, so the people it affects
|
||||
recognise themselves. "Performance improvements" tells nobody anything.
|
||||
-->
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.11.0…v2.12.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.11.0...v2.12.0)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.11.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)
|
||||
|
||||
### ✨ What's new
|
||||
|
||||
A correctness fix for held modules on multi-frame runs, plus a set of tools for reconnecting workflow items to your library.
|
||||
|
||||
- **Held modules stay held for the whole run.** On a multi-frame sequence, a module set to **Hold across run** could quietly re-roll after the first frame — a held outfit or location would change mid-sequence — and every frame after the first filled the debug panel with constraint warnings. Held picks now carry through every frame, so constraints that depend on them keep applying, cross-node holds stay put, and the warning flood is gone.
|
||||
- **Re-link items that lost their library connection.** When a re-import gives a library entry a new id, the copy in your workflow stops matching it: no drift, no **Edit library entry in SPA**, and saving creates a duplicate. **Push to library** now offers **Re-link to an existing library entry** when it finds one holding the same content — picking it automatically when there's exactly one match, or letting you choose from a list you can search by name *or* id. Works for bundles too.
|
||||
- **Disconnected items now show as drifted** instead of looking like local-only rows. That means the **refresh drifted** button reconnects them in one click, and the library link, drift dot and refresh action all start working again.
|
||||
- **"Reset to library snapshot" can no longer empty a bundle.** If the bundle wasn't linked to a library entry, or the snapshot came back empty, reset used to replace your modules with nothing. It now refuses and explains why, leaving the bundle untouched.
|
||||
- **Importing content you already have offers to link it.** When an import contains an item identical to one already in your library under a different id, it's flagged **IN LIBRARY** with the matching entry named, and you can either link to it — references follow to the existing entry — or import the copy anyway. Nothing is linked or overwritten without you choosing.
|
||||
- **Every edit modal shows the item's id.** A small chip in the header, click to copy — the same id that `@{...}` references, constraint sources and library rows all match on.
|
||||
- **Nested references and variables render as chips** in derivation rule values and constraint exceptions, instead of showing raw text inside `{a|b}` braces. Exception values also get a proper picker of real option values rather than a free-text box.
|
||||
- **Hover any reference chip** to see what it resolves to: its id, its sub-category filter in plain language, and how many options currently match — read live from your library, so the count moves when the library does.
|
||||
- **Search by id.** Pasting an 8-character id into a search box now finds the item, in the manager lists and in the remap / reattach pickers. Previously only names matched, which was useless when two entries shared one.
|
||||
- **Long values wrap** in the derivation override boxes instead of being clipped at the edge of the field.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* Merge pull request #11 from DumiFlex/fix/held-constraint-registration ([fd4c501](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/fd4c501)), closes [#11](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/11)
|
||||
* docs(release-notes): summarise held-module fix + library re-link tooling ([9fb653f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9fb653f))
|
||||
* chore(size): raise total bundle budget to 461 KB ([3f45b26](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/3f45b26))
|
||||
* fix(bundle): never let "Reset to library snapshot" silently empty a bundle ([78297c4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/78297c4))
|
||||
* fix(chips): render nested refs + vars in derivation and exception surfaces ([a64e56d](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a64e56d)), closes [label/#option](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/option)
|
||||
* fix(engine): carry held modules' frame-0 constraint state to later frames ([1b32a8a](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/1b32a8a))
|
||||
* fix(linkage): surface drift for a library-uuid module with an empty local hash ([bb5f6b8](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/bb5f6b8))
|
||||
* fix(search): match modules, bundles and templates by id as well as name ([44d0ca2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/44d0ca2))
|
||||
* feat(chips): hover card on nested-ref and variable chips ([699deda](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/699deda))
|
||||
* feat(editors): show module identity in edit-modal headers + wrap long values ([5a83e61](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/5a83e61))
|
||||
* feat(import): link-to-existing decision for content duplicates (D3b core) ([e63be54](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/e63be54)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
|
||||
* feat(import): surface content duplicates as a resolvable conflict row (D3b) ([c268e75](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c268e75))
|
||||
* feat(relink): content-aware library re-link for detached bundle instances ([ecebd55](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ecebd55))
|
||||
* feat(relink): content-aware library re-link for detached module rows ([a059b48](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a059b48))
|
||||
* feat(relink): content-aware re-link match + apply helpers (pure) ([9ef1fb6](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/9ef1fb6))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 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)
|
||||
|
||||
<details>
|
||||
<summary><b>📋 Full changelog</b> — click to expand the per-commit list</summary>
|
||||
|
||||
|
||||
* fix(api): keep duplicate children when creating or updating bundles ([c1f45a3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c1f45a3))
|
||||
* fix(constraint): drop matrix scroll-container padding that leaked scrolled cells ([d3e25b7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d3e25b7))
|
||||
* fix(constraint): stop scrolled cells bleeding through the frozen matrix headers ([4098009](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/4098009))
|
||||
* fix(manager): decimal option probabilities and distinct sub-category group colors ([11d205b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/11d205b))
|
||||
* feat(constraint): group the canvas matrix axes like the SPA ([538db06](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/538db06))
|
||||
* feat(manager): author and save bundles directly in the manager ([a77e60b](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a77e60b))
|
||||
* feat(manager): group constraint matrix axes with header chips and column bands ([c0f354c](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c0f354c))
|
||||
* feat(manager): label the matrix ungrouped axis run as "uncategorized" ([eac852f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/eac852f))
|
||||
* feat(manager): show uuid + option meta in reattach pickers to tell duplicates apart ([0bbf150](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0bbf150))
|
||||
* feat(manager): type-to-filter all dropdowns ([b3537ad](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/b3537ad))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.5.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(manager): one-time library repair for non-hex module/bundle ids ([02cc9f7](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/02cc9f7))
|
||||
* fix(import): normalize non-hex module/bundle ids on import + download ([a1d0511](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a1d0511))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.4.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 #4 from DumiFlex/feat/inline-weights-pool-ui-debug ([ccb8392](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/ccb8392)), closes [#4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/4)
|
||||
* fix(engine): apply N:: branch weights in simple inline {a|b|c} choices ([a951699](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a951699))
|
||||
* fix(extension): wildcard pool MOD dot, subtitle counts, library-aware refs ([d8c2072](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/d8c2072))
|
||||
* feat(engine): emit a constraint source-missing debug warning ([0e9e5db](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0e9e5db))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.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 #3 from DumiFlex/fix/instance-modal-scroll ([6a5a8d3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6a5a8d3)), closes [#3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/3)
|
||||
* Merge remote-tracking branch 'origin/main' into fix/instance-modal-scroll ([a19e5f4](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/a19e5f4))
|
||||
* fix(extension): scroll instance edit modals with pinned header + footer ([c43bff0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/c43bff0))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.3.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 #2 from DumiFlex/feat/manager-bulk-editor ([6e8696f](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/6e8696f)), closes [#2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues/2)
|
||||
* feat(manager): add bulk editor for wildcard options and fixed values ([959cfbe](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/959cfbe))
|
||||
* feat(widgets): shield clipboard shortcuts on editable widget inputs ([325c908](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/325c908))
|
||||
* fix(manager): recover from rich-text editor crash and persist drafts on unload ([0f5166e](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/commit/0f5166e))
|
||||
|
||||
</details>
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.2.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)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/docs/flow-intro.svg" alt="Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP" />
|
||||
<img src="public/images/docs/flow-intro.svg" alt="Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits named $variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning" />
|
||||
</p>
|
||||
|
||||
## What it does
|
||||
@@ -33,6 +33,7 @@ Stock ComfyUI prompt nodes either take a single literal string or pick from a fl
|
||||
- **Persistent library** — a manager SPA backed by SQLite stores reusable modules, frozen bundles, prompt templates, and a category tree. Browse / edit / fork / push from the canvas.
|
||||
- **Inspector** — `WP Debug` shows the post-run snapshot, per-module trace, per-wildcard picks, and the conflict-scanner warnings.
|
||||
- **Type-coercion helpers** — `WP Var → Int / Float / Bool` parse typed values out of any `$variable` so wildcards can drive image width, step count, sampler cfg, conditional switches.
|
||||
- **Works on both renderers** — the legacy canvas and ComfyUI's Nodes 2.0 Vue renderer, including switching between them with a workflow already open.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -105,7 +106,7 @@ Full module reference: [Modules wiki page](https://github.com/DumiFlex/ComfyUI-W
|
||||
## Looping a chain — one click, N coherent prompts
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/docs/flow-loop.svg" alt="WP Context Loop + WP Seed List → KSampler — both lists fan out in lockstep" />
|
||||
<img src="public/images/docs/flow-loop.svg" alt="WP Context Loop drives WP Context and the Prompt Assembler; CLIP Encode turns each prompt into conditioning for KSampler, while WP Seed List supplies the matching seed directly" />
|
||||
</p>
|
||||
|
||||
Drop `WP Context Loop` before your `WP Context` and pair it with `WP Seed List` for distinct sampler seeds. Both lists fan out in lockstep so iteration N's prompt always pairs with seed N. Each iteration carries its own `$iteration` / `$iteration_total` for in-template labelling (`frame 1 of 4 — a fox`). See [Concepts → Seeds and loops](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Concepts#seeds-and-loops).
|
||||
@@ -145,7 +146,7 @@ See [Nodes → WP Debug](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/w
|
||||
|
||||
## Status
|
||||
|
||||
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every PR.
|
||||
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every branch push and PR.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Prompt-syntax atoms the cleaner must not rewrite.
|
||||
|
||||
The cleaner's rules treat a prompt as prose or as a comma-separated tag list.
|
||||
That is the right model for tags, and the wrong one for the handful of tokens
|
||||
that are *addresses* rather than words — `embedding:name` and `<lora:name:1.0>`
|
||||
resolve to a file on disk, so a single edited character silently changes what
|
||||
the sampler loads, or loads nothing at all.
|
||||
|
||||
Two rules were provably destructive (P3 #31, pinned by
|
||||
`tests/engine/cleaner/test_embedding_roundtrip.py`):
|
||||
|
||||
* punctuation stripping — `_` and `-` are in the stripped set, so
|
||||
`embedding:bad_prompt_` (a real published shape) became
|
||||
`embedding:bad_prompt`. ComfyUI resolves a missing embedding to nothing,
|
||||
so the negative prompt quietly stopped working, with no error.
|
||||
* fuzzy dedupe — versioned names are near-identical strings and the
|
||||
threshold is 0.9, so `embedding:badhandv4, embedding:badhandv5` lost the
|
||||
second. Two different files, one silently dropped.
|
||||
|
||||
This widens the cleaner's remit from "text" to "knows a little prompt syntax",
|
||||
which the findings called out as the cost of fixing it. The alternative was
|
||||
leaving a rule that corrupts a file reference without saying so.
|
||||
|
||||
Deliberately narrow: only the well-known forms, and only whole tokens. Anything
|
||||
not matched here keeps its existing treatment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# `embedding:name`, optionally weighted / parenthesised by the caller. The name
|
||||
# runs to the next separator: whitespace, comma, or a closing paren/angle.
|
||||
_EMBEDDING_RE = re.compile(r"\bembedding:[^\s,)\]>]+", re.IGNORECASE)
|
||||
|
||||
# `<lora:name:1.0>` and the siblings that share its shape. Matched whole, so
|
||||
# internal punctuation and decimal weights are untouchable.
|
||||
_ANGLE_RE = re.compile(
|
||||
r"<\s*(?:lora|lyco|locon|hypernet|embedding)\s*:[^>]*>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def contains_atom(text: str) -> bool:
|
||||
"""True when `text` carries a file-reference token cleaning must preserve."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(_EMBEDDING_RE.search(text) or _ANGLE_RE.search(text))
|
||||
|
||||
|
||||
def ends_with_atom(text: str) -> bool:
|
||||
"""True when `text` ENDS in an atom, so trimming its trailing edge would
|
||||
eat part of the reference. Used by the text-mode punctuation pass, which
|
||||
trims the whole string rather than per-tag."""
|
||||
if not text:
|
||||
return False
|
||||
stripped = text.rstrip()
|
||||
for pattern in (_EMBEDDING_RE, _ANGLE_RE):
|
||||
for match in pattern.finditer(stripped):
|
||||
if match.end() == len(stripped):
|
||||
return True
|
||||
return False
|
||||
@@ -8,6 +8,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from engine.cleaner.atoms import contains_atom
|
||||
from engine.cleaner.types import RuleResult
|
||||
|
||||
_FUZZY_THRESHOLD = 0.9
|
||||
@@ -74,7 +75,17 @@ def apply_fuzzy(text: str, mode: str, config: dict) -> RuleResult:
|
||||
for tag in tags:
|
||||
key = tag.casefold()
|
||||
is_dup = False
|
||||
# Versioned file references are near-identical strings by design --
|
||||
# `embedding:badhandv4` vs `...v5` scores well above the 0.9 threshold
|
||||
# while naming two different files. Exact dedupe still collapses a
|
||||
# genuinely repeated one; only the FUZZY pass is held back.
|
||||
tag_is_atom = contains_atom(tag)
|
||||
for kept_tag in kept:
|
||||
if tag_is_atom or contains_atom(kept_tag):
|
||||
if key == kept_tag.casefold():
|
||||
is_dup = True
|
||||
break
|
||||
continue
|
||||
if _ratio(key, kept_tag.casefold()) >= _FUZZY_THRESHOLD:
|
||||
is_dup = True
|
||||
break
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from engine.cleaner.atoms import contains_atom, ends_with_atom
|
||||
from engine.cleaner.types import RuleResult
|
||||
|
||||
_PUNCT_CHARS = r".,:;!?\-_"
|
||||
@@ -37,6 +38,13 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
if _PURE_PUNCT_RE.match(tag):
|
||||
stripped += 1
|
||||
continue
|
||||
# `embedding:name` / `<lora:name:1.0>` are file references, and
|
||||
# `_` and `-` are in the stripped set — trimming an edge renames
|
||||
# the file. ComfyUI resolves a missing embedding to nothing, so
|
||||
# the damage is silent. See engine/cleaner/atoms.py.
|
||||
if contains_atom(tag):
|
||||
kept.append(tag)
|
||||
continue
|
||||
edged, changed = _strip_edges(tag)
|
||||
if changed:
|
||||
stripped += 1
|
||||
@@ -44,7 +52,14 @@ def apply(text: str, mode: str, config: dict) -> RuleResult:
|
||||
kept.append(edged)
|
||||
out = ", ".join(kept)
|
||||
else:
|
||||
out, changed = _strip_edges(text)
|
||||
if changed:
|
||||
stripped += 1
|
||||
# Text mode trims the whole string's edges rather than per tag, so the
|
||||
# only risk is a prompt that ENDS in an atom.
|
||||
if ends_with_atom(text):
|
||||
out = _LEADING_PUNCT_RE.sub("", text)
|
||||
if out != text:
|
||||
stripped += 1
|
||||
else:
|
||||
out, changed = _strip_edges(text)
|
||||
if changed:
|
||||
stripped += 1
|
||||
return {"text": out, "stats": {"stripped": stripped}}
|
||||
|
||||
+107
-9
@@ -14,6 +14,7 @@ import graph.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -26,6 +27,24 @@ _SQL_DIR = Path(__file__).parent / "migrations_sql"
|
||||
_VERSION_RE = re.compile(r"^(\d{3})_.+\.(sql|py)$")
|
||||
|
||||
|
||||
class SchemaAheadError(RuntimeError):
|
||||
"""The database was migrated by a build this one cannot safely read.
|
||||
|
||||
Raised instead of letting the runner's "skip anything already applied"
|
||||
rule quietly hand a foreign schema to code that expects its own.
|
||||
"""
|
||||
|
||||
|
||||
def _checksum(path: Path) -> str:
|
||||
"""sha256 of a migration file's bytes.
|
||||
|
||||
Bytes, not parsed content: a `.py` migration's behaviour lives in code the
|
||||
runner `exec`s, so any edit at all is a different migration regardless of
|
||||
whether the SQL it emits looks the same.
|
||||
"""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
@@ -75,8 +94,93 @@ def _load_py_migration(path: Path) -> Callable[[sqlite3.Connection], object]:
|
||||
return up # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def head_version() -> int:
|
||||
"""Highest migration version this build ships."""
|
||||
pairs = _discover()
|
||||
return pairs[-1][0] if pairs else 0
|
||||
|
||||
|
||||
def _has_checksum_column(conn: sqlite3.Connection) -> bool:
|
||||
cur = conn.execute("PRAGMA table_info(migrations);")
|
||||
return any(row[1] == "checksum" for row in cur.fetchall())
|
||||
|
||||
|
||||
def _record(conn: sqlite3.Connection, version: int, mig_file: Path) -> None:
|
||||
"""Insert the applied-migration row, with a checksum when we can store one.
|
||||
|
||||
Migration 017 is what adds the column, so on a fresh database every
|
||||
migration before it inserts without one. Those rows stay NULL forever —
|
||||
see the 017 docblock for why backfilling them would defeat the check.
|
||||
"""
|
||||
if _has_checksum_column(conn):
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at, checksum) VALUES(?, ?, ?);",
|
||||
(version, now_iso(), _checksum(mig_file)),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def assert_schema_compatible(conn: sqlite3.Connection) -> None:
|
||||
"""Refuse to touch a database some other build's migrations shaped.
|
||||
|
||||
Two distinct failures, both silent before this existed:
|
||||
|
||||
1. The database is AHEAD — migrated by a newer build. Every migration is
|
||||
`<= MAX(version)`, so the runner does nothing and the caller reads a
|
||||
schema it has never seen.
|
||||
2. The database is at the SAME version but a DIFFERENT shape, because a
|
||||
migration file changed after it had already run somewhere. Version
|
||||
numbers match, so nothing compares anything.
|
||||
|
||||
Both are cumulative: each subsequent write goes through code whose
|
||||
assumptions about the row shape are wrong. Raising is recoverable;
|
||||
continuing is not.
|
||||
"""
|
||||
if not _migrations_table_exists(conn):
|
||||
return
|
||||
|
||||
applied = current_version(conn)
|
||||
head = head_version()
|
||||
if applied > head:
|
||||
raise SchemaAheadError(
|
||||
f"This database was migrated to version {applied}, but this build only "
|
||||
f"knows up to {head}. It was almost certainly written by a newer "
|
||||
f"version of Wildcard Pipeline. Reading it here could corrupt your "
|
||||
f"library — install the newer version again, or restore a backup."
|
||||
)
|
||||
|
||||
if not _has_checksum_column(conn):
|
||||
return
|
||||
|
||||
known = dict(_discover())
|
||||
rows = conn.execute(
|
||||
"SELECT version, checksum FROM migrations WHERE checksum IS NOT NULL;"
|
||||
).fetchall()
|
||||
for version, checksum in rows:
|
||||
mig_file = known.get(int(version))
|
||||
# Unknown version is the `applied > head` case, already handled above.
|
||||
if mig_file is None:
|
||||
continue
|
||||
if _checksum(mig_file) != checksum:
|
||||
raise SchemaAheadError(
|
||||
f"Migration {int(version):03d} was applied by a build whose copy of "
|
||||
f"it differs from this one's. The version numbers match but the "
|
||||
f"schemas may not, so this build cannot safely read your library. "
|
||||
f"Restore a backup taken before that build ran."
|
||||
)
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Apply all pending migrations. Safe to call repeatedly."""
|
||||
"""Apply all pending migrations. Safe to call repeatedly.
|
||||
|
||||
Guards first: applying this build's migrations on top of a schema another
|
||||
build shaped is how a mismatch becomes permanent.
|
||||
"""
|
||||
assert_schema_compatible(conn)
|
||||
applied = current_version(conn)
|
||||
for version, mig_file in _discover():
|
||||
if version <= applied:
|
||||
@@ -85,10 +189,7 @@ def migrate(conn: sqlite3.Connection) -> None:
|
||||
up = _load_py_migration(mig_file)
|
||||
with conn:
|
||||
up(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
_record(conn, version, mig_file)
|
||||
else:
|
||||
sql = mig_file.read_text(encoding="utf-8")
|
||||
with conn:
|
||||
@@ -96,7 +197,4 @@ def migrate(conn: sqlite3.Connection) -> None:
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
conn.execute(stmt)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
_record(conn, version, mig_file)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Migration 017 — record WHICH migration ran, not just how many.
|
||||
|
||||
``migrations`` tracked only ``version``, and the runner skips anything
|
||||
``<= MAX(version)``. That makes an integer the entire identity of a
|
||||
schema, which it is not:
|
||||
|
||||
- A build whose head is 015 opened against a DB migrated to 016 skips
|
||||
every migration, finds nothing to do, and reads a schema it has never
|
||||
seen. Additive columns survive; a shape change does not.
|
||||
- Worse, two builds can both call something "016" and mean different
|
||||
things — a reworked migration, a hand-edited file, a branch that never
|
||||
shipped. The version matches, so nothing anywhere notices.
|
||||
|
||||
Both are silent, and both corrupt cumulatively: every subsequent write
|
||||
goes through code whose assumptions about the row shape are wrong.
|
||||
|
||||
``checksum`` fixes it by storing the sha256 of the migration file that
|
||||
actually ran. ``assert_schema_compatible`` then compares applied
|
||||
checksums against the files this build ships, so a divergence is a
|
||||
loud, recoverable error instead of silence.
|
||||
|
||||
Nullable on purpose: rows applied before this migration existed have no
|
||||
recorded checksum and cannot retroactively grow one. The guard skips
|
||||
them rather than guessing — a NULL means "unknown", never "mismatched".
|
||||
|
||||
Idempotent via ``PRAGMA table_info`` so tests that rewind the
|
||||
``migrations`` table can re-run it against a populated DB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
cur = conn.execute(f"PRAGMA table_info({table});")
|
||||
return any(row[1] == column for row in cur.fetchall())
|
||||
|
||||
|
||||
def up(conn: sqlite3.Connection) -> None:
|
||||
if not _has_column(conn, "migrations", "checksum"):
|
||||
conn.execute("ALTER TABLE migrations ADD COLUMN checksum TEXT;")
|
||||
@@ -382,7 +382,15 @@ class ModuleRepository:
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
|
||||
# Match the name OR the uuid — searching by id is the fast path for
|
||||
# debugging library linkage (finding the row a workflow's @{id} /
|
||||
# constraint source/target points at). NOCASE so an 8-hex id typed
|
||||
# in any case still matches.
|
||||
clauses.append(
|
||||
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
|
||||
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
|
||||
)
|
||||
params.append(f"%{escaped}%")
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
@@ -767,7 +775,15 @@ class BundleRepository:
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
|
||||
# Match the name OR the uuid — searching by id is the fast path for
|
||||
# debugging library linkage (finding the row a workflow's @{id} /
|
||||
# constraint source/target points at). NOCASE so an 8-hex id typed
|
||||
# in any case still matches.
|
||||
clauses.append(
|
||||
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
|
||||
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
|
||||
)
|
||||
params.append(f"%{escaped}%")
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
@@ -951,7 +967,15 @@ class TemplateRepository:
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
|
||||
# Match the name OR the uuid — searching by id is the fast path for
|
||||
# debugging library linkage (finding the row a workflow's @{id} /
|
||||
# constraint source/target points at). NOCASE so an 8-hex id typed
|
||||
# in any case still matches.
|
||||
clauses.append(
|
||||
"(name LIKE ? ESCAPE '\\' COLLATE NOCASE "
|
||||
"OR id LIKE ? ESCAPE '\\' COLLATE NOCASE)"
|
||||
)
|
||||
params.append(f"%{escaped}%")
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -211,7 +211,7 @@ class WalkResult:
|
||||
walk_overflow: list[WalkOverflow] = field(default_factory=list)
|
||||
|
||||
|
||||
def _extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
|
||||
def extract_at_uuid_refs(payload: dict[str, Any]) -> set[str]:
|
||||
"""Collect all ``@{8hex}`` refs referenced inside a payload's
|
||||
string-typed leaves. Uses ``tokenize_text`` exclusively — only
|
||||
canonical 8-hex-char refs (spec §2.4) are followed.
|
||||
@@ -260,6 +260,41 @@ def _row_to_snapshot_entry(
|
||||
}
|
||||
|
||||
|
||||
def ref_seed_uuids(modules: Iterable[Any] | None) -> set[str]:
|
||||
"""Every ``@{uuid}`` target referenced by ANY module in a node's list.
|
||||
|
||||
A catalog is synthesised from wildcard modules only, and
|
||||
``walk_transitive_refs`` follows refs found in wildcard payloads only. That
|
||||
covers a ref authored inside a wildcard option, but ``@{uuid}`` is legal on
|
||||
other surfaces too — most notably a derivation rule's action value, and the
|
||||
per-instance ``action_value_overrides`` / ``condition_value_overrides`` the
|
||||
edit modal writes when a user retargets a branch on the canvas.
|
||||
|
||||
Refs on those surfaces were never discovered, so their target reached the
|
||||
catalog only by coincidence — when the user happened to ALSO have that
|
||||
wildcard picked in the same node. Removing the wildcard from the node (a
|
||||
reasonable thing to do once a derivation references it by uuid) dropped it
|
||||
with nothing to put it back, and every such ref resolved to ``unknown_ref``
|
||||
→ empty string no matter what the live library held.
|
||||
|
||||
Seeds therefore come from each module's ``payload`` AND its ``instance``:
|
||||
the instance is where a canvas-side retarget lives, and it is not part of
|
||||
the library payload the walker would otherwise see.
|
||||
|
||||
Tolerant of malformed rows — a corrupt widget value must never break a
|
||||
graph run.
|
||||
"""
|
||||
seeds: set[str] = set()
|
||||
for module in modules or []:
|
||||
if not isinstance(module, dict):
|
||||
continue
|
||||
for key in ("payload", "instance"):
|
||||
value = module.get(key)
|
||||
if isinstance(value, dict):
|
||||
seeds |= extract_at_uuid_refs(value)
|
||||
return seeds
|
||||
|
||||
|
||||
def walk_transitive_refs(
|
||||
root_uuids: Iterable[str],
|
||||
*,
|
||||
@@ -347,7 +382,7 @@ def walk_transitive_refs(
|
||||
continue
|
||||
|
||||
new_ancestors = ancestors | {uuid}
|
||||
for child_uuid in _extract_at_uuid_refs(row["payload"]):
|
||||
for child_uuid in extract_at_uuid_refs(row["payload"]):
|
||||
queue.append((child_uuid, depth + 1, uuid, new_ancestors))
|
||||
|
||||
# After walk: re-stamp every dep's parent list (BFS may have accumulated
|
||||
|
||||
@@ -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:
|
||||
|
||||
+177
-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,66 @@ 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
|
||||
|
||||
# Held wildcards copy their frozen VALUE from the base pass but
|
||||
# early-return before `_record_pick` (wildcard_handler), so their
|
||||
# frame-0 pick never lands in the real ctx. Downstream constraints
|
||||
# then can't find the held source — they mis-fire (spurious
|
||||
# source_missing / never_applied warnings) and, cross-node, leave a
|
||||
# held target UNCONSTRAINED (the held-outfit drift: kimono at
|
||||
# frame 0, unconstrained pick on frames 2+). Carry the base pass's
|
||||
# pick + hit state for held modules into the real ctx HERE, before
|
||||
# the per-module loop, so the registration is present for this
|
||||
# node's constraints AND flows downstream (via __wp_picks__ /
|
||||
# __wp_constraints__ carry-over) into the next node's base pass.
|
||||
# The value-freeze path in the wildcard handler is untouched.
|
||||
_base_picks = base_ctx.get("__wp_picks__")
|
||||
if isinstance(_base_picks, dict):
|
||||
_real_picks = ctx.setdefault("__wp_picks__", {})
|
||||
if isinstance(_real_picks, dict):
|
||||
for _m in modules:
|
||||
if _module_seed_scope(_m) != "hold":
|
||||
continue
|
||||
_mid = (
|
||||
_m.get("id", "") if isinstance(_m, dict)
|
||||
else getattr(_m, "id", "")
|
||||
)
|
||||
if _mid and _mid in _base_picks and _mid not in _real_picks:
|
||||
_real_picks[_mid] = _base_picks[_mid]
|
||||
_base_hits = base_ctx.get("__wp_constraint_hits__")
|
||||
if isinstance(_base_hits, dict):
|
||||
_real_hits = ctx.setdefault("__wp_constraint_hits__", {})
|
||||
if isinstance(_real_hits, dict):
|
||||
for _cid, _n in _base_hits.items():
|
||||
_real_hits[_cid] = max(
|
||||
int(_real_hits.get(_cid, 0) or 0), int(_n or 0)
|
||||
)
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
# Normalise id/type/enabled reads to work for both dicts and objects.
|
||||
if isinstance(module, dict):
|
||||
@@ -296,18 +372,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 +435,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
|
||||
@@ -613,6 +744,44 @@ class PipelineEngine:
|
||||
# otherwise render as plain text).
|
||||
"message": message,
|
||||
})
|
||||
# Source-side companion warning. The `never_applied` message
|
||||
# above is target-focused; when the constraint's SOURCE
|
||||
# wildcard is ALSO absent from the chain, surface that
|
||||
# distinctly so the Debug viewer shows BOTH ends (mirrors the
|
||||
# editor's separate "source missing" / "target missing" row
|
||||
# badges). A constraint registers by its own module position,
|
||||
# so it can sit in the bucket with neither end present.
|
||||
source_uuid = c.get("source_wildcard_id", "")
|
||||
if source_uuid and not _target_present_in_chain(
|
||||
source_uuid, modules, catalog,
|
||||
):
|
||||
src_row = (
|
||||
catalog.get(source_uuid) if isinstance(catalog, dict) else None
|
||||
)
|
||||
source_name = (
|
||||
(src_row.get("name", "") or "")
|
||||
if isinstance(src_row, dict) else ""
|
||||
)
|
||||
src_suffix = _build_ref_name_suffix(source_name)
|
||||
warnings_bucket = ctx.setdefault("__wp_warnings__", [])
|
||||
if isinstance(warnings_bucket, list):
|
||||
warnings_bucket.append({
|
||||
"type": "constraint_source_missing",
|
||||
"severity": "warn",
|
||||
"module_id": cid,
|
||||
"source_field": "",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"constraint_id": cid,
|
||||
"source_wildcard_id": source_uuid,
|
||||
},
|
||||
"message": (
|
||||
f"constraint @{{{library_cid}{cid_suffix}}} did not apply — "
|
||||
f"its source @{{{source_uuid}{src_suffix}}} wildcard isn't in "
|
||||
f"this chain, so it never registers a source pick."
|
||||
),
|
||||
})
|
||||
|
||||
# SP3 partial-reach finalisation: a constraint that DID fire but
|
||||
# whose `next N` / `pick` selector matched FEWER occurrences than
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -169,8 +169,19 @@ def _resolve_inline_pick(
|
||||
branches: list[str] = tok.meta.get("branches", [])
|
||||
if not branches:
|
||||
return ""
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = branches[chosen_idx]
|
||||
# Per-branch weights (`N::value`), same micro-syntax the multi-pick block
|
||||
# honors. Strip the prefix off every branch so it never leaks into output;
|
||||
# only switch to weighted selection when a branch actually carries a weight,
|
||||
# so unweighted `{a|b|c}` keeps its byte-for-byte seed sequence (legacy
|
||||
# randrange path — changing the draw would shift every existing seed).
|
||||
values = [_strip_branch_weight(b) for b in branches]
|
||||
has_weights = any(v != b for v, b in zip(values, branches, strict=True))
|
||||
if has_weights:
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
chosen_idx = _weighted_pick_index(weights, ctx.rng)
|
||||
else:
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = values[chosen_idx]
|
||||
if not chosen:
|
||||
return ""
|
||||
nested_tokens = tokenize_text(chosen)
|
||||
@@ -380,6 +391,7 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
|
||||
filter_expr = tok.meta.get("filter_expr")
|
||||
exclude_null = bool(tok.meta.get("exclude_null"))
|
||||
if (isinstance(filter_expr, str) and filter_expr.strip()) or exclude_null:
|
||||
pool_total = len(options)
|
||||
ast = _parse_subcat(filter_expr) if isinstance(filter_expr, str) else None
|
||||
options = [
|
||||
o for o in options
|
||||
@@ -393,6 +405,41 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
|
||||
)
|
||||
]
|
||||
if not options:
|
||||
# "Matched no options" on its own is true and useless — it cannot
|
||||
# tell a wrong filter from a right filter aimed at a stale pool.
|
||||
# Report WHICH pool was searched, how big it was, and what tags it
|
||||
# actually offers, so the panel answers the question instead of
|
||||
# restating the symptom.
|
||||
available = sorted({
|
||||
t
|
||||
for o in (payload_dict.get("options", []) if isinstance(payload_dict, dict)
|
||||
else module.get("options", []))
|
||||
if isinstance(o, dict)
|
||||
for t in (o.get("sub_categories") or [])
|
||||
if isinstance(t, str)
|
||||
})
|
||||
pool_origin = module.get("pool_origin")
|
||||
library_count = module.get("library_option_count")
|
||||
detail = {
|
||||
"uuid": uuid,
|
||||
"filter_expr": filter_expr,
|
||||
"exclude_null": exclude_null,
|
||||
"pool_total": pool_total,
|
||||
"available_sub_categories": available,
|
||||
}
|
||||
if pool_origin:
|
||||
detail["pool_origin"] = pool_origin
|
||||
if isinstance(library_count, int):
|
||||
detail["library_option_count"] = library_count
|
||||
where = (
|
||||
"this node's copy" if pool_origin == "node"
|
||||
else "the library" if pool_origin == "library"
|
||||
else "the pool"
|
||||
)
|
||||
drift = (
|
||||
f"; the library now has {library_count}"
|
||||
if isinstance(library_count, int) else ""
|
||||
)
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="ref_subcategory_empty_pool",
|
||||
@@ -401,14 +448,12 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={
|
||||
"uuid": uuid,
|
||||
"filter_expr": filter_expr,
|
||||
"exclude_null": exclude_null,
|
||||
},
|
||||
detail=detail,
|
||||
message=(
|
||||
f"@{{{uuid}:{filter_expr or ''}"
|
||||
f"{'!null' if exclude_null else ''}}} matched no options"
|
||||
f"{'!null' if exclude_null else ''}}} matched no options — "
|
||||
f"searched {where} ({pool_total} option"
|
||||
f"{'' if pool_total == 1 else 's'}){drift}"
|
||||
),
|
||||
)
|
||||
return []
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.2.0",
|
||||
"version": "2.13.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
@@ -13,6 +13,7 @@
|
||||
"build:manager": "vite build --mode manager",
|
||||
"build": "pnpm build:extension && pnpm build:manager",
|
||||
"docs:copy": "node scripts/copy-help-docs.mjs",
|
||||
"docs:figures": "node scripts/build-flow-figures.mjs",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,vue}\"",
|
||||
"size": "node scripts/check-bundle-size.mjs",
|
||||
@@ -24,7 +25,8 @@
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"test:py": "pytest",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"announce": "node scripts/discord-announce.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.6",
|
||||
|
||||
+131
-127
@@ -1,130 +1,134 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 280" role="img" aria-label="WP Context emits resolved variables, Prompt Assembler fills them, CLIP Encode produces conditioning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 202" width="880" height="202" role="img" aria-label="Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits resolved dollar-variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning">
|
||||
<style><![CDATA[
|
||||
.bg { fill: #14161e; }
|
||||
.panel { fill: #1a1d2a; }
|
||||
.border-cyan { stroke: #3a8fb7; }
|
||||
.border-violet { stroke: #7c6ad6; }
|
||||
.border-neutral { stroke: #6a6a6a; }
|
||||
.border-dim { stroke: #353841; }
|
||||
.ico-cyan { fill: #7ec8e3; }
|
||||
.ico-violet { fill: #c4b5fd; }
|
||||
.ico-neutral { fill: #aab; }
|
||||
.name { fill: #d6d8e0; font: 600 13px 'Inter', system-ui, sans-serif; }
|
||||
.sub { fill: #6f7384; font: 400 10px ui-monospace, monospace; }
|
||||
.arrow-lbl { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
|
||||
.arrow-head { fill: #4a4d55; }
|
||||
.modules-header { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.pill-bg { fill: #1a1d2a; }
|
||||
.pill-stroke-wc { stroke: #b29cf2; }
|
||||
.pill-stroke-fv { stroke: #2dd4bf; }
|
||||
.pill-stroke-cb { stroke: #6ad28b; }
|
||||
.pill-stroke-dv { stroke: #f4a261; }
|
||||
.pill-stroke-cs { stroke: #ec96b8; }
|
||||
.pill-stroke-var { stroke: #c4b5fd; }
|
||||
.pill-text-wc { fill: #b29cf2; }
|
||||
.pill-text-fv { fill: #2dd4bf; }
|
||||
.pill-text-cb { fill: #6ad28b; }
|
||||
.pill-text-dv { fill: #f4a261; }
|
||||
.pill-text-cs { fill: #ec96b8; }
|
||||
.pill-text-var { fill: #c4b5fd; }
|
||||
.pill-text { font: 500 10px 'Inter', system-ui, sans-serif; }
|
||||
.pill { fill-opacity: 0.12; stroke-width: 1; }
|
||||
.resolved { fill: #6f7384; font: 600 9.5px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.down-arrow { stroke: #4a4d55; stroke-width: 1.5; fill: none; }
|
||||
.frame, .card, .pill, .chip { stroke-width: 1; }
|
||||
.cap { font: 400 10px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .06em; }
|
||||
.feeder { font: 400 10.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||
.nm { font: 600 12px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||
.id { font: 400 9.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
|
||||
.lbl, .varcap { font: 400 9.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .05em; }
|
||||
.chipt { font: 400 10.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
|
||||
|
||||
.frame { fill: #1e1e1e; stroke: #3a3a3a; }
|
||||
.card { fill: #232323; stroke: #4a4a4a; }
|
||||
.card--wp { stroke: #2dd4bf; stroke-opacity: .55; }
|
||||
.ico--wp { color: #2dd4bf; }
|
||||
.ico--ext { color: #aaaaaa; }
|
||||
.ico--wildcard { color: #a78bfa; }
|
||||
.ico--fixed { color: #22d3ee; }
|
||||
.ico--combine { color: #34d399; }
|
||||
.ico--derivation { color: #fbbf24; }
|
||||
.ico--constraint { color: #f472b6; }
|
||||
.pill { fill: #232323; stroke: #3a3a3a; }
|
||||
.feeder { fill: #aaaaaa; }
|
||||
.nm { fill: #dddddd; }
|
||||
.id, .lbl, .cap, .varcap { fill: #666666; }
|
||||
.ln { stroke: #4a4a4a; }
|
||||
.wash { stop-color: #2dd4bf; }
|
||||
.flow0 { stop-color: #4a4a4a; }
|
||||
.head { fill: #4a4a4a; }
|
||||
.chip { fill: #232323; }
|
||||
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
|
||||
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
|
||||
.chip--c { stroke: #34d399; stroke-opacity: .4; }
|
||||
.chipt--a { fill: #c4b5fd; }
|
||||
.chipt--b { fill: #67e8f9; }
|
||||
.chipt--c { fill: #86efac; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
.frame { fill: #f5f5f5; stroke: #b0b0b0; }
|
||||
.card { fill: #ffffff; stroke: #999999; }
|
||||
.card--wp { stroke: #0f766e; stroke-opacity: .55; }
|
||||
.ico--wp { color: #0f766e; }
|
||||
.ico--ext { color: #555555; }
|
||||
.ico--wildcard { color: #6d28d9; }
|
||||
.ico--fixed { color: #1d4ed8; }
|
||||
.ico--combine { color: #065f46; }
|
||||
.ico--derivation { color: #92400e; }
|
||||
.ico--constraint { color: #9d174d; }
|
||||
.pill { fill: #ffffff; stroke: #b0b0b0; }
|
||||
.feeder { fill: #555555; }
|
||||
.nm { fill: #1a1a1a; }
|
||||
.id, .lbl, .cap, .varcap { fill: #888888; }
|
||||
.ln { stroke: #999999; }
|
||||
.wash { stop-color: #0f766e; }
|
||||
.flow0 { stop-color: #999999; }
|
||||
.head { fill: #999999; }
|
||||
.chip { fill: #ffffff; }
|
||||
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
|
||||
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
|
||||
.chip--c { stroke: #34d399; stroke-opacity: .4; }
|
||||
.chipt--a { fill: #6d28d9; }
|
||||
.chipt--b { fill: #1d4ed8; }
|
||||
.chipt--c { fill: #065f46; }
|
||||
}
|
||||
]]></style>
|
||||
|
||||
<rect class="bg" width="980" height="280" rx="8" />
|
||||
|
||||
<!-- MODULES INSIDE THE CONTEXT header -->
|
||||
<text class="modules-header" x="490" y="28" text-anchor="middle">Modules inside the context</text>
|
||||
|
||||
<!-- Module kind pills row -->
|
||||
<g transform="translate(245, 42)">
|
||||
<!-- Wildcard -->
|
||||
<rect class="pill pill-stroke-wc" x="0" y="0" width="76" height="22" rx="11" fill="#b29cf2" />
|
||||
<text class="pill-text pill-text-wc" x="38" y="15" text-anchor="middle">✦ Wildcard</text>
|
||||
<!-- Fixed Values -->
|
||||
<rect class="pill pill-stroke-fv" x="84" y="0" width="92" height="22" rx="11" fill="#2dd4bf" />
|
||||
<text class="pill-text pill-text-fv" x="130" y="15" text-anchor="middle">⌘ Fixed Values</text>
|
||||
<!-- Combine -->
|
||||
<rect class="pill pill-stroke-cb" x="184" y="0" width="76" height="22" rx="11" fill="#6ad28b" />
|
||||
<text class="pill-text pill-text-cb" x="222" y="15" text-anchor="middle">⇄ Combine</text>
|
||||
<!-- Derivation -->
|
||||
<rect class="pill pill-stroke-dv" x="268" y="0" width="84" height="22" rx="11" fill="#f4a261" />
|
||||
<text class="pill-text pill-text-dv" x="310" y="15" text-anchor="middle">⇆ Derivation</text>
|
||||
<!-- Constraint -->
|
||||
<rect class="pill pill-stroke-cs" x="360" y="0" width="84" height="22" rx="11" fill="#ec96b8" />
|
||||
<text class="pill-text pill-text-cs" x="402" y="15" text-anchor="middle">⊥ Constraint</text>
|
||||
</g>
|
||||
|
||||
<!-- Down arrow indicating these go INTO the Context -->
|
||||
<path class="down-arrow" d="M 490 78 L 490 96 M 484 90 L 490 96 L 496 90" />
|
||||
|
||||
<!-- ===== Main flow row at y≈170 ===== -->
|
||||
|
||||
<!-- WP Context box -->
|
||||
<g transform="translate(40, 130)">
|
||||
<rect class="panel border-cyan" x="0" y="0" width="160" height="100" rx="10" stroke-width="2" />
|
||||
<text class="ico-cyan" x="80" y="32" text-anchor="middle" font-size="20">⌬</text>
|
||||
<text class="name" x="80" y="58" text-anchor="middle">WP Context</text>
|
||||
<text class="sub" x="80" y="78" text-anchor="middle">rolls + resolves</text>
|
||||
</g>
|
||||
|
||||
<!-- EMITS arrow -->
|
||||
<g transform="translate(210, 180)">
|
||||
<text class="arrow-lbl" x="50" y="-12" text-anchor="middle">Emits</text>
|
||||
<line class="arrow-line" x1="0" y1="0" x2="92" y2="0" />
|
||||
<polygon class="arrow-head" points="92,-5 100,0 92,5" />
|
||||
</g>
|
||||
|
||||
<!-- Resolved vars cluster (no box; chips on transparent) -->
|
||||
<g transform="translate(320, 160)">
|
||||
<!-- $subject -->
|
||||
<rect class="pill pill-stroke-var" x="0" y="0" width="68" height="22" rx="11" fill="#c4b5fd" />
|
||||
<text class="pill-text pill-text-var" x="34" y="15" text-anchor="middle">$subject</text>
|
||||
<!-- $style -->
|
||||
<rect class="pill pill-stroke-var" x="76" y="0" width="52" height="22" rx="11" fill="#c4b5fd" />
|
||||
<text class="pill-text pill-text-var" x="102" y="15" text-anchor="middle">$style</text>
|
||||
<!-- $lighting (second row, centered) -->
|
||||
<rect class="pill pill-stroke-var" x="32" y="30" width="72" height="22" rx="11" fill="#c4b5fd" />
|
||||
<text class="pill-text pill-text-var" x="68" y="45" text-anchor="middle">$lighting</text>
|
||||
<!-- RESOLVED $VARS caption -->
|
||||
<text class="resolved" x="64" y="72" text-anchor="middle">Resolved $vars</text>
|
||||
</g>
|
||||
|
||||
<!-- FILLS arrow -->
|
||||
<g transform="translate(465, 180)">
|
||||
<text class="arrow-lbl" x="50" y="-12" text-anchor="middle">Fills</text>
|
||||
<line class="arrow-line" x1="0" y1="0" x2="92" y2="0" />
|
||||
<polygon class="arrow-head" points="92,-5 100,0 92,5" />
|
||||
</g>
|
||||
|
||||
<!-- Prompt Assembler box -->
|
||||
<g transform="translate(575, 130)">
|
||||
<rect class="panel border-violet" x="0" y="0" width="184" height="100" rx="10" stroke-width="2" />
|
||||
<text class="ico-violet" x="92" y="32" text-anchor="middle" font-size="20">≡</text>
|
||||
<text class="name" x="92" y="58" text-anchor="middle">Prompt Assembler</text>
|
||||
<text class="sub" x="92" y="78" text-anchor="middle">"a $style portrait…"</text>
|
||||
</g>
|
||||
|
||||
<!-- PROMPT arrow -->
|
||||
<g transform="translate(770, 180)">
|
||||
<text class="arrow-lbl" x="40" y="-12" text-anchor="middle">Prompt</text>
|
||||
<line class="arrow-line" x1="0" y1="0" x2="72" y2="0" />
|
||||
<polygon class="arrow-head" points="72,-5 80,0 72,5" />
|
||||
</g>
|
||||
|
||||
<!-- CLIP Encode box -->
|
||||
<g transform="translate(860, 130)">
|
||||
<rect class="panel border-neutral" x="0" y="0" width="88" height="100" rx="10" stroke-width="2" />
|
||||
<text class="ico-neutral" x="44" y="32" text-anchor="middle" font-size="20">⚡</text>
|
||||
<text class="name" x="44" y="58" text-anchor="middle">CLIP</text>
|
||||
<text class="sub" x="44" y="78" text-anchor="middle">conditioning</text>
|
||||
</g>
|
||||
|
||||
<!-- Caption -->
|
||||
<text class="resolved" x="490" y="260" text-anchor="middle" style="text-transform:none; letter-spacing:0;">
|
||||
Modules resolve inside WP Context → named $variables → WP Prompt Assembler → CLIP
|
||||
</text>
|
||||
<defs>
|
||||
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
|
||||
width by 100 percent of the HEIGHT, anchored top-left — so it spans
|
||||
the whole panel. Squashing it by the aspect ratio instead pins it to
|
||||
the corner and leaves the rest looking flat. -->
|
||||
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
|
||||
<stop class="wash" offset="0" stop-opacity="0.10"/>
|
||||
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
|
||||
<stop class="wash" offset="1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.25" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.2" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect class="frame" x="0.5" y="0.5" width="879" height="201" rx="12"/>
|
||||
<rect x="0.5" y="0.5" width="879" height="201" rx="12" fill="url(#wash)"/>
|
||||
<text class="cap" x="440" y="30" text-anchor="middle">MODULES INSIDE THE CONTEXT</text>
|
||||
<rect class="pill" x="214.69" y="40" width="77.68" height="21" rx="10.5"/>
|
||||
<g class="ico--wildcard"><g fill="currentColor" transform="translate(224.69 45.5) scale(0.4166666666666667)"><path fill-rule="evenodd" clip-rule="evenodd" d="M18.8923 5.29743L20.1462 5.71539C20.4197 5.80656 20.4197 6.19343 20.1462 6.2846L18.8923 6.70256C18.8027 6.73242 18.7324 6.80272 18.7026 6.8923L18.2846 8.14618C18.1934 8.41969 17.8066 8.41969 17.7154 8.14618L17.2974 6.8923C17.2676 6.80272 17.1973 6.73242 17.1077 6.70256L15.8538 6.2846C15.5803 6.19343 15.5803 5.80656 15.8538 5.71539L17.1077 5.29743C17.1973 5.26757 17.2676 5.19728 17.2974 5.1077L17.7154 3.85381C17.8066 3.5803 18.1934 3.5803 18.2846 3.85381L18.7026 5.1077C18.7324 5.19728 18.8027 5.26757 18.8923 5.29743ZM9.19361 5.04743C9.45192 4.27248 10.5481 4.2725 10.8064 5.04743L12.3429 9.65707L16.9526 11.1936C17.7275 11.4519 17.7275 12.5481 16.9526 12.8064L12.3429 14.3429L10.8064 18.9526C10.5481 19.7275 9.45192 19.7275 9.19361 18.9526L7.65706 14.3429L3.04742 12.8064C2.27247 12.5481 2.27249 11.4519 3.04742 11.1936L7.65706 9.65707L9.19361 5.04743ZM9.99999 7.37171L8.97731 10.4397C8.89271 10.6935 8.69354 10.8927 8.43973 10.9773L5.3717 12L8.43973 13.0227C8.69354 13.1073 8.89271 13.3064 8.97731 13.5603L9.99999 16.6283L11.0227 13.5603C11.1073 13.3064 11.3064 13.1073 11.5603 13.0227L14.6283 12L11.5603 10.9773C11.3064 10.8927 11.1073 10.6935 11.0227 10.4397L9.99999 7.37171ZM20.1462 17.7154L18.8923 17.2974C18.8027 17.2676 18.7324 17.1973 18.7026 17.1077L18.2846 15.8538C18.1934 15.5803 17.8066 15.5803 17.7154 15.8538L17.2974 17.1077C17.2676 17.1973 17.1973 17.2676 17.1077 17.2974L15.8538 17.7154C15.5803 17.8066 15.5803 18.1934 15.8538 18.2846L17.1077 18.7026C17.1973 18.7324 17.2676 18.8027 17.2974 18.8923L17.7154 20.1462C17.8066 20.4197 18.1934 20.4197 18.2846 20.1462L18.7026 18.8923C18.7324 18.8027 18.8027 18.7324 18.8923 18.7026L20.1462 18.2846C20.4197 18.1934 20.4197 17.8066 20.1462 17.7154Z"/></g></g>
|
||||
<text class="feeder" x="238.69" y="54">Wildcard</text>
|
||||
<rect class="pill" x="298.37" y="40" width="99.52" height="21" rx="10.5"/>
|
||||
<g class="ico--fixed"><g fill="currentColor" transform="translate(308.37 45.5) scale(0.4166666666666667)"><path d="M12.32,19.98c-.58,0-1.16-.22-1.6-.66l-6.48-6.47c-.14-.14-.22-.33-.22-.53V4.77c0-.41,.34-.75,.75-.75h7.54c.2,0,.39,.08,.53,.22l6.48,6.48c.87,.88,.87,2.31,0,3.19,0,0,0,0,0,0l-5.41,5.41c-.44,.44-1.02,.66-1.6,.66Zm-6.8-7.97l6.26,6.25c.3,.29,.78,.29,1.07,0l5.41-5.41c.29-.29,.29-.77,0-1.07l-6.26-6.26H5.52v6.48Zm13.27,1.37h0Zm-10.29-3.63c-.69,0-1.25-.56-1.25-1.25s.56-1.25,1.25-1.25,1.25,.56,1.25,1.25-.56,1.25-1.25,1.25Z"/></g></g>
|
||||
<text class="feeder" x="322.37" y="54">Fixed Values</text>
|
||||
<rect class="pill" x="403.89" y="40" width="72.22" height="21" rx="10.5"/>
|
||||
<g class="ico--combine"><g fill="currentColor" transform="translate(413.89 45.5) scale(0.4166666666666667)"><g id="link"><path d="M7.23,18.25A4,4,0,0,1,4.4,17.09a4.23,4.23,0,0,1,.15-5.95L8.31,7.35A4.44,4.44,0,0,1,11.42,6,4,4,0,0,1,14.25,7.2a4.25,4.25,0,0,1-.15,6l-1.26,1.26a.75.75,0,1,1-1.06-1.06L13,12.1a2.73,2.73,0,0,0,.14-3.84,2.77,2.77,0,0,0-3.8.15L5.61,12.19A2.74,2.74,0,0,0,5.46,16a2.5,2.5,0,0,0,2,.71.74.74,0,0,1,.81.67.75.75,0,0,1-.67.82Z"/><path d="M12.58,18A4,4,0,0,1,9.75,16.8a4.25,4.25,0,0,1,.15-6l1.26-1.26a.75.75,0,1,1,1.06,1.06L11,11.9a2.73,2.73,0,0,0-.14,3.84,2.77,2.77,0,0,0,3.8-.15l3.77-3.78A2.74,2.74,0,0,0,18.54,8a2.5,2.5,0,0,0-2-.71.74.74,0,0,1-.81-.67.75.75,0,0,1,.67-.82A4,4,0,0,1,19.6,6.91a4.23,4.23,0,0,1-.15,5.95l-3.76,3.79A4.44,4.44,0,0,1,12.58,18Z"/></g></g></g>
|
||||
<text class="feeder" x="427.89" y="54">Combine</text>
|
||||
<rect class="pill" x="482.11" y="40" width="88.6" height="21" rx="10.5"/>
|
||||
<g class="ico--derivation"><g fill="currentColor" transform="translate(492.11 45.5) scale(0.4166666666666667)"><path d="M19.75,16c0,.41-.34,.75-.75,.75H6.81l1.22,1.22c.29,.29,.29,.77,0,1.06-.15,.15-.34,.22-.53,.22s-.38-.07-.53-.22l-2.5-2.5c-.07-.07-.12-.15-.16-.24-.08-.18-.08-.39,0-.57,.04-.09,.09-.17,.16-.24l2.5-2.5c.29-.29,.77-.29,1.06,0s.29,.77,0,1.06l-1.22,1.22h12.19c.41,0,.75,.34,.75,.75ZM5,8.75h12.19l-1.22,1.22c-.29,.29-.29,.77,0,1.06,.15,.15,.34,.22,.53,.22s.38-.07,.53-.22l2.5-2.5c.07-.07,.12-.15,.16-.24,.08-.18,.08-.39,0-.57-.04-.09-.09-.17-.16-.24l-2.5-2.5c-.29-.29-.77-.29-1.06,0s-.29,.77,0,1.06l1.22,1.22H5c-.41,0-.75,.34-.75,.75s.34,.75,.75,.75Z"/></g></g>
|
||||
<text class="feeder" x="506.11" y="54">Derivation</text>
|
||||
<rect class="pill" x="576.71" y="40" width="88.6" height="21" rx="10.5"/>
|
||||
<g class="ico--constraint"><g fill="currentColor" transform="translate(586.71 45.5) scale(0.4166666666666667)"><g id="filter"><path d="M14,20.5H10a.76.76,0,0,1-.75-.75V12L3.9,4.69a.73.73,0,0,1-.07-.78A.76.76,0,0,1,4.5,3.5h15a.76.76,0,0,1,.67.41.73.73,0,0,1-.07.78L14.75,12v7.75A.76.76,0,0,1,14,20.5ZM10.75,19h2.5V11.75a.71.71,0,0,1,.15-.44L18,5H6l4.62,6.31a.71.71,0,0,1,.15.44Z"/></g></g></g>
|
||||
<text class="feeder" x="600.71" y="54">Constraint</text>
|
||||
<g class="ico--ext"><g fill="currentColor" transform="translate(434 72) scale(0.5)"><g id="angle-down"><path d="M12,14.5a.74.74,0,0,1-.53-.22L8,10.78A.75.75,0,0,1,9,9.72l3,3,3-3A.75.75,0,0,1,16,10.78l-3.5,3.5A.74.74,0,0,1,12,14.5Z"/></g></g></g>
|
||||
<rect class="card card--wp" x="50" y="104" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 115) scale(0.6666666666666666)"><g id="sitemap"><path d="M13.5,9.75h-3A1.76,1.76,0,0,1,8.75,8V5A1.76,1.76,0,0,1,10.5,3.25h3A1.76,1.76,0,0,1,15.25,5V8A1.76,1.76,0,0,1,13.5,9.75Zm-3-5a.25.25,0,0,0-.25.25V8a.25.25,0,0,0,.25.25h3A.25.25,0,0,0,13.75,8V5a.25.25,0,0,0-.25-.25Z"/><path d="M6,19.75H4A1.76,1.76,0,0,1,2.25,18V16A1.76,1.76,0,0,1,4,14.25H6A1.76,1.76,0,0,1,7.75,16v2A1.76,1.76,0,0,1,6,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25H6A.25.25,0,0,0,6.25,18V16A.25.25,0,0,0,6,15.75Z"/><path d="M13,19.75H11A1.76,1.76,0,0,1,9.25,18V16A1.76,1.76,0,0,1,11,14.25h2A1.76,1.76,0,0,1,14.75,16v2A1.76,1.76,0,0,1,13,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M20,19.75H18A1.76,1.76,0,0,1,16.25,18V16A1.76,1.76,0,0,1,18,14.25h2A1.76,1.76,0,0,1,21.75,16v2A1.76,1.76,0,0,1,20,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M19,15.75a.76.76,0,0,1-.75-.75V13a.25.25,0,0,0-.25-.25H6a.25.25,0,0,0-.25.25v2a.75.75,0,0,1-1.5,0V13A1.76,1.76,0,0,1,6,11.25H18A1.76,1.76,0,0,1,19.75,13v2A.76.76,0,0,1,19,15.75Z"/><path d="M12,15.75a.76.76,0,0,1-.75-.75V9a.75.75,0,0,1,1.5,0v6A.76.76,0,0,1,12,15.75Z"/></g></g></g>
|
||||
<text class="nm" x="112" y="147" text-anchor="middle">WP Context</text>
|
||||
<text class="id" x="112" y="161" text-anchor="middle">rolls + resolves</text>
|
||||
<text class="lbl" x="226" y="128" text-anchor="middle">EMITS</text>
|
||||
<rect fill="url(#flow)" x="182" y="136.2" width="82" height="1.6"/>
|
||||
<path class="head" d="M264 132.5 L270 137 L264 141.5 Z"/>
|
||||
<rect class="chip chip--a" x="287.4" y="102" width="64.4" height="19" rx="9.5"/>
|
||||
<text class="chipt chipt--a" x="319.59999999999997" y="115" text-anchor="middle">$subject</text>
|
||||
<rect class="chip chip--b" x="356.79999999999995" y="102" width="51.8" height="19" rx="9.5"/>
|
||||
<text class="chipt chipt--b" x="382.69999999999993" y="115" text-anchor="middle">$style</text>
|
||||
<rect class="chip chip--c" x="312.65" y="126" width="70.69999999999999" height="19" rx="9.5"/>
|
||||
<text class="chipt chipt--c" x="348" y="139" text-anchor="middle">$lighting</text>
|
||||
<text class="varcap" x="348" y="164" text-anchor="middle">RESOLVED $VARS</text>
|
||||
<text class="lbl" x="457" y="128" text-anchor="middle">FILLS</text>
|
||||
<rect fill="url(#flow)" x="426" y="136.2" width="56" height="1.6"/>
|
||||
<path class="head" d="M482 132.5 L488 137 L482 141.5 Z"/>
|
||||
<rect class="card card--wp" x="496" y="104" width="140" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(558 115) scale(0.6666666666666666)"><g id="align-left"><path d="M13.93,11h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,7h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,15h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M13.93,19h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/></g></g></g>
|
||||
<text class="nm" x="566" y="147" text-anchor="middle">Prompt Assembler</text>
|
||||
<text class="id" x="566" y="161" text-anchor="middle">"A $style portrait…"</text>
|
||||
<text class="lbl" x="673" y="128" text-anchor="middle">PROMPT</text>
|
||||
<rect fill="url(#flow)" x="644" y="136.2" width="52" height="1.6"/>
|
||||
<path class="head" d="M696 132.5 L702 137 L696 141.5 Z"/>
|
||||
<rect class="card card--ext" x="710" y="104" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--ext"><g fill="currentColor" transform="translate(764 115) scale(0.6666666666666666)"><g id="bolt"><path d="M11.09,21.5a.67.67,0,0,1-.24,0,.83.83,0,0,1-.59-.81v-.11l.9-6.35H6.82a.8.8,0,0,1-.71-.43.85.85,0,0,1,0-.86l2-3.49,4.1-6.52a.79.79,0,0,1,.92-.35.83.83,0,0,1,.59.81v.11l-.9,6.35h4.35a.8.8,0,0,1,.71.43.85.85,0,0,1,0,.86l-2,3.49-4.1,6.52A.79.79,0,0,1,11.09,21.5Zm-.53-1.21h0Zm-2.63-7.6h4a.84.84,0,0,1,.83.85v.11l-.59,4.14,2.5-4,1.44-2.48h-4a.84.84,0,0,1-.83-.85v-.11l.59-4.14-2.5,4Zm-.57,1Zm9.28-3.34ZM13.44,3.7h0Z"/></g></g></g>
|
||||
<text class="nm" x="772" y="147" text-anchor="middle">CLIP Encode</text>
|
||||
<text class="id" x="772" y="161" text-anchor="middle">conditioning</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,102 +1,132 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 980 240" role="img" aria-label="WP Context Loop emits N prompts and feeds WP Seed List N seeds; both fan out into KSampler in lockstep">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 286" width="880" height="286" role="img" aria-label="WP Context Loop drives WP Context and the Prompt Assembler to produce N prompts, CLIP Encode turns them into conditioning for KSampler, and WP Seed List supplies the matching seed for each iteration directly to KSampler">
|
||||
<style><![CDATA[
|
||||
.bg { fill: #14161e; }
|
||||
.panel { fill: #1a1d2a; }
|
||||
.border-loop { stroke: #7c6ad6; }
|
||||
.border-ctx { stroke: #3a8fb7; }
|
||||
.border-asm { stroke: #7c6ad6; }
|
||||
.border-seed { stroke: #3a8fb7; }
|
||||
.border-k { stroke: #6a6a6a; }
|
||||
.ico-loop { fill: #c4b5fd; }
|
||||
.ico-ctx { fill: #7ec8e3; }
|
||||
.ico-asm { fill: #c4b5fd; }
|
||||
.ico-seed { fill: #7ec8e3; }
|
||||
.ico-k { fill: #aab; }
|
||||
.name { fill: #d6d8e0; font: 600 13px 'Inter', system-ui, sans-serif; }
|
||||
.sub { fill: #6f7384; font: 400 10px ui-monospace, monospace; }
|
||||
.arrow-lbl { fill: #6f7384; font: 600 10px 'Inter', system-ui, sans-serif; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.arrow-line { stroke: #4a4d55; stroke-width: 2; fill: none; }
|
||||
.arrow-line-dim { stroke: #353841; stroke-width: 2; fill: none; stroke-dasharray: 5 4; }
|
||||
.arrow-head { fill: #4a4d55; }
|
||||
.caption { fill: #6f7384; font: 500 11px 'Inter', system-ui, sans-serif; }
|
||||
.frame, .card, .pill, .chip { stroke-width: 1; }
|
||||
.cap { font: 400 10px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .06em; }
|
||||
.feeder { font: 400 10.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||
.nm { font: 600 12px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||
.id { font: 400 9.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
|
||||
.lbl, .varcap { font: 400 9.5px 'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; letter-spacing: .05em; }
|
||||
.chipt { font: 400 10.5px 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace; }
|
||||
|
||||
.frame { fill: #1e1e1e; stroke: #3a3a3a; }
|
||||
.card { fill: #232323; stroke: #4a4a4a; }
|
||||
.card--wp { stroke: #2dd4bf; stroke-opacity: .55; }
|
||||
.ico--wp { color: #2dd4bf; }
|
||||
.ico--ext { color: #aaaaaa; }
|
||||
.ico--wildcard { color: #a78bfa; }
|
||||
.ico--fixed { color: #22d3ee; }
|
||||
.ico--combine { color: #34d399; }
|
||||
.ico--derivation { color: #fbbf24; }
|
||||
.ico--constraint { color: #f472b6; }
|
||||
.pill { fill: #232323; stroke: #3a3a3a; }
|
||||
.feeder { fill: #aaaaaa; }
|
||||
.nm { fill: #dddddd; }
|
||||
.id, .lbl, .cap, .varcap { fill: #666666; }
|
||||
.ln { stroke: #4a4a4a; }
|
||||
.wash { stop-color: #2dd4bf; }
|
||||
.flow0 { stop-color: #4a4a4a; }
|
||||
.head { fill: #4a4a4a; }
|
||||
.chip { fill: #232323; }
|
||||
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
|
||||
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
|
||||
.chip--c { stroke: #34d399; stroke-opacity: .4; }
|
||||
.chipt--a { fill: #c4b5fd; }
|
||||
.chipt--b { fill: #67e8f9; }
|
||||
.chipt--c { fill: #86efac; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
.frame { fill: #f5f5f5; stroke: #b0b0b0; }
|
||||
.card { fill: #ffffff; stroke: #999999; }
|
||||
.card--wp { stroke: #0f766e; stroke-opacity: .55; }
|
||||
.ico--wp { color: #0f766e; }
|
||||
.ico--ext { color: #555555; }
|
||||
.ico--wildcard { color: #6d28d9; }
|
||||
.ico--fixed { color: #1d4ed8; }
|
||||
.ico--combine { color: #065f46; }
|
||||
.ico--derivation { color: #92400e; }
|
||||
.ico--constraint { color: #9d174d; }
|
||||
.pill { fill: #ffffff; stroke: #b0b0b0; }
|
||||
.feeder { fill: #555555; }
|
||||
.nm { fill: #1a1a1a; }
|
||||
.id, .lbl, .cap, .varcap { fill: #888888; }
|
||||
.ln { stroke: #999999; }
|
||||
.wash { stop-color: #0f766e; }
|
||||
.flow0 { stop-color: #999999; }
|
||||
.head { fill: #999999; }
|
||||
.chip { fill: #ffffff; }
|
||||
.chip--a { stroke: #8b5cf6; stroke-opacity: .4; }
|
||||
.chip--b { stroke: #22d3ee; stroke-opacity: .4; }
|
||||
.chip--c { stroke: #34d399; stroke-opacity: .4; }
|
||||
.chipt--a { fill: #6d28d9; }
|
||||
.chipt--b { fill: #1d4ed8; }
|
||||
.chipt--c { fill: #065f46; }
|
||||
}
|
||||
]]></style>
|
||||
|
||||
<rect class="bg" width="980" height="240" rx="8" />
|
||||
|
||||
<!-- WP Context Loop -->
|
||||
<g transform="translate(30, 70)">
|
||||
<rect class="panel border-loop" x="0" y="0" width="160" height="100" rx="10" stroke-width="2" />
|
||||
<text class="ico-loop" x="80" y="32" text-anchor="middle" font-size="20">↺</text>
|
||||
<text class="name" x="80" y="58" text-anchor="middle">WP Context Loop</text>
|
||||
<text class="sub" x="80" y="78" text-anchor="middle">N prompts</text>
|
||||
</g>
|
||||
|
||||
<!-- Loop splits into TWO lanes -->
|
||||
|
||||
<!-- Lane A: loop_config → Seed List → seed[] -->
|
||||
<g transform="translate(200, 60)">
|
||||
<text class="arrow-lbl" x="50" y="-6" text-anchor="middle">loop_config</text>
|
||||
<line class="arrow-line" x1="0" y1="6" x2="92" y2="6" />
|
||||
<polygon class="arrow-head" points="92,1 100,6 92,11" />
|
||||
</g>
|
||||
<g transform="translate(310, 30)">
|
||||
<rect class="panel border-seed" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
|
||||
<text class="ico-seed" x="80" y="28" text-anchor="middle" font-size="18">#</text>
|
||||
<text class="name" x="80" y="50" text-anchor="middle">WP Seed List</text>
|
||||
<text class="sub" x="80" y="68" text-anchor="middle">N seeds</text>
|
||||
</g>
|
||||
|
||||
<!-- Lane B: context → WP Context -->
|
||||
<g transform="translate(200, 160)">
|
||||
<text class="arrow-lbl" x="50" y="-6" text-anchor="middle">context</text>
|
||||
<line class="arrow-line" x1="0" y1="6" x2="92" y2="6" />
|
||||
<polygon class="arrow-head" points="92,1 100,6 92,11" />
|
||||
</g>
|
||||
<g transform="translate(310, 130)">
|
||||
<rect class="panel border-ctx" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
|
||||
<text class="ico-ctx" x="80" y="28" text-anchor="middle" font-size="18">⌬</text>
|
||||
<text class="name" x="80" y="50" text-anchor="middle">WP Context</text>
|
||||
<text class="sub" x="80" y="68" text-anchor="middle">module stack</text>
|
||||
</g>
|
||||
|
||||
<!-- WP Context → Prompt Assembler -->
|
||||
<g transform="translate(480, 160)">
|
||||
<line class="arrow-line" x1="0" y1="6" x2="62" y2="6" />
|
||||
<polygon class="arrow-head" points="62,1 70,6 62,11" />
|
||||
</g>
|
||||
|
||||
<!-- Prompt Assembler -->
|
||||
<g transform="translate(560, 130)">
|
||||
<rect class="panel border-asm" x="0" y="0" width="160" height="80" rx="10" stroke-width="2" />
|
||||
<text class="ico-asm" x="80" y="28" text-anchor="middle" font-size="18">≡</text>
|
||||
<text class="name" x="80" y="50" text-anchor="middle">Prompt Assembler</text>
|
||||
<text class="sub" x="80" y="68" text-anchor="middle">N prompts</text>
|
||||
</g>
|
||||
|
||||
<!-- Seed List → KSampler (top lane) -->
|
||||
<g transform="translate(480, 60)">
|
||||
<text class="arrow-lbl" x="180" y="-6" text-anchor="middle">seed[]</text>
|
||||
<path class="arrow-line" d="M 0 6 L 360 6 L 360 64" />
|
||||
<polygon class="arrow-head" points="355,59 360,67 365,59" />
|
||||
</g>
|
||||
|
||||
<!-- Assembler → KSampler (bottom lane joining top arrow at sampler) -->
|
||||
<g transform="translate(730, 160)">
|
||||
<text class="arrow-lbl" x="40" y="-6" text-anchor="middle">prompt[]</text>
|
||||
<line class="arrow-line" x1="0" y1="6" x2="62" y2="6" />
|
||||
<polygon class="arrow-head" points="62,1 70,6 62,11" />
|
||||
</g>
|
||||
|
||||
<!-- KSampler -->
|
||||
<g transform="translate(810, 130)">
|
||||
<rect class="panel border-k" x="0" y="0" width="140" height="80" rx="10" stroke-width="2" />
|
||||
<text class="ico-k" x="70" y="28" text-anchor="middle" font-size="18">⚡</text>
|
||||
<text class="name" x="70" y="50" text-anchor="middle">KSampler</text>
|
||||
<text class="sub" x="70" y="68" text-anchor="middle">N unique images</text>
|
||||
</g>
|
||||
|
||||
<!-- Caption -->
|
||||
<text class="caption" x="490" y="225" text-anchor="middle">
|
||||
Loop emits N prompts · Seed List emits N seeds · both lists fan out into the sampler in lockstep — iteration N's prompt pairs with seed N
|
||||
</text>
|
||||
<defs>
|
||||
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
|
||||
width by 100 percent of the HEIGHT, anchored top-left — so it spans
|
||||
the whole panel. Squashing it by the aspect ratio instead pins it to
|
||||
the corner and leaves the rest looking flat. -->
|
||||
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
|
||||
<stop class="wash" offset="0" stop-opacity="0.10"/>
|
||||
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
|
||||
<stop class="wash" offset="1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.25" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.2" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect class="frame" x="0.5" y="0.5" width="879" height="285" rx="12"/>
|
||||
<rect x="0.5" y="0.5" width="879" height="285" rx="12" fill="url(#wash)"/>
|
||||
<text class="cap" x="440" y="30" text-anchor="middle">BOTH LISTS FAN OUT IN LOCKSTEP — ITERATION N MEETS SEED N</text>
|
||||
<rect class="card card--wp" x="48.199999999999996" y="54" width="127.60000000000001" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 65) scale(0.6666666666666666)"><g id="refresh"><path d="M12,20.75a7.25,7.25,0,0,1,0-14.5h2.5a.75.75,0,0,1,0,1.5H12a5.75,5.75,0,1,0,5.75,5.75.75.75,0,0,1,1.5,0A7.26,7.26,0,0,1,12,20.75Z"/><path d="M12,10.75a.74.74,0,0,1-.53-.22.75.75,0,0,1,0-1.06L13.94,7,11.47,4.53a.75.75,0,1,1,1.06-1.06l3,3a.75.75,0,0,1,0,1.06l-3,3A.74.74,0,0,1,12,10.75Z"/></g></g></g>
|
||||
<text class="nm" x="112" y="97" text-anchor="middle">WP Context Loop</text>
|
||||
<text class="id" x="112" y="111" text-anchor="middle">N iterations</text>
|
||||
<rect class="card card--wp" x="50" y="158" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(104 169) scale(0.6666666666666666)"><g id="list"><path d="M19,12.75H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M19,8.25H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M19,17.25H8a.75.75,0,0,1,0-1.5H19a.75.75,0,0,1,0,1.5Z"/><path d="M5,8.5a1,1,0,0,1-.38-.07,1.46,1.46,0,0,1-.33-.22A1,1,0,0,1,4,7.5a1.05,1.05,0,0,1,.29-.71.93.93,0,0,1,.33-.21,1,1,0,0,1,.76,0,1,1,0,0,1,.33.21A1.05,1.05,0,0,1,6,7.5a1,1,0,0,1-.29.71,1.46,1.46,0,0,1-.33.22A1,1,0,0,1,5,8.5Z"/><path d="M5,13a1,1,0,0,1-.38-.08,1.15,1.15,0,0,1-.33-.21A1,1,0,0,1,4,12a1.05,1.05,0,0,1,.29-.71,1.15,1.15,0,0,1,.33-.21A1,1,0,0,1,5.2,11l.18.06.18.09a1.58,1.58,0,0,1,.15.12A1.05,1.05,0,0,1,6,12a1,1,0,0,1-1,1Z"/><path d="M5,17.5a1,1,0,0,1-.38-.07,1.46,1.46,0,0,1-.33-.22,1.15,1.15,0,0,1-.21-.33.94.94,0,0,1,0-.76,1.15,1.15,0,0,1,.21-.33,1,1,0,0,1,1.09-.21,1,1,0,0,1,.33.21,1.15,1.15,0,0,1,.21.33.94.94,0,0,1,0,.76,1.15,1.15,0,0,1-.21.33A1,1,0,0,1,5,17.5Z"/></g></g></g>
|
||||
<text class="nm" x="112" y="201" text-anchor="middle">WP Seed List</text>
|
||||
<text class="id" x="112" y="215" text-anchor="middle">N seeds</text>
|
||||
<rect class="card card--wp" x="278" y="54" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(332 65) scale(0.6666666666666666)"><g id="sitemap"><path d="M13.5,9.75h-3A1.76,1.76,0,0,1,8.75,8V5A1.76,1.76,0,0,1,10.5,3.25h3A1.76,1.76,0,0,1,15.25,5V8A1.76,1.76,0,0,1,13.5,9.75Zm-3-5a.25.25,0,0,0-.25.25V8a.25.25,0,0,0,.25.25h3A.25.25,0,0,0,13.75,8V5a.25.25,0,0,0-.25-.25Z"/><path d="M6,19.75H4A1.76,1.76,0,0,1,2.25,18V16A1.76,1.76,0,0,1,4,14.25H6A1.76,1.76,0,0,1,7.75,16v2A1.76,1.76,0,0,1,6,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25H6A.25.25,0,0,0,6.25,18V16A.25.25,0,0,0,6,15.75Z"/><path d="M13,19.75H11A1.76,1.76,0,0,1,9.25,18V16A1.76,1.76,0,0,1,11,14.25h2A1.76,1.76,0,0,1,14.75,16v2A1.76,1.76,0,0,1,13,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M20,19.75H18A1.76,1.76,0,0,1,16.25,18V16A1.76,1.76,0,0,1,18,14.25h2A1.76,1.76,0,0,1,21.75,16v2A1.76,1.76,0,0,1,20,19.75Zm-2-4a.25.25,0,0,0-.25.25v2a.25.25,0,0,0,.25.25h2a.25.25,0,0,0,.25-.25V16a.25.25,0,0,0-.25-.25Z"/><path d="M19,15.75a.76.76,0,0,1-.75-.75V13a.25.25,0,0,0-.25-.25H6a.25.25,0,0,0-.25.25v2a.75.75,0,0,1-1.5,0V13A1.76,1.76,0,0,1,6,11.25H18A1.76,1.76,0,0,1,19.75,13v2A.76.76,0,0,1,19,15.75Z"/><path d="M12,15.75a.76.76,0,0,1-.75-.75V9a.75.75,0,0,1,1.5,0v6A.76.76,0,0,1,12,15.75Z"/></g></g></g>
|
||||
<text class="nm" x="340" y="97" text-anchor="middle">WP Context</text>
|
||||
<text class="id" x="340" y="111" text-anchor="middle">module stack</text>
|
||||
<rect class="card card--wp" x="499.08" y="54" width="133.84" height="66" rx="10"/>
|
||||
<g class="ico ico--wp"><g fill="currentColor" transform="translate(558 65) scale(0.6666666666666666)"><g id="align-left"><path d="M13.93,11h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,7h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M20.07,15h-16a.75.75,0,0,1,0-1.5h16a.75.75,0,0,1,0,1.5Z"/><path d="M13.93,19h-10a.75.75,0,1,1,0-1.5h10a.75.75,0,0,1,0,1.5Z"/></g></g></g>
|
||||
<text class="nm" x="566" y="97" text-anchor="middle">Prompt Assembler</text>
|
||||
<text class="id" x="566" y="111" text-anchor="middle">N prompts</text>
|
||||
<rect class="card card--ext" x="504" y="158" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--ext"><g fill="currentColor" transform="translate(558 169) scale(0.6666666666666666)"><g id="bolt"><path d="M11.09,21.5a.67.67,0,0,1-.24,0,.83.83,0,0,1-.59-.81v-.11l.9-6.35H6.82a.8.8,0,0,1-.71-.43.85.85,0,0,1,0-.86l2-3.49,4.1-6.52a.79.79,0,0,1,.92-.35.83.83,0,0,1,.59.81v.11l-.9,6.35h4.35a.8.8,0,0,1,.71.43.85.85,0,0,1,0,.86l-2,3.49-4.1,6.52A.79.79,0,0,1,11.09,21.5Zm-.53-1.21h0Zm-2.63-7.6h4a.84.84,0,0,1,.83.85v.11l-.59,4.14,2.5-4,1.44-2.48h-4a.84.84,0,0,1-.83-.85v-.11l.59-4.14-2.5,4Zm-.57,1Zm9.28-3.34ZM13.44,3.7h0Z"/></g></g></g>
|
||||
<text class="nm" x="566" y="201" text-anchor="middle">CLIP Encode</text>
|
||||
<text class="id" x="566" y="215" text-anchor="middle">N conditionings</text>
|
||||
<rect class="card card--ext" x="710" y="158" width="124" height="66" rx="10"/>
|
||||
<g class="ico ico--ext"><g fill="currentColor" transform="translate(764 169) scale(0.6666666666666666)"><g id="image"><path d="M18,4.25H6A2.75,2.75,0,0,0,3.25,7V17A2.75,2.75,0,0,0,6,19.75H18A2.75,2.75,0,0,0,20.75,17V7A2.75,2.75,0,0,0,18,4.25ZM6,5.75H18A1.25,1.25,0,0,1,19.25,7v8.19l-2.72-2.72a.7.7,0,0,0-.56-.22.79.79,0,0,0-.55.27l-1.29,1.55-4.6-4.6A.7.7,0,0,0,9,9.25a.79.79,0,0,0-.55.27L4.75,13.93V7A1.25,1.25,0,0,1,6,5.75ZM4.75,17v-.73l4.3-5.16,4.12,4.12-2.52,3H6A1.25,1.25,0,0,1,4.75,17ZM18,18.25H12.6l3.45-4.14,3.15,3.15A1.23,1.23,0,0,1,18,18.25Z"/></g></g></g>
|
||||
<text class="nm" x="772" y="201" text-anchor="middle">KSampler</text>
|
||||
<text class="id" x="772" y="215" text-anchor="middle">N images</text>
|
||||
<text class="lbl" x="226.9" y="78" text-anchor="middle">CONTEXT</text>
|
||||
<rect fill="url(#flow)" x="183.8" y="86.2" width="80.19999999999999" height="1.6"/>
|
||||
<path class="head" d="M264 82.5 L270 87 L264 91.5 Z"/>
|
||||
<text class="lbl" x="122" y="143">LOOP_CONFIG</text>
|
||||
<rect fill="url(#flowv)" x="111.2" y="120" width="1.6" height="32"/>
|
||||
<path class="head" d="M108 152 L112 158 L116 152 Z"/>
|
||||
<text class="lbl" x="450.53999999999996" y="78" text-anchor="middle">$VARS</text>
|
||||
<rect fill="url(#flow)" x="410" y="86.2" width="75.07999999999998" height="1.6"/>
|
||||
<path class="head" d="M485.08 82.5 L491.08 87 L485.08 91.5 Z"/>
|
||||
<text class="lbl" x="576" y="141">PROMPT</text>
|
||||
<rect fill="url(#flowv)" x="565.2" y="120" width="1.6" height="32"/>
|
||||
<path class="head" d="M562 152 L566 158 L570 152 Z"/>
|
||||
<text class="lbl" x="669" y="182" text-anchor="middle">COND</text>
|
||||
<rect fill="url(#flow)" x="636" y="190.2" width="60" height="1.6"/>
|
||||
<path class="head" d="M696 186.5 L702 191 L696 195.5 Z"/>
|
||||
<path class="ln" fill="none" d="M112 224 V 252 H 772 V 230"/>
|
||||
<path class="head" d="M768 230 L772 224 L776 230 Z"/>
|
||||
<text class="lbl" x="420" y="244" text-anchor="middle">SEED[]</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 11 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.2.0"
|
||||
version = "2.13.2"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Generate the README flow figures.
|
||||
*
|
||||
* The SPA draws these as Vue + CSS with PrimeIcons webfont glyphs
|
||||
* (`src/manager/components/docs/PipelineDiagram.vue`, framed by
|
||||
* `DocFigure.vue`). A README can run neither: GitHub serves the file through
|
||||
* `<img>`, so there is no component runtime and no icon font.
|
||||
*
|
||||
* So this reproduces that figure in SVG, deriving every value from the real
|
||||
* component rather than approximating it by eye:
|
||||
*
|
||||
* frame --wp-bg-1 / --wp-border, radius --wp-radius-lg (12)
|
||||
* card --wp-bg-2, --wp-border-strong, radius 10, min-width 120,
|
||||
* column layout: icon 16 / name 12 semibold / id 9.5 mono dim
|
||||
* WP card border tinted with --wp-node, icon in --wp-node
|
||||
* feeders pills, --wp-bg-2 + --wp-border, 10.5px, icon in the kind colour
|
||||
* labels 9.5px uppercase, letter-spacing .04em, --wp-text-dim
|
||||
* var chips mono 10.5px, pill, per-chip accent border
|
||||
*
|
||||
* Icons are the REAL PrimeIcons paths from `primeicons/raw-svg` — the same
|
||||
* source the webfont is built from — so the README shows the same glyphs the
|
||||
* app does.
|
||||
*
|
||||
* Both themes ship in one file: GitHub honours `prefers-color-scheme` inside
|
||||
* an SVG served to `<img>`, so the light block below mirrors the SPA's light
|
||||
* theme rather than washing the dark one out.
|
||||
*
|
||||
* Run: node scripts/build-flow-figures.mjs
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ICON_DIR = "node_modules/primeicons/raw-svg";
|
||||
const OUT_DIR = "public/images/docs";
|
||||
|
||||
/* ── tokens, lifted from src/components/shared/theme.css + manager tokens ── */
|
||||
const DARK = {
|
||||
frameBg: "#1e1e1e", frameBd: "#3a3a3a",
|
||||
cardBg: "#232323", cardBd: "#4a4a4a",
|
||||
node: "#2dd4bf",
|
||||
text: "#dddddd", muted: "#aaaaaa", dim: "#666666",
|
||||
wildcard: "#a78bfa", fixed: "#22d3ee", combine: "#34d399",
|
||||
derivation: "#fbbf24", constraint: "#f472b6",
|
||||
chipA: "#c4b5fd", chipAb: "#8b5cf6",
|
||||
chipB: "#67e8f9", chipBb: "#22d3ee",
|
||||
chipC: "#86efac", chipCb: "#34d399",
|
||||
};
|
||||
const LIGHT = {
|
||||
frameBg: "#f5f5f5", frameBd: "#b0b0b0",
|
||||
cardBg: "#ffffff", cardBd: "#999999",
|
||||
node: "#0f766e",
|
||||
text: "#1a1a1a", muted: "#555555", dim: "#888888",
|
||||
wildcard: "#6d28d9", fixed: "#1d4ed8", combine: "#065f46",
|
||||
derivation: "#92400e", constraint: "#9d174d",
|
||||
chipA: "#6d28d9", chipAb: "#8b5cf6",
|
||||
chipB: "#1d4ed8", chipBb: "#22d3ee",
|
||||
chipC: "#065f46", chipCb: "#34d399",
|
||||
};
|
||||
|
||||
const SANS = "'Segoe UI', -apple-system, BlinkMacSystemFont, system-ui, sans-serif";
|
||||
const MONO = "'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace";
|
||||
|
||||
/** Rough advance width. Good enough to size pills; every label is centred, so
|
||||
* a few px of slop never misaligns text against its box. */
|
||||
const textW = (s, px, mono = false) => s.length * px * (mono ? 0.6 : 0.52);
|
||||
|
||||
/** Inner markup of a PrimeIcon, scaled to `size` and centred on (cx, cy).
|
||||
*
|
||||
* Colour comes from the wrapping `<g fill="currentColor">`, NOT from rewriting
|
||||
* each path. Most PrimeIcons declare no `fill` at all (only `sparkles` does),
|
||||
* so a rewrite-what-exists approach leaves them at SVG's default black — which
|
||||
* is exactly how every node icon came out black while the feeder pills, whose
|
||||
* icons happened to carry fills, coloured correctly.
|
||||
*
|
||||
* `fill="none"` is preserved: on the icons that use it, it marks the part that
|
||||
* is deliberately hollow, and filling it would blob the glyph.
|
||||
*/
|
||||
function icon(name, cx, cy, size) {
|
||||
const raw = fs.readFileSync(path.join(ICON_DIR, `${name}.svg`), "utf8");
|
||||
const inner = raw
|
||||
.replace(/^[\s\S]*?<svg[^>]*>/, "")
|
||||
.replace(/<\/svg>\s*$/, "")
|
||||
.replace(/\s(?:width|height)="[^"]*"/g, "")
|
||||
// Drop concrete fills so the group's currentColor wins; keep `none`.
|
||||
.replace(/\sfill="(?!none)[^"]*"/g, "")
|
||||
.replace(/\sstroke="(?!none)[^"]*"/g, ' stroke="currentColor"')
|
||||
.trim();
|
||||
const s = size / 24;
|
||||
return `<g fill="currentColor" transform="translate(${cx - size / 2} ${cy - size / 2}) scale(${s})">${inner}</g>`;
|
||||
}
|
||||
|
||||
/** A node card. Vertical, centred: icon, name, mono subtitle — the SPA's
|
||||
* `.wp-doc-diagram__node-box`. `wp` cards take the teal node accent. */
|
||||
function card(cx, top, label, sub, iconName, kind = "wp") {
|
||||
const w = Math.max(124, textW(label, 12) + 34, textW(sub, 9.5, true) + 26);
|
||||
const h = 66;
|
||||
const x = cx - w / 2;
|
||||
return {
|
||||
w, h,
|
||||
svg: [
|
||||
`<rect class="card card--${kind}" x="${x}" y="${top}" width="${w}" height="${h}" rx="10"/>`,
|
||||
`<g class="ico ico--${kind}">${icon(iconName, cx, top + 19, 16)}</g>`,
|
||||
`<text class="nm" x="${cx}" y="${top + 43}" text-anchor="middle">${label}</text>`,
|
||||
`<text class="id" x="${cx}" y="${top + 57}" text-anchor="middle">${sub}</text>`,
|
||||
].join("\n "),
|
||||
};
|
||||
}
|
||||
|
||||
/** Labelled connector, matching `.wp-doc-diagram__arrow`: uppercase label
|
||||
* above a hairline that ends in a small solid head. */
|
||||
function connector(x1, x2, y, label) {
|
||||
const mid = (x1 + x2) / 2;
|
||||
return [
|
||||
label ? `<text class="lbl" x="${mid}" y="${y - 9}" text-anchor="middle">${label}</text>` : "",
|
||||
// Gradient fill, not a stroke: the SPA's `.wp-doc-diagram__arrow-ln` fades
|
||||
// in and out at both ends so a connector reads as a flow rather than a
|
||||
// drawn border. It has to be a RECT — a horizontal <line> has a zero-height
|
||||
// bounding box, so an objectBoundingBox gradient on it collapses and the
|
||||
// line disappears entirely, leaving a floating arrowhead.
|
||||
`<rect fill="url(#flow)" x="${x1}" y="${y - 0.8}" width="${x2 - 6 - x1}" height="1.6"/>`,
|
||||
`<path class="head" d="M${x2 - 6} ${y - 4.5} L${x2} ${y} L${x2 - 6} ${y + 4.5} Z"/>`,
|
||||
].join("\n ");
|
||||
}
|
||||
|
||||
/** Pill row of the five module kinds — `.wp-doc-diagram__feeder`. */
|
||||
function feeders(cx, y) {
|
||||
const items = [
|
||||
["sparkles", "Wildcard", "wildcard"],
|
||||
["tag", "Fixed Values", "fixed"],
|
||||
["link", "Combine", "combine"],
|
||||
["arrow-right-arrow-left", "Derivation", "derivation"],
|
||||
["filter", "Constraint", "constraint"],
|
||||
];
|
||||
const gap = 6;
|
||||
const pills = items.map(([ico, label, kind]) => {
|
||||
const w = 9 + 10 + 6 + textW(label, 10.5) + 9;
|
||||
return { ico, label, kind, w };
|
||||
});
|
||||
const total = pills.reduce((a, p) => a + p.w, 0) + gap * (pills.length - 1);
|
||||
let x = cx - total / 2;
|
||||
const out = pills.map((p) => {
|
||||
const s = [
|
||||
`<rect class="pill" x="${x}" y="${y}" width="${p.w}" height="21" rx="10.5"/>`,
|
||||
`<g class="ico--${p.kind}">${icon(p.ico, x + 15, y + 10.5, 10)}</g>`,
|
||||
`<text class="feeder" x="${x + 24}" y="${y + 14}">${p.label}</text>`,
|
||||
].join("\n ");
|
||||
x += p.w + gap;
|
||||
return s;
|
||||
});
|
||||
return out.join("\n ");
|
||||
}
|
||||
|
||||
/** The `$vars` stage: chips over a small uppercase caption. */
|
||||
function varBag(cx, top) {
|
||||
const chips = [["$subject", "a"], ["$style", "b"], ["$lighting", "c"]];
|
||||
const gap = 5;
|
||||
const sized = chips.map(([t, v]) => ({ t, v, w: textW(t, 10.5, true) + 14 }));
|
||||
// Two rows, mirroring the SPA's 150px-capped wrapping bag.
|
||||
const row1 = sized.slice(0, 2);
|
||||
const row2 = sized.slice(2);
|
||||
const line = (items, y) => {
|
||||
const total = items.reduce((a, c) => a + c.w, 0) + gap * (items.length - 1);
|
||||
let x = cx - total / 2;
|
||||
return items.map((c) => {
|
||||
const s = [
|
||||
`<rect class="chip chip--${c.v}" x="${x}" y="${y}" width="${c.w}" height="19" rx="9.5"/>`,
|
||||
`<text class="chipt chipt--${c.v}" x="${x + c.w / 2}" y="${y + 13}" text-anchor="middle">${c.t}</text>`,
|
||||
].join("\n ");
|
||||
x += c.w + gap;
|
||||
return s;
|
||||
}).join("\n ");
|
||||
};
|
||||
return [
|
||||
line(row1, top),
|
||||
line(row2, top + 24),
|
||||
`<text class="varcap" x="${cx}" y="${top + 62}" text-anchor="middle">RESOLVED $VARS</text>`,
|
||||
].join("\n ");
|
||||
}
|
||||
|
||||
function themeBlock(t, prefix = "") {
|
||||
return `
|
||||
${prefix}.frame { fill: ${t.frameBg}; stroke: ${t.frameBd}; }
|
||||
${prefix}.card { fill: ${t.cardBg}; stroke: ${t.cardBd}; }
|
||||
${prefix}.card--wp { stroke: ${t.node}; stroke-opacity: .55; }
|
||||
${prefix}.ico--wp { color: ${t.node}; }
|
||||
${prefix}.ico--ext { color: ${t.muted}; }
|
||||
${prefix}.ico--wildcard { color: ${t.wildcard}; }
|
||||
${prefix}.ico--fixed { color: ${t.fixed}; }
|
||||
${prefix}.ico--combine { color: ${t.combine}; }
|
||||
${prefix}.ico--derivation { color: ${t.derivation}; }
|
||||
${prefix}.ico--constraint { color: ${t.constraint}; }
|
||||
${prefix}.pill { fill: ${t.cardBg}; stroke: ${t.frameBd}; }
|
||||
${prefix}.feeder { fill: ${t.muted}; }
|
||||
${prefix}.nm { fill: ${t.text}; }
|
||||
${prefix}.id, ${prefix}.lbl, ${prefix}.cap, ${prefix}.varcap { fill: ${t.dim}; }
|
||||
${prefix}.ln { stroke: ${t.cardBd}; }
|
||||
${prefix}.wash { stop-color: ${t.node}; }
|
||||
${prefix}.flow0 { stop-color: ${t.cardBd}; }
|
||||
${prefix}.head { fill: ${t.cardBd}; }
|
||||
${prefix}.chip { fill: ${t.cardBg}; }
|
||||
${prefix}.chip--a { stroke: ${t.chipAb}; stroke-opacity: .4; }
|
||||
${prefix}.chip--b { stroke: ${t.chipBb}; stroke-opacity: .4; }
|
||||
${prefix}.chip--c { stroke: ${t.chipCb}; stroke-opacity: .4; }
|
||||
${prefix}.chipt--a { fill: ${t.chipA}; }
|
||||
${prefix}.chipt--b { fill: ${t.chipB}; }
|
||||
${prefix}.chipt--c { fill: ${t.chipC}; }`;
|
||||
}
|
||||
|
||||
function shell(w, h, label, body) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="${label}">
|
||||
<style><![CDATA[
|
||||
.frame, .card, .pill, .chip { stroke-width: 1; }
|
||||
.cap { font: 400 10px ${SANS}; letter-spacing: .06em; }
|
||||
.feeder { font: 400 10.5px ${SANS}; }
|
||||
.nm { font: 600 12px ${SANS}; }
|
||||
.id { font: 400 9.5px ${MONO}; }
|
||||
.lbl, .varcap { font: 400 9.5px ${SANS}; letter-spacing: .05em; }
|
||||
.chipt { font: 400 10.5px ${MONO}; }
|
||||
${themeBlock(DARK)}
|
||||
@media (prefers-color-scheme: light) {${themeBlock(LIGHT, " ")}
|
||||
}
|
||||
]]></style>
|
||||
<defs>
|
||||
<!-- Mirrors the SPA frame wash. The CSS is an ellipse 120 percent of the
|
||||
width by 100 percent of the HEIGHT, anchored top-left — so it spans
|
||||
the whole panel. Squashing it by the aspect ratio instead pins it to
|
||||
the corner and leaves the rest looking flat. -->
|
||||
<radialGradient id="wash" cx="0" cy="0" r="1" gradientTransform="scale(1.2 1)">
|
||||
<stop class="wash" offset="0" stop-opacity="0.10"/>
|
||||
<stop class="wash" offset="0.6" stop-opacity="0.02"/>
|
||||
<stop class="wash" offset="1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="flowv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.25" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="flow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop class="flow0" offset="0" stop-opacity="0"/>
|
||||
<stop class="flow0" offset="0.2" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="0.8" stop-opacity="1"/>
|
||||
<stop class="flow0" offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect class="frame" x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="12"/>
|
||||
<rect x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="12" fill="url(#wash)"/>
|
||||
${body}
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── figure 1 — what the pipeline does ─────────────────────────────────── */
|
||||
function buildIntro() {
|
||||
const W = 880;
|
||||
const H = 202;
|
||||
const cx = W / 2;
|
||||
const rowTop = 104;
|
||||
const midY = rowTop + 33;
|
||||
|
||||
const ctx = card(112, rowTop, "WP Context", "rolls + resolves", "sitemap");
|
||||
const asm = card(566, rowTop, "Prompt Assembler", '"A $style portrait…"', "align-left");
|
||||
const clip = card(772, rowTop, "CLIP Encode", "conditioning", "bolt", "ext");
|
||||
const varsCx = 348;
|
||||
|
||||
const body = [
|
||||
`<text class="cap" x="${cx}" y="30" text-anchor="middle">MODULES INSIDE THE CONTEXT</text>`,
|
||||
feeders(cx, 40),
|
||||
`<g class="ico--ext">${icon("angle-down", cx, 78, 12)}</g>`,
|
||||
ctx.svg,
|
||||
connector(112 + ctx.w / 2 + 8, varsCx - 78, midY, "EMITS"),
|
||||
varBag(varsCx, rowTop - 2),
|
||||
connector(varsCx + 78, 566 - asm.w / 2 - 8, midY, "FILLS"),
|
||||
asm.svg,
|
||||
connector(566 + asm.w / 2 + 8, 772 - clip.w / 2 - 8, midY, "PROMPT"),
|
||||
clip.svg,
|
||||
].join("\n ");
|
||||
|
||||
return shell(
|
||||
W,
|
||||
H,
|
||||
"Wildcard, Fixed Values, Combine, Derivation and Constraint modules resolve inside WP Context, which emits resolved dollar-variables that fill the WP Prompt Assembler template, which CLIP Encode turns into conditioning",
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ── figure 2 — looping a chain ────────────────────────────────────────────
|
||||
* The old figure ran the assembled prompt straight into KSampler. It does not
|
||||
* go there: text is encoded first and only conditioning reaches the sampler.
|
||||
* Seeds are the one thing that DOES arrive directly, so they get their own
|
||||
* lane BELOW the text path rather than cutting through it. */
|
||||
function buildLoop() {
|
||||
const W = 880;
|
||||
const H = 286;
|
||||
const cx = W / 2;
|
||||
const r1 = 54;
|
||||
const r2 = 158;
|
||||
const mid1 = r1 + 33;
|
||||
const mid2 = r2 + 33;
|
||||
const laneY = 252;
|
||||
|
||||
const loop = card(112, r1, "WP Context Loop", "N iterations", "refresh");
|
||||
const seeds = card(112, r2, "WP Seed List", "N seeds", "list");
|
||||
const ctx = card(340, r1, "WP Context", "module stack", "sitemap");
|
||||
const asm = card(566, r1, "Prompt Assembler", "N prompts", "align-left");
|
||||
const clip = card(566, r2, "CLIP Encode", "N conditionings", "bolt", "ext");
|
||||
const ks = card(772, r2, "KSampler", "N images", "image", "ext");
|
||||
|
||||
const body = [
|
||||
`<text class="cap" x="${cx}" y="30" text-anchor="middle">BOTH LISTS FAN OUT IN LOCKSTEP — ITERATION N MEETS SEED N</text>`,
|
||||
loop.svg, seeds.svg, ctx.svg, asm.svg, clip.svg, ks.svg,
|
||||
connector(112 + loop.w / 2 + 8, 340 - ctx.w / 2 - 8, mid1, "CONTEXT"),
|
||||
// WP_ContextLoop has a `loop_config` side-output and WP_SeedList takes it
|
||||
// as an optional input, so the seed list mirrors the loop's count,
|
||||
// strategy and base seed. That link is what makes the caption's "lockstep"
|
||||
// true; without it the two read as unrelated sources.
|
||||
`<text class="lbl" x="122" y="${(r1 + 66 + r2) / 2 + 4}">LOOP_CONFIG</text>`,
|
||||
`<rect fill="url(#flowv)" x="111.2" y="${r1 + 66}" width="1.6" height="${r2 - 6 - (r1 + 66)}"/>`,
|
||||
`<path class="head" d="M108 ${r2 - 6} L112 ${r2} L116 ${r2 - 6} Z"/>`,
|
||||
connector(340 + ctx.w / 2 + 8, 566 - asm.w / 2 - 8, mid1, "$VARS"),
|
||||
// assembler drops straight down into the encoder
|
||||
`<text class="lbl" x="576" y="141">PROMPT</text>`,
|
||||
`<rect fill="url(#flowv)" x="565.2" y="${r1 + 66}" width="1.6" height="${r2 - 6 - (r1 + 66)}"/>`,
|
||||
`<path class="head" d="M562 ${r2 - 6} L566 ${r2} L570 ${r2 - 6} Z"/>`,
|
||||
connector(566 + clip.w / 2 + 8, 772 - ks.w / 2 - 8, mid2, "COND"),
|
||||
// seeds take their own lane under the text path and rise into the sampler
|
||||
`<path class="ln" fill="none" d="M112 ${r2 + 66} V ${laneY} H 772 V ${r2 + 72}"/>`,
|
||||
`<path class="head" d="M768 ${r2 + 72} L772 ${r2 + 66} L776 ${r2 + 72} Z"/>`,
|
||||
`<text class="lbl" x="420" y="${laneY - 8}" text-anchor="middle">SEED[]</text>`,
|
||||
].join("\n ");
|
||||
|
||||
return shell(
|
||||
W,
|
||||
H,
|
||||
"WP Context Loop drives WP Context and the Prompt Assembler to produce N prompts, CLIP Encode turns them into conditioning for KSampler, and WP Seed List supplies the matching seed for each iteration directly to KSampler",
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(OUT_DIR, "flow-intro.svg"), buildIntro());
|
||||
fs.writeFileSync(path.join(OUT_DIR, "flow-loop.svg"), buildLoop());
|
||||
console.log("wrote flow-intro.svg + flow-loop.svg");
|
||||
@@ -157,7 +157,97 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// parity test) now; the upcoming reach/constraint-matrix UI phases need the
|
||||
// total budget headroom in the lazy ContextWidget chunk. Approved alongside
|
||||
// the SP3 constraint feature.
|
||||
const TOTAL_LIMIT = 430 * 1024; // 430 KB
|
||||
// Bumped 430 -> 431 KB on 2026-06-18 — two same-day landings share the
|
||||
// ceiling (both CSS/glue; the manager-only bulk UI adds nothing to js/):
|
||||
// - editor robustness: RichTextInput renderEpoch crash fix + useEditorDraft
|
||||
// flush-on-unmount/unload + ErrorBoundary copy + clipboard-shield wired
|
||||
// into _shared.ts / main.ts (Ctrl-A/C/V/X guard on editable inputs).
|
||||
// - instance-modal scroll/sticky: sticky header + runtime/footer dock +
|
||||
// own-scrollbar inner lists (options/values/rules/exceptions) + compact
|
||||
// exceptions trash, across all 6 edit modals.
|
||||
// Bumped 431 -> 432 KB on 2026-06-18 for the syntax + UI fix batch: inline
|
||||
// `{N::a|b}` branch weights (engine + TS resolver mirror), the filter / pick /
|
||||
// exclude-null MOD-dot fix, the "X of Y options" + "picks N-M" wildcard
|
||||
// subtitle, the library-aware nested-ref check (kills the false BROKEN REF),
|
||||
// and the constraint source-missing debug warning + label. ~0.4 KB net gzip
|
||||
// across the ContextWidget + conflicts chunks; entry stays put.
|
||||
// Bumped 432 -> 435 KB on 2026-06-22 for the canvas constraint-matrix grouping
|
||||
// parity. MatrixSection (the ComfyUI instance-modal grid) gains the SPA's
|
||||
// grouped-axis layout — column bands, row header chips, solo eyebrow, the
|
||||
// labelled "uncategorized" bucket, and frozen headers + both-axis scroll —
|
||||
// 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.
|
||||
// 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.
|
||||
// Bumped 453 -> 461 KB on 2026-07-23 for the chip-UX + library-linkage feature
|
||||
// set (issues #1-#9). Workstream B (RefChip hover card: uuid + filter reads-as
|
||||
// + live "N of M options match" from the extended preview-resolver) landed the
|
||||
// canvas within 10 bytes of the 453 ceiling; the +8 KB gives the remaining
|
||||
// workstreams D (library re-link UI) and E (nested-ref propagation) room in the
|
||||
// same approved feature set. Explicitly approved.
|
||||
// Bumped 461 -> 468 KB on 2026-07-30 for the QoL sweep (P0+P1). The bulk is UI
|
||||
// that had to grow: export/import row detail per entity kind, the category
|
||||
// IconPicker, the feedback modal, cross-bucket search, collapsible tag pools,
|
||||
// and finally `collectUpstreamProducers` + the var-chip producer card, which
|
||||
// tipped it 180 bytes over. Explicitly approved by the maintainer, who asked
|
||||
// for the budget to be raised as needed rather than features trimmed to fit.
|
||||
// Bumped 468 -> 490 KB on 2026-08-02 for the `@`/`$` autocomplete + sub-category
|
||||
// filter redesign. The popover rows carry structure now (icon, uuid, option /
|
||||
// axis / tag facts, and for `$` the producer line and override badge) instead
|
||||
// of one line of text, and the shared filter panel that follows adds the
|
||||
// axis-grouped palette with collapse + search, the live match count and the
|
||||
// zero-match / unknown-term states. Sized with headroom deliberately: the
|
||||
// previous bump left 179 bytes free, which meant every subsequent change had
|
||||
// to argue with the gate. Explicitly approved by the maintainer, who asked for
|
||||
// the budget to be raised as needed rather than features trimmed to fit.
|
||||
const TOTAL_LIMIT = 490 * 1024; // 490 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post a release announcement to Discord.
|
||||
*
|
||||
* Derives the message from the release notes that already exist — there is
|
||||
* nothing extra to write. Run by `.github/workflows/discord-announce.yml` when
|
||||
* a release is published, and runnable by hand for the catch-up case.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 # dry run
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 --post # → staff
|
||||
* node scripts/discord-announce.mjs --version v2.12.0 --post --channel public
|
||||
* node scripts/discord-announce.mjs --since v2.10.2 --post # catch-up
|
||||
* node scripts/discord-announce.mjs --notes-file docs/release-notes/next.md \
|
||||
* --version v2.12.0 # preview
|
||||
*
|
||||
* DRY RUN IS THE DEFAULT. `--post` is required to send anything, so previewing
|
||||
* an announcement can never accidentally publish one.
|
||||
*
|
||||
* Environment:
|
||||
* DISCORD_WEBHOOK_STAFF staff / pre-announcement channel (the default)
|
||||
* DISCORD_WEBHOOK_PUBLIC public announcement channel (--channel public only)
|
||||
* DISCORD_WEBHOOK_URL legacy fallback for the staff webhook
|
||||
* DISCORD_ROLE_ID optional; pinged as <@&ID>, PUBLIC channel only
|
||||
* DISCORD_USERNAME optional; overrides the posting name
|
||||
* DISCORD_AVATAR_URL optional; overrides the posting avatar (raster only)
|
||||
* GITHUB_TOKEN optional; raises the API rate limit for --since
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { buildPayload, buildRollupPayload, normalizeRoleId } from "./lib/discord-notes.mjs";
|
||||
|
||||
const REPO = "DumiFlex/ComfyUI-Wildcard-Pipeline";
|
||||
|
||||
/**
|
||||
* Two channels, deliberately asymmetric.
|
||||
*
|
||||
* `staff` posts automatically on every release and carries a link to publish
|
||||
* the same message onward — it is the review step. `public` only ever runs
|
||||
* from a deliberate manual dispatch, so a release can never announce itself to
|
||||
* everyone without someone having read it first.
|
||||
*/
|
||||
const CHANNELS = {
|
||||
staff: ["DISCORD_WEBHOOK_STAFF", "DISCORD_WEBHOOK_URL"],
|
||||
public: ["DISCORD_WEBHOOK_PUBLIC"],
|
||||
};
|
||||
|
||||
function webhookFor(channel) {
|
||||
for (const name of CHANNELS[channel]) {
|
||||
if (process.env[name]) return { url: process.env[name], via: name };
|
||||
}
|
||||
return { url: null, via: CHANNELS[channel][0] };
|
||||
}
|
||||
|
||||
function arg(name, fallback = undefined) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const next = process.argv[i + 1];
|
||||
return next && !next.startsWith("--") ? next : true;
|
||||
}
|
||||
|
||||
function ghHeaders() {
|
||||
const h = { Accept: "application/vnd.github+json", "User-Agent": "wp-discord-announce" };
|
||||
if (process.env.GITHUB_TOKEN) h.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function fetchRelease(tag) {
|
||||
const url = tag
|
||||
? `https://api.github.com/repos/${REPO}/releases/tags/${tag}`
|
||||
: `https://api.github.com/repos/${REPO}/releases/latest`;
|
||||
const res = await fetch(url, { headers: ghHeaders() });
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status} fetching ${tag ?? "latest"}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Releases newer than `sinceTag`, newest first. Drafts and prereleases skipped. */
|
||||
async function fetchReleasesSince(sinceTag) {
|
||||
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100`, {
|
||||
headers: ghHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status} listing releases`);
|
||||
const all = (await res.json()).filter((r) => !r.draft && !r.prerelease);
|
||||
const idx = all.findIndex((r) => r.tag_name === sinceTag);
|
||||
if (idx === -1) throw new Error(`No release tagged ${sinceTag}`);
|
||||
return all.slice(0, idx);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const post = arg("post", false) === true;
|
||||
const channel = arg("channel", "staff");
|
||||
if (channel !== "staff" && channel !== "public") {
|
||||
console.error(`Unknown --channel "${channel}". Use staff or public.`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Belt to the workflow's braces. The workflow only passes `--channel public`
|
||||
// on a manual dispatch, but a release event reaching this line with public
|
||||
// selected would announce to everyone with nobody having read it.
|
||||
if (channel === "public" && process.env.GITHUB_EVENT_NAME === "release") {
|
||||
console.error("Refusing to post to the public channel from a release event.");
|
||||
process.exit(1);
|
||||
}
|
||||
// Only the public post pings. A role ping in the staff channel on every
|
||||
// release is noise in a room that already exists to watch releases.
|
||||
const rawRole = channel === "public" ? process.env.DISCORD_ROLE_ID : undefined;
|
||||
const roleId = normalizeRoleId(rawRole);
|
||||
// `buildPayload` drops a malformed id either way, but silence would leave
|
||||
// someone wondering why the ping never fired.
|
||||
if (rawRole && rawRole.trim() && !roleId) {
|
||||
console.warn(
|
||||
`DISCORD_ROLE_ID is not a valid role id (${JSON.stringify(rawRole)}) — posting without a ping.`,
|
||||
);
|
||||
}
|
||||
// Undefined falls through to the defaults in discord-notes.mjs — an empty
|
||||
// env var must not blank the identity and hand the post back to the
|
||||
// webhook's own name.
|
||||
const username = process.env.DISCORD_USERNAME || undefined;
|
||||
const avatarUrl = process.env.DISCORD_AVATAR_URL || undefined;
|
||||
const since = arg("since");
|
||||
const notesFile = arg("notes-file");
|
||||
let version = arg("version");
|
||||
|
||||
let payload;
|
||||
|
||||
if (typeof since === "string") {
|
||||
const releases = await fetchReleasesSince(since);
|
||||
if (!releases.length) {
|
||||
console.error(`Nothing published after ${since} — nothing to announce.`);
|
||||
process.exit(0);
|
||||
}
|
||||
payload = buildRollupPayload({
|
||||
releases: releases.map((r) => ({ version: r.tag_name, body: r.body ?? "" })),
|
||||
sinceVersion: since,
|
||||
releaseUrl: releases[0].html_url,
|
||||
roleId,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
} else if (typeof notesFile === "string") {
|
||||
// Local preview against unreleased notes. No network, so the compare link
|
||||
// is unknown and simply omitted.
|
||||
payload = buildPayload({
|
||||
version: version || "(unreleased)",
|
||||
body: readFileSync(notesFile, "utf8"),
|
||||
roleId,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
} else {
|
||||
const rel = await fetchRelease(typeof version === "string" ? version : undefined);
|
||||
version = rel.tag_name;
|
||||
payload = buildPayload({
|
||||
version,
|
||||
body: rel.body ?? "",
|
||||
releaseUrl: rel.html_url,
|
||||
roleId,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
console.log("--- DRY RUN (pass --post to send) ---\n");
|
||||
// Identity is part of what a preview is for: it is the thing that was
|
||||
// wrong on the first real post, and it is invisible in the embed body.
|
||||
console.log(`channel: ${channel}`);
|
||||
console.log(`posting as: ${payload.username}`);
|
||||
console.log(`avatar: ${payload.avatar_url}`);
|
||||
console.log("");
|
||||
console.log(payload.embeds[0].title);
|
||||
console.log("-".repeat(60));
|
||||
console.log(payload.embeds[0].description);
|
||||
console.log("-".repeat(60));
|
||||
console.log(`${payload.embeds[0].description.length} / 4096 chars`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { url: webhook, via } = webhookFor(channel);
|
||||
if (!webhook) {
|
||||
console.error(`${via} is not set — refusing to post to the ${channel} channel.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const res = await fetch(`${webhook}?wait=true`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Discord ${res.status}: ${await res.text()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Announced ${payload.embeds[0].title} → ${channel}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Release notes → Discord message.
|
||||
*
|
||||
* The announcement is DERIVED, never authored. `docs/release-notes/next.md` is
|
||||
* already written by hand for every release and already is the GitHub release
|
||||
* body — so the only reason a Discord post was a separate chore is that nothing
|
||||
* transformed one into the other. Nothing here asks for new prose.
|
||||
*
|
||||
* Discord is not a markdown renderer in the way GitHub is, and the gaps are
|
||||
* specific rather than general:
|
||||
*
|
||||
* - Masked links `[text](url)` render ONLY inside an embed. In plain message
|
||||
* content they show as literal brackets, which is why the notes go in an
|
||||
* embed and the message content stays a bare ping.
|
||||
* - There are no tables. A pipe table degrades into unreadable pipes.
|
||||
* - `<details>` is HTML; it shows as raw tags.
|
||||
* - Embed description caps at 4096 characters, and the whole payload at 6000.
|
||||
*/
|
||||
|
||||
/** Everything below this marker is the long tail; the SPA modal cuts here too. */
|
||||
const MODAL_CUT = /<!--\s*\/modal\s*-->/i;
|
||||
|
||||
/** Discord's hard cap on a single embed description. */
|
||||
export const EMBED_DESCRIPTION_LIMIT = 4096;
|
||||
|
||||
/** Accent purple, matching `--wp-accent-500` in the manager. */
|
||||
export const BRAND_COLOR = 0x8b5cf6;
|
||||
|
||||
/**
|
||||
* Who the post appears to be from.
|
||||
*
|
||||
* A webhook posts under whatever name and avatar it was created with — the
|
||||
* Discord default is a generic name like "Captain Hook" and a placeholder
|
||||
* image, which is what an announcement went out as. `username` and
|
||||
* `avatar_url` override that PER MESSAGE, so the identity lives here rather
|
||||
* than in a webhook's settings: it stays right if the webhook is ever rotated
|
||||
* or recreated, and it is the same in every channel the automation posts to.
|
||||
*/
|
||||
export const WEBHOOK_USERNAME = "Wildcard Pipeline";
|
||||
|
||||
/**
|
||||
* Discord fetches this server-side, so it has to be a public raster URL — an
|
||||
* SVG will not render, which rules out the repo's `favicon.svg`. Pinned to
|
||||
* `main` rather than a tag so replacing the logo does not need a code change.
|
||||
*/
|
||||
export const WEBHOOK_AVATAR_URL =
|
||||
"https://raw.githubusercontent.com/DumiFlex/ComfyUI-Wildcard-Pipeline/main/public/images/web-app-manifest-512x512.png";
|
||||
|
||||
/**
|
||||
* A role id we are willing to turn into a ping, or `undefined`.
|
||||
*
|
||||
* An unset secret was always safe, but a MALFORMED one was not: any truthy
|
||||
* string went straight into `<@&…>`, so a stray space produced a literal
|
||||
* `<@& >` at the top of the announcement and a pasted mention produced
|
||||
* `<@&<@&123>>`. Both render as visible junk in the most public message the
|
||||
* project sends.
|
||||
*
|
||||
* Accepts a bare snowflake or the `<@&123>` form, since "Copy ID" and copying
|
||||
* the mention text are equally easy mistakes to make. Anything else is
|
||||
* rejected rather than guessed at — a ping that silently does not happen is a
|
||||
* much smaller problem than a broken one that does.
|
||||
*/
|
||||
export function normalizeRoleId(raw) {
|
||||
if (typeof raw !== "string" && typeof raw !== "number") return undefined;
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return undefined;
|
||||
const unwrapped = trimmed.replace(/^<@&(\d+)>$/, "$1");
|
||||
// Discord snowflakes are 17-20 digits today; the range leaves room without
|
||||
// accepting arbitrary numbers.
|
||||
return /^\d{17,20}$/.test(unwrapped) ? unwrapped : undefined;
|
||||
}
|
||||
|
||||
/** Where the manual "publish to the public channel" run is started. */
|
||||
export const DISPATCH_URL =
|
||||
"https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/actions/workflows/discord-announce.yml";
|
||||
|
||||
/**
|
||||
* Split a release body into the part worth announcing and what is left behind.
|
||||
*
|
||||
* The cut is the same one the in-app update dialog makes, for the same reason:
|
||||
* an announcement is an interruption and should stay short. The tail is not
|
||||
* discarded silently — `tailCount` lets the message say how much it is not
|
||||
* showing, so a big release does not read as a small one.
|
||||
*/
|
||||
export function splitBody(md) {
|
||||
const at = String(md ?? "").search(MODAL_CUT);
|
||||
if (at === -1) return { head: String(md ?? ""), tail: "", tailCount: 0 };
|
||||
const head = md.slice(0, at);
|
||||
// Count against comment-stripped text, NOT details-stripped text. The
|
||||
// "Also in this release" bullets live INSIDE the <details> block, so
|
||||
// stripping it first counts nothing real — while the authoring guidance in
|
||||
// the trailing HTML comment contains bullets of its own that must not be
|
||||
// counted. Getting this backwards reported a 26-change release as 3.
|
||||
const tail = stripComments(md.slice(at));
|
||||
const tailCount = (tail.match(/^\s*[-*]\s+\S/gm) ?? []).length;
|
||||
return { head, tail, tailCount };
|
||||
}
|
||||
|
||||
function stripDetails(md) {
|
||||
return md.replace(/<details[\s\S]*?<\/details>/gi, "");
|
||||
}
|
||||
|
||||
function stripComments(md) {
|
||||
return md.replace(/<!--[\s\S]*?-->/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the header block the GitHub template prepends.
|
||||
*
|
||||
* It is a row of links to the wiki, Discord and the issue tracker. On Discord
|
||||
* the Discord link is noise, and the rest belong in the embed's own fields
|
||||
* rather than buried in the body.
|
||||
*/
|
||||
function stripTemplateHeader(md) {
|
||||
return md
|
||||
.replace(/^\s*##\s*🎉[^\n]*\n/, "")
|
||||
.replace(/^\s*(?:📖|💬|📦|🐛)[^\n]*\n/gm, "");
|
||||
}
|
||||
|
||||
/** Pipe tables have no Discord equivalent; each row becomes a bullet. */
|
||||
function tablesToBullets(md) {
|
||||
const lines = md.split("\n");
|
||||
const out = [];
|
||||
for (const line of lines) {
|
||||
const isRow = /^\s*\|.*\|\s*$/.test(line);
|
||||
if (!isRow) { out.push(line); continue; }
|
||||
// Separator row (|---|---|) carries no content.
|
||||
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
|
||||
const cells = line.trim().slice(1, -1).split("|").map((c) => c.trim());
|
||||
if (!cells.some(Boolean)) continue;
|
||||
out.push(`- ${cells.filter(Boolean).join(" — ")}`);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Headings become bold lines.
|
||||
*
|
||||
* Discord does render `#`-style headings now, but at sizes chosen for chat
|
||||
* rather than for a dense embed — an `##` inside an embed is larger than the
|
||||
* embed title above it. Bold keeps the hierarchy legible without competing.
|
||||
*/
|
||||
function headingsToBold(md) {
|
||||
return md.replace(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/gm, (_m, text) => `**${text.trim()}**`);
|
||||
}
|
||||
|
||||
/** Horizontal rules have no embed equivalent and read as stray dashes. */
|
||||
function stripRules(md) {
|
||||
return md.replace(/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/gm, "");
|
||||
}
|
||||
|
||||
/** Collapse the blank lines the earlier passes leave behind. */
|
||||
function tidy(md) {
|
||||
return md.replace(/\n{3,}/g, "\n\n").replace(/[ \t]+$/gm, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate at a bullet boundary rather than mid-sentence.
|
||||
*
|
||||
* A hard slice at 4096 can cut inside a link or a bold run, which Discord then
|
||||
* renders as broken markup — worse than saying less.
|
||||
*/
|
||||
export function clampToLimit(text, limit = EMBED_DESCRIPTION_LIMIT, suffix = "\n\n…") {
|
||||
if (text.length <= limit) return text;
|
||||
const room = limit - suffix.length;
|
||||
const slice = text.slice(0, room);
|
||||
const cut = Math.max(slice.lastIndexOf("\n- "), slice.lastIndexOf("\n\n"));
|
||||
return (cut > room * 0.5 ? slice.slice(0, cut) : slice).trimEnd() + suffix;
|
||||
}
|
||||
|
||||
/** Full markdown → Discord-embed markdown. */
|
||||
export function toDiscordMarkdown(md) {
|
||||
return tidy(
|
||||
stripRules(
|
||||
headingsToBold(
|
||||
tablesToBullets(
|
||||
stripTemplateHeader(stripComments(stripDetails(String(md ?? "")))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the description for one release.
|
||||
*
|
||||
* `tailCount` is appended rather than dropped: a release whose highlights are
|
||||
* three bullets and whose tail is forty changes should not look like a quiet
|
||||
* week.
|
||||
*/
|
||||
export function buildDescription(body, { compareUrl } = {}) {
|
||||
const { head, tailCount } = splitBody(body);
|
||||
let out = toDiscordMarkdown(head);
|
||||
if (tailCount > 0) {
|
||||
const line = `*…plus ${tailCount} smaller change${tailCount === 1 ? "" : "s"}*`;
|
||||
out += `\n\n${compareUrl ? `[${line.slice(1, -1)}](${compareUrl})` : line}`;
|
||||
}
|
||||
return clampToLimit(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* One release → a Discord webhook payload.
|
||||
*
|
||||
* `allowed_mentions` is always explicit. A webhook with no restriction will
|
||||
* happily resolve an `@everyone` that happened to appear in release notes, and
|
||||
* the notes are not written with that in mind.
|
||||
*/
|
||||
export function buildPayload({
|
||||
version,
|
||||
body,
|
||||
releaseUrl,
|
||||
compareUrl,
|
||||
roleId: rawRoleId,
|
||||
username = WEBHOOK_USERNAME,
|
||||
avatarUrl = WEBHOOK_AVATAR_URL,
|
||||
channel = "staff",
|
||||
installHint = 'ComfyUI Manager → search "Wildcard Pipeline" → Update → restart ComfyUI',
|
||||
}) {
|
||||
const description = buildDescription(body, { compareUrl });
|
||||
// Normalised HERE rather than at the call site so every caller — CLI,
|
||||
// roll-up, future ones — gets the same guarantee.
|
||||
const roleId = normalizeRoleId(rawRoleId);
|
||||
return {
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
content: roleId ? `<@&${roleId}>` : "",
|
||||
allowed_mentions: { parse: [], roles: roleId ? [roleId] : [] },
|
||||
embeds: [
|
||||
{
|
||||
title: `Wildcard Pipeline ${version}`,
|
||||
url: releaseUrl || undefined,
|
||||
description,
|
||||
color: BRAND_COLOR,
|
||||
fields: reviewFields(channel),
|
||||
footer: { text: installHint },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff copy carries its own publish button; the public copy does not.
|
||||
*
|
||||
* This is what makes the staff channel a review step rather than a duplicate
|
||||
* feed: the post you are reading is the post that will go out, and the link to
|
||||
* send it is attached to it. Without that, "go and publish it" is a separate
|
||||
* instruction living in someone's head.
|
||||
*
|
||||
* Returned as `undefined` for the public channel rather than an empty array —
|
||||
* Discord rejects `fields: []` on some client versions, and an empty field
|
||||
* block would render as dead space in the announcement everyone sees.
|
||||
*/
|
||||
function reviewFields(channel) {
|
||||
if (channel !== "staff") return undefined;
|
||||
return [
|
||||
{
|
||||
name: "Ready to go public?",
|
||||
value:
|
||||
`[Run the workflow](${DISPATCH_URL}) with **channel: public** and ` +
|
||||
"**dry_run: false** to post this to the announcement channel.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Several releases → one catch-up payload.
|
||||
*
|
||||
* For the case that actually happens: announcements skipped for a few versions,
|
||||
* then one post covering the gap. Each release keeps its own heading so readers
|
||||
* can tell which version brought what, and the newest goes first because that is
|
||||
* the one people are deciding whether to install.
|
||||
*
|
||||
* `releases` is newest-first: `{ version, body }`.
|
||||
*/
|
||||
export function buildRollupPayload({
|
||||
releases, sinceVersion, releaseUrl, roleId, installHint, username, avatarUrl,
|
||||
channel = "staff",
|
||||
}) {
|
||||
const latest = releases[0];
|
||||
const sections = releases.map((r) => {
|
||||
const { head } = splitBody(r.body);
|
||||
const text = toDiscordMarkdown(head);
|
||||
return `**${r.version}**\n${text}`;
|
||||
});
|
||||
const joined = sections.join("\n\n");
|
||||
const base = buildPayload({
|
||||
version: latest?.version ?? "",
|
||||
body: "",
|
||||
releaseUrl,
|
||||
roleId,
|
||||
installHint,
|
||||
username,
|
||||
avatarUrl,
|
||||
channel,
|
||||
});
|
||||
base.embeds[0].title = sinceVersion
|
||||
? `Wildcard Pipeline ${latest?.version ?? ""} — everything since ${sinceVersion}`
|
||||
: `Wildcard Pipeline ${latest?.version ?? ""}`;
|
||||
base.embeds[0].description = clampToLimit(joined);
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DISPATCH_URL,
|
||||
normalizeRoleId,
|
||||
splitBody,
|
||||
toDiscordMarkdown,
|
||||
buildDescription,
|
||||
buildPayload,
|
||||
buildRollupPayload,
|
||||
clampToLimit,
|
||||
EMBED_DESCRIPTION_LIMIT,
|
||||
} from "./discord-notes.mjs";
|
||||
|
||||
/**
|
||||
* The announcement is derived from notes that already exist, so the risk is not
|
||||
* "does it write good copy" — it is "does it mangle copy that was already
|
||||
* fine". Most of these pin a Discord-specific rendering gap.
|
||||
*/
|
||||
|
||||
const REAL_SHAPE = `## 🎉 Wildcard Pipeline v2.11.0
|
||||
|
||||
📖 [Docs (wiki)](https://x/wiki) · 💬 [Discord](https://discord.gg/x) · 📦 [Install](https://x/q) · 🐛 [Issues](https://x/i)
|
||||
|
||||
### ✨ What's new
|
||||
|
||||
A correctness fix for held modules, plus tools for reconnecting workflow items.
|
||||
|
||||
- **Held modules stay held.** A module set to **Hold across run** could re-roll.
|
||||
- **Search by id.** Pasting an 8-character id now finds the item.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Also in this release
|
||||
|
||||
- Wire collapse survives a live renderer switch
|
||||
- Modal keystrokes no longer escape
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.10.2…v2.11.0](https://x/compare)
|
||||
`;
|
||||
|
||||
describe("splitBody", () => {
|
||||
it("cuts at the modal marker and counts what is left behind", () => {
|
||||
const { head, tailCount } = splitBody(REAL_SHAPE);
|
||||
expect(head).toContain("Held modules stay held");
|
||||
expect(head).not.toContain("Also in this release");
|
||||
expect(tailCount).toBe(2);
|
||||
});
|
||||
|
||||
it("treats a body with no marker as all highlights", () => {
|
||||
const { head, tailCount } = splitBody("- one\n- two");
|
||||
expect(head).toBe("- one\n- two");
|
||||
expect(tailCount).toBe(0);
|
||||
});
|
||||
|
||||
it("survives an empty or missing body", () => {
|
||||
expect(splitBody("").head).toBe("");
|
||||
expect(splitBody(undefined).head).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDiscordMarkdown", () => {
|
||||
it("drops the GitHub template header — the Discord link is noise on Discord", () => {
|
||||
const out = toDiscordMarkdown(REAL_SHAPE);
|
||||
expect(out).not.toContain("🎉 Wildcard Pipeline");
|
||||
expect(out).not.toContain("discord.gg");
|
||||
expect(out).not.toContain("Docs (wiki)");
|
||||
});
|
||||
|
||||
it("keeps masked links, which DO render inside an embed", () => {
|
||||
expect(toDiscordMarkdown("see [the docs](https://x/d)")).toContain("[the docs](https://x/d)");
|
||||
});
|
||||
|
||||
it("turns headings into bold — an embed h2 outsizes the embed title", () => {
|
||||
const out = toDiscordMarkdown("### What's new\nbody");
|
||||
expect(out).toContain("**What's new**");
|
||||
expect(out).not.toMatch(/^#/m);
|
||||
});
|
||||
|
||||
it("converts tables to bullets, since Discord has none", () => {
|
||||
const out = toDiscordMarkdown("| Item | Result |\n| --- | --- |\n| #29 | shipped |");
|
||||
expect(out).toContain("- Item — Result");
|
||||
expect(out).toContain("- #29 — shipped");
|
||||
expect(out).not.toContain("|");
|
||||
});
|
||||
|
||||
it("strips <details>, which renders as raw tags", () => {
|
||||
const out = toDiscordMarkdown("keep\n<details><summary>x</summary>\n- hidden\n</details>");
|
||||
expect(out).toContain("keep");
|
||||
expect(out).not.toContain("hidden");
|
||||
expect(out).not.toContain("<details>");
|
||||
});
|
||||
|
||||
it("strips HTML comments and horizontal rules", () => {
|
||||
const out = toDiscordMarkdown("a\n\n<!-- note -->\n\n---\n\nb");
|
||||
expect(out).not.toContain("note");
|
||||
expect(out).not.toMatch(/^-{3,}$/m);
|
||||
expect(out).toContain("a");
|
||||
expect(out).toContain("b");
|
||||
});
|
||||
|
||||
it("leaves bold, italics and bullets alone", () => {
|
||||
const out = toDiscordMarkdown("- **bold** and *italic* and `code`");
|
||||
expect(out).toBe("- **bold** and *italic* and `code`");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDescription", () => {
|
||||
// A three-bullet highlights section over a forty-change release should not
|
||||
// read as a quiet week.
|
||||
it("says how much it is not showing", () => {
|
||||
const out = buildDescription(REAL_SHAPE);
|
||||
expect(out).toMatch(/plus 2 smaller changes/);
|
||||
});
|
||||
|
||||
it("links the tail to the compare view when there is one", () => {
|
||||
const out = buildDescription(REAL_SHAPE, { compareUrl: "https://x/compare" });
|
||||
expect(out).toContain("](https://x/compare)");
|
||||
});
|
||||
|
||||
// The "Also in this release" bullets live INSIDE a <details> block, and the
|
||||
// file ends with an authoring comment that contains bullets of its own.
|
||||
// Stripping details before counting reported a real 26-change release as 3.
|
||||
it("counts the bullets inside <details>, and ignores those in comments", () => {
|
||||
const body = [
|
||||
"- highlight",
|
||||
"<!-- /modal -->",
|
||||
"<details><summary>Also in this release — 3 smaller changes</summary>",
|
||||
"- one",
|
||||
"- two",
|
||||
"- three",
|
||||
"</details>",
|
||||
"<!--",
|
||||
" - not a change, this is authoring guidance",
|
||||
" - nor is this",
|
||||
"-->",
|
||||
].join("\n");
|
||||
expect(splitBody(body).tailCount).toBe(3);
|
||||
expect(buildDescription(body)).toMatch(/plus 3 smaller changes/);
|
||||
});
|
||||
|
||||
it("says nothing about a tail that does not exist", () => {
|
||||
expect(buildDescription("- only this")).not.toMatch(/smaller change/);
|
||||
});
|
||||
|
||||
it("uses the singular for one", () => {
|
||||
expect(buildDescription("- a\n<!-- /modal -->\n- b")).toMatch(/1 smaller change\b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampToLimit", () => {
|
||||
it("leaves anything under the cap untouched", () => {
|
||||
expect(clampToLimit("short")).toBe("short");
|
||||
});
|
||||
|
||||
// A hard slice can land inside a link or a bold run, which Discord then
|
||||
// renders as broken markup — worse than saying less.
|
||||
it("cuts at a bullet boundary, not mid-markup", () => {
|
||||
const body = Array.from({ length: 400 }, (_, i) => `- item ${i} with some text`).join("\n");
|
||||
const out = clampToLimit(body);
|
||||
expect(out.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
expect(out.split("\n").at(-3)).toMatch(/^- item \d+ with some text$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPayload", () => {
|
||||
it("puts the notes in an embed, because masked links need one", () => {
|
||||
const p = buildPayload({ version: "v2.12.0", body: REAL_SHAPE, releaseUrl: "https://x/r" });
|
||||
expect(p.embeds).toHaveLength(1);
|
||||
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0");
|
||||
expect(p.embeds[0].url).toBe("https://x/r");
|
||||
expect(p.embeds[0].footer.text).toMatch(/ComfyUI Manager/);
|
||||
});
|
||||
|
||||
// Release notes are not written with @everyone in mind, and a webhook will
|
||||
// happily resolve one that slipped in.
|
||||
it("never lets the notes mention anyone by accident", () => {
|
||||
const p = buildPayload({ version: "v1", body: "ping @everyone please" });
|
||||
expect(p.allowed_mentions.parse).toEqual([]);
|
||||
expect(p.allowed_mentions.roles).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* An unset secret was always safe. A MALFORMED one was not: any truthy string
|
||||
* went straight into `<@&…>`, so a stray space produced a literal `<@& >`
|
||||
* at the top of the most public message the project sends.
|
||||
*/
|
||||
describe("role ping", () => {
|
||||
const content = (roleId) => buildPayload({ version: "v1", body: "x", roleId }).content;
|
||||
|
||||
it("stays silent for every flavour of absent", () => {
|
||||
expect(content(undefined)).toBe("");
|
||||
expect(content(null)).toBe("");
|
||||
expect(content("")).toBe("");
|
||||
expect(content(" ")).toBe("");
|
||||
});
|
||||
|
||||
it("refuses anything that is not a snowflake, rather than guessing", () => {
|
||||
expect(content("not-an-id")).toBe("");
|
||||
expect(content("12345")).toBe(""); // too short
|
||||
expect(content("1234567890123456789012")).toBe(""); // too long
|
||||
expect(content("123abc456def78901")).toBe("");
|
||||
});
|
||||
|
||||
it("never emits a half-formed mention", () => {
|
||||
for (const bad of [" ", "not-an-id", "<@&>", "@everyone"]) {
|
||||
expect(content(bad)).not.toContain("<@&");
|
||||
}
|
||||
});
|
||||
|
||||
// "Copy ID" and copying the mention text are equally easy mistakes.
|
||||
it("accepts a pasted mention as well as a bare id", () => {
|
||||
expect(content("123456789012345678")).toBe("<@&123456789012345678>");
|
||||
expect(content("<@&123456789012345678>")).toBe("<@&123456789012345678>");
|
||||
expect(content(" 123456789012345678 ")).toBe("<@&123456789012345678>");
|
||||
});
|
||||
|
||||
it("keeps allowed_mentions in step with the content", () => {
|
||||
const bad = buildPayload({ version: "v1", body: "x", roleId: "junk" });
|
||||
expect(bad.allowed_mentions.roles).toEqual([]);
|
||||
const good = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
|
||||
expect(good.allowed_mentions.roles).toEqual(["123456789012345678"]);
|
||||
expect(good.allowed_mentions.parse).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizeRoleId is the single decision point", () => {
|
||||
expect(normalizeRoleId("<@&123456789012345678>")).toBe("123456789012345678");
|
||||
expect(normalizeRoleId("nope")).toBeUndefined();
|
||||
expect(normalizeRoleId(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("pings only the configured role, and only when configured", () => {
|
||||
expect(buildPayload({ version: "v1", body: "x" }).content).toBe("");
|
||||
const p = buildPayload({ version: "v1", body: "x", roleId: "123456789012345678" });
|
||||
expect(p.content).toBe("<@&123456789012345678>");
|
||||
expect(p.allowed_mentions.roles).toEqual(["123456789012345678"]);
|
||||
});
|
||||
|
||||
// A webhook posts under whatever name and avatar it was created with —
|
||||
// Discord's default is a generic name ("Captain Hook") and a placeholder
|
||||
// image, which is exactly what v2.12.0 went out as.
|
||||
it("posts under the project's own name and avatar, not the webhook's", () => {
|
||||
const p = buildPayload({ version: "v1", body: "x" });
|
||||
expect(p.username).toBe("Wildcard Pipeline");
|
||||
expect(p.avatar_url).toMatch(/^https:\/\/.+\.png$/);
|
||||
});
|
||||
|
||||
it("uses a raster avatar — Discord will not render an SVG", () => {
|
||||
expect(buildPayload({ version: "v1", body: "x" }).avatar_url).not.toMatch(/\.svg$/i);
|
||||
});
|
||||
|
||||
it("lets a caller override the identity", () => {
|
||||
const p = buildPayload({
|
||||
version: "v1", body: "x", username: "WP Staff", avatarUrl: "https://x/a.png",
|
||||
});
|
||||
expect(p.username).toBe("WP Staff");
|
||||
expect(p.avatar_url).toBe("https://x/a.png");
|
||||
});
|
||||
|
||||
/**
|
||||
* Two channels, deliberately asymmetric. The staff copy is the review step,
|
||||
* so it carries the button that publishes it onward; the public copy is the
|
||||
* announcement itself and must not tell readers how to re-send it.
|
||||
*/
|
||||
it("gives the staff copy a publish link, so review and button are one thing", () => {
|
||||
const p = buildPayload({ version: "v1", body: "x", channel: "staff" });
|
||||
const field = p.embeds[0].fields[0];
|
||||
expect(field.value).toContain(DISPATCH_URL);
|
||||
expect(field.value).toMatch(/channel.*public/i);
|
||||
});
|
||||
|
||||
it("defaults to the staff copy — the safe one to send by accident", () => {
|
||||
expect(buildPayload({ version: "v1", body: "x" }).embeds[0].fields).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves the public copy without the publish link", () => {
|
||||
const p = buildPayload({ version: "v1", body: "x", channel: "public" });
|
||||
// `undefined`, not `[]` — an empty field block renders as dead space.
|
||||
expect(p.embeds[0].fields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays inside the embed description cap", () => {
|
||||
const huge = Array.from({ length: 900 }, (_, i) => `- change ${i}`).join("\n");
|
||||
const p = buildPayload({ version: "v1", body: huge });
|
||||
expect(p.embeds[0].description.length).toBeLessThanOrEqual(EMBED_DESCRIPTION_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRollupPayload", () => {
|
||||
// The case that actually happens: several versions skipped, then one post.
|
||||
it("labels each version so readers can tell what came from where", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [
|
||||
{ version: "v2.12.0", body: "- newest thing" },
|
||||
{ version: "v2.11.0", body: "- older thing" },
|
||||
],
|
||||
sinceVersion: "v2.10.2",
|
||||
releaseUrl: "https://x/r",
|
||||
});
|
||||
expect(p.embeds[0].title).toBe("Wildcard Pipeline v2.12.0 — everything since v2.10.2");
|
||||
expect(p.embeds[0].description).toContain("**v2.12.0**");
|
||||
expect(p.embeds[0].description).toContain("**v2.11.0**");
|
||||
});
|
||||
|
||||
it("carries the channel through a roll-up", () => {
|
||||
const staff = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "staff",
|
||||
});
|
||||
const pub = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- x" }], sinceVersion: "v2.11.0", channel: "public",
|
||||
});
|
||||
expect(staff.embeds[0].fields).toBeTruthy();
|
||||
expect(pub.embeds[0].fields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the identity on a roll-up too", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- x" }],
|
||||
sinceVersion: "v2.10.2",
|
||||
});
|
||||
expect(p.username).toBe("Wildcard Pipeline");
|
||||
expect(p.avatar_url).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
it("leads with the newest, which is what people decide to install", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [
|
||||
{ version: "v2.12.0", body: "- newest" },
|
||||
{ version: "v2.11.0", body: "- older" },
|
||||
],
|
||||
sinceVersion: "v2.10.2",
|
||||
});
|
||||
const d = p.embeds[0].description;
|
||||
expect(d.indexOf("**v2.12.0**")).toBeLessThan(d.indexOf("**v2.11.0**"));
|
||||
});
|
||||
|
||||
it("drops each release's own tail so a roll-up does not become a changelog", () => {
|
||||
const p = buildRollupPayload({
|
||||
releases: [{ version: "v2.12.0", body: "- kept\n<!-- /modal -->\n- dropped" }],
|
||||
sinceVersion: "v2.11.0",
|
||||
});
|
||||
expect(p.embeds[0].description).toContain("kept");
|
||||
expect(p.embeds[0].description).not.toContain("dropped");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
* - web/ (built manager SPA)
|
||||
* - public/ (favicons + doc images referenced by the SPA)
|
||||
* - docs/help/ (per-node help markdown the canvas reads)
|
||||
* - locales/ (node display names + settings labels ComfyUI reads)
|
||||
* - README.md
|
||||
* - LICENSE
|
||||
*
|
||||
@@ -59,8 +60,55 @@ const SHIP_PATHS = [
|
||||
"web",
|
||||
"public",
|
||||
"docs/help",
|
||||
// ComfyUI reads `custom_nodes/<pack>/locales/<lang>/` for node display
|
||||
// names, descriptions and settings labels (app/custom_node_manager.py).
|
||||
// The registry archive picks this up automatically because it ships
|
||||
// everything git-tracked that `.comfyignore` doesn't exclude; this list is
|
||||
// hand-maintained, so it did not. The two artifacts silently disagreed —
|
||||
// see DEV_ONLY below for the guard that now stops that recurring.
|
||||
"locales",
|
||||
];
|
||||
|
||||
/**
|
||||
* Top-level tracked entries that deliberately do NOT ship.
|
||||
*
|
||||
* Exists to make the ship list total: every top-level path in git is either
|
||||
* shipped or listed here, and `assertShipListIsTotal` fails the release if a
|
||||
* new one appears in neither. Without it, adding a runtime directory and
|
||||
* forgetting this file produces a zip that installs and silently misbehaves —
|
||||
* which is exactly how `locales/` went missing.
|
||||
*/
|
||||
const DEV_ONLY = new Set([
|
||||
".comfyignore", ".gitattributes", ".github", ".gitignore", ".husky", ".npmrc",
|
||||
".releaserc.js", "CHANGELOG.md", "CLAUDE.md", "config", "conftest.py",
|
||||
"eslint.config.js", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml",
|
||||
"scripts", "src", "tests", "tsconfig.json", "tsconfig.node.json",
|
||||
"vite.config.mts", "vitest.config.ts",
|
||||
]);
|
||||
|
||||
function assertShipListIsTotal() {
|
||||
const ls = spawnSync("git", ["ls-files"], { cwd: root, encoding: "utf8" });
|
||||
if (ls.status !== 0) {
|
||||
console.warn("pack-release: `git ls-files` unavailable — skipping ship-list completeness check");
|
||||
return;
|
||||
}
|
||||
// A ship path may name a subtree (`docs/help`); its top-level segment is
|
||||
// what makes the parent directory "accounted for".
|
||||
const covered = new Set(SHIP_PATHS.map((p) => p.split("/")[0]));
|
||||
const tracked = new Set(
|
||||
ls.stdout.split("\n").map((line) => line.trim().split("/")[0]).filter(Boolean),
|
||||
);
|
||||
const unaccounted = [...tracked].filter((e) => !covered.has(e) && !DEV_ONLY.has(e)).sort();
|
||||
if (unaccounted.length > 0) {
|
||||
console.error("pack-release: top-level paths in git that are neither shipped nor dev-only:");
|
||||
for (const p of unaccounted) console.error(` - ${p}`);
|
||||
console.error("Add each to SHIP_PATHS (ships to users) or DEV_ONLY (stays in the repo).");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
assertShipListIsTotal();
|
||||
|
||||
// Sanity: every path must exist before zipping. If `js/` or `web/`
|
||||
// is missing the release would ship a broken bundle — catch here so the
|
||||
// release workflow fails fast rather than uploading a half-baked zip.
|
||||
@@ -93,6 +141,13 @@ const args = [
|
||||
];
|
||||
|
||||
const result = spawnSync("zip", args, { stdio: "inherit", cwd: root });
|
||||
if (result.error?.code === "ENOENT") {
|
||||
// Windows has no `zip` by default, so a maintainer running this locally
|
||||
// otherwise gets a bare "exited with status null".
|
||||
console.error("pack-release: the `zip` CLI was not found on PATH.");
|
||||
console.error("The release workflow runs on ubuntu-latest where it is always present.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
console.error(`pack-release: zip exited with status ${result.status}`);
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
@@ -260,7 +260,7 @@ async function submit(mode: "update" | "new") {
|
||||
font-family: inherit;
|
||||
}
|
||||
.wp-stm-input:focus { outline: 1px solid var(--wp-accent-500, var(--wp-accent)); }
|
||||
.wp-stm-input--multi { resize: vertical; min-height: 46px; }
|
||||
.wp-stm-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 46px; }
|
||||
.wp-stm-select { cursor: pointer; }
|
||||
.wp-stm-preview {
|
||||
margin: 0;
|
||||
|
||||
@@ -174,7 +174,7 @@ function onCancel(): void {
|
||||
padding: 8px 10px;
|
||||
font-family: var(--wp-font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
resize: vertical; overscroll-behavior: contain;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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";
|
||||
import { deriveLoopSeeds } from "../shared/seed-derive";
|
||||
|
||||
describe("ContextLoopWidget", () => {
|
||||
it("renders defaults — hash chip active, both switches off, internal toggles on", () => {
|
||||
@@ -89,3 +92,215 @@ 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-fchips__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-4"]').classes()).toContain("wp-fchips__chip--bypassed");
|
||||
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-fchips__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]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Modifier-click on the frame chips. Both states were already visible here but
|
||||
* only changeable inside the modal, two clicks away and covering the node you
|
||||
* were reading.
|
||||
*/
|
||||
describe("ContextLoopWidget frame chip modifier-clicks", () => {
|
||||
beforeEach(() => { currentFrame.value = null; });
|
||||
|
||||
function fw(cfgPatch: Partial<ReturnType<typeof emptyContextLoopConfig>> = {}, count = 4) {
|
||||
return mount(ContextLoopWidget, {
|
||||
props: { modelValue: { ...emptyContextLoopConfig(), ...cfgPatch }, count, baseSeed: 42 },
|
||||
});
|
||||
}
|
||||
const lastEmit = (w: ReturnType<typeof fw>) => {
|
||||
const calls = w.emitted("update:modelValue");
|
||||
return calls?.[calls.length - 1]?.[0] as ReturnType<typeof emptyContextLoopConfig>;
|
||||
};
|
||||
|
||||
it("shift-click locks the frame to the seed it would have used", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
|
||||
const next = lastEmit(w);
|
||||
// Frame index 2, and the value must be a real derived seed — not a
|
||||
// placeholder, or the lock would silently change the output it froze.
|
||||
expect(Object.keys(next.seed_locks!)).toEqual(["2"]);
|
||||
expect(Number.isInteger(next.seed_locks!["2"])).toBe(true);
|
||||
});
|
||||
|
||||
it("shift-click on a locked frame unlocks it", async () => {
|
||||
const w = fw({ seed_locks: { "2": 777 } });
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
|
||||
expect(lastEmit(w).seed_locks).toEqual({});
|
||||
});
|
||||
|
||||
it("the chip-lock value matches what the modal's Lock all would write", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { shiftKey: true });
|
||||
const chipValue = lastEmit(w).seed_locks!["1"];
|
||||
expect(chipValue).toBe(deriveLoopSeeds(42, 4, "hash_index")[1]);
|
||||
});
|
||||
|
||||
it("alt-click bypasses a frame, and again re-enables it", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1]);
|
||||
|
||||
const w2 = fw({ bypass_frames: [1] });
|
||||
await w2.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w2).bypass_frames).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bypassed indices sorted so the config does not churn", async () => {
|
||||
const w = fw({ bypass_frames: [3] });
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("refuses to bypass the last running frame — a loop needs one", async () => {
|
||||
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
|
||||
await w.find('[data-test="loop-frame-4"]').trigger("click", { altKey: true });
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still lets the last running frame be re-enabled and locked", async () => {
|
||||
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
|
||||
await w.find('[data-test="loop-frame-1"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
// Moving the editor to another frame as a side effect of locking one is the
|
||||
// kind of surprise that makes people stop trusting a shortcut.
|
||||
it("a modifier click never also moves the edit cursor", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
|
||||
expect(currentFrame.value).toBe(null);
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
|
||||
it("a plain click still just selects the frame", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click");
|
||||
expect(currentFrame.value).toBe(2);
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("base takes no modifiers — it has no seed and nothing to bypass", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-base"]').trigger("click", { shiftKey: true });
|
||||
await w.find('[data-test="loop-frame-base"]').trigger("click", { altKey: true });
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks locked chips so the state is readable without the modal", () => {
|
||||
const w = fw({ seed_locks: { "1": 123 } });
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--locked");
|
||||
expect(w.find('[data-test="loop-frame-1"]').classes()).not.toContain("wp-fchips__chip--locked");
|
||||
});
|
||||
|
||||
it("a frame can be both locked and bypassed", () => {
|
||||
const w = fw({ seed_locks: { "1": 123 }, bypass_frames: [1] });
|
||||
const chip = w.find('[data-test="loop-frame-2"]');
|
||||
expect(chip.classes()).toContain("wp-fchips__chip--locked");
|
||||
expect(chip.classes()).toContain("wp-fchips__chip--bypassed");
|
||||
});
|
||||
|
||||
it("ignores non-numeric lock keys rather than rendering a NaN state", () => {
|
||||
const w = fw({ seed_locks: { junk: 1, "1": 123 } as Record<string, number> });
|
||||
expect(w.find('[data-test="loop-frame-2"]').classes()).toContain("wp-fchips__chip--locked");
|
||||
});
|
||||
|
||||
it("advertises the combos in the widget, not just in tooltips", () => {
|
||||
const w = fw();
|
||||
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/shift/i);
|
||||
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt|option/i);
|
||||
});
|
||||
|
||||
it("the tooltip names the action the next click will take", () => {
|
||||
const w = fw({ seed_locks: { "1": 123 }, bypass_frames: [1] });
|
||||
const t = w.find('[data-test="loop-frame-2"]').attributes("title")!;
|
||||
expect(t).toMatch(/unlock/i);
|
||||
expect(t).toMatch(/re-enable/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
* 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 { deriveLoopSeeds } from "../shared/seed-derive";
|
||||
import { pushToast } from "../shared/toast-store";
|
||||
import { currentFrame, setFrame } from "./frame-cursor";
|
||||
import FrameChips from "../shared/FrameChips.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -21,12 +26,24 @@ 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] }>();
|
||||
|
||||
/** Frame-grid collapse. Owned by the widget glue so it can persist to
|
||||
* `node.properties` and survive a workflow save. */
|
||||
const chipsCollapsed = defineModel<boolean>("chipsCollapsed", { default: false });
|
||||
|
||||
const STRATEGIES: { id: LoopStrategy; label: string; hint: string }[] = [
|
||||
{ id: "hash_index", label: "hash", hint: "Independent per-iteration; recommended for varied results." },
|
||||
{ id: "sequential", label: "sequential", hint: "base, base+1, base+2, … Predictable diffs." },
|
||||
@@ -46,6 +63,81 @@ const OVERRIDE_SEED_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);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Modifier-click on a frame chip — lock / bypass without the modal.
|
||||
*
|
||||
* Both states were already VISIBLE on the chips but only CHANGEABLE two clicks
|
||||
* away, inside a modal that covers the node you are reading. Toggling a couple
|
||||
* of frames is the common case; the modal earns its place for the bulk
|
||||
* operations (lock all, paste a series, out-of-range locks) and keeps them.
|
||||
*
|
||||
* Modifier choice: Alt and Shift both survive the trip. Ctrl is out because on
|
||||
* macOS Ctrl-click IS a right-click, so the combo would open a context menu
|
||||
* instead. Alt for bypass follows the seed modal's existing Alt-click-for-a-
|
||||
* variant idiom on its Copy button.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** 0-based locked frame indices. Keys are strings in the config; anything
|
||||
* non-numeric is ignored rather than becoming `NaN` in the set. */
|
||||
const lockSet = computed(() => {
|
||||
const out = new Set<number>();
|
||||
for (const k of Object.keys(props.modelValue.seed_locks ?? {})) {
|
||||
const n = Number(k);
|
||||
if (Number.isInteger(n)) out.add(n);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
/** The seed series this loop WOULD produce — the same values "Lock all" in
|
||||
* the modal writes, so a chip-lock and a modal-lock agree. */
|
||||
const derivedSeeds = computed(() =>
|
||||
deriveLoopSeeds(props.baseSeed, Math.max(1, props.count ?? 1), props.modelValue.strategy),
|
||||
);
|
||||
|
||||
/** In-range frames that are still running. Bypassing the last one would leave
|
||||
* the loop with nothing to execute, so the modal forbids it and so do we. */
|
||||
const activeFrameCount = computed(() => {
|
||||
let n = 0;
|
||||
for (let i = 0; i < Math.max(1, props.count ?? 1); i++) if (!bypassSet.value.has(i)) n++;
|
||||
return n;
|
||||
});
|
||||
|
||||
function toggleFrameLock(i: number): void {
|
||||
const next = { ...(props.modelValue.seed_locks ?? {}) };
|
||||
if (lockSet.value.has(i)) delete next[String(i)];
|
||||
else next[String(i)] = derivedSeeds.value[i] ?? props.baseSeed;
|
||||
emit("update:modelValue", { ...props.modelValue, seed_locks: next });
|
||||
}
|
||||
|
||||
function toggleFrameBypass(i: number): void {
|
||||
const on = bypassSet.value.has(i);
|
||||
if (!on && activeFrameCount.value <= 1) {
|
||||
// Silence here would read as a broken click, and the reason is not
|
||||
// guessable from the chip.
|
||||
pushToast("A loop needs at least one running frame.", { severity: "warning" });
|
||||
return;
|
||||
}
|
||||
const next = new Set(bypassSet.value);
|
||||
if (on) next.delete(i);
|
||||
else next.add(i);
|
||||
onBypassFrames([...next].sort((a, b) => a - b));
|
||||
}
|
||||
|
||||
function pickStrategy(s: LoopStrategy): void {
|
||||
if (props.modelValue.strategy === s) return;
|
||||
emit("update:modelValue", { ...props.modelValue, strategy: s });
|
||||
@@ -108,6 +200,32 @@ 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>
|
||||
|
||||
<!-- `base` is not a frame: it has no seed to lock and nothing to bypass,
|
||||
so it takes no modifiers. -->
|
||||
<FrameChips
|
||||
label="edit frame"
|
||||
:count="Math.max(1, count ?? 1)"
|
||||
:locked="[...lockSet]"
|
||||
:bypassed="[...bypassSet]"
|
||||
v-model:collapsed="chipsCollapsed"
|
||||
:current="currentFrame"
|
||||
selectable
|
||||
show-base
|
||||
bypass-interactive
|
||||
@select="setFrame"
|
||||
@toggle-lock="toggleFrameLock"
|
||||
@toggle-bypass="toggleFrameBypass"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="wp-loop__row"
|
||||
:title="OVERRIDE_SEED_TOOLTIP"
|
||||
@@ -169,7 +287,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 +299,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 +329,11 @@ function toggleTotalInternal(): void {
|
||||
}
|
||||
|
||||
.wp-loop__chips { display: flex; gap: 4px; }
|
||||
/* Strategy chips only — the per-frame grid now lives in FrameChips.vue. */
|
||||
.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;
|
||||
@@ -289,8 +416,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 +433,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import ConfirmDialog from "../shared/ConfirmDialog.vue";
|
||||
import { api } from "../../manager/api/client";
|
||||
@@ -165,14 +165,15 @@ function onKeydown(ev: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
// Keys arrive from ModalShell (@keydown below) rather than window: the modal
|
||||
// shield stops inside-modal keys at the overlay so they cannot reach ComfyUI's
|
||||
// global shortcuts, so a window listener here would never fire.
|
||||
|
||||
const alreadyAddedSet = computed(() => new Set(props.alreadyAddedIds ?? []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="emit('close')">
|
||||
<ModalShell :visible="visible" @close="emit('close')" @keydown="onKeydown">
|
||||
<div class="wp-bp" role="dialog">
|
||||
<header class="wp-bp__head">
|
||||
<div class="wp-bp__head-text">
|
||||
|
||||
@@ -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
|
||||
@@ -207,6 +208,75 @@ describe("ContextWidget drift dot", () => {
|
||||
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("shows drift for an empty-hash module whose uuid IS in the live library", async () => {
|
||||
// Repro: a workflow whose embedded module carries payload_hash "" (a
|
||||
// malformed/legacy/builder-emitted copy) but whose uuid exists in the
|
||||
// library. It must NOT be treated as inline/verdict-less — surface it as
|
||||
// drift so the bulk "refresh drifted" button can relink it.
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { bbbbbbbb: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "bbbbbbbb", type: "wildcard", enabled: true,
|
||||
meta: { name: "wc", description: "", tags: [] }, entries: [],
|
||||
payload: { options: [] }, payload_hash: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 98, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find(".wp-mod-dot--drift").exists()).toBe(true);
|
||||
});
|
||||
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("leaves a genuinely inline module (empty hash, uuid NOT in library) verdict-less", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/wp/api/modules/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: { somethingelse: { type: "wildcard", payload_hash: "h-LIVE" } } }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "cccccccc", type: "wildcard", enabled: true,
|
||||
meta: { name: "inline", description: "", tags: [] }, entries: [],
|
||||
payload: { options: [] }, payload_hash: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 97, initialJson, upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
// Give the poll a beat to land, then assert no drift/missing badges.
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".wp-mod-dot--drift").exists()).toBe(false);
|
||||
expect(wrapper.find(".wp-mod-dot--missing").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContextWidget bulk refresh", () => {
|
||||
@@ -648,6 +718,105 @@ describe("ContextWidget bundle ops via ctxmenu", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("Reset REFUSES (keeps children) when the instance isn't library-linked", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 })));
|
||||
const bundleUid = "buidEMPTY001";
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "dddddddd", type: "wildcard", enabled: true,
|
||||
meta: { name: "LocallyEdited" }, entries: [],
|
||||
payload: { var_binding: "v", options: [{ id: "o2", value: "y", weight: 2 }] },
|
||||
payload_hash: "h-edited", bundle_origin: bundleUid,
|
||||
},
|
||||
],
|
||||
bundles: [
|
||||
{
|
||||
_uid: bundleUid, library_id: "", start_idx: 0, end_idx: 0,
|
||||
enabled: true, collapsed: false, inserted_at_hash: "", name: "Unlinked", color: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 305, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
const header = document.querySelector(`[data-bundle-uid="${bundleUid}"][data-bundle-header]`);
|
||||
expect(header).toBeTruthy();
|
||||
header!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const reset = items.find((el) => el.textContent?.includes("Reset to library snapshot"));
|
||||
expect(reset).toBeTruthy();
|
||||
reset!.click();
|
||||
await flushPromises();
|
||||
|
||||
// Refused — the live child is untouched, no destructive empty commit.
|
||||
expect(wrapper.find('[data-module-id="dddddddd"]').exists()).toBe(true);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("Reset REFUSES (keeps children) when the library snapshot comes back empty", async () => {
|
||||
resetDriftStore();
|
||||
const fetchSpy = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (typeof url === "string" && url.match(/\/wp\/api\/bundles\/bn_empty$/) && (!init || init.method === "GET")) {
|
||||
return new Response(JSON.stringify({
|
||||
id: "bn_empty", name: "EmptySnap", description: "", color: null,
|
||||
category_id: null, tags: [], is_favorite: false,
|
||||
children: [], payload_hash: "ph-empty", version: 1, created_at: "", updated_at: "",
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const bundleUid = "buidEMPTY002";
|
||||
const initialJson = JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "dddddddd", type: "wildcard", enabled: true,
|
||||
meta: { name: "LocallyEdited" }, entries: [],
|
||||
payload: { var_binding: "v", options: [{ id: "o2", value: "y", weight: 2 }] },
|
||||
payload_hash: "h-edited", bundle_origin: bundleUid,
|
||||
},
|
||||
],
|
||||
bundles: [
|
||||
{
|
||||
_uid: bundleUid, library_id: "bn_empty", start_idx: 0, end_idx: 0,
|
||||
enabled: true, collapsed: false, inserted_at_hash: "ph-old", name: "EmptySnap", color: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 306, initialJson, upstreamVars: [], onChange },
|
||||
});
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
const header = document.querySelector(`[data-bundle-uid="${bundleUid}"][data-bundle-header]`);
|
||||
expect(header).toBeTruthy();
|
||||
header!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
|
||||
await flushPromises();
|
||||
const items = Array.from(document.querySelectorAll(".wp-ctxmenu__item")) as HTMLElement[];
|
||||
const reset = items.find((el) => el.textContent?.includes("Reset to library snapshot"));
|
||||
expect(reset).toBeTruthy();
|
||||
reset!.click();
|
||||
await flushPromises();
|
||||
|
||||
// The empty snapshot must NOT wipe the live child.
|
||||
expect(wrapper.find('[data-module-id="dddddddd"]').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("Reset re-adds children removed since insert (bundle end_idx grew via reconcile)", async () => {
|
||||
resetDriftStore();
|
||||
// Library has TWO children; in-graph bundle currently has ONE
|
||||
@@ -728,6 +897,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();
|
||||
@@ -947,6 +1234,64 @@ function mountWithModules(stubs: StubModule[]) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── D3a: library re-link (onPushSaved path) ──────────────────────────────────
|
||||
|
||||
describe("ContextWidget module re-link", () => {
|
||||
it("re-links a detached row + follows a sibling constraint's source_wildcard_id", async () => {
|
||||
const onChange = vi.fn();
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 9100,
|
||||
initialJson: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "dead0001", type: "wildcard", enabled: true,
|
||||
meta: { name: "hair", description: "", tags: [] }, entries: [],
|
||||
payload: { options: [{ id: "o1", value: "red" }] }, payload_hash: "HASH_A",
|
||||
},
|
||||
{
|
||||
id: "c1", type: "constraint", enabled: true,
|
||||
meta: { name: "cn", description: "", tags: [] }, entries: [],
|
||||
payload: { source_wildcard_id: "dead0001", target_wildcard_id: "w2", matrix: {} },
|
||||
payload_hash: "HC",
|
||||
},
|
||||
],
|
||||
}),
|
||||
upstreamVars: [],
|
||||
onChange,
|
||||
},
|
||||
});
|
||||
// Let the initial parse settle (its suppressWatch cycle) so the relink
|
||||
// change below isn't coalesced into the swallowed first watcher tick.
|
||||
await flushPromises();
|
||||
onChange.mockClear();
|
||||
|
||||
// Two PushToLibraryModal instances exist: ModuleEditModal's (bound to
|
||||
// onPushToLibrarySaved, draft=null here) and ContextWidget's own (bound to
|
||||
// onPushSaved, the right-click path). The latter is last in DFS order.
|
||||
const ptls = wrapper.findAllComponents({ name: "PushToLibraryModal" });
|
||||
expect(ptls.length).toBeGreaterThan(0);
|
||||
const ptl = ptls[ptls.length - 1];
|
||||
ptl.vm.$emit("saved", {
|
||||
mode: "relink", id: "live0001", origId: "dead0001",
|
||||
payload_hash: "HASH_A", name: "hair", bundles_updated: [],
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const calls = onChange.mock.calls;
|
||||
const lastJson = calls[calls.length - 1]?.[0] as string;
|
||||
const parsed = JSON.parse(lastJson) as {
|
||||
modules: Array<{ id: string; type: string; payload: Record<string, unknown> }>;
|
||||
};
|
||||
const wc = parsed.modules.find((m) => m.type === "wildcard");
|
||||
const con = parsed.modules.find((m) => m.type === "constraint");
|
||||
expect(wc?.id).toBe("live0001");
|
||||
expect(con?.payload.source_wildcard_id).toBe("live0001");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sibling badge (Phase B: lives in summary line, not header) ────────────
|
||||
//
|
||||
// Phase A shipped the badge in the header always-visible. Phase B
|
||||
@@ -1150,6 +1495,56 @@ describe("ContextWidget status badges", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("an UNLINKED bundle reads as missing, not modified", async () => {
|
||||
// A bundle with no `library_id` could never be MISSING (that check
|
||||
// early-returned false without one), so its only badge was MODIFIED —
|
||||
// diffing its children against a snapshot baseline from a library entry
|
||||
// that isn't there. "Modified against what?" had no answer.
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (typeof url === "string" && url.includes("/hashes")) {
|
||||
return new Response(JSON.stringify({ hashes: {} }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
nodeId: 815,
|
||||
initialJson: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{ id: "bbbbbbbb", type: "wildcard", enabled: true, meta: { name: "child" }, entries: [], payload: {} },
|
||||
],
|
||||
bundles: [{
|
||||
_uid: "bu1",
|
||||
// No library_id — the unlinked shape.
|
||||
library_id: "",
|
||||
name: "Pose Library",
|
||||
start_idx: 0,
|
||||
end_idx: 0,
|
||||
enabled: true,
|
||||
// A stale baseline that would otherwise light up MOD.
|
||||
snapshot_fingerprint: "v2:deadbeef",
|
||||
}],
|
||||
}),
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.find(".wp-mod-badge--missing").exists()).toBe(true);
|
||||
});
|
||||
// MOD is meaningless with no insert to have diverged from.
|
||||
expect(wrapper.find(".wp-mod-badge--modified").exists()).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("renders 'missing' text badge when uuid is not in library", async () => {
|
||||
resetDriftStore();
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -28,7 +28,14 @@ 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 {
|
||||
CONTEXT_POOLS_KEY,
|
||||
FOREIGN_POOL_LOOKUP_KEY,
|
||||
buildContextPools,
|
||||
} from "../../extension/context-pools";
|
||||
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";
|
||||
import {
|
||||
cascadeRestoreForBundle,
|
||||
@@ -51,7 +58,8 @@ import {
|
||||
MOTION_FLIP_MS,
|
||||
MOTION_CURVE_FLIP,
|
||||
} from "./bundles/flip";
|
||||
import { api } from "../../manager/api/client";
|
||||
import { ApiError, api } from "../../manager/api/client";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
import { emptyBundleInstance, type BundleInstance } from "../../widgets/_shared";
|
||||
import ModuleEditModal from "./ModuleEditModal.vue";
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
@@ -73,7 +81,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 {
|
||||
@@ -87,6 +97,7 @@ import {
|
||||
unsubscribe as unsubscribeDrift,
|
||||
} from "./drift-store";
|
||||
import { classifyOne, type CollisionState } from "../../manager/import-export/collision";
|
||||
import { applyRelink } from "../../manager/import-export/relink-apply";
|
||||
import {
|
||||
getBundleCollapsedByDefault,
|
||||
getConfirmDestructiveBundle,
|
||||
@@ -97,6 +108,16 @@ const props = withDefaults(defineProps<{
|
||||
nodeId: number;
|
||||
initialJson: string;
|
||||
upstreamVars: string[];
|
||||
/** `$var` → which module in which node writes it (last-write-wins).
|
||||
* Threaded to the edit modals so a var chip's hover card can name its
|
||||
* producer instead of only confirming the name exists. Optional for
|
||||
* headless mounts. */
|
||||
upstreamProducers?: Record<string, VarProducerLike>;
|
||||
/** uuid → labels of OTHER Context nodes that hold it as a wildcard. Lets a
|
||||
* nested-ref hover card explain WHY it fell back to the library: the pool
|
||||
* exists on the canvas, just not in this node, and a catalog never crosses
|
||||
* a node boundary. Absent in headless mounts (no graph to walk). */
|
||||
wildcardHomesReader?: (uuid: string) => string[];
|
||||
/** Resolved upstream-var snapshot — `$name → resolved string` map.
|
||||
* Drives the combine modal's live-preview pane so users see the
|
||||
* template with vars substituted (e.g. `red portrait` instead of
|
||||
@@ -150,6 +171,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
|
||||
@@ -459,7 +484,7 @@ function openPushToLibrary(idx: number): void {
|
||||
}
|
||||
|
||||
interface PushSaveResult {
|
||||
mode: "update" | "fork" | "reattach";
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
id: string;
|
||||
payload_hash: string;
|
||||
bundles_updated: string[];
|
||||
@@ -503,6 +528,21 @@ function onPushSaved(result: PushSaveResult): void {
|
||||
};
|
||||
});
|
||||
commitModules(next);
|
||||
} else if (result.mode === "relink") {
|
||||
// Re-link: point the detached row(s) at an EXISTING content-identical
|
||||
// library uuid AND walk every module so sibling @{} refs + constraint
|
||||
// source/target follow the new uuid (applyRelink swaps id + payload_hash
|
||||
// on the matching row + remaps refs everywhere else).
|
||||
const remapped = applyRelink(
|
||||
value.value.modules as unknown as Record<string, unknown>[],
|
||||
{ oldId: result.origId, newId: result.id, newPayloadHash: result.payload_hash },
|
||||
) as unknown as ModuleEntry[];
|
||||
const next = remapped.map((m) =>
|
||||
m.id === result.id
|
||||
? { ...m, meta: { ...(m.meta ?? {}), library_name: result.name } }
|
||||
: m,
|
||||
);
|
||||
commitModules(next);
|
||||
}
|
||||
const bundlesNote =
|
||||
result.bundles_updated.length > 0
|
||||
@@ -511,6 +551,7 @@ function onPushSaved(result: PushSaveResult): void {
|
||||
let msg: string;
|
||||
if (result.mode === "fork") msg = `Saved as new library entry "${result.name}"`;
|
||||
else if (result.mode === "reattach") msg = `Re-attached "${result.name}" to library`;
|
||||
else if (result.mode === "relink") msg = `Re-linked "${result.name}" to library`;
|
||||
else msg = `Saved "${result.name}" to library${bundlesNote}`;
|
||||
pushToast(msg, { severity: "success" });
|
||||
pushOpen.value = false;
|
||||
@@ -625,7 +666,7 @@ async function pushBundleCascadeRestore(): Promise<{
|
||||
}
|
||||
|
||||
interface BundlePushSaveResult {
|
||||
mode: "update" | "fork" | "reattach";
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
id: string;
|
||||
payload_hash: string;
|
||||
name: string;
|
||||
@@ -634,11 +675,12 @@ interface BundlePushSaveResult {
|
||||
}
|
||||
function onBundlePushSaved(result: BundlePushSaveResult): void {
|
||||
void forceRefreshHashes();
|
||||
if (result.mode === "update" || result.mode === "reattach") {
|
||||
if (result.mode === "update" || result.mode === "reattach" || result.mode === "relink") {
|
||||
// Update: refresh inserted_at_hash + snapshot fingerprint for every
|
||||
// BundleInstance pointing at this library entry — drift + MOD dots
|
||||
// clear instantly. Reattach: also rebind library_id from the dead
|
||||
// uuid to the freshly-created one.
|
||||
// uuid to the freshly-created one. Relink: same rebind, but onto an
|
||||
// EXISTING content-identical library bundle (no write happened).
|
||||
const nextBundles = (value.value.bundles ?? []).map((b) => {
|
||||
if (b.library_id !== result.origId) return b;
|
||||
const rebound: BundleInstance = {
|
||||
@@ -655,6 +697,7 @@ function onBundlePushSaved(result: BundlePushSaveResult): void {
|
||||
let msg: string;
|
||||
if (result.mode === "fork") msg = `Saved as new library bundle "${result.name}"`;
|
||||
else if (result.mode === "reattach") msg = `Re-attached "${result.name}" to library`;
|
||||
else if (result.mode === "relink") msg = `Re-linked "${result.name}" to library`;
|
||||
else msg = `Saved "${result.name}" to library`;
|
||||
if (result.cascade) {
|
||||
const parts: string[] = [];
|
||||
@@ -678,9 +721,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
|
||||
@@ -736,11 +781,51 @@ const resolvedForEditing = computed<Record<string, ResolvedValue>>(() => {
|
||||
// Combine + wildcard have no entries; without this kind-aware fallback the
|
||||
// modal's insert-var dropdown would render empty for chains where the only
|
||||
// vars come from binding-producer modules.
|
||||
const siblingNodeVars = computed<string[]>(() => {
|
||||
/**
|
||||
* Vars written by the OTHER modules in this same Context node, together with
|
||||
* who writes each one.
|
||||
*
|
||||
* `collectUpstreamProducers` walks the node chain and deliberately excludes
|
||||
* the point-of-view node, so it can only attribute vars produced further
|
||||
* upstream. In a graph where one Context node carries most of the modules —
|
||||
* which is the normal shape — that left nearly every `$var` in the
|
||||
* autocomplete with no writer at all, and the rows rendered bare while the
|
||||
* SPA's equivalent showed a full attribution line for the same names.
|
||||
*
|
||||
* Built in the same pass as the name list so the two cannot disagree about
|
||||
* which module owns a name.
|
||||
*/
|
||||
const siblingVarInfo = computed<{
|
||||
names: string[];
|
||||
producers: Record<string, VarProducerLike>;
|
||||
}>(() => {
|
||||
const names = new Set<string>();
|
||||
const producers: Record<string, VarProducerLike> = {};
|
||||
/** Last module to write each name, so repeat writes from one module (a
|
||||
* derivation's branches) do not inflate the override count. */
|
||||
const lastWriter = new Map<string, string>();
|
||||
let owner: { kind: string; moduleName: string; moduleId: string } | null = null;
|
||||
function add(name: string | undefined | null): void {
|
||||
const trimmed = (name ?? "").replace(/^\$+/, "").trim();
|
||||
if (trimmed) names.add(trimmed);
|
||||
if (!trimmed) return;
|
||||
names.add(trimmed);
|
||||
if (!owner) return;
|
||||
// Later modules win, matching the engine's last-write-wins resolution.
|
||||
// The count tracks distinct MODULES, not write statements: a derivation
|
||||
// calls this once per branch and a fixed_values once per row, but at most
|
||||
// one branch fires and the module is a single writer either way.
|
||||
const prev = producers[trimmed];
|
||||
const sameWriter = lastWriter.get(trimmed) === owner.moduleId;
|
||||
lastWriter.set(trimmed, owner.moduleId);
|
||||
producers[trimmed] = {
|
||||
kind: owner.kind,
|
||||
moduleName: owner.moduleName,
|
||||
moduleId: owner.moduleId,
|
||||
// Same node, so there is no other node to name — saying which one it is
|
||||
// would be noise when every sibling gives the same answer.
|
||||
nodeLabel: "this node",
|
||||
shadowed: prev ? (sameWriter ? prev.shadowed : prev.shadowed + 1) : 0,
|
||||
};
|
||||
}
|
||||
const editingM = editingModule.value;
|
||||
const bundleEnabled = buildBundleEnabledMap(value.value.bundles);
|
||||
@@ -751,6 +836,11 @@ const siblingNodeVars = computed<string[]>(() => {
|
||||
// Defensive: also skip if same object (shouldn't happen but Vue
|
||||
// proxies can confuse identity equals).
|
||||
void editingM;
|
||||
owner = {
|
||||
kind: m.type,
|
||||
moduleName: m.meta?.name ?? m.type,
|
||||
moduleId: m.id,
|
||||
};
|
||||
for (const e of m.entries) add(e.variable_name);
|
||||
const inst = (m.instance ?? {}) as {
|
||||
variable_binding?: string | null;
|
||||
@@ -783,7 +873,38 @@ const siblingNodeVars = computed<string[]>(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...names];
|
||||
owner = null;
|
||||
return { names: [...names], producers };
|
||||
});
|
||||
|
||||
const siblingNodeVars = computed<string[]>(() => siblingVarInfo.value.names);
|
||||
|
||||
/**
|
||||
* Every `$var` writer the editors can name: upstream nodes first, then this
|
||||
* node's own modules on top.
|
||||
*
|
||||
* Siblings override because they run later in the same chain position, which
|
||||
* is what the engine does too — so the row names the module whose value
|
||||
* actually survives.
|
||||
*
|
||||
* The override COUNT has to be carried across the seam. A plain spread
|
||||
* replaces the upstream entry outright, taking its `shadowed` with it: with
|
||||
* `$outfit` written by an upstream wildcard, rewritten by an upstream
|
||||
* derivation, then rewritten again by a module in this node, the row reported
|
||||
* "0" — no badge at all — because the sibling map only ever counted siblings
|
||||
* and started from zero. A sibling that displaces an upstream writer has
|
||||
* overridden that writer PLUS everything it had already overridden.
|
||||
*/
|
||||
const allVarProducers = computed<Record<string, VarProducerLike>>(() => {
|
||||
const upstream = props.upstreamProducers ?? {};
|
||||
const merged: Record<string, VarProducerLike> = { ...upstream };
|
||||
for (const [name, sib] of Object.entries(siblingVarInfo.value.producers)) {
|
||||
const up = upstream[name];
|
||||
merged[name] = up
|
||||
? { ...sib, shadowed: sib.shadowed + up.shadowed + 1 }
|
||||
: sib;
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
function clearDragHover() {
|
||||
@@ -1583,6 +1704,17 @@ async function duplicateBundle(uid: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Human message for a failed bundle-snapshot fetch. A 404 means the
|
||||
* library row is gone (deleted, or re-imported under a fresh uuid), which
|
||||
* the raw API text doesn't say — and re-linking, not retrying, is the fix.
|
||||
* Anything else falls back to the raw message. */
|
||||
function bundleResetErrorMessage(e: unknown, name: string): string {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
return `Can't reset "${name || "bundle"}" — its library bundle no longer exists. Re-link it first.`;
|
||||
}
|
||||
return `Reset failed: ${e instanceof Error ? e.message : String(e)}`;
|
||||
}
|
||||
|
||||
/** Reset bundle to library snapshot — re-fetch the library entry
|
||||
* + replace current children with the frozen blob. Drops any user
|
||||
* edits made to bundle children since insert. Confirms first since
|
||||
@@ -1591,6 +1723,17 @@ async function resetBundleToLibrary(uid: string): Promise<void> {
|
||||
const bundles = value.value.bundles ?? [];
|
||||
const target = bundles.find((b) => b._uid === uid);
|
||||
if (!target) return;
|
||||
// Guard: a detached/unlinked instance (empty library_id — e.g. a bundle
|
||||
// whose section was never linked, or whose library row was re-imported
|
||||
// under a new uuid) has nothing to fetch. Refuse instead of fetching an
|
||||
// empty snapshot and wiping the live children. Re-link is the recovery.
|
||||
if (!target.library_id) {
|
||||
pushToast(
|
||||
`Can't reset "${target.name || "bundle"}" — it isn't linked to a library bundle. Re-link it first.`,
|
||||
{ severity: "error" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Delta-undo capture: keep the old child refs (their _uids will be
|
||||
// discarded by the reset since the new library snapshot stamps
|
||||
// fresh ones, but the BundleInstance _uid we re-find by below
|
||||
@@ -1614,6 +1757,17 @@ async function resetBundleToLibrary(uid: string): Promise<void> {
|
||||
// local work.
|
||||
try {
|
||||
const entry = await api.bundles.get(target.library_id);
|
||||
// Guard: never wipe the live children with an empty snapshot. A valid
|
||||
// library bundle always resolves at least one child; an empty/missing
|
||||
// `children` means the fetch resolved to the wrong shape (broken link,
|
||||
// list wrapper, stale row). Abort the destructive replace + tell the user.
|
||||
if (!Array.isArray(entry.children) || entry.children.length === 0) {
|
||||
pushToast(
|
||||
`Can't reset "${target.name || "bundle"}" — the library snapshot came back empty (its link may be broken). Re-link the bundle instead.`,
|
||||
{ severity: "error" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const libEntry: BundleLibraryEntry = {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
@@ -1761,8 +1915,7 @@ async function resetBundleToLibrary(uid: string): Promise<void> {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
pushToast(`Reset failed: ${msg}`, { severity: "error" });
|
||||
pushToast(bundleResetErrorMessage(e, target.name), { severity: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1781,6 +1934,18 @@ async function resetChildToBundleSnapshot(idx: number): Promise<void> {
|
||||
if (!bundle) return;
|
||||
const posInBundle = idx - bundle.start_idx;
|
||||
if (posInBundle < 0) return;
|
||||
// Guard: mirrors resetBundleToLibrary. An unlinked/detached BundleInstance
|
||||
// carries an empty `library_id`; fetching it builds `/wp/api/bundles/` whose
|
||||
// trailing slash normalizes to the collection route, so aiohttp reports
|
||||
// "No such API route: /wp/api/bundles" — a nonsense message for the user.
|
||||
// Refuse up front and name the real recovery.
|
||||
if (!bundle.library_id) {
|
||||
pushToast(
|
||||
`Can't reset "${bundle.name || "bundle"}" children — it isn't linked to a library bundle. Re-link it first.`,
|
||||
{ severity: "error" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Delta-undo capture: the original module ref keeps its `_uid` so
|
||||
// Undo can find it in live state regardless of position. Reset
|
||||
// only swaps payload/instance/payload_hash; the _uid stays put,
|
||||
@@ -1820,8 +1985,7 @@ async function resetChildToBundleSnapshot(idx: number): Promise<void> {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
pushToast(`Reset failed: ${msg}`, { severity: "error" });
|
||||
pushToast(bundleResetErrorMessage(e, bundle.name), { severity: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1912,21 +2076,39 @@ function openBundleContextMenu(ev: MouseEvent, uid: string): void {
|
||||
onSelect: () => toggleBundleEnabled(uid, !target.enabled),
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: "Reset to library snapshot",
|
||||
icon: "pi-refresh",
|
||||
subtitle: "Replace children with frozen library state",
|
||||
onSelect: () => { void resetBundleToLibrary(uid); },
|
||||
},
|
||||
// The two library actions accent themselves to match the bundle's CURRENT
|
||||
// state, so the menu points at the recovery for the badge the user just
|
||||
// saw. Only one lights up: the states are ranked, since a bundle can be
|
||||
// several at once and two competing accents would say nothing.
|
||||
// missing → Push (re-link or add) — nothing to reset FROM
|
||||
// drifted → Reset (pull the library's newer version)
|
||||
// modified → Push (save your local edits up)
|
||||
(() => {
|
||||
const drifted = !isBundleMissingFromLibrary(target)
|
||||
&& isBundleLibraryDrifted(target);
|
||||
return {
|
||||
label: "Reset to library snapshot",
|
||||
icon: "pi-refresh",
|
||||
subtitle: drifted
|
||||
? "Library changed since insert — pull the newer version"
|
||||
: "Replace children with frozen library state",
|
||||
accent: drifted,
|
||||
onSelect: () => { void resetBundleToLibrary(uid); },
|
||||
};
|
||||
})(),
|
||||
(() => {
|
||||
const bundleMissing = isBundleMissingFromLibrary(target);
|
||||
const modified = !bundleMissing
|
||||
&& !isBundleLibraryDrifted(target)
|
||||
&& bundleSnapshotModified(target, value.value.modules);
|
||||
let subtitle = "Rename, retag, overwrite library entry";
|
||||
if (bundleMissing) subtitle = "Not linked to a library entry — re-link or add";
|
||||
else if (modified) subtitle = "Local edits since insert — save them to the library";
|
||||
return {
|
||||
label: "Push to library…",
|
||||
icon: "pi-cloud-upload",
|
||||
subtitle: bundleMissing
|
||||
? "Library entry deleted — re-add to library"
|
||||
: "Rename, retag, overwrite library entry",
|
||||
accent: bundleMissing,
|
||||
subtitle,
|
||||
accent: bundleMissing || modified,
|
||||
onSelect: () => openPushBundleToLibrary(uid),
|
||||
divider: true,
|
||||
};
|
||||
@@ -2027,6 +2209,9 @@ const conflicts = computed<Conflict[]>(() => {
|
||||
props.downstreamWildcardUuids,
|
||||
props.downstreamNestedReachUuids,
|
||||
pairOrphanByUid,
|
||||
// Live-library uuids — a nested @{} ref to any of these resolves at run
|
||||
// time (DB-expanded catalog), so it must not read as a broken ref.
|
||||
new Set(Object.keys(libraryHashes.value ?? {})),
|
||||
);
|
||||
// Filter by user's validation-strictness preference. The accessor
|
||||
// reads from the same module-level state map the panel onChange
|
||||
@@ -2174,9 +2359,16 @@ function conflictTooltip(id: string): string {
|
||||
* lives in `classifyOne` (import-export/collision).
|
||||
*/
|
||||
function matchStateOf(m: ModuleEntry): CollisionState | null {
|
||||
if (!m.payload_hash) return null;
|
||||
if (libraryHashes.value === null) return null;
|
||||
const live = libraryHashes.value[m.id];
|
||||
if (!m.payload_hash) {
|
||||
// No local hash. Only meaningful when the uuid IS in the library — a
|
||||
// workflow copy that lost its hash (malformed import, or a builder that
|
||||
// wrote payload_hash: ""). Treat as drift so the bulk "refresh drifted"
|
||||
// button relinks it. A genuinely inline module (fresh uuid, not in the
|
||||
// library) stays verdict-less exactly as before.
|
||||
return live ? "conflict" : null;
|
||||
}
|
||||
return classifyOne(
|
||||
m.type,
|
||||
m.payload_hash,
|
||||
@@ -2376,17 +2568,32 @@ function isBundleLibraryDrifted(bundle: BundleInstance): boolean {
|
||||
return live !== bundle.inserted_at_hash;
|
||||
}
|
||||
|
||||
/** True when the bundle's library entry has been deleted upstream —
|
||||
* the polled `bundleHashes` map no longer contains its uuid. Pairs
|
||||
* with `isMissingFromLibrary` for modules. Returns false until first
|
||||
* poll lands so we don't flash MISSING before the truth is known. */
|
||||
/** True when the bundle has NO live library entry behind it. Covers both
|
||||
* shapes of that: it carries a `library_id` the polled map no longer knows,
|
||||
* OR it carries no `library_id` at all.
|
||||
*
|
||||
* The second case used to early-return false, so an unlinked bundle could
|
||||
* never show MISSING — leaving MODIFIED as its only badge, which then
|
||||
* compared its children against a snapshot baseline from a library entry that
|
||||
* isn't there. "Modified against what?" was a fair question with no answer.
|
||||
*
|
||||
* Returns false until the first poll lands so we don't flash MISSING before
|
||||
* the truth is known. */
|
||||
function isBundleMissingFromLibrary(bundle: BundleInstance): boolean {
|
||||
const map = bundleHashes.value;
|
||||
if (map === null) return false;
|
||||
if (!bundle.library_id) return false;
|
||||
if (!bundle.library_id) return true;
|
||||
return !(bundle.library_id in map);
|
||||
}
|
||||
|
||||
/** True when the bundle never had a library entry to diverge FROM — no
|
||||
* `library_id` at all. MODIFIED is meaningless in that state: there is no
|
||||
* insert to be modified since. Distinct from "the entry was deleted", where
|
||||
* the local snapshot IS a real baseline and MOD still reads correctly. */
|
||||
function isBundleUnlinked(bundle: BundleInstance): boolean {
|
||||
return bundleHashes.value !== null && !bundle.library_id;
|
||||
}
|
||||
|
||||
// Pending confirm-dialog state. Single slot — only one destructive op
|
||||
// can be in flight at a time, and the bundle ctxmenu / header buttons
|
||||
// are synchronous enough that overlapping calls don't happen in
|
||||
@@ -2806,7 +3013,16 @@ function isModified(m: ModuleEntry): boolean {
|
||||
if (nonEmptyObj(inst.option_weights)) return true;
|
||||
if (inst.mode && inst.mode !== "random") return true;
|
||||
if (inst.pinned_option_id) return true;
|
||||
if (nonEmptyArr(inst.category_filter)) return true;
|
||||
// `category_filter` is a STRING expr — the old `nonEmptyArr()` test
|
||||
// never matched it, so toggling the pool filter never lit the dot.
|
||||
if (typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") return true;
|
||||
if (inst.exclude_null) return true;
|
||||
// Pick-range override (multi-pick / narrowed). Sparse — absent or 1..1
|
||||
// is the library default; anything else is a behavioural override.
|
||||
if (inst.pick_min != null && inst.pick_min !== 1) return true;
|
||||
if (inst.pick_max != null && inst.pick_max !== 1) return true;
|
||||
if (inst.pick_independent) return true;
|
||||
if (typeof inst.pick_separator === "string" && inst.pick_separator !== "") return true;
|
||||
return false;
|
||||
case "fixed_values":
|
||||
// Library-tracked: `values_overrides` non-empty = user edited entries,
|
||||
@@ -2867,11 +3083,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
|
||||
@@ -2925,6 +3165,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") {
|
||||
@@ -2967,7 +3223,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);
|
||||
}
|
||||
|
||||
@@ -3181,14 +3439,37 @@ function summaryTokens(m: ModuleEntry): SummaryToken[] {
|
||||
// Card summary respects the per-instance binding override so a
|
||||
// renamed wildcard reads as `$<override>` on the canvas. Engine
|
||||
// applies the same precedence at run time.
|
||||
const inst = m.instance?.variable_binding;
|
||||
const instBind = m.instance?.variable_binding;
|
||||
const payloadBinding = (p.var_binding as string | undefined)?.trim();
|
||||
const binding = (typeof inst === "string" && inst.trim()) || payloadBinding;
|
||||
const opts = Array.isArray(p.options) ? p.options.length : 0;
|
||||
const binding = (typeof instBind === "string" && instBind.trim()) || payloadBinding;
|
||||
const head: SummaryToken = binding ? v(binding) : lit("wildcard");
|
||||
return opts
|
||||
? [head, lit(` · ${opts} option${opts === 1 ? "" : "s"}`)]
|
||||
: [head];
|
||||
const allOpts = (Array.isArray(p.options) ? p.options : []) as WildcardOption[];
|
||||
const total = allOpts.length;
|
||||
if (!total) return [head];
|
||||
const inst = (m.instance ?? {}) as InstanceLike & { pick_min?: number; pick_max?: number };
|
||||
const pmin = typeof inst.pick_min === "number" ? inst.pick_min : 1;
|
||||
const pmax = typeof inst.pick_max === "number" ? Math.max(pmin, inst.pick_max) : pmin;
|
||||
const multiActive = pmax > 1;
|
||||
// "X of Y options" only when a filter / enabled-subset actually narrows
|
||||
// the pool — a plain count when nothing is filtering (keeps the common
|
||||
// case clean). The null-option drop in multi mode alone never triggers it.
|
||||
const hasFilter =
|
||||
(typeof inst.category_filter === "string" && inst.category_filter.trim() !== "") ||
|
||||
Array.isArray(inst.enabled_options);
|
||||
const eligible = allOpts.filter((o) => isEnabled(o, inst, multiActive)).length;
|
||||
const tokens: SummaryToken[] = [
|
||||
head,
|
||||
lit(
|
||||
hasFilter && eligible < total
|
||||
? ` · ${eligible} of ${total} options`
|
||||
: ` · ${total} option${total === 1 ? "" : "s"}`,
|
||||
),
|
||||
];
|
||||
// Multi-pick hint — surface when the instance resolves more than one.
|
||||
if (pmax > 1) {
|
||||
tokens.push(lit(pmin === pmax ? ` · picks ${pmin}` : ` · picks ${pmin}-${pmax}`));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
case "combine": {
|
||||
// Same per-instance precedence as wildcard: rebinding via the v2
|
||||
@@ -3661,11 +3942,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;
|
||||
@@ -3682,6 +3984,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
|
||||
@@ -3765,7 +4090,7 @@ function openEditModal(idx: number) {
|
||||
editingIdx.value = idx;
|
||||
}
|
||||
|
||||
function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
function saveEditedModule(updated: ModuleEntry & { _originalId?: string; _relinkFrom?: string }) {
|
||||
// Phase B: index-based replacement — sibling rows share `m.id`, so
|
||||
// mapping `m.id === updated.id` would clobber every sibling at once.
|
||||
// We use `editingIdx` captured at modal-open to target the specific
|
||||
@@ -3776,11 +4101,42 @@ function saveEditedModule(updated: ModuleEntry & { _originalId?: string }) {
|
||||
editingIdx.value = null;
|
||||
return;
|
||||
}
|
||||
// `_relinkFrom` (the OLD id) is set only on the re-link path — after the
|
||||
// row is committed with its new id, walk the whole list so sibling @{}
|
||||
// refs + constraint source/target follow. Captured before we strip markers.
|
||||
const relinkFrom = updated._relinkFrom;
|
||||
const cleaned: ModuleEntry = { ...updated };
|
||||
delete (cleaned as { _originalId?: string })._originalId;
|
||||
delete (cleaned as { _relinkFrom?: string })._relinkFrom;
|
||||
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 (relinkFrom && relinkFrom !== updated.id) {
|
||||
// The re-linked row already carries its new id (list[targetIdx]); walk
|
||||
// EVERY module so sibling nested @{} refs + constraint source/target that
|
||||
// pointed at the old uuid follow to the library uuid. applyRelink leaves
|
||||
// the already-swapped row's id alone (its id !== relinkFrom).
|
||||
const remapped = applyRelink(
|
||||
value.value.modules as unknown as Record<string, unknown>[],
|
||||
{ oldId: relinkFrom, newId: updated.id, newPayloadHash: updated.payload_hash ?? "" },
|
||||
) as unknown as ModuleEntry[];
|
||||
commitModules(remapped);
|
||||
}
|
||||
if (updated._originalId && updated._originalId !== updated.id) {
|
||||
nextTick(() => {
|
||||
const el = document.querySelector<HTMLElement>(
|
||||
@@ -3795,6 +4151,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
|
||||
@@ -3998,6 +4366,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
|
||||
@@ -4006,6 +4394,10 @@ function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
|
||||
items.push({
|
||||
label: "Refresh from library",
|
||||
icon: "pi-refresh",
|
||||
// Accented + explained: drift is the state where the menu HAS the fix
|
||||
// for the badge the user just saw, so it should say so.
|
||||
subtitle: "Library changed since insert — pull the newer version",
|
||||
accent: true,
|
||||
onSelect: () => { void refreshOne(idx); },
|
||||
divider: true,
|
||||
});
|
||||
@@ -4018,11 +4410,17 @@ function openContextMenu(ev: MouseEvent, m: ModuleEntry, idx: number) {
|
||||
// greys out the "Update existing" button when payload_hash is empty.
|
||||
if (!!m.payload) {
|
||||
const missing = isMissingFromLibrary(m);
|
||||
// Local overrides with no drift → pushing is what the user probably wants.
|
||||
// Ranked below `missing` so only one library action ever accents.
|
||||
const modified = !missing && !isDrifted(m) && isModified(m);
|
||||
let subtitle: string | undefined;
|
||||
if (missing) subtitle = "Not linked to a library entry — re-link or add";
|
||||
else if (modified) subtitle = "Local edits since insert — save them to the library";
|
||||
items.push({
|
||||
label: "Push to library…",
|
||||
icon: "pi-cloud-upload",
|
||||
subtitle: missing ? "Library entry deleted — re-add as new entry" : undefined,
|
||||
accent: missing,
|
||||
subtitle,
|
||||
accent: missing || modified,
|
||||
onSelect: () => openPushToLibrary(idx),
|
||||
divider: true,
|
||||
});
|
||||
@@ -4684,8 +5082,33 @@ const moduleRowCtx: ModuleRowCtx = {
|
||||
pairingFor,
|
||||
viaInboundFor,
|
||||
contributorsFor,
|
||||
currentFrame,
|
||||
isHeld,
|
||||
isOverriddenOnFrame,
|
||||
effectiveLockedSeed,
|
||||
effectiveEnabled,
|
||||
frameEnableOverride,
|
||||
};
|
||||
provide(ModuleRowCtxKey, moduleRowCtx);
|
||||
// Pools this node holds, for the nested-ref hover card. Provided rather than
|
||||
// threaded: `RefChip` sits six components below here (row → edit modal →
|
||||
// section → value chips → preview → chip) and not one layer in between has any
|
||||
// business knowing about option pools. Recomputes with the module list, so
|
||||
// picking or removing a wildcard moves the card's numbers immediately.
|
||||
provide(CONTEXT_POOLS_KEY, computed(() => buildContextPools(value.value.modules)));
|
||||
// Companion lookup for the same card: when the ref falls back to the library,
|
||||
// this names any OTHER node holding the pool, so "why isn't it using the one
|
||||
// I can see right there" has an answer on the card instead of being a puzzle.
|
||||
provide(FOREIGN_POOL_LOOKUP_KEY, (uuid: string): string[] =>
|
||||
props.wildcardHomesReader?.(uuid) ?? []);
|
||||
// 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,
|
||||
@@ -4695,7 +5118,10 @@ const bundleFrameCtx: BundleFrameCtx = {
|
||||
bundleLockState,
|
||||
bundleHeaderGap,
|
||||
isBundleDropTarget,
|
||||
isBundleSnapshotModified: (b: BundleInstance) => bundleSnapshotModified(b, value.value.modules),
|
||||
// Suppressed for an unlinked bundle — there is no insert for its children to
|
||||
// have diverged from, so MOD would be diffing against a ghost.
|
||||
isBundleSnapshotModified: (b: BundleInstance) =>
|
||||
!isBundleUnlinked(b) && bundleSnapshotModified(b, value.value.modules),
|
||||
recentDropUids,
|
||||
pulseDelayFor,
|
||||
toggleBundleCollapsed,
|
||||
@@ -4944,14 +5370,17 @@ provide(BundleFrameCtxKey, bundleFrameCtx);
|
||||
:visible="editingModule !== null"
|
||||
:module="editingModule"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="allVarProducers"
|
||||
:upstream-resolved="resolvedForEditing"
|
||||
:sibling-vars="siblingNodeVars"
|
||||
:sibling-modules="value.modules"
|
||||
: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 { ref, computed, watch } from "vue";
|
||||
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import type { ModuleEntry, ModuleEntryKind } from "../../widgets/_shared";
|
||||
import { type ResolvedValue } from "../../widgets/richTokenize";
|
||||
@@ -17,6 +18,7 @@ import CombineInstanceModal from "./editors/combine/CombineInstanceModal.vue";
|
||||
import DerivationInstanceModal from "./editors/derivation/DerivationInstanceModal.vue";
|
||||
import ConstraintInstanceModal from "./editors/constraint/ConstraintInstanceModal.vue";
|
||||
import type { ChainModule, PairingBadge } from "../../extension/constraint-pairs";
|
||||
import type { VarProducerLike } from "../../manager/components/RefChip.vue";
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,6 +26,9 @@ const props = defineProps<{
|
||||
module: ModuleEntry | null;
|
||||
/** Variable names defined upstream — used for autocomplete + validity checks. */
|
||||
upstreamVars?: string[];
|
||||
/** `$var` → its producing module + node. Forwarded to the per-kind modals so
|
||||
* var chips can name where a value comes from. */
|
||||
upstreamProducers?: Record<string, VarProducerLike>;
|
||||
/** Resolved upstream `$name → value` map. Combine modal uses this
|
||||
* to render a live preview pane with vars substituted. */
|
||||
upstreamResolved?: Record<string, ResolvedValue>;
|
||||
@@ -51,11 +56,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,18 +72,22 @@ 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 {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
draft.value = null;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
// Keys arrive from ModalShell, not from window: the modal shield stops
|
||||
// inside-modal keys at the overlay so they cannot reach ComfyUI's global
|
||||
// shortcuts, which means a window listener here would never fire.
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (!props.visible) return;
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
|
||||
@@ -239,7 +252,7 @@ function onSaveToLibraryClick(): void {
|
||||
}
|
||||
|
||||
interface PushSaveResult {
|
||||
mode: "update" | "fork" | "reattach";
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
id: string;
|
||||
payload_hash: string;
|
||||
bundles_updated: string[];
|
||||
@@ -253,12 +266,17 @@ function onPushToLibrarySaved(result: PushSaveResult): void {
|
||||
return;
|
||||
}
|
||||
setLibraryHash(result.id, result.payload_hash);
|
||||
if (result.mode === "fork" || result.mode === "reattach") {
|
||||
if (result.mode === "fork" || result.mode === "reattach" || result.mode === "relink") {
|
||||
// Fork: the new entry replaces the draft's identity; `_originalId`
|
||||
// lets saveEditedModule swap the row by old id during the next
|
||||
// save(). Reattach: same wiring — the dead uuid gets swapped for
|
||||
// the new one so MISSING clears.
|
||||
(draft.value as ModuleEntry & { _originalId?: string })._originalId = draft.value.id;
|
||||
// the new one so MISSING clears. Relink: point the detached row at an
|
||||
// EXISTING library uuid; `_relinkFrom` (the OLD id, captured before the
|
||||
// swap) additionally tells saveEditedModule to remap sibling @{} refs +
|
||||
// constraint source/target so cross-references follow the new uuid.
|
||||
const marked = draft.value as ModuleEntry & { _originalId?: string; _relinkFrom?: string };
|
||||
marked._originalId = draft.value.id;
|
||||
if (result.mode === "relink") marked._relinkFrom = draft.value.id;
|
||||
draft.value.id = result.id;
|
||||
draft.value.payload_hash = result.payload_hash;
|
||||
draft.value.meta = {
|
||||
@@ -269,7 +287,9 @@ function onPushToLibrarySaved(result: PushSaveResult): void {
|
||||
pushToast(
|
||||
result.mode === "reattach"
|
||||
? `Re-attached "${result.name}" to library`
|
||||
: `Saved as new library entry "${result.name}"`,
|
||||
: result.mode === "relink"
|
||||
? `Re-linked "${result.name}" to library`
|
||||
: `Saved as new library entry "${result.name}"`,
|
||||
{ severity: "success" },
|
||||
);
|
||||
save();
|
||||
@@ -381,7 +401,24 @@ function cancel() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="cancel">
|
||||
<ModalShell :visible="visible" @close="cancel" @keydown="onKeydown">
|
||||
<!-- Column so the frame banner stacks ABOVE the per-kind modal card
|
||||
(each per-kind modal renders a plain card; ModalShell's wrapper is
|
||||
a centered flex ROW, so a bare banner sibling would land beside the
|
||||
card). align-self:stretch on the banner spans it to the card width. -->
|
||||
<div class="wp-frame-edit-body">
|
||||
<!-- Frame-context banner — shown when the user is editing overrides
|
||||
for a specific iteration frame (#k). Sits above all per-kind
|
||||
modal bodies. Invisible on base editing (currentFrame == null). -->
|
||||
<div
|
||||
v-if="currentFrame != null"
|
||||
class="wp-modal-frame-banner"
|
||||
data-test="modal-frame-banner"
|
||||
>
|
||||
<span>Editing frame #{{ currentFrame + 1 }} — changes affect only this frame.</span>
|
||||
<button 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). -->
|
||||
@@ -390,8 +427,10 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="upstreamProducers"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -404,6 +443,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"
|
||||
@@ -419,8 +459,10 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="upstreamProducers"
|
||||
:upstream-resolved="upstreamResolved"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -440,8 +482,10 @@ function cancel() {
|
||||
:module="draft"
|
||||
:is-modified="instanceModified"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-producers="upstreamProducers"
|
||||
:sibling-vars="siblingVars"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:frame-active="currentFrame != null"
|
||||
@update="onUpdate"
|
||||
@save="save"
|
||||
@cancel="cancel"
|
||||
@@ -460,13 +504,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 +548,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>
|
||||
|
||||
|
||||
@@ -316,7 +316,14 @@
|
||||
:data-kind="byId.get(id)?.type"
|
||||
>
|
||||
<span class="wp-picker__sel-dot"></span>
|
||||
{{ byId.get(id)?.name ?? id }}
|
||||
<!-- Wrapped in its own span so it can ellipsis-clip. As a bare
|
||||
text node it wrapped onto a second line inside a chip fixed at
|
||||
22px tall, so a long name like "Accent piece (color-neutral)"
|
||||
spilled out of its own pill. -->
|
||||
<span
|
||||
class="wp-picker__sel-name"
|
||||
:title="byId.get(id)?.name ?? id"
|
||||
>{{ byId.get(id)?.name ?? id }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-picker__sel-close"
|
||||
@@ -1443,6 +1450,12 @@ onBeforeUnmount(detachCaptureListeners);
|
||||
font-weight: 500;
|
||||
max-width: 200px;
|
||||
}
|
||||
.wp-picker__sel-name {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wp-picker__sel-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -433,7 +433,10 @@ const multiEntryTitle = computed<string>(() =>
|
||||
|
||||
.wp-pair-pop {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
/* Hover-card tier — above the popover tier (10020), since a pair badge can
|
||||
be rendered inside a popover and its card must clear it. Matches
|
||||
RefChip's hover card. */
|
||||
z-index: 10030;
|
||||
width: 280px;
|
||||
padding: 8px 10px;
|
||||
background: var(--wp-bg-1, var(--wp-bg2, #1a1d24));
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* PushBundleToLibraryModal — bundle re-link (D3a bundle side).
|
||||
*
|
||||
* A workflow BundleInstance is "detached" when its `library_id` is no longer
|
||||
* in the polled `bundleHashes` map (a re-import minted a fresh uuid). Its
|
||||
* `inserted_at_hash` IS the library bundle's payload_hash at insert time, so
|
||||
* a content-identical library bundle under a DIFFERENT uuid matches on it —
|
||||
* the same signal the module re-link uses.
|
||||
*/
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
vi.mock("#comfyui/app", () => ({
|
||||
app: { graph: { _nodes: [], _nodes_by_id: {} } },
|
||||
}));
|
||||
|
||||
import PushBundleToLibraryModal from "./PushBundleToLibraryModal.vue";
|
||||
import { bundleHashes } from "./drift-store";
|
||||
import type { BundleInstance } from "../../widgets/_shared";
|
||||
|
||||
function makeBundle(over: Partial<BundleInstance> = {}): BundleInstance {
|
||||
return {
|
||||
_uid: "buid0001",
|
||||
library_id: "dead0001",
|
||||
name: "Environments",
|
||||
color: null,
|
||||
enabled: true,
|
||||
collapsed: false,
|
||||
parent_uid: null,
|
||||
start_idx: 0,
|
||||
end_idx: 0,
|
||||
inserted_at_hash: "HASH_A",
|
||||
...over,
|
||||
} as BundleInstance;
|
||||
}
|
||||
|
||||
function resetBody(): void {
|
||||
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetBody();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
resetBody();
|
||||
bundleHashes.value = null;
|
||||
});
|
||||
|
||||
async function openModal(bundle: BundleInstance | null) {
|
||||
const wrap = mount(PushBundleToLibraryModal, {
|
||||
attachTo: document.body,
|
||||
props: { open: false, bundle: null, childrenForLibrary: [], childrenPreview: [] },
|
||||
});
|
||||
await wrap.setProps({ open: true, bundle, childrenForLibrary: [], childrenPreview: [] });
|
||||
await flushPromises();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
describe("PushBundleToLibraryModal re-link", () => {
|
||||
it("offers re-link to a content-identical library bundle when detached", async () => {
|
||||
// A DIFFERENT uuid carries this instance's inserted_at_hash.
|
||||
bundleHashes.value = { live0001: "HASH_A" };
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/wp/api/bundles")) {
|
||||
return new Response(
|
||||
JSON.stringify({ items: [{ id: "live0001", name: "Environments" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}));
|
||||
|
||||
const wrap = await openModal(makeBundle());
|
||||
await flushPromises();
|
||||
|
||||
expect(document.querySelector('[data-test="pbtl-relink"]')).not.toBeNull();
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-test="pbtl-relink-confirm"]')!.click();
|
||||
await flushPromises();
|
||||
|
||||
const saved = wrap.emitted("saved");
|
||||
expect(saved).toBeTruthy();
|
||||
const result = saved![0][0] as { mode: string; id: string; origId: string };
|
||||
expect(result.mode).toBe("relink");
|
||||
expect(result.id).toBe("live0001");
|
||||
expect(result.origId).toBe("dead0001");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does NOT offer re-link when the bundle is still linked", async () => {
|
||||
// library_id present in the live map → not detached.
|
||||
bundleHashes.value = { dead0001: "HASH_A" };
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 200 })));
|
||||
const wrap = await openModal(makeBundle());
|
||||
await flushPromises();
|
||||
expect(document.querySelector('[data-test="pbtl-relink"]')).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does NOT offer re-link when no library bundle matches the content", async () => {
|
||||
bundleHashes.value = { live0009: "OTHER_HASH" };
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/wp/api/bundles")) {
|
||||
return new Response(
|
||||
JSON.stringify({ items: [{ id: "live0009", name: "Unrelated" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}));
|
||||
const wrap = await openModal(makeBundle({ name: "Environments" }));
|
||||
await flushPromises();
|
||||
expect(document.querySelector('[data-test="pbtl-relink"]')).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,12 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { bundleHashes } from "./drift-store";
|
||||
import { api } from "../../manager/api/client";
|
||||
import {
|
||||
findRelinkCandidates,
|
||||
autoRelinkTarget,
|
||||
type RelinkCandidate,
|
||||
} from "../../manager/import-export/relink-match";
|
||||
import { bundleContentKey } from "./bundles/bundle-fingerprint";
|
||||
import type { BundleInstance } from "../../widgets/_shared";
|
||||
import WpCheck from "@/components/shared/WpCheck.vue";
|
||||
|
||||
@@ -51,10 +57,12 @@ interface SaveResult {
|
||||
* "(copy)" suffix; workflow row keeps its old library_id. `reattach`
|
||||
* — POST when source entry was missing; no copy suffix, caller
|
||||
* should rebind the workflow row's library_id to the new uuid so
|
||||
* MISSING clears. */
|
||||
mode: "update" | "fork" | "reattach";
|
||||
/** New library uuid when forking/reattaching; same as bundle.library_id
|
||||
* when updating. */
|
||||
* MISSING clears. `relink` — no write: point the detached instance at
|
||||
* an EXISTING content-identical library bundle; caller rebinds
|
||||
* library_id + inserted_at_hash. */
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
/** New library uuid when forking/reattaching/re-linking; same as
|
||||
* bundle.library_id when updating. */
|
||||
id: string;
|
||||
/** payload_hash returned by the server. */
|
||||
payload_hash: string;
|
||||
@@ -122,10 +130,120 @@ const isLibraryMissing = computed(() => {
|
||||
return !(props.bundle.library_id in bundleHashes.value);
|
||||
});
|
||||
|
||||
/** Whether to OFFER the re-link picker — the bundle-side mirror of the module
|
||||
* modal's `needsRelink`. Wider than `isLibraryMissing`, which additionally
|
||||
* requires a `library_id`: a bundle that lost its link (shared workflow,
|
||||
* stale cache) still corresponds to a library entry, and matching it on child
|
||||
* content is what `bundleContentKey` is for. Costs nothing when there is no
|
||||
* match — the picker only renders once candidates come back. */
|
||||
const needsRelink = computed(() => {
|
||||
const b = props.bundle;
|
||||
if (!b || bundleHashes.value === null) return false;
|
||||
return !b.library_id || !(b.library_id in bundleHashes.value);
|
||||
});
|
||||
|
||||
const canUpdateExisting = computed(
|
||||
() => isLibraryTracked.value && !isLibraryMissing.value,
|
||||
);
|
||||
|
||||
/** uuid -> display name for every live library bundle, fetched once when the
|
||||
* instance turns out to be detached. `bundleHashes` carries the content hash
|
||||
* but not names, so the re-link picker needs this to label + weakly-match. */
|
||||
const relinkNames = ref<Map<string, { name: string; type: string }>>(new Map());
|
||||
|
||||
/** uuid → child-content key for every live library bundle. Only consulted when
|
||||
* the instance carries no `inserted_at_hash`. */
|
||||
const relinkContentKeys = ref<Map<string, string>>(new Map());
|
||||
|
||||
async function fetchBundleNames(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/wp/api/bundles");
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as
|
||||
| { items?: Array<{ id?: string; name?: string; children?: unknown }> }
|
||||
| Array<{ id?: string; name?: string; children?: unknown }>;
|
||||
const items = Array.isArray(data) ? data : (data.items ?? []);
|
||||
const next = new Map<string, { name: string; type: string }>();
|
||||
const keys = new Map<string, string>();
|
||||
for (const it of items) {
|
||||
if (typeof it.id === "string" && it.id) {
|
||||
next.set(it.id, { name: it.name ?? "", type: "bundle" });
|
||||
if (Array.isArray(it.children)) {
|
||||
keys.set(it.id, bundleContentKey(
|
||||
it.children as Array<{ type?: string; payload_hash?: string }>,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
relinkNames.value = next;
|
||||
relinkContentKeys.value = keys;
|
||||
} catch {
|
||||
/* network — leave empty; no re-link offered */
|
||||
}
|
||||
}
|
||||
|
||||
/** Content-aware re-link candidates for a DETACHED bundle instance. The
|
||||
* instance's `inserted_at_hash` IS the library bundle's payload_hash at
|
||||
* insert time, so an identical-content library bundle under a different uuid
|
||||
* matches on it exactly — the same signal the module re-link uses. */
|
||||
const relinkCandidates = computed<RelinkCandidate[]>(() => {
|
||||
const b = props.bundle;
|
||||
if (!b || !needsRelink.value || bundleHashes.value === null) return [];
|
||||
const live: Record<string, { type?: string; payload_hash: string; contentKey?: string }> = {};
|
||||
for (const [uuid, h] of Object.entries(bundleHashes.value)) {
|
||||
live[uuid] = { type: "bundle", payload_hash: h, contentKey: relinkContentKeys.value.get(uuid) };
|
||||
}
|
||||
return findRelinkCandidates(
|
||||
{
|
||||
id: b.library_id,
|
||||
type: "bundle",
|
||||
payload_hash: b.inserted_at_hash,
|
||||
// Only when there's no stored hash to compare — the server hash stays
|
||||
// authoritative whenever it exists.
|
||||
contentKey: b.inserted_at_hash
|
||||
? undefined
|
||||
: bundleContentKey(
|
||||
props.childrenForLibrary as Array<{ type?: string; payload_hash?: string }>,
|
||||
),
|
||||
name: b.name ?? "",
|
||||
},
|
||||
live,
|
||||
(u) => relinkNames.value.get(u),
|
||||
);
|
||||
});
|
||||
|
||||
const relinkAuto = computed(() => autoRelinkTarget(relinkCandidates.value));
|
||||
const relinkPick = ref<RelinkCandidate | null>(null);
|
||||
const relinkQuery = ref<string>("");
|
||||
const relinkFiltered = computed<RelinkCandidate[]>(() => {
|
||||
const q = relinkQuery.value.trim().toLowerCase();
|
||||
if (!q) return relinkCandidates.value;
|
||||
return relinkCandidates.value.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.uuid.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
const relinkTarget = computed<RelinkCandidate | null>(
|
||||
() => relinkPick.value ?? relinkAuto.value,
|
||||
);
|
||||
|
||||
/** Danger tone only when nothing can be reconnected — a re-linkable bundle is
|
||||
* a routine state, and red steered users toward the duplicate-making path. */
|
||||
const relinkNoteClass = computed(() =>
|
||||
relinkCandidates.value.length > 0 ? "wp-ptl-note--info" : "wp-ptl-note--danger",
|
||||
);
|
||||
|
||||
function doRelink(): void {
|
||||
const t = relinkTarget.value;
|
||||
if (!t || !props.bundle) return;
|
||||
emit("saved", {
|
||||
mode: "relink",
|
||||
id: t.uuid,
|
||||
payload_hash: t.payloadHash,
|
||||
name: t.name,
|
||||
origId: props.bundle.library_id,
|
||||
});
|
||||
}
|
||||
|
||||
const tagsParsed = computed<string[]>(() =>
|
||||
tagsText.value
|
||||
.split(",")
|
||||
@@ -144,8 +262,17 @@ function resetForm(): void {
|
||||
// into library, so we make the safer choice the default and let the
|
||||
// user opt out.
|
||||
cascadeEnabled.value = cascadeAvailable.value;
|
||||
relinkPick.value = null;
|
||||
relinkQuery.value = "";
|
||||
}
|
||||
|
||||
// Re-link candidates only matter for a DETACHED instance — skip the extra
|
||||
// library-list fetch when the bundle is tracked + present. A drift poll can
|
||||
// flip it to detached after open, so also fetch the first time that happens.
|
||||
watch(needsRelink, (missing) => {
|
||||
if (props.open && missing && relinkNames.value.size === 0) void fetchBundleNames();
|
||||
});
|
||||
|
||||
/** Seed description + tags from the live library entry when the
|
||||
* bundle is tracked. Skips on missing — the entry is gone. Only
|
||||
* fills empty form fields so a mid-edit doesn't get clobbered. */
|
||||
@@ -170,6 +297,7 @@ watch(
|
||||
if (!isOpen) return;
|
||||
resetForm();
|
||||
void seedFromLibrary();
|
||||
if (needsRelink.value) void fetchBundleNames();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -438,10 +566,68 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="isLibraryMissing" class="wp-ptl-note wp-ptl-note--danger" data-test="pbtl-missing">
|
||||
The library entry for this bundle has been deleted upstream.
|
||||
"Update existing" is unavailable — use "Save as new entry" to
|
||||
re-add it to the library.
|
||||
<div
|
||||
v-if="needsRelink && relinkCandidates.length"
|
||||
class="wp-ptl-relink"
|
||||
data-test="pbtl-relink"
|
||||
>
|
||||
<div class="wp-ptl-relink__title">Re-link to an existing library bundle</div>
|
||||
<p class="wp-ptl-relink__hint">
|
||||
This bundle's content matches a library entry under a different id (a re-import
|
||||
minted a new one). Re-link to reconnect drift + reset instead of adding a duplicate.
|
||||
</p>
|
||||
<input
|
||||
v-if="relinkCandidates.length > 1"
|
||||
v-model="relinkQuery"
|
||||
type="text"
|
||||
class="wp-ptl-input"
|
||||
data-test="pbtl-relink-search"
|
||||
placeholder="Search by name or id…"
|
||||
aria-label="Search library bundles to re-link"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ul class="wp-ptl-relink__list">
|
||||
<li
|
||||
v-for="c in relinkFiltered"
|
||||
:key="c.uuid"
|
||||
class="wp-ptl-relink__cand"
|
||||
:class="{ 'is-picked': relinkTarget != null && relinkTarget.uuid === c.uuid }"
|
||||
data-test="pbtl-relink-candidate"
|
||||
@click="relinkPick = c"
|
||||
>
|
||||
<span class="wp-ptl-relink__name">{{ c.name || "(unnamed)" }}</span>
|
||||
<code class="wp-ptl-relink__uuid">{{ c.uuid }}</code>
|
||||
<span
|
||||
class="wp-ptl-relink__tag"
|
||||
:class="c.contentIdentical ? 'is-identical' : 'is-diff'"
|
||||
>{{ c.contentIdentical ? "identical" : "content differs" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-ptl-btn wp-ptl-btn--primary"
|
||||
data-test="pbtl-relink-confirm"
|
||||
:disabled="!relinkTarget || undefined"
|
||||
@click="doRelink"
|
||||
>Re-link<template v-if="relinkTarget"> to “{{ relinkTarget.name || relinkTarget.uuid }}”</template></button>
|
||||
</div>
|
||||
|
||||
<!-- States the observable fact, not a guessed cause — see the
|
||||
matching note in PushToLibraryModal. Danger tone only when
|
||||
there is genuinely nothing to reconnect to. -->
|
||||
<div v-if="needsRelink" class="wp-ptl-note" :class="relinkNoteClass" data-test="pbtl-missing">
|
||||
<template v-if="relinkCandidates.length > 0">
|
||||
This bundle isn’t linked to any library entry under its recorded id.
|
||||
{{ relinkTarget?.contentIdentical
|
||||
? "A library bundle with identical children is selected above — re-linking reconnects them."
|
||||
: "Pick a match above to re-link, or save it as a new entry." }}
|
||||
</template>
|
||||
<template v-else>
|
||||
This bundle isn’t linked to any library entry under its recorded id, and
|
||||
nothing in the library matches its children or name. “Update existing” is
|
||||
unavailable — “Save as new entry” adds it to the library.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="wp-ptl-error" data-test="pbtl-error">
|
||||
@@ -538,7 +724,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
font-family: inherit;
|
||||
}
|
||||
.wp-ptl-input:focus { outline: 1px solid var(--wp-accent-500, #8b5cf6); }
|
||||
.wp-ptl-input--multi { resize: vertical; min-height: 48px; }
|
||||
.wp-ptl-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 48px; }
|
||||
.wp-ptl-children {
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
@@ -630,6 +816,50 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 32%, transparent);
|
||||
color: var(--wp-danger, #ef4444);
|
||||
}
|
||||
/* Informational tone for the "pick a match" case — mirrors PushToLibraryModal. */
|
||||
.wp-ptl-note--info {
|
||||
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
|
||||
color: var(--wp-text, #e7e7ee);
|
||||
}
|
||||
.wp-ptl-relink {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 8%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wp-ptl-relink__title { font-size: 12.5px; font-weight: 600; color: var(--wp-text, #e7e7ee); }
|
||||
.wp-ptl-relink__hint { margin: 0; font-size: 11.5px; color: var(--wp-text-muted, #a1a1ad); }
|
||||
.wp-ptl-relink__list {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
max-height: 160px; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.wp-ptl-relink__cand {
|
||||
display: flex; align-items: baseline; gap: 8px;
|
||||
padding: 5px 8px; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.wp-ptl-relink__cand:hover { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 16%, transparent); }
|
||||
.wp-ptl-relink__cand.is-picked { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 28%, transparent); }
|
||||
.wp-ptl-relink__name { font-size: 12px; color: var(--wp-text, #e7e7ee); }
|
||||
.wp-ptl-relink__uuid { font-family: var(--wp-font-mono, monospace); font-size: 10px; color: var(--wp-text-dim, #6e6e7c); }
|
||||
.wp-ptl-relink__tag {
|
||||
margin-left: auto; flex-shrink: 0;
|
||||
font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
}
|
||||
.wp-ptl-relink__tag.is-identical {
|
||||
color: var(--wp-green, #6ad28b);
|
||||
background: color-mix(in oklab, var(--wp-green, #6ad28b) 16%, transparent);
|
||||
}
|
||||
.wp-ptl-relink__tag.is-diff {
|
||||
color: var(--wp-warn, #facc15);
|
||||
background: color-mix(in oklab, var(--wp-warn, #facc15) 16%, transparent);
|
||||
}
|
||||
.wp-ptl-relink .wp-ptl-btn--primary { align-self: flex-start; }
|
||||
.wp-ptl-error {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("#comfyui/app", () => ({
|
||||
}));
|
||||
|
||||
import PushToLibraryModal from "./PushToLibraryModal.vue";
|
||||
import { hashes } from "./drift-store";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
const VALID_PAYLOAD = {
|
||||
@@ -86,6 +87,9 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
resetBody();
|
||||
// The re-link branch reads the drift-store live hashes; null it out so a
|
||||
// set value can't leak into other tests and falsely trip isLibraryMissing.
|
||||
hashes.value = null;
|
||||
});
|
||||
|
||||
async function openModal(draft: ModuleEntry | null) {
|
||||
@@ -247,6 +251,41 @@ describe("PushToLibraryModal", () => {
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("offers re-link to a content-identical library row when the draft is detached", async () => {
|
||||
// A DIFFERENT uuid carries the draft's payload_hash — the re-imported twin.
|
||||
hashes.value = { live0001: { type: "wildcard", payload_hash: "HASH_A" } };
|
||||
pushResponse({ items: [] }); // fetchBundlesContaining
|
||||
pushResponse({ description: "", tags: [] }); // seedFromLibrary
|
||||
pushResponse({ items: [{ id: "live0001", name: "test_w", type: "wildcard" }] }); // fetchLibraryNames
|
||||
const wrap = await openModal(makeDraft({ id: "dead0001", payload_hash: "HASH_A" }));
|
||||
|
||||
const relink = document.querySelector('[data-test="ptl-relink"]');
|
||||
expect(relink).not.toBeNull();
|
||||
|
||||
document.querySelector<HTMLButtonElement>('[data-test="ptl-relink-confirm"]')!.click();
|
||||
await flushPromises();
|
||||
|
||||
const saved = wrap.emitted("saved");
|
||||
expect(saved).toBeTruthy();
|
||||
const result = saved![0][0] as { mode: string; id: string; origId: string };
|
||||
expect(result.mode).toBe("relink");
|
||||
expect(result.id).toBe("live0001");
|
||||
expect(result.origId).toBe("dead0001");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does not offer re-link when no content match exists (detached, novel content)", async () => {
|
||||
// Library has a different-content row only → no identical + no name match
|
||||
// for a differently-named draft → no re-link block (Save as new is the path).
|
||||
hashes.value = { live0009: { type: "wildcard", payload_hash: "OTHER" } };
|
||||
pushResponse({ items: [] }); // bundles
|
||||
pushResponse({ description: "", tags: [] }); // seedFromLibrary
|
||||
pushResponse({ items: [{ id: "live0009", name: "unrelated", type: "wildcard" }] }); // names
|
||||
const wrap = await openModal(makeDraft({ id: "dead0002", payload_hash: "HASH_Z" }));
|
||||
expect(document.querySelector('[data-test="ptl-relink"]')).toBeNull();
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("opt-out checkbox round-trips into the PUT body", async () => {
|
||||
pushResponse({ items: [{ id: "b1", name: "framing" }] });
|
||||
pushResponse({ description: "", tags: [] }); // seedFromLibrary
|
||||
|
||||
@@ -27,6 +27,12 @@ import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { workflowSiblingCount } from "./duplicates/sibling-count";
|
||||
import { forkModule } from "./duplicates/fork";
|
||||
import { hashes as libraryHashes } from "./drift-store";
|
||||
import {
|
||||
findRelinkCandidates,
|
||||
autoRelinkTarget,
|
||||
type RelinkCandidate,
|
||||
} from "../../manager/import-export/relink-match";
|
||||
import { localPayloadFingerprint } from "../../manager/import-export/fingerprint";
|
||||
import { app } from "#comfyui/app";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import WpCheck from "@/components/shared/WpCheck.vue";
|
||||
@@ -41,8 +47,10 @@ interface SaveResult {
|
||||
* entry with a "(copy)" suffix; workflow row keeps its old id.
|
||||
* `reattach` — fork when the source entry was missing upstream;
|
||||
* no copy suffix, AND the caller should rebind the workflow row to
|
||||
* the new uuid so MISSING clears. */
|
||||
mode: "update" | "fork" | "reattach";
|
||||
* the new uuid so MISSING clears. `relink` — no write at all: point
|
||||
* the detached workflow row at an EXISTING content-identical library
|
||||
* uuid; the caller swaps the row id + remaps sibling refs. */
|
||||
mode: "update" | "fork" | "reattach" | "relink";
|
||||
/** New library uuid when forking; same as draft.id when updating. */
|
||||
id: string;
|
||||
/** payload_hash returned by the server after the write. */
|
||||
@@ -88,6 +96,22 @@ const isLibraryMissing = computed(() => {
|
||||
return !(props.draft.id in libraryHashes.value);
|
||||
});
|
||||
|
||||
/** Whether to OFFER the re-link picker.
|
||||
*
|
||||
* Wider than `isLibraryMissing`, which requires a payload_hash. A hash-less
|
||||
* row was treated as "never library-tracked, nothing to re-link" — true for a
|
||||
* module authored in the widget, wrong for the cases that actually bite: a
|
||||
* shared workflow, or a row whose stored hash was lost. Those still correspond
|
||||
* to a library entry, and matching them is what `contentKey` is for.
|
||||
*
|
||||
* Costs nothing when there IS no match: the picker only renders when
|
||||
* candidates come back, so a genuinely new module still sees the plain
|
||||
* save-as-new flow. */
|
||||
const needsRelink = computed(() => {
|
||||
if (!props.draft || libraryHashes.value === null) return false;
|
||||
return !(props.draft.id in libraryHashes.value);
|
||||
});
|
||||
|
||||
/** "Update existing" is enabled only when the entry exists AND is
|
||||
* library-tracked. Disabled when never library-tracked OR when the
|
||||
* upstream entry has been deleted. */
|
||||
@@ -95,6 +119,107 @@ const canUpdateExisting = computed(
|
||||
() => isLibraryTracked.value && !isLibraryMissing.value,
|
||||
);
|
||||
|
||||
/** uuid → {name, type} for every live library module, fetched once on open.
|
||||
* Backs the re-link candidate name lookup + the (type, name) fallback match.
|
||||
* drift-store hashes carry (type, payload_hash) but not names, so the picker
|
||||
* needs this to label + weakly-match candidates. */
|
||||
const relinkNames = ref<Map<string, { name: string; type: string }>>(new Map());
|
||||
|
||||
/** uuid → locally computed payload fingerprint for every live library module.
|
||||
* Only consulted for a HASH-LESS draft, where the server hash can't decide.
|
||||
* Both sides are fingerprinted by the same local function, so this needs no
|
||||
* parity with the engine's own hashing. */
|
||||
const relinkContentKeys = ref<Map<string, string>>(new Map());
|
||||
|
||||
async function fetchLibraryNames(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/wp/api/modules");
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as
|
||||
| { items?: Array<{ id?: string; name?: string; type?: string; payload?: unknown }> }
|
||||
| Array<{ id?: string; name?: string; type?: string; payload?: unknown }>;
|
||||
const items = Array.isArray(data) ? data : (data.items ?? []);
|
||||
const next = new Map<string, { name: string; type: string }>();
|
||||
const keys = new Map<string, string>();
|
||||
for (const it of items) {
|
||||
if (typeof it.id === "string" && it.id) {
|
||||
next.set(it.id, { name: it.name ?? "", type: it.type ?? "" });
|
||||
if (it.payload !== undefined) {
|
||||
keys.set(it.id, localPayloadFingerprint(it.payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
relinkNames.value = next;
|
||||
relinkContentKeys.value = keys;
|
||||
} catch {
|
||||
/* network — leave the maps empty; no re-link offered */
|
||||
}
|
||||
}
|
||||
|
||||
/** Content-aware re-link candidates for a DETACHED draft: existing library
|
||||
* rows whose (type, payload_hash) match the draft's content (identical) or
|
||||
* whose (type, name) match (content differs). Empty unless the draft is
|
||||
* detached (`isLibraryMissing`). */
|
||||
const relinkCandidates = computed<RelinkCandidate[]>(() => {
|
||||
if (!props.draft || !needsRelink.value || libraryHashes.value === null) return [];
|
||||
// Merge the locally computed content keys into the hash map the matcher
|
||||
// walks, so a hash-less draft can still match on payload content.
|
||||
const live: Record<string, { type?: string; payload_hash: string; contentKey?: string }> = {};
|
||||
for (const [uuid, entry] of Object.entries(libraryHashes.value)) {
|
||||
live[uuid] = { ...entry, contentKey: relinkContentKeys.value.get(uuid) };
|
||||
}
|
||||
return findRelinkCandidates(
|
||||
{
|
||||
id: props.draft.id,
|
||||
type: props.draft.type,
|
||||
payload_hash: props.draft.payload_hash,
|
||||
contentKey: props.draft.payload_hash
|
||||
? undefined
|
||||
: localPayloadFingerprint(props.draft.payload),
|
||||
name: props.draft.meta?.name ?? "",
|
||||
},
|
||||
live,
|
||||
(u) => relinkNames.value.get(u),
|
||||
);
|
||||
});
|
||||
|
||||
/** The sole content-identical candidate, if unambiguous (else null). */
|
||||
const relinkAuto = computed(() => autoRelinkTarget(relinkCandidates.value));
|
||||
const relinkPick = ref<RelinkCandidate | null>(null);
|
||||
const relinkQuery = ref<string>("");
|
||||
const relinkFiltered = computed<RelinkCandidate[]>(() => {
|
||||
const q = relinkQuery.value.trim().toLowerCase();
|
||||
if (!q) return relinkCandidates.value;
|
||||
return relinkCandidates.value.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.uuid.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
/** The active re-link target: an explicit pick wins, else the unambiguous
|
||||
* auto-target (single identical candidate). */
|
||||
const relinkTarget = computed<RelinkCandidate | null>(
|
||||
() => relinkPick.value ?? relinkAuto.value,
|
||||
);
|
||||
|
||||
/** Danger tone only when there is genuinely nothing to reconnect to. With a
|
||||
* match on offer this is a routine "pick one" prompt, not a failure. */
|
||||
const relinkNoteClass = computed(() =>
|
||||
relinkCandidates.value.length > 0 ? "wp-ptl-note--info" : "wp-ptl-note--danger",
|
||||
);
|
||||
|
||||
function doRelink(): void {
|
||||
const t = relinkTarget.value;
|
||||
if (!t || !props.draft) return;
|
||||
emit("saved", {
|
||||
mode: "relink",
|
||||
id: t.uuid,
|
||||
payload_hash: t.payloadHash,
|
||||
bundles_updated: [],
|
||||
name: t.name,
|
||||
origId: props.draft.id,
|
||||
});
|
||||
}
|
||||
|
||||
const siblings = computed<number>(() => {
|
||||
if (!props.draft) return 0;
|
||||
try {
|
||||
@@ -133,6 +258,8 @@ function resetForm(): void {
|
||||
propagate.value = true;
|
||||
busy.value = false;
|
||||
errorMsg.value = "";
|
||||
relinkPick.value = null;
|
||||
relinkQuery.value = "";
|
||||
}
|
||||
|
||||
/** When the row is library-tracked, fetch the live library entry and
|
||||
@@ -184,9 +311,19 @@ watch(
|
||||
resetForm();
|
||||
void fetchBundlesContaining();
|
||||
void seedFromLibrary();
|
||||
// Re-link candidates only matter when the row's id ISN'T in the library —
|
||||
// skip the extra library-list fetch entirely when it's present.
|
||||
if (needsRelink.value) void fetchLibraryNames();
|
||||
},
|
||||
);
|
||||
|
||||
// A drift poll can flip the draft to unlinked AFTER open (hashes land late);
|
||||
// fetch the candidate map the first time that happens so re-link appears
|
||||
// without a reopen. One-shot: only when open, unlinked, and not yet loaded.
|
||||
watch(needsRelink, (unlinked) => {
|
||||
if (props.open && unlinked && relinkNames.value.size === 0) void fetchLibraryNames();
|
||||
});
|
||||
|
||||
function close(): void {
|
||||
if (busy.value) return;
|
||||
emit("close");
|
||||
@@ -411,10 +548,82 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
changes the library entry every instance reads from.
|
||||
</div>
|
||||
|
||||
<div v-if="isLibraryMissing" class="wp-ptl-note wp-ptl-note--danger" data-test="ptl-missing">
|
||||
The library entry for this module has been deleted upstream. "Update existing"
|
||||
is unavailable — use "Save as new entry" to re-add it to the library as a fresh
|
||||
entry.
|
||||
<div
|
||||
v-if="needsRelink && relinkCandidates.length"
|
||||
class="wp-ptl-relink"
|
||||
data-test="ptl-relink"
|
||||
>
|
||||
<div class="wp-ptl-relink__title">Re-link to an existing library entry</div>
|
||||
<p class="wp-ptl-relink__hint">
|
||||
This module's content matches a library entry under a different id (a re-import
|
||||
minted a new one). Re-link to reconnect drift + refresh instead of adding a duplicate.
|
||||
</p>
|
||||
<input
|
||||
v-if="relinkCandidates.length > 1"
|
||||
v-model="relinkQuery"
|
||||
type="text"
|
||||
class="wp-ptl-input"
|
||||
data-test="ptl-relink-search"
|
||||
placeholder="Search by name or id…"
|
||||
aria-label="Search library entries to re-link"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ul class="wp-ptl-relink__list">
|
||||
<li
|
||||
v-for="c in relinkFiltered"
|
||||
:key="c.uuid"
|
||||
class="wp-ptl-relink__cand"
|
||||
:class="{ 'is-picked': relinkTarget != null && relinkTarget.uuid === c.uuid }"
|
||||
data-test="ptl-relink-candidate"
|
||||
@click="relinkPick = c"
|
||||
>
|
||||
<span class="wp-ptl-relink__name">{{ c.name || "(unnamed)" }}</span>
|
||||
<code class="wp-ptl-relink__uuid">{{ c.uuid }}</code>
|
||||
<!-- "identical (content)" distinguishes a match established
|
||||
by locally fingerprinting the payload — the hash-less
|
||||
path — from one the server's own hash confirmed. Same
|
||||
confidence in practice, but the label shouldn't imply
|
||||
the authoritative hash agreed when there wasn't one. -->
|
||||
<span
|
||||
class="wp-ptl-relink__tag"
|
||||
:class="c.contentIdentical ? 'is-identical' : 'is-diff'"
|
||||
:title="c.matchedByContent
|
||||
? 'Matched by comparing payload content — this module carries no stored hash'
|
||||
: undefined"
|
||||
>{{ c.contentIdentical
|
||||
? (c.matchedByContent ? "identical (content)" : "identical")
|
||||
: "content differs" }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-ptl-btn wp-ptl-btn--primary"
|
||||
data-test="ptl-relink-confirm"
|
||||
:disabled="!relinkTarget || undefined"
|
||||
@click="doRelink"
|
||||
>Re-link<template v-if="relinkTarget"> to “{{ relinkTarget.name || relinkTarget.uuid }}”</template></button>
|
||||
</div>
|
||||
|
||||
<!-- States the OBSERVABLE fact and points at Re-link first when a
|
||||
match exists. The old copy asserted the entry "has been
|
||||
deleted upstream" — a cause it cannot know (a shared workflow,
|
||||
a re-import under a new id, or a lost hash all look identical
|
||||
from here) — and recommended "Save as new entry" while the
|
||||
Re-link panel sat directly above offering the better option,
|
||||
which is how duplicates got made. -->
|
||||
<div v-if="needsRelink" class="wp-ptl-note" :class="relinkNoteClass" data-test="ptl-missing">
|
||||
<template v-if="relinkCandidates.length > 0">
|
||||
This module isn’t linked to any library entry under its recorded id.
|
||||
{{ relinkTarget?.contentIdentical
|
||||
? "A library entry with identical content is selected above — re-linking reconnects them."
|
||||
: "Pick a match above to re-link, or save it as a new entry." }}
|
||||
</template>
|
||||
<template v-else>
|
||||
This module isn’t linked to any library entry under its recorded id, and
|
||||
nothing in the library matches its content or name. “Update existing” is
|
||||
unavailable — “Save as new entry” adds it to the library.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="wp-ptl-error" data-test="ptl-error">
|
||||
@@ -511,7 +720,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
font-family: inherit;
|
||||
}
|
||||
.wp-ptl-input:focus { outline: 1px solid var(--wp-accent-500, #8b5cf6); }
|
||||
.wp-ptl-input--multi { resize: vertical; min-height: 48px; }
|
||||
.wp-ptl-input--multi { resize: vertical; overscroll-behavior: contain; min-height: 48px; }
|
||||
.wp-ptl-payload {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
@@ -559,6 +768,66 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
border: 1px solid color-mix(in oklab, var(--wp-danger, #ef4444) 32%, transparent);
|
||||
color: var(--wp-danger, #ef4444);
|
||||
}
|
||||
/* Informational tone for the "pick a match" case — a re-linkable module is a
|
||||
routine state, not an error, and painting it red pushed users toward the
|
||||
duplicate-creating escape hatch. */
|
||||
.wp-ptl-note--info {
|
||||
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
|
||||
color: var(--wp-text, #e7e7ee);
|
||||
}
|
||||
.wp-ptl-relink {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 8%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 30%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wp-ptl-relink__title { font-size: 12.5px; font-weight: 600; color: var(--wp-text, #e7e7ee); }
|
||||
.wp-ptl-relink__hint { margin: 0; font-size: 11.5px; color: var(--wp-text-muted, #a1a1ad); }
|
||||
.wp-ptl-relink__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.wp-ptl-relink__cand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-ptl-relink__cand:hover { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 16%, transparent); }
|
||||
.wp-ptl-relink__cand.is-picked { background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 28%, transparent); }
|
||||
.wp-ptl-relink__name { font-size: 12px; color: var(--wp-text, #e7e7ee); }
|
||||
.wp-ptl-relink__uuid { font-family: var(--wp-font-mono, monospace); font-size: 10px; color: var(--wp-text-dim, #6e6e7c); }
|
||||
.wp-ptl-relink__tag {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wp-ptl-relink__tag.is-identical {
|
||||
color: var(--wp-green, #6ad28b);
|
||||
background: color-mix(in oklab, var(--wp-green, #6ad28b) 16%, transparent);
|
||||
}
|
||||
.wp-ptl-relink__tag.is-diff {
|
||||
color: var(--wp-warn, #facc15);
|
||||
background: color-mix(in oklab, var(--wp-warn, #facc15) 16%, transparent);
|
||||
}
|
||||
.wp-ptl-relink .wp-ptl-btn--primary { align-self: flex-start; }
|
||||
.wp-ptl-error {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -75,6 +75,14 @@ const frameColor = computed(() =>
|
||||
props.color && props.color.length ? props.color : "var(--wp-bundle-default)",
|
||||
);
|
||||
|
||||
/** Tooltip for the MISSING badge. Names the two recoveries in the order the
|
||||
* user should try them — Re-link reconnects to an existing entry, saving a
|
||||
* new one is the fallback. The old copy only offered the fallback, which is
|
||||
* what produced duplicate library entries. */
|
||||
const missingTitle =
|
||||
"No live library entry for this bundle under its recorded id. "
|
||||
+ "Right-click → Push to library: re-link it to a matching entry, or save it as a new one.";
|
||||
|
||||
const summary = computed(() => {
|
||||
const word = props.childCount === 1 ? "mod" : "mods";
|
||||
const parts = [`${props.childCount} ${word}`];
|
||||
@@ -165,16 +173,21 @@ const summary = computed(() => {
|
||||
its own dot — re-stacking them at the bundle level would be
|
||||
visual noise. -->
|
||||
<span v-if="libraryMissing || libraryDrifted || snapshotModified" class="wp-mod-dots">
|
||||
<!-- Visual hierarchy: MISSING (library deleted) is the most severe
|
||||
<!-- Visual hierarchy: MISSING (no live library entry) is the most severe
|
||||
and shows first; LIBRARY UPDATED (drift) dominates next; MOD
|
||||
sits to its right. Multiple can show simultaneously — they
|
||||
represent independent axes. -->
|
||||
represent independent axes.
|
||||
Copy states the observable fact rather than asserting the entry was
|
||||
DELETED: an id absent from the library reads identically whether it
|
||||
was deleted, re-imported under a new id, or arrived in a shared
|
||||
workflow — and Push to library now offers Re-link for the last two,
|
||||
which "deleted" steered users away from. -->
|
||||
<template v-if="libraryMissing">
|
||||
<span class="wp-mod-dot wp-mod-dot--missing"
|
||||
title="The bundle's library entry has been deleted. Right-click → Push to library to re-add as a new entry."
|
||||
:title="missingTitle"
|
||||
aria-hidden="true"></span>
|
||||
<span class="wp-mod-badge wp-mod-badge--missing"
|
||||
title="The bundle's library entry has been deleted. Right-click → Push to library to re-add as a new entry.">missing</span>
|
||||
:title="missingTitle">missing</span>
|
||||
</template>
|
||||
<template v-if="libraryDrifted">
|
||||
<span class="wp-mod-dot wp-mod-dot--drift"
|
||||
@@ -399,10 +412,19 @@ const summary = computed(() => {
|
||||
color: var(--wp-warn);
|
||||
background: color-mix(in srgb, var(--wp-warn) 7%, transparent);
|
||||
}
|
||||
/* Child-count / drift summary. Was 10px in --wp-text-dim (#666 on the dark
|
||||
* theme), which sat right at the edge of legibility against the bundle frame
|
||||
* tint — the one piece of information telling you how big a collapsed bundle
|
||||
* is. Bumped to the muted token at 11px and given a faint pill so it reads as
|
||||
* a value rather than a watermark. */
|
||||
.wp-bundle-summary {
|
||||
font: 500 10px/1.3 var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
font: 600 11px/1.3 var(--wp-font-sans);
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
flex-shrink: 0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, var(--wp-text-muted, var(--wp-text2)) 12%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Bundle action button — base size + shape from shared
|
||||
* .wp-btn--icon-sm. Bundle-specific tweak: bg + border tinted with
|
||||
|
||||
@@ -1,4 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { bundleContentKey } from "./bundle-fingerprint";
|
||||
|
||||
describe("bundleContentKey", () => {
|
||||
it("agrees across two copies of the same library bundle", () => {
|
||||
// This is the point: `computeBundleFingerprint` folds in per-instance
|
||||
// `_uid` / meta / instance, so a fresh insert never matches the library
|
||||
// row it came from. This key keeps only what survives an insert.
|
||||
const libraryChildren = [
|
||||
{ type: "wildcard", payload_hash: "H1", id: "aaa" },
|
||||
{ type: "constraint", payload_hash: "H2", id: "bbb" },
|
||||
];
|
||||
const workflowChildren = [
|
||||
{ type: "wildcard", payload_hash: "H1", id: "zzz", _uid: "u1", meta: { name: "renamed" } },
|
||||
{ type: "constraint", payload_hash: "H2", id: "yyy", _uid: "u2" },
|
||||
];
|
||||
expect(bundleContentKey(workflowChildren)).toBe(bundleContentKey(libraryChildren));
|
||||
});
|
||||
|
||||
it("separates different children", () => {
|
||||
expect(bundleContentKey([{ type: "wildcard", payload_hash: "H1" }]))
|
||||
.not.toBe(bundleContentKey([{ type: "wildcard", payload_hash: "H2" }]));
|
||||
});
|
||||
|
||||
it("respects child ORDER — a bundle is an ordered range", () => {
|
||||
const a = [{ type: "wildcard", payload_hash: "H1" }, { type: "combine", payload_hash: "H2" }];
|
||||
const b = [{ type: "combine", payload_hash: "H2" }, { type: "wildcard", payload_hash: "H1" }];
|
||||
expect(bundleContentKey(a)).not.toBe(bundleContentKey(b));
|
||||
});
|
||||
|
||||
it("keeps a hash-less child in the shape rather than skipping it", () => {
|
||||
const withChild = [{ type: "wildcard", payload_hash: "H1" }, { type: "combine" }];
|
||||
const without = [{ type: "wildcard", payload_hash: "H1" }];
|
||||
expect(bundleContentKey(withChild)).not.toBe(bundleContentKey(without));
|
||||
});
|
||||
|
||||
it("distinguishes an empty bundle from a one-child bundle", () => {
|
||||
expect(bundleContentKey([])).not.toBe(bundleContentKey([{ type: "wildcard" }]));
|
||||
});
|
||||
});
|
||||
import type { BundleInstance, ModuleEntry } from "../../../widgets/_shared";
|
||||
import {
|
||||
computeBundleFingerprint,
|
||||
|
||||
@@ -132,6 +132,31 @@ export function computeBundleFingerprint(
|
||||
return `${FINGERPRINT_VERSION}:${djb2(parts.join("\n"))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-library content key for a bundle: the ordered list of its children's
|
||||
* `(type, payload_hash)` pairs.
|
||||
*
|
||||
* Distinct from `computeBundleFingerprint`, which answers "have MY children
|
||||
* been edited since insert" and therefore folds in per-instance state — `_uid`,
|
||||
* `meta`, `instance`. That makes it useless for comparing a workflow bundle
|
||||
* against a LIBRARY bundle: a fresh insert mints new uids, so two copies of the
|
||||
* same library bundle never agree.
|
||||
*
|
||||
* This one deliberately keeps only what survives an insert, so a workflow
|
||||
* bundle and the library row it came from produce the same key. Order matters
|
||||
* (a bundle IS an ordered range) and children with no `payload_hash` contribute
|
||||
* an empty slot rather than being skipped, so a hash-less child still affects
|
||||
* the shape.
|
||||
*
|
||||
* Used by the re-link picker when a bundle carries no `inserted_at_hash`.
|
||||
*/
|
||||
export function bundleContentKey(
|
||||
children: ReadonlyArray<{ type?: string; payload_hash?: string }>,
|
||||
): string {
|
||||
const parts = children.map((c) => `${c.type ?? ""}|${c.payload_hash ?? ""}`);
|
||||
return `bk1:${djb2(parts.join("\n"))}`;
|
||||
}
|
||||
|
||||
/** True when the bundle's current children diverge from the snapshot
|
||||
* fingerprint stored on the instance. Returns FALSE when:
|
||||
* - no fingerprint stored (workflow predates MOD detection), OR
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import InstanceIdChip from "./InstanceIdChip.vue";
|
||||
|
||||
describe("InstanceIdChip", () => {
|
||||
beforeEach(() => {
|
||||
// jsdom has no clipboard by default — stub a resolving writeText.
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: vi.fn(async () => undefined) },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the library id as the visible label", () => {
|
||||
const w = mount(InstanceIdChip, { props: { id: "ab12cd34" } });
|
||||
const chip = w.get('[data-test="instance-id-chip"]');
|
||||
expect(chip.text()).toContain("ab12cd34");
|
||||
});
|
||||
|
||||
it("renders nothing when id is empty (unlinked/inline module)", () => {
|
||||
const w = mount(InstanceIdChip, { props: { id: "" } });
|
||||
expect(w.find('[data-test="instance-id-chip"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces both id and _uid in the tooltip", () => {
|
||||
const w = mount(InstanceIdChip, { props: { id: "ab12cd34", uid: "uid-9f9f9f" } });
|
||||
const title = w.get('[data-test="instance-id-chip"]').attributes("title") ?? "";
|
||||
expect(title).toContain("ab12cd34");
|
||||
expect(title).toContain("uid-9f9f9f");
|
||||
});
|
||||
|
||||
it("copies the id to the clipboard and flips to a copied state on click", async () => {
|
||||
const writeText = navigator.clipboard.writeText as ReturnType<typeof vi.fn>;
|
||||
const w = mount(InstanceIdChip, { props: { id: "ab12cd34" } });
|
||||
await w.get('[data-test="instance-id-chip"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(writeText).toHaveBeenCalledWith("ab12cd34");
|
||||
expect(w.get('[data-test="instance-id-chip"]').classes()).toContain("wp-idchip--copied");
|
||||
expect(w.get('[data-test="instance-id-chip"]').text()).toContain("copied");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Small monospace, click-to-copy chip that surfaces a module/bundle's
|
||||
* identity in the edit-modal header. Debugging aid (workstream C / issue #4):
|
||||
* the library `id` is the uuid that `@{id}` nested refs, constraint
|
||||
* source/target, and library rows all match on — so seeing it (and being
|
||||
* able to copy it) makes drift/linking bugs traceable at a glance.
|
||||
*
|
||||
* Shows the library `id` as the visible label; the instance `_uid` (what
|
||||
* constraint `pick` + `bundle_origin` key on) rides along in the tooltip so
|
||||
* both identities are one hover away without cluttering the header.
|
||||
*/
|
||||
import { ref, onBeforeUnmount } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Library uuid — the payload row id. Matches `@{id}`, constraint
|
||||
* source/target ids, and library rows. Empty for inline-only modules. */
|
||||
id: string;
|
||||
/** Instance uid — per-Context-node instance identity used by constraint
|
||||
* `pick` selectors + `bundle_origin`. Optional (absent on some surfaces). */
|
||||
uid?: string;
|
||||
}>();
|
||||
|
||||
const copied = ref(false);
|
||||
let timer: number | undefined;
|
||||
|
||||
async function copyId(): Promise<void> {
|
||||
if (!props.id) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.id);
|
||||
copied.value = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => { copied.value = false; }, 1200);
|
||||
} catch {
|
||||
// Clipboard blocked (insecure context / denied) — the id stays visible
|
||||
// for manual selection, so a failed copy is a silent no-op.
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="id"
|
||||
type="button"
|
||||
class="wp-idchip"
|
||||
:class="{ 'wp-idchip--copied': copied }"
|
||||
:title="`library id: ${id}${uid ? `\ninstance uid: ${uid}` : ''}\n(click to copy id)`"
|
||||
data-test="instance-id-chip"
|
||||
@click.stop="copyId"
|
||||
>
|
||||
<i
|
||||
class="pi wp-idchip__icon"
|
||||
:class="copied ? 'pi-check' : 'pi-hashtag'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="wp-idchip__id">{{ copied ? "copied" : id }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-idchip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--wp-border, #33333f);
|
||||
border-radius: var(--wp-radius-sm, 4px);
|
||||
background: var(--wp-bg2, #1b1b22);
|
||||
color: var(--wp-text-dim, #8a8a99);
|
||||
font: 500 10px/1.4 var(--wp-font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.12s, border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.wp-idchip:hover {
|
||||
color: var(--wp-text, #e7e7ee);
|
||||
border-color: color-mix(in oklab, var(--wp-accent, #7c6ad6) 55%, var(--wp-border, #33333f));
|
||||
}
|
||||
.wp-idchip__icon {
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.wp-idchip__id {
|
||||
font-family: inherit;
|
||||
}
|
||||
.wp-idchip--copied {
|
||||
color: var(--wp-green, #6ad28b);
|
||||
border-color: color-mix(in oklab, var(--wp-green, #6ad28b) 55%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,12 @@
|
||||
.wp-wcm__head { --modal-accent: var(--wp-kind-wildcard); }
|
||||
|
||||
.wp-cbm__head, .wp-cnm__head, .wp-dvm__head, .wp-fvm__head, .wp-wcm__head {
|
||||
/* Pinned to the top of the scrolling modal body so the title + close
|
||||
* stay visible while the content scrolls. Opaque (gradient bottoms out
|
||||
* at --wp-bg2) so scrolled content doesn't bleed through. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Shared template-section controls — insert-var/insert-slot dropdown
|
||||
* button + reset button + dropdown panel + dropdown item. Used by:
|
||||
* - Combine's TemplateSection (`.wp-tpl__*`)
|
||||
* - InjectorBindingModal (`.ibm__*`)
|
||||
/* Shared template-section controls — insert-slot dropdown button + reset
|
||||
* button + dropdown panel + dropdown item. Used by:
|
||||
* - InjectorBindingModal (`.ibm__*`) — the insert-ref menu
|
||||
* - Combine's TemplateSection (`.wp-tpl__reset` only)
|
||||
*
|
||||
* Both modals previously declared their own copies of these rules with
|
||||
* tiny differences in padding/height — visible in the UI as a slight
|
||||
@@ -10,6 +10,10 @@
|
||||
* locked to the same dimensions (22px tall, explicit height + box-
|
||||
* sizing).
|
||||
*
|
||||
* NB: the `.wp-tpl__menu*` half of these pairs was dropped — Combine's
|
||||
* TemplateSection no longer has an insert menu, so those selectors matched
|
||||
* nothing. `.wp-tpl__reset` below IS still live.
|
||||
*
|
||||
* Imported globally from ContextWidget's unscoped `<style>` block.
|
||||
* Class names are namespaced (`.wp-tpl__*`, `.ibm__*`) so no leakage
|
||||
* concerns. */
|
||||
@@ -20,18 +24,14 @@
|
||||
* line-height. Block-level wrapper + inline-flex child can leave
|
||||
* 1-2px of descender space below the button, shifting it down vs
|
||||
* the sibling reset button (which is a direct flex child with no
|
||||
* wrapping line-box). Empirically: this is the exact gap the
|
||||
* injector modal showed but the combine modal didn't, since the
|
||||
* combine modal's `.tpl` section uses a different base line-height
|
||||
* inheritance chain. Switching the wrapper to inline-flex pins
|
||||
* cross-axis size to the button itself, killing the offset. */
|
||||
.wp-tpl__menu-wrap, .ibm__menu-wrap {
|
||||
* wrapping line-box). */
|
||||
.wp-ibm__menu-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wp-tpl__menu-btn, .ibm__menu-btn {
|
||||
.wp-ibm__menu-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -46,11 +46,11 @@
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.wp-tpl__menu-btn:hover, .ibm__menu-btn:hover {
|
||||
.wp-ibm__menu-btn:hover {
|
||||
border-color: var(--wp-accent);
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-tpl__menu-btn .pi, .ibm__menu-btn .pi { font-size: 9px; }
|
||||
.wp-ibm__menu-btn .pi { font-size: 9px; }
|
||||
|
||||
/* Reset button — used by section-level modal headers:
|
||||
* - .wp-tpl__reset Combine TemplateSection reset
|
||||
@@ -91,10 +91,20 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.wp-tpl__menu, .ibm__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
/* Teleported to <body>, so `fixed` viewport coords rather than `absolute`
|
||||
* inside the modal — as an absolutely-positioned child the menu was CLIPPED
|
||||
* by the modal's scroll container and vanished below the fold whenever its
|
||||
* trigger sat near the bottom. The owning component measures the button and
|
||||
* writes `top` / `left` inline; `--up` flips the box above the trigger when
|
||||
* there's no room below.
|
||||
*
|
||||
* z-index 10020 puts this in the POPOVER tier, above every modal overlay.
|
||||
* Modals here range from 9999 (ModalShell) to 10010 (InjectorBindingModal),
|
||||
* so the old 9999 rendered this menu BEHIND its own modal — it worked only
|
||||
* where the modal was also 9999 and DOM order broke the tie. Any popup that
|
||||
* teleports out of a modal has to clear the whole modal tier. */
|
||||
.wp-ibm__menu {
|
||||
position: fixed;
|
||||
min-width: 200px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
@@ -102,14 +112,16 @@
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 20;
|
||||
z-index: 10020;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.wp-tpl__menu-item, .ibm__menu-item {
|
||||
.wp-ibm__menu--up { transform: translateY(-100%); }
|
||||
|
||||
.wp-ibm__menu-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
@@ -121,6 +133,6 @@
|
||||
cursor: pointer;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-tpl__menu-item:hover, .ibm__menu-item:hover {
|
||||
.wp-ibm__menu-item:hover {
|
||||
background: color-mix(in srgb, var(--wp-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
* ModuleEditModal. Master Lock/Hide route to the live cascade
|
||||
* handlers (same ones BundleHeader buttons call). */
|
||||
import { computed } from "vue";
|
||||
import { saveChordLabel } from "../../../shared/platform-keys";
|
||||
import type { BundleInstance } from "../../../../widgets/_shared";
|
||||
import IdentitySection from "./sections/IdentitySection.vue";
|
||||
import RuntimeSection from "./sections/RuntimeSection.vue";
|
||||
import InstanceIdChip from "../InstanceIdChip.vue";
|
||||
|
||||
|
||||
/** Platform-correct save chord for the footer hint. */
|
||||
const saveChord = saveChordLabel();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
bundle: BundleInstance;
|
||||
@@ -70,6 +75,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
<div class="wp-bdm__title-row">
|
||||
<span class="wp-bdm__name" data-test="bdm-name-display">{{ headerName }}</span>
|
||||
<span class="wp-bdm__chip" data-test="bdm-chip">bundle</span>
|
||||
<InstanceIdChip :id="bundle.library_id" :uid="bundle._uid" />
|
||||
<span
|
||||
v-if="libraryDrifted"
|
||||
class="wp-bdm__badge wp-bdm__badge--drift"
|
||||
@@ -102,6 +108,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
:library-color="libraryColor"
|
||||
@update="(patch) => emit('update', patch)"
|
||||
/>
|
||||
<div class="wp-bdm__dock">
|
||||
<RuntimeSection
|
||||
:lock-state="lockState"
|
||||
:internal-state="internalState"
|
||||
@@ -134,7 +141,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
Reset to library
|
||||
</button>
|
||||
<span class="wp-bdm__hint">
|
||||
<kbd>Esc</kbd> cancel · <kbd>⌘↵</kbd> save
|
||||
<kbd>Esc</kbd> cancel · <kbd>{{ saveChord }}</kbd> save
|
||||
</span>
|
||||
<button
|
||||
v-if="canSaveToLibrary"
|
||||
@@ -157,12 +164,13 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
|
||||
.wp-bdm__head { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
|
||||
.wp-bdm { background: var(--wp-bg2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius); width: 720px; max-width: 100%; max-height: 80vh; display: flex; flex-direction: column; overflow-y: auto; font-family: var(--wp-font-sans, sans-serif); font-size: 12px; color: var(--wp-text); }
|
||||
.wp-bdm__head { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--modal-accent, var(--wp-accent)) 18%, var(--wp-bg2)) 0%, var(--wp-bg2) 100%); border-bottom: 1px solid var(--wp-border); }
|
||||
.wp-bdm__head-icon { color: var(--modal-accent, var(--wp-accent)); font-size: 18px; width: 24px; text-align: center; }
|
||||
.wp-bdm__title-block { flex: 1; min-width: 0; }
|
||||
.wp-bdm__title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -175,6 +183,7 @@ const headerName = computed(() => props.bundle.name || props.libraryName || "Bun
|
||||
.wp-bdm__close { background: transparent; border: 0; color: var(--wp-text-dim, var(--wp-text3)); cursor: pointer; padding: 4px; }
|
||||
.wp-bdm__close:hover { color: var(--wp-text); }
|
||||
.wp-bdm__close .pi { font-size: 12px; }
|
||||
.wp-bdm__dock { position: sticky; bottom: 0; z-index: 2; background: var(--wp-bg2); }
|
||||
.wp-bdm__foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--wp-bg3); border-top: 1px solid var(--wp-border); }
|
||||
.wp-bdm__spa-link { display: inline-flex; align-items: center; gap: 5px; font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text2)); text-decoration: none; }
|
||||
.wp-bdm__spa-link:hover { color: var(--wp-accent-text, var(--wp-text)); }
|
||||
@@ -183,6 +192,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; }
|
||||
|
||||
@@ -12,12 +12,18 @@
|
||||
* override, locked seed, hide-from-prompt.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { saveChordLabel } from "../../../shared/platform-keys";
|
||||
import { type ResolvedValue } from "../../../../widgets/richTokenize";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import IdentitySection from "./sections/IdentitySection.vue";
|
||||
import TemplateSection from "./sections/TemplateSection.vue";
|
||||
import type { VarProducerLike } from "../../../../manager/components/RefChip.vue";
|
||||
import RuntimeSection from "./sections/RuntimeSection.vue";
|
||||
import InstanceIdChip from "../InstanceIdChip.vue";
|
||||
|
||||
|
||||
/** Platform-correct save chord for the footer hint. */
|
||||
const saveChord = saveChordLabel();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
module: ModuleEntry;
|
||||
@@ -29,12 +35,19 @@ const props = withDefaults(
|
||||
* insert-var dropdown so users don't have to remember which
|
||||
* bindings they can reach from this module. */
|
||||
upstreamVars?: string[];
|
||||
/** `$var` → who writes it. Feeds the template editor's `$` autocomplete
|
||||
* so each suggestion names its producing module rather than listing a
|
||||
* bare name the user has to recognise from memory. */
|
||||
upstreamProducers?: Record<string, VarProducerLike>;
|
||||
/** Resolved upstream-var snapshot — drives the TemplateSection's
|
||||
* live preview pane with substituted values. */
|
||||
upstreamResolved?: Record<string, ResolvedValue>;
|
||||
/** 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,
|
||||
@@ -84,6 +97,7 @@ function onSpaClick(): void {
|
||||
<div class="wp-cbm__title-row">
|
||||
<span class="wp-cbm__name" data-test="cbm-name">{{ module.meta?.name ?? module.type }}</span>
|
||||
<span class="wp-cbm__chip" data-test="cbm-chip">combine</span>
|
||||
<InstanceIdChip :id="module.id" :uid="module._uid" />
|
||||
</div>
|
||||
<div class="wp-cbm__sub">Library entry · template interpolates $vars into output binding</div>
|
||||
</div>
|
||||
@@ -100,16 +114,19 @@ function onSpaClick(): void {
|
||||
:module="module"
|
||||
:upstream-vars="upstreamVars"
|
||||
:sibling-vars="siblingVars"
|
||||
:frame-active="frameActive"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<TemplateSection
|
||||
:upstream-producers="upstreamProducers"
|
||||
:module="module"
|
||||
:upstream-vars="upstreamVars"
|
||||
:upstream-resolved="upstreamResolved"
|
||||
:sibling-vars="siblingVars"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<div class="wp-cbm__dock">
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-cbm__foot">
|
||||
<a
|
||||
@@ -135,7 +152,7 @@ function onSpaClick(): void {
|
||||
Reset overrides
|
||||
</button>
|
||||
<span class="wp-cbm__hint">
|
||||
<kbd>Esc</kbd> cancel · <kbd>⌘↵</kbd> save
|
||||
<kbd>Esc</kbd> cancel · <kbd>{{ saveChord }}</kbd> save
|
||||
</span>
|
||||
<button
|
||||
v-if="canSaveToLibrary"
|
||||
@@ -147,6 +164,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-cbm__btn" data-test="cbm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-cbm__btn wp-cbm__btn--primary" data-test="cbm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,7 +178,7 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
@@ -168,6 +186,12 @@ function onSpaClick(): void {
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). Every modal in this family shares
|
||||
* the same accent-tinted gradient + chip + close button. */
|
||||
.wp-cbm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-cbm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -212,6 +236,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>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { applyVarAccessor, type ResolvedValue } from "../../../../../widgets/ric
|
||||
// Async-import the rich-text editor so the chunk is split into its own
|
||||
// asset and only pulled in when the combine instance modal opens. Bundle
|
||||
// budget is tight — eager import pushes total over.
|
||||
import type { VarProducerLike } from "../../../../../manager/components/RefChip.vue";
|
||||
const RichTextInput = defineAsyncComponent(
|
||||
() => import("../../../../../manager/components/RichTextInput.vue"),
|
||||
);
|
||||
@@ -30,6 +31,9 @@ const props = withDefaults(
|
||||
/** Vars produced upstream of this Context node — surfaced in the
|
||||
* insert-var dropdown so users don't have to remember names. */
|
||||
upstreamVars?: string[];
|
||||
/** `$var` → its producing module + node, so the `$` autocomplete can
|
||||
* attribute each name instead of listing it bare. */
|
||||
upstreamProducers?: Record<string, VarProducerLike>;
|
||||
/** Resolved upstream `$name → value` map. Drives the live-preview
|
||||
* pane below the syntax preview: when set, the template renders
|
||||
* with `$var` substitutions visible at edit time so users see the
|
||||
@@ -46,6 +50,11 @@ const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
/** Combined upstream + sibling vars, deduped, alpha-sorted. Drives
|
||||
* both the validity-coloring on detected pills and the
|
||||
* RichTextInput's `$`-trigger autocomplete suggestion list. */
|
||||
/** A Map because the row builder does one `get` per suggestion. */
|
||||
const varProducerMap = computed<Map<string, VarProducerLike>>(
|
||||
() => new Map(Object.entries(props.upstreamProducers ?? {})),
|
||||
);
|
||||
|
||||
const availableVars = computed<string[]>(() => {
|
||||
const set = new Set<string>();
|
||||
for (const n of props.upstreamVars) if (n) set.add(n);
|
||||
@@ -257,6 +266,8 @@ function onResetTemplate(): void {
|
||||
<RichTextInput
|
||||
:model-value="templateValue"
|
||||
:var-suggestions="availableVars"
|
||||
:var-producers="varProducerMap"
|
||||
graph-aware
|
||||
:multiline="true"
|
||||
:rows="3"
|
||||
surface="combine"
|
||||
@@ -386,7 +397,7 @@ function onResetTemplate(): void {
|
||||
font: 11px/1.5 var(--wp-font-mono);
|
||||
color: var(--wp-text);
|
||||
min-height: 56px;
|
||||
resize: vertical;
|
||||
resize: vertical; overscroll-behavior: contain;
|
||||
}
|
||||
.wp-tpl__input:focus { outline: none; border-color: var(--wp-accent); }
|
||||
.wp-tpl__input--mod {
|
||||
|
||||
@@ -17,12 +17,14 @@
|
||||
* dimmed dead UI would lie.
|
||||
*/
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { saveChordLabel } from "../../../shared/platform-keys";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import type { ChainModule, TargetSelect } from "../../../../extension/constraint-pairs";
|
||||
import IdentitySection from "./sections/IdentitySection.vue";
|
||||
import MatrixSection from "./sections/MatrixSection.vue";
|
||||
import TargetReachSection from "./sections/TargetReachSection.vue";
|
||||
import ExceptionsSection from "./sections/ExceptionsSection.vue";
|
||||
import InstanceIdChip from "../InstanceIdChip.vue";
|
||||
// Library fallback for cross-Context constraints. When the referenced
|
||||
// source/target wildcard isn't a sibling in this WP_Context (lives in
|
||||
// another node, or library-only), pull its sub_categories + option
|
||||
@@ -45,6 +47,9 @@ import { collectTransitiveDeps } from "../../../../manager/community/transitive-
|
||||
import { downloadDepsForDangling } from "../../../../manager/community/download-and-reattach";
|
||||
import { installEnvelope } from "../../../../manager/import-export/install";
|
||||
|
||||
|
||||
/** Platform-correct save chord for the footer hint. */
|
||||
const saveChord = saveChordLabel();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
module: ModuleEntry;
|
||||
@@ -65,6 +70,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: () => [] },
|
||||
);
|
||||
@@ -90,6 +100,7 @@ function spaUrl(): string {
|
||||
interface WildcardPayload {
|
||||
sub_categories?: string[];
|
||||
options?: Array<{ value?: string }>;
|
||||
tag_groups?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** Normalised module shape the resolution lookups consume. Unifies the
|
||||
@@ -284,6 +295,19 @@ const targetValues = computed<string[]>(() => {
|
||||
return Array.from(set);
|
||||
});
|
||||
|
||||
/** Per-axis grouping (tag_groups) for the matrix headers — orders + colours
|
||||
* the row/col tags by sub-category axis. Sibling/chain wildcard payloads
|
||||
* carry it; the preview-resolver cache does not, so a cross-context ref
|
||||
* resolved only via cache groups flat (acceptable degradation). */
|
||||
const sourceGroups = computed<Record<string, string[]>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.source_wildcard_id)?.tag_groups ?? {};
|
||||
});
|
||||
const targetGroups = computed<Record<string, string[]>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.target_wildcard_id)?.tag_groups ?? {};
|
||||
});
|
||||
|
||||
const sourceName = computed(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return wildcardName(pl.source_wildcard_id);
|
||||
@@ -649,6 +673,7 @@ function onSpaClick(): void {
|
||||
<div class="wp-cnm__title-row">
|
||||
<span class="wp-cnm__name" data-test="cnm-name">{{ module.meta?.name ?? module.type }}</span>
|
||||
<span class="wp-cnm__chip" data-test="cnm-chip">constraint</span>
|
||||
<InstanceIdChip :id="module.id" :uid="module._uid" />
|
||||
</div>
|
||||
<div class="wp-cnm__sub">Library entry · source pick → modifies target option weights</div>
|
||||
</div>
|
||||
@@ -688,17 +713,11 @@ function onSpaClick(): void {
|
||||
:target-subs="targetSubs"
|
||||
:source-name="sourceName"
|
||||
:target-name="targetName"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:stranded="hasDangling"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<TargetReachSection
|
||||
:model-value="targetSelect"
|
||||
:chain-modules="chainModules"
|
||||
:constraint-uid="constraintUid"
|
||||
:target-wildcard-id="targetWildcardId"
|
||||
:target-name="targetName"
|
||||
@update:model-value="onTargetSelect"
|
||||
/>
|
||||
<ExceptionsSection
|
||||
:module="module"
|
||||
:source-values="sourceValues"
|
||||
@@ -712,6 +731,15 @@ function onSpaClick(): void {
|
||||
@update="onUpdate"
|
||||
/>
|
||||
|
||||
<div class="wp-cnm__dock">
|
||||
<TargetReachSection
|
||||
:model-value="targetSelect"
|
||||
:chain-modules="chainModules"
|
||||
:constraint-uid="constraintUid"
|
||||
:target-wildcard-id="targetWildcardId"
|
||||
:target-name="targetName"
|
||||
@update:model-value="onTargetSelect"
|
||||
/>
|
||||
<footer class="wp-cnm__foot">
|
||||
<a
|
||||
v-if="isLibraryTracked"
|
||||
@@ -736,7 +764,7 @@ function onSpaClick(): void {
|
||||
Reset overrides
|
||||
</button>
|
||||
<span class="wp-cnm__hint">
|
||||
<kbd>Esc</kbd> cancel · <kbd>⌘↵</kbd> save
|
||||
<kbd>Esc</kbd> cancel · <kbd>{{ saveChord }}</kbd> save
|
||||
</span>
|
||||
<button
|
||||
v-if="canSaveToLibrary"
|
||||
@@ -748,6 +776,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-cnm__btn" data-test="cnm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-cnm__btn wp-cnm__btn--primary" data-test="cnm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Feature 2: confirm pulling the missing dependency (+ its closure)
|
||||
from the community before any network/install side effect. -->
|
||||
@@ -772,13 +801,19 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). */
|
||||
.wp-cnm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-cnm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -823,6 +858,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));
|
||||
|
||||
+22
-1
@@ -80,7 +80,28 @@ describe("ConstraintReattachSection", () => {
|
||||
const w = mountSection({ sourceCachedName: "colour", refData: rd });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
const first = w.findAll("[data-test='reattach-candidate']")[0];
|
||||
expect(first.text()).toBe("colour");
|
||||
expect(first.find("[data-test='reattach-cand-name']").text()).toBe("colour");
|
||||
});
|
||||
|
||||
it("shows each candidate's uuid + option/sub-category meta so duplicates are distinguishable", async () => {
|
||||
const rd = refData();
|
||||
// Two library wildcards share the name "colour" — only the uuid + content
|
||||
// tell them apart. Without these the picker is a coin-flip.
|
||||
rd.uuidToName = new Map([["beef0001", "colour"], ["beef0002", "colour"]]);
|
||||
rd.uuidToSubCategories = new Map([["beef0001", ["warm", "cold"]], ["beef0002", ["hue"]]]);
|
||||
rd.uuidToOptionsCount = new Map([["beef0001", 2], ["beef0002", 9]]);
|
||||
const w = mountSection({ refData: rd });
|
||||
await w.find("[data-test='reattach-btn-source']").trigger("click");
|
||||
const c1 = w.find("[data-test-id='reattach-candidate-beef0001']");
|
||||
const c2 = w.find("[data-test-id='reattach-candidate-beef0002']");
|
||||
// The 8-hex uuid is the definitive disambiguator.
|
||||
expect(c1.text()).toContain("beef0001");
|
||||
expect(c2.text()).toContain("beef0002");
|
||||
// Option count + sub-categories give a human-readable tiebreaker.
|
||||
expect(c1.text()).toContain("2 opts");
|
||||
expect(c1.text()).toContain("warm");
|
||||
expect(c2.text()).toContain("9 opts");
|
||||
expect(c2.text()).toContain("hue");
|
||||
});
|
||||
|
||||
it("renders the community-download button only on a downloadable side", () => {
|
||||
|
||||
@@ -66,7 +66,7 @@ const openSide = ref<Side | null>(null);
|
||||
const query = ref("");
|
||||
const picked = ref<{ uuid: string; name: string } | null>(null);
|
||||
|
||||
interface Candidate { uuid: string; name: string }
|
||||
interface Candidate { uuid: string; name: string; count: number; subCats: string[] }
|
||||
|
||||
function candidates(): Candidate[] {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
@@ -77,8 +77,15 @@ function candidates(): Candidate[] {
|
||||
const dead = new Set([props.sourceUuid, props.targetUuid]);
|
||||
for (const [uuid, name] of props.refData.uuidToName) {
|
||||
if (dead.has(uuid)) continue; // can't re-pick the dangling uuid
|
||||
if (q && !name.toLowerCase().includes(q)) continue;
|
||||
out.push({ uuid, name });
|
||||
// Match name OR uuid — re-picking a source/target by its 8-hex id is the
|
||||
// reliable path when two wildcards share a display name.
|
||||
if (q && !name.toLowerCase().includes(q) && !uuid.toLowerCase().includes(q)) continue;
|
||||
out.push({
|
||||
uuid,
|
||||
name,
|
||||
count: props.refData.uuidToOptionsCount.get(uuid) ?? 0,
|
||||
subCats: props.refData.uuidToSubCategories.get(uuid) ?? [],
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => {
|
||||
const ax = a.name.toLowerCase() === cached ? 0 : 1;
|
||||
@@ -87,6 +94,14 @@ function candidates(): Candidate[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** One-line disambiguator under the name: option count + declared
|
||||
* sub-categories. Same-named wildcards differ here (or, failing that, by the
|
||||
* always-shown uuid). */
|
||||
function candMeta(c: Candidate): string {
|
||||
const opts = `${c.count} opt${c.count === 1 ? "" : "s"}`;
|
||||
return c.subCats.length ? `${opts} · ${c.subCats.join(", ")}` : opts;
|
||||
}
|
||||
|
||||
function openDropdown(side: Side): void {
|
||||
openSide.value = side;
|
||||
// Empty query keeps every live candidate visible; the cached name is the
|
||||
@@ -153,7 +168,13 @@ function confirm(side: Side): void {
|
||||
:data-test-id="`reattach-candidate-${c.uuid}`"
|
||||
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
|
||||
@click="pick(c)"
|
||||
>{{ c.name }}</li>
|
||||
>
|
||||
<span class="rb__cand-main">
|
||||
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-uuid">{{ c.uuid }}</code>
|
||||
</span>
|
||||
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-source">
|
||||
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
|
||||
@@ -194,7 +215,13 @@ function confirm(side: Side): void {
|
||||
:data-test-id="`reattach-candidate-${c.uuid}`"
|
||||
:class="{ 'rb__cand--picked': c.uuid === picked?.uuid }"
|
||||
@click="pick(c)"
|
||||
>{{ c.name }}</li>
|
||||
>
|
||||
<span class="rb__cand-main">
|
||||
<span class="rb__cand-name" data-test="reattach-cand-name">{{ c.name }}</span>
|
||||
<code class="rb__cand-uuid">{{ c.uuid }}</code>
|
||||
</span>
|
||||
<span class="rb__cand-meta">{{ candMeta(c) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="picked && (droppedCellCount ?? 0) > 0" class="rb__dropped" data-test="reattach-dropped-target">
|
||||
{{ droppedCellCount }} cells dropped — not on “{{ picked.name }}”.
|
||||
@@ -273,9 +300,13 @@ function confirm(side: Side): void {
|
||||
outline: none;
|
||||
}
|
||||
.rb__list { list-style: none; margin: 6px 0; padding: 0; max-height: 150px; overflow-y: auto; }
|
||||
.rb__cand { padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand { display: flex; flex-direction: column; gap: 2px; padding: 5px 8px; border-radius: 4px; cursor: pointer; color: var(--wp-text, #e7e7ee); }
|
||||
.rb__cand:hover { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 18%, transparent); }
|
||||
.rb__cand--picked { background: color-mix(in oklab, var(--wp-accent, #8b5cf6) 28%, transparent); }
|
||||
.rb__cand-main { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.rb__cand-name { font: 11px var(--wp-font-sans); }
|
||||
.rb__cand-uuid { flex-shrink: 0; font: 10px var(--wp-font-mono, monospace); color: var(--wp-text-dim, #6e6e7c); }
|
||||
.rb__cand-meta { font: 10px var(--wp-font-sans); color: var(--wp-text-muted, var(--wp-text-dim, #6e6e7c)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rb__dropped { margin: 6px 0 0; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
|
||||
.rb__blast { margin: 6px 0 0; display: flex; align-items: center; gap: 5px; font: 11px var(--wp-font-sans); color: var(--wp-warn, #f59e0b); }
|
||||
.rb__note { margin: 6px 0 0; font: 10px var(--wp-font-sans); color: var(--wp-text-dim, #6e6e7c); }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ExceptionsSection from "./ExceptionsSection.vue";
|
||||
import Select from "../../../../../manager/components/ui/Select.vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
|
||||
function makeModule(overrides: Partial<ModuleEntry> = {}): ModuleEntry {
|
||||
@@ -240,7 +241,7 @@ describe("constraint ExceptionsSection", () => {
|
||||
expect(patch.instance?.disabled_exception_keys).toEqual(['["red","black"]']);
|
||||
});
|
||||
|
||||
it("extra row source/target use VarAutocompleteInput", () => {
|
||||
it("extra row source/target use the Select value picker (concrete options)", () => {
|
||||
const w = mount(ExceptionsSection, {
|
||||
props: {
|
||||
module: makeModule({
|
||||
@@ -254,7 +255,12 @@ describe("constraint ExceptionsSection", () => {
|
||||
targetValues: TARGET_VALUES,
|
||||
},
|
||||
});
|
||||
expect(w.findAllComponents({ name: "VarAutocompleteInput" }).length).toBeGreaterThan(0);
|
||||
// Source + target must be picked from concrete options — a Select each
|
||||
// (its dropdown renders values as chips via the #option/#label slots),
|
||||
// never a free text box.
|
||||
expect(w.find('[data-test="ex-extra-src-0"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="ex-extra-tgt-0"]').exists()).toBe(true);
|
||||
expect(w.findAllComponents(Select).length).toBe(2);
|
||||
});
|
||||
|
||||
it("library exception with source='' renders the pi-ban chip when sourceHasNull=true", () => {
|
||||
@@ -391,9 +397,9 @@ describe("constraint ExceptionsSection — stranded read-only", () => {
|
||||
stranded: true,
|
||||
},
|
||||
});
|
||||
// The extra row still shows, but as a static snapshot.
|
||||
// The extra row still shows, but as a static snapshot — no Select picker.
|
||||
expect(w.find('[data-test="ex-extra-0"]').exists()).toBe(true);
|
||||
expect(w.findAllComponents({ name: "VarAutocompleteInput" }).length).toBe(0);
|
||||
expect(w.findAllComponents(Select).length).toBe(0);
|
||||
});
|
||||
|
||||
it("control: a healthy (non-stranded) constraint keeps the cycle button + edit controls", () => {
|
||||
|
||||
@@ -17,8 +17,8 @@ import { computed } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import { encodeKey } from "../../instance/keys";
|
||||
import { patchInstance } from "../../instance/patch";
|
||||
import VarAutocompleteInput from "../../../../../manager/components/VarAutocompleteInput.vue";
|
||||
import RichTextPreview from "../../../../../manager/components/RichTextPreview.vue";
|
||||
import Select, { type SelectOption } from "../../../../../manager/components/ui/Select.vue";
|
||||
|
||||
type Mode = "allow" | "exclude" | "boost" | "reduce";
|
||||
|
||||
@@ -138,6 +138,26 @@ const libraryExceptions = computed<LibraryException[]>(() => {
|
||||
|
||||
const instance = computed(() => props.module.instance ?? {});
|
||||
|
||||
/** Exception source/target must be a CONCRETE option of the source / target
|
||||
* wildcard — never free text or "any". The engine keys exceptions on real
|
||||
* option-value strings, so a picker (not a text box) is the correct control.
|
||||
* Build its options from the value lists; include the null option only when
|
||||
* the wildcard actually has one. Mirrors the SPA ConstraintEditor's
|
||||
* exceptionSrcOptions / exceptionTgtOptions. The `#option` / `#label` slots
|
||||
* render each value through RichTextPreview so `@{uuid}` refs + `{a|b}` blocks
|
||||
* chip instead of showing raw hex (issue #7). */
|
||||
const NULL_OPT_LABEL = "⌀ null";
|
||||
const exceptionSrcOptions = computed<SelectOption[]>(() => {
|
||||
const base: SelectOption[] = props.sourceValues.map((v) => ({ label: v, value: v }));
|
||||
if (props.sourceHasNull) base.unshift({ label: NULL_OPT_LABEL, value: "" });
|
||||
return base;
|
||||
});
|
||||
const exceptionTgtOptions = computed<SelectOption[]>(() => {
|
||||
const base: SelectOption[] = props.targetValues.map((v) => ({ label: v, value: v }));
|
||||
if (props.targetHasNull) base.unshift({ label: NULL_OPT_LABEL, value: "" });
|
||||
return base;
|
||||
});
|
||||
|
||||
const disabledKeys = computed<Set<string>>(
|
||||
() => new Set(instance.value.disabled_exception_keys ?? []),
|
||||
);
|
||||
@@ -304,6 +324,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
<section class="ex" data-test="ex-section">
|
||||
<div class="ex__label">Exceptions</div>
|
||||
|
||||
<div class="ex__list">
|
||||
<div
|
||||
v-for="(exc, i) in libraryExceptions"
|
||||
:key="libKey(exc)"
|
||||
@@ -479,23 +500,57 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
>×{{ exc.factor }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<VarAutocompleteInput
|
||||
<Select
|
||||
class="ex__extra-input"
|
||||
size="sm"
|
||||
:model-value="excSrc(exc, props.sourceOptionsById)"
|
||||
:suggestions="[...sourceValues]"
|
||||
:data-test="`ex-extra-src-${i}`"
|
||||
placeholder="source"
|
||||
:options="exceptionSrcOptions"
|
||||
placeholder="Pick value"
|
||||
aria-label="Extra exception source"
|
||||
@update:model-value="(v) => onExtraFieldChange(i, 'source_value', v)"
|
||||
/>
|
||||
:data-test="`ex-extra-src-${i}`"
|
||||
@update:model-value="(v) => onExtraFieldChange(i, 'source_value', String(v ?? ''))"
|
||||
>
|
||||
<template #label="{ option }">
|
||||
<RichTextPreview
|
||||
:value="String(option.value) === '' ? option.label : String(option.value)"
|
||||
:uuid-to-name="uuidToName"
|
||||
surface="wildcard"
|
||||
/>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<RichTextPreview
|
||||
:value="String(option.value) === '' ? option.label : String(option.value)"
|
||||
:uuid-to-name="uuidToName"
|
||||
surface="wildcard"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
<span class="ex__arrow">→</span>
|
||||
<VarAutocompleteInput
|
||||
<Select
|
||||
class="ex__extra-input"
|
||||
size="sm"
|
||||
:model-value="excTgt(exc, props.targetOptionsById)"
|
||||
:suggestions="[...targetValues]"
|
||||
:data-test="`ex-extra-tgt-${i}`"
|
||||
placeholder="target"
|
||||
:options="exceptionTgtOptions"
|
||||
placeholder="Pick value"
|
||||
aria-label="Extra exception target"
|
||||
@update:model-value="(v) => onExtraFieldChange(i, 'target_value', v)"
|
||||
/>
|
||||
:data-test="`ex-extra-tgt-${i}`"
|
||||
@update:model-value="(v) => onExtraFieldChange(i, 'target_value', String(v ?? ''))"
|
||||
>
|
||||
<template #label="{ option }">
|
||||
<RichTextPreview
|
||||
:value="String(option.value) === '' ? option.label : String(option.value)"
|
||||
:uuid-to-name="uuidToName"
|
||||
surface="wildcard"
|
||||
/>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<RichTextPreview
|
||||
:value="String(option.value) === '' ? option.label : String(option.value)"
|
||||
:uuid-to-name="uuidToName"
|
||||
surface="wildcard"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
<button
|
||||
type="button"
|
||||
class="ex__mode-chip"
|
||||
@@ -551,6 +606,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
><i class="pi pi-trash" /></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!stranded"
|
||||
@@ -581,11 +637,18 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ex__list {
|
||||
/* Long exception lists scroll in place; the label + add button stay put. */
|
||||
max-height: 34vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ex__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
/* right padding keeps the trash off the section edge + the list
|
||||
* scrollbar when the exceptions list scrolls. */
|
||||
padding: 4px 8px 4px 0;
|
||||
font: 11px var(--wp-font-mono);
|
||||
border-bottom: 1px dashed var(--wp-border-soft, var(--wp-border));
|
||||
}
|
||||
@@ -642,6 +705,14 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
}
|
||||
.ex__arrow { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
|
||||
/* Extra-exception value pickers share the row width with the mode chip +
|
||||
* factor + trash. flex:1 + min-width:0 lets the two Selects split the free
|
||||
* space instead of overflowing the row. */
|
||||
.ex__extra-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Styled checkbox — mirror of OptionRow's `.opt__check` pattern so
|
||||
* users see one consistent on/off control across wildcard / fixed-
|
||||
* values / constraint edit modals. */
|
||||
@@ -801,17 +872,17 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, var(--wp-text) 3%, transparent);
|
||||
border: 1px solid var(--wp-border);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.ex__trash:hover {
|
||||
background: color-mix(in srgb, var(--wp-danger, #e05252) 18%, transparent);
|
||||
|
||||
@@ -93,6 +93,64 @@ describe("constraint MatrixSection — layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — grouped axes", () => {
|
||||
it("renders a column band per target axis + a row header chip per source axis", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
sourceGroups: { warmth: ["red", "blue"] },
|
||||
targetGroups: { fabric: ["cotton", "silk"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length)).toEqual(["fabric"]);
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]);
|
||||
// Cells stay keyed by tag name, so the grouped layout is reorder-safe.
|
||||
expect(w.find('[data-test="mx-cell-red-silk"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="mx-cell-blue-cotton"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("a solo source axis folds its name into the tag eyebrow (no header chip)", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: ["red", "blue", "green"],
|
||||
targetSubs: TARGET_SUBS,
|
||||
sourceGroups: { warmth: ["red", "blue"], shade: ["green"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth"]); // shade is solo
|
||||
const solo = w.find(".mx-th-row.solo");
|
||||
expect(solo.exists()).toBe(true);
|
||||
expect(solo.text()).toContain("shade");
|
||||
expect(solo.text()).toContain("green");
|
||||
});
|
||||
|
||||
it("labels the leftover ungrouped run 'uncategorized' instead of leaving it blank", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: ["red", "blue", "extra"],
|
||||
targetSubs: ["cotton", "silk", "wool"],
|
||||
sourceGroups: { warmth: ["red", "blue"] },
|
||||
targetGroups: { fabric: ["cotton", "silk"] },
|
||||
},
|
||||
});
|
||||
expect(w.findAll(".mx-grp-head").map((h) => h.text())).toEqual(["warmth", "uncategorized"]);
|
||||
expect(w.findAll(".mx-th-band").map((b) => b.text()).filter((t) => t.length))
|
||||
.toEqual(["fabric", "uncategorized"]);
|
||||
});
|
||||
|
||||
it("stays flat (no bands / chips) when no groups are passed", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: { module: makeModule(), sourceSubs: ["red"], targetSubs: ["cotton"] },
|
||||
});
|
||||
expect(w.findAll(".mx-th-band").length).toBe(0);
|
||||
expect(w.findAll(".mx-grp-head").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — override marker", () => {
|
||||
it("renders override marker when mode differs from library", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
|
||||
@@ -15,12 +15,19 @@
|
||||
* three (`allow` / `disabled` / missing) as runtime passthrough,
|
||||
* so the collapse is engine-equivalent.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import { encodeKey } from "../../instance/keys";
|
||||
import { patchInstance } from "../../instance/patch";
|
||||
import CellRulePopover from "../CellRulePopover.vue";
|
||||
import MatrixLegend from "../MatrixLegend.vue";
|
||||
import {
|
||||
groupHue as tagHue,
|
||||
groupLabel,
|
||||
isBucket,
|
||||
orderByGroups,
|
||||
toGroups,
|
||||
} from "../../../../shared/matrix-axis";
|
||||
|
||||
type Mode = "neutral" | "exclude" | "boost" | "reduce";
|
||||
|
||||
@@ -33,6 +40,12 @@ const props = withDefaults(
|
||||
targetSubs: readonly string[];
|
||||
sourceName?: string;
|
||||
targetName?: string;
|
||||
/** Sub-category axes (tag_groups) of the source/target wildcards. When
|
||||
* present, the matrix orders + colours its rows / cols by axis (bands on
|
||||
* the columns, header chips on the rows) — same treatment as the SPA
|
||||
* ConstraintMatrix. Empty → flat source/target-tinted headers. */
|
||||
sourceGroups?: Record<string, string[]>;
|
||||
targetGroups?: Record<string, string[]>;
|
||||
/** Read-only recovery view: the source/target wildcard was deleted, so
|
||||
* the configured rules are shown for understanding only. Cells don't
|
||||
* open the rule popover; the grid frame + cells mute to a snapshot.
|
||||
@@ -40,7 +53,7 @@ const props = withDefaults(
|
||||
* ConstraintMatrix `readonly` prop / `wp-mx--readonly` treatment. */
|
||||
stranded?: boolean;
|
||||
}>(),
|
||||
{ sourceName: "", targetName: "", stranded: false },
|
||||
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}), stranded: false },
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
@@ -61,6 +74,26 @@ const cellFactorOverrides = computed<Record<string, number>>(
|
||||
() => (instance.value.cell_factor_overrides as Record<string, number> | null) ?? {},
|
||||
);
|
||||
|
||||
// ── Axis grouping — order + group + colour rows/cols by sub-category axis,
|
||||
// shared with the SPA ConstraintMatrix via the matrix-axis module. ──
|
||||
const orderedSources = computed(() => orderByGroups([...props.sourceSubs], props.sourceGroups));
|
||||
const orderedTargets = computed(() => orderByGroups([...props.targetSubs], props.targetGroups));
|
||||
const rowGroups = computed(() => toGroups(orderedSources.value));
|
||||
const colGroups = computed(() => toGroups(orderedTargets.value));
|
||||
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
|
||||
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
|
||||
|
||||
// Measure the column band row's height so the tag row beneath it sticks at
|
||||
// the right `top` offset, independent of font sizing.
|
||||
const gridEl = ref<HTMLElement | null>(null);
|
||||
function measureBands(): void {
|
||||
const grid = gridEl.value;
|
||||
if (!grid) return;
|
||||
const band = grid.querySelector<HTMLElement>(".mx-th-band");
|
||||
grid.style.setProperty("--mx-band-h", `${band?.offsetHeight ?? 0}px`);
|
||||
}
|
||||
watch(colGroups, () => void nextTick(measureBands));
|
||||
|
||||
/** Reads `payload.matrix[src][tgt]`. Returns null when no library rule. */
|
||||
function libCell(src: string, tgt: string): Cell | null {
|
||||
const cell = matrix.value[src]?.[tgt];
|
||||
@@ -303,12 +336,15 @@ onMounted(() => {
|
||||
document.addEventListener("keydown", onDocKeydown);
|
||||
window.addEventListener("scroll", reanchorPopover, true);
|
||||
window.addEventListener("resize", reanchorPopover);
|
||||
window.addEventListener("resize", measureBands);
|
||||
void nextTick(measureBands);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("mousedown", onDocPointerDown, true);
|
||||
document.removeEventListener("keydown", onDocKeydown);
|
||||
window.removeEventListener("scroll", reanchorPopover, true);
|
||||
window.removeEventListener("resize", reanchorPopover);
|
||||
window.removeEventListener("resize", measureBands);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -335,40 +371,101 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mx-grid">
|
||||
<div ref="gridEl" class="mx-grid">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="mx-corner" data-test="mx-corner">
|
||||
<th class="mx-corner" :rowspan="hasColBands ? 2 : 1" data-test="mx-corner">
|
||||
<span class="row-1">↓ source</span>
|
||||
<span class="row-2">→ target</span>
|
||||
</th>
|
||||
<th v-for="t in targetSubs" :key="t" class="mx-th-col">{{ t }}</th>
|
||||
<!-- Grouped cols: a band row names each axis, spanning its tags. -->
|
||||
<template v-if="hasColBands">
|
||||
<th
|
||||
v-for="(grp, gi) in colGroups"
|
||||
:key="`band-${gi}`"
|
||||
class="mx-th-band"
|
||||
:class="{ 'mx-th-band--bucket': isBucket(grp) }"
|
||||
:colspan="grp.tags.length"
|
||||
:style="{ '--ax': tagHue(grp, 'target', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</template>
|
||||
<!-- Flat cols: tag headers sit directly in the single header row. -->
|
||||
<template v-else>
|
||||
<th
|
||||
v-for="t in targetSubs"
|
||||
:key="t"
|
||||
class="mx-th-col"
|
||||
:style="{ '--ax': 'var(--wp-constraint-target)' }"
|
||||
><span class="chip">{{ t }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="hasColBands">
|
||||
<template v-for="(grp, gi) in colGroups" :key="`coltags-${gi}`">
|
||||
<th
|
||||
v-for="(t, ti) in grp.tags"
|
||||
:key="t"
|
||||
class="mx-th-col"
|
||||
:class="{ 'group-start': !isBucket(grp) && ti === 0 }"
|
||||
:style="{ '--ax': tagHue(grp, 'target', hasColBands) }"
|
||||
><span class="chip">{{ t }}</span></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in sourceSubs" :key="s">
|
||||
<th class="mx-th-row">{{ s }}</th>
|
||||
<td v-for="t in targetSubs" :key="`${s}-${t}`" class="mx-td">
|
||||
<template v-for="(grp, gi) in rowGroups" :key="`rowgrp-${gi}`">
|
||||
<!-- Header chip: a named multi-tag axis, OR the labelled
|
||||
uncategorized bucket (when the matrix has any named axis). A
|
||||
named solo axis folds into an eyebrow instead (below). -->
|
||||
<tr
|
||||
v-if="(!isBucket(grp) && grp.tags.length > 1) || (isBucket(grp) && hasRowGroups)"
|
||||
class="mx-grp-row"
|
||||
>
|
||||
<th
|
||||
class="mx-grp-head"
|
||||
:class="{ 'mx-grp-head--bucket': isBucket(grp) }"
|
||||
:colspan="orderedTargets.length + 1"
|
||||
:style="{ '--ax': tagHue(grp, 'source', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
</tr>
|
||||
<tr v-for="s in grp.tags" :key="s">
|
||||
<th
|
||||
class="mx-th-row"
|
||||
:class="{
|
||||
'group-start': !isBucket(grp) && s === grp.tags[0],
|
||||
solo: !isBucket(grp) && grp.tags.length === 1,
|
||||
}"
|
||||
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
|
||||
>
|
||||
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
|
||||
<span class="eye">{{ grp.axisName }}</span>
|
||||
<span class="v">{{ s }}</span>
|
||||
</span>
|
||||
<span v-else class="chip">{{ s }}</span>
|
||||
</th>
|
||||
<!-- role/tabindex stay static even when stranded (mirrors the
|
||||
SPA ConstraintMatrix): the cell is still a labelled
|
||||
snapshot a screen-reader can land on; `onCellClick` early-
|
||||
returns so it's inert. -->
|
||||
<div
|
||||
:class="cellClasses(s, t)"
|
||||
:data-test="`mx-cell-${s}-${t}`"
|
||||
:aria-label="cellAriaLabel(s, t)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onCellClick(s, t)"
|
||||
@keydown.enter.prevent="onCellClick(s, t)"
|
||||
@keydown.space.prevent="onCellClick(s, t)"
|
||||
>
|
||||
<span class="glyph">{{ cellGlyph(s, t) }}</span>
|
||||
<span v-if="cellShowsFactor(s, t)" class="factor">{{ cellFactorText(s, t) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<td v-for="t in orderedTargets" :key="`${s}-${t.tag}`" class="mx-td">
|
||||
<div
|
||||
:class="cellClasses(s, t.tag)"
|
||||
:data-test="`mx-cell-${s}-${t.tag}`"
|
||||
:aria-label="cellAriaLabel(s, t.tag)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="onCellClick(s, t.tag)"
|
||||
@keydown.enter.prevent="onCellClick(s, t.tag)"
|
||||
@keydown.space.prevent="onCellClick(s, t.tag)"
|
||||
>
|
||||
<span class="glyph">{{ cellGlyph(s, t.tag) }}</span>
|
||||
<span v-if="cellShowsFactor(s, t.tag)" class="factor">{{ cellFactorText(s, t.tag) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -475,48 +572,131 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.mx-axis .arrow { font: 14px var(--wp-font-mono); line-height: 1; }
|
||||
|
||||
/* ── Grid frame ────────────────────────────────────────────── */
|
||||
/* ── Grid frame — scrolls on BOTH axes; headers + corner freeze ── */
|
||||
.mx-grid {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
max-height: 52vh;
|
||||
overflow: auto;
|
||||
/* Own stacking context: the frozen header/corner cells below use z-index up
|
||||
* to 5, which without isolation compete in the modal's root context and
|
||||
* paint OVER the sticky footer/reach dock (z-index 2) — the matrix's headers
|
||||
* + scrollbar bled onto the runtime options on a tall matrix. Isolating
|
||||
* keeps those z-indexes local so the whole grid sits under the dock. */
|
||||
isolation: isolate;
|
||||
background: var(--wp-bg-deep, #0e1015);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
/* No padding on the SCROLL container — see ConstraintMatrix: sticky pins
|
||||
* headers at the padding edge while overflow still renders scrolling cells
|
||||
* inside the padding, so padding leaves a scrolled-cell bleed strip on the
|
||||
* top/left. Content sits flush; the border + radius give the frame. */
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.mx-grid table { border-collapse: separate; border-spacing: 4px; }
|
||||
/* `separate` + zero spacing (NOT `collapse`) — sticky header cells with
|
||||
* border-collapse have a Chromium paint bug where scrolled cells bleed through
|
||||
* the frozen row/column. The 4px inter-cell gap still comes from th/td padding,
|
||||
* so the layout is unchanged. */
|
||||
.mx-grid table { border-collapse: separate; border-spacing: 0; }
|
||||
/* 2px cell padding = the 4px inter-cell gap. Sticky headers fill with the
|
||||
* OPAQUE grid bg so scrolled cells can't bleed through them. */
|
||||
.mx-grid th,
|
||||
.mx-grid td { padding: 0; vertical-align: middle; }
|
||||
.mx-grid td { padding: 2px; vertical-align: middle; }
|
||||
.mx-grid thead th,
|
||||
.mx-grid tbody th {
|
||||
position: sticky;
|
||||
background: var(--wp-bg-deep, #0e1015);
|
||||
}
|
||||
.mx-grid thead th { top: 0; z-index: 2; }
|
||||
.mx-grid tbody th { left: 0; z-index: 1; }
|
||||
/* Band row pins to the top; the tag row sticks just beneath it (offset = the
|
||||
* measured band height). Corner outranks both so it stays in the angle. */
|
||||
.mx-grid thead th.mx-th-band { top: 0; z-index: 3; }
|
||||
.mx-grid thead th.mx-th-col { top: var(--mx-band-h, 0px); z-index: 2; }
|
||||
.mx-grid thead th.mx-corner { top: 0; left: 0; z-index: 5; padding: 5px 8px; }
|
||||
|
||||
.mx-corner {
|
||||
width: 96px;
|
||||
height: 32px;
|
||||
font: 9px var(--wp-font-mono);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
padding: 0 8px !important;
|
||||
background: color-mix(in srgb, var(--wp-text) 4%, transparent);
|
||||
}
|
||||
.mx-corner .row-1 { display: block; color: var(--wp-constraint-source-text); line-height: 1.3; }
|
||||
.mx-corner .row-2 { display: block; color: var(--wp-constraint-target-text); line-height: 1.3; }
|
||||
.mx-th-col {
|
||||
width: 64px;
|
||||
height: 28px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
color: var(--wp-constraint-target-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-target) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
|
||||
/* Column band — axis name spanning its tags, atop the tag row. */
|
||||
.mx-th-band .chip {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 22px; padding: 0 8px;
|
||||
font: 700 9px var(--wp-font-sans);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 4px 4px 0 0;
|
||||
color: color-mix(in srgb, var(--ax) 82%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-deep, #0e1015));
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
|
||||
}
|
||||
.mx-th-row {
|
||||
width: 96px;
|
||||
height: 36px;
|
||||
.mx-th-band--bucket .chip { border-bottom-style: dashed; }
|
||||
|
||||
/* Column tag chip — per-tag header below the band (or the only header row
|
||||
* when columns are ungrouped). */
|
||||
.mx-th-col .chip {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 64px; min-height: 28px; padding: 3px 2px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
color: var(--wp-constraint-source-text);
|
||||
background: color-mix(in oklab, var(--wp-constraint-source) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
text-align: right;
|
||||
padding: 0 12px !important;
|
||||
border-radius: 0 0 4px 4px;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 9%, var(--wp-bg-deep, #0e1015));
|
||||
}
|
||||
.mx-th-col.group-start .chip { border-left: 2px solid color-mix(in srgb, var(--ax) 45%, transparent); }
|
||||
|
||||
/* Row group header chip — names the axis above its tags, pinned to the left
|
||||
* edge so it stays visible during horizontal scroll. The <th> spans the row
|
||||
* but stays transparent; only the chip is visible + sticky. */
|
||||
.mx-grp-row .mx-grp-head {
|
||||
position: relative; z-index: 1; background: transparent;
|
||||
padding: 4px 0 1px; text-align: left;
|
||||
}
|
||||
.mx-grp-head .chip {
|
||||
position: sticky; left: 0;
|
||||
display: inline-flex; align-items: center;
|
||||
max-width: 220px; height: 22px; padding: 0 11px;
|
||||
font: 700 9px var(--wp-font-sans);
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 80%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 16%, var(--wp-bg-deep, #0e1015));
|
||||
border-left: 3px solid var(--ax);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--wp-bg, #000) 55%, transparent);
|
||||
}
|
||||
.mx-grp-head--bucket .chip { border-left-style: dashed; }
|
||||
|
||||
/* Row tag chip — one per source sub-category, left accent in the axis hue. */
|
||||
.mx-th-row .chip {
|
||||
display: flex; align-items: center; justify-content: flex-start;
|
||||
width: 96px; height: 36px; padding: 0 10px;
|
||||
font: 700 10px var(--wp-font-sans);
|
||||
text-align: left;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: color-mix(in srgb, var(--ax) 76%, var(--wp-text));
|
||||
background: color-mix(in srgb, var(--ax) 10%, var(--wp-bg-deep, #0e1015));
|
||||
border-left: 2px solid color-mix(in srgb, var(--ax) 48%, transparent);
|
||||
}
|
||||
.mx-th-row.solo .chip {
|
||||
flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px;
|
||||
border-left-width: 3px; border-left-color: var(--ax);
|
||||
}
|
||||
.mx-th-row.solo .eye {
|
||||
max-width: 78px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font: 700 7px var(--wp-font-sans);
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--ax) 72%, var(--wp-text-dim));
|
||||
}
|
||||
.mx-th-row.solo .v { font: 600 11px var(--wp-font-sans); color: var(--wp-text); }
|
||||
.mx-td { position: relative; }
|
||||
|
||||
/* ── Body cells ────────────────────────────────────────────── */
|
||||
|
||||
@@ -153,6 +153,30 @@ describe("TargetReachSection — pick checklist", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking anywhere on a pick row toggles it, not just the 14px checkbox", async () => {
|
||||
const { w, updates } = mountSection({ mode: "pick", picks: [] });
|
||||
// Click the NAME cell — previously inert, the checkbox was the only
|
||||
// hit target.
|
||||
await w.find('[data-test="reach-pick-1#mood-a"] .rh-pick__name').trigger("click");
|
||||
expect(last(updates)).toEqual({
|
||||
mode: "pick",
|
||||
picks: [{ kind: "direct", uid: "mood-a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("a checkbox click toggles exactly once (row handler must not double-fire)", async () => {
|
||||
// The row handler and WpCheck both see the click as it bubbles. If the
|
||||
// row didn't bail on interactive descendants the second toggle would
|
||||
// undo the first and ticking would appear to do nothing.
|
||||
const { w, updates } = mountSection({ mode: "pick", picks: [] });
|
||||
await w.find('[data-test="reach-pick-1#mood-a"] .wp-check').trigger("click");
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(last(updates)).toEqual({
|
||||
mode: "pick",
|
||||
picks: [{ kind: "direct", uid: "mood-a" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("nested row labels the @TARGET ref (display name) hosted in the carrier (display name, no @)", () => {
|
||||
const { w } = mountSection({ mode: "pick", picks: [] }, { targetName: "test-target" });
|
||||
const nestedRow = w.find('[data-test="reach-pick-2#backdrop-b::opt_a"]');
|
||||
|
||||
@@ -260,6 +260,16 @@ function togglePick(entry: PickEntry): void {
|
||||
emit("update:modelValue", { mode: "pick", picks: next });
|
||||
}
|
||||
|
||||
/** Pointer clicks anywhere on a pick row toggle it. Clicks that started
|
||||
* inside the WpCheck (or any future interactive descendant) are ignored —
|
||||
* the control already emitted its own toggle, and handling it twice would
|
||||
* cancel the change out. */
|
||||
function onPickRowClick(ev: MouseEvent, entry: PickEntry): void {
|
||||
const target = ev.target as HTMLElement | null;
|
||||
if (target?.closest?.("button, input, a, label, .wp-check")) return;
|
||||
togglePick(entry);
|
||||
}
|
||||
|
||||
// ── Consequence hint ────────────────────────────────────────────
|
||||
const reachCount = computed(() => pickRows.value.length);
|
||||
const hint = computed<string>(() => {
|
||||
@@ -338,14 +348,18 @@ const hint = computed<string>(() => {
|
||||
class="rh-pick__empty"
|
||||
data-test="reach-pick-empty"
|
||||
>No reachable target instances — use first / next / all.</div>
|
||||
<!-- WpCheck is the interactive control (own role/keyboard + aria-
|
||||
label); the row is presentational so the checkbox is the single
|
||||
focus/click target — no redundant row-level handler. -->
|
||||
<!-- WpCheck remains the single FOCUS target (it owns role + keyboard
|
||||
+ aria-label), so keyboard and screen-reader users get exactly one
|
||||
stop per row. The row additionally takes POINTER clicks: hitting a
|
||||
14px checkbox for every pick was needless precision work. The two
|
||||
don't double-toggle because `onPickRowClick` bails on any click
|
||||
that originated inside an interactive descendant. -->
|
||||
<div
|
||||
v-for="row in pickRows"
|
||||
:key="row.key"
|
||||
class="rh-pick__row"
|
||||
:data-test="`reach-pick-${row.key}`"
|
||||
@click="onPickRowClick($event, row.entry)"
|
||||
>
|
||||
<WpCheck
|
||||
:model-value="isPicked(row.entry)"
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
* Section order matches all shipped v2 modals: Header → Identity →
|
||||
* Rules (kind-specific) → Runtime → Footer.
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, inject, onMounted, ref } from "vue";
|
||||
import { saveChordLabel } from "../../../shared/platform-keys";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import type { PairingBadge } from "../../../../extension/constraint-pairs";
|
||||
import type { ModuleRow } from "../../../../manager/api/types";
|
||||
import type { VarProducerLike } from "../../../../manager/components/RefChip.vue";
|
||||
import {
|
||||
buildWildcardRefData,
|
||||
collectLibraryWildcardRefs,
|
||||
@@ -23,7 +25,12 @@ import {
|
||||
import IdentitySection from "./sections/IdentitySection.vue";
|
||||
import RulesSection from "./sections/RulesSection.vue";
|
||||
import RuntimeSection from "./sections/RuntimeSection.vue";
|
||||
import InstanceIdChip from "../InstanceIdChip.vue";
|
||||
import { CONTEXT_POOLS_KEY, overlayContextPools } from "../../../../extension/context-pools";
|
||||
|
||||
|
||||
/** Platform-correct save chord for the footer hint. */
|
||||
const saveChord = saveChordLabel();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
module: ModuleEntry;
|
||||
@@ -34,6 +41,8 @@ const props = withDefaults(
|
||||
/** Vars produced upstream of this Context node — forwarded as the
|
||||
* `$var` autocomplete list for the rule override fields. */
|
||||
upstreamVars?: string[];
|
||||
/** `$var` → its producing module + node, for the chip hover cards. */
|
||||
upstreamProducers?: Record<string, VarProducerLike>;
|
||||
/** Vars produced by other modules in the SAME Context node. */
|
||||
siblingVars?: string[];
|
||||
/** Via-nested constraint-pair badges for this derivation when it acts
|
||||
@@ -42,6 +51,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,
|
||||
@@ -61,11 +73,25 @@ const props = withDefaults(
|
||||
// `moduleStore.catalog`. The canvas has no Pinia store, so we fetch the
|
||||
// library ONCE when the modal mounts (same `/wp/api/modules` source the
|
||||
// ModulePickerModal reads) and build the SAME maps via the shared
|
||||
// `buildWildcardRefData`. The `@{}` source is the LIBRARY (by identity),
|
||||
// NOT this Context node's chain — a chain sibling would be a `$var`.
|
||||
// `buildWildcardRefData`.
|
||||
//
|
||||
// A `@{}` addresses a wildcard BY IDENTITY, so the candidate list is the
|
||||
// library — a chain sibling would be a `$var`. What the ref then RESOLVES
|
||||
// against is a separate question, answered by the overlay below.
|
||||
const catalog = ref<ModuleRow[]>([]);
|
||||
|
||||
const refData = computed(() => buildWildcardRefData(catalog.value));
|
||||
// Library-derived picker data, then the node's OWN pools overlaid on top.
|
||||
//
|
||||
// The engine resolves a nested `@{}` ref against this node's snapshot whenever
|
||||
// the uuid is also a module here, falling back to the library otherwise. The
|
||||
// picker used to offer the library's tags unconditionally, so on a drifted
|
||||
// snapshot it listed a tag no option in the RUNNING pool carries — pick it and
|
||||
// the ref resolves to nothing, with the checkbox list itself being the thing
|
||||
// that misled you. Same precedence the hover card applies, so card, picker and
|
||||
// engine now describe one pool.
|
||||
const contextPools = inject(CONTEXT_POOLS_KEY, undefined);
|
||||
const refData = computed(() =>
|
||||
overlayContextPools(buildWildcardRefData(catalog.value), contextPools?.value));
|
||||
const uuidToName = computed(() => refData.value.uuidToName);
|
||||
// Library wildcard uuids for `@{}` autocomplete, excluding this module's own
|
||||
// id (a wildcard never nests itself) — sorted by display name to mirror the
|
||||
@@ -83,6 +109,12 @@ const varSuggestions = computed<string[]>(() => {
|
||||
return [...set].sort();
|
||||
});
|
||||
|
||||
/** Producer lookup for the chip hover cards. A Map because RefChip reads it
|
||||
* per-chip and `Map.get` beats rebuilding an object lookup on every render. */
|
||||
const varProducerMap = computed<Map<string, VarProducerLike>>(
|
||||
() => new Map(Object.entries(props.upstreamProducers ?? {})),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// Fire-and-forget: until this resolves the ref-data maps stay empty, so
|
||||
// autocomplete simply shows nothing + summary chips fall back to cached
|
||||
@@ -134,6 +166,7 @@ function onSpaClick(): void {
|
||||
<div class="wp-dvm__title-row">
|
||||
<span class="wp-dvm__name" data-test="dvm-name">{{ module.meta?.name ?? module.type }}</span>
|
||||
<span class="wp-dvm__chip" data-test="dvm-chip">derivation</span>
|
||||
<InstanceIdChip :id="module.id" :uid="module._uid" />
|
||||
</div>
|
||||
<div class="wp-dvm__sub">Library entry · conditional rule-based variable rewrites</div>
|
||||
</div>
|
||||
@@ -150,6 +183,7 @@ function onSpaClick(): void {
|
||||
<RulesSection
|
||||
:module="module"
|
||||
:var-suggestions="varSuggestions"
|
||||
:var-producers="varProducerMap"
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:uuid-to-sub-categories="refData.uuidToSubCategories"
|
||||
@@ -160,7 +194,8 @@ function onSpaClick(): void {
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<div class="wp-dvm__dock">
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-dvm__foot">
|
||||
<a
|
||||
@@ -186,7 +221,7 @@ function onSpaClick(): void {
|
||||
Reset overrides
|
||||
</button>
|
||||
<span class="wp-dvm__hint">
|
||||
<kbd>Esc</kbd> cancel · <kbd>⌘↵</kbd> save
|
||||
<kbd>Esc</kbd> cancel · <kbd>{{ saveChord }}</kbd> save
|
||||
</span>
|
||||
<button
|
||||
v-if="canSaveToLibrary"
|
||||
@@ -198,6 +233,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-dvm__btn" data-test="dvm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-dvm__btn wp-dvm__btn--primary" data-test="dvm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -211,6 +247,16 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 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;
|
||||
@@ -218,6 +264,13 @@ function onSpaClick(): void {
|
||||
}
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). */
|
||||
.wp-dvm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-dvm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -262,6 +315,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));
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RuleValueChips from "./RuleValueChips.vue";
|
||||
|
||||
// Issue #1: nested `@{uuid}` / `$var` inside an inline `{a|b}` block used to
|
||||
// render as raw hex because RuleValueChips dumped the whole dp-brace token as
|
||||
// text. It now delegates to RichTextPreview (which decomposes braces via
|
||||
// atomicEditorModel.parse and chips every ref/var via RefChip), so the inner
|
||||
// refs chip at brace depth too.
|
||||
describe("RuleValueChips — nested refs inside braces", () => {
|
||||
const names = new Map([
|
||||
["beef0001", "standing"],
|
||||
["beef0002", "sitting"],
|
||||
]);
|
||||
|
||||
it("chips each @{} ref inside a {a|b} block (not raw hex)", () => {
|
||||
const w = mount(RuleValueChips, {
|
||||
props: { value: "{@{beef0001}|@{beef0002}}", uuidToName: names },
|
||||
});
|
||||
// Two inner refs → two RefChips; raw uuid hex must not leak as text.
|
||||
expect(w.findAllComponents({ name: "RefChip" }).length).toBe(2);
|
||||
expect(w.text()).not.toContain("beef0001");
|
||||
expect(w.text()).toContain("standing");
|
||||
expect(w.text()).toContain("sitting");
|
||||
});
|
||||
|
||||
it("chips a $var reference too", () => {
|
||||
const w = mount(RuleValueChips, { props: { value: "$pose_standing" } });
|
||||
expect(w.findAllComponents({ name: "RefChip" }).length).toBe(1);
|
||||
expect(w.text()).toContain("pose_standing");
|
||||
});
|
||||
|
||||
it("renders a distinct 'empty' state (not '?') for an empty value", () => {
|
||||
// Empty replace is valid — must read as an intentional empty, not a
|
||||
// failed resolve. No RefChip, no bare '?'.
|
||||
const w = mount(RuleValueChips, { props: { value: "" } });
|
||||
const empty = w.find('[data-test="rvc-empty"]');
|
||||
expect(empty.exists()).toBe(true);
|
||||
expect(empty.text()).toContain("empty");
|
||||
expect(w.text()).not.toContain("?");
|
||||
expect(w.findAllComponents({ name: "RefChip" }).length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,201 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* RuleValueChips — read-only chip renderer for a derivation rule's
|
||||
* action.value / condition.value strings inside the canvas instance modal
|
||||
* summary. Tokenizes the value and renders `@{uuid}` nested-wildcard refs +
|
||||
* `$var` references as CHIPS, brace blocks / escapes as colored spans — so a
|
||||
* value like `@{ad00acd5#mood:warm}` reads as a `@mood` chip with a funnel
|
||||
* mark instead of leaking raw hex.
|
||||
* action.value / condition.value strings in the canvas instance-modal
|
||||
* summary. Thin wrapper over the shared {@link RichTextPreview}: it owns only
|
||||
* the empty-value placeholder; all token rendering (var/ref chips at EVERY
|
||||
* brace depth, brace/escape scaffolding) delegates to RichTextPreview so a
|
||||
* value like `{@{a}|@{b}}` chips its inner refs instead of leaking raw hex.
|
||||
*
|
||||
* Chip markup + CSS are copied verbatim from the wildcard editor's OptionRow
|
||||
* (`editors/wildcard/sections/OptionRow.vue`) so the canvas reads identically
|
||||
* to the option-value chips. Names resolve from the modal-supplied
|
||||
* `uuidToName` map (the live library catalog) — no preview-resolver dep on
|
||||
* this surface. A missing display value renders the placeholder text.
|
||||
* Previously this hand-rolled a `tokenizeRich` loop that emitted `dp-brace`
|
||||
* blocks as `{{ tok.raw }}` — so nested `@{}` / `$var` inside `{a|b}` never
|
||||
* chipped (issue #1). Consolidating on RichTextPreview (which decomposes
|
||||
* braces via `atomicEditorModel.parse` and renders every chip as `RefChip`)
|
||||
* fixes that AND means one hover/click surface for all chips.
|
||||
*
|
||||
* `surface="wildcard"` so refs render as ACTIVE purple chips (derivation
|
||||
* action values are `@{}` carriers — the engine resolves them), matching the
|
||||
* prior appearance; `$var` reads still render as green var chips.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
import { splitRefFilter, tokenizeRich, type RichToken } from "../../../../../widgets/richTokenize";
|
||||
import { parse, readsAs } from "@/manager/parsing/subcatFilter";
|
||||
import RichTextPreview from "../../../../../manager/components/RichTextPreview.vue";
|
||||
import type { VarProducerLike } from "../../../../../manager/components/RefChip.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value?: string;
|
||||
/** UUID → display name, from the modal's catalog fetch. Empty until the
|
||||
* fetch resolves — chips fall back to the token's cached `#name`. */
|
||||
uuidToName?: Map<string, string>;
|
||||
/** Shown when `value` is empty (mirrors the old `|| "?"` placeholder). */
|
||||
/** UUID → display name, from the modal's catalog fetch. Forwarded to
|
||||
* RichTextPreview so ref chips show the human label. */
|
||||
uuidToName?: ReadonlyMap<string, string>;
|
||||
/** Label for the empty state. Defaults to "empty" — an empty value is a
|
||||
* VALID derivation action (replace-with-nothing), so it must read as an
|
||||
* intentional empty, NOT the `?` that looked like a failed resolve. */
|
||||
placeholder?: string;
|
||||
/** `$var` → producer, so the summary chips' hover cards can name who
|
||||
* writes the variable instead of falling back to "binds at runtime". */
|
||||
varProducers?: ReadonlyMap<string, VarProducerLike>;
|
||||
/** Canvas host — a missing producer is a real finding here. */
|
||||
graphAware?: boolean;
|
||||
}>(),
|
||||
{ value: "", uuidToName: () => new Map(), placeholder: "?" },
|
||||
{
|
||||
value: "", uuidToName: () => new Map(), placeholder: "empty",
|
||||
varProducers: () => new Map(), graphAware: false,
|
||||
},
|
||||
);
|
||||
|
||||
const tokens = computed<RichToken[]>(() => tokenizeRich(props.value ?? ""));
|
||||
const isEmpty = computed(() => (props.value ?? "").length === 0);
|
||||
|
||||
/** Human label for a ref chip — `@name` from the live map, else the cached
|
||||
* `#name` (stripping a leaked `!null`), else the raw token. Mirrors
|
||||
* OptionRow.refDisplay's fallback chain. */
|
||||
function refDisplay(tok: RichToken): string {
|
||||
const uuid = tok.meta?.uuid;
|
||||
if (!uuid) return tok.raw;
|
||||
const live = props.uuidToName.get(uuid);
|
||||
if (live && live.trim()) return `@${live.trim()}`;
|
||||
const cached = typeof tok.meta?.name === "string" ? tok.meta.name : "";
|
||||
if (cached.trim()) return `@${cached.split("!")[0].trim() || cached.trim()}`;
|
||||
return tok.raw;
|
||||
}
|
||||
|
||||
/** Ref uuid absent from the live map → broken reference. Empty map (pre-fetch)
|
||||
* is "unknown, not broken" so chips don't flash red while loading. */
|
||||
function refIsUnresolved(tok: RichToken): boolean {
|
||||
const uuid = tok.meta?.uuid;
|
||||
if (!uuid) return false;
|
||||
if (props.uuidToName.size === 0) return false;
|
||||
return !props.uuidToName.has(uuid);
|
||||
}
|
||||
|
||||
interface RefFilterInfo { hasExpr: boolean; excludeNull: boolean; isFiltered: boolean; title: string }
|
||||
const _refFilterMemo = new WeakMap<RichToken, RefFilterInfo>();
|
||||
/** Funnel (sub-cat expression) + ban (null excluded) marks + hover title.
|
||||
* Copied from OptionRow.refFilter. */
|
||||
function refFilter(tok: RichToken): RefFilterInfo {
|
||||
const cached = _refFilterMemo.get(tok);
|
||||
if (cached) return cached;
|
||||
const subs = tok.meta?.sub_categories;
|
||||
let expr = "";
|
||||
let excludeNull = false;
|
||||
if (Array.isArray(subs) && subs.length > 0) {
|
||||
({ expr, excludeNull } = splitRefFilter(subs.join(",")));
|
||||
} else {
|
||||
const name = typeof tok.meta?.name === "string" ? tok.meta.name : "";
|
||||
const bang = name.indexOf("!");
|
||||
if (bang >= 0) excludeNull = name.slice(bang + 1) === "null";
|
||||
}
|
||||
let reads = "";
|
||||
if (expr) {
|
||||
try { reads = readsAs(parse(expr)); } catch { reads = expr; }
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (reads) parts.push(reads);
|
||||
if (excludeNull) parts.push("null excluded");
|
||||
const info: RefFilterInfo = {
|
||||
hasExpr: expr.length > 0,
|
||||
excludeNull,
|
||||
isFiltered: expr.length > 0 || excludeNull,
|
||||
title: parts.join(" · "),
|
||||
};
|
||||
_refFilterMemo.set(tok, info);
|
||||
return info;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="rvc">
|
||||
<span v-if="isEmpty" class="rvc__placeholder">{{ placeholder }}</span>
|
||||
<template v-for="(tok, idx) in tokens" v-else :key="idx">
|
||||
<span
|
||||
v-if="tok.kind === 'ref'"
|
||||
class="rvc__tok rvc__tok--ref"
|
||||
:class="{
|
||||
'rvc__tok--filtered': refFilter(tok).isFiltered,
|
||||
'rvc__tok--unresolved': refIsUnresolved(tok),
|
||||
}"
|
||||
:data-uuid="tok.meta?.uuid"
|
||||
:title="refIsUnresolved(tok)
|
||||
? `Reference ${tok.meta?.uuid} not in library`
|
||||
: (refFilter(tok).title || undefined)"
|
||||
>
|
||||
<span class="rvc__tok-icon" aria-hidden="true">{{ refIsUnresolved(tok) ? '?' : '✦' }}</span>
|
||||
<span class="rvc__tok-label">{{ refDisplay(tok) }}</span>
|
||||
<span
|
||||
v-if="refFilter(tok).isFiltered"
|
||||
class="rvc__tok-filter"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i v-if="refFilter(tok).hasExpr" class="pi pi-filter rvc__tok-funnel"></i>
|
||||
<i v-if="refFilter(tok).excludeNull" class="pi pi-ban rvc__tok-nonull"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="tok.kind === 'var'"
|
||||
class="rvc__tok rvc__tok--var"
|
||||
>
|
||||
<span class="rvc__tok-icon" aria-hidden="true">⌘</span>
|
||||
<span class="rvc__tok-label">{{ tok.raw }}</span>
|
||||
</span>
|
||||
<span v-else-if="tok.kind === 'dp-brace'" class="rvc__tok rvc__tok--brace">{{ tok.raw }}</span>
|
||||
<span v-else-if="tok.kind === 'dp-multi'" class="rvc__tok rvc__tok--multi">{{ tok.raw }}</span>
|
||||
<span v-else-if="tok.kind === 'escape'" class="rvc__tok rvc__tok--escape">{{ tok.raw }}</span>
|
||||
<template v-else>{{ tok.raw }}</template>
|
||||
</template>
|
||||
<!-- Empty is a first-class, VALID state (replace $var with ""). Render a
|
||||
muted ∅ pill — visually distinct from both a real value (chips) and an
|
||||
unresolved/broken ref (red) — so it never reads as "didn't resolve". -->
|
||||
<span
|
||||
v-if="isEmpty"
|
||||
class="rvc__empty"
|
||||
title="Replaced with an empty value"
|
||||
data-test="rvc-empty"
|
||||
>
|
||||
<span class="rvc__empty-glyph" aria-hidden="true">∅</span>{{ placeholder }}
|
||||
</span>
|
||||
<RichTextPreview
|
||||
v-else
|
||||
:value="value"
|
||||
:uuid-to-name="uuidToName"
|
||||
:var-producers="varProducers"
|
||||
:graph-aware="graphAware"
|
||||
surface="wildcard"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Inline-syntax chips — palette + icon prefix copied from the wildcard
|
||||
* OptionRow (`editors/wildcard/sections/OptionRow.vue`) so an `@nestedName`
|
||||
* reads the same in the derivation summary as in an option value: purple ✦
|
||||
* for refs, green ⌘ for vars, amber funnel/ban filter marks. */
|
||||
.rvc {
|
||||
display: inline;
|
||||
}
|
||||
.rvc__placeholder {
|
||||
color: var(--wp-text);
|
||||
background: color-mix(in srgb, var(--wp-text-dim, var(--wp-text3)) 12%, transparent);
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.rvc__tok {
|
||||
margin: 0 1px;
|
||||
font-weight: 500;
|
||||
.rvc__empty {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.rvc__tok--ref,
|
||||
.rvc__tok--var {
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
gap: 3px;
|
||||
border: 1px solid;
|
||||
font: 10px/1.4 var(--wp-font-mono);
|
||||
padding: 0 5px;
|
||||
border: 1px dashed color-mix(in srgb, var(--wp-text-dim, var(--wp-text3)) 45%, transparent);
|
||||
border-radius: 3px;
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
font: italic 10px/1.4 var(--wp-font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.rvc__tok-icon { font-size: 8px; opacity: 0.75; }
|
||||
.rvc__tok-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 2px;
|
||||
color: var(--wp-status-modified, #fbbf24);
|
||||
}
|
||||
.rvc__tok-funnel { font-size: 8px; line-height: 1; }
|
||||
.rvc__tok-nonull { font-size: 8px; line-height: 1; opacity: 0.85; }
|
||||
.rvc__tok--ref {
|
||||
background: color-mix(in srgb, var(--wp-kind-wildcard, #a855f7) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--wp-kind-wildcard, #a855f7) 50%, transparent);
|
||||
color: var(--wp-kind-wildcard);
|
||||
}
|
||||
.rvc__tok--ref.rvc__tok--unresolved {
|
||||
background: color-mix(in srgb, var(--wp-danger, #ef4444) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--wp-danger, #ef4444) 50%, transparent);
|
||||
color: var(--wp-danger, #ef4444);
|
||||
cursor: help;
|
||||
}
|
||||
.rvc__tok--var {
|
||||
background: color-mix(in srgb, var(--wp-success, #22c55e) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--wp-success, #22c55e) 50%, transparent);
|
||||
color: var(--wp-success);
|
||||
}
|
||||
.rvc__tok--brace {
|
||||
color: var(--wp-warn, #fcd34d);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rvc__tok--multi {
|
||||
color: var(--wp-rt-token-good, #4ad4c4);
|
||||
font-weight: 500;
|
||||
}
|
||||
.rvc__tok--escape {
|
||||
color: var(--wp-text-muted, var(--wp-text2));
|
||||
opacity: 0.7;
|
||||
.rvc__empty-glyph {
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import type { ModuleEntry } from "../../../../../widgets/_shared";
|
||||
import type { PairingBadge } from "../../../../../extension/constraint-pairs";
|
||||
import type { VarProducerLike } from "../../../../../manager/components/RefChip.vue";
|
||||
import { patchInstance } from "../../instance/patch";
|
||||
import { varColorClass } from "../../../../shared/var-color";
|
||||
import RuleValueChips from "./RuleValueChips.vue";
|
||||
@@ -57,6 +58,9 @@ const props = withDefaults(
|
||||
/** `$var` autocomplete list for the override RichTextInputs —
|
||||
* upstream + sibling producer vars, forwarded by the modal. */
|
||||
varSuggestions?: string[];
|
||||
/** `$var` → producing module + node, forwarded to the override fields'
|
||||
* RichTextInputs so their var chips can name the producer on hover. */
|
||||
varProducers?: Map<string, VarProducerLike>;
|
||||
/** Library WILDCARD uuids for the `@{}` autocomplete (ACTION /
|
||||
* ELSE-action inputs only — `condition.value` is compared raw). */
|
||||
refSuggestions?: string[];
|
||||
@@ -81,6 +85,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
varSuggestions: () => [],
|
||||
varProducers: () => new Map(),
|
||||
refSuggestions: () => [],
|
||||
uuidToName: () => new Map(),
|
||||
uuidToSubCategories: () => new Map(),
|
||||
@@ -363,6 +368,68 @@ function modeLabel(mode: string | undefined): string {
|
||||
function opUsesValue(op: string | undefined): boolean {
|
||||
return op !== "exists" && op !== "not_exists" && op !== "is_set" && op !== "is_unset";
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest value a preview row will render before it gets cut.
|
||||
*
|
||||
* The summary wraps, which is what makes a multi-chip value readable — but a
|
||||
* paragraph-length override then ran to dozens of lines and pushed the rest of
|
||||
* the modal off screen. Capping the VALUE rather than the layout keeps every
|
||||
* normal row exactly as it was (chips, wrapping, all of it visible) and only
|
||||
* touches the outliers. `title` still carries the full text on hover.
|
||||
*/
|
||||
const SUMMARY_MAX_CHARS = 120;
|
||||
|
||||
/**
|
||||
* Cut `value` to `SUMMARY_MAX_CHARS` and append an ellipsis, never splitting a
|
||||
* `@{...}` token — half a token would render as literal text instead of a
|
||||
* chip, which looks like corruption rather than truncation. A token that would
|
||||
* straddle the limit is dropped whole.
|
||||
*/
|
||||
function clampPreviewValue(value: unknown): string {
|
||||
const s = typeof value === "string" ? value : "";
|
||||
if (s.length <= SUMMARY_MAX_CHARS) return s;
|
||||
// Capturing split keeps the `@{...}` tokens as their own segments.
|
||||
const segments = s.split(/(@\{[^}]*\})/g);
|
||||
let out = "";
|
||||
for (const seg of segments) {
|
||||
if (out.length >= SUMMARY_MAX_CHARS) break;
|
||||
if (seg.startsWith("@{")) {
|
||||
if (out.length > 0 && out.length + seg.length > SUMMARY_MAX_CHARS) break;
|
||||
out += seg;
|
||||
} else {
|
||||
out += seg.slice(0, SUMMARY_MAX_CHARS - out.length);
|
||||
}
|
||||
}
|
||||
return out.length < s.length ? `${out.trimEnd()}…` : out;
|
||||
}
|
||||
|
||||
/** Plain-text rendering of a branch, for the `title` on the preview rows —
|
||||
* the visible summary caps long values, so this is how the user reads the
|
||||
* rest. */
|
||||
function branchSummaryText(
|
||||
branch: { condition?: { var?: string; op?: string; value?: string };
|
||||
action?: { target_var?: string; mode?: string; value?: string } } | undefined,
|
||||
): string {
|
||||
if (!branch) return "";
|
||||
const parts: string[] = [];
|
||||
const c = branch.condition;
|
||||
if (c?.var) {
|
||||
parts.push(`$${c.var} ${opSymbol(c.op)}`);
|
||||
if (opUsesValue(c.op) && c.value) parts.push(c.value);
|
||||
}
|
||||
const a = branch.action;
|
||||
if (a?.target_var) parts.push(`→ $${a.target_var} ${modeLabel(a.mode)} ${a.value ?? ""}`);
|
||||
return parts.join(" ").trim();
|
||||
}
|
||||
|
||||
/** Same, for the collapsed rule head — which previews its FIRST branch. */
|
||||
function ruleSummaryText(rule: DerivationRule): string {
|
||||
const first = rule.branches?.[0];
|
||||
const head = branchSummaryText(first);
|
||||
const n = rule.branches?.length ?? 0;
|
||||
return n > 1 ? `${head} (+${n - 1} more branch${n - 1 === 1 ? "" : "es"})` : head;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -422,7 +489,11 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
|
||||
<span class="rule-head__num" :title="`Rule id ${rule.id}`">Rule {{ ruleIdx + 1 }}</span>
|
||||
|
||||
<span class="rule-head__summary" :data-test="`rule-summary-${rule.id}`">
|
||||
<span
|
||||
class="rule-head__summary"
|
||||
:title="ruleSummaryText(rule)"
|
||||
:data-test="`rule-summary-${rule.id}`"
|
||||
>
|
||||
<template v-if="rule.branches && rule.branches.length > 0">
|
||||
<span
|
||||
v-if="rule.branches[0].condition?.var"
|
||||
@@ -432,14 +503,14 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
<span
|
||||
v-if="opUsesValue(rule.branches[0].condition?.op)"
|
||||
class="rule-tok-val"
|
||||
><RuleValueChips :value="rule.branches[0].condition?.value" :uuid-to-name="uuidToName" /></span>
|
||||
><RuleValueChips :value="clampPreviewValue(rule.branches[0].condition?.value)" :uuid-to-name="uuidToName" :var-producers="varProducers" graph-aware /></span>
|
||||
<span class="rule-tok-arrow">→</span>
|
||||
<span
|
||||
v-if="rule.branches[0].action?.target_var"
|
||||
:class="['rule-tok-var', varColorClass(rule.branches[0].action.target_var)]"
|
||||
>${{ rule.branches[0].action.target_var }}</span>
|
||||
<span class="rule-tok-op">{{ modeLabel(rule.branches[0].action?.mode) }}</span>
|
||||
<span class="rule-tok-val"><RuleValueChips :value="rule.branches[0].action?.value" :uuid-to-name="uuidToName" /></span>
|
||||
<span class="rule-tok-val"><RuleValueChips :value="clampPreviewValue(rule.branches[0].action?.value)" :uuid-to-name="uuidToName" :var-producers="varProducers" graph-aware /></span>
|
||||
<!-- Inline ↪#N constraint-pair badge — rendered when this
|
||||
derivation is a constraint carrier through the IF branch's
|
||||
nested `@{uuid}` ref. Mirrors OptionRow's per-option badge
|
||||
@@ -518,7 +589,7 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
<span class="branch-cell branch-cell--tag" :data-kind="bi === 0 ? 'if' : 'elif'">
|
||||
{{ bi === 0 ? "IF" : "ELIF" }}
|
||||
</span>
|
||||
<span class="branch-cell branch-cell--summary">
|
||||
<span class="branch-cell branch-cell--summary" :title="branchSummaryText(branch)">
|
||||
<span
|
||||
v-if="branch.condition?.var"
|
||||
:class="['rule-tok-var', varColorClass(branch.condition.var)]"
|
||||
@@ -527,14 +598,14 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
<span
|
||||
v-if="opUsesValue(branch.condition?.op)"
|
||||
class="rule-tok-val"
|
||||
><RuleValueChips :value="branch.condition?.value" :uuid-to-name="uuidToName" /></span>
|
||||
><RuleValueChips :value="clampPreviewValue(branch.condition?.value)" :uuid-to-name="uuidToName" :var-producers="varProducers" graph-aware /></span>
|
||||
<span class="rule-tok-arrow">→</span>
|
||||
<span
|
||||
v-if="branch.action?.target_var"
|
||||
:class="['rule-tok-var', varColorClass(branch.action.target_var)]"
|
||||
>${{ branch.action.target_var }}</span>
|
||||
<span class="rule-tok-op">{{ modeLabel(branch.action?.mode) }}</span>
|
||||
<span class="rule-tok-val"><RuleValueChips :value="branch.action?.value" :uuid-to-name="uuidToName" /></span>
|
||||
<span class="rule-tok-val"><RuleValueChips :value="clampPreviewValue(branch.action?.value)" :uuid-to-name="uuidToName" :var-producers="varProducers" graph-aware /></span>
|
||||
<PairBadge
|
||||
v-for="p in pairBadgesFor(rule.id, bi)"
|
||||
:key="`${p.number}-${p.targetUuid}`"
|
||||
@@ -551,7 +622,8 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
<RichTextInput
|
||||
v-if="opUsesValue(branch.condition?.op)"
|
||||
surface="derivation"
|
||||
:var-suggestions="varSuggestions"
|
||||
wrap
|
||||
:var-suggestions="varSuggestions" :var-producers="varProducers" graph-aware
|
||||
:uuid-to-name="uuidToName"
|
||||
:model-value="getOverride('condition_value_overrides', rule.id, String(bi))"
|
||||
:placeholder="branch.condition?.value || ''"
|
||||
@@ -570,7 +642,8 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
<RichTextInput
|
||||
surface="derivation"
|
||||
allow-nested-refs
|
||||
:var-suggestions="varSuggestions"
|
||||
wrap
|
||||
:var-suggestions="varSuggestions" :var-producers="varProducers" graph-aware
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:uuid-to-sub-categories="uuidToSubCategories"
|
||||
@@ -610,14 +683,17 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
</span>
|
||||
</span>
|
||||
<span class="branch-cell branch-cell--tag" data-kind="else">ELSE</span>
|
||||
<span class="branch-cell branch-cell--summary">
|
||||
<span
|
||||
class="branch-cell branch-cell--summary"
|
||||
:title="branchSummaryText({ action: rule.else.action })"
|
||||
>
|
||||
<span class="rule-tok-arrow">→</span>
|
||||
<span
|
||||
v-if="rule.else.action?.target_var"
|
||||
:class="['rule-tok-var', varColorClass(rule.else.action.target_var)]"
|
||||
>${{ rule.else.action.target_var }}</span>
|
||||
<span class="rule-tok-op">{{ modeLabel(rule.else.action?.mode) }}</span>
|
||||
<span class="rule-tok-val"><RuleValueChips :value="rule.else.action?.value" :uuid-to-name="uuidToName" /></span>
|
||||
<span class="rule-tok-val"><RuleValueChips :value="clampPreviewValue(rule.else.action?.value)" :uuid-to-name="uuidToName" :var-producers="varProducers" graph-aware /></span>
|
||||
<PairBadge
|
||||
v-for="p in pairBadgesFor(rule.id, 'else')"
|
||||
:key="`${p.number}-${p.targetUuid}`"
|
||||
@@ -633,7 +709,8 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
<RichTextInput
|
||||
surface="derivation"
|
||||
allow-nested-refs
|
||||
:var-suggestions="varSuggestions"
|
||||
wrap
|
||||
:var-suggestions="varSuggestions" :var-producers="varProducers" graph-aware
|
||||
:ref-suggestions="refSuggestions"
|
||||
:uuid-to-name="uuidToName"
|
||||
:uuid-to-sub-categories="uuidToSubCategories"
|
||||
@@ -693,7 +770,34 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
/* 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 {
|
||||
@@ -701,6 +805,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; }
|
||||
@@ -757,6 +868,17 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* A rule head is a PREVIEW, kept to ONE line. Wrapping let a paragraph-length
|
||||
action value run to hundreds of lines and push the rest of the modal off
|
||||
screen. Truncating keeps the row a clean single line, and `title` (see
|
||||
`ruleSummaryText`) gives the full text on hover.
|
||||
|
||||
`display: block`, not flex: `text-overflow: ellipsis` is ignored on a flex
|
||||
container, which is why an earlier attempt reached for a gradient mask
|
||||
instead — but a mask fades EVERY pixel under it, including the ref chips,
|
||||
so the tail of the summary went unreadable rather than merely cut. As an
|
||||
inline formatting context the browser draws a real `…` at the clip point
|
||||
and leaves everything before it at full opacity. */
|
||||
.rule-head__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -766,6 +888,7 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
font: 11px var(--wp-font-mono);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rule-card--off .rule-head__summary { text-decoration: line-through; }
|
||||
.rule-head__chip {
|
||||
font: 600 9px var(--wp-font-sans);
|
||||
@@ -821,13 +944,19 @@ function opUsesValue(op: string | undefined): boolean {
|
||||
.branch-row--off {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.branch-row--off .branch-cell--summary { text-decoration: line-through; }
|
||||
.branch-row--off .branch-cell--summary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.branch-cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
/* Same treatment as the rule head, for the same reason: this is a per-branch
|
||||
PREVIEW column, and one long action value stretched its row until the branch
|
||||
table stopped being a table. Single line + real ellipsis + `title` on hover;
|
||||
the override inputs beside it hold the full value and scroll. */
|
||||
.branch-cell--summary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { saveChordLabel } from "../../../shared/platform-keys";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import IdentitySection from "./sections/IdentitySection.vue";
|
||||
import ValuesSection from "./sections/ValuesSection.vue";
|
||||
import RuntimeSection from "./sections/RuntimeSection.vue";
|
||||
import InstanceIdChip from "../InstanceIdChip.vue";
|
||||
|
||||
|
||||
/** Platform-correct save chord for the footer hint. */
|
||||
const saveChord = saveChordLabel();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
module: ModuleEntry;
|
||||
@@ -12,6 +17,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 },
|
||||
);
|
||||
@@ -52,6 +60,7 @@ function onSpaClick(): void {
|
||||
<div class="wp-fvm__title-row">
|
||||
<span class="wp-fvm__name" data-test="fvm-name">{{ module.meta?.name ?? module.type }}</span>
|
||||
<span class="wp-fvm__chip" data-test="fvm-chip">fixed</span>
|
||||
<InstanceIdChip :id="module.id" :uid="module._uid" />
|
||||
</div>
|
||||
<div class="wp-fvm__sub">Library entry · static $var → value pairs</div>
|
||||
</div>
|
||||
@@ -66,7 +75,8 @@ function onSpaClick(): void {
|
||||
|
||||
<IdentitySection :module="module" @update="onUpdate" />
|
||||
<ValuesSection :module="module" @update="onUpdate" />
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<div class="wp-fvm__dock">
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-fvm__foot">
|
||||
<a
|
||||
@@ -93,7 +103,7 @@ function onSpaClick(): void {
|
||||
Reset overrides
|
||||
</button>
|
||||
<span class="wp-fvm__hint">
|
||||
<kbd>Esc</kbd> cancel · <kbd>⌘↵</kbd> save
|
||||
<kbd>Esc</kbd> cancel · <kbd>{{ saveChord }}</kbd> save
|
||||
</span>
|
||||
<button
|
||||
v-if="canSaveToLibrary"
|
||||
@@ -105,6 +115,7 @@ function onSpaClick(): void {
|
||||
<button type="button" class="wp-fvm__btn" data-test="fvm-cancel" @click="emit('cancel')">Cancel</button>
|
||||
<button type="button" class="wp-fvm__btn wp-fvm__btn--primary" data-test="fvm-save" @click="emit('save')">Save</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -118,13 +129,19 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). */
|
||||
.wp-fvm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-fvm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -169,6 +186,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>
|
||||
|
||||
@@ -14,6 +14,34 @@ describe("ValueRow", () => {
|
||||
expect(w.find<HTMLInputElement>('[data-test="row-value"]').element.value).toBe("85mm");
|
||||
});
|
||||
|
||||
it("value field is a growing textarea, not a single-line input", async () => {
|
||||
// Users routinely paste a whole sentence into a fixed value; a one-line
|
||||
// <input> showed a sliver with no way to see the rest.
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const el = w.find('[data-test="row-value"]');
|
||||
expect(el.element.tagName).toBe("TEXTAREA");
|
||||
});
|
||||
|
||||
it("Enter inserts a newline like any textarea", async () => {
|
||||
// Briefly swallowed for parity with the single-line <input> this replaced,
|
||||
// which was parity for its own sake: the engine stores the value verbatim,
|
||||
// multi-line round-trips fine, and the modal saves on Cmd/Ctrl+Enter.
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const el = w.find('[data-test="row-value"]');
|
||||
const ev = new KeyboardEvent("keydown", { key: "Enter", cancelable: true, bubbles: true });
|
||||
el.element.dispatchEvent(ev);
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a multi-line value intact on input", async () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const el = w.find<HTMLTextAreaElement>('[data-test="row-value"]');
|
||||
el.element.value = "line one\nline two";
|
||||
await el.trigger("input");
|
||||
const updates = w.emitted("update") ?? [];
|
||||
expect(updates[updates.length - 1]).toEqual(["v1", { value: "line one\nline two" }]);
|
||||
});
|
||||
|
||||
it("checkbox is checked when enabled", () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
expect(w.find('[data-test="row-check"]').classes()).toContain("row__check--on");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useGrowableField } from "../../../../shared/useGrowableField";
|
||||
import {
|
||||
rowOverrideKind,
|
||||
type DraftRow,
|
||||
@@ -62,8 +63,74 @@ function onNameInput(ev: Event): void {
|
||||
emit("update", props.row.id, { name });
|
||||
}
|
||||
function onValueInput(ev: Event): void {
|
||||
emit("update", props.row.id, { value: (ev.target as HTMLInputElement).value });
|
||||
const el = ev.target as HTMLTextAreaElement;
|
||||
emit("update", props.row.id, { value: el.value });
|
||||
autosize();
|
||||
updateOverflowHint();
|
||||
}
|
||||
|
||||
/** Grow the value field to fit its content, capped by the CSS `max-height`
|
||||
* (after which it scrolls). The field is a `<textarea>` purely to get this
|
||||
* wrapping + growth — users routinely paste an entire sentence into a fixed
|
||||
* value and a single-line `<input>` showed a sliver of it with no way to see
|
||||
* the rest. Semantics stay single-value; `onValueKeydown` blocks newlines. */
|
||||
/* Auto-grow now lives in `useGrowableField` so it can YIELD to the drag
|
||||
* handle. The old local version wrote `height = scrollHeight` on every input
|
||||
* and every external value change, which stomped whatever height the user had
|
||||
* dragged to — collapse the box, type one character, and it sprang back open.
|
||||
* That is the "the drag sticks, then starts working" report. */
|
||||
|
||||
/* Enter inserts a newline, like any other textarea.
|
||||
*
|
||||
* It was briefly swallowed to preserve the behaviour of the single-line
|
||||
* `<input>` this replaced, but that was parity for its own sake: a fixed value
|
||||
* is an arbitrary string, the engine stores and emits it verbatim, and a
|
||||
* multi-line value round-trips fine. The modal saves on Cmd/Ctrl+Enter, so
|
||||
* plain Enter is free — and users writing paragraph-length values want their
|
||||
* line breaks. */
|
||||
|
||||
const valueEl = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
/* Shared auto-grow + overflow-hint + grip-follow. See `useGrowableField` for
|
||||
* why the three belong together — chiefly that auto-grow has to yield to the
|
||||
* drag handle, which the local copy did not. */
|
||||
const {
|
||||
hasMoreBelow,
|
||||
updateOverflowHint,
|
||||
scheduleOverflowHint,
|
||||
autosize,
|
||||
attach,
|
||||
startResize,
|
||||
} = useGrowableField(() => valueEl.value);
|
||||
|
||||
/** True when the field is capped and still hiding content below the fold.
|
||||
* Mirrors RichTextInput's hint — a capped box otherwise looks identical
|
||||
* whether it holds its whole value or a third of it. A `<textarea>` can't
|
||||
* carry a reliable `::after`, so the fade lives on `.row__value-wrap`. */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* Size correctly on first paint + whenever the row's value changes from
|
||||
outside (reset-to-library, undo). */
|
||||
watch(
|
||||
() => props.row.value,
|
||||
() => void nextTick(() => {
|
||||
autosize();
|
||||
scheduleOverflowHint();
|
||||
}),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
autosize();
|
||||
scheduleOverflowHint();
|
||||
attach();
|
||||
});
|
||||
|
||||
|
||||
function onReset(): void {
|
||||
emit("reset", props.row.id);
|
||||
}
|
||||
@@ -128,18 +195,35 @@ function onDelete(): void {
|
||||
>
|
||||
<span
|
||||
class="row__value-wrap"
|
||||
:class="{ 'row__value-wrap--mod': valueOverridden }"
|
||||
:class="{
|
||||
'row__value-wrap--mod': valueOverridden,
|
||||
'row__value-wrap--more': hasMoreBelow,
|
||||
}"
|
||||
data-test="row-value-wrap"
|
||||
>
|
||||
<input
|
||||
<textarea
|
||||
ref="valueEl"
|
||||
class="row__value"
|
||||
data-test="row-value"
|
||||
type="text"
|
||||
rows="1"
|
||||
spellcheck="false"
|
||||
:value="row.value"
|
||||
:disabled="!row.enabled"
|
||||
:aria-label="`Value for row ${row.id}`"
|
||||
@input="onValueInput"
|
||||
@scroll="updateOverflowHint"
|
||||
/>
|
||||
<!-- Resize grip, replacing `resize: vertical`. The native resizer
|
||||
recomputes from an origin captured at pointerdown and never clamps
|
||||
to min/max, so over-dragging banks invisible travel the user has to
|
||||
walk back — measured at 13 of 23 dead frames. Ours applies each
|
||||
move's delta to the current height. -->
|
||||
<span
|
||||
class="row__grip"
|
||||
data-test="row-grip"
|
||||
aria-hidden="true"
|
||||
@pointerdown="startResize"
|
||||
></span>
|
||||
</span>
|
||||
<span
|
||||
v-if="hasNonTextToken"
|
||||
@@ -260,6 +344,72 @@ function onDelete(): void {
|
||||
}
|
||||
.row__name:focus, .row__value:focus { outline: none; }
|
||||
|
||||
/* The value field is a <textarea> so a long value WRAPS and the box grows to
|
||||
* fit (height driven by `autosizeValue`). Capped so a pasted paragraph can't
|
||||
* push the rest of the modal off-screen; past the cap it scrolls. */
|
||||
.row__value {
|
||||
/* Manual drag handle as well as the auto-grow, matching the derivation and
|
||||
combine template inputs — auto-sizing picks a sensible height, the handle
|
||||
lets the user override it. */
|
||||
overscroll-behavior: contain;
|
||||
overflow-y: auto;
|
||||
/* Floor for the drag handle. Without it the computed `min-height` is `auto`,
|
||||
which reads as 0 — so the field could be dragged away to a sliver with no
|
||||
way to see what you were editing. One line plus its padding. */
|
||||
min-height: 28px;
|
||||
max-height: 12rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--wp-font-mono);
|
||||
}
|
||||
/* The wrapper centred a single-line input; with a growing field the label
|
||||
* column should sit at the top instead of drifting down as it grows. */
|
||||
.row__value-wrap { align-items: flex-start; position: relative; }
|
||||
|
||||
/* "More below" hint, matching RichTextInput. A <textarea> can't carry a
|
||||
* dependable `::after`, so it hangs off the wrapper. Gradient runs to a
|
||||
* TRANSPARENT accent so the text underneath still reads; the 1px accent line
|
||||
* at the bottom is what catches the eye. */
|
||||
/* Sits in the corner the overflow fade deliberately leaves clear. */
|
||||
.row__grip {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: ns-resize;
|
||||
z-index: 2;
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
transparent 0 2px,
|
||||
var(--wp-text3, #8a8a9a) 2px 3px
|
||||
);
|
||||
/* Clipped to a bottom-right triangle so the hatching runs parallel to the
|
||||
hypotenuse — the shape the browser's own resizer draws, which is what
|
||||
users already read as "drag me". A filled square read as a button. */
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.row__grip:hover { opacity: 0.9; }
|
||||
|
||||
.row__value-wrap--more::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
/* Right edge stops short of the resize grip. The band is as tall as the
|
||||
handle and sat straight on top of it, so a resizable field's grip was
|
||||
invisible — users aimed, missed, re-grabbed. */
|
||||
inset: auto 16px 0 0;
|
||||
height: 14px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--wp-accent, #6366f1) 26%, transparent)
|
||||
);
|
||||
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--wp-accent, #6366f1) 60%, transparent);
|
||||
}
|
||||
|
||||
.row--off { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.row--off .row__name-wrap, .row--off .row__value-wrap { opacity: 0.5; }
|
||||
.row--off .row__name, .row--off .row__value { text-decoration: line-through; }
|
||||
|
||||
@@ -183,9 +183,14 @@ const addedCount = computed(() => draftRows.value.filter((r) => r.libraryId ===
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
/* Long value lists scroll in place; the header row stays pinned. */
|
||||
max-height: 42vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vals__head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 22px 180px 1fr 28px 28px;
|
||||
gap: 10px;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { VarProducerLike } from "@/manager/components/RefChip.vue";
|
||||
import { saveChordLabel } from "../../../shared/platform-keys";
|
||||
import type { ModuleEntry } from "../../../../widgets/_shared";
|
||||
import type { PairingBadge } from "../../../../extension/constraint-pairs";
|
||||
import IdentitySection from "./sections/IdentitySection.vue";
|
||||
import PoolSection from "./sections/PoolSection.vue";
|
||||
import RuntimeSection from "./sections/RuntimeSection.vue";
|
||||
import InstanceIdChip from "../InstanceIdChip.vue";
|
||||
|
||||
|
||||
/** Platform-correct save chord for the footer hint. */
|
||||
const saveChord = saveChordLabel();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
module: ModuleEntry;
|
||||
@@ -16,14 +22,28 @@ const props = withDefaults(
|
||||
/** Names produced upstream of this Context node. Drives the
|
||||
* IdentitySection collision warning. */
|
||||
upstreamVars?: string[];
|
||||
/** `$var` → who writes it upstream. Feeds the option chips' hover cards
|
||||
* so a var names its producer rather than "binds at runtime". */
|
||||
upstreamProducers?: Record<string, VarProducerLike>;
|
||||
/** Names produced by other modules in the SAME Context node. */
|
||||
siblingVars?: string[];
|
||||
/** Per-option pair badges when this wildcard is a constraint carrier
|
||||
* (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() },
|
||||
{
|
||||
isDrifted: false, isModified: false, upstreamVars: () => [],
|
||||
upstreamProducers: () => ({}), siblingVars: () => [],
|
||||
viaOptionPairs: () => new Map(),
|
||||
},
|
||||
);
|
||||
|
||||
const varProducerMap = computed(
|
||||
() => new Map(Object.entries(props.upstreamProducers ?? {})),
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -76,6 +96,7 @@ function onSpaClick(): void {
|
||||
<div class="wp-wcm__title-row">
|
||||
<span class="wp-wcm__name" data-test="wcm-name">{{ module.meta?.name ?? module.type }}</span>
|
||||
<span class="wp-wcm__chip" data-test="wcm-chip">wildcard</span>
|
||||
<InstanceIdChip :id="module.id" :uid="module._uid" />
|
||||
</div>
|
||||
<div class="wp-wcm__sub" data-test="wcm-sub">
|
||||
{{ isMultiPick
|
||||
@@ -98,10 +119,17 @@ 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" />
|
||||
<RuntimeSection :module="module" @update="onUpdate" />
|
||||
<PoolSection
|
||||
:module="module"
|
||||
:via-option-pairs="viaOptionPairs"
|
||||
:var-producers="varProducerMap"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
<div class="wp-wcm__dock">
|
||||
<RuntimeSection :module="module" :frame-active="frameActive" @update="onUpdate" />
|
||||
|
||||
<footer class="wp-wcm__foot">
|
||||
<a
|
||||
@@ -127,7 +155,7 @@ function onSpaClick(): void {
|
||||
Reset overrides
|
||||
</button>
|
||||
<span class="wp-wcm__hint">
|
||||
<kbd>Esc</kbd> cancel · <kbd>⌘↵</kbd> save
|
||||
<kbd>Esc</kbd> cancel · <kbd>{{ saveChord }}</kbd> save
|
||||
</span>
|
||||
<button
|
||||
v-if="canSaveToLibrary"
|
||||
@@ -150,6 +178,7 @@ function onSpaClick(): void {
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -163,7 +192,7 @@ function onSpaClick(): void {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
@@ -171,6 +200,12 @@ function onSpaClick(): void {
|
||||
/* Head styling lives in src/components/context/editors/_modal-head.css
|
||||
* (imported once by ContextWidget). Adopted from InjectorBindingModal
|
||||
* so every per-instance edit modal reads as one design family. */
|
||||
.wp-wcm__dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: var(--wp-bg2);
|
||||
}
|
||||
.wp-wcm__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -215,6 +250,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));
|
||||
|
||||
@@ -29,12 +29,40 @@ describe("OptionRow", () => {
|
||||
expect(w.find('[data-test="opt-cat-warm"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("collapses a long tag run behind a +N pill", () => {
|
||||
// A well-tagged option carries 8+ sub-categories; wrapped inside the 96px
|
||||
// CATEGORY column they stacked into eight lines and made one row taller
|
||||
// than the option list was worth.
|
||||
const tags = ["red", "warm", "medium", "vivid", "hair-vivid", "eye-vivid", "fabric", "makeup-ok"];
|
||||
const opt = { id: "m", value: "crimson", weight: 1, sub_categories: tags };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
expect(w.findAll(".opt__cat-chip")).toHaveLength(3);
|
||||
expect(w.find('[data-test="opt-cat-more-m"]').text()).toBe("+5");
|
||||
});
|
||||
|
||||
it("expanding the +N pill reveals every tag", async () => {
|
||||
const tags = ["red", "warm", "medium", "vivid", "hair-vivid", "eye-vivid", "fabric", "makeup-ok"];
|
||||
const opt = { id: "m", value: "crimson", weight: 1, sub_categories: tags };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
await w.find('[data-test="opt-cat-more-m"]').trigger("click");
|
||||
expect(w.findAll(".opt__cat-chip")).toHaveLength(8);
|
||||
expect(w.find('[data-test="opt-cat-more-m"]').text()).toBe("−");
|
||||
await w.find('[data-test="opt-cat-more-m"]').trigger("click");
|
||||
expect(w.findAll(".opt__cat-chip")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does NOT show a +N pill when the tags already fit", () => {
|
||||
const opt = { id: "m", value: "lynx", weight: 1, sub_categories: ["feline", "warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
expect(w.find('[data-test="opt-cat-more-m"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("decomposes a {N$$…} block into scaffolding + an inner ref chip (SP2b #6)", () => {
|
||||
_setForTests("ad00acd5", { name: "mood", varBinding: "mood" });
|
||||
const opt = { id: "b1", value: "{2$$, $$@{ad00acd5}|warm}", weight: 1, sub_categories: [] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
// The inner @{uuid} arm renders as a resolved ref chip, not raw uuid text.
|
||||
const ref = w.find(".opt__tok--ref");
|
||||
const ref = w.find(".wp-refchip--ref");
|
||||
expect(ref.exists()).toBe(true);
|
||||
expect(ref.text()).toContain("@mood");
|
||||
expect(w.find(".opt__tok--multi").exists()).toBe(true); // scaffolding coloured
|
||||
@@ -45,11 +73,11 @@ describe("OptionRow", () => {
|
||||
_setForTests("ad00acd5", { name: "mood", varBinding: "mood" });
|
||||
const opt = { id: "b3", value: "{3$$, $$@{ad00acd5#mood!null}}", weight: 1, sub_categories: [] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
const ref = w.find(".opt__tok--ref");
|
||||
const ref = w.find(".wp-refchip--ref");
|
||||
expect(ref.exists()).toBe(true);
|
||||
// The leaked `!null` is detected → ban (null-excluded) indicator shows.
|
||||
expect(ref.find(".opt__tok-filter").exists()).toBe(true);
|
||||
expect(ref.find(".opt__tok-nonull").exists()).toBe(true);
|
||||
expect(ref.find(".wp-refchip__filter").exists()).toBe(true);
|
||||
expect(ref.find(".wp-refchip__nonull").exists()).toBe(true);
|
||||
// Label is the clean name, not `@mood!null`.
|
||||
expect(ref.text()).toContain("@mood");
|
||||
expect(ref.text()).not.toContain("!null");
|
||||
@@ -59,7 +87,7 @@ describe("OptionRow", () => {
|
||||
_setForTests("ad00acd5", { name: "hair", varBinding: "hair" });
|
||||
const opt = { id: "b2", value: "{warm|@{ad00acd5}}", weight: 1, sub_categories: [] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
expect(w.find(".opt__tok--ref").exists()).toBe(true);
|
||||
expect(w.find(".wp-refchip--ref").exists()).toBe(true);
|
||||
expect(w.find(".opt__tok--brace").exists()).toBe(true);
|
||||
expect(w.find('[data-test="opt-name"]').text()).not.toContain("ad00acd5");
|
||||
});
|
||||
@@ -130,6 +158,22 @@ describe("OptionRow", () => {
|
||||
expect(w.find('[data-test="opt-prob-pct"]').text()).toBe("50%");
|
||||
});
|
||||
|
||||
it("shows sub-1% probabilities with decimals, matching the SPA", () => {
|
||||
// The canvas modal rounded to a whole number, so with 30+ options every
|
||||
// small-but-real weight read as a flat "0%".
|
||||
const many = [
|
||||
{ id: "rare", value: "rare", weight: 1, sub_categories: [] },
|
||||
...Array.from({ length: 199 }, (_, i) => ({
|
||||
id: `o${i}`, value: `o${i}`, weight: 1, sub_categories: [],
|
||||
})),
|
||||
];
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: many[0], allOptions: many, instance: {} },
|
||||
});
|
||||
// 1 of 200 equal weights = 0.5%, which the old formatter collapsed to "0%".
|
||||
expect(w.find('[data-test="opt-prob-pct"]').text()).toBe("0.5%");
|
||||
});
|
||||
|
||||
it("disabled row shows 0% probability + line-through styling", () => {
|
||||
const w = mount(OptionRow, {
|
||||
props: { option: baseOption, allOptions, instance: { enabled_options: ["o2"] } },
|
||||
@@ -206,18 +250,21 @@ describe("OptionRow", () => {
|
||||
it("renders @{uuid} ref token with raw uuid form when name not yet cached", () => {
|
||||
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_categories: ["warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
const ref = w.find('[data-test="opt-name"] .opt__tok--ref');
|
||||
const ref = w.find('[data-test="opt-name"] .wp-refchip--ref');
|
||||
expect(ref.exists()).toBe(true);
|
||||
expect(ref.attributes("data-uuid")).toBe("a361dbdc");
|
||||
expect(ref.find(".opt__tok-label").text()).toBe("@{a361dbdc}");
|
||||
// RefChip prefixes `@` only once a ref RESOLVES; an unresolved one shows
|
||||
// the cached name or bare uuid. Same convention as every other surface,
|
||||
// which is the point of rendering the real component here.
|
||||
expect(ref.find(".wp-refchip__label").text()).toBe("{a361dbdc}");
|
||||
});
|
||||
|
||||
it("renders @{uuid} ref as @<varBinding> once resolver caches the entry", () => {
|
||||
_setForTests("a361dbdc", { name: "subject", varBinding: "subject_name" });
|
||||
const opt = { id: "o9", value: "city @{a361dbdc} dusk", weight: 1, sub_categories: ["warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
const ref = w.find('[data-test="opt-name"] .opt__tok--ref');
|
||||
expect(ref.find(".opt__tok-label").text()).toBe("@subject_name");
|
||||
const ref = w.find('[data-test="opt-name"] .wp-refchip--ref');
|
||||
expect(ref.find(".wp-refchip__label").text()).toBe("@subject_name");
|
||||
});
|
||||
|
||||
it("renders a v2 boolean ref filter as funnel + ban, not inline !null text", () => {
|
||||
@@ -229,18 +276,21 @@ describe("OptionRow", () => {
|
||||
sub_categories: ["canine"],
|
||||
};
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
const ref = w.find('[data-test="opt-name"] .opt__tok--ref');
|
||||
const ref = w.find('[data-test="opt-name"] .wp-refchip--ref');
|
||||
expect(ref.exists()).toBe(true);
|
||||
// The raw expression + `!null` marker must NOT appear inline anymore.
|
||||
expect(ref.text()).not.toContain("!null");
|
||||
expect(ref.text()).not.toContain("warm or intense");
|
||||
// Compact indicators instead — funnel (expression) + ban (exclude-null),
|
||||
// matching the SPA RefChip grammar.
|
||||
expect(ref.find(".opt__tok-funnel").exists()).toBe(true);
|
||||
expect(ref.find(".opt__tok-nonull").exists()).toBe(true);
|
||||
// Full normalized expression lives in the hover title, `!null` peeled.
|
||||
expect(ref.attributes("title")).toContain("warm or intense");
|
||||
expect(ref.attributes("title")).not.toContain("!null");
|
||||
expect(ref.find(".wp-refchip__funnel").exists()).toBe(true);
|
||||
expect(ref.find(".wp-refchip__nonull").exists()).toBe(true);
|
||||
// The normalized expression is no longer a native `title`: RefChip
|
||||
// deliberately renders an empty one and puts the detail in its hover
|
||||
// card, so the chip carries the funnel/ban grammar and the card carries
|
||||
// the "reads as" text.
|
||||
expect(ref.attributes("title")).toBe("");
|
||||
expect(ref.classes()).toContain("wp-refchip--filtered");
|
||||
});
|
||||
|
||||
it("renders {a|b|c} brace block as a brace token (warn colour)", () => {
|
||||
@@ -259,7 +309,7 @@ describe("OptionRow", () => {
|
||||
it("renders $varname as a var token", () => {
|
||||
const opt = { id: "o9", value: "uses $style here", weight: 1, sub_categories: ["warm"] };
|
||||
const w = mount(OptionRow, { props: { option: opt, allOptions: [opt], instance: {} } });
|
||||
expect(w.find('[data-test="opt-name"] .opt__tok--var .opt__tok-label').text()).toBe("$style");
|
||||
expect(w.find('[data-test="opt-name"] .wp-refchip--var .wp-refchip__label').text()).toBe("$style");
|
||||
});
|
||||
|
||||
it("typing weight back to library default clears the override (null)", async () => {
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { mount as vtuMount } from "@vue/test-utils";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import PoolSection from "../sections/PoolSection.vue";
|
||||
|
||||
/**
|
||||
* The tag-pill grid ships COLLAPSED (a well-tagged wildcard's 40-odd pills
|
||||
* pushed the option list off screen). Every test below is about the pills
|
||||
* themselves, so this local `mount` opens the pool via the component's
|
||||
* `defaultOpen` prop rather than making each case drive a click first.
|
||||
*
|
||||
* The collapse behaviour itself is covered in "PoolSection collapse", which
|
||||
* mounts through `vtuMount` to see the real default.
|
||||
*/
|
||||
const mount = ((component: unknown, options: Record<string, unknown> = {}) => {
|
||||
const opts = {
|
||||
...options,
|
||||
props: { ...((options.props as Record<string, unknown>) ?? {}), defaultOpen: true },
|
||||
};
|
||||
return (vtuMount as unknown as (c: unknown, o: unknown) => ReturnType<typeof vtuMount>)(
|
||||
component,
|
||||
opts,
|
||||
);
|
||||
}) as unknown as typeof vtuMount;
|
||||
import type { ModuleEntry } from "@/widgets/_shared";
|
||||
|
||||
const module = {
|
||||
@@ -164,6 +184,38 @@ describe("PoolSection pick-count (SP2a)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PoolSection collapse", () => {
|
||||
it("starts COLLAPSED, and the chevron opens the pill grid", async () => {
|
||||
// A richly tagged wildcard shows 40-odd pills, pushing the option list —
|
||||
// the thing the user came to edit — most of a screen down. Uses the real
|
||||
// default, so mounts through vtuMount rather than the local helper.
|
||||
const w = vtuMount(PoolSection, { props: { module } });
|
||||
expect(w.find('[data-test="cat-chip-feline"]').exists()).toBe(false);
|
||||
await w.get('[data-test="pool-collapse"]').trigger("click");
|
||||
expect(w.find('[data-test="cat-chip-feline"]').exists()).toBe(true);
|
||||
await w.get('[data-test="pool-collapse"]').trigger("click");
|
||||
expect(w.find('[data-test="cat-chip-feline"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("accents the collapse control while shut so it reads as pressable", () => {
|
||||
const w = vtuMount(PoolSection, { props: { module } });
|
||||
expect(w.get('[data-test="pool-collapse"]').classes()).toContain("pool__collapse--nudge");
|
||||
});
|
||||
|
||||
it("drops the accent once expanded", async () => {
|
||||
const w = vtuMount(PoolSection, { props: { module } });
|
||||
await w.get('[data-test="pool-collapse"]').trigger("click");
|
||||
expect(w.get('[data-test="pool-collapse"]').classes()).not.toContain("pool__collapse--nudge");
|
||||
});
|
||||
|
||||
it("collapsed header reports how many tags are ticked", async () => {
|
||||
const w = mount(PoolSection, { props: { module } });
|
||||
await w.get('[data-test="cat-chip-feline"]').trigger("click");
|
||||
await w.get('[data-test="pool-collapse"]').trigger("click");
|
||||
expect(w.get('[data-test="pool-collapsed-count"]').text()).toContain("1 selected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PoolSection grouped quick pills", () => {
|
||||
it("ticking family=feline + temp=warm builds 'feline and warm'", async () => {
|
||||
const w = mount(PoolSection, { props: { module } });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user