Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee42389c4 | ||
|
|
44db79a9e2 | ||
|
|
a1c7b886cf | ||
|
|
9ff8014590 | ||
|
|
43b2f0b3cf | ||
|
|
d6e1a9c2d6 | ||
|
|
bb78e94178 | ||
|
|
4a666fcd29 | ||
|
|
020da57218 | ||
|
|
fbd7b743a9 | ||
|
|
dbc694f4d8 | ||
|
|
3bce46b28d | ||
|
|
c7314e98f2 | ||
|
|
6cdf7996c8 | ||
|
|
aef082964d | ||
|
|
67ee027592 | ||
|
|
c436bd931f | ||
|
|
417e36da5d | ||
|
|
0d7e5b73a4 | ||
|
|
35b401c371 | ||
|
|
b8679a621e | ||
|
|
918ba26b27 | ||
|
|
9f0f38a0a6 | ||
|
|
4afbb5a08e | ||
|
|
6d8b609454 | ||
|
|
7960d46085 | ||
|
|
ad4b78cfbb | ||
|
|
a54c525bf6 | ||
|
|
06231cf226 | ||
|
|
0a7f4f4386 | ||
|
|
82d5bcaa1b | ||
|
|
8b6b6ad6d2 | ||
|
|
27943d8da3 | ||
|
|
38f539d400 | ||
|
|
bd3fbd54c2 | ||
|
|
eb18825853 | ||
|
|
58360c75f8 | ||
|
|
934f7dc92d | ||
|
|
caffb9824f | ||
|
|
fd6642b258 | ||
|
|
1cb59b2159 | ||
|
|
afa4e47bb8 | ||
|
|
1486785df1 | ||
|
|
b765235f2b | ||
|
|
fac9c4df4b | ||
|
|
69a855291c | ||
|
|
df252c736a | ||
|
|
2cf7f7e8c3 | ||
|
|
0fce54165e | ||
|
|
f4c8411fb8 | ||
|
|
6aadcf0575 | ||
|
|
c4e1663210 | ||
|
|
13acd9a17d | ||
|
|
a29c0fc564 | ||
|
|
7b820d193d | ||
|
|
dc119ad6bd | ||
|
|
7e6e373076 | ||
|
|
e0b4ab0bcc | ||
|
|
27f6244142 | ||
|
|
726cca5d91 | ||
|
|
6d19bd94f4 | ||
|
|
2e9a5a5b6a | ||
|
|
f487cc82a3 | ||
|
|
995dc4d17a | ||
|
|
393004bc54 | ||
|
|
93a05c948e | ||
|
|
fe7f12e65d | ||
|
|
05d53a0c6c | ||
|
|
b55a9794a3 | ||
|
|
c7c761424a | ||
|
|
1e4398ae29 | ||
|
|
f0117d4230 | ||
|
|
5d910f5f39 | ||
|
|
14edfa3458 | ||
|
|
9fe33ab4b2 | ||
|
|
2f64f6c7ac | ||
|
|
f4444394e5 | ||
|
|
b6e6f55cce | ||
|
|
c811d6bc62 | ||
|
|
7cd08f070c | ||
|
|
1c305e4fb1 | ||
|
|
f23a9886d4 | ||
|
|
45c7bf50de | ||
|
|
8929b2e5cb | ||
|
|
7523aae41e | ||
|
|
ea16a06991 | ||
|
|
ef30b366ce | ||
|
|
70bd479b83 | ||
|
|
4b2a76e72b | ||
|
|
e8b75a11a9 | ||
|
|
cce1ac4dae | ||
|
|
25527c37ff | ||
|
|
09257471f8 | ||
|
|
09b0e0ddd3 | ||
|
|
bc8421fed3 | ||
|
|
5c164bb6ee | ||
|
|
28487f1a68 | ||
|
|
91e426b595 | ||
|
|
17cefad53c | ||
|
|
a723243d3d | ||
|
|
51bc4893ec | ||
|
|
bb40882238 | ||
|
|
bb91476f9f | ||
|
|
3de5b6a924 | ||
|
|
ca87384b5b | ||
|
|
8aea38dd45 | ||
|
|
e2b9f2f053 | ||
|
|
a4a89e5694 | ||
|
|
107e1de41e | ||
|
|
b8957acf34 | ||
|
|
36969d089a | ||
|
|
51db05620d | ||
|
|
b2fedcd87a | ||
|
|
e7feeaa112 | ||
|
|
33c2e718c1 | ||
|
|
b4d764fd4e | ||
|
|
fd9ba60bec | ||
|
|
0d01a633d0 | ||
|
|
39266cb87b | ||
|
|
ff6332cf23 | ||
|
|
49f882f131 | ||
|
|
eb9167e1c1 | ||
|
|
fa9cde0546 | ||
|
|
c3e74e206d |
@@ -0,0 +1,101 @@
|
||||
name: Build tag list
|
||||
|
||||
# MANUAL ONLY, and deliberately so.
|
||||
#
|
||||
# The list is not tied to our release cycle — it describes Danbooru, not this
|
||||
# project — and it goes stale slowly. Verified 2026-08-06: a roughly two-year-old
|
||||
# tag file still autocompletes correctly, because staleness moves post counts
|
||||
# (and therefore ranking) while tag NAMES barely change. There is no correctness
|
||||
# argument for a schedule, only a ranking one, and that does not justify a timer
|
||||
# pointed at someone else's API.
|
||||
#
|
||||
# So: run it when you want a refresh — a new character tag everyone is asking
|
||||
# for, or once in a while out of tidiness. The asset stays put until then.
|
||||
#
|
||||
# It publishes to a FIXED tag, replacing the asset in place, so the download URL
|
||||
# never changes:
|
||||
#
|
||||
# https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/releases/download/tag-list/wildcard-pipeline-tags.csv
|
||||
#
|
||||
# That stability is the point: the SPA's "download tag list" button hardcodes
|
||||
# that URL and never needs updating. A per-run tag would break it every time.
|
||||
#
|
||||
# The tag is intentionally not semver, so semantic-release ignores it and this
|
||||
# never looks like a product release.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
min_count:
|
||||
description: "Stop once tags drop below this many posts. 20 ≈ 150k tags; 68 ≈ the 100k other extensions ship."
|
||||
required: true
|
||||
default: "20"
|
||||
type: string
|
||||
alias_pages:
|
||||
description: "Pages of aliases to fetch (1000 each). Aliases are what make `hires` find `highres`."
|
||||
required: true
|
||||
default: "60"
|
||||
type: string
|
||||
publish:
|
||||
description: "Attach the result to the tag-list release. Off = build it and keep it as a run artifact only."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# No dependencies on purpose: the builder uses urllib from the standard
|
||||
# library, so there is nothing to install and nothing to keep patched.
|
||||
- name: Build
|
||||
run: |
|
||||
python scripts/build_tag_list.py \
|
||||
--min-count "${{ inputs.min_count }}" \
|
||||
--alias-pages "${{ inputs.alias_pages }}" \
|
||||
--out wildcard-pipeline-tags.csv
|
||||
|
||||
- name: Summarise
|
||||
run: |
|
||||
{
|
||||
echo "### Tag list built"
|
||||
echo
|
||||
echo "| | |"
|
||||
echo "|---|---|"
|
||||
echo "| Tags | $(wc -l < wildcard-pipeline-tags.csv) |"
|
||||
echo "| Size | $(du -h wildcard-pipeline-tags.csv | cut -f1) |"
|
||||
echo "| Floor | ${{ inputs.min_count }} posts |"
|
||||
echo "| Published | ${{ inputs.publish }} |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Always kept, so a build can be inspected before it replaces the asset
|
||||
# everyone downloads.
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wildcard-pipeline-tags
|
||||
path: wildcard-pipeline-tags.csv
|
||||
retention-days: 14
|
||||
|
||||
- name: Publish to the fixed tag-list release
|
||||
if: ${{ inputs.publish }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `--clobber` replaces the asset in place, which is what keeps the
|
||||
# download URL stable across refreshes.
|
||||
if gh release view tag-list >/dev/null 2>&1; then
|
||||
gh release upload tag-list wildcard-pipeline-tags.csv --clobber
|
||||
else
|
||||
gh release create tag-list wildcard-pipeline-tags.csv \
|
||||
--title "Booru tag list" \
|
||||
--notes "Autocomplete data for ComfyUI-Wildcard-Pipeline. Rebuilt on demand; not a product release. Download it from the manager's Settings page rather than by hand." \
|
||||
--latest=false
|
||||
fi
|
||||
@@ -61,8 +61,15 @@ Thumbs.db
|
||||
|
||||
# Throwaway design / scratch artifacts (mockups, screenshots, demos)
|
||||
/_temp/
|
||||
# Root-level PNGs are always scratch QA screenshots here — legit images live
|
||||
# under public/. Anchored so it never touches public/images/**. Keeps stray
|
||||
# captures out of git and out of pack-release's ship-list gate.
|
||||
/*.png
|
||||
|
||||
# DB location control — per-contributor sidecar config + root-location DB dir
|
||||
db-config.json
|
||||
/db/
|
||||
.tmp-check/
|
||||
|
||||
# Playwright MCP session artifacts (page snapshots, console logs).
|
||||
.playwright-mcp/
|
||||
|
||||
+6
-2
@@ -119,7 +119,11 @@ export default {
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
// 1. Stamp version into package.json + pyproject.toml.
|
||||
// 2. Pack the deployable file set into a zip so the
|
||||
// 2. Rebuild js/ + web/ so the bundle's baked-in version matches the
|
||||
// stamped pyproject.toml. The server reports that version and the
|
||||
// canvas + manager ask for a reload while the two differ, so a zip
|
||||
// packed from the pre-stamp build would nag on every page load.
|
||||
// Pack the deployable file set into a zip so the
|
||||
// @semantic-release/github plugin can attach it to the
|
||||
// release. Zip is built AFTER versioning so the file lands
|
||||
// with the right version in its name.
|
||||
@@ -127,7 +131,7 @@ export default {
|
||||
// from a clean slate (the content already lives in this release's
|
||||
// notes + CHANGELOG.md).
|
||||
prepareCmd:
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node -e \"require('fs').writeFileSync('docs/release-notes/next.md','')\" && node scripts/pack-release.mjs ${nextRelease.version}",
|
||||
"node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json'));p.version='${nextRelease.version}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n');const t=fs.readFileSync('pyproject.toml','utf8').replace(/^version = \\\".+?\\\"/m,'version = \\\"${nextRelease.version}\\\"');fs.writeFileSync('pyproject.toml',t);\" && node -e \"require('fs').writeFileSync('docs/release-notes/next.md','')\" && pnpm build && node scripts/pack-release.mjs ${nextRelease.version}",
|
||||
},
|
||||
],
|
||||
[
|
||||
|
||||
+151
@@ -1,3 +1,154 @@
|
||||
## 🎉 Wildcard Pipeline 2.16.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### Features
|
||||
|
||||
- **Link one wildcard's pick to another's with the new Only rule.** A constraint rule set to **Only** turns its row into a short list. An exception *maid → frilled apron* in Only mode means that whenever the source rolls "maid", the target can only roll "frilled apron", and an outfit you add to the target later is shut out too. Before, the same link needed an Exclude rule for every other outfit, and any outfit added afterwards slipped through. In the matrix, an Only cell links subcategories the same way, and the other cells in that row show a faint × so you can see what is shut out. Only is in the cell popover, the exceptions mode picker and the canvas instance editor. Constraints that use it publish as schema version 6, so an older extension refuses them and asks you to update instead of failing the constraint.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Setting a matrix cell back to Neutral on the canvas no longer breaks the constraint.** Overriding a library rule to Neutral in a Context node's constraint editor saved a value the engine didn't accept, so the whole constraint stopped applying when the graph ran. The editor now saves it correctly, and workflows saved with the old value run again.
|
||||
- **The constraint docs described Allow wrongly.** They said Allow keeps only the matching options. Allow has always meant "no change" (it shows as Neutral in the matrix). The docs now say so, and the keep-only behaviour they described is what Only does.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.15.3…v2.16.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.3...v2.16.0)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.15.3
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
New docs for chips, references and the filter grammar.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Docs: Chips & references.** A new page in the manager's Docs tab (How it connects → Chips & references) explains the chips you see in text fields and their three states (resolved, filtered, broken), how to make a placeholder `$var` chip before anything produces it, the full `@{uuid#name:filter!null}` reference form and why plain `@name` stays text, and the sub-category filter grammar: `not`, `and`, `or` (or a comma) and parentheses, e.g. `not warm and (low or high)`. Searching the docs for "chip" or "filter" now finds it.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.15.2…v2.15.3](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.2...v2.15.3)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.15.2
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **`$outfit.1.SHOES` now reads the second pick's shoe.** On a wildcard that picks several options, an indexed axis read skipped any pick whose option carries no tag on that axis, so every later index slid down by one: with `robe, jeans` picked, `$outfit.0.SHOES` printed the jeans' boots beside the robe and `$outfit.1.SHOES` came out empty. Each index now names the same pick as `$outfit.1`, in templates and in derivation conditions alike. The bare `$outfit.SHOES` read was already right and is unchanged.
|
||||
|
||||
- **The Prompt Assembler preview shows the tag that was actually rolled.** The preview filled `$outfit.SHOES` with the first tag of the group whatever the option, so it could read `sandals | robe | red stilettos` for an outfit with no shoes and a pair of heels. It now reads the same roll as the values beside it, and matches what the prompt will say.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.15.1…v2.15.2](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.1...v2.15.2)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.15.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Library file exports now record the schema version their content needs.** Exporting from the library always wrote schema version 2 into the file, even when it held range multi-picks like `{1-3$$…}`, constraints with a narrowed reach, or wildcards with an **accepts** tag axis. An older extension would then import the file and silently misread or drop those features. Exports now say 3, 4 or 5 when they use those features, so an older extension refuses the file and asks you to update. Files without them still export as version 2.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.15.0…v2.15.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.15.0...v2.15.1)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.15.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Shared wildcards with accepts tag axes now say when your extension is too old for them.** A wildcard whose tag group is marked **accepts** is now published as schema version 5. Before, it went out as version 2, and an extension from before 2.14 installed it without complaint while quietly dropping the accepts setting, so `$outfit.SHOES` read empty and constraints lost the rolled tag. That older extension now refuses the pack and asks you to update. Wildcards without an accepts group publish exactly as before.
|
||||
|
||||
- **Updating a community item no longer re-runs the old tag conversion on it.** "Update" and "Install as new entry" labelled every download as the oldest schema version, so each install ran the version 1 to 2 upgrade again over content that was already current. Downloads now keep the version the community recorded for them, and so do dependencies fetched from a constraint's "download missing" action.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.14.1…v2.15.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.14.1...v2.15.0)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.14.1
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Cutting an already-empty field no longer wipes your clipboard.** Cut an option's text, press Ctrl+A Ctrl+X again on the now-empty field, and the text you meant to paste was gone: the editor let the browser cut an invisible caret marker, which replaced what was on the clipboard. The second cut now leaves the clipboard alone, and a paste never writes that invisible character into a value.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.14.0…v2.14.1](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.14.0...v2.14.1)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.14.0
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
Read a wildcard's chosen tag as a variable, a much faster ComfyUI start, and a bulk-delete fix worth reading.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Tag groups can now be axes you read as variables.** Mark a wildcard's tag group **accepts** and it becomes an *axis*: the wildcard rolls exactly one of its tags each pick, and you can read that tag anywhere with **`$var.AXIS`**. An `$outfit` wildcard with an accepts group `SHOES` exposes **`$outfit.SHOES`** as the shoe it chose this run — the same value in every template, combine, derivation and chained node, because it is rolled once when the option is picked rather than each time it is read. Groups left **classify** (the default) behave exactly as before, so nothing already in your library changes.
|
||||
|
||||
Where it pays off is constraints. When an accepts axis is the **source** of a constraint, the rule keys on the single rolled tag rather than every tag the option happens to carry — so a diagonal "same tag wins" matrix pins the target to exactly what was rolled, and `$outfit.SHOES` and the shoe that gets picked agree every run. If the **target** is also an accepts axis, it stays in the pool when the source allows any of its alternatives and then rolls the matched one, so the two wildcards never disagree on the axis. The editor marks accepts groups in the constraint matrix, warns when an option carries none of its axis's tags, and offers a wildcard's axes in the `$` autocomplete.
|
||||
|
||||
- **Optional booru tag autocomplete.** Turn it on in Settings → Tag autocomplete and typing a few letters into an option value suggests danbooru tags, ranked by how often they are actually used, colour-coded by kind (character, copyright, artist, meta) and resolving aliases — type `hires` and it offers `highres`, naming what it matched so the jump is never mysterious.
|
||||
|
||||
It stays out of the way of everything you already use: `$` variables and `@` references always win the caret, and suggestions never appear while you are typing one. Off unless you switch it on.
|
||||
|
||||
The tag list is a file you install once — press **Download** in Settings to fetch it from this project's GitHub release, or drop your own CSV at the path shown there. **This is the only thing this extension ever fetches from the internet**, it only happens when you press the button, and the [README](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline#network-access) documents exactly what that request does and the restrictions around it.
|
||||
|
||||
- **This extension was making ComfyUI slow to load, and it will clean up after itself on the next start.** Our frontend files carry a content hash in their names, so every release produced new filenames — and ComfyUI Manager installs an update by writing the new files over the old ones without removing what the new version no longer ships. Nothing ever deleted the previous version's files, so they piled up with every update.
|
||||
|
||||
On a real install that had been updated a dozen times: **1,774 files taking 60 MB, where about 300 were live.** ComfyUI requests every JavaScript file an extension exposes when the page loads, so that install was fetching 237 of our chunks instead of 58 — nearly 10 MB, which was 42% of all extension code on that page and by a wide margin the largest single contributor to its load time.
|
||||
|
||||
This release records what each build produces and deletes anything left over from an older version when ComfyUI starts. **No action needed — the first start after updating does the cleanup**, and it is conservative: only files this project's build system generates are ever removed. On the install measured above it freed 52 MB and removed 1,579 files.
|
||||
|
||||
- **Sourcemaps are no longer shipped.** They were being written on every build and included in the package, but never linked, so no browser had ever requested one — 22.5 MB of files that existed only to be ignored. Debug builds can still produce them.
|
||||
|
||||
- **"Select all" now means the rows you can see.** With a filter active, the header checkbox selected *every* row, not the filtered ones — so narrowing a list down to a handful, selecting all and deleting removed the whole list, most of it never on screen and with no warning. This affected the **wildcard** and **fixed-values** editors, and on wildcards it reached bulk weight and tag changes too: setting a weight on a filtered selection silently rewrote every option's weight, with no change in row count to hint at it. Select-all is now scoped to what the filter is showing, in both directions. If you have ever lost options or had a weight distribution flattened, this was why.
|
||||
|
||||
- **A constraint can no longer hold two rules for the same pair.** Adding the same source/target combination twice — say `rain → sandals` as both *exclude* and *boost ×3* — looked like two active rules, but only one ever applied: whichever came last in the list. Order decided the outcome, nothing showed you the order, and in the worst case a deliberate **exclude was silently thrown away** by a later duplicate. Saving or importing a constraint with a repeated pair is now refused, naming the pair so you know which one to remove. Existing constraints keep working exactly as before — nothing already in your library is rejected, and no generation changes.
|
||||
|
||||
- **Constraint exceptions and derivation rules have a filter bar.** The wildcard and fixed-values editors got one previously; these two did not, which was backwards — an exception list is routinely longer than the wildcard it filters. Type to narrow the list, with a count and a clear button.
|
||||
|
||||
Rules keep their real numbers while filtered, so "Rule 5" stays Rule 5 instead of renumbering to 1, and editing or deleting a filtered row acts on that row rather than the one that happens to share its position.
|
||||
|
||||
- **The library-change check stopped re-downloading your whole library every five seconds.** Both the manager and the in-graph Context node watch for library edits made elsewhere, by polling a list of module hashes. That list was re-sent in full on every tick with no caching headers — 33 KB every five seconds on a real library, roughly **24 MB an hour per open tab**, sitting idle. Producing it also re-read and re-parsed every module payload in the database each time.
|
||||
|
||||
The check now answers "nothing changed" with an empty response the browser already knows how to handle, and works that out from a cheap row count rather than by rebuilding the list. On a local install this was invisible; if you reach ComfyUI over Tailscale, a VPN or any metered connection, it was the largest thing this extension did.
|
||||
|
||||
- **The webfonts are no longer bundled into JavaScript.** Both fonts were being base64-encoded into a code file that ComfyUI had to download and parse *before* it could finish registering this extension's nodes — about 90 KB of JavaScript that was never really JavaScript. They are now ordinary font files the browser fetches on its own, in the background, while everything else carries on. Node registration no longer waits for them.
|
||||
|
||||
Two things follow from that. Text appears sooner on a slow CPU, because 90 KB stopped going through the JavaScript parser. And the fonts now stay cached when you update — previously every release changed the code file they were hidden inside, so your browser re-downloaded them every time.
|
||||
|
||||
Total download on a fresh install is roughly unchanged: the encoding overhead and the compression that undid it cancel out. The gain is in *when* the work happens, not how much of it there is.
|
||||
|
||||
<!-- /modal -->
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
Nothing to do beyond updating. If the interface renders briefly in a system font on the first load after this update, that is the fonts arriving in the background as intended — it settles within a moment and does not recur.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Full changelog:** [v2.13.2…v2.14.0](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/compare/v2.13.2...v2.14.0)
|
||||
|
||||
## 🎉 Wildcard Pipeline 2.13.2
|
||||
|
||||
📖 [Docs (wiki)](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki) · 💬 [Discord](https://discord.gg/BFYR9WQdVR) · 📦 [Install via ComfyUI Manager](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/wiki/Quick-Start) · 🐛 [Issues](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)
|
||||
|
||||
@@ -45,7 +45,7 @@ ruff check . # Python lint
|
||||
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
|
||||
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Rule types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning), `constraint_source_missing` / `constraint_target_missing` (warning — uuid not in catalog), `constraint_orphan_source` (warning — no source instance UPSTREAM), `constraint_orphan_target` (warning — the constraint's reach selector covers ZERO reachable downstream instances; per-selector, NOT count-aware — overlapping constraints may freely share an instance). Engine emits runtime warnings `constraint_never_applied` (the reach matched nothing at runtime) and `constraint_partial_reach` (a `next N` / `pick` reach matched fewer instances than requested).
|
||||
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
|
||||
- **Constraint reach (SP3)** — each constraint carries a `target_select` reach selector: `{mode: "first"|"next"|"all"|"pick", count?, picks?}`, default `all`. Every constraint whose reach covers a firing downstream target instance applies, COMBINED — per-pick × per-tag × per-constraint multiply, with `exclude` (factor 0) absorbing. Mode `only` (linked picks, schema v6) turns its row into an allow-list: when the source fires, a target with no rule of its own in that row is excluded (by value on an exception, by tag in a matrix row; a matrix `allow` cell is NOT a mention because editors show it as Neutral). No more one-shot/consumed-set: the engine counts per-constraint downstream hits in `ctx["__wp_constraint_hits__"]`. Reach is downstream-relative (counts from the constraint's own chain position) and cross-node + transitive (resolves `@{uuid}` at any depth). `pick` selects specific instances by `_uid` (direct top-level) or carrier `_uid` + option id (nested). The re-weight math is the pure `engine/modules/_constraint_math.py::combine_constraint_factor`, mirrored in TS at `src/manager/utils/constraint-math.ts` via the shared `tests/fixtures/constraint-corpus.json`. Published payloads using a non-default `target_select` stamp `schema_version = 4` (additive; community catalog v4).
|
||||
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
|
||||
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
|
||||
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
|
||||
@@ -81,11 +81,19 @@ Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-
|
||||
- `tolerant_drift_status TEXT` — `none` | `tolerant` | `strict`. Set by parseTolerantAsCurrentShape during install.
|
||||
- `schema_migrated_at TEXT` — wall-clock stamp of last lazy migration step.
|
||||
|
||||
**Which number goes where** (three constants, one job each — don't collapse them):
|
||||
|
||||
- **`CURRENT_SCHEMA_VERSION` = 2** (`src/manager/import-export/migrations.ts`, mirrored in `engine/migrations/__init__.py`): head of the migration chain. Stays 2 because v3–v6 are additive and need no migrator.
|
||||
- **`MAX_KNOWN_SCHEMA_VERSION` = 6** (same two files): the highest version this runtime reads + writes natively. Advertised to the community as `window.__wpcRuntime.schemaVersion` (`src/manager/main.ts`) and the ceiling above which `migrateImportEnvelope` refuses a pack.
|
||||
- **Content stamp** (`schemaVersionForPayload` in `src/manager/import-export/single-row-publish.ts`): what publish sends — the LOWEST version the payload's features need (2 baseline, 3 SP2b range/`~` grammar, 4 non-default constraint `target_select`, 5 a wildcard `accepts` tag axis, 6 a constraint `only` rule).
|
||||
|
||||
A pack arriving from the community keeps the stamp the server stored for it (`CommunityDownload.schema_version`, fed to `wrapAsEngineExport` / the dependency downloader) so install migrates it from where it actually is. Never stamp a received pack with a constant. Local round-trips of live rows (e.g. `BundleEditor` extract) stamp `MAX_KNOWN_SCHEMA_VERSION`.
|
||||
|
||||
**The machine**:
|
||||
|
||||
- **Validator registry** (`src/manager/import-export/validators/`): one Zod validator per known `schema_version`. Strict at `≤ CURRENT_SCHEMA_VERSION`; future versions go through `parseTolerantAsCurrentShape` (additive-strip).
|
||||
- **Validator registry** (`src/validators/index.ts:getValidator`): one Zod validator per shape-changing `schema_version` (v1, v2). v3–v6 didn't change a shape, so they validate against v2. Tolerant mode (`.strip()`) is for installing newer payloads (`parseTolerantAsCurrentShape`).
|
||||
- **`parseTolerantAsCurrentShape(raw, currentVersion)`** (`src/manager/import-export/tolerant-parse.ts`): strips fields the current shape doesn't know, returns the projection that matches `currentVersion`. Used at install when source `schema_version > CURRENT`.
|
||||
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migration-machine.ts`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
|
||||
- **`migratePayload(payload, from, to)`** (`src/manager/import-export/migrations.ts`, beside the envelope-level `migrateImportEnvelope`): forward-only chain. Each step is a registered function in the migrators map. `from > to` is illegal — no downgrades.
|
||||
- **`lazy_migrate_row(row, currentVersion, migrators, validators)`** (`engine/db/lazy_migrate.py`): reads `original_payload_json` (or `payload` if absent), walks the migrator chain to `currentVersion`, validates, writes back. Bulk-at-boot runner is eager iteration of this routine.
|
||||
- **Install decision tree** (`src/manager/import-export/install.ts`): branches on `source_schema_version` vs `CURRENT_SCHEMA_VERSION`:
|
||||
- `=`: strict validate + insert.
|
||||
@@ -93,20 +101,30 @@ Spec: `D:\Desktop\Wildcard-Pipeline-Community\docs\superpowers\specs\2026-06-05-
|
||||
- `>` and tolerant-only diff: parseTolerantAsCurrentShape, mark `tolerant_drift_status='tolerant'`, insert.
|
||||
- `>` and breaking-future diff (per catalog `is_breaking_from_previous` AND-fold across the interval): refuse install with a clear error.
|
||||
|
||||
**Server probe**: `engine-export-wrap.ts:ENGINE_SCHEMA_VERSION` is the sister's pinned CURRENT — it ships in the sister bundle, not fetched from server. The host bridge install path reads it from `window.__wpcRuntime.schemaVersion`. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's CURRENT.
|
||||
**Server probe**: the host bridge advertises `MAX_KNOWN_SCHEMA_VERSION` as `window.__wpcRuntime.schemaVersion`; it ships in the sister bundle, not fetched from server. The community embed refuses to install a pack stamped above it. Server-first deploy ordering (see community CLAUDE.md) means the community catalog always reflects shapes ≥ sister's MAX_KNOWN.
|
||||
|
||||
### Bumping `schema_version` (the proper way)
|
||||
|
||||
When a payload shape change lands (not a row-column change — those don't bump schema):
|
||||
|
||||
1. **Add the new shape's validator.** New file under `src/manager/import-export/validators/v<N>.ts` exporting a Zod schema. Register in `validators/index.ts`.
|
||||
2. **Add the migrator step.** In `src/manager/import-export/migration-machine.ts`, register `migrators[N-1 → N]: (payload) => ...`. Forward-only. The function is pure — no side effects.
|
||||
3. **Add fixtures.** `src/manager/__tests__/import-export/fixtures/v<N>/` — at minimum: a `valid-strict.json`, a `from-v<N-1>-migrated.json` round-trip case, and one `breaking-shape.json` if the bump is breaking.
|
||||
4. **Bump the constant.** `CURRENT_SCHEMA_VERSION` lives in the validator registry. Bump it to `N`. Also bump `ENGINE_SCHEMA_VERSION` in `src/manager/components/engine-export-wrap.ts`.
|
||||
**Additive bump** (an optional field, new enum value or new text grammar an old runtime can safely lack — v3, v4, v5, v6 all were):
|
||||
|
||||
1. **Add the version constant** next to `TAG_AXES_SCHEMA_VERSION` / `CONSTRAINT_ONLY_SCHEMA_VERSION` in `migrations.ts` (and `engine/migrations/__init__.py`), with a comment saying what triggers it.
|
||||
2. **Teach `schemaVersionForPayload`** AND its Python mirror `engine/migrations/stamping.py` to return it only when the payload actually uses the feature (structural walk, bundle children + instance overrides included), and add cases to `src/manager/__tests__/import-export/publish-stamping.test.ts` + `tests/engine/test_schema_stamping.py`.
|
||||
3. **Bump `MAX_KNOWN_SCHEMA_VERSION`** in `migrations.ts` AND `engine/migrations/__init__.py`, and update the drift guard in `schema-version-fork.test.ts` to exercise the new highest stamp.
|
||||
4. **Make sure the strict validator keeps the new field** (it strips unknown keys) — `src/validators/module/v2.ts` + the engine-parity test.
|
||||
5. **Catalog row** — step 7 below. `CURRENT_SCHEMA_VERSION` does not move.
|
||||
|
||||
**Shape-changing bump** (needs a migrator):
|
||||
|
||||
1. **Add the new shape's validator.** New file under `src/validators/module/v<N>.ts` (and `bundle/` if needed). Register every subtype at `N` in `src/validators/index.ts`.
|
||||
2. **Add the migrator step.** In `src/manager/import-export/migrations.ts`, add to `MIGRATION_CHAIN` (envelope) and/or `registerMigrator` (per row), and mirror it under `engine/migrations/`. Forward-only. The function is pure — no side effects.
|
||||
3. **Add fixtures.** Under `src/validators/fixtures/v<N>/` — at minimum a strict-valid module per subtype, plus a migrated-from-v<N-1> round-trip case in `src/manager/__tests__/import-export/migration-machine.test.ts`.
|
||||
4. **Bump the constants.** `CURRENT_SCHEMA_VERSION` and `MAX_KNOWN_SCHEMA_VERSION` to at least `N`, in both `migrations.ts` and `engine/migrations/__init__.py`.
|
||||
5. **Write the migration test.** `tests/engine/db/test_lazy_migrate.py` should cover v<N-1>→v<N> round-trip + the breaking-future path if applicable.
|
||||
6. **Run `pytest tests/engine/ -q && pnpm test` until green.** Sister pre-commit hook (~2 min) also runs lint + typecheck + build + size; expect that wait.
|
||||
7. **Coordinate the community-side catalog row.** Community web's `schema_catalog` table gets the matching `(version, is_breaking_from_previous, notes)` row + a deploy that lands BEFORE sister's PR merges. See community CLAUDE.md "Bumping the schema" for the server side.
|
||||
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's CURRENT, sister can safely refuse breaking-future shapes.
|
||||
8. **Sister deploys after server.** Once catalog HEAD ≥ sister's MAX_KNOWN, sister can safely publish the new stamp and refuse breaking-future shapes.
|
||||
|
||||
### Validator ↔ engine parity (DON'T skip)
|
||||
|
||||
@@ -122,6 +140,9 @@ this:
|
||||
shape to `src/validators/fixtures/engine-parity/*.json`.
|
||||
- `src/validators/__tests__/engine-parity.test.ts` asserts the **strict**
|
||||
community validator accepts every one of those fixtures.
|
||||
- `tests/test_engine_parity_fixtures.py` re-runs the dump in memory and fails
|
||||
pytest when a committed fixture is stale, so forgetting the script is
|
||||
caught in CI.
|
||||
|
||||
**Any time you change an engine module payload shape** (a `*_handler.py`
|
||||
`validate_payload`, or what the editor stores), run
|
||||
|
||||
@@ -144,6 +144,27 @@ See [Nodes → WP Debug](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/w
|
||||
- 💭 **[GitHub Discussions](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/discussions)** — long-form questions, ideas, design threads
|
||||
- 🐛 **[Issue tracker](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline/issues)** — bug reports + feature requests
|
||||
|
||||
## Network access
|
||||
|
||||
This extension works entirely offline. It makes **one** outbound request, and only when you ask for it:
|
||||
|
||||
| What | When | Where to |
|
||||
| --- | --- | --- |
|
||||
| Booru tag list for the optional value autocomplete | Only when you click **Download** in Settings → Tag autocomplete | `github.com` → `objects.githubusercontent.com`, our own release asset |
|
||||
|
||||
Nothing is fetched at startup, on a schedule, or as a side effect of anything else. Delete the file and the feature simply turns off. The update check in Settings talks to the GitHub releases API on the same terms — you can switch it off, and it never runs without you.
|
||||
|
||||
Because ComfyUI serves its API without authentication, that download endpoint is deliberately built with nothing to steer:
|
||||
|
||||
- **The URL is a constant.** There is no URL parameter — the request body is ignored — so it cannot be pointed at your internal network or a cloud metadata address.
|
||||
- **Redirects are checked at every hop** against a fixed host list, because GitHub serves release assets via a CDN. A redirect anywhere else is refused mid-chain.
|
||||
- **The destination is computed server-side** from ComfyUI's user directory plus a fixed filename. No caller input reaches the path, and a symlink sitting at that path is refused rather than followed.
|
||||
- **The response is size-capped while streaming**, so a hostile or corrupted reply cannot fill your disk. `Content-Length` is checked but never trusted alone.
|
||||
- **One download at a time**, so the endpoint cannot be used to start many large fetches at once.
|
||||
- **The file is parsed before it replaces anything.** A reply that is not a readable tag list leaves your existing one untouched, and the final move is atomic.
|
||||
|
||||
The reasoning is kept next to the code in [`wp_api/_tag_download.py`](wp_api/_tag_download.py), and each restriction has a test in [`tests/wp_api/test_tag_download.py`](tests/wp_api/test_tag_download.py).
|
||||
|
||||
## Status
|
||||
|
||||
Active development. Versioned releases with [semantic-release](https://github.com/semantic-release/semantic-release) — every merged commit on `main` either ships a release or stays as `next` until the next breaking / feature change rolls one. Bundle-size + test gates run on every branch push and PR.
|
||||
|
||||
+65
-1
@@ -4,7 +4,14 @@ import os
|
||||
import sys
|
||||
|
||||
# Tells ComfyUI to serve files from this folder under
|
||||
# /extensions/<package-name>/ and auto-load any `*.js` it finds.
|
||||
# /extensions/<package-name>/ and auto-load any `*.js` it finds. Only the
|
||||
# entry `main.js` uses that extension; lazy chunks are `.mjs` so ComfyUI does
|
||||
# not import them all up front (vite.config.mts).
|
||||
#
|
||||
# "any *.js it finds" is load-bearing: ComfyUI fetches EVERY file it lists
|
||||
# here on page load, so a chunk left behind by an older version is not merely
|
||||
# dead weight on disk — it is a request and a parse on every visit. Hence the
|
||||
# prune below.
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
# Expose engine/ and wp_nodes/ as top-level packages so internal absolute imports
|
||||
@@ -22,6 +29,63 @@ if _PKG_DIR not in sys.path:
|
||||
# ruff: noqa: E402 — imports must follow the sys.path shim above
|
||||
from comfy_api.latest import ComfyExtension
|
||||
|
||||
# Remove chunks left by previous versions BEFORE ComfyUI scans WEB_DIRECTORY.
|
||||
# Manager updates extract over the installed folder without deleting what the
|
||||
# new version dropped, and our filenames are content-hashed, so every update
|
||||
# leaves the previous build behind. Self-healing on the next start; see
|
||||
# `wp_api/web_assets.py` for the measurements that motivated it.
|
||||
#
|
||||
# Wrapped because nothing here is worth failing a plugin load over — the
|
||||
# fallback is the disk usage that already exists.
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
from wp_api.web_assets import prune_package_assets as _prune_package_assets
|
||||
|
||||
_prune_package_assets(_Path(_PKG_DIR))
|
||||
except Exception: # noqa: BLE001 - never crash ComfyUI over housekeeping
|
||||
import logging as _logging
|
||||
|
||||
_logging.getLogger(__name__).exception(
|
||||
"wildcard-pipeline: stale-asset prune failed",
|
||||
)
|
||||
|
||||
# Move our files into `<ComfyUI>/user/wildcard-pipeline/` if a previous version
|
||||
# left them loose in the user directory.
|
||||
#
|
||||
# Placement is load-bearing: this must run BEFORE anything opens the database.
|
||||
# SQLite keeps `-wal` and `-shm` sidecars beside the main file, and moving that
|
||||
# set out from under an open connection corrupts it. Node imports below can
|
||||
# reach the DB, so the migration goes above them.
|
||||
#
|
||||
# Wrapped for the same reason as the prune above: the fallback is "keep using
|
||||
# the old location", which is strictly better than failing to load.
|
||||
try:
|
||||
from engine.db.relocate import migrate_user_data as _migrate_user_data
|
||||
|
||||
_report = _migrate_user_data()
|
||||
if _report.did_anything:
|
||||
import logging as _logging
|
||||
|
||||
_log = _logging.getLogger(__name__)
|
||||
if _report.moved:
|
||||
_log.info(
|
||||
"wildcard-pipeline: moved into user/wildcard-pipeline/: %s",
|
||||
", ".join(_report.moved),
|
||||
)
|
||||
# Skips and failures are warnings: in both cases a file the user cares
|
||||
# about is not where the extension will now look for it.
|
||||
for _note in _report.skipped:
|
||||
_log.warning("wildcard-pipeline: left in place — %s", _note)
|
||||
for _note in _report.failed:
|
||||
_log.warning("wildcard-pipeline: could not move %s", _note)
|
||||
except Exception: # noqa: BLE001 - never crash ComfyUI over housekeeping
|
||||
import logging as _logging
|
||||
|
||||
_logging.getLogger(__name__).exception(
|
||||
"wildcard-pipeline: user-data relocation failed",
|
||||
)
|
||||
|
||||
from wp_nodes.assembler_node import WPPromptAssembler
|
||||
from wp_nodes.context_loop import WPContextLoop
|
||||
from wp_nodes.context_node import WPContext
|
||||
|
||||
@@ -21,6 +21,32 @@ class ContextInternals(TypedDict, total=False):
|
||||
Context = dict[str, Any]
|
||||
|
||||
|
||||
#: Engine tables the text resolver reads by KEY rather than as variables.
|
||||
#: `strip_internals` still drops them — its result is the variable surface, and
|
||||
#: anything enumerating it (the assembler's variables panel, debug views) would
|
||||
#: list `$__wp_axes__` as a user variable. Callers that need accessor syntax
|
||||
#: re-attach them to their own local dict via `with_resolver_tables`.
|
||||
RESOLVER_TABLES = ("__wp_axes__", "__wp_picks__")
|
||||
|
||||
|
||||
def with_resolver_tables(
|
||||
stripped: dict[str, Any], source: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Re-attach the accessor tables to an already-stripped ctx.
|
||||
|
||||
`$var.0` and `$var.AXIS` resolve against `__wp_picks__` / `__wp_axes__`,
|
||||
which `strip_internals` removes along with every other `__` key. Without
|
||||
them the assembler rendered both accessors as literal text while the same
|
||||
template resolved one node upstream. The returned dict is for a resolver
|
||||
call only — never for anything that enumerates variables.
|
||||
"""
|
||||
out = dict(stripped)
|
||||
for key in RESOLVER_TABLES:
|
||||
if key in source:
|
||||
out[key] = source[key]
|
||||
return out
|
||||
|
||||
|
||||
def strip_internals(ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a copy of ``ctx`` with engine-only + user-flagged-internal keys removed.
|
||||
|
||||
|
||||
+36
-3
@@ -33,6 +33,17 @@ from pathlib import Path
|
||||
|
||||
DB_FILENAME = "wildcard-pipeline.db"
|
||||
|
||||
#: Our own folder under ComfyUI's user directory.
|
||||
#:
|
||||
#: Everything of ours that must survive an extension reinstall lives here: the
|
||||
#: library database and the downloaded tag list, with room for whatever comes
|
||||
#: next. They used to sit loose in `<ComfyUI>/user/` alongside ComfyUI's own
|
||||
#: files, which was fine when there was one of them and stopped being fine at
|
||||
#: two. One folder is also one thing to back up, and one thing to delete.
|
||||
#:
|
||||
#: `engine/db/relocate.py` moves pre-existing files in on first boot.
|
||||
WP_DATA_DIRNAME = "wildcard-pipeline"
|
||||
|
||||
|
||||
def _comfyui_user_dir_from_api() -> Path | None:
|
||||
"""Use ComfyUI's `folder_paths.get_user_directory()` when the host
|
||||
@@ -102,6 +113,28 @@ def _load_sidecar() -> dict:
|
||||
return dict(load())
|
||||
|
||||
|
||||
def comfyui_user_dir() -> Path | None:
|
||||
"""ComfyUI's user directory, or ``None`` when it cannot be detected.
|
||||
|
||||
The same detector chain the ``user`` DB location uses, exposed because it
|
||||
is not database-specific — anything of ours that must survive an extension
|
||||
reinstall belongs under here rather than in the package folder.
|
||||
"""
|
||||
return _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
|
||||
|
||||
def wp_data_dir() -> Path | None:
|
||||
"""Our folder inside ComfyUI's user directory, or ``None`` when the user
|
||||
directory cannot be detected.
|
||||
|
||||
Does NOT create the directory — callers that write do that, so a read-only
|
||||
probe (the Settings panel asking "where would this live?") never has the
|
||||
side effect of making a folder the user did not ask for.
|
||||
"""
|
||||
base = comfyui_user_dir()
|
||||
return (base / WP_DATA_DIRNAME) if base else None
|
||||
|
||||
|
||||
def user_location_path() -> Path | None:
|
||||
"""Return the ``user`` preference target path, or ``None`` if the
|
||||
ComfyUI user dir can't be detected.
|
||||
@@ -111,7 +144,7 @@ def user_location_path() -> Path | None:
|
||||
list all three potential locations regardless of which one is
|
||||
currently active (or whether one is detectable at all)."""
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
return (comfy_user / DB_FILENAME) if comfy_user else None
|
||||
return (comfy_user / WP_DATA_DIRNAME / DB_FILENAME) if comfy_user else None
|
||||
|
||||
|
||||
def global_location_path() -> Path:
|
||||
@@ -153,14 +186,14 @@ def resolve_db_path_with_source() -> tuple[Path, str]:
|
||||
if preference == "user":
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
if comfy_user is not None:
|
||||
return comfy_user / DB_FILENAME, "user"
|
||||
return comfy_user / WP_DATA_DIRNAME / DB_FILENAME, "user"
|
||||
# Detection failed — fall through to the default chain rather
|
||||
# than crash. Last-ditch fallback below will catch it.
|
||||
|
||||
# Default — same as explicit "user" preference.
|
||||
comfy_user = _comfyui_user_dir_from_api() or _comfyui_user_dir_from_path()
|
||||
if comfy_user is not None:
|
||||
return comfy_user / DB_FILENAME, "user"
|
||||
return comfy_user / WP_DATA_DIRNAME / DB_FILENAME, "user"
|
||||
|
||||
# Last-ditch fallback. The design folds the old "legacy" label into
|
||||
# "global" because the path is identical and the UI has no separate
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""One-time move of our files from ``<ComfyUI>/user/`` into ``<user>/wildcard-pipeline/``.
|
||||
|
||||
The library database and the downloaded tag list used to sit loose in ComfyUI's
|
||||
user directory. Existing installs have real data there, so introducing the
|
||||
subfolder is only half the change — without this the extension would silently
|
||||
come up with an empty library and no tag list, which reads as data loss even
|
||||
though the files are still on disk.
|
||||
|
||||
Design constraints, in the order they mattered:
|
||||
|
||||
1. **Never overwrite.** If something already exists at the destination, the
|
||||
legacy file is left exactly where it is and reported as skipped. A
|
||||
destination file means either a completed migration or a deliberate
|
||||
user-placed file; both outsrank anything we could infer.
|
||||
2. **Run before the database is opened.** SQLite keeps ``-wal`` and ``-shm``
|
||||
sidecars next to the main file, and moving the set out from under an open
|
||||
connection corrupts it. ``__init__.py`` calls this at import, before routes
|
||||
are registered and before anything connects.
|
||||
3. **Move the sidecars with it.** A ``-wal`` left behind holds committed
|
||||
transactions that have not been checkpointed into the main file yet; moving
|
||||
the ``.db`` alone can lose the most recent writes.
|
||||
4. **Never raise.** A failed migration must degrade to "extension starts with
|
||||
the old path still in place", not to "ComfyUI fails to load the extension".
|
||||
Every failure is reported in the return value for the caller to log.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from engine.db.connection import DB_FILENAME, comfyui_user_dir, wp_data_dir
|
||||
|
||||
#: Kept in sync with `wp_api/tags.py::TAG_FILE_NAME`. Duplicated rather than
|
||||
#: imported because `engine/` must not import from `wp_api/` — the directory
|
||||
#: contract in CLAUDE.md keeps the engine free of ComfyUI-facing modules, and
|
||||
#: `wp_api` imports aiohttp.
|
||||
TAG_FILE_NAME = "wildcard-pipeline-tags.csv"
|
||||
|
||||
#: SQLite's write-ahead-log sidecars. Moved with the database, never alone.
|
||||
_DB_SIDECAR_SUFFIXES = ("-wal", "-shm")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RelocationReport:
|
||||
"""What happened, for the caller to log. Empty everywhere = nothing to do."""
|
||||
|
||||
moved: list[str] = field(default_factory=list)
|
||||
skipped: list[str] = field(default_factory=list)
|
||||
failed: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def did_anything(self) -> bool:
|
||||
return bool(self.moved or self.skipped or self.failed)
|
||||
|
||||
|
||||
def _move_one(src: Path, dst: Path, report: RelocationReport) -> None:
|
||||
"""Move ``src`` to ``dst`` if and only if ``src`` exists and ``dst`` does not."""
|
||||
if not src.exists():
|
||||
return
|
||||
if dst.exists():
|
||||
report.skipped.append(f"{src.name} (already present at destination)")
|
||||
return
|
||||
try:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
# `shutil.move` rather than `Path.rename`: the user directory and our
|
||||
# subfolder are normally the same filesystem, but a user dir that is a
|
||||
# mount or a symlink elsewhere would make rename fail with EXDEV.
|
||||
shutil.move(str(src), str(dst))
|
||||
report.moved.append(src.name)
|
||||
except Exception as exc: # noqa: BLE001 - see module docstring, item 4
|
||||
report.failed.append(f"{src.name}: {exc}")
|
||||
|
||||
|
||||
def migrate_user_data() -> RelocationReport:
|
||||
"""Move the database and tag list into ``<user>/wildcard-pipeline/``.
|
||||
|
||||
Idempotent: once the files are at the destination every subsequent call
|
||||
finds nothing to move and returns an empty report. Safe to call on every
|
||||
boot, which is exactly what happens.
|
||||
"""
|
||||
report = RelocationReport()
|
||||
base = comfyui_user_dir()
|
||||
target = wp_data_dir()
|
||||
if base is None or target is None:
|
||||
# No detectable user directory. `resolve_db_path_with_source` falls
|
||||
# back to `~/.comfyui/` in this case, which was never inside the user
|
||||
# dir, so there is nothing here to move.
|
||||
return report
|
||||
|
||||
# Database first, sidecars immediately after it. Order matters only for
|
||||
# readability of the report; the "never overwrite" rule makes each move
|
||||
# independent.
|
||||
_move_one(base / DB_FILENAME, target / DB_FILENAME, report)
|
||||
for suffix in _DB_SIDECAR_SUFFIXES:
|
||||
_move_one(
|
||||
base / f"{DB_FILENAME}{suffix}",
|
||||
target / f"{DB_FILENAME}{suffix}",
|
||||
report,
|
||||
)
|
||||
|
||||
_move_one(base / TAG_FILE_NAME, target / TAG_FILE_NAME, report)
|
||||
return report
|
||||
@@ -422,6 +422,57 @@ class ModuleRepository:
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
return [_row_to_module(r) for r in rows]
|
||||
|
||||
def library_stamp(self) -> str:
|
||||
"""A cheap value that changes whenever any module's payload changes.
|
||||
|
||||
Exists so the drift endpoint can answer "nothing changed" WITHOUT
|
||||
building the hash map. That map costs a `json.loads` of every payload
|
||||
in the library, and the drift poll asks for it every five seconds per
|
||||
open tab — so the expensive part has to sit behind this, not in front
|
||||
of it.
|
||||
|
||||
Three columns, each covering what the others miss:
|
||||
- `COUNT(*)` — any insert or delete.
|
||||
- `SUM(version)`— any payload edit; `update` does `version = version + 1`.
|
||||
- `MAX(updated_at)` — backstop for a delete+insert that happened to
|
||||
leave count and version-sum unchanged.
|
||||
|
||||
Note what deliberately does NOT move it: `set_community_origin` and the
|
||||
favorite toggle write without bumping `version`, and neither changes a
|
||||
`payload_hash`. A stamp that ignored them would be wrong; a stamp that
|
||||
moved on them would just cost every client a pointless refetch.
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) AS n, COALESCE(SUM(version), 0) AS v, "
|
||||
"COALESCE(MAX(updated_at), '') AS t FROM modules;"
|
||||
).fetchone()
|
||||
return f"{row['n']}:{row['v']}:{row['t']}"
|
||||
|
||||
def list_hash_rows(self) -> list[dict[str, Any]]:
|
||||
"""`{id, type, payload_hash}` for every module — the drift map.
|
||||
|
||||
Deliberately not `list()`. That selects every column and runs each row
|
||||
through `_row_to_module`, which parses `tags`, reads the community and
|
||||
content-rating columns and builds a seventeen-key dict — all of it
|
||||
discarded here. This selects the three columns the drift map needs.
|
||||
|
||||
The `payload` parse survives because `payload_hash` is computed, not
|
||||
stored (unlike `bundles`, which has the column). Removing that too
|
||||
means a schema migration; the stamp above makes it moot in the steady
|
||||
state, which is the case that actually repeats.
|
||||
"""
|
||||
rows = self._conn.execute(
|
||||
"SELECT id, type, payload FROM modules;"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"type": r["type"],
|
||||
"payload_hash": payload_hash(json.loads(r["payload"])),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def count(
|
||||
self,
|
||||
*,
|
||||
@@ -813,6 +864,30 @@ class BundleRepository:
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
return [_row_to_bundle(r) for r in rows]
|
||||
|
||||
def library_stamp(self) -> str:
|
||||
"""Cheap change signal for the bundle drift poll.
|
||||
|
||||
See `ModuleRepository.library_stamp` — same three columns, same
|
||||
reasoning."""
|
||||
row = self._conn.execute(
|
||||
"SELECT COUNT(*) AS n, COALESCE(SUM(version), 0) AS v, "
|
||||
"COALESCE(MAX(updated_at), '') AS t FROM bundles;"
|
||||
).fetchone()
|
||||
return f"{row['n']}:{row['v']}:{row['t']}"
|
||||
|
||||
def list_hash_rows(self) -> list[dict[str, Any]]:
|
||||
"""`{id, payload_hash}` for every bundle.
|
||||
|
||||
Unlike modules, `bundles.payload_hash` is a real stored column, so
|
||||
this reads two columns and parses nothing at all. `list()` here would
|
||||
have selected every column and run `_row_to_bundle` over each row,
|
||||
which parses `tags` AND `children` per bundle for a map that wants
|
||||
neither."""
|
||||
rows = self._conn.execute(
|
||||
"SELECT id, payload_hash FROM bundles;"
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "payload_hash": r["payload_hash"]} for r in rows]
|
||||
|
||||
def count(
|
||||
self,
|
||||
*,
|
||||
|
||||
+10
-4
@@ -33,7 +33,7 @@ from engine.db.repositories import (
|
||||
TemplateNotFound,
|
||||
TemplateRepository,
|
||||
)
|
||||
from engine.migrations import CURRENT_SCHEMA_VERSION
|
||||
from engine.migrations.stamping import schema_version_for_payload
|
||||
|
||||
|
||||
def _walk_inner_bundles(repo: BundleRepository, seed: list[str]) -> list[str]:
|
||||
@@ -152,9 +152,7 @@ def build_export_payload(
|
||||
except TemplateNotFound:
|
||||
pass
|
||||
|
||||
return {
|
||||
"schema_version": CURRENT_SCHEMA_VERSION,
|
||||
"exported_at": now_iso(),
|
||||
buckets: dict[str, list[dict[str, Any]]] = {
|
||||
"bundles": bundles,
|
||||
"wildcards": wildcards,
|
||||
"fixed_values": fixed_values,
|
||||
@@ -164,3 +162,11 @@ def build_export_payload(
|
||||
"categories": categories,
|
||||
"templates": templates,
|
||||
}
|
||||
# Stamp the lowest version the exported content needs (range grammar 3,
|
||||
# constraint reach 4, accepts tag axes 5), not the migration-chain head:
|
||||
# an older extension must refuse a file whose features it can't read.
|
||||
return {
|
||||
"schema_version": schema_version_for_payload(buckets),
|
||||
"exported_at": now_iso(),
|
||||
**buckets,
|
||||
}
|
||||
|
||||
@@ -16,16 +16,26 @@ CURRENT_SCHEMA_VERSION = 2
|
||||
|
||||
# Highest schema version this runtime can natively READ + WRITE — distinct
|
||||
# from CURRENT_SCHEMA_VERSION (the migration-chain head, which stays 2 because
|
||||
# v2->v3/v3->v4 are no-ops). v3 is the SP2b text-grammar bump (shape-identical
|
||||
# to v2) and v4 is the additive `target_select` constraint reach; both are
|
||||
# handled natively, so a payload at CURRENT < v <= MAX_KNOWN installs AS-IS
|
||||
# with no migration. Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
|
||||
# v2->v3 through v5->v6 are no-ops). v3 is the SP2b text-grammar bump (shape-
|
||||
# identical to v2), v4 is the additive `target_select` constraint reach, v5
|
||||
# is the additive `accepts` tag-axis kind and v6 the constraint `only` rule;
|
||||
# all are handled natively, so a
|
||||
# payload at CURRENT < v <= MAX_KNOWN installs AS-IS with no migration.
|
||||
# Mirror of TS `MAX_KNOWN_SCHEMA_VERSION` in
|
||||
# `src/manager/import-export/migrations.ts`.
|
||||
#
|
||||
# MAINTENANCE CONTRACT: bump this whenever the TS `schemaVersionForPayload()`
|
||||
# learns to stamp a new (higher) version — otherwise this commit-side
|
||||
# re-validate will reject the very shapes the runtime just learned to produce.
|
||||
MAX_KNOWN_SCHEMA_VERSION = 4
|
||||
MAX_KNOWN_SCHEMA_VERSION = 6
|
||||
|
||||
# Feature stamps (mirror of the TS constants beside MAX_KNOWN). Each is the
|
||||
# catalog version a payload needs once it uses that feature; see
|
||||
# `engine/migrations/stamping.py:schema_version_for_payload`.
|
||||
SP2B_SCHEMA_VERSION = 3 # range count / `~` flag in a `{N$$…}` multi-pick
|
||||
SP3_REACH_SCHEMA_VERSION = 4 # non-default constraint `target_select`
|
||||
TAG_AXES_SCHEMA_VERSION = 5 # wildcard `tag_group_kinds` with an `accepts` group
|
||||
CONSTRAINT_ONLY_SCHEMA_VERSION = 6 # constraint rule with mode `only`
|
||||
|
||||
_CHAIN = {
|
||||
0: migrate_v0_to_v1,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Content-driven ``schema_version`` stamping. Mirror of TS
|
||||
``schemaVersionForPayload`` in ``src/manager/import-export/single-row-publish.ts``.
|
||||
|
||||
A payload is stamped with the LOWEST catalog version its features need, so
|
||||
an older consumer can still install everything that doesn't use a newer
|
||||
feature:
|
||||
|
||||
- ``CONSTRAINT_ONLY_SCHEMA_VERSION`` (6): a constraint matrix cell,
|
||||
exception or instance mode override uses the ``only`` rule.
|
||||
- ``TAG_AXES_SCHEMA_VERSION`` (5): a wildcard ``tag_group_kinds`` map marks
|
||||
any group ``accepts``.
|
||||
- ``SP3_REACH_SCHEMA_VERSION`` (4): a constraint carries a non-default
|
||||
``target_select`` reach selector.
|
||||
- ``SP2B_SCHEMA_VERSION`` (3): the payload text uses a range count or the
|
||||
``~`` independent flag in a ``{N$$…}`` multi-pick.
|
||||
- ``CURRENT_SCHEMA_VERSION`` (2) otherwise.
|
||||
|
||||
Each scan walks the whole structure (bundle children, instance overrides,
|
||||
every export bucket), so the marker is found wherever it lives. Keep the two
|
||||
sides in step: a new feature stamp lands in both files in the same PR.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.migrations import (
|
||||
CONSTRAINT_ONLY_SCHEMA_VERSION,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
SP2B_SCHEMA_VERSION,
|
||||
SP3_REACH_SCHEMA_VERSION,
|
||||
TAG_AXES_SCHEMA_VERSION,
|
||||
)
|
||||
|
||||
# SP2b nested-multi-pick marker: a `{N$$…}` whose count is a range (`N-M`)
|
||||
# or carries the `~` flag. A plain fixed-count `{N$$…}` predates SP2b.
|
||||
_SP2B_MARKER_RE = re.compile(r"\{\d+(?:-\d+~?|~)\$\$")
|
||||
|
||||
|
||||
def _is_number(value: Any) -> bool:
|
||||
# JSON booleans come back as Python bools, which are ints; TS
|
||||
# `typeof x === "number"` is false for them.
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _is_non_default_target_select(sel: Any) -> bool:
|
||||
"""True when ``sel`` narrows reach. Only an absent selector or a bare
|
||||
``{"mode": "all"}`` is the default; ``{"mode": "pick", "picks": []}``
|
||||
still counts because a pre-SP3 consumer can't parse the mode at all."""
|
||||
if not isinstance(sel, dict):
|
||||
return False
|
||||
mode = sel.get("mode")
|
||||
if isinstance(mode, str) and mode != "all":
|
||||
return True
|
||||
if _is_number(sel.get("count")):
|
||||
return True
|
||||
picks = sel.get("picks")
|
||||
return isinstance(picks, list) and len(picks) > 0
|
||||
|
||||
|
||||
def uses_target_select_reach(node: Any) -> bool:
|
||||
"""Any ``target_select`` at any depth holding a non-default selector."""
|
||||
if isinstance(node, list):
|
||||
return any(uses_target_select_reach(child) for child in node)
|
||||
if not isinstance(node, dict):
|
||||
return False
|
||||
if _is_non_default_target_select(node.get("target_select")):
|
||||
return True
|
||||
return any(
|
||||
isinstance(value, (dict, list)) and uses_target_select_reach(value)
|
||||
for value in node.values()
|
||||
)
|
||||
|
||||
|
||||
def uses_accepts_tag_axis(node: Any) -> bool:
|
||||
"""Any ``tag_group_kinds`` map at any depth marking a group ``accepts``."""
|
||||
if isinstance(node, list):
|
||||
return any(uses_accepts_tag_axis(child) for child in node)
|
||||
if not isinstance(node, dict):
|
||||
return False
|
||||
kinds = node.get("tag_group_kinds")
|
||||
if isinstance(kinds, dict) and any(k == "accepts" for k in kinds.values()):
|
||||
return True
|
||||
return any(
|
||||
isinstance(value, (dict, list)) and uses_accepts_tag_axis(value)
|
||||
for value in node.values()
|
||||
)
|
||||
|
||||
|
||||
_EXCEPTION_LIST_KEYS = ("exceptions", "extra_exceptions")
|
||||
_MODE_OVERRIDE_KEYS = ("cell_mode_overrides", "exception_mode_overrides")
|
||||
|
||||
|
||||
def _matrix_uses_only(matrix: Any) -> bool:
|
||||
if not isinstance(matrix, dict):
|
||||
return False
|
||||
return any(
|
||||
isinstance(row, dict) and any(
|
||||
isinstance(cell, dict) and cell.get("mode") == "only"
|
||||
for cell in row.values()
|
||||
)
|
||||
for row in matrix.values()
|
||||
)
|
||||
|
||||
|
||||
def uses_constraint_only_rule(node: Any) -> bool:
|
||||
"""Any constraint rule at any depth whose mode is ``only``: a library
|
||||
matrix cell or exception, an instance extra exception, or an instance mode
|
||||
override (a string map)."""
|
||||
if isinstance(node, list):
|
||||
return any(uses_constraint_only_rule(child) for child in node)
|
||||
if not isinstance(node, dict):
|
||||
return False
|
||||
if _matrix_uses_only(node.get("matrix")):
|
||||
return True
|
||||
for key in _EXCEPTION_LIST_KEYS:
|
||||
excs = node.get(key)
|
||||
if isinstance(excs, list) and any(
|
||||
isinstance(e, dict) and e.get("mode") == "only" for e in excs
|
||||
):
|
||||
return True
|
||||
for key in _MODE_OVERRIDE_KEYS:
|
||||
overrides = node.get(key)
|
||||
if isinstance(overrides, dict) and any(
|
||||
m == "only" for m in overrides.values()
|
||||
):
|
||||
return True
|
||||
return any(
|
||||
isinstance(value, (dict, list)) and uses_constraint_only_rule(value)
|
||||
for value in node.values()
|
||||
)
|
||||
|
||||
|
||||
def uses_sp2b_grammar(node: Any) -> bool:
|
||||
"""Range-count or ``~`` multi-pick anywhere in the serialised payload."""
|
||||
text = json.dumps(node, ensure_ascii=False, default=str)
|
||||
return _SP2B_MARKER_RE.search(text) is not None
|
||||
|
||||
|
||||
def schema_version_for_payload(payload: Any) -> int:
|
||||
"""The lowest catalog version that covers every feature in ``payload``."""
|
||||
if uses_constraint_only_rule(payload):
|
||||
return CONSTRAINT_ONLY_SCHEMA_VERSION
|
||||
if uses_accepts_tag_axis(payload):
|
||||
return TAG_AXES_SCHEMA_VERSION
|
||||
if uses_target_select_reach(payload):
|
||||
return SP3_REACH_SCHEMA_VERSION
|
||||
if uses_sp2b_grammar(payload):
|
||||
return SP2B_SCHEMA_VERSION
|
||||
return CURRENT_SCHEMA_VERSION
|
||||
@@ -47,6 +47,46 @@ class _RuntimeResolveContext:
|
||||
# occurrence matches on. None outside any carrier option.
|
||||
_carrier_uid: str | None = None
|
||||
_carrier_option_id: str | None = None
|
||||
# Rolled `accepts`-axis choices, keyed by VARIABLE BINDING (the namespace
|
||||
# `$outfit.SHOES` addresses) rather than module uuid like `_picks`. A
|
||||
# projection of `ctx["__wp_axes__"]`, built once here so the resolver stays
|
||||
# a pure consumer with no ctx dependency.
|
||||
_axes: dict[str, Any] = field(default_factory=dict)
|
||||
# Accepts axes each binding's wildcard declares (`ctx["__wp_axis_decl__"]`),
|
||||
# so an empty axis read can say whether the axis is missing or the pick
|
||||
# simply carried no tag on it.
|
||||
_axis_decl: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def get_axis(self, name: str, axis: str) -> Any:
|
||||
"""Rolled axis choice(s) for a binding, or None when it has none.
|
||||
|
||||
Returns a plain string for a single-pick source and a list (one entry
|
||||
per pick, in pick order) for a multi-select one, mirroring the shape of
|
||||
the variable itself. Shaping and the empty/warn decision belong to the
|
||||
resolver; this only fetches.
|
||||
|
||||
A binding that rolled no axes at all, and a binding that rolled some
|
||||
but not THIS one, both yield None — the caller cannot act differently
|
||||
on those two, and collapsing them keeps the read a single lookup.
|
||||
"""
|
||||
entry = self._axes.get(name)
|
||||
if isinstance(entry, list):
|
||||
# Positional: one slot per pick, None where that pick's option
|
||||
# carries no tag on this axis. Compacting the gaps away shifted
|
||||
# every later pick down, so `$outfit.1.SHOES` read pick 2's shoe
|
||||
# beside `$outfit.1`.
|
||||
got = [e.get(axis) if isinstance(e, dict) else None for e in entry]
|
||||
return got if any(g is not None for g in got) else None
|
||||
if isinstance(entry, dict):
|
||||
return entry.get(axis)
|
||||
return None
|
||||
|
||||
def declared_axes(self, name: str) -> list[str] | None:
|
||||
"""Accepts axes the wildcard bound to `name` declares, or None when
|
||||
that is unknown (nothing recorded, e.g. a ctx from before this table
|
||||
existed or a binding no wildcard wrote)."""
|
||||
got = self._axis_decl.get(name)
|
||||
return [str(a) for a in got] if isinstance(got, list) else None
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
# SP2a: return the raw stored value (may be a ListVar from a
|
||||
@@ -132,6 +172,8 @@ def build_resolve_ctx(
|
||||
"""
|
||||
constraints = ctx.get("__wp_constraints__")
|
||||
picks = ctx.get("__wp_picks__")
|
||||
axes = ctx.get("__wp_axes__")
|
||||
axis_decl = ctx.get("__wp_axis_decl__")
|
||||
hits = ctx.setdefault("__wp_constraint_hits__", {})
|
||||
return _RuntimeResolveContext( # type: ignore[return-value]
|
||||
rng=ctx["__wp_rng__"],
|
||||
@@ -153,6 +195,11 @@ def build_resolve_ctx(
|
||||
# resolver's increments stick in ctx + share one counter with
|
||||
# the direct path for first/next coverage.
|
||||
_hits=hits if isinstance(hits, dict) else {},
|
||||
# Same treatment as `_vars`: a projection built at frame construction,
|
||||
# so `get_axis` is a dict lookup and the resolver never reaches into
|
||||
# ctx.
|
||||
_axes=axes if isinstance(axes, dict) else {},
|
||||
_axis_decl=axis_decl if isinstance(axis_decl, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,14 +3,53 @@
|
||||
One multiply operator at three levels: across an option's matching matrix
|
||||
cells (multi-tag), across source picks (multi-pick), and — by the caller —
|
||||
across constraints. `exclude` (factor 0) is the absorbing element.
|
||||
|
||||
**One exception, added 2026-08 (tag axes).** Tags belonging to a source group
|
||||
the wildcard declared `accepts` are ALTERNATIVES the option offers, not
|
||||
statements that are all true at once. Those fold with `max`, so one sibling's
|
||||
`exclude` cannot speak for the rest. Everything else still multiplies, and the
|
||||
axis path is unreachable unless the caller passes `axis_kinds` — so a payload
|
||||
that predates the feature folds byte-identically.
|
||||
|
||||
**`only` (2026-09, linked picks).** A rule that turns its row into an
|
||||
allow-list: when the source fires, every target WITHOUT a rule of its own in
|
||||
that row is excluded. On an exception it gates by literal value — any target
|
||||
value with no exception for that source value drops out. In a matrix row it
|
||||
gates by tag — an option carrying no tag that has a rule in the row drops out.
|
||||
The `only` rule itself weighs 1.0 (like `allow`), and a target that has any
|
||||
other rule in the row keeps that rule, so `only` shuts out the unmentioned and
|
||||
leaves the mentioned alone. In a matrix row an `allow` cell does NOT count as
|
||||
a mention: the editors show `allow` as the empty "neutral" state, so counting
|
||||
it would let an invisible cell through. An `allow` exception is a visible row
|
||||
in the exceptions list, so it does count. A payload with no `only` rule never reaches either
|
||||
gate, so it folds byte-identically.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
EXCLUDE = object() # sentinel: option drops out of the pool
|
||||
|
||||
def _apply_rule(rule: dict[str, Any]):
|
||||
class _ExcludeSentinel:
|
||||
"""Absorbing zero: the option drops out of the pool entirely.
|
||||
|
||||
A distinct class rather than a bare ``object()`` so a type checker can
|
||||
narrow ``float | _ExcludeSentinel`` after an isinstance test. With the bare
|
||||
sentinel every ``factor *= r`` was unprovable, because ``is EXCLUDE`` does
|
||||
not narrow an ``object``.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug aid only
|
||||
return "EXCLUDE"
|
||||
|
||||
|
||||
EXCLUDE = _ExcludeSentinel() # sentinel: option drops out of the pool
|
||||
|
||||
_Factor = float | _ExcludeSentinel
|
||||
|
||||
|
||||
def _apply_rule(rule: dict[str, Any]) -> _Factor:
|
||||
mode = rule.get("mode")
|
||||
if mode == "exclude":
|
||||
return EXCLUDE
|
||||
@@ -19,10 +58,87 @@ def _apply_rule(rule: dict[str, Any]):
|
||||
return max(0.0, float(rule.get("factor", 1.0)))
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
return 1.0 # allow / unknown -> no weight change
|
||||
return 1.0 # allow / only / unknown -> no weight change
|
||||
|
||||
def combine_constraint_factor(source_picks, option, matrix, exceptions):
|
||||
|
||||
def _row_has_only(row: dict[str, Any]) -> bool:
|
||||
return any(isinstance(r, dict) and r.get("mode") == "only" for r in row.values())
|
||||
|
||||
|
||||
def _cell_factor(
|
||||
matrix: dict[str, Any],
|
||||
source_tag: str,
|
||||
opt_tags: list[str],
|
||||
target_axes: dict[str, list[str]] | None = None,
|
||||
) -> _Factor:
|
||||
"""Fold one source tag against every tag on the option.
|
||||
|
||||
Classify target tags fold with PRODUCT — an option sitting on several rows
|
||||
of the same source tag is genuinely several separate statements about it.
|
||||
Tags belonging to a target ``accepts`` axis (``target_axes``: group →
|
||||
members) fold with MAX instead: the option OFFERS those as ALTERNATIVES, so
|
||||
it stays viable when the source allows ANY of them (the mirror of the
|
||||
source-side accepts fold — the target's own roll is then restricted to the
|
||||
allowed members by the caller). A no-rule target tag is neutral (1.0).
|
||||
EXCLUDE is absorbing for a classify tag and for a fully-excluded axis; the
|
||||
``target_axes`` path is unreachable unless the caller passes it, so a legacy
|
||||
call folds byte-identically.
|
||||
"""
|
||||
row = matrix.get(source_tag)
|
||||
if not isinstance(row, dict):
|
||||
return 1.0
|
||||
# `only` makes the row an allow-list: an option carrying no tag that the
|
||||
# row mentions is shut out. Inside an accepts axis an unmentioned member
|
||||
# then offers nothing (0), rather than the usual neutral 1.0. A neutral
|
||||
# (`allow`) cell is not a mention — see the module docstring.
|
||||
has_only = _row_has_only(row)
|
||||
if has_only:
|
||||
row = {
|
||||
t: r for t, r in row.items()
|
||||
if isinstance(r, dict) and r.get("mode") != "allow"
|
||||
}
|
||||
if not any(t in row for t in opt_tags):
|
||||
return EXCLUDE
|
||||
unruled = 0.0 if has_only else 1.0
|
||||
target_axes = target_axes or {}
|
||||
axis_of = {t: ax for ax, members in target_axes.items() for t in (members or [])}
|
||||
factor = 1.0
|
||||
axis_members: dict[str, list[str]] = {}
|
||||
for t in opt_tags:
|
||||
ax = axis_of.get(t)
|
||||
if ax is None:
|
||||
rule = row.get(t)
|
||||
if isinstance(rule, dict):
|
||||
r = _apply_rule(rule)
|
||||
if isinstance(r, _ExcludeSentinel):
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
else:
|
||||
axis_members.setdefault(ax, []).append(t)
|
||||
for members in axis_members.values():
|
||||
best = 0.0
|
||||
for t in members:
|
||||
rule = row.get(t)
|
||||
r = _apply_rule(rule) if isinstance(rule, dict) else unruled
|
||||
best = max(best, 0.0 if isinstance(r, _ExcludeSentinel) else r)
|
||||
# Every alternative this axis offered was excluded → the option drops.
|
||||
if best <= 0.0:
|
||||
return EXCLUDE
|
||||
factor *= best
|
||||
return factor
|
||||
|
||||
|
||||
def combine_constraint_factor(
|
||||
source_picks: list[dict[str, Any]] | None,
|
||||
option: dict[str, Any],
|
||||
matrix: dict[str, Any] | None,
|
||||
exceptions: list[Any] | None,
|
||||
axis_kinds: dict[str, str] | None = None,
|
||||
target_axes: dict[str, list[str]] | None = None,
|
||||
) -> _Factor:
|
||||
matrix = matrix or {}
|
||||
axis_kinds = axis_kinds or {}
|
||||
target_axes = target_axes or {}
|
||||
exc_by_pair: dict[tuple[str, str], dict] = {}
|
||||
for e in (exceptions or []):
|
||||
if not isinstance(e, dict):
|
||||
@@ -35,6 +151,12 @@ def combine_constraint_factor(source_picks, option, matrix, exceptions):
|
||||
t = e.get("target")
|
||||
if isinstance(s, str) and isinstance(t, str):
|
||||
exc_by_pair[(s, t)] = e
|
||||
# Source values whose exceptions include an `only`: for those, a target
|
||||
# value with no exception of its own is excluded. Read off the de-duplicated
|
||||
# pair map so it agrees with the rule that actually applies to each pair.
|
||||
only_sources = {
|
||||
s for (s, _t), e in exc_by_pair.items() if e.get("mode") == "only"
|
||||
}
|
||||
opt_value = str(option.get("value", ""))
|
||||
opt_tags = option.get("tags") or []
|
||||
factor = 1.0
|
||||
@@ -44,19 +166,47 @@ def combine_constraint_factor(source_picks, option, matrix, exceptions):
|
||||
exc = exc_by_pair.get((p_value, opt_value))
|
||||
if exc is not None:
|
||||
r = _apply_rule(exc)
|
||||
if r is EXCLUDE:
|
||||
if isinstance(r, _ExcludeSentinel):
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
continue
|
||||
for s in p_tags:
|
||||
row = matrix.get(s)
|
||||
if not isinstance(row, dict):
|
||||
if p_value in only_sources:
|
||||
return EXCLUDE
|
||||
|
||||
# Partition this pick's tags: those inside an `accepts` axis fold with
|
||||
# max, the rest keep multiplying. A pick only carries `axes` when the
|
||||
# source wildcard declared the group `accepts`, and `axis_kinds` gates
|
||||
# it a second time, so a legacy pick takes the flat path untouched.
|
||||
pick_axes = pick.get("axes") or {}
|
||||
accepts: dict[str, list[str]] = {}
|
||||
claimed: set[str] = set()
|
||||
for axis, members in pick_axes.items():
|
||||
if axis_kinds.get(axis) != "accepts":
|
||||
continue
|
||||
for t in opt_tags:
|
||||
rule = row.get(t)
|
||||
if isinstance(rule, dict):
|
||||
r = _apply_rule(rule)
|
||||
if r is EXCLUDE:
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
in_axis = [t for t in p_tags if t in (members or [])]
|
||||
if in_axis:
|
||||
accepts[axis] = in_axis
|
||||
claimed.update(in_axis)
|
||||
|
||||
for s in p_tags:
|
||||
if s in claimed:
|
||||
continue
|
||||
r = _cell_factor(matrix, s, opt_tags, target_axes)
|
||||
if isinstance(r, _ExcludeSentinel):
|
||||
return EXCLUDE
|
||||
factor *= r
|
||||
|
||||
for members in accepts.values():
|
||||
best = 0.0
|
||||
for s in members:
|
||||
r = _cell_factor(matrix, s, opt_tags, target_axes)
|
||||
# One member's EXCLUDE is that member declining, not the axis
|
||||
# declining — it contributes 0 to the max and the siblings
|
||||
# still get their say.
|
||||
best = max(best, 0.0 if isinstance(r, _ExcludeSentinel) else r)
|
||||
# Every alternative the option offered was excluded, so the option
|
||||
# really does drop out.
|
||||
if best <= 0.0:
|
||||
return EXCLUDE
|
||||
factor *= best
|
||||
return factor
|
||||
|
||||
@@ -132,6 +132,8 @@ def apply_constraints_for_target(
|
||||
hits=None,
|
||||
firing_uid=None,
|
||||
carrier_ctx=None,
|
||||
applied_out=None,
|
||||
target_axes=None,
|
||||
) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""Apply EVERY constraint whose reach selector covers this firing
|
||||
target instance, combined sequentially.
|
||||
@@ -219,9 +221,18 @@ def apply_constraints_for_target(
|
||||
continue
|
||||
adjust_warnings: list[dict[str, Any]] = []
|
||||
options = _apply_constraint_to_options(
|
||||
options, c, src_pick, adjust_warnings,
|
||||
options, c, src_pick, adjust_warnings, target_axes,
|
||||
)
|
||||
any_applied = True
|
||||
# Record the (constraint, resolved source pick) pairs that actually
|
||||
# covered THIS firing target instance. The caller (wildcard_handler)
|
||||
# uses them to restrict the target's OWN `accepts`-axis roll to tags
|
||||
# these same constraints don't exclude — so `$source.AXIS` and
|
||||
# `$target.AXIS` agree under a diagonal. Only covered constraints land
|
||||
# here, so a selector-limited constraint (`first`/`next`/`pick`) that
|
||||
# skipped this instance also leaves the axis roll alone.
|
||||
if applied_out is not None:
|
||||
applied_out.append((c, src_pick))
|
||||
for w in adjust_warnings:
|
||||
_push_constraint_warning(
|
||||
warnings,
|
||||
@@ -242,10 +253,10 @@ def warn_excludes_all(
|
||||
warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Emit the ``constraint_excludes_all_options`` warning when post-
|
||||
application weights sum to zero. ``_pick_weighted`` falls back to
|
||||
``options[0]`` silently — without this warning the user sees the
|
||||
same option forever with no signal that constraints over-narrowed
|
||||
the pool.
|
||||
application weights sum to zero. ``_pick_weighted`` returns None for
|
||||
such a pool and the wildcard binds an empty string, so without this
|
||||
warning an over-narrowed pool is indistinguishable from a wildcard
|
||||
that legitimately rolled its null option.
|
||||
|
||||
Call AFTER ``apply_constraints_for_target`` returned ``any_applied``.
|
||||
"""
|
||||
@@ -259,6 +270,6 @@ def warn_excludes_all(
|
||||
target_uuid=target_uuid,
|
||||
detail={
|
||||
"target_wildcard_id": target_uuid,
|
||||
"hint": "every option was excluded; falling back to options[0]",
|
||||
"hint": "every option was excluded; the target emits nothing",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -22,7 +22,9 @@ from typing import Any
|
||||
from engine.modules._keys import decode_key, encode_key
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
|
||||
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
|
||||
# `only` (2026-09, linked picks) turns its row into an allow-list — see
|
||||
# `_constraint_math`. Additive: a payload using it stamps schema_version 6.
|
||||
_VALID_MODES = {"allow", "exclude", "boost", "reduce", "only"}
|
||||
|
||||
# SP3 reach selector — which downstream target instance(s) a constraint
|
||||
# reaches. Shape only here; reach *behaviour* is applied by a later task.
|
||||
@@ -142,6 +144,14 @@ def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
|
||||
})
|
||||
|
||||
|
||||
def _override_mode(mode: Any) -> Any:
|
||||
"""Canvas matrix builds before 2026-09 stored a neutral cell override as
|
||||
``"neutral"`` (the editor's name) instead of ``"allow"`` (the engine's),
|
||||
which failed the whole constraint at run time. Read it as ``allow`` so
|
||||
workflows saved by those builds run."""
|
||||
return "allow" if mode == "neutral" else mode
|
||||
|
||||
|
||||
def _validate_cell(cell: Any, where: str) -> None:
|
||||
if not isinstance(cell, dict):
|
||||
raise ValueError(f"constraint {where} must be an object")
|
||||
@@ -163,6 +173,55 @@ def _validate_cell(cell: Any, where: str) -> None:
|
||||
raise ValueError(f"constraint {where}.factor must not be negative")
|
||||
|
||||
|
||||
def exception_pair(exc: dict[str, Any]) -> tuple[str, str] | None:
|
||||
"""The `(source, target)` key an exception is filed under, or None.
|
||||
|
||||
MUST match `_constraint_math.combine_constraint_factor`'s key resolution
|
||||
exactly — tier-2 `source_value`/`target_value` first, legacy
|
||||
`source`/`target` as fallback. If the two ever disagree, the duplicate
|
||||
check below stops describing what the engine actually does.
|
||||
"""
|
||||
source = exc.get("source_value")
|
||||
if source is None:
|
||||
source = exc.get("source")
|
||||
target = exc.get("target_value")
|
||||
if target is None:
|
||||
target = exc.get("target")
|
||||
if isinstance(source, str) and isinstance(target, str):
|
||||
return (source, target)
|
||||
return None
|
||||
|
||||
|
||||
def find_duplicate_exception_pairs(payload: dict[str, Any]) -> list[tuple[str, str]]:
|
||||
"""`(source, target)` pairs appearing more than once, in first-seen order.
|
||||
|
||||
Two exceptions on the same pair cannot both apply. `combine_constraint_factor`
|
||||
files them into a dict keyed by the pair, so the LAST one in the list wins
|
||||
and every earlier one is discarded silently — and that includes discarding
|
||||
an `exclude`, which the documented model says is absorbing:
|
||||
|
||||
exceptions [exclude, boost x3] -> factor 3.0 (exclusion defeated)
|
||||
exceptions [boost x3, exclude] -> EXCLUDE
|
||||
exceptions [boost x3, reduce x0.5] -> 0.5 (not the combined 1.5)
|
||||
|
||||
So the result depends on list order, which no surface shows and nothing
|
||||
lets you edit. Reported by the maintainer 2026-08-06 after noticing the
|
||||
editor would happily accept contradictory duplicates.
|
||||
"""
|
||||
seen: set[tuple[str, str]] = set()
|
||||
duplicates: list[tuple[str, str]] = []
|
||||
for exc in payload.get("exceptions") or []:
|
||||
if not isinstance(exc, dict):
|
||||
continue
|
||||
pair = exception_pair(exc)
|
||||
if pair is None:
|
||||
continue
|
||||
if pair in seen and pair not in duplicates:
|
||||
duplicates.append(pair)
|
||||
seen.add(pair)
|
||||
return duplicates
|
||||
|
||||
|
||||
class ConstraintHandler(ModuleHandler):
|
||||
"""Records a constraint matrix into the context for downstream consumers."""
|
||||
|
||||
@@ -236,6 +295,31 @@ class ConstraintHandler(ModuleHandler):
|
||||
)
|
||||
_validate_target_select(payload.get("target_select"))
|
||||
|
||||
@classmethod
|
||||
def validate_authoring(cls, payload: dict[str, Any]) -> None:
|
||||
"""Extra rules applied when a payload is SAVED or IMPORTED, not when
|
||||
it is executed.
|
||||
|
||||
Deliberately separate from `validate_payload`, which `resolve` calls on
|
||||
every run. A rule enforced there would turn an existing bad payload
|
||||
into a hard failure mid-graph — strictly worse than the silent
|
||||
misbehaviour it replaces, and it would fire on data the user cannot
|
||||
reach to fix without the graph running. Enforcing at the authoring
|
||||
boundary stops new ones being created (from the editor, the API, or an
|
||||
import) while leaving existing rows executable.
|
||||
"""
|
||||
duplicates = find_duplicate_exception_pairs(payload)
|
||||
if duplicates:
|
||||
listed = ", ".join(
|
||||
f"({source!r} -> {target!r})" for source, target in duplicates
|
||||
)
|
||||
raise ValueError(
|
||||
"constraint payload.exceptions contains more than one rule for "
|
||||
f"the same source/target pair: {listed}. Only one can apply — "
|
||||
"the engine keeps whichever comes last — so keep the rule you "
|
||||
"want and delete the rest."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
@@ -288,7 +372,7 @@ class ConstraintHandler(ModuleHandler):
|
||||
# value across resolves.
|
||||
merged = dict(cell)
|
||||
if key in cell_mode_overrides:
|
||||
mode = cell_mode_overrides[key]
|
||||
mode = _override_mode(cell_mode_overrides[key])
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError(
|
||||
f"constraint cell_mode_overrides[{key!r}] must be one of "
|
||||
@@ -328,7 +412,7 @@ class ConstraintHandler(ModuleHandler):
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
src, tgt = parts[0], parts[1]
|
||||
mode = cell_mode_overrides.get(key, "allow")
|
||||
mode = _override_mode(cell_mode_overrides.get(key, "allow"))
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError(
|
||||
f"constraint cell_mode_overrides[{key!r}] must be one of "
|
||||
|
||||
@@ -19,7 +19,11 @@ from engine.modules import build_resolve_ctx
|
||||
from engine.modules._seed import derive_module_rng
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
from engine.syntax.types import deref_var_value, split_var_accessor
|
||||
from engine.syntax.types import (
|
||||
deref_var_value,
|
||||
parse_var_reference,
|
||||
split_var_accessor,
|
||||
)
|
||||
|
||||
_VALID_OPS = {
|
||||
"equals", "not_equals", "contains", "matches",
|
||||
@@ -56,11 +60,43 @@ def _ctx_get_raw(ctx: Any, name: str) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
def _ctx_axis(ctx: Any, base: str, axis: str, index: int | None) -> str:
|
||||
"""Read a rolled axis choice out of `ctx["__wp_axes__"]`.
|
||||
|
||||
Mirrors the resolver's `_resolve_axis` shaping so a condition and a
|
||||
template agree on what `$outfit.SHOES` means. Missing yields "" rather than
|
||||
raising: derivations are control flow, so an unanswerable condition is
|
||||
simply false.
|
||||
"""
|
||||
if not isinstance(ctx, dict):
|
||||
return ""
|
||||
entry = (ctx.get("__wp_axes__") or {}).get(base)
|
||||
if isinstance(entry, list):
|
||||
# Positional, like the resolver: a pick with no tag on this axis keeps
|
||||
# its slot so `.K` lines up with `$outfit.K`.
|
||||
vals = [
|
||||
(e.get(axis) or "") if isinstance(e, dict) else "" for e in entry
|
||||
]
|
||||
if index is None:
|
||||
return ", ".join(v for v in vals if v)
|
||||
return vals[index] if 0 <= index < len(vals) else ""
|
||||
if isinstance(entry, dict):
|
||||
got = entry.get(axis, "")
|
||||
# A single pick behaves as a one-element list, as everywhere else.
|
||||
if index is not None:
|
||||
return got if index == 0 else ""
|
||||
return got
|
||||
return ""
|
||||
|
||||
|
||||
def _ctx_get(ctx: Any, name: str) -> str:
|
||||
"""Read `name` as a string, honoring a `.K` list accessor and folding a
|
||||
ListVar to its joined value (SP2a): `$mood.0` splits to base `mood` +
|
||||
index 0. The fold/accessor contract lives in deref_var_value."""
|
||||
base, index = split_var_accessor(name)
|
||||
"""Read `name` as a string, honoring a `.K` list accessor, a `.AXIS` read
|
||||
(2026-08 tag axes), and folding a ListVar to its joined value (SP2a):
|
||||
`$mood.0` splits to base `mood` + index 0. The fold/accessor contract lives
|
||||
in deref_var_value; the axis contract in `_ctx_axis`."""
|
||||
base, index, axis = parse_var_reference(name)
|
||||
if axis:
|
||||
return _ctx_axis(ctx, base, axis, index)
|
||||
return deref_var_value(_ctx_get_raw(ctx, base), index)
|
||||
|
||||
|
||||
@@ -76,7 +112,21 @@ def _ctx_has(ctx: Any, name: str) -> bool:
|
||||
"""
|
||||
if ctx is None:
|
||||
return False
|
||||
name = split_var_accessor(name)[0] # SP2a: presence checks the base var
|
||||
base, _index, axis = parse_var_reference(name)
|
||||
if axis:
|
||||
# An axis is present when the binding actually rolled it. Checked
|
||||
# against the axes bucket, not ctx: `$outfit.SHOES` can exist while
|
||||
# `outfit` is unbound, and vice versa.
|
||||
entry = (
|
||||
(ctx.get("__wp_axes__") or {}).get(base)
|
||||
if isinstance(ctx, dict) else None
|
||||
)
|
||||
if isinstance(entry, dict):
|
||||
return axis in entry
|
||||
if isinstance(entry, list):
|
||||
return any(isinstance(e, dict) and axis in e for e in entry)
|
||||
return False
|
||||
name = base # SP2a: presence checks the base var
|
||||
# Engine ctx is a dict in tests + at runtime; check `__contains__`
|
||||
# before falling back to a getter probe so we get the cheap path.
|
||||
try:
|
||||
|
||||
@@ -64,7 +64,13 @@ def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
|
||||
return None
|
||||
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
|
||||
if total <= 0:
|
||||
return options[0]
|
||||
# Nothing is selectable — every option is either weight 0 (documented
|
||||
# as "disable without deleting") or was excluded by a constraint.
|
||||
# Returning options[0] here looked like a working pick: a real option,
|
||||
# right shape, right place, and wrong. It stayed wrong silently until
|
||||
# the list was reordered. Callers turn None into an empty binding,
|
||||
# which is what the null option already means.
|
||||
return None
|
||||
r = rng.random() * total
|
||||
acc = 0.0
|
||||
for opt in options:
|
||||
@@ -79,6 +85,7 @@ def _apply_constraint_to_options(
|
||||
constraint: dict[str, Any],
|
||||
source_pick: dict[str, Any],
|
||||
adjustment_warnings: list[dict[str, Any]] | None = None,
|
||||
target_axes: dict[str, list[str]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Adjust option weights according to a single constraint, given the
|
||||
already-picked source option(s).
|
||||
@@ -107,7 +114,7 @@ def _apply_constraint_to_options(
|
||||
combine fn treats as a no-op.
|
||||
These are diagnostic only; they never change the computed weight.
|
||||
"""
|
||||
from engine.modules._constraint_math import EXCLUDE, combine_constraint_factor
|
||||
from engine.modules._constraint_math import combine_constraint_factor
|
||||
|
||||
matrix = constraint.get("matrix") or {}
|
||||
exceptions = constraint.get("exceptions") or []
|
||||
@@ -131,17 +138,33 @@ def _apply_constraint_to_options(
|
||||
picks, options, matrix, exceptions, adjustment_warnings,
|
||||
)
|
||||
|
||||
# A pick only carries an `axes` entry when the source declared that group
|
||||
# `accepts` (see `_axis_menus`), so the pick record IS the kind map. This
|
||||
# keeps `combine_constraint_factor` pure — it never looks up a payload —
|
||||
# and a legacy pick with no `axes` yields an empty map, which takes the
|
||||
# unchanged flat-product path.
|
||||
axis_kinds = {
|
||||
axis: "accepts"
|
||||
for p in picks
|
||||
for axis in (p.get("axes") or {})
|
||||
}
|
||||
|
||||
adjusted: list[dict[str, Any]] = []
|
||||
for opt in options:
|
||||
option = {"value": opt.get("value", ""), "tags": opt.get("sub_categories") or []}
|
||||
f = combine_constraint_factor(picks, option, matrix, exceptions)
|
||||
f = combine_constraint_factor(
|
||||
picks, option, matrix, exceptions, axis_kinds, target_axes,
|
||||
)
|
||||
# `isinstance(f, float)` rather than `f is not EXCLUDE`: identity
|
||||
# against a sentinel does not narrow the union, so the multiply below
|
||||
# was unprovable. Same test, and the type checker can follow it.
|
||||
weight = float(opt.get("weight", 1))
|
||||
weight = 0.0 if f is EXCLUDE else max(0.0, weight * float(f))
|
||||
weight = max(0.0, weight * f) if isinstance(f, float) else 0.0
|
||||
adjusted.append({**opt, "weight": weight})
|
||||
return adjusted
|
||||
|
||||
|
||||
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce"})
|
||||
_KNOWN_CONSTRAINT_MODES = frozenset({"allow", "exclude", "boost", "reduce", "only"})
|
||||
|
||||
|
||||
def _exc_pair_index(exceptions: list[Any]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
@@ -205,8 +228,8 @@ def _warn_for_rule(
|
||||
adjustment_warnings: list[dict[str, Any]],
|
||||
) -> None:
|
||||
mode = rule.get("mode")
|
||||
if mode == "allow":
|
||||
# `allow` is a weight no-op; a non-1 factor is almost certainly a
|
||||
if mode in ("allow", "only"):
|
||||
# `allow` / `only` weigh 1.0; a non-1 factor is almost certainly a
|
||||
# user expecting weight×factor and silently getting full weight.
|
||||
try:
|
||||
factor = float(rule.get("factor", 1.0))
|
||||
@@ -257,7 +280,175 @@ def _file_pick_by_origin(
|
||||
entry["by_origin"] = by_origin
|
||||
|
||||
|
||||
def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
|
||||
def _axis_menus(
|
||||
payload: dict[str, Any], tags: list[str] | None
|
||||
) -> dict[str, list[str]]:
|
||||
"""The option's tags, grouped by `accepts` axis, in registry order.
|
||||
|
||||
Only `accepts` groups appear — a `classify` group's tags stay in the flat
|
||||
bag where the constraint fold multiplies them, which is today's behaviour.
|
||||
An axis the option says nothing about is omitted rather than mapped to an
|
||||
empty list, so callers can treat presence as meaning.
|
||||
"""
|
||||
kinds = payload.get("tag_group_kinds") or {}
|
||||
groups = payload.get("tag_groups") or {}
|
||||
have = set(tags or [])
|
||||
out: dict[str, list[str]] = {}
|
||||
for axis, members in groups.items():
|
||||
if kinds.get(axis) != "accepts":
|
||||
continue
|
||||
in_axis = [m for m in (members or []) if m in have]
|
||||
if in_axis:
|
||||
out[axis] = in_axis
|
||||
return out
|
||||
|
||||
|
||||
def _accepts_axes(payload: dict[str, Any]) -> dict[str, list[str]]:
|
||||
"""Every ``accepts`` axis on this payload as group → full member list
|
||||
(independent of any option). Fed to the constraint fold as the TARGET side:
|
||||
a target option's tags inside one of these groups are ALTERNATIVES, so the
|
||||
fold keeps the option viable when the source allows any of them (symmetric
|
||||
to the source's rolled-winner collapse), and the target's own roll is later
|
||||
restricted to the allowed members. Empty ⇒ the fold takes the flat product
|
||||
path, i.e. today's behaviour.
|
||||
"""
|
||||
kinds = payload.get("tag_group_kinds") or {}
|
||||
groups = payload.get("tag_groups") or {}
|
||||
return {
|
||||
g: list(members or [])
|
||||
for g, members in groups.items()
|
||||
if kinds.get(g) == "accepts"
|
||||
}
|
||||
|
||||
|
||||
def _roll_axes(menus: dict[str, list[str]], rng) -> dict[str, str]:
|
||||
"""One winner per axis, uniform over the option's own menu.
|
||||
|
||||
Uniform rather than weighted: option `weight` describes how often the
|
||||
OPTION is chosen, and nothing in the payload expresses a preference between
|
||||
an option's accepted tags. Inventing one would be a guess.
|
||||
|
||||
Axis order is the payload's group order, so the draw sequence is stable
|
||||
across runs for a given seed.
|
||||
"""
|
||||
return {
|
||||
axis: members[int(rng.random() * len(members))]
|
||||
for axis, members in menus.items()
|
||||
if members
|
||||
}
|
||||
|
||||
|
||||
def _pick_record_for_constraint(
|
||||
chosen: dict[str, Any], payload: dict[str, Any], rolled: dict[str, str] | None
|
||||
) -> dict[str, Any]:
|
||||
"""The per-pick record a constraint reads. For an ``accepts`` axis the
|
||||
option offers a menu but rolls ONE winner; the constraint keys on that
|
||||
single winner (the value ``$var.AXIS`` exposes), NOT the whole menu —
|
||||
otherwise a diagonal narrows the target to the entire accepted set instead
|
||||
of the rolled tag, and ``$source.AXIS`` disagrees with the picked target
|
||||
(the reported "picks are not good"). So each accepts axis collapses to its
|
||||
winner: the axis members leave the flat ``tags`` bag and the winner is added
|
||||
back, and ``axes`` carries only the winner. Classify tags are untouched
|
||||
(they still multiply). An option with no accepts axis produces the same
|
||||
record as before the feature.
|
||||
"""
|
||||
sub = list(chosen.get("sub_categories") or [])
|
||||
menus = _axis_menus(payload, sub)
|
||||
rolled = rolled or {}
|
||||
axis_members = {m for members in menus.values() for m in members}
|
||||
winners = [rolled[a] for a in menus if a in rolled]
|
||||
tags = [t for t in sub if t not in axis_members] + winners
|
||||
axes = {a: [rolled[a]] for a in menus if a in rolled}
|
||||
return {"value": chosen.get("value", ""), "tags": tags, "axes": axes}
|
||||
|
||||
|
||||
def _restrict_menus_by_constraints(
|
||||
menus: dict[str, list[str]],
|
||||
applied_constraints: list[tuple[dict[str, Any], dict[str, Any]]] | None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Drop from each accepts-axis menu the tags a FIRED constraint excludes, so
|
||||
the target's own roll can only land on a constraint-allowed tag.
|
||||
|
||||
Under a diagonal from a source that rolled ``sandals``, the target's
|
||||
``{sandals, heels}`` option can no longer roll ``heels`` — so
|
||||
``$source.SHOES`` and ``$target.SHOES`` agree. A tag survives iff EVERY
|
||||
fired constraint gives a single-tag option ``{tags: [t]}`` a positive
|
||||
factor. Never empties an axis: if every member is excluded the menu is left
|
||||
intact (the option itself survived the pool narrowing, so its axis must
|
||||
still roll something) — reachable only with a pathological matrix.
|
||||
"""
|
||||
if not applied_constraints or not menus:
|
||||
return menus
|
||||
from engine.modules._constraint_math import combine_constraint_factor
|
||||
out: dict[str, list[str]] = {}
|
||||
for axis, members in menus.items():
|
||||
allowed: list[str] = []
|
||||
for t in members:
|
||||
ok = True
|
||||
for c, src_pick in applied_constraints:
|
||||
s_picks = src_pick.get("picks")
|
||||
if not isinstance(s_picks, list):
|
||||
s_picks = [{
|
||||
"value": src_pick.get("value", ""),
|
||||
"tags": list(src_pick.get("sub_categories") or []),
|
||||
}]
|
||||
axis_kinds = {
|
||||
ax: "accepts"
|
||||
for p in s_picks for ax in (p.get("axes") or {})
|
||||
}
|
||||
# The probe is a bare tag with no real value, so a
|
||||
# value-level `only` would shut it out and leave the menu
|
||||
# unrestricted. The option itself already passed that gate
|
||||
# in the pool, so only the tag rules matter here.
|
||||
probe_excs = [
|
||||
e for e in (c.get("exceptions") or [])
|
||||
if not (isinstance(e, dict) and e.get("mode") == "only")
|
||||
]
|
||||
f = combine_constraint_factor(
|
||||
s_picks, {"value": "", "tags": [t]},
|
||||
c.get("matrix") or {}, probe_excs, axis_kinds,
|
||||
)
|
||||
if not (isinstance(f, float) and f > 0.0):
|
||||
ok = False
|
||||
break
|
||||
if ok:
|
||||
allowed.append(t)
|
||||
out[axis] = allowed if allowed else members
|
||||
return out
|
||||
|
||||
|
||||
def _record_axes(
|
||||
ctx: Any, binding: str, rolled: Any, payload: dict[str, Any]
|
||||
) -> None:
|
||||
"""Publish the rolled axis choices under the VARIABLE binding.
|
||||
|
||||
Deliberately not on the pick record: `$outfit.SHOES` addresses by binding,
|
||||
while `__wp_picks__` is keyed by module uuid. Keying each consumer the way
|
||||
it addresses things means neither has to translate, and the axis read never
|
||||
inherits the uuid-bucket collision (task_5200c1fc) that only reaches
|
||||
consumers binding by uuid.
|
||||
"""
|
||||
if not isinstance(ctx, dict) or not binding:
|
||||
return
|
||||
bucket = ctx.setdefault("__wp_axes__", {})
|
||||
if isinstance(bucket, dict):
|
||||
bucket[binding] = rolled
|
||||
# Which accepts axes this binding's wildcard DECLARES, whatever the pick
|
||||
# rolled. `__wp_axes__` alone cannot tell "the picked option has no SHOES
|
||||
# tag" from "there is no SHOES axis", and the resolver's warning needs to
|
||||
# say which one happened. Always written (even empty) so a later wildcard
|
||||
# rebinding the same name replaces the earlier declaration.
|
||||
decl = ctx.setdefault("__wp_axis_decl__", {})
|
||||
if isinstance(decl, dict):
|
||||
decl[binding] = list(_accepts_axes(payload))
|
||||
|
||||
|
||||
def _record_pick(
|
||||
ctx: Any,
|
||||
chosen: dict[str, Any],
|
||||
payload: dict[str, Any],
|
||||
rolled: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Stash the picked option dict in `ctx["__wp_picks__"][module_id]` so a
|
||||
downstream constraint-aware wildcard can look up its source's value +
|
||||
sub_categories. Keyed by the active module id (set by pipeline.py).
|
||||
@@ -282,10 +473,10 @@ def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
|
||||
# SP3: per-pick list the combine fn reads. A single pick is a
|
||||
# one-element list so the multi-tag/multi-pick applier has a
|
||||
# uniform shape regardless of single- vs multi-select source.
|
||||
"picks": [{
|
||||
"value": chosen.get("value", ""),
|
||||
"tags": list(chosen.get("sub_categories") or []),
|
||||
}],
|
||||
# An `accepts` axis is collapsed to its rolled winner here (see
|
||||
# `_pick_record_for_constraint`) so the constraint keys on the one
|
||||
# tag `$var.AXIS` exposes, not the whole menu.
|
||||
"picks": [_pick_record_for_constraint(chosen, payload, rolled)],
|
||||
}
|
||||
# Additive per-instance view (task_5200c1fc). When this wildcard
|
||||
# carries a `bundle_origin`, ALSO file the pick under
|
||||
@@ -298,7 +489,13 @@ def _record_pick(ctx: Any, chosen: dict[str, Any]) -> None:
|
||||
bucket[module_id] = entry
|
||||
|
||||
|
||||
def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) -> None:
|
||||
def _record_pick_multi(
|
||||
ctx: Any,
|
||||
chosen_list: list[dict[str, Any]],
|
||||
sep: str,
|
||||
payload: dict[str, Any],
|
||||
rolled_list: list[dict[str, str]] | None = None,
|
||||
) -> None:
|
||||
"""Multi-select counterpart to `_record_pick` (SP2a). Stash the joined
|
||||
value + the individual picked values + the union of their sub-categories,
|
||||
so the debug Picks view can render the list. Single-pick records (via
|
||||
@@ -318,10 +515,15 @@ def _record_pick_multi(ctx: Any, chosen_list: list[dict[str, Any]], sep: str) ->
|
||||
union.append(t)
|
||||
# SP3: per-pick list (value + that pick's own tags, NOT the union) so the
|
||||
# combine fn can apply the matrix per pick. The union above stays for the
|
||||
# debug Picks view; `picks` is what constraint application reads.
|
||||
# debug Picks view; `picks` is what constraint application reads. Each
|
||||
# pick's `accepts` axis is collapsed to its own rolled winner (parallel to
|
||||
# the single-pick path) so a multi-select source contributes one tag per
|
||||
# pick, not each pick's whole menu.
|
||||
per_pick = [
|
||||
{"value": str(c.get("value", "")), "tags": list(c.get("sub_categories") or [])}
|
||||
for c in chosen_list
|
||||
_pick_record_for_constraint(
|
||||
c, payload, (rolled_list[i] if rolled_list and i < len(rolled_list) else None)
|
||||
)
|
||||
for i, c in enumerate(chosen_list)
|
||||
]
|
||||
entry = {
|
||||
"value": sep.join(values),
|
||||
@@ -421,6 +623,38 @@ class WildcardHandler(ModuleHandler):
|
||||
)
|
||||
seen_grouped.add(mem)
|
||||
|
||||
# Group KIND (2026-08 tag axes). Additive: absence means every group
|
||||
# is `classify`, which is exactly today's behaviour, so no stored
|
||||
# payload changes meaning. `accepts` marks the group's tags as
|
||||
# alternatives the option offers — an OR-set the constraint fold reads
|
||||
# with max, and which `$var.AXIS` rolls one winner from.
|
||||
kinds = payload.get("tag_group_kinds")
|
||||
if kinds is not None:
|
||||
if not isinstance(kinds, dict):
|
||||
raise ValueError(
|
||||
"wildcard payload.tag_group_kinds must be an object"
|
||||
)
|
||||
declared = tag_groups if isinstance(tag_groups, dict) else {}
|
||||
for gname, kind in kinds.items():
|
||||
if gname not in declared:
|
||||
raise ValueError(
|
||||
f"wildcard payload.tag_group_kinds[{gname!r}]: "
|
||||
f"not a declared tag group"
|
||||
)
|
||||
if kind not in ("classify", "accepts"):
|
||||
raise ValueError(
|
||||
f"wildcard payload.tag_group_kinds[{gname!r}] must be "
|
||||
f"'classify' or 'accepts' (got {kind!r})"
|
||||
)
|
||||
# Only an `accepts` axis is addressable as `$var.AXIS`, so only
|
||||
# it has to survive the accessor grammar. A `classify` group
|
||||
# keeps whatever name it has today.
|
||||
if kind == "accepts" and not _IDENT_RE.match(gname):
|
||||
raise ValueError(
|
||||
f"wildcard payload.tag_group_kinds[{gname!r}]: an "
|
||||
f"'accepts' group name must be a valid identifier"
|
||||
)
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
null_count = 0
|
||||
for i, opt in enumerate(options):
|
||||
@@ -546,10 +780,23 @@ class WildcardHandler(ModuleHandler):
|
||||
pinned_id = instance.get("pinned_option_id")
|
||||
pinned = next((o for o in options if o.get("id") == pinned_id), None)
|
||||
if pinned is not None:
|
||||
# A pinned option still rolls its axes: pinning fixes WHICH
|
||||
# option fires, not which of the shoes it accepts. Roll BEFORE
|
||||
# recording so the pick carries the single rolled winner (the
|
||||
# constraint source-view a downstream target reads), same as the
|
||||
# random path. Pinned bypasses this wildcard's own constraint
|
||||
# application, so there's nothing to restrict the roll against.
|
||||
pinned_rolled = _roll_axes(
|
||||
_axis_menus(payload, pinned.get("sub_categories")),
|
||||
_derive_module_rng(
|
||||
int(ctx.get("__wp_node_seed__", 0) or 0), f"{binding}::axes",
|
||||
),
|
||||
)
|
||||
# Track the pinned pick the same way as a random pick —
|
||||
# downstream constraint-aware wildcards need source
|
||||
# info regardless of how the source resolved its option.
|
||||
_record_pick(ctx, pinned)
|
||||
_record_pick(ctx, pinned, payload, pinned_rolled)
|
||||
_record_axes(ctx, binding, pinned_rolled, payload)
|
||||
value = str(pinned.get("value", ""))
|
||||
if not value:
|
||||
return {binding: ""}
|
||||
@@ -614,6 +861,11 @@ class WildcardHandler(ModuleHandler):
|
||||
)
|
||||
my_id = ctx.get("__wp_current_module_id__") if ctx is not None else None
|
||||
any_constraint_applied = False
|
||||
# (constraint, source_pick) pairs that actually re-weighted this target.
|
||||
# Used below to restrict this wildcard's OWN accepts-axis roll to tags
|
||||
# those constraints allow, so `$source.AXIS` == `$target.AXIS` under a
|
||||
# diagonal (A). Empty when nothing constrains this instance.
|
||||
applied_constraints: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
||||
if my_id:
|
||||
constraints = ctx.get("__wp_constraints__") if ctx is not None else None
|
||||
picks = ctx.get("__wp_picks__") if ctx is not None else None
|
||||
@@ -633,6 +885,13 @@ class WildcardHandler(ModuleHandler):
|
||||
options, any_constraint_applied = apply_constraints_for_target(
|
||||
options, my_id, constraints, picks, ctx["__wp_warnings__"],
|
||||
hits=hits, firing_uid=firing_uid,
|
||||
applied_out=applied_constraints,
|
||||
# TARGET-side accepts axes: a target option's tags in one of
|
||||
# these groups are alternatives the option offers, so the fold
|
||||
# keeps it viable when the source allows any of them (mirror of
|
||||
# the source rolled-winner collapse). The roll is then pinned to
|
||||
# the allowed member by `_restrict_menus_by_constraints`.
|
||||
target_axes=_accepts_axes(payload),
|
||||
)
|
||||
if any_constraint_applied:
|
||||
warn_excludes_all(options, my_id or "", ctx["__wp_warnings__"])
|
||||
@@ -706,18 +965,51 @@ class WildcardHandler(ModuleHandler):
|
||||
items.append(resolve_text(str(opt.get("value", "")), multi_ctx))
|
||||
finally:
|
||||
ctx["__wp_rng__"] = saved_rng_multi
|
||||
_record_pick_multi(ctx, picks, sep)
|
||||
# One roll per pick, in pick order, so `$outfit.SHOES` mirrors the
|
||||
# shape of `$outfit` and `$outfit.1.SHOES` lines up with `$outfit.1`.
|
||||
# Each pick's accepts menu is first restricted to the tags the fired
|
||||
# constraints allow (A), then rolled; the winners feed BOTH the pick
|
||||
# record (source view) and the accessor. Rolled before the record —
|
||||
# the record draws no rng and the restriction consumes none + keeps
|
||||
# one draw per axis, so downstream reproducibility is unchanged.
|
||||
rolled_list = [
|
||||
_roll_axes(
|
||||
_restrict_menus_by_constraints(
|
||||
_axis_menus(payload, o.get("sub_categories")),
|
||||
applied_constraints,
|
||||
),
|
||||
rng,
|
||||
)
|
||||
for o in picks
|
||||
]
|
||||
_record_pick_multi(ctx, picks, sep, payload, rolled_list)
|
||||
_record_axes(ctx, binding, rolled_list, payload)
|
||||
return {binding: ListVar(items, sep)}
|
||||
|
||||
chosen = _pick_weighted(options, rng)
|
||||
if chosen is None:
|
||||
return {binding: ""}
|
||||
|
||||
# Roll the accepts axes AFTER the option draw (same rng, so a locked
|
||||
# seed still reproduces the option) but BEFORE recording the pick, so
|
||||
# the pick a downstream target reads carries the single rolled winner
|
||||
# per axis (source fix). This wildcard's OWN menu is first restricted to
|
||||
# tags the constraints that fired on it allow (A) — so a constrained
|
||||
# `$target.AXIS` agrees with the source. The restriction consumes no rng
|
||||
# and keeps one draw per axis, so downstream reproducibility holds.
|
||||
rolled = _roll_axes(
|
||||
_restrict_menus_by_constraints(
|
||||
_axis_menus(payload, chosen.get("sub_categories")),
|
||||
applied_constraints,
|
||||
),
|
||||
rng,
|
||||
)
|
||||
# Record this wildcard's pick so a downstream constraint-aware
|
||||
# wildcard can read it. Done BEFORE resolve_text so even an
|
||||
# empty-string-value pick is registered (matters if a
|
||||
# constraint exception keys on the literal empty pick value).
|
||||
_record_pick(ctx, chosen)
|
||||
_record_pick(ctx, chosen, payload, rolled)
|
||||
_record_axes(ctx, binding, rolled, payload)
|
||||
|
||||
value = str(chosen.get("value", ""))
|
||||
if not value:
|
||||
|
||||
+59
-1
@@ -248,6 +248,32 @@ def _module_seed_scope(module: object) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _module_binding(module: object) -> str | None:
|
||||
"""Read the variable name a wildcard binds to, instance override first.
|
||||
|
||||
Mirrors the precedence in `WildcardHandler.resolve`. Needed because rolled
|
||||
axes are keyed by BINDING — the namespace `$outfit.SHOES` addresses — while
|
||||
picks are keyed by module id, so the two carry-forwards cannot share a key.
|
||||
"""
|
||||
inst = (
|
||||
module.get("instance") if isinstance(module, dict)
|
||||
else getattr(module, "instance", None)
|
||||
)
|
||||
if isinstance(inst, dict):
|
||||
b = inst.get("variable_binding")
|
||||
if isinstance(b, str) and b:
|
||||
return b
|
||||
payload = (
|
||||
module.get("payload") if isinstance(module, dict)
|
||||
else getattr(module, "payload", None)
|
||||
)
|
||||
if isinstance(payload, dict):
|
||||
b = payload.get("var_binding")
|
||||
if isinstance(b, str) and b:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
class PipelineEngine:
|
||||
"""Runs an ordered list of modules against a context dict."""
|
||||
|
||||
@@ -326,6 +352,29 @@ class PipelineEngine:
|
||||
)
|
||||
if _mid and _mid in _base_picks and _mid not in _real_picks:
|
||||
_real_picks[_mid] = _base_picks[_mid]
|
||||
# Same carry-forward for rolled axes, but keyed by BINDING rather
|
||||
# than module id — that is the namespace `$outfit.SHOES` addresses.
|
||||
# Restricted to held modules, and `not in` rather than assignment,
|
||||
# so a wildcard that never asked to hold still re-rolls this
|
||||
# iteration.
|
||||
# The declared-axes table travels with the rolls it describes.
|
||||
for _table in ("__wp_axes__", "__wp_axis_decl__"):
|
||||
_base_axes = base_ctx.get(_table)
|
||||
if not isinstance(_base_axes, dict):
|
||||
continue
|
||||
_real_axes = ctx.setdefault(_table, {})
|
||||
if not isinstance(_real_axes, dict):
|
||||
continue
|
||||
for _m in modules:
|
||||
if _module_seed_scope(_m) != "hold":
|
||||
continue
|
||||
_binding = _module_binding(_m)
|
||||
if (
|
||||
_binding
|
||||
and _binding in _base_axes
|
||||
and _binding not in _real_axes
|
||||
):
|
||||
_real_axes[_binding] = _base_axes[_binding]
|
||||
_base_hits = base_ctx.get("__wp_constraint_hits__")
|
||||
if isinstance(_base_hits, dict):
|
||||
_real_hits = ctx.setdefault("__wp_constraint_hits__", {})
|
||||
@@ -580,9 +629,18 @@ class PipelineEngine:
|
||||
key = var.lstrip("$")
|
||||
before = ctx.get(key)
|
||||
ctx[key] = value
|
||||
# Last-write-wins applies to the internal flag too: whichever
|
||||
# module writes the var LAST decides whether it is internal. A
|
||||
# public writer overriding an earlier internal one MUST clear
|
||||
# the flag — otherwise the var stays hidden from the assembler
|
||||
# forever, even though its final value came from a public
|
||||
# module. (Mark $outfit internal, then override it with a
|
||||
# public $outfit: the override should be visible.)
|
||||
flags = ctx.setdefault("__wp_internal_flags__", {})
|
||||
if mark_internal:
|
||||
flags = ctx.setdefault("__wp_internal_flags__", {})
|
||||
flags[key] = True
|
||||
else:
|
||||
flags.pop(key, None)
|
||||
writes.append({
|
||||
"variable": key,
|
||||
"value": value,
|
||||
|
||||
@@ -103,6 +103,13 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
|
||||
),
|
||||
)
|
||||
return f"${name}"
|
||||
# An axis read is a different lookup against a different bucket, so it
|
||||
# branches BEFORE the var lookup — `$outfit.SHOES` must resolve even when
|
||||
# nothing bound `$outfit` itself, and must not raise `unknown_var` on the
|
||||
# way past.
|
||||
axis = tok.meta.get("axis")
|
||||
if axis:
|
||||
return _resolve_axis(tok, ctx, str(axis))
|
||||
value = ctx.get_var(name)
|
||||
if value is None:
|
||||
_push_warning(
|
||||
@@ -125,6 +132,81 @@ def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
|
||||
return deref_var_value(value, tok.meta.get("index"))
|
||||
|
||||
|
||||
def _resolve_axis(tok: Token, ctx: ResolveContext, axis: str) -> str:
|
||||
"""Render `$name.AXIS`, `$name.K.AXIS` and `$name.AXIS.K`.
|
||||
|
||||
Never re-rolls: the choice was made when the wildcard picked, and this
|
||||
reads it back — that is what makes two modules, or two nodes, agree.
|
||||
|
||||
Deliberately does NOT route through `deref_var_value`. An axis value is not
|
||||
a ListVar, and keeping it off that path leaves the SP2a accessor contract
|
||||
untouched.
|
||||
"""
|
||||
name = str(tok.meta.get("name", ""))
|
||||
got = ctx.get_axis(name, axis)
|
||||
if got is None:
|
||||
declared = ctx.declared_axes(name)
|
||||
if declared is not None and axis in declared:
|
||||
# The axis exists; this roll's option just carries no tag on it.
|
||||
# Legitimate (the editor already advises on such options), so it
|
||||
# is an info note, and asking whether the group is marked
|
||||
# `accepts` would send the user to fix something that is fine.
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="axis_untagged_pick",
|
||||
severity="info",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"name": name, "axis": axis, "surface": ctx.surface},
|
||||
message=(
|
||||
f"${name}.{axis} is empty: the picked ${name} option "
|
||||
f"has no '{axis}' tag"
|
||||
),
|
||||
)
|
||||
return ""
|
||||
if declared:
|
||||
hint = (
|
||||
f"its wildcard's accepts groups are {', '.join(declared)}. "
|
||||
f"Is '{axis}' a classify group, or misspelled?"
|
||||
)
|
||||
elif declared is not None:
|
||||
hint = (
|
||||
"its wildcard has no accepts groups. Is that tag group "
|
||||
"marked 'accepts'?"
|
||||
)
|
||||
else:
|
||||
hint = "is that tag group marked 'accepts'?"
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="unknown_tag_axis",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={
|
||||
"name": name, "axis": axis, "surface": ctx.surface,
|
||||
"declared": declared,
|
||||
},
|
||||
message=f"${name} has no '{axis}' axis — {hint}",
|
||||
)
|
||||
return ""
|
||||
index = tok.meta.get("index")
|
||||
if isinstance(got, list):
|
||||
if index is None:
|
||||
return ", ".join(g for g in got if g)
|
||||
if isinstance(index, int) and 0 <= index < len(got):
|
||||
return got[index] or ""
|
||||
return ""
|
||||
# A single pick behaves as a one-element list, mirroring deref_var_value so
|
||||
# `$x.0.AXIS` == `$x.AXIS` and any higher index is empty.
|
||||
if index is not None:
|
||||
return str(got) if index == 0 else ""
|
||||
return str(got)
|
||||
|
||||
|
||||
def _push_warning(ctx: ResolveContext, **fields) -> None:
|
||||
"""Append a warning dict to ctx.warnings. Helper to keep call sites tidy."""
|
||||
ctx.warnings.append(fields)
|
||||
@@ -140,7 +222,13 @@ def _pick_weighted(options: list[dict], rng) -> dict | None:
|
||||
return None
|
||||
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
|
||||
if total <= 0:
|
||||
return options[0]
|
||||
# Nothing is selectable — every option is either weight 0 (documented
|
||||
# as "disable without deleting") or was excluded by a constraint.
|
||||
# Returning options[0] here looked like a working pick: a real option,
|
||||
# right shape, right place, and wrong. It stayed wrong silently until
|
||||
# the list was reordered. Callers turn None into an empty binding,
|
||||
# which is what the null option already means.
|
||||
return None
|
||||
r = rng.random() * total
|
||||
acc = 0.0
|
||||
for opt in options:
|
||||
@@ -467,6 +555,7 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
|
||||
apply_constraints_for_target,
|
||||
warn_excludes_all,
|
||||
)
|
||||
from engine.modules.wildcard_handler import _accepts_axes
|
||||
# SP3: share the ctx-resident hit counter so a nested-ref
|
||||
# target occurrence counts toward the same first/next
|
||||
# coverage as direct top-level instances. `carrier_ctx` is
|
||||
@@ -484,6 +573,13 @@ def ref_option_pool(tok: Token, ctx: ResolveContext) -> list[dict]:
|
||||
options, any_applied = apply_constraints_for_target(
|
||||
options, uuid, constraints, get_picks(), ctx.warnings,
|
||||
hits=hits, firing_uid=None, carrier_ctx=carrier_ctx,
|
||||
# TARGET-side accepts axes for a nested @{} target, so its
|
||||
# multi-tag accepts option survives when the source allows any
|
||||
# alternative (same as the top-level path).
|
||||
target_axes=(
|
||||
_accepts_axes(payload_dict)
|
||||
if isinstance(payload_dict, dict) else {}
|
||||
),
|
||||
)
|
||||
if any_applied:
|
||||
warn_excludes_all(options, uuid, ctx.warnings)
|
||||
|
||||
@@ -13,10 +13,21 @@ import re
|
||||
|
||||
from engine.syntax.types import Token, TokenKind
|
||||
|
||||
# `$name` or `$name.K` — identifier starts with letter or underscore, then
|
||||
# word chars, with an optional `.K` list-index accessor (SP2a). Group 2 = the
|
||||
# index digits when present.
|
||||
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?")
|
||||
# `$name` with an optional accessor pair: at most one pick index and at most
|
||||
# one axis, in EITHER order (`$o.0.SHOES` == `$o.SHOES.0`). Two mirrored
|
||||
# alternatives, so `.0.1` and `.SHOES.BELTS` simply do not match the second
|
||||
# segment — the grammar rejects them without a validation pass, and the tail
|
||||
# stays literal text.
|
||||
# group 1 — name
|
||||
# group 2 — index, written first ($o.0 / $o.0.SHOES)
|
||||
# group 3 — axis after an index ($o.0.SHOES)
|
||||
# group 4 — axis, written first ($o.SHOES / $o.SHOES.0)
|
||||
# group 5 — index after an axis ($o.SHOES.0)
|
||||
_VAR_RE = re.compile(
|
||||
r"\$([A-Za-z_][A-Za-z0-9_]*)"
|
||||
r"(?:\.(?:(\d+)(?:\.([A-Za-z_][A-Za-z0-9_]*))?"
|
||||
r"|([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?))?"
|
||||
)
|
||||
# 4-segment ref grammar (fixed order): `@{8hex [#name] [:expr] [!null]}`.
|
||||
# group 1 — uuid: exactly 8 lowercase hex chars.
|
||||
# group 2 — optional `#name`: display label cached at write-time so a
|
||||
@@ -186,8 +197,14 @@ def tokenize_text(text: str) -> list[Token]:
|
||||
if m:
|
||||
_flush_text(i)
|
||||
var_meta: dict[str, object] = {"name": m.group(1)}
|
||||
if m.group(2) is not None:
|
||||
var_meta["index"] = int(m.group(2))
|
||||
# Normalise both spellings here, so nothing downstream ever
|
||||
# learns which order the user wrote.
|
||||
_idx = m.group(2) or m.group(5)
|
||||
_axis = m.group(3) or m.group(4)
|
||||
if _idx is not None:
|
||||
var_meta["index"] = int(_idx)
|
||||
if _axis is not None:
|
||||
var_meta["axis"] = _axis
|
||||
out.append(Token(
|
||||
kind=TokenKind.VAR,
|
||||
raw=m.group(0),
|
||||
|
||||
@@ -93,6 +93,36 @@ def split_var_accessor(name: str) -> tuple[str, int | None]:
|
||||
return (name, None)
|
||||
|
||||
|
||||
_VAR_REF_RE = re.compile(
|
||||
r"^([A-Za-z_][A-Za-z0-9_]*)"
|
||||
r"(?:\.(?:(\d+)(?:\.([A-Za-z_][A-Za-z0-9_]*))?"
|
||||
r"|([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?))?$"
|
||||
)
|
||||
|
||||
|
||||
def parse_var_reference(name: str) -> tuple[str, int | None, str | None]:
|
||||
"""Split a bare var reference into ``(base, index, axis)``.
|
||||
|
||||
The string-path twin of the tokenizer's ``_VAR_RE`` — derivation conditions
|
||||
store ``condition.var`` as text, not tokens. Kept separate from
|
||||
:func:`split_var_accessor` so the SP2a list-accessor contract and its
|
||||
existing callers are untouched.
|
||||
|
||||
Both accessor orders are accepted and normalise to the same triple:
|
||||
``outfit.0.SHOES`` and ``outfit.SHOES.0`` are the same reference. A name
|
||||
that matches nothing is returned whole with both accessors ``None``, so an
|
||||
unusual ctx key resolves as itself rather than being truncated.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return ("", None, None)
|
||||
m = _VAR_REF_RE.match(name)
|
||||
if not m:
|
||||
return (name, None, None)
|
||||
idx = m.group(2) or m.group(5)
|
||||
axis = m.group(3) or m.group(4)
|
||||
return (m.group(1), int(idx) if idx is not None else None, axis)
|
||||
|
||||
|
||||
def deref_var_value(value: str | ListVar | None, index: int | None) -> str:
|
||||
"""Render a resolved var value to a string, honoring an optional ``.K``
|
||||
list accessor (SP2a). ListVar: bare -> ``sep.join(items)``; ``.K`` ->
|
||||
@@ -134,6 +164,24 @@ class ResolveContext(Protocol):
|
||||
binds a ``str``."""
|
||||
...
|
||||
|
||||
def get_axis(self, name: str, axis: str) -> str | list[str | None] | None:
|
||||
"""Return the tag rolled for `name`'s `axis`, or None if it has none.
|
||||
|
||||
A single-pick source yields a ``str``; a multi-select one yields a
|
||||
list with one slot per pick in pick order (``None`` for a pick whose
|
||||
option carries no tag on this axis, so indexes line up with the
|
||||
variable's own). The value was rolled
|
||||
when the wildcard picked — this only reads it back, which is what makes
|
||||
two modules (or two nodes) agree.
|
||||
"""
|
||||
...
|
||||
|
||||
def declared_axes(self, name: str) -> list[str] | None:
|
||||
"""Return the ``accepts`` axes the wildcard bound to `name` declares,
|
||||
or None when that is not known. Lets an empty axis read tell "this
|
||||
pick carries no tag on the axis" apart from "there is no such axis"."""
|
||||
...
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
"""Return the module catalog row for `uuid`, or None if missing.
|
||||
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "2.13.2",
|
||||
"version": "2.16.0",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
@@ -9,10 +9,11 @@
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode extension",
|
||||
"build:extension": "vite build --mode extension && pnpm docs:copy",
|
||||
"build:manager": "vite build --mode manager",
|
||||
"build:extension": "vite build --mode extension && pnpm docs:copy && pnpm fonts:copy && node scripts/write-asset-manifest.mjs js",
|
||||
"build:manager": "vite build --mode manager && node scripts/write-asset-manifest.mjs web",
|
||||
"build": "pnpm build:extension && pnpm build:manager",
|
||||
"docs:copy": "node scripts/copy-help-docs.mjs",
|
||||
"fonts:copy": "node scripts/copy-fonts.mjs",
|
||||
"docs:figures": "node scripts/build-flow-figures.mjs",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,vue}\"",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "2.13.2"
|
||||
version = "2.16.0"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the booru tag list the optional value autocomplete reads.
|
||||
|
||||
Output is the four-column shape `wp_api/_tag_index.py` parses:
|
||||
|
||||
name,category,post_count,"alias,alias"
|
||||
|
||||
Run by `.github/workflows/tag-list.yml`, not at install time. The result ships
|
||||
as a release asset — never in the repository (it regenerates, and a 6 MB file
|
||||
per refresh would bloat history forever) and never in the package (a usable
|
||||
list dwarfs the whole gzipped JS budget).
|
||||
|
||||
WHY A POST-COUNT FLOOR AND NOT A PAGE COUNT
|
||||
-------------------------------------------
|
||||
`search[order]=count` sorts strictly descending ACROSS pages, verified against
|
||||
the live API on 2026-08-06:
|
||||
|
||||
page 1: first 8,244,950 last 55,918
|
||||
page 100: first 68 last 67
|
||||
page 800: first 1 last 1
|
||||
|
||||
So a popular tag cannot hide on a later page, and stopping once counts fall
|
||||
below the floor provably loses nothing — it is an early exit, not a filter.
|
||||
Walking all 1000 allowed pages spends ~900 requests and a quarter of an hour
|
||||
collecting single-post tags nobody will ever autocomplete.
|
||||
|
||||
For reference, ComfyUI-Custom-Scripts ships ~100k tags, which lands around a
|
||||
floor of 68.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
|
||||
BASE = "https://danbooru.donmai.us"
|
||||
# MUST stay in bare `product/version` form. Danbooru sits behind a bot filter
|
||||
# that 403s anything else, measured 2026-08-06:
|
||||
#
|
||||
# wildcard-pipeline-taglist/1.0 -> 200
|
||||
# wp-tag-list/1.0 -> 200
|
||||
# ComfyUI-Wildcard-Pipeline -> 403 (no version)
|
||||
# ComfyUI-Wildcard-Pipeline/1.0 -> 403
|
||||
# wp-tag-list/1.0 (+https://github.com/DumiFlex/...) -> 403 (URL present)
|
||||
#
|
||||
# So do NOT "improve" this by adding a contact URL or a longer description.
|
||||
# That is the normal courtesy for a scraper and it is exactly what gets this
|
||||
# blocked; the version suffix is what makes it acceptable.
|
||||
USER_AGENT = "wildcard-pipeline-taglist/1.0"
|
||||
PAGE_LIMIT = 1000
|
||||
# The API refuses page > 1000 outright (HTTP 410, "You cannot go beyond page
|
||||
# 1000"), so this is the ceiling whatever the floor says.
|
||||
MAX_PAGE = 1000
|
||||
|
||||
|
||||
def fetch(path: str, params: dict[str, str], *, retries: int = 4) -> list[dict]:
|
||||
"""GET a JSON array, retrying on transient failures.
|
||||
|
||||
The original script this replaces aborted on the first non-200, which for
|
||||
a 100-request run means one blip discards everything collected so far.
|
||||
"""
|
||||
url = f"{BASE}{path}?{urllib.parse.urlencode(params)}"
|
||||
delay = 2.0
|
||||
for attempt in range(retries):
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as err:
|
||||
# 410 is the deep-pagination refusal: retrying cannot help.
|
||||
if err.code == 410:
|
||||
return []
|
||||
if err.code not in (429, 500, 502, 503, 504) or attempt == retries - 1:
|
||||
raise
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
||||
if attempt == retries - 1:
|
||||
raise
|
||||
print(f" retry in {delay:.0f}s ...", flush=True)
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
return []
|
||||
|
||||
|
||||
def fetch_tags(min_count: int, pause: float) -> list[dict]:
|
||||
"""Tags ordered by post count, stopping once they fall below `min_count`."""
|
||||
out: list[dict] = []
|
||||
for page in range(1, MAX_PAGE + 1):
|
||||
rows = fetch("/tags.json", {
|
||||
"limit": str(PAGE_LIMIT),
|
||||
"search[hide_empty]": "yes",
|
||||
"search[is_deprecated]": "no",
|
||||
"search[order]": "count",
|
||||
"page": str(page),
|
||||
})
|
||||
if not rows:
|
||||
print(f" page {page}: empty, stopping", flush=True)
|
||||
break
|
||||
kept = [r for r in rows if r.get("post_count", 0) >= min_count]
|
||||
out.extend(kept)
|
||||
print(
|
||||
f" page {page:>4}: +{len(kept):>4} "
|
||||
f"(lowest {rows[-1].get('post_count', 0):,}) total {len(out):,}",
|
||||
flush=True,
|
||||
)
|
||||
if len(kept) < len(rows):
|
||||
# Descending order means everything after this point is below the
|
||||
# floor too. Nothing worth having is left.
|
||||
print(f" reached the floor of {min_count} posts", flush=True)
|
||||
break
|
||||
time.sleep(pause)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_aliases(pause: float, max_pages: int) -> dict[str, list[str]]:
|
||||
"""`canonical -> [alias, ...]`, from the separate aliases endpoint.
|
||||
|
||||
`tags.json` carries no aliases, and they are what let someone type `hires`
|
||||
and land on `highres` — the single most valuable thing in the file after
|
||||
the names themselves.
|
||||
"""
|
||||
mapping: dict[str, list[str]] = defaultdict(list)
|
||||
for page in range(1, max_pages + 1):
|
||||
rows = fetch("/tag_aliases.json", {
|
||||
"limit": str(PAGE_LIMIT),
|
||||
"search[status]": "active",
|
||||
"page": str(page),
|
||||
})
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
antecedent = row.get("antecedent_name")
|
||||
consequent = row.get("consequent_name")
|
||||
if isinstance(antecedent, str) and isinstance(consequent, str):
|
||||
mapping[consequent].append(antecedent)
|
||||
print(f" aliases page {page:>3}: {len(mapping):,} tags covered", flush=True)
|
||||
time.sleep(pause)
|
||||
return mapping
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument(
|
||||
"--min-count", type=int, default=20,
|
||||
help="stop once tags drop below this many posts (default: 20)",
|
||||
)
|
||||
ap.add_argument("--out", default="wildcard-pipeline-tags.csv")
|
||||
ap.add_argument(
|
||||
"--pause", type=float, default=1.0,
|
||||
help="seconds between requests (default: 1.0)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--alias-pages", type=int, default=60,
|
||||
help="pages of aliases to fetch (default: 60)",
|
||||
)
|
||||
ap.add_argument("--no-aliases", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
print(f"Fetching tags with at least {args.min_count} posts ...", flush=True)
|
||||
tags = fetch_tags(args.min_count, args.pause)
|
||||
if not tags:
|
||||
print("No tags fetched — refusing to write an empty list.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
aliases: dict[str, list[str]] = {}
|
||||
if not args.no_aliases:
|
||||
print("Fetching aliases ...", flush=True)
|
||||
aliases = fetch_aliases(args.pause, args.alias_pages)
|
||||
|
||||
with open(args.out, "w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.writer(fh)
|
||||
for tag in tags:
|
||||
name = tag.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
writer.writerow([
|
||||
name,
|
||||
tag.get("category", 0),
|
||||
tag.get("post_count", 0),
|
||||
",".join(aliases.get(name, [])),
|
||||
])
|
||||
|
||||
print(
|
||||
f"\nWrote {len(tags):,} tags to {args.out} "
|
||||
f"({sum(len(v) for v in aliases.values()):,} aliases)",
|
||||
flush=True,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -247,7 +247,17 @@ const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
// previous bump left 179 bytes free, which meant every subsequent change had
|
||||
// to argue with the gate. Explicitly approved by the maintainer, who asked for
|
||||
// the budget to be raised as needed rather than features trimmed to fit.
|
||||
const TOTAL_LIMIT = 490 * 1024; // 490 KB
|
||||
// LOWERED 490 -> 420 KB on 2026-08-06, after the webfonts stopped being bundled.
|
||||
// Vite library mode ignores `assetsInlineLimit` and base64-inlined both woff2
|
||||
// files into a JS chunk; they are now copied to js/assets/fonts/ and <link>ed at
|
||||
// runtime (see src/extension/fonts.ts). Measured 492,407 -> 403,055 bytes.
|
||||
//
|
||||
// Tightened rather than banked, because the gate is what stops it silently
|
||||
// regressing: re-inlining the fonts would add ~90 KB, which fits under 490 KB
|
||||
// and would have passed unnoticed. It does not fit under 420 KB. The 17 KB of
|
||||
// headroom left is deliberate — enough for ordinary work, not enough to hide a
|
||||
// whole asset class coming back.
|
||||
const TOTAL_LIMIT = 420 * 1024; // 420 KB
|
||||
|
||||
function gzipSize(path) {
|
||||
return gzipSync(readFileSync(path)).length;
|
||||
@@ -259,7 +269,7 @@ function allJsFiles(dir) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...allJsFiles(full));
|
||||
} else if (name.endsWith(".js")) {
|
||||
} else if (name.endsWith(".js") || name.endsWith(".mjs")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
@@ -281,7 +291,9 @@ for (const file of files) {
|
||||
// content edit and would otherwise show every chunk as added/removed.
|
||||
// Normalize Windows backslashes so the manifest diffs cleanly against
|
||||
// CI runs (which always emit forward slashes on Linux).
|
||||
const stable = rel.replace(/-[A-Za-z0-9_-]{8,}\.js$/, ".js").replaceAll("\\", "/");
|
||||
// Lazy chunks are `.mjs` (see vite.config.mts); report them under `.js` so
|
||||
// manifests from before that switch still diff chunk-for-chunk.
|
||||
const stable = rel.replace(/-[A-Za-z0-9_-]{8,}\.m?js$/, ".js").replaceAll("\\", "/");
|
||||
manifest.files[stable] = size;
|
||||
console.log(` ${rel.padEnd(40)} ${(size / 1024).toFixed(2).padStart(7)} KB`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
// Copy the webfont directory verbatim from src/assets/fonts/ to
|
||||
// js/assets/fonts/, so ComfyUI serves it under
|
||||
// /extensions/<package>/assets/fonts/.
|
||||
//
|
||||
// These files deliberately do NOT go through Vite. Library mode always
|
||||
// base64-inlines assets — `build.assetsInlineLimit` is documented as ignored
|
||||
// when `build.lib` is set — so a woff2 reachable from the module graph ends up
|
||||
// ~33% larger, inside a JS chunk, parsed as JavaScript. Copying sidesteps the
|
||||
// bundler entirely; `src/extension/fonts.ts` <link>s the stylesheet at runtime
|
||||
// and the browser resolves the woff2 URLs relative to it.
|
||||
//
|
||||
// Run as part of `pnpm build:extension`, alongside copy-help-docs.mjs.
|
||||
|
||||
import { readdirSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
||||
const SRC = join(ROOT, "src", "assets", "fonts");
|
||||
const DST = join(ROOT, "js", "assets", "fonts");
|
||||
|
||||
try {
|
||||
rmSync(DST, { recursive: true, force: true });
|
||||
mkdirSync(DST, { recursive: true });
|
||||
const files = readdirSync(SRC);
|
||||
if (!files.some((f) => f.endsWith(".css"))) {
|
||||
// A missing stylesheet is silent at runtime — the <link> 404s and text
|
||||
// quietly falls back to the system stack. Fail the build instead.
|
||||
throw new Error(`no stylesheet in ${SRC}; fonts.ts expects wp-fonts.css`);
|
||||
}
|
||||
for (const name of files) {
|
||||
copyFileSync(join(SRC, name), join(DST, name));
|
||||
console.log(` src/assets/fonts/${name} → js/assets/fonts/${name}`);
|
||||
}
|
||||
console.log(`Copied ${files.length} font file(s).`);
|
||||
} catch (err) {
|
||||
console.error("[copy-fonts] failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -61,12 +61,17 @@ _SAMPLES: dict[str, tuple[type, dict]] = {
|
||||
{
|
||||
"var_binding": "mood",
|
||||
"sub_categories": ["soft", "bold"],
|
||||
# Tag axes: an `accepts` group must survive the round-trip, so the
|
||||
# sample carries both the group map and its kind. A stripped
|
||||
# tag_group_kinds silently degrades the axis to classify.
|
||||
"tag_groups": {"TEXTURE": ["soft", "bold"]},
|
||||
"tag_group_kinds": {"TEXTURE": "accepts"},
|
||||
"options": [
|
||||
{"id": "opt00001", "value": "serene", "weight": 1, "sub_category": "soft"},
|
||||
{"id": "opt00001", "value": "serene", "weight": 1, "sub_categories": ["soft"]},
|
||||
# Null option: the is_null contract. Must round-trip
|
||||
# through the validator or install rebuilds it as an
|
||||
# invalid empty-value option.
|
||||
{"id": "opt00002", "value": "", "weight": 1, "sub_category": None, "is_null": True},
|
||||
{"id": "opt00002", "value": "", "weight": 1, "sub_categories": [], "is_null": True},
|
||||
],
|
||||
},
|
||||
),
|
||||
@@ -112,6 +117,9 @@ _SAMPLES: dict[str, tuple[type, dict]] = {
|
||||
"target_wildcard_id": "tgt00001",
|
||||
"exceptions": [],
|
||||
"matrix": {"soft": {"bold": {"mode": "boost", "factor": 3}}},
|
||||
# SP3 reach selector — must survive the round-trip, not strip back
|
||||
# to the {mode: "all"} default.
|
||||
"target_select": {"mode": "next", "count": 2},
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
// Record exactly which files a clean build produced, so the plugin can delete
|
||||
// everything else on the next ComfyUI start.
|
||||
//
|
||||
// WHY THIS EXISTS. Our chunks are content-hashed, so every release emits new
|
||||
// filenames. ComfyUI Manager updates by extracting the new package OVER the
|
||||
// installed folder — it never removes files the new version dropped. The two
|
||||
// behaviours are individually reasonable and together they leak: measured on a
|
||||
// real 2.13.1 install (2026-08-06), `js/` held 237 chunks where 58 were live
|
||||
// and `web/` held 1,256 files where 136 were, for 54.6 MB total.
|
||||
//
|
||||
// It goes unnoticed in development because `emptyOutDir` wipes the output
|
||||
// directory on every local build. Only installs that UPDATE accumulate.
|
||||
//
|
||||
// The manifest lists paths relative to the directory it sits in. Written last,
|
||||
// after the copy steps, so it captures docs and fonts as well as chunks.
|
||||
//
|
||||
// Usage: node scripts/write-asset-manifest.mjs <dir>
|
||||
|
||||
import { readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
||||
const target = process.argv[2];
|
||||
if (!target) {
|
||||
console.error("usage: write-asset-manifest.mjs <dir> (e.g. js, web)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dir = join(ROOT, target);
|
||||
const MANIFEST = ".wp-assets.json";
|
||||
|
||||
function walk(current) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(current)) {
|
||||
const full = join(current, name);
|
||||
if (statSync(full).isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
try {
|
||||
const files = walk(dir)
|
||||
// Always use forward slashes: the manifest is written on whatever machine
|
||||
// builds the release and read on the user's, which may not be the same OS.
|
||||
.map((p) => relative(dir, p).split(sep).join("/"))
|
||||
.filter((p) => p !== MANIFEST)
|
||||
.sort();
|
||||
writeFileSync(
|
||||
join(dir, MANIFEST),
|
||||
`${JSON.stringify({ files }, null, 0)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
console.log(` ${target}/${MANIFEST} — ${files.length} files recorded`);
|
||||
} catch (err) {
|
||||
console.error("[write-asset-manifest] failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
/* Webfonts for the canvas extension — JetBrains Mono, latin subset,
|
||||
* variable axis 100-900.
|
||||
*
|
||||
* NO INTER HERE, deliberately. ComfyUI's own frontend serves an Inter that is
|
||||
* identical to the one we used to ship — same family name, same
|
||||
* `font-weight: 100 900` variable axis, same latin woff2, same
|
||||
* `font-display: swap` — and it declares it in its MAIN stylesheet, so it is
|
||||
* already loaded on every page a canvas widget can appear on. We were shipping
|
||||
* and downloading a 48 KB duplicate.
|
||||
*
|
||||
* The coupling this creates is on the FAMILY NAME, not on their URL: our
|
||||
* `--wp-font-sans` stack names `Inter` and the browser resolves it against
|
||||
* whatever `Inter` face is loaded, so their file being renamed, re-subset or
|
||||
* re-hashed is invisible to us. Their copy also lives in `public/fonts/`,
|
||||
* which Vite serves un-hashed precisely so the URL stays stable — hashed
|
||||
* assets go to `assets/`.
|
||||
*
|
||||
* Evidence for the bet, since "rely on a host asset" deserves some: added
|
||||
* 2025-09-16 in Comfy-Org/ComfyUI_frontend 4ff18fd ("Add Inter font used by
|
||||
* Figma designs", #5535) and untouched since — one commit in its whole
|
||||
* history. It is the typeface of their entire UI, so removing it means
|
||||
* restyling their product, not dropping an optional extra.
|
||||
*
|
||||
* And if they do remove it, nothing breaks loudly: the stack falls through to
|
||||
* `-apple-system, Segoe UI, Roboto, sans-serif`, which is the behaviour
|
||||
* `font-display: swap` already shows during every cold load.
|
||||
*
|
||||
* JetBrains Mono stays. ComfyUI ships no monospace face.
|
||||
*
|
||||
* This file is NOT processed by Vite. `scripts/copy-fonts.mjs` copies this
|
||||
* directory verbatim into `js/assets/fonts/`, and `src/extension/fonts.ts`
|
||||
* injects a <link> to it at runtime.
|
||||
*
|
||||
* Why not an import: Vite library mode ALWAYS base64-inlines assets and
|
||||
* ignores `build.assetsInlineLimit` entirely, so any woff2 reachable from a
|
||||
* module graph becomes ~33% larger and lands inside a JS chunk — parsed as
|
||||
* JavaScript, re-downloaded whenever that chunk's hash changes, and counted
|
||||
* against the bundle-size gate. Keeping the fonts outside the graph is the
|
||||
* only way to ship them as fonts.
|
||||
*
|
||||
* The `src` URLs below are relative to THIS FILE, so the browser resolves
|
||||
* them against the stylesheet's own location. No path arithmetic in JS.
|
||||
*
|
||||
* font-display: swap renders the system stack first and re-paints when the
|
||||
* woff2 lands, so text is never invisible during the fetch.
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src:
|
||||
url("./jetbrains-mono-latin.woff2") format("woff2-variations"),
|
||||
url("./jetbrains-mono-latin.woff2") format("woff2");
|
||||
}
|
||||
@@ -452,3 +452,45 @@ describe("AssemblerHelper var-color rendering", () => {
|
||||
expect(wrapper.find('[data-test="asm-save-template"]').attributes("disabled")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AssemblerHelper $var.AXIS preview (tag axes)", () => {
|
||||
const base = {
|
||||
upstreamVars: ["outfit", "shoes"],
|
||||
templateVars: ["outfit", "shoes"],
|
||||
resolvedMap: { outfit: "robe", shoes: "red stilettos" },
|
||||
varAxes: { outfit: { SHOES: "sandals" } },
|
||||
};
|
||||
const preview = (props: Record<string, unknown>) =>
|
||||
mount(AssemblerHelper, { props: { ...base, ...props } as never })
|
||||
.find('[data-test="asm-preview"]').text();
|
||||
|
||||
it("reads the tag the preview run rolled, not the axis's first tag", () => {
|
||||
expect(preview({
|
||||
template: "$outfit.SHOES | $outfit",
|
||||
resolvedMap: { outfit: "summer dress", shoes: "red stilettos" },
|
||||
rolledAxes: { outfit: { SHOES: "heels" } },
|
||||
})).toBe("heels | summer dress");
|
||||
});
|
||||
|
||||
it("renders empty when the picked option carries no tag on the axis", () => {
|
||||
// The reported preview: "sandals | robe" while the prompt said "| robe".
|
||||
expect(preview({ template: "$outfit.SHOES | $outfit", rolledAxes: { outfit: {} } }))
|
||||
.toBe("| robe");
|
||||
});
|
||||
|
||||
it("guesses the first tag only until the preview run answers", () => {
|
||||
expect(preview({ template: "$outfit.SHOES" })).toBe("sandals");
|
||||
});
|
||||
|
||||
it("keeps an undeclared axis raw so the gap stays visible", () => {
|
||||
expect(preview({ template: "$outfit.BELTS", rolledAxes: { outfit: {} } }))
|
||||
.toBe("$outfit.BELTS");
|
||||
});
|
||||
|
||||
it("indexes a multi-pick positionally in either accessor order", () => {
|
||||
const rolledAxes = { outfit: [{}, { SHOES: "boots" }] };
|
||||
expect(preview({ template: "[$outfit.0.SHOES] [$outfit.1.SHOES] [$outfit.SHOES.1]", rolledAxes }))
|
||||
.toBe("[] [boots] [boots]");
|
||||
expect(preview({ template: "$outfit.SHOES", rolledAxes })).toBe("boots");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { varColorClass } from "../shared/var-color";
|
||||
import { modelSyntaxHtml } from "../../widgets/richTokenize";
|
||||
import ContextMenu, { type ContextMenuItem } from "../shared/ContextMenu.vue";
|
||||
import { kindIcon, type WpKind } from "../shared/kind-icons";
|
||||
import { applyVarAccessor, type ResolvedValue } from "../../widgets/richTokenize";
|
||||
import {
|
||||
applyVarAccessor,
|
||||
readRolledAxis,
|
||||
varAccessorParts,
|
||||
type ResolvedValue,
|
||||
type RolledAxes,
|
||||
} from "../../widgets/richTokenize";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Flat array of upstream variable names. Primary prop shape. */
|
||||
@@ -17,6 +24,16 @@ const props = defineProps<{
|
||||
* precedence over `resolved`.
|
||||
*/
|
||||
resolvedMap?: Record<string, ResolvedValue>;
|
||||
/** `{ varName: { AXIS: firstTag } }`. A `$var.AXIS` read resolves against
|
||||
* this, never against `resolvedMap` — the axis names a tag menu, not the
|
||||
* variable's text. */
|
||||
varAxes?: Record<string, Record<string, string>>;
|
||||
/** What the preview run actually rolled, keyed by binding (the preview
|
||||
* endpoint's `axes`). Preferred over `varAxes` for any axis the chain
|
||||
* declares, because `varAxes` only knows the axis's first tag — it printed
|
||||
* "sandals" beside an option with no shoes and beside a pick whose
|
||||
* constrained target had rolled heels. */
|
||||
rolledAxes?: Record<string, RolledAxes>;
|
||||
/**
|
||||
* Pre-resolved string — template with $var replaced by resolved values.
|
||||
* Legacy fallback when `resolvedMap` not provided. Suffers from
|
||||
@@ -85,7 +102,12 @@ const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
// SP2a: capture an optional `.K` list accessor (group 2) so `$mood.0` consumes
|
||||
// the whole token — `m[0].length` then advances past `.K` (no stranded ".0"
|
||||
// literal) and group 2 drives applyVarAccessor in the substitution below.
|
||||
const TEMPLATE_VAR_RE = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/g;
|
||||
// Groups: 1=name · 2=index · 3=axis. The accessor has to be part of the match
|
||||
// or the preview prints the resolved value and strands ".SHOES" after it.
|
||||
// Both accessor orders the engine accepts (`$o.0.SHOES`, `$o.SHOES.0`); the
|
||||
// match is re-split by `varAccessorParts`, the one canonical grammar.
|
||||
const TEMPLATE_VAR_RE =
|
||||
/(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(?:\d+(?:\.[A-Za-z_][A-Za-z0-9_]*)?|[A-Za-z_][A-Za-z0-9_]*(?:\.\d+)?))?/g;
|
||||
|
||||
/** Variable names referenced in the template. */
|
||||
const templateVarsInternal = computed(() => {
|
||||
@@ -134,6 +156,22 @@ watch(
|
||||
|
||||
interface PreviewToken { kind: "literal" | "var"; text: string; varName?: string }
|
||||
|
||||
/** An axis read resolves to a TAG. Falling through to the value is what
|
||||
* rendered "a white t-shirt and denim skirt.SHOES". An axis the chain does not
|
||||
* declare stays raw so the gap is visible rather than silently dropped; a
|
||||
* declared one reads what the preview run rolled (empty when the picked
|
||||
* option carries no tag on it, exactly as the prompt will), and only guesses
|
||||
* the axis's first tag until that run has answered. */
|
||||
function axisText(name: string, axis: string, index: number | undefined, raw: string): string {
|
||||
const guess = props.varAxes?.[name]?.[axis];
|
||||
if (guess === undefined) return raw;
|
||||
const rolled = props.rolledAxes;
|
||||
if (rolled && Object.prototype.hasOwnProperty.call(rolled, name)) {
|
||||
return readRolledAxis(rolled[name], axis, index);
|
||||
}
|
||||
return index != null && index > 0 ? "" : guess;
|
||||
}
|
||||
|
||||
const previewTokens = computed<PreviewToken[]>(() => {
|
||||
if (!props.template) return [];
|
||||
|
||||
@@ -145,17 +183,14 @@ const previewTokens = computed<PreviewToken[]>(() => {
|
||||
for (const m of props.template.matchAll(TEMPLATE_VAR_RE)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) tokens.push({ kind: "literal", text: props.template.slice(last, idx) });
|
||||
const name = m[1];
|
||||
const idxStr = m[2];
|
||||
const index = idxStr != null ? parseInt(idxStr, 10) : undefined;
|
||||
const raw = m[0];
|
||||
const { base: name, index, axis } = varAccessorParts(raw);
|
||||
const has = Object.prototype.hasOwnProperty.call(props.resolvedMap, name);
|
||||
// Resolved → join (bare $name) or index (.K) via the shared accessor;
|
||||
// unresolved → keep the raw `$name.K` so the user sees what's missing.
|
||||
tokens.push({
|
||||
kind: "var",
|
||||
text: has
|
||||
? applyVarAccessor(props.resolvedMap[name], index)
|
||||
: `$${name}${idxStr != null ? "." + idxStr : ""}`,
|
||||
text: axis
|
||||
? axisText(name, axis, index, raw)
|
||||
: (has ? applyVarAccessor(props.resolvedMap[name], index) : raw),
|
||||
varName: name,
|
||||
});
|
||||
last = idx + m[0].length;
|
||||
@@ -396,7 +431,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
@click="onLoadTemplate"
|
||||
>
|
||||
<i class="pi pi-folder-open" aria-hidden="true" />
|
||||
<span>Load</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="onSaveTemplate"
|
||||
@@ -409,7 +443,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
@click="onSaveTemplate"
|
||||
>
|
||||
<i class="pi pi-save" aria-hidden="true" />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="onClearTemplate"
|
||||
@@ -422,7 +455,6 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
@click="onClearTemplate"
|
||||
>
|
||||
<i class="pi pi-trash" aria-hidden="true" />
|
||||
<span>Clear template</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="wp-asm-section-stat">
|
||||
@@ -490,18 +522,22 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
</div>
|
||||
<div v-else key="tokens" class="wp-asm-preview__tokens">
|
||||
<template v-for="(tok, i) in previewTokens" :key="i">
|
||||
<span v-if="tok.kind === 'literal'" class="literal">{{ tok.text }}</span>
|
||||
<!-- Literal preview text still carries `<lora:…>` and
|
||||
`embedding:…`, which resolve to themselves rather than to a
|
||||
variable — so without this they were the only place the two
|
||||
syntaxes appeared uncoloured. Same function the editor uses,
|
||||
so the two cannot drift; it escapes everything it emits. -->
|
||||
<span
|
||||
v-if="tok.kind === 'literal'"
|
||||
class="literal"
|
||||
v-html="modelSyntaxHtml(tok.text)"
|
||||
></span>
|
||||
<span v-else :class="['res', varColorClass(tok.varName ?? '')]">{{ tok.text }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- hint -->
|
||||
<div class="wp-asm-hint">
|
||||
<span>click → insert <kbd>$var</kbd> · <kbd>Ctrl</kbd>+click → remove · right-click → more</span>
|
||||
<span style="margin-left: auto;">click missing → remove</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -588,11 +624,15 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
/* Icon-only. Each button already carried a `title` and an `aria-label`, so the
|
||||
visible word was the only thing dropped — and three words across the row
|
||||
were most of its width. Square-ish padding keeps the tap target honest. */
|
||||
.wp-asm-toolbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
padding: 4px 7px;
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: 3px;
|
||||
@@ -665,8 +705,11 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
.wp-asm-vars {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
/* On the project's own spacing scale rather than the bare 4/6 they were.
|
||||
At 4px the chips ran together into one block of texture — the strip read
|
||||
as a single control instead of a row of separate variables. */
|
||||
gap: var(--wp-space-3);
|
||||
padding: var(--wp-space-4);
|
||||
background: var(--wp-bg-deep, var(--wp-bg));
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
@@ -774,22 +817,4 @@ function openChipMenu(ev: MouseEvent, v: string, isMissing: boolean): void {
|
||||
}
|
||||
.wp-asm-preview__ghost-text { display: inline; }
|
||||
|
||||
.wp-asm-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font: 11px/1.3 var(--wp-font-sans);
|
||||
color: var(--wp-text-dim);
|
||||
}
|
||||
.wp-asm-hint kbd {
|
||||
background: var(--wp-bg-1);
|
||||
border: 1px solid var(--wp-border-soft);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 2px;
|
||||
font: 10px/1 var(--wp-font-mono);
|
||||
padding: 1px 4px;
|
||||
color: var(--wp-text-muted);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -198,9 +198,9 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
|
||||
return calls?.[calls.length - 1]?.[0] as ReturnType<typeof emptyContextLoopConfig>;
|
||||
};
|
||||
|
||||
it("shift-click locks the frame to the seed it would have used", async () => {
|
||||
it("alt-click locks the frame to the seed it would have used", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { altKey: true });
|
||||
const next = lastEmit(w);
|
||||
// Frame index 2, and the value must be a real derived seed — not a
|
||||
// placeholder, or the lock would silently change the output it froze.
|
||||
@@ -208,44 +208,44 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
|
||||
expect(Number.isInteger(next.seed_locks!["2"])).toBe(true);
|
||||
});
|
||||
|
||||
it("shift-click on a locked frame unlocks it", async () => {
|
||||
it("alt-click on a locked frame unlocks it", async () => {
|
||||
const w = fw({ seed_locks: { "2": 777 } });
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { altKey: true });
|
||||
expect(lastEmit(w).seed_locks).toEqual({});
|
||||
});
|
||||
|
||||
it("the chip-lock value matches what the modal's Lock all would write", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { shiftKey: true });
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
const chipValue = lastEmit(w).seed_locks!["1"];
|
||||
expect(chipValue).toBe(deriveLoopSeeds(42, 4, "hash_index")[1]);
|
||||
});
|
||||
|
||||
it("alt-click bypasses a frame, and again re-enables it", async () => {
|
||||
it("ctrl-click bypasses a frame, and again re-enables it", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1]);
|
||||
|
||||
const w2 = fw({ bypass_frames: [1] });
|
||||
await w2.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
await w2.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
|
||||
expect(lastEmit(w2).bypass_frames).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps bypassed indices sorted so the config does not churn", async () => {
|
||||
const w = fw({ bypass_frames: [3] });
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("refuses to bypass the last running frame — a loop needs one", async () => {
|
||||
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
|
||||
await w.find('[data-test="loop-frame-4"]').trigger("click", { altKey: true });
|
||||
await w.find('[data-test="loop-frame-4"]').trigger("click", { ctrlKey: true });
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still lets the last running frame be re-enabled and locked", async () => {
|
||||
const w = fw({ bypass_frames: [0, 1, 2] }, 4);
|
||||
await w.find('[data-test="loop-frame-1"]').trigger("click", { altKey: true });
|
||||
await w.find('[data-test="loop-frame-1"]').trigger("click", { ctrlKey: true });
|
||||
expect(lastEmit(w).bypass_frames).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
@@ -253,9 +253,9 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
|
||||
// kind of surprise that makes people stop trusting a shortcut.
|
||||
it("a modifier click never also moves the edit cursor", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { shiftKey: true });
|
||||
await w.find('[data-test="loop-frame-3"]').trigger("click", { altKey: true });
|
||||
expect(currentFrame.value).toBe(null);
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { altKey: true });
|
||||
await w.find('[data-test="loop-frame-2"]').trigger("click", { ctrlKey: true });
|
||||
expect(currentFrame.value).toBe(null);
|
||||
});
|
||||
|
||||
@@ -268,8 +268,8 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
|
||||
|
||||
it("base takes no modifiers — it has no seed and nothing to bypass", async () => {
|
||||
const w = fw();
|
||||
await w.find('[data-test="loop-frame-base"]').trigger("click", { shiftKey: true });
|
||||
await w.find('[data-test="loop-frame-base"]').trigger("click", { altKey: true });
|
||||
await w.find('[data-test="loop-frame-base"]').trigger("click", { ctrlKey: true });
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -293,7 +293,7 @@ describe("ContextLoopWidget frame chip modifier-clicks", () => {
|
||||
|
||||
it("advertises the combos in the widget, not just in tooltips", () => {
|
||||
const w = fw();
|
||||
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/shift/i);
|
||||
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt-click locks/i);
|
||||
expect(w.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt|option/i);
|
||||
});
|
||||
|
||||
|
||||
@@ -86,10 +86,10 @@ function onBypassFrames(next: number[]): void {
|
||||
* of frames is the common case; the modal earns its place for the bulk
|
||||
* operations (lock all, paste a series, out-of-range locks) and keeps them.
|
||||
*
|
||||
* Modifier choice: Alt and Shift both survive the trip. Ctrl is out because on
|
||||
* macOS Ctrl-click IS a right-click, so the combo would open a context menu
|
||||
* instead. Alt for bypass follows the seed modal's existing Alt-click-for-a-
|
||||
* variant idiom on its Copy button.
|
||||
* Modifier choice: Alt-click locks a seed, Ctrl-click bypasses — the reflex
|
||||
* pairing users reach for. On macOS Ctrl-click IS a right-click, so Cmd stands
|
||||
* in for bypass there (see FrameChips `isBypassChord`); Alt/Option survives the
|
||||
* trip on every platform.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** 0-based locked frame indices. Keys are strings in the config; anything
|
||||
|
||||
@@ -795,6 +795,28 @@ const resolvedForEditing = computed<Record<string, ResolvedValue>>(() => {
|
||||
* Built in the same pass as the name list so the two cannot disagree about
|
||||
* which module owns a name.
|
||||
*/
|
||||
/** The `accepts` axes a wildcard payload declares, with the tags each rolls
|
||||
* from. `hueIndex` counts EVERY group so a colour matches the wildcard
|
||||
* editor's. Mirrors `acceptsAxesOf` in `manager/utils/library-suggestions`;
|
||||
* duplicated rather than imported because that module pulls the whole module
|
||||
* store into the canvas bundle, which the size gate will not carry. */
|
||||
function acceptsAxesOfPayload(
|
||||
payload: unknown,
|
||||
): { axis: string; tags: string[]; hueIndex: number }[] | undefined {
|
||||
const p = (payload ?? {}) as {
|
||||
tag_groups?: Record<string, string[]>;
|
||||
tag_group_kinds?: Record<string, string>;
|
||||
};
|
||||
if (!p.tag_group_kinds) return undefined;
|
||||
const out: { axis: string; tags: string[]; hueIndex: number }[] = [];
|
||||
Object.entries(p.tag_groups ?? {}).forEach(([axis, tags], hueIndex) => {
|
||||
if (p.tag_group_kinds?.[axis] === "accepts") {
|
||||
out.push({ axis, tags: tags ?? [], hueIndex });
|
||||
}
|
||||
});
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
const siblingVarInfo = computed<{
|
||||
names: string[];
|
||||
producers: Record<string, VarProducerLike>;
|
||||
@@ -804,7 +826,10 @@ const siblingVarInfo = computed<{
|
||||
/** Last module to write each name, so repeat writes from one module (a
|
||||
* derivation's branches) do not inflate the override count. */
|
||||
const lastWriter = new Map<string, string>();
|
||||
let owner: { kind: string; moduleName: string; moduleId: string } | null = null;
|
||||
let owner: {
|
||||
kind: string; moduleName: string; moduleId: string;
|
||||
axes?: { axis: string; tags: string[]; hueIndex: number }[];
|
||||
} | null = null;
|
||||
function add(name: string | undefined | null): void {
|
||||
const trimmed = (name ?? "").replace(/^\$+/, "").trim();
|
||||
if (!trimmed) return;
|
||||
@@ -825,6 +850,12 @@ const siblingVarInfo = computed<{
|
||||
// would be noise when every sibling gives the same answer.
|
||||
nodeLabel: "this node",
|
||||
shadowed: prev ? (sameWriter ? prev.shadowed : prev.shadowed + 1) : 0,
|
||||
// `accepts` axes, so `$var.AXIS` completion works for a variable written
|
||||
// in THIS node. Same-node writers replace the upstream entry wholesale
|
||||
// in `allVarProducers`, so axes attached only during the graph walk were
|
||||
// dropped for exactly the common case: the wildcard and the template
|
||||
// that reads it sitting in one Context.
|
||||
...(owner.axes ? { axes: owner.axes } : {}),
|
||||
};
|
||||
}
|
||||
const editingM = editingModule.value;
|
||||
@@ -840,6 +871,10 @@ const siblingVarInfo = computed<{
|
||||
kind: m.type,
|
||||
moduleName: m.meta?.name ?? m.type,
|
||||
moduleId: m.id,
|
||||
// Read from the module's own payload snapshot — the node runs what it
|
||||
// captured, so this is the truth for THIS node regardless of what the
|
||||
// library says now.
|
||||
...(m.type === "wildcard" ? { axes: acceptsAxesOfPayload(m.payload) } : {}),
|
||||
};
|
||||
for (const e of m.entries) add(e.variable_name);
|
||||
const inst = (m.instance ?? {}) as {
|
||||
@@ -900,8 +935,16 @@ const allVarProducers = computed<Record<string, VarProducerLike>>(() => {
|
||||
const merged: Record<string, VarProducerLike> = { ...upstream };
|
||||
for (const [name, sib] of Object.entries(siblingVarInfo.value.producers)) {
|
||||
const up = upstream[name];
|
||||
// Same carry-forward as the graph walk: a same-node writer that declares
|
||||
// no axes must not erase the ones an upstream wildcard declared for this
|
||||
// binding — the engine keys `__wp_axes__` by binding, so the rolled axis
|
||||
// outlives a rebind.
|
||||
merged[name] = up
|
||||
? { ...sib, shadowed: sib.shadowed + up.shadowed + 1 }
|
||||
? {
|
||||
...sib,
|
||||
...(sib.axes ?? up.axes ? { axes: sib.axes ?? up.axes } : {}),
|
||||
shadowed: sib.shadowed + up.shadowed + 1,
|
||||
}
|
||||
: sib;
|
||||
}
|
||||
return merged;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref, type Ref } from "vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
import { checkInstalledVersion } from "./version-notice";
|
||||
|
||||
/**
|
||||
* Shape of the SnapshotEntry returned by `POST /wp/api/modules/embed-bundle`
|
||||
@@ -119,6 +120,7 @@ function sameModuleHashes(
|
||||
async function fetchModuleHashes(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch("/wp/api/modules/hashes");
|
||||
checkInstalledVersion(res);
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as {
|
||||
hashes?: Record<string, { type?: string; payload_hash: string }>;
|
||||
|
||||
@@ -29,6 +29,11 @@ export interface PreviewToken {
|
||||
varName?: string;
|
||||
/** SP2a `.K` list accessor on a var token (`$mood.0` -> index 0). */
|
||||
index?: number;
|
||||
/** Tag-axis accessor (`$outfit.SHOES` -> "SHOES"). Carried for the same
|
||||
* reason `index` is: without it the axis segment is not part of the var
|
||||
* token, so every consumer renders the variable and then the bare text
|
||||
* ".SHOES" beside it. */
|
||||
axis?: string;
|
||||
refUuid?: string;
|
||||
branches?: string[];
|
||||
count?: number;
|
||||
@@ -42,7 +47,11 @@ const REF_VALID_SURFACES = new Set<Surface>(["wildcard"]);
|
||||
// Group 2 = optional `.K` list accessor (SP2a) so `$mood.0` is ONE var token
|
||||
// (base name in m[1], index in m[2]) — mirrors engine tokenize.py +
|
||||
// richTokenize.ts, so the `.K` never strands as a separate text token.
|
||||
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/y;
|
||||
// Groups: 1=name · 2=index-first · 3=axis-after-index · 4=axis-first ·
|
||||
// 5=index-after-axis. Two mirrored alternatives because the accessor order is
|
||||
// free (`$v.0.AXIS` and `$v.AXIS.0` both parse) — same shape as richTokenize.
|
||||
const VAR_RE =
|
||||
/\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?(?:\.([A-Za-z_][A-Za-z0-9_]*))?|\$([A-Za-z_][A-Za-z0-9_]*)(?:\.([A-Za-z_][A-Za-z0-9_]*))?(?:\.(\d+))?/y;
|
||||
// Optional `:subcat[,subcat]` per-call sub-category filter — empty
|
||||
// filter is equivalent to no filter, sub-categories are stripped of
|
||||
// whitespace + comma-separated.
|
||||
@@ -90,6 +99,7 @@ export function tokenize(text: string, surface: Surface): PreviewToken[] {
|
||||
raw: m[0],
|
||||
varName: m[1],
|
||||
index: m[2] !== undefined ? Number.parseInt(m[2], 10) : undefined,
|
||||
axis: m[3],
|
||||
invalid: !VAR_VALID_SURFACES.has(surface),
|
||||
});
|
||||
i += m[0].length;
|
||||
|
||||
@@ -146,6 +146,15 @@ interface ResolvedToken {
|
||||
kind: "literal" | "var-resolved" | "var-unresolved";
|
||||
varName?: string;
|
||||
}
|
||||
/** First tag of `axis` on whichever producer writes `name`, or undefined when
|
||||
* that producer declares no such `accepts` axis — in which case the read
|
||||
* stays unresolved rather than silently losing the accessor. */
|
||||
function producerAxisTag(name: string, axis: string): string | undefined {
|
||||
const axes = varProducerMap.value.get(name)?.axes ?? [];
|
||||
const hit = axes.find((a: { axis: string; tags: string[] }) => a.axis === axis);
|
||||
return hit && hit.tags.length > 0 ? hit.tags[0] : undefined;
|
||||
}
|
||||
|
||||
const resolvedTokens = computed<ResolvedToken[]>(() => {
|
||||
const tokens: ResolvedToken[] = [];
|
||||
const map = props.upstreamResolved;
|
||||
@@ -159,6 +168,21 @@ const resolvedTokens = computed<ResolvedToken[]>(() => {
|
||||
// SP2a: resolve via the shared accessor so `$mood.0` indexes (and a
|
||||
// bare `$mood` joins a list) — the `.K` never strands as literal text.
|
||||
// Presence is keyed on the base name, not the value's type.
|
||||
if (tok.axis) {
|
||||
// An axis read resolves to a TAG, never to the variable's text.
|
||||
// Resolving it as a plain `$var` and letting the tokenizer's axis
|
||||
// segment fall through as literal is what produced
|
||||
// "a white t-shirt and denim skirt.SHOES" in both preview lines.
|
||||
// Runtime rolls one member per pick; with no rng here, show the
|
||||
// first — same convention the rest of the static preview uses.
|
||||
const tag = producerAxisTag(tok.varName, tok.axis);
|
||||
tokens.push(
|
||||
tag != null
|
||||
? { text: tag, kind: "var-resolved", varName: tok.varName }
|
||||
: { text: tok.raw, kind: "var-unresolved", varName: tok.varName },
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(map, tok.varName)) {
|
||||
tokens.push({
|
||||
text: applyVarAccessor(map[tok.varName], tok.index),
|
||||
|
||||
@@ -3,22 +3,36 @@ import { describe, it, expect } from "vitest";
|
||||
import CellRulePopover from "../../../../../src/components/context/editors/constraint/CellRulePopover.vue";
|
||||
|
||||
describe("CellRulePopover", () => {
|
||||
it("renders four labeled state buttons", () => {
|
||||
it("renders five labeled state buttons", () => {
|
||||
const wrap = mount(CellRulePopover, {
|
||||
props: { state: "neutral", factor: 1, srcLabel: "warm", tgtLabel: "positive_qa" },
|
||||
});
|
||||
const btns = wrap.findAll("button.pop-btn");
|
||||
expect(btns).toHaveLength(4);
|
||||
expect(btns).toHaveLength(5);
|
||||
expect(btns.map((b) => b.text())).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/neutral/i),
|
||||
expect.stringMatching(/exclude/i),
|
||||
expect.stringMatching(/boost/i),
|
||||
expect.stringMatching(/reduce/i),
|
||||
expect.stringMatching(/only/i),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits only and explains the row-wide effect when only is active", async () => {
|
||||
const wrap = mount(CellRulePopover, {
|
||||
props: { state: "neutral", factor: 1, srcLabel: "summer", tgtLabel: "open" },
|
||||
});
|
||||
expect(wrap.find(".pop-note").exists()).toBe(false);
|
||||
await wrap.find("button.b-only").trigger("click");
|
||||
expect(wrap.emitted("update:state")?.[0]).toEqual(["only"]);
|
||||
await wrap.setProps({ state: "only" });
|
||||
expect(wrap.find(".pop-note").text()).toMatch(/without a rule of its own is excluded when summer fires/);
|
||||
// `only` weighs 1.0, so there is no factor to edit.
|
||||
expect(wrap.find(".pop-factor").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("marks the current state button as active", () => {
|
||||
const wrap = mount(CellRulePopover, {
|
||||
props: { state: "boost", factor: 1.8, srcLabel: "warm", tgtLabel: "positive_qa" },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* CellRulePopover — body of the constraint-matrix cell editor.
|
||||
*
|
||||
* Renders four labeled state buttons (NEUTRAL/EXCLUDE/BOOST/REDUCE) plus
|
||||
* Renders five labeled state buttons (NEUTRAL/EXCLUDE/BOOST/REDUCE/ONLY) plus
|
||||
* a numeric factor input (boost / reduce only) and an optional "Reset"
|
||||
* button shown when the caller signals there's an override to clear.
|
||||
*
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
|
||||
type RuleState = "neutral" | "exclude" | "boost" | "reduce";
|
||||
type RuleState = "neutral" | "exclude" | "boost" | "reduce" | "only";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -165,6 +165,19 @@ function onReset(): void {
|
||||
>
|
||||
<span class="b-glyph">↓</span> Reduce
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pop-btn b-only"
|
||||
:class="{ active: state === 'only' }"
|
||||
:aria-pressed="state === 'only'"
|
||||
title="Link: when this source tag fires, only targets with a rule in this row can be picked"
|
||||
@click="pickState('only')"
|
||||
>
|
||||
<span class="b-glyph">✓</span> Only
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="state === 'only'" class="pop-note">
|
||||
Every target in this row without a rule of its own is excluded when {{ srcLabel }} fires.
|
||||
</div>
|
||||
|
||||
<div v-if="showFactor" class="pop-factor">
|
||||
@@ -274,6 +287,17 @@ function onReset(): void {
|
||||
}
|
||||
.pop-btn.b-exclude.active { background: color-mix(in srgb, var(--wp-danger, #ef4444) 22%, transparent); color: var(--wp-danger); border-color: color-mix(in srgb, var(--wp-danger, #ef4444) 50%, transparent); }
|
||||
.pop-btn.b-boost.active { background: color-mix(in srgb, var(--wp-success, #22c55e) 22%, transparent); color: var(--wp-success); border-color: color-mix(in srgb, var(--wp-success, #22c55e) 50%, transparent); }
|
||||
.pop-btn.b-only {
|
||||
/* Fifth button spans the grid so the 2-column layout stays even. */
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.pop-btn.b-only.active { background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent); color: var(--wp-info, #3b82f6); border-color: color-mix(in srgb, var(--wp-info, #3b82f6) 50%, transparent); }
|
||||
.pop-note {
|
||||
margin: -2px 0 8px;
|
||||
font: 10px var(--wp-font-sans, sans-serif);
|
||||
color: var(--wp-text-muted, #8a8d99);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.pop-btn.b-reduce.active { background: color-mix(in srgb, var(--wp-warn, #f97316) 22%, transparent); color: var(--wp-warn); border-color: color-mix(in srgb, var(--wp-warn, #f97316) 50%, transparent); }
|
||||
|
||||
/* ── Factor input — wrap + stacked stepper, mirrors OptionRow's
|
||||
|
||||
@@ -101,6 +101,7 @@ interface WildcardPayload {
|
||||
sub_categories?: string[];
|
||||
options?: Array<{ value?: string }>;
|
||||
tag_groups?: Record<string, string[]>;
|
||||
tag_group_kinds?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Normalised module shape the resolution lookups consume. Unifies the
|
||||
@@ -307,6 +308,14 @@ const targetGroups = computed<Record<string, string[]>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.target_wildcard_id)?.tag_groups ?? {};
|
||||
});
|
||||
const sourceGroupKinds = computed<Record<string, string>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.source_wildcard_id)?.tag_group_kinds ?? {};
|
||||
});
|
||||
const targetGroupKinds = computed<Record<string, string>>(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
return findWildcard(pl.target_wildcard_id)?.tag_group_kinds ?? {};
|
||||
});
|
||||
|
||||
const sourceName = computed(() => {
|
||||
const pl = (props.module.payload ?? {}) as ConstraintPayload;
|
||||
@@ -715,6 +724,8 @@ function onSpaClick(): void {
|
||||
:target-name="targetName"
|
||||
:source-groups="sourceGroups"
|
||||
:target-groups="targetGroups"
|
||||
:source-group-kinds="sourceGroupKinds"
|
||||
:target-group-kinds="targetGroupKinds"
|
||||
:stranded="hasDangling"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
|
||||
@@ -8,11 +8,11 @@ describe("MatrixLegend", () => {
|
||||
expect(wrap.find(".legend-body").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("clicking the toggle expands the body with four state rows", async () => {
|
||||
it("clicking the toggle expands the body with five state rows", async () => {
|
||||
const wrap = mount(MatrixLegend);
|
||||
await wrap.find(".legend-toggle").trigger("click");
|
||||
expect(wrap.find(".legend-body").exists()).toBe(true);
|
||||
expect(wrap.findAll(".legend-row")).toHaveLength(4);
|
||||
expect(wrap.findAll(".legend-row")).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("each row labels one state in plain language", async () => {
|
||||
@@ -20,7 +20,7 @@ describe("MatrixLegend", () => {
|
||||
await wrap.find(".legend-toggle").trigger("click");
|
||||
const names = wrap.findAll(".legend-name").map((n) => n.text().toLowerCase());
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(["neutral", "exclude", "boost", "reduce"]),
|
||||
expect.arrayContaining(["neutral", "exclude", "boost", "reduce", "only"]),
|
||||
);
|
||||
const descs = wrap.findAll(".legend-desc").map((d) => d.text());
|
||||
expect(descs.some((d) => /excluded entirely/i.test(d))).toBe(true);
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
*/
|
||||
import { ref } from "vue";
|
||||
|
||||
defineProps<{
|
||||
/** Show the accepts-axis row — only when this matrix actually has one, so the
|
||||
* legend doesn't explain a mark that isn't on screen. */
|
||||
showAccepts?: boolean;
|
||||
}>();
|
||||
|
||||
const expanded = ref(false);
|
||||
|
||||
function toggle(): void {
|
||||
@@ -46,12 +52,39 @@ function toggle(): void {
|
||||
<span class="legend-name n-reduce">Reduce</span>
|
||||
<span class="legend-desc">When the source sub-cat is picked, target values in this column are N times less likely.</span>
|
||||
</div>
|
||||
<div class="legend-row">
|
||||
<span class="legend-sample s-only"><span class="glyph">✓</span></span>
|
||||
<span class="legend-name n-only">Only</span>
|
||||
<span class="legend-desc">Links picks. When the source sub-cat is picked, only target values with a rule in this row can be picked; everything else in the row is excluded.</span>
|
||||
</div>
|
||||
<div v-if="showAccepts" class="legend-row">
|
||||
<span class="legend-sample s-axis"><i class="pi pi-sync glyph" aria-hidden="true" /></span>
|
||||
<span class="legend-name n-axis">Accepts axis</span>
|
||||
<span class="legend-desc">This group rolls one tag per pick and is readable as <code>$var.NAME</code>. Marked <i class="pi pi-sync" aria-hidden="true" /> on its band.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legend { margin-top: 14px; }
|
||||
.legend-sample.s-only {
|
||||
color: var(--wp-info, #3b82f6);
|
||||
background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 45%, transparent);
|
||||
}
|
||||
.legend-name.n-only { color: var(--wp-info, #3b82f6); }
|
||||
.legend-sample.s-axis {
|
||||
color: var(--wp-axis, #fbbf24);
|
||||
background: color-mix(in srgb, var(--wp-axis, #fbbf24) 14%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--wp-axis, #fbbf24) 40%, transparent);
|
||||
}
|
||||
.legend-name.n-axis { color: var(--wp-axis, #fbbf24); }
|
||||
.legend-desc code {
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
color: var(--wp-text-muted, #a1a1ad);
|
||||
}
|
||||
.legend-desc .pi-sync { color: var(--wp-axis, #fbbf24); font-size: 10px; }
|
||||
.legend-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -100,20 +100,24 @@ describe("constraint ExceptionsSection", () => {
|
||||
expect(patch.instance?.exception_mode_overrides).toEqual({ '["red","black"]': "boost" });
|
||||
});
|
||||
|
||||
it("mode cycle from reduce goes back to allow (4-state, no disabled)", async () => {
|
||||
const w = mount(ExceptionsSection, {
|
||||
props: {
|
||||
module: makeModule({
|
||||
instance: { exception_mode_overrides: { '["red","black"]': "reduce" } },
|
||||
}),
|
||||
sourceValues: SOURCE_VALUES,
|
||||
targetValues: TARGET_VALUES,
|
||||
},
|
||||
});
|
||||
await w.find('[data-test="ex-mode-0"]').trigger("click");
|
||||
const updates = w.emitted("update")!;
|
||||
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
|
||||
expect(patch.instance?.exception_mode_overrides).toEqual({ '["red","black"]': "allow" });
|
||||
it("mode cycle goes reduce → only → allow (5-state, no disabled)", async () => {
|
||||
const cycleFrom = async (mode: "reduce" | "only") => {
|
||||
const w = mount(ExceptionsSection, {
|
||||
props: {
|
||||
module: makeModule({
|
||||
instance: { exception_mode_overrides: { '["red","black"]': mode } },
|
||||
}),
|
||||
sourceValues: SOURCE_VALUES,
|
||||
targetValues: TARGET_VALUES,
|
||||
},
|
||||
});
|
||||
await w.find('[data-test="ex-mode-0"]').trigger("click");
|
||||
const updates = w.emitted("update")!;
|
||||
const patch = updates[updates.length - 1][0] as Partial<ModuleEntry>;
|
||||
return patch.instance?.exception_mode_overrides;
|
||||
};
|
||||
expect(await cycleFrom("reduce")).toEqual({ '["red","black"]': "only" });
|
||||
expect(await cycleFrom("only")).toEqual({ '["red","black"]': "allow" });
|
||||
});
|
||||
|
||||
it("factor input visible only when effective mode is boost or reduce", () => {
|
||||
@@ -417,3 +421,37 @@ describe("constraint ExceptionsSection — stranded read-only", () => {
|
||||
expect(w.find('[data-test="ex-add-extra"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint ExceptionsSection — only-link note", () => {
|
||||
const mountWith = (overrides: Partial<ModuleEntry>) =>
|
||||
mount(ExceptionsSection, {
|
||||
props: { module: makeModule(overrides), sourceValues: SOURCE_VALUES, targetValues: TARGET_VALUES },
|
||||
});
|
||||
|
||||
it("hides the note when no exception is only", () => {
|
||||
expect(mountWith({}).find('[data-test="ex-only-note"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("names the source of a library only link", () => {
|
||||
const w = mountWith({
|
||||
payload: {
|
||||
source_wildcard_id: "wc_color", target_wildcard_id: "wc_fabric", matrix: {},
|
||||
exceptions: [{ source_value: "red", target_value: "black", mode: "only", factor: 1 }],
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="ex-only-note"]').text()).toContain("when red is picked");
|
||||
});
|
||||
|
||||
it("follows instance overrides, disabled rows and extras", () => {
|
||||
const w = mountWith({
|
||||
instance: {
|
||||
exception_mode_overrides: { '["blue","green"]': "only" },
|
||||
disabled_exception_keys: ['["red","black"]'],
|
||||
extra_exceptions: [{ source_value: "silver", target_value: "white", mode: "only", factor: 1 }],
|
||||
},
|
||||
});
|
||||
const text = w.find('[data-test="ex-only-note"]').text();
|
||||
expect(text).toContain("blue, silver are picked");
|
||||
expect(text).not.toContain("red");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Constraint ExceptionsSection — library exception list + extras
|
||||
* section. Library rows: checkbox toggles `disabled_exception_keys`,
|
||||
* mode chip cycles 4 modes (no "disabled" — that's the row checkbox's
|
||||
* mode chip cycles 5 modes (no "disabled" — that's the row checkbox's
|
||||
* job), factor input visible on boost/reduce.
|
||||
*
|
||||
* Extras section: instance-only rows from `extra_exceptions`. Source
|
||||
@@ -20,7 +20,7 @@ import { patchInstance } from "../../instance/patch";
|
||||
import RichTextPreview from "../../../../../manager/components/RichTextPreview.vue";
|
||||
import Select, { type SelectOption } from "../../../../../manager/components/ui/Select.vue";
|
||||
|
||||
type Mode = "allow" | "exclude" | "boost" | "reduce";
|
||||
type Mode = "allow" | "exclude" | "boost" | "reduce" | "only";
|
||||
|
||||
/** Mode → glyph + label for the colored mode chips. Glyphs mirror the
|
||||
* matrix's MODE_ICON / cellGlyph convention (boost ↑ / reduce ↓ /
|
||||
@@ -32,6 +32,7 @@ const MODE_META: Record<Mode, { glyph: string; label: string }> = {
|
||||
reduce: { glyph: "↓", label: "Reduce" },
|
||||
exclude: { glyph: "×", label: "Exclude" },
|
||||
allow: { glyph: "·", label: "Neutral" },
|
||||
only: { glyph: "✓", label: "Only" },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -183,11 +184,28 @@ function effectiveFactor(exc: LibraryException): number {
|
||||
return factorOverrides.value[libKey(exc)] ?? exc.factor;
|
||||
}
|
||||
|
||||
/** Source values with an active `only` link (library rows that are enabled,
|
||||
* after instance mode overrides, plus instance extras). Drives the
|
||||
* "Linked" note, mirroring the SPA ConstraintEditor's `onlyLinkSources`. */
|
||||
const onlyLinkSources = computed<string[]>(() => {
|
||||
const out: string[] = [];
|
||||
const push = (src: string) => { if (!out.includes(src)) out.push(src); };
|
||||
for (const exc of libraryExceptions.value) {
|
||||
if (disabledKeys.value.has(libKey(exc))) continue;
|
||||
if (effectiveMode(exc) === "only") push(excSrc(exc, props.sourceOptionsById));
|
||||
}
|
||||
for (const exc of extras.value) {
|
||||
if (exc.mode === "only") push(exc.source_value);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const MODE_CYCLE: Record<Mode, Mode> = {
|
||||
allow: "exclude",
|
||||
exclude: "boost",
|
||||
boost: "reduce",
|
||||
reduce: "allow",
|
||||
reduce: "only",
|
||||
only: "allow",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -608,6 +626,13 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="onlyLinkSources.length" class="ex__only-note" data-test="ex-only-note">
|
||||
<span class="ex__only-note-glyph" aria-hidden="true">✓</span>
|
||||
Linked: when {{ onlyLinkSources.map((s) => s || "⌀ null").join(", ") }}
|
||||
{{ onlyLinkSources.length === 1 ? "is" : "are" }} picked, only target values listed here for
|
||||
{{ onlyLinkSources.length === 1 ? "it" : "them" }} can be picked.
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="!stranded"
|
||||
type="button"
|
||||
@@ -676,6 +701,10 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
border-left-color: color-mix(in srgb, var(--wp-warn, #f97316) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--wp-warn, #f97316) 5%, transparent);
|
||||
}
|
||||
.ex__row--only:not(.ex__row--extra) {
|
||||
border-left-color: color-mix(in srgb, var(--wp-info, #3b82f6) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--wp-info, #3b82f6) 5%, transparent);
|
||||
}
|
||||
.ex__row--exclude:not(.ex__row--extra) {
|
||||
border-left-color: color-mix(in srgb, var(--wp-danger, #ef4444) 60%, transparent);
|
||||
background: color-mix(in srgb, var(--wp-danger, #ef4444) 5%, transparent);
|
||||
@@ -805,6 +834,7 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
.ex__mode-chip--allow { background: color-mix(in srgb, var(--wp-accent) 22%, transparent); color: var(--wp-accent-text, var(--wp-text)); }
|
||||
.ex__mode-chip--exclude { background: color-mix(in srgb, var(--wp-danger, #e05252) 22%, transparent); color: var(--wp-danger, #e05252); }
|
||||
.ex__mode-chip--boost { background: color-mix(in srgb, var(--wp-success, #6bc96f) 22%, transparent); color: var(--wp-success, #6bc96f); }
|
||||
.ex__mode-chip--only { background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent); color: var(--wp-info, #3b82f6); }
|
||||
.ex__mode-chip--reduce { background: color-mix(in srgb, var(--wp-warn, #f59e0b) 22%, transparent); color: var(--wp-warn, #f59e0b); }
|
||||
.ex__factor-wrap {
|
||||
display: inline-flex;
|
||||
@@ -911,6 +941,12 @@ function bumpExtraFactor(idx: number, dir: 1 | -1): void {
|
||||
color: var(--wp-accent-text, var(--wp-text));
|
||||
}
|
||||
.ex__add-extra .pi { font-size: 10px; }
|
||||
.ex__only-note {
|
||||
margin: 8px 0 0;
|
||||
font: 400 11px var(--wp-font-sans);
|
||||
color: var(--wp-text-dim, var(--wp-text3));
|
||||
}
|
||||
.ex__only-note-glyph { color: var(--wp-info, #3b82f6); margin-right: 4px; }
|
||||
.ex__readonly-hint {
|
||||
margin: 8px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Constraint MatrixSection — sub_cat × sub_cat grid.
|
||||
//
|
||||
// Click any cell opens CellRulePopover (4 labeled state buttons +
|
||||
// Click any cell opens CellRulePopover (5 labeled state buttons +
|
||||
// numeric factor input). The old 4-state click-cycle and cog-anchored
|
||||
// factor popover are gone. `mode: "allow"` / `"disabled"` collapse to
|
||||
// `"neutral"` on read; touching a legacy-disabled cell strips its
|
||||
@@ -487,3 +487,70 @@ describe("constraint MatrixSection — keyboard", () => {
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraint MatrixSection — only (linked picks)", () => {
|
||||
it("selecting neutral on a library boost stores `allow`, the engine's name", async () => {
|
||||
// A stored "neutral" override failed the whole constraint at run time.
|
||||
const updates: Array<Partial<ModuleEntry>> = [];
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
"onUpdate": (p: Partial<ModuleEntry>) => updates.push(p),
|
||||
},
|
||||
});
|
||||
await w.find('[data-test="mx-cell-red-silk"]').trigger("click");
|
||||
await popoverWrap(w).find("button.pop-btn.b-neutral").trigger("click");
|
||||
const last = updates[updates.length - 1].instance as Record<string, unknown>;
|
||||
expect(last.cell_mode_overrides).toEqual({ [RED_SILK]: "allow" });
|
||||
});
|
||||
|
||||
it("reads a legacy `neutral` override as neutral", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule({ instance: { cell_mode_overrides: { [RED_SILK]: "neutral" as unknown as "allow" } } }),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
},
|
||||
});
|
||||
expect(w.find('[data-test="mx-cell-red-silk"]').classes()).toContain("s-neutral");
|
||||
});
|
||||
|
||||
it("selecting only writes the override with no factor", async () => {
|
||||
const updates: Array<Partial<ModuleEntry>> = [];
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule(),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: TARGET_SUBS,
|
||||
"onUpdate": (p: Partial<ModuleEntry>) => updates.push(p),
|
||||
},
|
||||
});
|
||||
await w.find('[data-test="mx-cell-red-cotton"]').trigger("click");
|
||||
await popoverWrap(w).find("button.pop-btn.b-only").trigger("click");
|
||||
const last = updates[updates.length - 1].instance as Record<string, unknown>;
|
||||
expect(last.cell_mode_overrides).toEqual({ [RED_COTTON]: "only" });
|
||||
expect(last.cell_factor_overrides ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the neutral cells of an only row as implied-out", () => {
|
||||
const w = mount(MatrixSection, {
|
||||
props: {
|
||||
module: makeModule({ instance: { cell_mode_overrides: { [BLUE_SILK]: "only" } } }),
|
||||
sourceSubs: SOURCE_SUBS,
|
||||
targetSubs: ["cotton", "silk", "wool"],
|
||||
},
|
||||
});
|
||||
const silk = w.find('[data-test="mx-cell-blue-silk"]');
|
||||
expect(silk.classes()).toContain("s-only");
|
||||
expect(silk.text()).toContain("✓");
|
||||
for (const tgt of ["cotton", "wool"]) {
|
||||
const cell = w.find(`[data-test="mx-cell-blue-${tgt}"]`);
|
||||
expect(cell.classes()).toContain("implied-out");
|
||||
expect(cell.text()).toContain("×");
|
||||
}
|
||||
// Rows without an only rule are untouched.
|
||||
expect(w.find('[data-test="mx-cell-red-cotton"]').classes()).not.toContain("implied-out");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
toGroups,
|
||||
} from "../../../../shared/matrix-axis";
|
||||
|
||||
type Mode = "neutral" | "exclude" | "boost" | "reduce";
|
||||
type Mode = "neutral" | "exclude" | "boost" | "reduce" | "only";
|
||||
|
||||
interface Cell { mode: Mode; factor: number }
|
||||
|
||||
@@ -46,6 +46,10 @@ const props = withDefaults(
|
||||
* ConstraintMatrix. Empty → flat source/target-tinted headers. */
|
||||
sourceGroups?: Record<string, string[]>;
|
||||
targetGroups?: Record<string, string[]>;
|
||||
/** axis name → kind. `"accepts"` axes get a marked band (see SPA
|
||||
* ConstraintMatrix) — the matrix otherwise can't show accepts-ness. */
|
||||
sourceGroupKinds?: Record<string, string>;
|
||||
targetGroupKinds?: Record<string, string>;
|
||||
/** Read-only recovery view: the source/target wildcard was deleted, so
|
||||
* the configured rules are shown for understanding only. Cells don't
|
||||
* open the rule popover; the grid frame + cells mute to a snapshot.
|
||||
@@ -53,7 +57,8 @@ const props = withDefaults(
|
||||
* ConstraintMatrix `readonly` prop / `wp-mx--readonly` treatment. */
|
||||
stranded?: boolean;
|
||||
}>(),
|
||||
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}), stranded: false },
|
||||
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}),
|
||||
sourceGroupKinds: () => ({}), targetGroupKinds: () => ({}), stranded: false },
|
||||
);
|
||||
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
|
||||
|
||||
@@ -67,8 +72,8 @@ const instance = computed(() => props.module.instance ?? {});
|
||||
const disabledCells = computed<Set<string>>(
|
||||
() => new Set(instance.value.disabled_matrix_cells ?? []),
|
||||
);
|
||||
const cellModeOverrides = computed<Record<string, Mode>>(
|
||||
() => (instance.value.cell_mode_overrides as Record<string, Mode> | null) ?? {},
|
||||
const cellModeOverrides = computed<Record<string, string>>(
|
||||
() => (instance.value.cell_mode_overrides as Record<string, string> | null) ?? {},
|
||||
);
|
||||
const cellFactorOverrides = computed<Record<string, number>>(
|
||||
() => (instance.value.cell_factor_overrides as Record<string, number> | null) ?? {},
|
||||
@@ -80,6 +85,22 @@ const orderedSources = computed(() => orderByGroups([...props.sourceSubs], props
|
||||
const orderedTargets = computed(() => orderByGroups([...props.targetSubs], props.targetGroups));
|
||||
const rowGroups = computed(() => toGroups(orderedSources.value));
|
||||
const colGroups = computed(() => toGroups(orderedTargets.value));
|
||||
|
||||
const sourceAcceptsAxes = computed(() => acceptsSet(props.sourceGroupKinds));
|
||||
const targetAcceptsAxes = computed(() => acceptsSet(props.targetGroupKinds));
|
||||
function acceptsSet(kinds: Record<string, string>): Set<string> {
|
||||
return new Set(Object.entries(kinds).filter(([, k]) => k === "accepts").map(([a]) => a));
|
||||
}
|
||||
function isAcceptsBand(grp: { axisName: string }, which: "source" | "target"): boolean {
|
||||
const set = which === "source" ? sourceAcceptsAxes.value : targetAcceptsAxes.value;
|
||||
return set.has(grp.axisName);
|
||||
}
|
||||
function acceptsTip(grp: { axisName: string }): string {
|
||||
return `Accepts axis — rolls one tag per pick, readable as $var.${grp.axisName}`;
|
||||
}
|
||||
const hasAcceptsAxis = computed(
|
||||
() => sourceAcceptsAxes.value.size > 0 || targetAcceptsAxes.value.size > 0,
|
||||
);
|
||||
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
|
||||
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
|
||||
|
||||
@@ -114,7 +135,10 @@ function effectiveState(src: string, tgt: string): Mode {
|
||||
const key = encodeKey([src, tgt]);
|
||||
if (disabledCells.value.has(key)) return "neutral";
|
||||
const override = cellModeOverrides.value[key];
|
||||
if (override === "neutral" || override === "exclude" || override === "boost" || override === "reduce") {
|
||||
// `allow` is how a neutral override is stored (the engine's name for it);
|
||||
// `neutral` is what earlier builds wrote, still read for saved workflows.
|
||||
if (override === "allow" || override === "neutral") return "neutral";
|
||||
if (override === "exclude" || override === "boost" || override === "reduce" || override === "only") {
|
||||
return override;
|
||||
}
|
||||
const lib = libCell(src, tgt);
|
||||
@@ -140,20 +164,36 @@ function isOverridden(src: string, tgt: string): boolean {
|
||||
function cellClasses(src: string, tgt: string): string[] {
|
||||
const out = ["mx-cell", `s-${effectiveState(src, tgt)}`];
|
||||
if (isOverridden(src, tgt)) out.push("mx-cell--mod");
|
||||
if (isImpliedOut(src, tgt)) out.push("implied-out");
|
||||
const open = openPopover.value;
|
||||
if (open && open.src === src && open.tgt === tgt) out.push("open");
|
||||
return out;
|
||||
}
|
||||
|
||||
function cellGlyph(src: string, tgt: string): string {
|
||||
if (isImpliedOut(src, tgt)) return "×";
|
||||
switch (effectiveState(src, tgt)) {
|
||||
case "neutral": return "·";
|
||||
case "exclude": return "×";
|
||||
case "boost": return "↑";
|
||||
case "reduce": return "↓";
|
||||
case "only": return "✓";
|
||||
}
|
||||
}
|
||||
|
||||
/** Source rows holding an `only` cell (effective, override-aware). Their
|
||||
* neutral cells are shut out at runtime, so the grid marks them. */
|
||||
const onlyRows = computed<Set<string>>(() => {
|
||||
const out = new Set<string>();
|
||||
for (const src of props.sourceSubs) {
|
||||
if (props.targetSubs.some((tgt) => effectiveState(src, tgt) === "only")) out.add(src);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
function isImpliedOut(src: string, tgt: string): boolean {
|
||||
return onlyRows.value.has(src) && effectiveState(src, tgt) === "neutral";
|
||||
}
|
||||
|
||||
function cellShowsFactor(src: string, tgt: string): boolean {
|
||||
const s = effectiveState(src, tgt);
|
||||
return s === "boost" || s === "reduce";
|
||||
@@ -167,10 +207,11 @@ function cellFactorText(src: string, tgt: string): string {
|
||||
function cellAriaLabel(src: string, tgt: string): string {
|
||||
const s = effectiveState(src, tgt);
|
||||
const factor = (s === "boost" || s === "reduce") ? ` ×${effectiveFactor(src, tgt).toFixed(1)}` : "";
|
||||
const implied = isImpliedOut(src, tgt) ? " (excluded by an only rule in this row)" : "";
|
||||
// Stranded → the grid is a read-only snapshot, so drop the "Click to
|
||||
// edit" affordance from the label (the cell is inert).
|
||||
const suffix = props.stranded ? " (read-only)" : ". Click to edit.";
|
||||
return `Rule: ${src} → ${tgt}, current state ${s}${factor}${suffix}`;
|
||||
return `Rule: ${src} → ${tgt}, current state ${s}${factor}${implied}${suffix}`;
|
||||
}
|
||||
|
||||
// ── Popover ────────────────────────────────────────────────────
|
||||
@@ -246,9 +287,11 @@ function commitMode(src: string, tgt: string, next: Mode): void {
|
||||
const key = encodeKey([src, tgt]);
|
||||
const lib = libCell(src, tgt);
|
||||
const libMode: Mode = lib ? lib.mode : "neutral";
|
||||
const modeMap = { ...cellModeOverrides.value };
|
||||
const modeMap: Record<string, string> = { ...cellModeOverrides.value };
|
||||
if (next === libMode) delete modeMap[key];
|
||||
else modeMap[key] = next;
|
||||
// Store neutral as `allow`, the engine's name for it — a stored "neutral"
|
||||
// failed the whole constraint at run time.
|
||||
else modeMap[key] = next === "neutral" ? "allow" : next;
|
||||
const factorMap = { ...cellFactorOverrides.value };
|
||||
// When leaving boost/reduce, drop the factor override too so the
|
||||
// cell falls back fully to library / default.
|
||||
@@ -388,8 +431,9 @@ onBeforeUnmount(() => {
|
||||
:class="{ 'mx-th-band--bucket': isBucket(grp) }"
|
||||
:colspan="grp.tags.length"
|
||||
:style="{ '--ax': tagHue(grp, 'target', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
:title="isAcceptsBand(grp, 'target') ? acceptsTip(grp) : groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp)
|
||||
}}<i v-if="isAcceptsBand(grp, 'target')" class="pi pi-sync mx-axis-mark" aria-hidden="true" /></span></th>
|
||||
</template>
|
||||
<!-- Flat cols: tag headers sit directly in the single header row. -->
|
||||
<template v-else>
|
||||
@@ -427,8 +471,9 @@ onBeforeUnmount(() => {
|
||||
:class="{ 'mx-grp-head--bucket': isBucket(grp) }"
|
||||
:colspan="orderedTargets.length + 1"
|
||||
:style="{ '--ax': tagHue(grp, 'source', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
:title="isAcceptsBand(grp, 'source') ? acceptsTip(grp) : groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp)
|
||||
}}<i v-if="isAcceptsBand(grp, 'source')" class="pi pi-sync mx-axis-mark" aria-hidden="true" /></span></th>
|
||||
</tr>
|
||||
<tr v-for="s in grp.tags" :key="s">
|
||||
<th
|
||||
@@ -440,7 +485,8 @@ onBeforeUnmount(() => {
|
||||
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
|
||||
>
|
||||
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
|
||||
<span class="eye">{{ grp.axisName }}</span>
|
||||
<span class="eye">{{ grp.axisName
|
||||
}}<i v-if="isAcceptsBand(grp, 'source')" class="pi pi-sync mx-axis-mark" :title="acceptsTip(grp)" aria-hidden="true" /></span>
|
||||
<span class="v">{{ s }}</span>
|
||||
</span>
|
||||
<span v-else class="chip">{{ s }}</span>
|
||||
@@ -474,7 +520,7 @@ onBeforeUnmount(() => {
|
||||
Reattach a live wildcard to edit these rules.
|
||||
</p>
|
||||
|
||||
<MatrixLegend />
|
||||
<MatrixLegend :show-accepts="hasAcceptsAxis" />
|
||||
|
||||
<!-- Popover lives in a body-level portal so its hover state stays
|
||||
isolated from the source cell, the cells in the row below
|
||||
@@ -509,6 +555,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mx-axis-mark { color: var(--wp-axis, #fbbf24); font-size: 9px; margin-left: 5px; vertical-align: baseline; }
|
||||
.mx {
|
||||
padding: 12px 16px;
|
||||
background: var(--wp-bg2);
|
||||
@@ -744,6 +791,17 @@ onBeforeUnmount(() => {
|
||||
color: var(--wp-warn);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-warn, #f97316) 45%, transparent);
|
||||
}
|
||||
.mx-cell.s-only {
|
||||
background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent);
|
||||
color: var(--wp-info, #3b82f6);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 45%, transparent);
|
||||
}
|
||||
/* A neutral cell in a row that has an Only rule: shut out at runtime. Faint
|
||||
* danger hue on the dashed border so it reads as implied, not authored. */
|
||||
.mx-cell.s-neutral.implied-out {
|
||||
color: color-mix(in srgb, var(--wp-danger, #ef4444) 60%, var(--wp-text-dim, #595c66));
|
||||
border-color: color-mix(in srgb, var(--wp-danger, #ef4444) 30%, transparent);
|
||||
}
|
||||
.mx-cell--mod {
|
||||
outline: 1px dashed var(--wp-status-modified, #fb923c);
|
||||
outline-offset: -1px;
|
||||
@@ -781,6 +839,11 @@ onBeforeUnmount(() => {
|
||||
border: 1px solid color-mix(in srgb, var(--wp-danger, #ef4444) 18%, transparent);
|
||||
color: color-mix(in srgb, var(--wp-danger, #ef4444) 70%, var(--wp-text-dim));
|
||||
}
|
||||
.mx--readonly .mx-cell.s-only {
|
||||
background: color-mix(in srgb, var(--wp-info, #3b82f6) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 18%, transparent);
|
||||
color: color-mix(in srgb, var(--wp-info, #3b82f6) 70%, var(--wp-text-dim));
|
||||
}
|
||||
.mx__readonly-hint {
|
||||
margin: 8px 0 0;
|
||||
font: 10px var(--wp-font-sans);
|
||||
|
||||
@@ -3,65 +3,102 @@ import { mount } from "@vue/test-utils";
|
||||
import ValueRow from "./ValueRow.vue";
|
||||
import type { LibraryRow, DraftRow } from "../defaults";
|
||||
|
||||
// The value field is a RichTextInput now, so booru tag autocomplete works in
|
||||
// it. Stubbed here: it is contenteditable with atom spans, which jsdom does
|
||||
// not implement, and these tests are about ValueRow's wiring.
|
||||
const RichTextInputStub = {
|
||||
name: "RichTextInput",
|
||||
props: {
|
||||
modelValue: String,
|
||||
surface: String,
|
||||
multiline: Boolean,
|
||||
rows: [Number, String],
|
||||
disabled: Boolean,
|
||||
ariaLabel: String,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
template: `<div class="wp-rt-stub" data-test="row-value" :data-model-value="modelValue"></div>`,
|
||||
};
|
||||
const globalStubs = { RichTextInput: RichTextInputStub };
|
||||
|
||||
/** What the value field is showing. */
|
||||
const valueOf = (w: any) => w.findComponent(RichTextInputStub).props("modelValue");
|
||||
/** Type into the value field. */
|
||||
async function typeValue(w: any, v: string): Promise<void> {
|
||||
w.findComponent(RichTextInputStub).vm.$emit("update:modelValue", v);
|
||||
await w.vm.$nextTick();
|
||||
}
|
||||
|
||||
|
||||
const lib: LibraryRow = { id: "v1", name: "lens", value: "85mm" };
|
||||
const plainDraft: DraftRow = { id: "v1", name: "lens", value: "85mm", enabled: true, libraryId: "v1" };
|
||||
|
||||
describe("ValueRow", () => {
|
||||
it("renders $-prefixed name + value inputs", () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
|
||||
expect(w.find('[data-test="row-name-prefix"]').text()).toBe("$");
|
||||
expect(w.find<HTMLInputElement>('[data-test="row-name"]').element.value).toBe("lens");
|
||||
expect(w.find<HTMLInputElement>('[data-test="row-value"]').element.value).toBe("85mm");
|
||||
expect(valueOf(w)).toBe("85mm");
|
||||
});
|
||||
|
||||
it("value field is a growing textarea, not a single-line input", async () => {
|
||||
it("value field grows rather than being a single line", async () => {
|
||||
// Users routinely paste a whole sentence into a fixed value; a one-line
|
||||
// <input> showed a sliver with no way to see the rest.
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const el = w.find('[data-test="row-value"]');
|
||||
expect(el.element.tagName).toBe("TEXTAREA");
|
||||
// field showed a sliver with no way to see the rest. The requirement is
|
||||
// "multiline and grows", not "is a <textarea>" — it is a RichTextInput
|
||||
// now, which carries the same `useGrowableField` auto-grow, overflow fade
|
||||
// and drag grip, plus the tag autocomplete a textarea could not host.
|
||||
const w = mount(ValueRow, {
|
||||
props: { row: plainDraft, library: lib },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
const rt = w.findComponent(RichTextInputStub);
|
||||
expect(rt.exists()).toBe(true);
|
||||
expect(rt.props("multiline")).toBe(true);
|
||||
});
|
||||
|
||||
it("Enter inserts a newline like any textarea", async () => {
|
||||
// Briefly swallowed for parity with the single-line <input> this replaced,
|
||||
// which was parity for its own sake: the engine stores the value verbatim,
|
||||
// multi-line round-trips fine, and the modal saves on Cmd/Ctrl+Enter.
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const el = w.find('[data-test="row-value"]');
|
||||
const ev = new KeyboardEvent("keydown", { key: "Enter", cancelable: true, bubbles: true });
|
||||
el.element.dispatchEvent(ev);
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
it("offers tag autocomplete but not $ or @", () => {
|
||||
// The engine treats fixed_values as a binding PRODUCER: it DEFINES what
|
||||
// `$name` resolves to, so `$var` reads and `@{}` refs are gated off and
|
||||
// render as literal text with a warning. The surface prop is what carries
|
||||
// that rule into the editor.
|
||||
const w = mount(ValueRow, {
|
||||
props: { row: plainDraft, library: lib },
|
||||
global: { stubs: globalStubs },
|
||||
});
|
||||
expect(w.findComponent(RichTextInputStub).props("surface")).toBe("fixed_values");
|
||||
});
|
||||
|
||||
it("keeps a multi-line value intact on input", async () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const el = w.find<HTMLTextAreaElement>('[data-test="row-value"]');
|
||||
el.element.value = "line one\nline two";
|
||||
await el.trigger("input");
|
||||
// Multi-line values round-trip: the engine stores the value verbatim, and
|
||||
// the modal saves on Cmd/Ctrl+Enter so plain Enter stays free for a
|
||||
// newline. Previously two tests — one drove a keydown to prove Enter was
|
||||
// not swallowed, which only made sense against a real <textarea>.
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
|
||||
await typeValue(w, "line one\nline two");
|
||||
const updates = w.emitted("update") ?? [];
|
||||
expect(updates[updates.length - 1]).toEqual(["v1", { value: "line one\nline two" }]);
|
||||
});
|
||||
|
||||
it("checkbox is checked when enabled", () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
|
||||
expect(w.find('[data-test="row-check"]').classes()).toContain("row__check--on");
|
||||
});
|
||||
|
||||
it("checkbox unchecked + line-through when disabled", () => {
|
||||
const draft = { ...plainDraft, enabled: false };
|
||||
const w = mount(ValueRow, { props: { row: draft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
|
||||
expect(w.find('[data-test="row-check"]').classes()).not.toContain("row__check--on");
|
||||
expect(w.classes()).toContain("row--off");
|
||||
});
|
||||
|
||||
it("emits toggle event with row id when checkbox clicked", async () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
|
||||
await w.find('[data-test="row-check"]').trigger("click");
|
||||
expect(w.emitted("toggle")?.[0]).toEqual(["v1"]);
|
||||
});
|
||||
|
||||
it("emits update event with new name on name input", async () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
|
||||
const input = w.find<HTMLInputElement>('[data-test="row-name"]');
|
||||
input.element.value = "camera_lens";
|
||||
await input.trigger("input");
|
||||
@@ -69,17 +106,15 @@ describe("ValueRow", () => {
|
||||
});
|
||||
|
||||
it("emits update event with new value on value input", async () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const input = w.find<HTMLInputElement>('[data-test="row-value"]');
|
||||
input.element.value = "50mm";
|
||||
await input.trigger("input");
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
|
||||
await typeValue(w, "50mm");
|
||||
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "50mm" }]);
|
||||
});
|
||||
|
||||
it("strips comma + punctuation from the name on input (value names must be identifiers)", async () => {
|
||||
// The name becomes a `$var`; only identifier chars allowed. The VALUE
|
||||
// field (below) is free-form and must NOT be sanitized.
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
|
||||
const input = w.find<HTMLInputElement>('[data-test="row-name"]');
|
||||
input.element.value = "cam,era;lens!";
|
||||
await input.trigger("input");
|
||||
@@ -88,15 +123,13 @@ describe("ValueRow", () => {
|
||||
});
|
||||
|
||||
it("does NOT strip punctuation from the value field (values are free-form)", async () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const input = w.find<HTMLInputElement>('[data-test="row-value"]');
|
||||
input.element.value = "85mm, f/1.8!";
|
||||
await input.trigger("input");
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
|
||||
await typeValue(w, "85mm, f/1.8!");
|
||||
expect(w.emitted("update")?.[0]).toEqual(["v1", { value: "85mm, f/1.8!" }]);
|
||||
});
|
||||
|
||||
it("library row with no override is plain (no --mod, no reset button)", () => {
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: plainDraft, library: lib }, global: { stubs: globalStubs } });
|
||||
expect(w.find('[data-test="row-name-wrap"]').classes()).not.toContain("row__name-wrap--mod");
|
||||
expect(w.find('[data-test="row-value-wrap"]').classes()).not.toContain("row__value-wrap--mod");
|
||||
expect(w.find('[data-test="row-reset"]').exists()).toBe(false);
|
||||
@@ -104,21 +137,21 @@ describe("ValueRow", () => {
|
||||
|
||||
it("library row with value override gets --mod class on value wrap + reset visible", () => {
|
||||
const draft: DraftRow = { ...plainDraft, value: "50mm" };
|
||||
const w = mount(ValueRow, { props: { row: draft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
|
||||
expect(w.find('[data-test="row-value-wrap"]').classes()).toContain("row__value-wrap--mod");
|
||||
expect(w.find('[data-test="row-reset"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("library row with name override gets --mod class on name wrap", () => {
|
||||
const draft: DraftRow = { ...plainDraft, name: "camera_lens" };
|
||||
const w = mount(ValueRow, { props: { row: draft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
|
||||
expect(w.find('[data-test="row-name-wrap"]').classes()).toContain("row__name-wrap--mod");
|
||||
expect(w.find('[data-test="row-reset"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("instance-added row (no library) shows --added + delete button (no reset)", () => {
|
||||
const draft: DraftRow = { id: "fresh01", name: "mood", value: "cozy", enabled: true, libraryId: null };
|
||||
const w = mount(ValueRow, { props: { row: draft, library: undefined } });
|
||||
const w = mount(ValueRow, { props: { row: draft, library: undefined }, global: { stubs: globalStubs } });
|
||||
expect(w.classes()).toContain("row--added");
|
||||
expect(w.find('[data-test="row-delete"]').exists()).toBe(true);
|
||||
expect(w.find('[data-test="row-reset"]').exists()).toBe(false);
|
||||
@@ -126,14 +159,14 @@ describe("ValueRow", () => {
|
||||
|
||||
it("reset button click emits reset with row id", async () => {
|
||||
const draft: DraftRow = { ...plainDraft, value: "50mm" };
|
||||
const w = mount(ValueRow, { props: { row: draft, library: lib } });
|
||||
const w = mount(ValueRow, { props: { row: draft, library: lib }, global: { stubs: globalStubs } });
|
||||
await w.find('[data-test="row-reset"]').trigger("click");
|
||||
expect(w.emitted("reset")?.[0]).toEqual(["v1"]);
|
||||
});
|
||||
|
||||
it("delete button click emits delete with row id", async () => {
|
||||
const draft: DraftRow = { id: "fresh01", name: "mood", value: "cozy", enabled: true, libraryId: null };
|
||||
const w = mount(ValueRow, { props: { row: draft, library: undefined } });
|
||||
const w = mount(ValueRow, { props: { row: draft, library: undefined }, global: { stubs: globalStubs } });
|
||||
await w.find('[data-test="row-delete"]').trigger("click");
|
||||
expect(w.emitted("delete")?.[0]).toEqual(["fresh01"]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useGrowableField } from "../../../../shared/useGrowableField";
|
||||
import { computed } from "vue";
|
||||
import RichTextInput from "../../../../../manager/components/RichTextInput.vue";
|
||||
import {
|
||||
rowOverrideKind,
|
||||
type DraftRow,
|
||||
@@ -62,12 +62,7 @@ function onNameInput(ev: Event): void {
|
||||
if (el.value !== name) el.value = name;
|
||||
emit("update", props.row.id, { name });
|
||||
}
|
||||
function onValueInput(ev: Event): void {
|
||||
const el = ev.target as HTMLTextAreaElement;
|
||||
emit("update", props.row.id, { value: el.value });
|
||||
autosize();
|
||||
updateOverflowHint();
|
||||
}
|
||||
|
||||
|
||||
/** Grow the value field to fit its content, capped by the CSS `max-height`
|
||||
* (after which it scrolls). The field is a `<textarea>` purely to get this
|
||||
@@ -89,19 +84,6 @@ function onValueInput(ev: Event): void {
|
||||
* plain Enter is free — and users writing paragraph-length values want their
|
||||
* line breaks. */
|
||||
|
||||
const valueEl = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
/* Shared auto-grow + overflow-hint + grip-follow. See `useGrowableField` for
|
||||
* why the three belong together — chiefly that auto-grow has to yield to the
|
||||
* drag handle, which the local copy did not. */
|
||||
const {
|
||||
hasMoreBelow,
|
||||
updateOverflowHint,
|
||||
scheduleOverflowHint,
|
||||
autosize,
|
||||
attach,
|
||||
startResize,
|
||||
} = useGrowableField(() => valueEl.value);
|
||||
|
||||
/** True when the field is capped and still hiding content below the fold.
|
||||
* Mirrors RichTextInput's hint — a capped box otherwise looks identical
|
||||
@@ -112,23 +94,6 @@ const {
|
||||
|
||||
|
||||
|
||||
/* Size correctly on first paint + whenever the row's value changes from
|
||||
outside (reset-to-library, undo). */
|
||||
watch(
|
||||
() => props.row.value,
|
||||
() => void nextTick(() => {
|
||||
autosize();
|
||||
scheduleOverflowHint();
|
||||
}),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
autosize();
|
||||
scheduleOverflowHint();
|
||||
attach();
|
||||
});
|
||||
|
||||
|
||||
function onReset(): void {
|
||||
@@ -197,33 +162,26 @@ function onDelete(): void {
|
||||
class="row__value-wrap"
|
||||
:class="{
|
||||
'row__value-wrap--mod': valueOverridden,
|
||||
'row__value-wrap--more': hasMoreBelow,
|
||||
}"
|
||||
data-test="row-value-wrap"
|
||||
>
|
||||
<textarea
|
||||
ref="valueEl"
|
||||
<!-- RichTextInput on the `fixed_values` surface. Tag autocomplete
|
||||
works here; `$` and `@` do NOT, and that is the engine's rule not a
|
||||
UI preference: a fixed value DEFINES what `$name` resolves to, so
|
||||
`resolve_text` gates both off and renders such a token as literal
|
||||
text with a warning. The producer/consumer default in
|
||||
RichTextInput encodes exactly that, so nothing is passed here. -->
|
||||
<RichTextInput
|
||||
class="row__value"
|
||||
data-test="row-value"
|
||||
rows="1"
|
||||
spellcheck="false"
|
||||
:value="row.value"
|
||||
:model-value="row.value"
|
||||
surface="fixed_values"
|
||||
multiline
|
||||
:rows="1"
|
||||
:disabled="!row.enabled"
|
||||
:aria-label="`Value for row ${row.id}`"
|
||||
@input="onValueInput"
|
||||
@scroll="updateOverflowHint"
|
||||
@update:model-value="(v: string) => emit('update', props.row.id, { value: v })"
|
||||
/>
|
||||
<!-- Resize grip, replacing `resize: vertical`. The native resizer
|
||||
recomputes from an origin captured at pointerdown and never clamps
|
||||
to min/max, so over-dragging banks invisible travel the user has to
|
||||
walk back — measured at 13 of 23 dead frames. Ours applies each
|
||||
move's delta to the current height. -->
|
||||
<span
|
||||
class="row__grip"
|
||||
data-test="row-grip"
|
||||
aria-hidden="true"
|
||||
@pointerdown="startResize"
|
||||
></span>
|
||||
</span>
|
||||
<span
|
||||
v-if="hasNonTextToken"
|
||||
@@ -344,71 +302,30 @@ function onDelete(): void {
|
||||
}
|
||||
.row__name:focus, .row__value:focus { outline: none; }
|
||||
|
||||
/* The value field is a <textarea> so a long value WRAPS and the box grows to
|
||||
* fit (height driven by `autosizeValue`). Capped so a pasted paragraph can't
|
||||
* push the rest of the modal off-screen; past the cap it scrolls. */
|
||||
/* This class sits on the RichTextInput ROOT, and everything it used to declare
|
||||
* about scrolling and sizing belonged to the <textarea> that used to be here.
|
||||
*
|
||||
* That textarea went in `ca87384b`. The rule stayed, and the two scroll
|
||||
* declarations then nested: the component's own `.wp-rt__host` already caps
|
||||
* and scrolls, so a capped, scrolling box sat inside another capped, scrolling
|
||||
* box and the row showed TWO vertical scrollbars. `min-height`, `max-height`,
|
||||
* `line-height` and `white-space` were the same mistake without a visible
|
||||
* symptom — the component declares each of them for itself.
|
||||
*
|
||||
* Only `word-break` survives, because a long unbroken value is a row-layout
|
||||
* concern rather than an editor one. */
|
||||
.row__value {
|
||||
/* Manual drag handle as well as the auto-grow, matching the derivation and
|
||||
combine template inputs — auto-sizing picks a sensible height, the handle
|
||||
lets the user override it. */
|
||||
overscroll-behavior: contain;
|
||||
overflow-y: auto;
|
||||
/* Floor for the drag handle. Without it the computed `min-height` is `auto`,
|
||||
which reads as 0 — so the field could be dragged away to a sliver with no
|
||||
way to see what you were editing. One line plus its padding. */
|
||||
min-height: 28px;
|
||||
max-height: 12rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--wp-font-mono);
|
||||
}
|
||||
/* The wrapper centred a single-line input; with a growing field the label
|
||||
* column should sit at the top instead of drifting down as it grows. */
|
||||
.row__value-wrap { align-items: flex-start; position: relative; }
|
||||
|
||||
/* "More below" hint, matching RichTextInput. A <textarea> can't carry a
|
||||
* dependable `::after`, so it hangs off the wrapper. Gradient runs to a
|
||||
* TRANSPARENT accent so the text underneath still reads; the 1px accent line
|
||||
* at the bottom is what catches the eye. */
|
||||
/* Sits in the corner the overflow fade deliberately leaves clear. */
|
||||
.row__grip {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: ns-resize;
|
||||
z-index: 2;
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
transparent 0 2px,
|
||||
var(--wp-text3, #8a8a9a) 2px 3px
|
||||
);
|
||||
/* Clipped to a bottom-right triangle so the hatching runs parallel to the
|
||||
hypotenuse — the shape the browser's own resizer draws, which is what
|
||||
users already read as "drag me". A filled square read as a button. */
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 100%);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.row__grip:hover { opacity: 0.9; }
|
||||
/* The overflow fade and the drag grip both live in RichTextInput now, and both
|
||||
* were reimplemented here only because a <textarea> could not carry them. The
|
||||
* markup for each went with the textarea in `ca87384b`; these rules outlived
|
||||
* it, matching nothing. */
|
||||
|
||||
.row__value-wrap--more::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
/* Right edge stops short of the resize grip. The band is as tall as the
|
||||
handle and sat straight on top of it, so a resizable field's grip was
|
||||
invisible — users aimed, missed, re-grabbed. */
|
||||
inset: auto 16px 0 0;
|
||||
height: 14px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--wp-accent, #6366f1) 26%, transparent)
|
||||
);
|
||||
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--wp-accent, #6366f1) 60%, transparent);
|
||||
}
|
||||
|
||||
.row--off { color: var(--wp-text-dim, var(--wp-text3)); }
|
||||
.row--off .row__name-wrap, .row--off .row__value-wrap { opacity: 0.5; }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { version } from "../../../package.json" with { type: "json" };
|
||||
import { toasts } from "../shared/toast-store";
|
||||
import { _resetVersionNoticeForTesting, checkInstalledVersion } from "./version-notice";
|
||||
|
||||
const res = (v?: string) =>
|
||||
new Response(null, { status: 204, headers: v ? { "X-WP-Version": v } : {} });
|
||||
|
||||
describe("checkInstalledVersion", () => {
|
||||
beforeEach(() => {
|
||||
toasts.value = [];
|
||||
_resetVersionNoticeForTesting();
|
||||
});
|
||||
|
||||
it("does nothing when the installed version matches the loaded one", () => {
|
||||
checkInstalledVersion(res(version));
|
||||
checkInstalledVersion(res());
|
||||
expect(toasts.value).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("asks once for a refresh when the pack was updated", () => {
|
||||
checkInstalledVersion(res("99.0.0"));
|
||||
checkInstalledVersion(res("99.0.0"));
|
||||
expect(toasts.value).toHaveLength(1);
|
||||
const t = toasts.value[0];
|
||||
expect(t.message).toContain("updated to v99.0.0");
|
||||
expect(t.severity).toBe("warning");
|
||||
expect(t.lifeMs).toBe(0);
|
||||
expect(t.action?.label).toBe("Refresh");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { version as loadedVersion } from "../../../package.json" with { type: "json" };
|
||||
import { pushToast } from "../shared/toast-store";
|
||||
|
||||
/**
|
||||
* The canvas keeps running the extension JS it loaded, even after the pack
|
||||
* is updated and ComfyUI restarts. Every `/wp/*` response carries the pack
|
||||
* version installed on disk (`X-WP-Version`); when it differs from the
|
||||
* version this bundle was built as, ask once for a page refresh.
|
||||
*/
|
||||
let notified = false;
|
||||
|
||||
export function checkInstalledVersion(res: Response): void {
|
||||
if (notified) return;
|
||||
const installed = res.headers.get("X-WP-Version");
|
||||
if (!installed || installed === loadedVersion) return;
|
||||
notified = true;
|
||||
pushToast(
|
||||
`Wildcard Pipeline was updated to v${installed}. Refresh the page to load it.`,
|
||||
{
|
||||
severity: "warning",
|
||||
lifeMs: 0,
|
||||
singletonKey: "wp-version-updated",
|
||||
action: { label: "Refresh", onSelect: () => window.location.reload() },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only: allow the notice to fire again. */
|
||||
export function _resetVersionNoticeForTesting(): void {
|
||||
notified = false;
|
||||
}
|
||||
@@ -4,6 +4,31 @@ import { nextTick } from "vue";
|
||||
import InjectorBindingModal from "./InjectorBindingModal.vue";
|
||||
import type { InjectorRow } from "../../widgets/_shared";
|
||||
|
||||
// The template field is a RichTextInput now, not a <textarea>: it needs the
|
||||
// `$` handler, and the ref list that already fed the "+ $ref" menu is exactly
|
||||
// the right suggestion pool. Stubbed the same way the combine editor's tests
|
||||
// stub it — assert prop wiring and simulate emits rather than driving
|
||||
// contenteditable in jsdom.
|
||||
const RichTextInputStub = {
|
||||
name: "RichTextInput",
|
||||
props: ["modelValue", "varSuggestions", "multiline", "rows", "surface", "placeholder", "ariaLabel"],
|
||||
emits: ["update:modelValue"],
|
||||
template: `<div class="wp-rt-stub" data-test="ibm-template" :data-model-value="modelValue"></div>`,
|
||||
};
|
||||
const globalStubs = { RichTextInput: RichTextInputStub };
|
||||
|
||||
/** Read what the template field is currently showing. */
|
||||
function templateValue(w: any): string | undefined {
|
||||
return w.findComponent(RichTextInputStub).props("modelValue");
|
||||
}
|
||||
|
||||
/** Type into the template field. */
|
||||
async function typeTemplate(w: any, value: string): Promise<void> {
|
||||
w.findComponent(RichTextInputStub).vm.$emit("update:modelValue", value);
|
||||
await w.vm.$nextTick();
|
||||
}
|
||||
|
||||
|
||||
// The insert menu is `<Teleport to="body">` — it escapes the modal's scroll
|
||||
// container so it can't be clipped, which means it is NOT inside the test
|
||||
// wrapper's subtree. Query the document for it instead.
|
||||
@@ -25,7 +50,18 @@ async function clickMenuItem(name: string): Promise<void> {
|
||||
const mounted: { unmount: () => void }[] = [];
|
||||
type MountArgs = Parameters<typeof mount<typeof InjectorBindingModal>>;
|
||||
function mountModal(...args: MountArgs): ReturnType<typeof mount<typeof InjectorBindingModal>> {
|
||||
const w = mount(...args);
|
||||
// Stub RichTextInput for every mount here. It is contenteditable with atom
|
||||
// spans, which jsdom does not implement, and these tests are about the
|
||||
// modal's own wiring rather than the editor's internals.
|
||||
const [component, options] = args as [unknown, Record<string, any> | undefined];
|
||||
const merged = {
|
||||
...(options ?? {}),
|
||||
global: {
|
||||
...((options?.global as Record<string, unknown>) ?? {}),
|
||||
stubs: { ...globalStubs, ...((options?.global as any)?.stubs ?? {}) },
|
||||
},
|
||||
};
|
||||
const w = mount(component as never, merged as never);
|
||||
mounted.push(w);
|
||||
return w;
|
||||
}
|
||||
@@ -77,13 +113,13 @@ describe("InjectorBindingModal — template section", () => {
|
||||
const w = mountModal(InjectorBindingModal, {
|
||||
props: { row: makeRow({ template: "i love $input_1" }) },
|
||||
});
|
||||
const ta = w.find<HTMLTextAreaElement>('[data-test="ibm-template"]');
|
||||
expect(ta.element.value).toBe("i love $input_1");
|
||||
const shown = templateValue(w);
|
||||
expect(shown).toBe("i love $input_1");
|
||||
});
|
||||
|
||||
it("Save emits a non-empty template string verbatim (no null collapse)", async () => {
|
||||
const w = mountModal(InjectorBindingModal, { props: { row: makeRow() } });
|
||||
await w.find<HTMLTextAreaElement>('[data-test="ibm-template"]').setValue("i love $input_2");
|
||||
await typeTemplate(w, "i love $input_2");
|
||||
await w.find('[data-test="ibm-save"]').trigger("click");
|
||||
const updates = w.emitted("update")!;
|
||||
const last = updates[updates.length - 1][0] as Partial<InjectorRow>;
|
||||
@@ -95,7 +131,7 @@ describe("InjectorBindingModal — template section", () => {
|
||||
const w = mountModal(InjectorBindingModal, {
|
||||
props: { row: makeRow({ template: "hello" }) },
|
||||
});
|
||||
await w.find<HTMLTextAreaElement>('[data-test="ibm-template"]').setValue(" ");
|
||||
await typeTemplate(w, " ");
|
||||
await w.find('[data-test="ibm-save"]').trigger("click");
|
||||
const updates = w.emitted("update")!;
|
||||
const last = updates[updates.length - 1][0] as Partial<InjectorRow>;
|
||||
@@ -142,8 +178,8 @@ describe("InjectorBindingModal — template section", () => {
|
||||
props: { row: makeRow({ template: "hi" }) },
|
||||
});
|
||||
await w.find('[data-test="ibm-template-reset"]').trigger("click");
|
||||
const ta = w.find<HTMLTextAreaElement>('[data-test="ibm-template"]');
|
||||
expect(ta.element.value).toBe("");
|
||||
const shown = templateValue(w);
|
||||
expect(shown).toBe("");
|
||||
});
|
||||
|
||||
it("typing a bare $ does NOT crash the tokenizer (regression)", () => {
|
||||
@@ -252,7 +288,7 @@ describe("InjectorBindingModal — general (template) row", () => {
|
||||
const w = mountModal(InjectorBindingModal, {
|
||||
props: { row: generalRow({ binding: "combo", template: "$input_0" }), references: ["input_0", "test"] },
|
||||
});
|
||||
await w.find<HTMLTextAreaElement>('[data-test="ibm-template"]').setValue("$input_0 by $test");
|
||||
await typeTemplate(w, "$input_0 by $test");
|
||||
await w.find('[data-test="ibm-save"]').trigger("click");
|
||||
const updates = w.emitted("update")!;
|
||||
const last = updates[updates.length - 1][0] as Partial<InjectorRow>;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import type { InjectorRow } from "../../widgets/_shared";
|
||||
import RichTextInput from "../../manager/components/RichTextInput.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -144,7 +145,6 @@ const insertOptions = computed<InsertOption[]>(() => {
|
||||
}];
|
||||
});
|
||||
|
||||
const taRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const showInsertMenu = ref(false);
|
||||
|
||||
// Draft buffer — mirrors the module InstanceModal pattern. Edits land
|
||||
@@ -175,33 +175,36 @@ function onBindingInput(ev: Event): void {
|
||||
draftBinding.value = (ev.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
function onTemplateInput(ev: Event): void {
|
||||
draftTemplate.value = (ev.target as HTMLTextAreaElement).value;
|
||||
}
|
||||
/**
|
||||
* The `$` pool for this modal, and ONLY this modal's refs.
|
||||
*
|
||||
* A socket row can reference just its own slot; the general row can reference
|
||||
* every socket. That is the same rule `insertOptions` already encodes for the
|
||||
* "+ $ref" menu, so the autocomplete and the menu cannot drift apart — they
|
||||
* read the same list. Deliberately not the library's variables: nothing here
|
||||
* can resolve one.
|
||||
*/
|
||||
const refSuggestionNames = computed(() => insertOptions.value.map((o) => o.slotName));
|
||||
|
||||
function onResetTemplate(): void {
|
||||
draftTemplate.value = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The "+ $ref" menu still appends, but no longer reaches into a textarea's
|
||||
* selection to do it.
|
||||
*
|
||||
* RichTextInput owns its own caret — it is contenteditable with atom spans,
|
||||
* not a plain text box, so `selectionStart` does not exist and the old
|
||||
* splice-at-cursor path had nothing to splice into. Appending is honest about
|
||||
* what it does; the menu is now the fallback anyway, since typing `$` opens
|
||||
* the same list in place.
|
||||
*/
|
||||
function insertSlotRef(slotName: string): void {
|
||||
const ta = taRef.value;
|
||||
const insertion = `$${slotName}`;
|
||||
const current = templateValue.value;
|
||||
let next: string;
|
||||
if (ta && typeof ta.selectionStart === "number") {
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd ?? start;
|
||||
next = current.slice(0, start) + insertion + current.slice(end);
|
||||
const caret = start + insertion.length;
|
||||
queueMicrotask(() => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(caret, caret);
|
||||
});
|
||||
} else {
|
||||
next = current + insertion;
|
||||
}
|
||||
const sep = current && !current.endsWith(" ") ? " " : "";
|
||||
closeInsertMenu();
|
||||
draftTemplate.value = next;
|
||||
draftTemplate.value = `${current}${sep}$${slotName}`;
|
||||
}
|
||||
|
||||
function onSave(): void {
|
||||
@@ -478,18 +481,26 @@ function onKeydown(ev: KeyboardEvent): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="taRef"
|
||||
<!-- RichTextInput rather than a bare <textarea>: the `$` handler it
|
||||
brings is exactly what this field needs, and the ref list feeding
|
||||
the "+ $ref" menu beside it was already the right suggestion pool
|
||||
— it simply had nothing to feed. `assembler` surface because this
|
||||
composes a template; it is NOT `wildcard`, which suppresses `$`
|
||||
on purpose. -->
|
||||
<RichTextInput
|
||||
class="ibm__template"
|
||||
:class="{ 'ibm__template--set': !!templateValue }"
|
||||
data-test="ibm-template"
|
||||
:value="templateValue"
|
||||
:model-value="templateValue"
|
||||
surface="assembler"
|
||||
multiline
|
||||
:rows="3"
|
||||
:var-suggestions="refSuggestionNames"
|
||||
:placeholder="isGeneral
|
||||
? 'e.g. $input_0 by $test — composed after all socket rows'
|
||||
: 'e.g. i love $input_0 — leave empty to pass the raw socket value'"
|
||||
aria-label="Template"
|
||||
rows="3"
|
||||
@input="onTemplateInput"
|
||||
@update:model-value="(v: string) => (draftTemplate = v)"
|
||||
/>
|
||||
|
||||
<div v-if="templateValue" class="ibm__detected">
|
||||
@@ -733,7 +744,9 @@ function onKeydown(ev: KeyboardEvent): void {
|
||||
font: 11px/1.5 var(--wp-font-mono);
|
||||
color: var(--wp-text);
|
||||
min-height: 56px;
|
||||
resize: vertical; overscroll-behavior: contain;
|
||||
/* No `resize: vertical` here. This class is on the RichTextInput root, and
|
||||
the component carries its own drag grip — the native resizer put a second
|
||||
handle in the same corner. Left over from the <textarea> this replaced. */
|
||||
}
|
||||
.ibm__template:focus { outline: none; border-color: var(--wp-accent); }
|
||||
.ibm__template--set {
|
||||
|
||||
@@ -114,10 +114,13 @@ describe("InjectorWidget — template persistence (Phase 6)", () => {
|
||||
editItem!.click();
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('[data-test="ibm-template"]');
|
||||
expect(textarea).not.toBeNull();
|
||||
textarea!.value = "i love $input_0";
|
||||
textarea!.dispatchEvent(new Event("input"));
|
||||
// The template field is a RichTextInput (contenteditable) rather than a
|
||||
// <textarea>, so drive the component instead of assigning `.value`.
|
||||
// jsdom does not implement contenteditable editing, and this test cares
|
||||
// that the value reaches the persisted JSON, not how the caret moved.
|
||||
const rt = w.findComponent({ name: "RichTextInput" });
|
||||
expect(rt.exists()).toBe(true);
|
||||
rt.vm.$emit("update:modelValue", "i love $input_0");
|
||||
await w.vm.$nextTick();
|
||||
|
||||
const saveBtn = document.querySelector<HTMLButtonElement>('[data-test="ibm-save"]');
|
||||
|
||||
@@ -263,7 +263,8 @@ describe("SeedListWidget frame chips", () => {
|
||||
// writing to a different node.
|
||||
it("refuses to change bypass — it is owned by the loop", async () => {
|
||||
const w = fw({}, { bypassedFrames: [1] });
|
||||
await w.find('[data-test="seedlist-frame-2"]').trigger("click", { altKey: true });
|
||||
// Ctrl is the bypass chord now; here bypass is foreign, so it must no-op.
|
||||
await w.find('[data-test="seedlist-frame-2"]').trigger("click", { ctrlKey: true });
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ const reduceMotion = ref<A11yMode>("auto");
|
||||
const contrast = ref<A11yMode>("auto");
|
||||
// Phase 2 — behavior axes
|
||||
const validation = ref<ValidationMode>("strict");
|
||||
const tagAutocomplete = ref<boolean>(false);
|
||||
const loraAutocomplete = ref<boolean>(false);
|
||||
const embeddingAutocomplete = ref<boolean>(false);
|
||||
const autocompleteSeparator = ref<boolean>(false);
|
||||
const toastLifetime = ref<ToastLifetime>("default");
|
||||
const suppressInfoToasts = ref<boolean>(false);
|
||||
const newModuleDisabled = ref<boolean>(false);
|
||||
@@ -103,6 +107,10 @@ function syncFromStore(): void {
|
||||
reduceMotion.value = asString(getSettingValue("reduceMotion"), "auto") as A11yMode;
|
||||
contrast.value = asString(getSettingValue("contrast"), "auto") as A11yMode;
|
||||
validation.value = asString(getSettingValue("validation"), "strict") as ValidationMode;
|
||||
tagAutocomplete.value = getSettingValue("tagAutocomplete") === true;
|
||||
loraAutocomplete.value = getSettingValue("loraAutocomplete") === true;
|
||||
embeddingAutocomplete.value = getSettingValue("embeddingAutocomplete") === true;
|
||||
autocompleteSeparator.value = getSettingValue("autocompleteSeparator") === true;
|
||||
toastLifetime.value = asString(getSettingValue("toastLifetime"), "default") as ToastLifetime;
|
||||
suppressInfoToasts.value = asBool(getSettingValue("suppressInfoToasts"), false);
|
||||
newModuleDisabled.value = asBool(getSettingValue("newModuleDisabled"), false);
|
||||
@@ -132,6 +140,10 @@ watch(focusMode, (v) => applySetting("focusMode", v));
|
||||
watch(reduceMotion, (v) => applySetting("reduceMotion", v));
|
||||
watch(contrast, (v) => applySetting("contrast", v));
|
||||
watch(validation, (v) => applySetting("validation", v));
|
||||
watch(tagAutocomplete, (v) => applySetting("tagAutocomplete", v));
|
||||
watch(loraAutocomplete, (v) => applySetting("loraAutocomplete", v));
|
||||
watch(embeddingAutocomplete, (v) => applySetting("embeddingAutocomplete", v));
|
||||
watch(autocompleteSeparator, (v) => applySetting("autocompleteSeparator", v));
|
||||
watch(toastLifetime, (v) => applySetting("toastLifetime", v));
|
||||
watch(suppressInfoToasts, (v) => applySetting("suppressInfoToasts", v));
|
||||
watch(newModuleDisabled, (v) => applySetting("newModuleDisabled", v));
|
||||
@@ -152,6 +164,10 @@ interface Defaults {
|
||||
reduceMotion: A11yMode;
|
||||
contrast: A11yMode;
|
||||
validation: ValidationMode;
|
||||
tagAutocomplete: boolean;
|
||||
loraAutocomplete: boolean;
|
||||
embeddingAutocomplete: boolean;
|
||||
autocompleteSeparator: boolean;
|
||||
toastLifetime: ToastLifetime;
|
||||
suppressInfoToasts: boolean;
|
||||
newModuleDisabled: boolean;
|
||||
@@ -173,6 +189,10 @@ const defaults: Defaults = {
|
||||
reduceMotion: "auto",
|
||||
contrast: "auto",
|
||||
validation: "strict",
|
||||
tagAutocomplete: false,
|
||||
loraAutocomplete: false,
|
||||
embeddingAutocomplete: false,
|
||||
autocompleteSeparator: false,
|
||||
toastLifetime: "default",
|
||||
suppressInfoToasts: false,
|
||||
newModuleDisabled: false,
|
||||
@@ -369,6 +389,30 @@ onBeforeUnmount(() => {
|
||||
|
||||
<fieldset class="wp-pg__group">
|
||||
<legend class="wp-pg__group-title">Runtime behavior</legend>
|
||||
<!-- Behaviour, not visuals, so there is nothing to preview on
|
||||
the right. It lives here anyway because the playground is
|
||||
where the settings are expected to be mirrored, and a
|
||||
setting that exists in the panel but not here is exactly the
|
||||
divergence this modal keeps growing. -->
|
||||
<label class="wp-pg__row">
|
||||
<span class="wp-pg__row-label">Booru tag autocomplete</span>
|
||||
<input v-model="tagAutocomplete" type="checkbox" class="wp-pg__check">
|
||||
</label>
|
||||
<!-- Separate switches on purpose: someone writing danbooru
|
||||
prompts may have no LoRAs at all, and someone with 200 of
|
||||
them may find the tag list noise. -->
|
||||
<label class="wp-pg__row">
|
||||
<span class="wp-pg__row-label">LoRA autocomplete</span>
|
||||
<input v-model="loraAutocomplete" type="checkbox" class="wp-pg__check">
|
||||
</label>
|
||||
<label class="wp-pg__row">
|
||||
<span class="wp-pg__row-label">Embedding autocomplete</span>
|
||||
<input v-model="embeddingAutocomplete" type="checkbox" class="wp-pg__check">
|
||||
</label>
|
||||
<label class="wp-pg__row">
|
||||
<span class="wp-pg__row-label">Append ", " after a completion</span>
|
||||
<input v-model="autocompleteSeparator" type="checkbox" class="wp-pg__check">
|
||||
</label>
|
||||
<label class="wp-pg__row">
|
||||
<span class="wp-pg__row-label">Validation strictness</span>
|
||||
<select v-model="validation" class="wp-pg__select">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Every setting in ComfyUI's panel must also appear in the Display Playground.
|
||||
*
|
||||
* The playground is where users expect to see the settings mirrored, and
|
||||
* keeping the two in step has been a manual chore — `playground-store.ts` even
|
||||
* carries a comment asking the next person to "keep this routing in sync if a
|
||||
* new key is added". A comment is not a mechanism: the tag-autocomplete setting
|
||||
* was added to the panel under the wrong category, in a namespace matching none
|
||||
* of the three the playground routes, and with no playground row at all. It
|
||||
* took a screenshot to notice.
|
||||
*
|
||||
* This is that mechanism. It reads the two sources and fails when they drift.
|
||||
*/
|
||||
const read = (rel: string) =>
|
||||
readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
|
||||
|
||||
const settingsSrc = read("../../../extension/settings.ts");
|
||||
const playgroundSrc = read("../DisplayPlaygroundModal.vue");
|
||||
const storeSrc = read("../playground-store.ts");
|
||||
|
||||
/**
|
||||
* Setting ids registered into ComfyUI's panel.
|
||||
*
|
||||
* Matches the id STRINGS anywhere in the file rather than `id:` sites, because
|
||||
* most entries reference a `SETTING_ID_*` constant declared further up. An
|
||||
* earlier version of this regex looked only at `id:` and found exactly one
|
||||
* setting — which the "finds them at all" assertion below caught immediately,
|
||||
* and is why that assertion exists.
|
||||
*/
|
||||
function registeredIds(): string[] {
|
||||
return [...new Set(
|
||||
[...settingsSrc.matchAll(/"(wildcardPipeline\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+)"/g)]
|
||||
.map((m) => m[1]),
|
||||
)]
|
||||
// Leading underscore marks a launcher/action row rather than a value —
|
||||
// the playground's own "Open playground" button is one, and mirroring it
|
||||
// inside the playground would be circular.
|
||||
.filter((id) => !id.split(".").pop()!.startsWith("_"));
|
||||
}
|
||||
|
||||
/** Namespaces the playground store knows how to route a key into. */
|
||||
const ROUTED_NAMESPACES = ["display", "a11y", "behavior"];
|
||||
|
||||
describe("playground mirrors the settings panel", () => {
|
||||
const ids = registeredIds();
|
||||
|
||||
it("finds the registered settings at all (guards the regex itself)", () => {
|
||||
expect(ids.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it.each(ids)("%s uses a namespace the playground can route", (id) => {
|
||||
const namespace = id.split(".")[1];
|
||||
expect(ROUTED_NAMESPACES).toContain(namespace);
|
||||
});
|
||||
|
||||
it.each(ids)("%s has a key the playground store recognises", (id) => {
|
||||
const key = id.split(".").pop()!;
|
||||
// Either listed in a routing set, or reachable as the default `display`
|
||||
// namespace — both mean `settingId()` can resolve it.
|
||||
expect(storeSrc).toContain(`"${key}"`);
|
||||
});
|
||||
|
||||
it.each(ids)("%s is bound to a control in the playground", (id) => {
|
||||
const key = id.split(".").pop()!;
|
||||
// The modal reads with getSettingValue and writes with applySetting; a
|
||||
// setting present in the panel but absent here is the drift we are after.
|
||||
expect(playgroundSrc).toContain(`applySetting("${key}"`);
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,11 @@ export type BehaviorKey =
|
||||
| "suppressInfoToasts"
|
||||
| "newModuleDisabled"
|
||||
| "confirmDestructiveBundle"
|
||||
| "bundleMasterOffBehavior";
|
||||
| "bundleMasterOffBehavior"
|
||||
| "tagAutocomplete"
|
||||
| "loraAutocomplete"
|
||||
| "embeddingAutocomplete"
|
||||
| "autocompleteSeparator";
|
||||
|
||||
export type SettingKey = DisplayKey | A11yKey | BehaviorKey;
|
||||
|
||||
@@ -72,6 +76,10 @@ const BEHAVIOR_KEYS = new Set<string>([
|
||||
"newModuleDisabled",
|
||||
"confirmDestructiveBundle",
|
||||
"bundleMasterOffBehavior",
|
||||
"tagAutocomplete",
|
||||
"loraAutocomplete",
|
||||
"embeddingAutocomplete",
|
||||
"autocompleteSeparator",
|
||||
]);
|
||||
|
||||
function settingId(key: SettingKey): string {
|
||||
|
||||
@@ -29,20 +29,20 @@ describe("FrameChips", () => {
|
||||
const sel = (extra = {}) =>
|
||||
mount(FrameChips, { props: { count: 4, selectable: true, showBase: true, bypassInteractive: true, ...extra } });
|
||||
|
||||
it("plain click selects, shift locks, alt bypasses", async () => {
|
||||
it("plain click selects, alt locks, ctrl bypasses", async () => {
|
||||
const w = sel();
|
||||
await at(w, 2).trigger("click");
|
||||
expect(w.emitted("select")?.[0]).toEqual([1]);
|
||||
await at(w, 3).trigger("click", { shiftKey: true });
|
||||
await at(w, 3).trigger("click", { altKey: true });
|
||||
expect(w.emitted("toggleLock")?.[0]).toEqual([2]);
|
||||
await at(w, 4).trigger("click", { altKey: true });
|
||||
await at(w, 4).trigger("click", { ctrlKey: true });
|
||||
expect(w.emitted("toggleBypass")?.[0]).toEqual([3]);
|
||||
});
|
||||
|
||||
it("a modifier click never also selects", async () => {
|
||||
const w = sel();
|
||||
await at(w, 2).trigger("click", { shiftKey: true });
|
||||
await at(w, 3).trigger("click", { altKey: true });
|
||||
await at(w, 2).trigger("click", { altKey: true });
|
||||
await at(w, 3).trigger("click", { ctrlKey: true });
|
||||
expect(w.emitted("select")).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -67,9 +67,9 @@ describe("FrameChips", () => {
|
||||
expect(w.emitted("select")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("alt does nothing when bypass belongs to another node", async () => {
|
||||
it("ctrl does nothing when bypass belongs to another node", async () => {
|
||||
const w = plain({ bypassed: [1] });
|
||||
await at(w, 2).trigger("click", { altKey: true });
|
||||
await at(w, 2).trigger("click", { ctrlKey: true });
|
||||
expect(w.emitted("toggleBypass")).toBeUndefined();
|
||||
expect(w.emitted("toggleLock")).toBeUndefined();
|
||||
});
|
||||
@@ -137,7 +137,8 @@ describe("FrameChips", () => {
|
||||
|
||||
it("prints the combos that apply to this mode", () => {
|
||||
const loop = mount(FrameChips, { props: { count: 2, selectable: true } });
|
||||
expect(loop.find('[data-test="loop-frames-hint"]').text()).toMatch(/shift/i);
|
||||
expect(loop.find('[data-test="loop-frames-hint"]').text()).toMatch(/alt-click locks/i);
|
||||
expect(loop.find('[data-test="loop-frames-hint"]').text()).toMatch(/ctrl-click bypasses/i);
|
||||
const list = mount(FrameChips, { props: { count: 2 } });
|
||||
expect(list.find('[data-test="loop-frames-hint"]').text()).toMatch(/click a frame to lock/i);
|
||||
expect(list.find('[data-test="loop-frames-hint"]').text()).not.toMatch(/shift/i);
|
||||
|
||||
@@ -37,7 +37,7 @@ const props = withDefaults(
|
||||
selectable?: boolean;
|
||||
/** Render the `base` chip. Only the Loop has values every frame inherits. */
|
||||
showBase?: boolean;
|
||||
/** Alt-click may toggle bypass. False where bypass belongs to another node. */
|
||||
/** Ctrl/Cmd-click may toggle bypass. False where bypass belongs to another node. */
|
||||
bypassInteractive?: boolean;
|
||||
/** Section heading. */
|
||||
label?: string;
|
||||
@@ -101,15 +101,26 @@ const summary = computed(() => {
|
||||
return bits.join(" · ");
|
||||
});
|
||||
|
||||
const IS_MAC =
|
||||
typeof navigator !== "undefined" && /Mac|iP(hone|ad|od)/i.test(navigator.userAgent);
|
||||
|
||||
/** Option on a Mac keyboard, Alt everywhere else. `altKey` covers both; only
|
||||
* the printed label differs. */
|
||||
const ALT_LABEL =
|
||||
typeof navigator !== "undefined" && /Mac|iP(hone|ad|od)/i.test(navigator.userAgent)
|
||||
? "⌥"
|
||||
: "Alt";
|
||||
const ALT_LABEL = IS_MAC ? "⌥" : "Alt";
|
||||
|
||||
/** Bypass modifier: Cmd on macOS, Ctrl everywhere else. Cmd rather than Ctrl on
|
||||
* Mac because a Ctrl-click there is a right-click and never reaches this
|
||||
* handler as a plain click. */
|
||||
const CTRL_LABEL = IS_MAC ? "⌘" : "Ctrl";
|
||||
|
||||
/** True when the event carries the bypass modifier for this platform. */
|
||||
function isBypassChord(ev: MouseEvent): boolean {
|
||||
return IS_MAC ? ev.metaKey : ev.ctrlKey;
|
||||
}
|
||||
|
||||
function onChipClick(i: number, ev: MouseEvent): void {
|
||||
if (ev.altKey) {
|
||||
// Ctrl (Cmd on macOS) toggles bypass, wherever bypass is interactive.
|
||||
if (isBypassChord(ev)) {
|
||||
if (props.bypassInteractive) emit("toggleBypass", i);
|
||||
return;
|
||||
}
|
||||
@@ -118,7 +129,8 @@ function onChipClick(i: number, ev: MouseEvent): void {
|
||||
emit("toggleLock", i);
|
||||
return;
|
||||
}
|
||||
if (ev.shiftKey) { emit("toggleLock", i); return; }
|
||||
// Alt toggles the seed lock; a plain click is spoken for by the edit cursor.
|
||||
if (ev.altKey) { emit("toggleLock", i); return; }
|
||||
emit("select", i);
|
||||
}
|
||||
|
||||
@@ -130,12 +142,12 @@ function chipTitle(i: number): string {
|
||||
const lines = [`Frame ${i + 1}${state ? ` — ${state}` : ""}`];
|
||||
const lockVerb = lockedSet.value.has(i) ? "Unlock" : "Lock";
|
||||
if (props.selectable) {
|
||||
lines.push("Click to edit this frame", `Shift-click to ${lockVerb.toLowerCase()} its seed`);
|
||||
lines.push("Click to edit this frame", `${ALT_LABEL}-click to ${lockVerb.toLowerCase()} its seed`);
|
||||
} else {
|
||||
lines.push(`Click to ${lockVerb.toLowerCase()} its seed`);
|
||||
}
|
||||
if (props.bypassInteractive) {
|
||||
lines.push(`${ALT_LABEL}-click to ${bypassedSet.value.has(i) ? "re-enable" : "bypass"} it`);
|
||||
lines.push(`${CTRL_LABEL}-click to ${bypassedSet.value.has(i) ? "re-enable" : "bypass"} it`);
|
||||
} else if (bypassedSet.value.has(i) && props.bypassReadonlyHint) {
|
||||
lines.push(props.bypassReadonlyHint);
|
||||
}
|
||||
@@ -155,7 +167,7 @@ const uiId = computed(() => `${props.testId}s`);
|
||||
* shortcut, and this row is where the reader is already looking. */
|
||||
const hint = computed(() => {
|
||||
if (!props.selectable) return "Click a frame to lock its seed";
|
||||
return `Shift-click locks a seed · ${ALT_LABEL}-click bypasses`;
|
||||
return `${ALT_LABEL}-click locks a seed · ${CTRL_LABEL}-click bypasses`;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ describe("useGrowableField — the drag itself", () => {
|
||||
}) as DOMRect;
|
||||
api.startResize(gripDown(500));
|
||||
const move = (y: number) => window.dispatchEvent(
|
||||
Object.assign(new MouseEvent("pointermove", { clientY: y }), { pointerId: 1 }),
|
||||
Object.assign(new MouseEvent("pointermove", { clientY: y, buttons: 1 }), { pointerId: 1 }),
|
||||
);
|
||||
move(520);
|
||||
expect(node.style.height).toBe("120px");
|
||||
@@ -236,9 +236,180 @@ describe("useGrowableField — the drag itself", () => {
|
||||
}) as DOMRect;
|
||||
api.startResize(gripDown(500));
|
||||
window.dispatchEvent(
|
||||
Object.assign(new MouseEvent("pointermove", { clientY: 400 }), { pointerId: 1 }),
|
||||
Object.assign(new MouseEvent("pointermove", { clientY: 400, buttons: 1 }), { pointerId: 1 }),
|
||||
);
|
||||
expect(node.style.height).toBe("34px");
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGrowableField — re-measures after the webfonts land", () => {
|
||||
/**
|
||||
* These fields are typed in `--wp-font-mono`, a bundled webfont declared
|
||||
* `font-display: swap`. The first paint uses the system fallback, the real
|
||||
* face swaps in later, and the two stacks have different metrics — so the
|
||||
* height measured at mount was taken against type about to be replaced.
|
||||
*
|
||||
* That is the "sometimes the bottom fade is wrong" report: wrong on a cold
|
||||
* load, right on a warm cache, and fixed by accident by any later scroll or
|
||||
* input. Deterministic once you know the font is the variable.
|
||||
*/
|
||||
it("re-runs the overflow hint once document.fonts.ready resolves", async () => {
|
||||
let resolveFonts!: () => void;
|
||||
const ready = new Promise<void>((res) => { resolveFonts = res; });
|
||||
Object.defineProperty(document, "fonts", {
|
||||
value: { ready }, configurable: true,
|
||||
});
|
||||
|
||||
const { api, node, wrap } = mountField();
|
||||
// Fallback metrics: content fits, so no fade.
|
||||
Object.defineProperty(node, "scrollHeight", { value: 100, configurable: true });
|
||||
Object.defineProperty(node, "clientHeight", { value: 100, configurable: true });
|
||||
Object.defineProperty(node, "scrollTop", { value: 0, configurable: true });
|
||||
api.updateOverflowHint();
|
||||
expect(api.hasMoreBelow.value).toBe(false);
|
||||
|
||||
// The real face is taller — the same text now overflows.
|
||||
Object.defineProperty(node, "scrollHeight", { value: 260, configurable: true });
|
||||
resolveFonts();
|
||||
await ready;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(api.hasMoreBelow.value).toBe(true);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("does not throw where the host exposes no font loading API", async () => {
|
||||
Object.defineProperty(document, "fonts", { value: undefined, configurable: true });
|
||||
const { wrap } = mountField();
|
||||
await Promise.resolve();
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGrowableField — grip-follow only while a grip is held", () => {
|
||||
/**
|
||||
* `followGrip` keeps the resize handle under the cursor by scrolling the
|
||||
* container by the overshoot. Ungated it also ran on content-driven growth,
|
||||
* so typing a long enough value scrolled the page out from under the user —
|
||||
* and it made the resize flush mutate scroll position, which moves the very
|
||||
* element the observer is measuring.
|
||||
*/
|
||||
function mountInScroller(height: number) {
|
||||
const scroller = document.createElement("div");
|
||||
scroller.style.overflowY = "auto";
|
||||
document.body.appendChild(scroller);
|
||||
const { wrap, api, node } = mountField(height);
|
||||
scroller.appendChild(node);
|
||||
scroller.getBoundingClientRect = () => ({
|
||||
height: 50, bottom: 50, top: 0, left: 0, right: 0, width: 100, x: 0, y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
scroller.scrollTop = 0;
|
||||
return { wrap, api, node, scroller };
|
||||
}
|
||||
|
||||
it("does not scroll the container when the content simply grew", () => {
|
||||
const { wrap, node, scroller } = mountInScroller(100);
|
||||
// Element now overshoots the scroller's bottom edge by a long way.
|
||||
node.getBoundingClientRect = () => ({
|
||||
height: 400, bottom: 400, top: 0, left: 0, right: 0, width: 100, x: 0, y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
observerCallback?.();
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
wrap.unmount();
|
||||
scroller.remove();
|
||||
});
|
||||
|
||||
it("does scroll while the handle is actually being dragged", () => {
|
||||
const { wrap, api, node, scroller } = mountInScroller(100);
|
||||
api.startResize({
|
||||
clientY: 10, pointerId: 1, preventDefault() {}, currentTarget: null,
|
||||
} as unknown as PointerEvent);
|
||||
node.getBoundingClientRect = () => ({
|
||||
height: 400, bottom: 400, top: 0, left: 0, right: 0, width: 100, x: 0, y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
observerCallback?.();
|
||||
expect(scroller.scrollTop).toBeGreaterThan(0);
|
||||
wrap.unmount();
|
||||
scroller.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGrowableField — a drag can always end", () => {
|
||||
/**
|
||||
* Reported symptom: "the resize is linked to my mouse and resizes as I move
|
||||
* my mouse around without holding, and can't be interrupted no matter what I
|
||||
* do or press."
|
||||
*
|
||||
* That is one state — `dragging` stuck true — reachable whenever a single
|
||||
* `pointerup` is missed. It was reachable two ways: `setPointerCapture`
|
||||
* retargeted every later pointer event to the grip, so a re-render that
|
||||
* replaced that element delivered the `pointerup` to a detached node where it
|
||||
* never bubbled; and the listeners sat in bubble phase, so any
|
||||
* `stopPropagation()` in between hid it too. Nothing then ever cleared the
|
||||
* flag, which is why no key or click could stop it.
|
||||
*/
|
||||
function beginDrag(api: ReturnType<typeof useGrowableField>) {
|
||||
api.startResize({
|
||||
clientY: 100, pointerId: 1, preventDefault() {}, currentTarget: null,
|
||||
} as unknown as PointerEvent);
|
||||
}
|
||||
|
||||
const move = (y: number, buttons: number) =>
|
||||
window.dispatchEvent(
|
||||
Object.assign(new Event("pointermove", { bubbles: true }), { clientY: y, buttons }),
|
||||
);
|
||||
|
||||
it("stops resizing after a pointerup, even one that never reached the window", () => {
|
||||
const { api, node, wrap } = mountField(100);
|
||||
beginDrag(api);
|
||||
move(140, 1);
|
||||
const grown = node.style.height;
|
||||
expect(grown).not.toBe("");
|
||||
|
||||
// The pointerup is simply never delivered — the stranded-capture case.
|
||||
// The next move reports no buttons held, which is the only honest signal
|
||||
// left that the drag is over.
|
||||
move(300, 0);
|
||||
const afterRelease = node.style.height;
|
||||
move(500, 0);
|
||||
expect(node.style.height).toBe(afterRelease);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("ends on a pointerup seen in capture phase", () => {
|
||||
const { api, node, wrap } = mountField(100);
|
||||
beginDrag(api);
|
||||
move(140, 1);
|
||||
const held = node.style.height;
|
||||
window.dispatchEvent(new Event("pointerup", { bubbles: true }));
|
||||
move(400, 1); // button still reported down, but the drag ended
|
||||
expect(node.style.height).toBe(held);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("ends on Escape, the key a stuck user actually presses", () => {
|
||||
const { api, node, wrap } = mountField(100);
|
||||
beginDrag(api);
|
||||
move(140, 1);
|
||||
const held = node.style.height;
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
move(400, 1);
|
||||
expect(node.style.height).toBe(held);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("ends when the window loses focus", () => {
|
||||
const { api, node, wrap } = mountField(100);
|
||||
beginDrag(api);
|
||||
move(140, 1);
|
||||
const held = node.style.height;
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
move(400, 1);
|
||||
expect(node.style.height).toBe(held);
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,3 +24,14 @@
|
||||
.wp-rt-preview .wp-rt-dp-multi { color: var(--wp-rt-token-good); font-weight: 500; }
|
||||
.wp-rt .wp-rt-dp-weight,
|
||||
.wp-rt-preview .wp-rt-dp-weight { color: var(--wp-rt-token-mut); font-weight: 500; }
|
||||
|
||||
/* Var accessor tails — canvas mirror of manager/styles/rich-text.css. The AXIS
|
||||
segment (`.SHOES`) is amber, the one part of the accessor that names a
|
||||
library concept; the pick INDEX (`.0`) stays the variable's own colour,
|
||||
dimmed, because it is positional and — being a runtime-only, multi-pick
|
||||
concept — is nothing a static surface can validate or should flag. Keep in
|
||||
sync with the SPA copy. */
|
||||
.wp-rt .wp-rt-var__accessor,
|
||||
.wp-rt-preview .wp-rt-var__accessor { color: var(--wp-axis, #fbbf24); font-weight: 600; }
|
||||
.wp-rt .wp-rt-var__index,
|
||||
.wp-rt-preview .wp-rt-var__index { opacity: 0.6; font-weight: 600; }
|
||||
|
||||
@@ -165,7 +165,15 @@ export function useGrowableField(
|
||||
// cursor. Deferring this to pointerup (an earlier attempt at the stall,
|
||||
// which turned out to be the height cap) just made the field vanish off
|
||||
// the bottom for the whole drag.
|
||||
if (h > lastHeight) followGrip();
|
||||
//
|
||||
// Gated on `dragging`, which is what the name always implied. It exists to
|
||||
// keep the GRIP under the CURSOR, and there is no grip being held unless a
|
||||
// drag is in progress. Ungated it also ran on content-driven growth — so
|
||||
// typing a long enough template scrolled the page out from under you — and
|
||||
// it made this flush mutate scroll position, which moves the element the
|
||||
// observer is measuring. A resize handler that can change its own input is
|
||||
// the shape a runaway needs.
|
||||
if (dragging && h > lastHeight) followGrip();
|
||||
lastHeight = h;
|
||||
}
|
||||
|
||||
@@ -221,15 +229,51 @@ export function useGrowableField(
|
||||
el.style.maxHeight = "none";
|
||||
capLifted = true;
|
||||
}
|
||||
(ev.currentTarget as HTMLElement | null)?.setPointerCapture?.(ev.pointerId);
|
||||
window.addEventListener("pointermove", onResizeMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
window.addEventListener("pointercancel", onPointerUp);
|
||||
/* No `setPointerCapture`.
|
||||
*
|
||||
* It reads like the right call and it is what stranded the drag. Capture
|
||||
* retargets every subsequent pointer event to the grip — including
|
||||
* `pointerup`. The editor re-renders while it resizes, and a captured
|
||||
* element that gets replaced is detached: the `pointerup` is delivered to a
|
||||
* node no longer in the tree, so it never bubbles to `window`, `dragging`
|
||||
* stays true, and the box follows the cursor with no button held and no way
|
||||
* to stop it. That is the reported symptom exactly.
|
||||
*
|
||||
* Capture bought nothing here anyway. The listeners below are on `window`,
|
||||
* which sees the events regardless of what is under the cursor.
|
||||
*
|
||||
* CAPTURE PHASE on all three, so a `stopPropagation()` between the target
|
||||
* and the window — ComfyUI's node layer does this to stop the canvas
|
||||
* panning under a widget — cannot hide the end of the drag from us.
|
||||
*/
|
||||
window.addEventListener("pointermove", onResizeMove, true);
|
||||
window.addEventListener("pointerup", onPointerUp, true);
|
||||
window.addEventListener("pointercancel", onPointerUp, true);
|
||||
// A fresh press means the previous drag is over however it ended.
|
||||
window.addEventListener("pointerdown", onPointerUp, true);
|
||||
window.addEventListener("blur", onPointerUp);
|
||||
window.addEventListener("keydown", onDragEscape, true);
|
||||
}
|
||||
|
||||
/** Escape ends a drag. Cheap, and it is the key a stuck user presses. */
|
||||
function onDragEscape(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") onPointerUp();
|
||||
}
|
||||
|
||||
function onResizeMove(ev: PointerEvent): void {
|
||||
const el = getEl();
|
||||
if (!el || !dragging) return;
|
||||
// Self-heal. Every pointermove reports which buttons are down, so a move
|
||||
// with none held means the pointerup was lost — whatever swallowed it —
|
||||
// and the drag should already be over. Without this the field follows the
|
||||
// cursor forever and no key or click can stop it, because nothing else in
|
||||
// here ever clears `dragging`. Belt and braces on top of the listener
|
||||
// changes above: those address the causes we found, this ends the state
|
||||
// regardless of a cause we did not.
|
||||
if (ev.buttons === 0) {
|
||||
onPointerUp();
|
||||
return;
|
||||
}
|
||||
const dy = ev.clientY - lastPointerY;
|
||||
lastPointerY = ev.clientY;
|
||||
const cur = el.getBoundingClientRect().height;
|
||||
@@ -239,6 +283,14 @@ export function useGrowableField(
|
||||
function onPointerUp(): void {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
// Re-arm for the next drag. `capLifted` guards a one-time "pin the current
|
||||
// height, release the max-height cap" step, and leaving it set meant the
|
||||
// release survived for the lifetime of the field while the explicit height
|
||||
// that bounded it did not — `applyAtoms` replaces the host element on a
|
||||
// rebuild, which drops the inline height but not this flag. The next drag
|
||||
// then found an uncapped, unpinned box. Each drag now re-establishes both
|
||||
// against the element that actually exists.
|
||||
capLifted = false;
|
||||
window.removeEventListener("pointermove", onResizeMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
window.removeEventListener("pointercancel", onPointerUp);
|
||||
@@ -255,6 +307,33 @@ export function useGrowableField(
|
||||
const el = getEl();
|
||||
if (!el) return;
|
||||
lastHeight = el.getBoundingClientRect().height;
|
||||
|
||||
/* Re-measure once the webfonts land.
|
||||
*
|
||||
* These fields are typed in `--wp-font-mono`, a bundled webfont declared
|
||||
* `font-display: swap` — so the first paint uses the system fallback
|
||||
* (Consolas / Monaco) and the real face swaps in later. The two stacks
|
||||
* have different metrics, so the `scrollHeight` measured at mount is taken
|
||||
* against type that is about to be replaced.
|
||||
*
|
||||
* That is the "sometimes the bottom fade is wrong" report: on a cold load
|
||||
* the hint is computed against the fallback and never recomputed, and on a
|
||||
* warm cache the font is already there so it looks fine. Any later scroll,
|
||||
* input or resize also fixes it by accident, which is what made it look
|
||||
* intermittent rather than deterministic.
|
||||
*
|
||||
* `document.fonts.ready` resolves once, after all pending faces settle.
|
||||
* Cheap, and a no-op where the font was already cached.
|
||||
*/
|
||||
if (typeof document !== "undefined" && document.fonts?.ready) {
|
||||
void document.fonts.ready.then(() => {
|
||||
// The field may have unmounted during the load.
|
||||
if (!getEl()) return;
|
||||
updateOverflowHint();
|
||||
autosize();
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
obs = new ResizeObserver(schedule);
|
||||
obs.observe(el);
|
||||
|
||||
@@ -90,3 +90,17 @@ export const nodeBadgePulseToken: WeakMap<object, number> = new WeakMap();
|
||||
* persist across animations (last value retained), so `.has(node)`
|
||||
* is true forever after the first collapse. */
|
||||
export const nodeCollapseAnimating: WeakSet<object> = new WeakSet();
|
||||
|
||||
/** Caret-aware insert hook for a node's template editor, published by
|
||||
* `widgets/templateEditor.ts` when it mounts and read by the assembler
|
||||
* helper's chip strip.
|
||||
*
|
||||
* Needed because the helper and the editor are two SEPARATE widgets on
|
||||
* the same node — the helper can reach the editor's VALUE through
|
||||
* `widget.value`, but a value write replaces the whole string and loses
|
||||
* the caret. Before Vue Nodes the helper spliced into `widget.inputEl`
|
||||
* (a real textarea); that element is now detached and unrendered, so the
|
||||
* only way to insert AT THE CARET is to ask the editor to do it. Absent
|
||||
* entry ⇒ the editor hasn't mounted yet, and callers fall back to a
|
||||
* value-level append. */
|
||||
export const templateInsertAtCaret: WeakMap<object, (token: string) => void> = new WeakMap();
|
||||
|
||||
@@ -1562,3 +1562,88 @@ describe("scanConflicts — combine @{} templates are an intentional non-gap", (
|
||||
expect(scanConflicts(value, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A wildcard writing `binding`, optionally declaring `accepts` axes. */
|
||||
const wc = (
|
||||
id: string,
|
||||
binding: string,
|
||||
kinds: Record<string, string> = {},
|
||||
): ContextWidgetValue["modules"][number] => ({
|
||||
id, type: "wildcard", enabled: true, meta: { name: "" }, entries: [],
|
||||
payload: {
|
||||
var_binding: binding,
|
||||
sub_categories: ["sneakers"],
|
||||
tag_groups: { SHOES: ["sneakers"] },
|
||||
...(Object.keys(kinds).length > 0 ? { tag_group_kinds: kinds } : {}),
|
||||
options: [{ id: "o1", value: "tee", weight: 1, sub_categories: ["sneakers"] }],
|
||||
},
|
||||
} as ContextWidgetValue["modules"][number]);
|
||||
|
||||
/** A combine reading `template`. */
|
||||
const cmb = (id: string, template: string): ContextWidgetValue["modules"][number] => ({
|
||||
id, type: "combine", enabled: true, meta: { name: "" }, entries: [],
|
||||
payload: { template, output_var: "out" },
|
||||
} as ContextWidgetValue["modules"][number]);
|
||||
|
||||
describe("scanConflicts — unknown_tag_axis", () => {
|
||||
it("flags a $var.AXIS read when the producing wildcard declares no such axis", () => {
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
modules: [wc("w1", "outfit", { SHOES: "accepts" }), cmb("c1", "wearing $outfit.BELTS")],
|
||||
};
|
||||
const out = scanConflicts(value, []);
|
||||
expect(out).toContainEqual(expect.objectContaining({
|
||||
type: "unknown_tag_axis", variable: "outfit.BELTS", severity: "warning",
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not flag a declared axis", () => {
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
modules: [wc("w1", "outfit", { SHOES: "accepts" }), cmb("c1", "wearing $outfit.SHOES")],
|
||||
};
|
||||
const out = scanConflicts(value, []);
|
||||
expect(out.every((c) => c.type !== "unknown_tag_axis")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags an axis whose group exists but was never promoted", () => {
|
||||
// The commonest mistake by far: the group is right there, it just still
|
||||
// says classify.
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
modules: [wc("w1", "outfit"), cmb("c1", "wearing $outfit.SHOES")],
|
||||
};
|
||||
const out = scanConflicts(value, []);
|
||||
expect(out).toContainEqual(expect.objectContaining({ type: "unknown_tag_axis" }));
|
||||
});
|
||||
|
||||
it("accepts either accessor order", () => {
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
modules: [
|
||||
wc("w1", "outfit", { SHOES: "accepts" }),
|
||||
cmb("c1", "$outfit.0.SHOES and $outfit.SHOES.0"),
|
||||
],
|
||||
};
|
||||
const out = scanConflicts(value, []);
|
||||
expect(out.every((c) => c.type !== "unknown_tag_axis")).toBe(true);
|
||||
});
|
||||
|
||||
it("stays silent when the producer is not in this node", () => {
|
||||
// The scanner receives upstream vars as NAMES only, so it cannot see an
|
||||
// upstream wildcard's groups. Warning here would fire on correct
|
||||
// workflows; under-reporting beats a warning nobody can act on.
|
||||
const value: ContextWidgetValue = { version: 1, modules: [cmb("c1", "$outfit.SHOES")] };
|
||||
const out = scanConflicts(value, ["outfit"]);
|
||||
expect(out.every((c) => c.type !== "unknown_tag_axis")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports one entry per distinct name.axis, not per occurrence", () => {
|
||||
const value: ContextWidgetValue = {
|
||||
version: 1,
|
||||
modules: [wc("w1", "outfit"), cmb("c1", "$outfit.SHOES $outfit.SHOES $outfit.SHOES")],
|
||||
};
|
||||
const out = scanConflicts(value, []);
|
||||
expect(out.filter((c) => c.type === "unknown_tag_axis")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +149,10 @@ export type ConflictType =
|
||||
// collapse into the two `orphan_*` types below. `_missing` rules stay
|
||||
// for the "uuid not in any catalog" case which is independent of
|
||||
// position.
|
||||
// A `$var.AXIS` read naming a group no in-node wildcard declares `accepts`.
|
||||
// Commonest cause is a group that exists but was never promoted; second
|
||||
// commonest is an axis renamed after the read was written.
|
||||
| "unknown_tag_axis"
|
||||
| "constraint_source_missing"
|
||||
| "constraint_target_missing"
|
||||
| "constraint_orphan_source" // no source instance upstream
|
||||
@@ -190,6 +194,7 @@ export function labelFor(type: ConflictType): string {
|
||||
if (type === "shadows_upstream") return "overrides upstream";
|
||||
if (type === "duplicate_variable") return "duplicate";
|
||||
if (type === "missing_template_variable") return "missing";
|
||||
if (type === "unknown_tag_axis") return "unknown tag axis — is that group marked 'accepts'?";
|
||||
if (type === "constraint_source_missing") return "source missing";
|
||||
if (type === "constraint_target_missing") return "target missing";
|
||||
if (type === "constraint_orphan_source") return "source missing — no upstream instance";
|
||||
@@ -210,6 +215,7 @@ export function shortConflictLabel(type: ConflictType): string {
|
||||
case "shadows_upstream": return "override";
|
||||
case "duplicate_variable": return "duplicate";
|
||||
case "missing_template_variable": return "missing var";
|
||||
case "unknown_tag_axis": return "unknown axis";
|
||||
case "constraint_source_missing": return "src missing";
|
||||
case "constraint_target_missing": return "tgt missing";
|
||||
case "constraint_orphan_source": return "no src upstream";
|
||||
@@ -300,6 +306,45 @@ function templateVarsIn(template: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `$name.AXIS` references, either accessor order. Mirrors the accessor
|
||||
* grammar in `engine/syntax/tokenize.py:_VAR_RE` — at most one pick index and
|
||||
* one axis, so `.0.AXIS` and `.AXIS.0` both yield the same pair. */
|
||||
const TEMPLATE_VAR_AXIS =
|
||||
/(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(?:\d+\.([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*)(?:\.\d+)?))/g;
|
||||
|
||||
export interface AxisRef { name: string; axis: string }
|
||||
|
||||
function templateAxisRefsIn(template: string): AxisRef[] {
|
||||
const out: AxisRef[] = [];
|
||||
for (const m of template.matchAll(TEMPLATE_VAR_AXIS)) {
|
||||
const axis = m[2] ?? m[3];
|
||||
if (axis) out.push({ name: m[1], axis });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Axis references a derivation's CONDITIONS make. Conditions store `var` as
|
||||
* bare text (no `$`), so the template regex does not apply. */
|
||||
function conditionAxisRefsIn(m: ModuleEntry): AxisRef[] {
|
||||
if (m.type !== "derivation") return [];
|
||||
const out: AxisRef[] = [];
|
||||
const rules = ((m.payload as { rules?: unknown[] } | undefined)?.rules ?? []) as Array<{
|
||||
branches?: Array<{ condition?: { var?: unknown } }>;
|
||||
}>;
|
||||
for (const rule of rules) {
|
||||
for (const br of rule.branches ?? []) {
|
||||
const v = br.condition?.var;
|
||||
if (typeof v !== "string") continue;
|
||||
const mm = v.match(
|
||||
/^([A-Za-z_][A-Za-z0-9_]*)(?:\.(?:\d+\.([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*)(?:\.\d+)?))$/,
|
||||
);
|
||||
const axis = mm?.[2] ?? mm?.[3];
|
||||
if (mm && axis) out.push({ name: mm[1], axis });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Extract the templates a module's runtime resolver will consume against
|
||||
* the ctx (`$var` lookups). Surfaces:
|
||||
* - combine: `payload.template`
|
||||
@@ -585,6 +630,37 @@ export function scanConflicts(
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
// Which `accepts` axes each binding written IN THIS NODE declares. Built up
|
||||
// front because a `$var.AXIS` read can sit above the wildcard that produces
|
||||
// it in module order — the read is resolved by the runtime ctx, not by
|
||||
// position, so judging it by position would be wrong.
|
||||
//
|
||||
// Only wildcards contribute: no other module type has tag groups. A binding
|
||||
// written outside this node is absent from the map and reads against it are
|
||||
// left alone (see `flagAxis`).
|
||||
const axisProducers = ((): Map<string, Set<string>> => {
|
||||
const out = new Map<string, Set<string>>();
|
||||
for (const m of value.modules) {
|
||||
if (m.type !== "wildcard") continue;
|
||||
const payload = (m.payload ?? {}) as {
|
||||
var_binding?: unknown;
|
||||
tag_group_kinds?: Record<string, unknown>;
|
||||
};
|
||||
const inst = (m.instance ?? {}) as { variable_binding?: unknown };
|
||||
const binding = typeof inst.variable_binding === "string" && inst.variable_binding
|
||||
? inst.variable_binding
|
||||
: (typeof payload.var_binding === "string" ? payload.var_binding : "");
|
||||
if (!binding) continue;
|
||||
const declared = out.get(binding) ?? new Set<string>();
|
||||
for (const [axis, kind] of Object.entries(payload.tag_group_kinds ?? {})) {
|
||||
if (kind === "accepts") declared.add(axis);
|
||||
}
|
||||
// Two wildcards can share a binding (last-write-wins at runtime); union
|
||||
// their axes rather than letting the second erase the first's.
|
||||
out.set(binding, declared);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
const written = new Set<string>();
|
||||
// Track the kind of the FIRST module to write each name in this
|
||||
// node. Used to distinguish intentional cross-kind overrides
|
||||
@@ -657,6 +733,36 @@ export function scanConflicts(
|
||||
for (const v of templateVarsIn(tpl)) flagMissing(v);
|
||||
}
|
||||
|
||||
// 1b. `$var.AXIS` reads naming a group nothing declares `accepts`. The
|
||||
// engine renders those empty, so without a signal here the only
|
||||
// symptom is a silently missing word in the prompt.
|
||||
//
|
||||
// Deliberately conservative: this scanner runs PER NODE and receives
|
||||
// upstream variables as names only, never payloads. So a read is
|
||||
// checked only when a wildcard in THIS node writes that binding — if
|
||||
// the producer lives in another node we cannot see its groups, and
|
||||
// guessing would fire on correct workflows. Under-reporting beats a
|
||||
// warning the user cannot act on.
|
||||
const seenAxis = new Set<string>();
|
||||
const flagAxis = ({ name, axis }: AxisRef): void => {
|
||||
const producer = axisProducers.get(name);
|
||||
if (!producer) return; // not written here — cannot judge
|
||||
if (producer.has(axis)) return; // declared accepts, all good
|
||||
const key = `${name}.${axis}`;
|
||||
if (seenAxis.has(key)) return;
|
||||
seenAxis.add(key);
|
||||
out.push({
|
||||
moduleId: m._uid ?? m.id,
|
||||
variable: key,
|
||||
type: "unknown_tag_axis",
|
||||
severity: "warning",
|
||||
});
|
||||
};
|
||||
for (const ref of conditionAxisRefsIn(m)) flagAxis(ref);
|
||||
for (const tpl of templatesOf(m)) {
|
||||
for (const ref of templateAxisRefsIn(tpl)) flagAxis(ref);
|
||||
}
|
||||
|
||||
// 2. Writes from this module — order-dependent, so happens AFTER
|
||||
// template scan so a combine can't satisfy its own `$var` refs.
|
||||
for (const name of writesOf(m)) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* These fonts are served, not bundled — see the header of `fonts.ts`. The
|
||||
* stylesheet is copied to `js/assets/fonts/` by `scripts/copy-fonts.mjs` and
|
||||
* located at runtime relative to this chunk's own URL.
|
||||
*
|
||||
* Both failure modes here are SILENT in a browser: a wrong path 404s and text
|
||||
* quietly falls back to the system stack, and a duplicated <link> costs a
|
||||
* second request nobody notices. Neither shows up in any other test.
|
||||
*/
|
||||
describe("webfont linking", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
document.head.querySelectorAll("#wp-webfonts").forEach((n) => n.remove());
|
||||
});
|
||||
|
||||
it("appends one stylesheet link pointing at the copied CSS", async () => {
|
||||
await import("./fonts");
|
||||
const links = document.head.querySelectorAll<HTMLLinkElement>("link#wp-webfonts");
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].rel).toBe("stylesheet");
|
||||
// Resolved against the module URL, so the leading segments differ between
|
||||
// the test run and the built chunk. The tail is what has to hold: the CSS
|
||||
// sits in a `fonts/` directory beside whatever chunk imports it.
|
||||
expect(links[0].href).toMatch(/\/fonts\/wp-fonts\.css$/);
|
||||
});
|
||||
|
||||
it("does not add a second link when imported again", async () => {
|
||||
await import("./fonts");
|
||||
await import("./fonts");
|
||||
expect(document.head.querySelectorAll("link#wp-webfonts")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("resolves the CSS beside the chunk, and the woff2 beside the CSS", () => {
|
||||
// The whole scheme in one assertion: chunk URL -> stylesheet -> font file.
|
||||
// If someone moves the copy target without moving the other end, this is
|
||||
// the test that says so.
|
||||
const chunk = "http://host/extensions/comfyui-wildcard-pipeline/assets/fonts-abc123.js";
|
||||
const css = new URL(["fonts", "wp-fonts.css"].join("/"), chunk).href;
|
||||
expect(css).toBe(
|
||||
"http://host/extensions/comfyui-wildcard-pipeline/assets/fonts/wp-fonts.css",
|
||||
);
|
||||
expect(new URL("./inter-latin.woff2", css).href).toBe(
|
||||
"http://host/extensions/comfyui-wildcard-pipeline/assets/fonts/inter-latin.woff2",
|
||||
);
|
||||
});
|
||||
});
|
||||
+31
-33
@@ -1,10 +1,23 @@
|
||||
// Webfonts (Inter + JetBrains Mono, latin variable axis 100-900) injected
|
||||
// at extension load. We side-step `cssInjectedByJsPlugin`'s URL-inlining
|
||||
// behavior by wiring the @font-face rules in JS — `new URL(path,
|
||||
// import.meta.url)` keeps woff2 files as separate assets (NOT
|
||||
// base64-inlined into the JS chunk) and resolves the URL at runtime
|
||||
// relative to the loaded module's location, which ComfyUI serves at
|
||||
// `/extensions/wildcard-pipeline/assets/...`.
|
||||
// Webfonts (Inter + JetBrains Mono, latin variable axis 100-900) linked at
|
||||
// extension load.
|
||||
//
|
||||
// The @font-face rules live in `src/assets/fonts/wp-fonts.css`, copied verbatim
|
||||
// into `js/assets/fonts/` by `scripts/copy-fonts.mjs`. This module only points a
|
||||
// <link> at them; the browser resolves each woff2 URL relative to the
|
||||
// stylesheet, so there is no path arithmetic here.
|
||||
//
|
||||
// WHY NOT AN IMPORT. This file used to do
|
||||
// new URL("../assets/fonts/inter-latin.woff2", import.meta.url)
|
||||
// in the belief that it kept the woff2 as separate assets. It did not — Vite
|
||||
// ignores `build.assetsInlineLimit` whenever `build.lib` is set and inlines
|
||||
// every asset unconditionally. Both fonts were base64'd into the chunk: 88,660
|
||||
// bytes of woff2 became a 118,896-byte JS file, downloaded and parsed as
|
||||
// JavaScript before ComfyUI could finish registering the extension.
|
||||
//
|
||||
// Do NOT turn `CSS_HREF` below back into a string literal. Vite's asset
|
||||
// transform fires on `new URL(<literal>, import.meta.url)` specifically, and
|
||||
// making it a literal would silently re-inline the stylesheet — undoing all of
|
||||
// this with no build error and no test failure.
|
||||
//
|
||||
// Trade-off vs. fontsource @import:
|
||||
// - Single latin subset only — no cyrillic/greek/vietnamese coverage.
|
||||
@@ -13,38 +26,23 @@
|
||||
// - Variable axis covers every weight from 100..900 in one file each,
|
||||
// so we don't pay per-weight asset cost.
|
||||
|
||||
const interUrl = new URL("../assets/fonts/inter-latin.woff2", import.meta.url).href;
|
||||
const jetBrainsMonoUrl = new URL("../assets/fonts/jetbrains-mono-latin.woff2", import.meta.url).href;
|
||||
/** Built from parts so Vite's `new URL(<literal>, ...)` transform cannot see a
|
||||
* literal path to resolve. See the note above before "simplifying" this. */
|
||||
const CSS_HREF = ["fonts", "wp-fonts.css"].join("/");
|
||||
|
||||
let injected = false;
|
||||
|
||||
function ensureFontFaces(): void {
|
||||
if (injected || typeof document === "undefined") return;
|
||||
injected = true;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wp-webfonts";
|
||||
// font-display: swap renders the system stack first then re-paints when
|
||||
// the woff2 lands — avoids invisible text during the 100–300ms fetch on
|
||||
// first load. font-weight `100 900` is the variable axis range.
|
||||
style.textContent = `
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(${interUrl}) format('woff2-variations'),
|
||||
url(${interUrl}) format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(${jetBrainsMonoUrl}) format('woff2-variations'),
|
||||
url(${jetBrainsMonoUrl}) format('woff2');
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
const link = document.createElement("link");
|
||||
link.id = "wp-webfonts";
|
||||
link.rel = "stylesheet";
|
||||
// Resolved against this chunk's own URL — `/extensions/<package>/assets/` —
|
||||
// so a renamed install directory still works. Hardcoding the package name
|
||||
// would break for anyone who cloned to a different folder name.
|
||||
link.href = new URL(CSS_HREF, import.meta.url).href;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
ensureFontFaces();
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
collectDownstreamWildcardUuids,
|
||||
collectLocalResolvedForModule,
|
||||
collectUpstreamProducers,
|
||||
collectUpstreamRenderableVariables,
|
||||
collectUpstreamResolved,
|
||||
collectUpstreamVariables,
|
||||
collectUpstreamWildcardUuids,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
findDownstreamAssemblers,
|
||||
findRootGraph,
|
||||
hasUpstreamLoopOverridingSeed,
|
||||
internalVarNames,
|
||||
resolveUpstreamLoopSeed,
|
||||
type LiteGraphLike,
|
||||
type LiteNodeLike,
|
||||
@@ -148,6 +150,30 @@ describe("collectUpstreamProducers", () => {
|
||||
expect(out.shared.shadowed).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a wildcard's accepts axes when a later node rebinds the var", () => {
|
||||
// The engine keys `__wp_axes__` by BINDING and writes it at pick time, so
|
||||
// a derivation downstream that rewrites `$shared` does not clear the
|
||||
// rolled axis — `$shared.SHOES` still resolves. The producer map replaced
|
||||
// the record wholesale, so the axis rows disappeared as soon as anything
|
||||
// downstream touched the name: true of essentially every real graph.
|
||||
const up = ctxWriting(1, "Outfit", "shared");
|
||||
const upMods = JSON.parse(String(up.widgets![0].value));
|
||||
upMods.modules[0].payload.tag_groups = { SHOES: ["sneakers", "heels"] };
|
||||
upMods.modules[0].payload.tag_group_kinds = { SHOES: "accepts" };
|
||||
up.widgets![0].value = JSON.stringify(upMods);
|
||||
const mid = ctxDerivation(2, "Rewrite", "shared", 4, 100);
|
||||
const pov = ctxWriting(3, "Other", "unused", 101);
|
||||
const out = collectUpstreamProducers(
|
||||
chain([up, mid, pov], {
|
||||
100: { origin_id: 1, target_id: 2 },
|
||||
101: { origin_id: 2, target_id: 3 },
|
||||
}),
|
||||
pov,
|
||||
);
|
||||
expect(out.shared.moduleName).toBe("Rewrite");
|
||||
expect(out.shared.axes?.map((a) => a.axis)).toEqual(["SHOES"]);
|
||||
});
|
||||
|
||||
it("counts two DIFFERENT modules in one node as two writers", () => {
|
||||
// They share `nodeId` and `kind`, so keying on those alone would collapse
|
||||
// a real last-write-wins conflict into a single silent writer.
|
||||
@@ -481,6 +507,56 @@ describe("collectDownstreamWildcardUuids", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUpstreamVariables — internal flag is last-write-wins", () => {
|
||||
/** One Context node, two fixed_values both binding `name`, with the given
|
||||
* internal flags in order. Mirrors the reported canvas setup. */
|
||||
function twoWriters(
|
||||
first: { value: string; internal: boolean },
|
||||
second: { value: string; internal: boolean },
|
||||
): LiteGraphLike {
|
||||
const mod = (uid: string, value: string, internal: boolean) => ({
|
||||
id: uid, _uid: uid, type: "fixed_values", enabled: true, meta: { name: uid },
|
||||
payload: { values: [{ id: uid + "v", name: "outfit", value }] },
|
||||
instance: { internal },
|
||||
});
|
||||
const ctx: LiteNodeLike = {
|
||||
id: 1, type: "WP_Context",
|
||||
inputs: [{ name: "upstream", link: null }],
|
||||
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [{ name: "wp_modules", value: JSON.stringify({
|
||||
version: 1, modules: [mod("aaaaaaaa", first.value, first.internal),
|
||||
mod("bbbbbbbb", second.value, second.internal)] }) }],
|
||||
};
|
||||
const asm: LiteNodeLike = {
|
||||
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
|
||||
};
|
||||
return {
|
||||
_nodes: [ctx, asm],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
it("REPORTED: a public writer AFTER an internal one makes the var renderable", () => {
|
||||
// internal → public. The public override is the last write, so the
|
||||
// assembler's PROMPT surface must see `outfit`. Add-only left it hidden
|
||||
// forever. (The plain variable list always keeps it; it is the renderable
|
||||
// list — the one the assembler's `$var` substitution honours — that must
|
||||
// reflect the last writer.)
|
||||
const g = twoWriters({ value: "hidden", internal: true },
|
||||
{ value: "public", internal: false });
|
||||
const asm = g.getNodeById(2)!;
|
||||
expect(collectUpstreamRenderableVariables(g, asm)).toContain("outfit");
|
||||
});
|
||||
|
||||
it("an internal writer AFTER a public one hides the var from the prompt", () => {
|
||||
const g = twoWriters({ value: "public", internal: false },
|
||||
{ value: "hidden", internal: true });
|
||||
const asm = g.getNodeById(2)!;
|
||||
expect(collectUpstreamRenderableVariables(g, asm)).not.toContain("outfit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUpstreamVariables — mute/bypass mode", () => {
|
||||
function makeChain(modeOnA?: number) {
|
||||
// a (writes "style") → b. If `modeOnA` is 2 (mute) or 4 (bypass),
|
||||
@@ -699,6 +775,57 @@ function fakeWildcardContextNode(
|
||||
};
|
||||
}
|
||||
|
||||
describe("collectUpstreamResolved axis reads", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
|
||||
/** Build a chain whose combine template reads `$outfit.SHOES`. */
|
||||
function axisGraph(template: string) {
|
||||
const ctx = fakeWildcardContextNode(1, [
|
||||
{ id: "aaaaaaaa", binding: "$outfit", options: [{ value: "a white t-shirt" }] },
|
||||
]);
|
||||
const mods = JSON.parse(String(ctx.widgets![0].value));
|
||||
mods.modules[0].payload.tag_groups = { SHOES: ["sneakers", "heels"], FIT: ["loose"] };
|
||||
mods.modules[0].payload.tag_group_kinds = { SHOES: "accepts" };
|
||||
mods.modules.push({
|
||||
id: "bbbbbbbb", type: "combine", enabled: true, meta: { name: "" }, entries: [],
|
||||
payload: { output_var: "scene", template },
|
||||
});
|
||||
ctx.widgets![0].value = JSON.stringify(mods);
|
||||
const asm: LiteNodeLike = {
|
||||
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
|
||||
};
|
||||
return {
|
||||
_nodes: [ctx, asm],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id: number) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
} as LiteGraphLike;
|
||||
}
|
||||
|
||||
it("REPORTED: an axis read previews as a TAG, not the variable text plus a stranded accessor", () => {
|
||||
// Was "a white t-shirt.SHOES" — `$outfit` substituted, `.SHOES` left behind.
|
||||
const out = collectUpstreamResolved(axisGraph("wearing $outfit.SHOES"), {
|
||||
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
|
||||
});
|
||||
expect(out.scene).toBe("wearing sneakers");
|
||||
});
|
||||
|
||||
it("leaves an unknown accessor verbatim rather than swallowing it", () => {
|
||||
// FIT exists but is a classify group, so it is not readable — and BELTS
|
||||
// does not exist at all. Consuming either would silently delete text.
|
||||
const out = collectUpstreamResolved(axisGraph("wearing $outfit.BELTS"), {
|
||||
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
|
||||
});
|
||||
expect(out.scene).toBe("wearing $outfit.BELTS");
|
||||
});
|
||||
|
||||
it("still resolves a plain read", () => {
|
||||
const out = collectUpstreamResolved(axisGraph("wearing $outfit"), {
|
||||
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
|
||||
});
|
||||
expect(out.scene).toBe("wearing a white t-shirt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUpstreamResolved nested @{uuid} fallback", () => {
|
||||
beforeEach(() => _resetForTests());
|
||||
|
||||
@@ -1347,3 +1474,145 @@ describe("findWildcardHomesElsewhere", () => {
|
||||
expect(findWildcardHomesElsewhere(graphOf([self, asm]), self, "")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* These walkers run inside `onConnectionsChange` (see `extension/reactive.ts`),
|
||||
* so a throw does not degrade to an empty preview — it escapes into litegraph's
|
||||
* connection handling and one malformed module breaks canvas interaction for
|
||||
* the whole graph. Both shapes below are things a real library payload can be:
|
||||
* `entries` is a widget-side mirror that only exists once a module has been
|
||||
* edited inline, so a row installed straight from the library has `payload`
|
||||
* and nothing else.
|
||||
*/
|
||||
describe("collectUpstreamResolved — malformed fixed_values rows", () => {
|
||||
/** A fixed_values module with whatever `entries` the caller wants (or none). */
|
||||
function fixedValuesGraph(module: Record<string, unknown>): {
|
||||
graph: LiteGraphLike;
|
||||
asm: LiteNodeLike;
|
||||
} {
|
||||
const ctx: LiteNodeLike = {
|
||||
id: 1,
|
||||
type: "WP_Context",
|
||||
inputs: [{ name: "upstream", link: null }],
|
||||
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [{ name: "wp_modules", value: JSON.stringify({ version: 1, modules: [module] }) }],
|
||||
};
|
||||
const asm: LiteNodeLike = {
|
||||
id: 2,
|
||||
type: "WP_PromptAssembler",
|
||||
inputs: [{ name: "context", link: 100 }],
|
||||
};
|
||||
return {
|
||||
asm,
|
||||
graph: {
|
||||
_nodes: [ctx, asm],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
values: [
|
||||
{ id: "v1", name: "style", value: "cinematic" },
|
||||
{ id: "v2", name: "mood", value: "brooding" },
|
||||
],
|
||||
};
|
||||
|
||||
it("resolves from payload.values when the module has no `entries` key at all", () => {
|
||||
const { graph, asm } = fixedValuesGraph({
|
||||
id: "aa11bb22", type: "fixed_values", enabled: true, meta: { name: "looks" }, payload,
|
||||
});
|
||||
expect(collectUpstreamResolved(graph, asm)).toEqual({ style: "cinematic", mood: "brooding" });
|
||||
});
|
||||
|
||||
it("skips an entry with no variable_name instead of throwing", () => {
|
||||
const { graph, asm } = fixedValuesGraph({
|
||||
id: "aa11bb22", type: "fixed_values", enabled: true, meta: { name: "looks" },
|
||||
entries: [{ value: "orphaned" }, { variable_name: "$style", value: "noir" }],
|
||||
payload: { values: [] },
|
||||
});
|
||||
expect(collectUpstreamResolved(graph, asm)).toEqual({ style: "noir" });
|
||||
});
|
||||
|
||||
it("tolerates `entries` being a non-array", () => {
|
||||
const { graph, asm } = fixedValuesGraph({
|
||||
id: "aa11bb22", type: "fixed_values", enabled: true, meta: { name: "looks" },
|
||||
entries: null, payload,
|
||||
});
|
||||
expect(collectUpstreamResolved(graph, asm)).toEqual({ style: "cinematic", mood: "brooding" });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* WP_PromptAssembler runs `strip_internals` over the render context before
|
||||
* resolving, so a `$var` naming a variable the user flagged internal
|
||||
* substitutes to nothing. The flagged names still cross the socket — Combine
|
||||
* and Derivation downstream read them — so the plain variable list keeps
|
||||
* them, and only the PROMPT surface filters. Before this split the
|
||||
* assembler's `$` autocomplete offered `$iteration` and the `*_bool` toggles,
|
||||
* which outnumbered the usable names.
|
||||
*/
|
||||
describe("collectUpstreamRenderableVariables", () => {
|
||||
/** A Context node with one plain fixed_values module and one flagged
|
||||
* `instance.internal`. */
|
||||
function graphWithInternalModule(): { graph: LiteGraphLike; asm: LiteNodeLike } {
|
||||
const ctx: LiteNodeLike = {
|
||||
id: 1,
|
||||
type: "WP_Context",
|
||||
inputs: [{ name: "upstream", link: null }],
|
||||
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [{
|
||||
name: "wp_modules",
|
||||
value: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "aa11bb22", type: "fixed_values", enabled: true, meta: { name: "looks" },
|
||||
entries: [], payload: { values: [{ id: "v1", name: "style", value: "cinematic" }] },
|
||||
},
|
||||
{
|
||||
id: "cc33dd44", type: "fixed_values", enabled: true, meta: { name: "toggles" },
|
||||
entries: [], instance: { internal: true },
|
||||
payload: { values: [{ id: "v2", name: "style_bool", value: "1" }] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}],
|
||||
};
|
||||
const asm: LiteNodeLike = {
|
||||
id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 100 }],
|
||||
};
|
||||
return {
|
||||
asm,
|
||||
graph: {
|
||||
_nodes: [ctx, asm],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("drops internal-flagged names that the prompt would never substitute", () => {
|
||||
const { graph, asm } = graphWithInternalModule();
|
||||
expect(collectUpstreamRenderableVariables(graph, asm)).toEqual(["style"]);
|
||||
});
|
||||
|
||||
it("keeps them in the plain variable list, which other surfaces still need", () => {
|
||||
const { graph, asm } = graphWithInternalModule();
|
||||
expect(collectUpstreamVariables(graph, asm).sort()).toEqual(["style", "style_bool"]);
|
||||
});
|
||||
|
||||
it("names the internal keys from the flag blob", () => {
|
||||
const { graph, asm } = graphWithInternalModule();
|
||||
expect([...internalVarNames(collectUpstreamResolved(graph, asm))]).toEqual(["style_bool"]);
|
||||
});
|
||||
|
||||
it("treats a malformed flag blob as no internals rather than throwing", () => {
|
||||
// The blob crosses the socket as an ordinary string value, so nothing
|
||||
// guarantees it parses. A broken map should cost a mis-styled chip, not
|
||||
// an exception inside a connection-change handler.
|
||||
expect([...internalVarNames({ __wp_internal_flags__: "{not json" })]).toEqual([]);
|
||||
expect([...internalVarNames({})]).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+170
-21
@@ -477,6 +477,45 @@ export function collectUpstreamVariables(rootGraph: LiteGraphLike, node: LiteNod
|
||||
.filter((k) => !k.startsWith("__"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the user flagged "hide from prompt", read out of the engine's
|
||||
* `__wp_internal_flags__` blob that rides along in a resolved map.
|
||||
*
|
||||
* The blob is JSON in a string because it crosses the PIPELINE_CONTEXT socket
|
||||
* as an ordinary context value. Malformed ⇒ treat as empty; a broken flag map
|
||||
* should cost the user a mis-styled chip, not an exception in a graph walk.
|
||||
*/
|
||||
export function internalVarNames(resolved: Record<string, ResolvedValue>): Set<string> {
|
||||
const out = new Set<string>();
|
||||
const blob = resolved["__wp_internal_flags__"];
|
||||
if (typeof blob !== "string") return out;
|
||||
try {
|
||||
for (const [k, v] of Object.entries(JSON.parse(blob) as Record<string, boolean>)) {
|
||||
if (v) out.add(k);
|
||||
}
|
||||
} catch { /* malformed, treat as empty */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream variables that would actually SUBSTITUTE in a rendered prompt —
|
||||
* `collectUpstreamVariables` minus the ones flagged internal.
|
||||
*
|
||||
* WP_PromptAssembler runs `strip_internals` over the render context before
|
||||
* resolving, so a `$var` naming an internal variable resolves to nothing. The
|
||||
* flagged names still travel the socket (Combine and Derivation downstream
|
||||
* read them), which is why the plain variable list keeps them — it is the
|
||||
* PROMPT surface specifically that must not offer them.
|
||||
*/
|
||||
export function collectUpstreamRenderableVariables(
|
||||
rootGraph: LiteGraphLike,
|
||||
node: LiteNodeLike,
|
||||
): string[] {
|
||||
const resolved = collectUpstreamResolved(rootGraph, node);
|
||||
const internal = internalVarNames(resolved);
|
||||
return Object.keys(resolved).filter((k) => !k.startsWith("__") && !internal.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upstream from `node` and return true iff some WP_ContextLoop in
|
||||
* the pipeline-context chain has `override_seed=true` in its widget
|
||||
@@ -753,6 +792,14 @@ export function collectUpstreamKinds(
|
||||
|
||||
const kinds: Record<string, string> = {};
|
||||
const internalKeys = new Set<string>();
|
||||
// Internal-ness is last-write-wins, exactly like the value: the CLOSEST
|
||||
// writer of a var decides. `add`-only left a var internal forever once any
|
||||
// writer marked it so — mark $outfit internal, override with a public
|
||||
// $outfit, and the public override stayed hidden from the assembler.
|
||||
const flagInternal = (name: string, on: boolean): void => {
|
||||
if (on) internalKeys.add(name);
|
||||
else internalKeys.delete(name);
|
||||
};
|
||||
// Walk furthest-upstream → closest, so later writes override
|
||||
// earlier ones (last-write-wins matches runtime).
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
@@ -768,7 +815,7 @@ export function collectUpstreamKinds(
|
||||
const b = (row.binding ?? "").trim();
|
||||
if (!b) continue;
|
||||
kinds[b] = "injector";
|
||||
if (row.internal === true) internalKeys.add(b);
|
||||
flagInternal(b, row.internal === true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -788,8 +835,8 @@ export function collectUpstreamKinds(
|
||||
const totalName = `${baseName}_total`;
|
||||
kinds[baseName] = "loop";
|
||||
kinds[totalName] = "loop";
|
||||
if (cfg.iteration_internal === true) internalKeys.add(baseName);
|
||||
if (cfg.total_internal === true) internalKeys.add(totalName);
|
||||
flagInternal(baseName, cfg.iteration_internal === true);
|
||||
flagInternal(totalName, cfg.total_internal === true);
|
||||
continue;
|
||||
}
|
||||
if (n.type !== "WP_Context") continue;
|
||||
@@ -819,7 +866,7 @@ export function collectUpstreamKinds(
|
||||
if (!passes(val.id)) continue;
|
||||
const name = (val.name ?? "").replace(/^\$/, "").trim();
|
||||
if (name) kinds[name] = "fixed_values";
|
||||
if (name && m.instance?.internal) internalKeys.add(name);
|
||||
if (name) flagInternal(name, !!m.instance?.internal);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -828,12 +875,12 @@ export function collectUpstreamKinds(
|
||||
if (!passes(val.id)) continue;
|
||||
const name = (val.name ?? "").replace(/^\$/, "").trim();
|
||||
if (name) kinds[name] = "fixed_values";
|
||||
if (name && m.instance?.internal) internalKeys.add(name);
|
||||
if (name) flagInternal(name, !!m.instance?.internal);
|
||||
}
|
||||
for (const e of m.entries ?? []) {
|
||||
const name = (e.variable_name ?? "").replace(/^\$/, "").trim();
|
||||
if (name) kinds[name] = "fixed_values";
|
||||
if (name && m.instance?.internal) internalKeys.add(name);
|
||||
if (name) flagInternal(name, !!m.instance?.internal);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -852,11 +899,11 @@ export function collectUpstreamKinds(
|
||||
for (const branch of rule.branches ?? []) {
|
||||
const name = (branch.action?.target_var ?? "").replace(/^\$/, "").trim();
|
||||
if (name) kinds[name] = "derivation";
|
||||
if (name && m.instance?.internal) internalKeys.add(name);
|
||||
if (name) flagInternal(name, !!m.instance?.internal);
|
||||
}
|
||||
const elseName = (rule.else?.action?.target_var ?? "").replace(/^\$/, "").trim();
|
||||
if (elseName) kinds[elseName] = "derivation";
|
||||
if (elseName && m.instance?.internal) internalKeys.add(elseName);
|
||||
if (elseName) flagInternal(elseName, !!m.instance?.internal);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -867,7 +914,7 @@ export function collectUpstreamKinds(
|
||||
const raw = inst.variable_binding ?? payload.var_binding ?? payload.output_var ?? "";
|
||||
const name = raw.replace(/^\$/, "").trim();
|
||||
if (name) kinds[name] = m.type;
|
||||
if (name && m.instance?.internal) internalKeys.add(name);
|
||||
if (name) flagInternal(name, !!m.instance?.internal);
|
||||
}
|
||||
}
|
||||
// Stash internal-flag map on the reserved slot (parallels
|
||||
@@ -890,6 +937,18 @@ export function collectUpstreamKinds(
|
||||
* one. This carries the owning node and module through instead of discarding
|
||||
* them.
|
||||
*/
|
||||
/** One `accepts` tag group a producing wildcard declares, with its member
|
||||
* tags. A library fact, so it is safe to complete against; a pick index is
|
||||
* not, because `pick_min`/`pick_max` live on the instance. */
|
||||
export interface VarAxis {
|
||||
axis: string;
|
||||
tags: string[];
|
||||
/** Position among ALL of the wildcard's tag groups, not among the accepts
|
||||
* ones — `axisHueAt()` is indexed that way in the wildcard editor, so
|
||||
* filtering first would give SHOES a different colour in the two places. */
|
||||
hueIndex: number;
|
||||
}
|
||||
|
||||
export interface VarProducer {
|
||||
/** "wildcard" | "fixed_values" | "combine" | "derivation" | "injector" | "loop" */
|
||||
kind: string;
|
||||
@@ -903,6 +962,9 @@ export interface VarProducer {
|
||||
/** Display name of the writing module. Absent for injector / loop, which
|
||||
* write from node config rather than a library module. */
|
||||
moduleName?: string;
|
||||
/** `accepts` axes this variable exposes, for `$var.AXIS` completion.
|
||||
* Wildcards only, and omitted when the wildcard declares none. */
|
||||
axes?: VarAxis[];
|
||||
/** 8-hex module id, when a module wrote it. */
|
||||
moduleId?: string;
|
||||
/** Flagged internal — resolves downstream but the assembler strips it from
|
||||
@@ -991,6 +1053,14 @@ export function collectUpstreamProducers(
|
||||
lastWriter.set(name, who);
|
||||
out[name] = {
|
||||
...info,
|
||||
// Axes survive being overwritten. The engine records a rolled axis in
|
||||
// `__wp_axes__` keyed by BINDING at pick time, and a later writer
|
||||
// rebinding the same `$var` — a derivation, an injector row — replaces
|
||||
// the text without touching that record, so `$outfit.SHOES` still
|
||||
// resolves. Replacing the producer wholesale dropped the wildcard's
|
||||
// axes the moment anything downstream wrote the same name, which is
|
||||
// every real graph: the axis rows vanished one node along the chain.
|
||||
...(info.axes ?? prev?.axes ? { axes: info.axes ?? prev?.axes } : {}),
|
||||
shadowed: prev ? (sameWriter ? prev.shadowed : prev.shadowed + 1) : 0,
|
||||
};
|
||||
};
|
||||
@@ -1093,9 +1163,30 @@ export function collectUpstreamProducers(
|
||||
}
|
||||
|
||||
const inst = (m.instance ?? {}) as { variable_binding?: string | null };
|
||||
const payload = (m.payload ?? {}) as { var_binding?: string; output_var?: string };
|
||||
const payload = (m.payload ?? {}) as {
|
||||
var_binding?: string;
|
||||
output_var?: string;
|
||||
tag_groups?: Record<string, string[]>;
|
||||
tag_group_kinds?: Record<string, string>;
|
||||
};
|
||||
const raw = inst.variable_binding ?? payload.var_binding ?? payload.output_var ?? "";
|
||||
write(raw.replace(/^\$/, "").trim(), base, writerKey);
|
||||
// `accepts` axes ride along so the template editor can complete
|
||||
// `$var.AXIS`. Read from the PAYLOAD because a group's kind is a library
|
||||
// fact shared by every instance — unlike `pick_min`/`pick_max`, which are
|
||||
// per-instance and therefore cannot be suggested from a library surface.
|
||||
const axes: VarAxis[] = [];
|
||||
if (m.type === "wildcard") {
|
||||
const kinds = payload.tag_group_kinds ?? {};
|
||||
// Index over EVERY group so the hue matches the wildcard editor's.
|
||||
Object.entries(payload.tag_groups ?? {}).forEach(([axis, tags], hueIndex) => {
|
||||
if (kinds[axis] === "accepts") axes.push({ axis, tags: tags ?? [], hueIndex });
|
||||
});
|
||||
}
|
||||
write(
|
||||
raw.replace(/^\$/, "").trim(),
|
||||
axes.length > 0 ? { ...base, axes } : base,
|
||||
writerKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -1127,13 +1218,39 @@ export function collectUpstreamProducers(
|
||||
|
||||
// SP2a: optional `.K` list accessor (group 2) so `$mood.0` is consumed whole
|
||||
// (no stranded ".0" literal) and the index drives applyVarAccessor below.
|
||||
const VAR_REF_RE = /\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?/g;
|
||||
const VAR_REF_RE =
|
||||
/\$([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?(?:\.([A-Za-z_][A-Za-z0-9_]*))?(?:\.(\d+))?/g;
|
||||
const WC_REF_RE = /@\{([0-9a-f]{8})(?:#[^#:}@{]*)?(?::[^}]*)?\}/g;
|
||||
const MAX_REF_DEPTH = 8;
|
||||
|
||||
interface MinimalWildcard {
|
||||
options?: Array<{ value?: string; weight?: number }>;
|
||||
var_binding?: string;
|
||||
tag_groups?: Record<string, string[]>;
|
||||
tag_group_kinds?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** The tag a `$var.AXIS` read previews as, or undefined when no wildcard in
|
||||
* the chain binds `name` with an `accepts` group called `axis`.
|
||||
*
|
||||
* Runtime rolls one member of the group per pick; the static preview has no
|
||||
* rng, so it shows the FIRST member — the same deterministic convention the
|
||||
* `@{uuid}` expansion uses when it previews a wildcard as its first option.
|
||||
* Returning undefined for an unknown axis is what keeps the caller from
|
||||
* swallowing an accessor it cannot resolve: `$size.LARGE` where LARGE is not
|
||||
* an axis stays verbatim rather than silently losing text. */
|
||||
function previewAxisTag(
|
||||
catalog: Map<string, MinimalWildcard>,
|
||||
name: string,
|
||||
axis: string,
|
||||
): string | undefined {
|
||||
for (const wc of catalog.values()) {
|
||||
if ((wc.var_binding ?? "").replace(/^\$/, "") !== name) continue;
|
||||
if (wc.tag_group_kinds?.[axis] !== "accepts") continue;
|
||||
const tags = wc.tag_groups?.[axis] ?? [];
|
||||
if (tags.length > 0) return tags[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveChainStatic(chain: LiteNodeLike[]): Record<string, ResolvedValue> {
|
||||
@@ -1223,6 +1340,7 @@ function resolveChainStatic(chain: LiteNodeLike[]): Record<string, ResolvedValue
|
||||
if (!binding) continue;
|
||||
ctx[binding] = `$${binding}`;
|
||||
if (row.internal === true) internalKeys.add(binding);
|
||||
else internalKeys.delete(binding);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1245,7 +1363,9 @@ function resolveChainStatic(chain: LiteNodeLike[]): Record<string, ResolvedValue
|
||||
ctx[baseName] = "1";
|
||||
ctx[totalName] = "1";
|
||||
if (cfg.iteration_internal === true) internalKeys.add(baseName);
|
||||
else internalKeys.delete(baseName);
|
||||
if (cfg.total_internal === true) internalKeys.add(totalName);
|
||||
else internalKeys.delete(totalName);
|
||||
continue;
|
||||
}
|
||||
if (n.type !== "WP_Context") continue;
|
||||
@@ -1256,11 +1376,17 @@ function resolveChainStatic(chain: LiteNodeLike[]): Record<string, ResolvedValue
|
||||
const bundleEnabled = buildBundleEnabledMap(v.bundles);
|
||||
for (const m of v.modules) {
|
||||
if (!isModuleEffectivelyEnabled(m, bundleEnabled)) continue;
|
||||
const beforeKeys = new Set(Object.keys(ctx));
|
||||
// Snapshot VALUES, not just keys: an override rebinds an existing key,
|
||||
// so a key-presence check never sees it and the flag could not flip.
|
||||
const beforeVals = new Map(Object.entries(ctx));
|
||||
writeBindings(ctx, m, catalog);
|
||||
if (m.instance?.internal) {
|
||||
for (const k of Object.keys(ctx)) {
|
||||
if (!beforeKeys.has(k)) internalKeys.add(k);
|
||||
const isInternal = !!m.instance?.internal;
|
||||
for (const k of Object.keys(ctx)) {
|
||||
// Written by THIS module = new key or changed value. Last writer wins,
|
||||
// so its internal flag replaces whatever an earlier writer set.
|
||||
if (!beforeVals.has(k) || beforeVals.get(k) !== ctx[k]) {
|
||||
if (isInternal) internalKeys.add(k);
|
||||
else internalKeys.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1322,7 +1448,16 @@ function writeBindings(
|
||||
for (const v of libValues) {
|
||||
if (typeof v.name === "string" && typeof v.id === "string") libNameToId.set(v.name, v.id);
|
||||
}
|
||||
for (const e of m.entries) {
|
||||
// Both guards matter, and neither is theoretical. This walker runs inside
|
||||
// `onConnectionsChange` (see `extension/reactive.ts`), so a throw here does
|
||||
// not degrade to an empty preview — it escapes into litegraph's connection
|
||||
// handling, and one malformed module breaks canvas interaction for the
|
||||
// whole graph. A library payload that carries `payload.values` but no
|
||||
// `entries` is enough to trigger it. The other two `entries` readers in
|
||||
// this file (the kind scan and the producer walk) already use `?? []`;
|
||||
// this one was the outlier.
|
||||
for (const e of Array.isArray(m.entries) ? m.entries : []) {
|
||||
if (typeof e?.variable_name !== "string") continue;
|
||||
const name = e.variable_name.replace(/^\$/, "").trim();
|
||||
if (!name) continue;
|
||||
const libId = libNameToId.get(e.variable_name);
|
||||
@@ -1507,10 +1642,24 @@ function expandValue(
|
||||
// unknowns (incl. their accessor) intact. ctx values are strings in this
|
||||
// static resolver, so applyVarAccessor treats `$s.0` as `$s` and `$s.K>0`
|
||||
// as "" — never leaking the literal ".K" into the preview.
|
||||
let out = raw.replace(VAR_REF_RE, (full, name, idx) =>
|
||||
Object.prototype.hasOwnProperty.call(ctx, name)
|
||||
? applyVarAccessor(ctx[name], idx != null ? parseInt(idx, 10) : undefined)
|
||||
: full,
|
||||
let out = raw.replace(
|
||||
VAR_REF_RE,
|
||||
(full, name, idxA, axis, idxB) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(ctx, name)) return full;
|
||||
const idx = idxA ?? idxB;
|
||||
const base = applyVarAccessor(
|
||||
ctx[name],
|
||||
idx != null ? parseInt(idx, 10) : undefined,
|
||||
);
|
||||
// An axis read resolves to a TAG, not to the variable's text. Without
|
||||
// this the preview substituted `$outfit` and left `.SHOES` stranded, so
|
||||
// both the preview and the resolved line read
|
||||
// "a white t-shirt and denim skirt.SHOES" — the accessor looked broken
|
||||
// in exactly the editor meant to teach it.
|
||||
if (axis == null) return base;
|
||||
const tag = previewAxisTag(catalog, name, axis);
|
||||
return tag ?? full;
|
||||
},
|
||||
);
|
||||
// 2. Substitute `@{8hex}` with the referenced wildcard's first option,
|
||||
// recursively expanded so chains (`@{a}` → "@{b} hat" → "blue hat")
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { effectScope } from "vue";
|
||||
import { reactiveFromGraph } from "./reactive";
|
||||
|
||||
/**
|
||||
* The polling fallback's cost, which nothing made visible before.
|
||||
*
|
||||
* Measured on an 8-node Context chain carrying 96 variables: 0.59ms per
|
||||
* refresh. Small alone, and not what it costs — every node walks its WHOLE
|
||||
* upstream chain, so a series chain is quadratic in total work, and a series
|
||||
* chain is the shape people build.
|
||||
*
|
||||
* The fallback exists for upstream edits that fire no event on this node.
|
||||
* Neither of the gates below can hide such an edit for long, and both stop
|
||||
* work at moments when no edit is possible at all.
|
||||
*/
|
||||
describe("reactiveFromGraph — the poll does not run flat out forever", () => {
|
||||
let hidden = false;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
hidden = false;
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
get: () => hidden,
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
/** A node whose computed value we control, with a counter for how often the
|
||||
* compute actually ran. */
|
||||
function mount(initial = "a") {
|
||||
let value = initial;
|
||||
let computes = 0;
|
||||
const scope = effectScope();
|
||||
scope.run(() => {
|
||||
reactiveFromGraph({}, () => { computes += 1; return value; }, (x, y) => x === y);
|
||||
});
|
||||
return {
|
||||
computes: () => computes,
|
||||
set: (v: string) => { value = v; },
|
||||
stop: () => scope.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
it("does no work at all while the tab is hidden", () => {
|
||||
const f = mount();
|
||||
const baseline = f.computes();
|
||||
hidden = true;
|
||||
vi.advanceTimersByTime(400 * 20);
|
||||
expect(f.computes()).toBe(baseline);
|
||||
f.stop();
|
||||
});
|
||||
|
||||
it("catches up once on the way back to visible", () => {
|
||||
// An edit made in another tab must not stay invisible; the point of the
|
||||
// gate is to skip work nobody can observe, not to miss it.
|
||||
const f = mount();
|
||||
hidden = true;
|
||||
vi.advanceTimersByTime(400 * 10);
|
||||
const beforeReturn = f.computes();
|
||||
hidden = false;
|
||||
vi.advanceTimersByTime(400);
|
||||
expect(f.computes()).toBe(beforeReturn + 1);
|
||||
f.stop();
|
||||
});
|
||||
|
||||
it("backs off once the value stops changing", () => {
|
||||
const f = mount();
|
||||
// Ten unchanged polls take it off the base rate.
|
||||
vi.advanceTimersByTime(400 * 10);
|
||||
const afterFirstQuiet = f.computes();
|
||||
// The next ten ticks are now spread across a 3x period, so far fewer of
|
||||
// them do any work.
|
||||
vi.advanceTimersByTime(400 * 10);
|
||||
const during = f.computes() - afterFirstQuiet;
|
||||
expect(during).toBeLessThan(10);
|
||||
expect(during).toBeGreaterThan(0); // still polling, just less often
|
||||
f.stop();
|
||||
});
|
||||
|
||||
it("snaps back to full rate the moment something actually changes", () => {
|
||||
const f = mount();
|
||||
vi.advanceTimersByTime(400 * 30); // fully backed off
|
||||
f.set("b");
|
||||
// Give the slow rate a chance to notice the change...
|
||||
vi.advanceTimersByTime(400 * 8);
|
||||
const afterChange = f.computes();
|
||||
// ...and from there it should be polling every tick again.
|
||||
vi.advanceTimersByTime(400 * 5);
|
||||
expect(f.computes() - afterChange).toBe(5);
|
||||
f.stop();
|
||||
});
|
||||
|
||||
it("snaps back on user activity, before any change is observed", () => {
|
||||
// Someone who starts editing should not wait out the back-off ladder to
|
||||
// see their own upstream edit land.
|
||||
const f = mount();
|
||||
vi.advanceTimersByTime(400 * 30);
|
||||
document.dispatchEvent(new Event("pointerdown"));
|
||||
vi.advanceTimersByTime(400); // resets, does not compute this tick
|
||||
const afterReset = f.computes();
|
||||
vi.advanceTimersByTime(400 * 5);
|
||||
expect(f.computes() - afterReset).toBe(5);
|
||||
f.stop();
|
||||
});
|
||||
});
|
||||
+112
-3
@@ -58,6 +58,86 @@ function watchNodeMode(node: ConnectableNode, cb: () => void): () => void {
|
||||
return () => { slot!.subs.delete(cb); };
|
||||
}
|
||||
|
||||
/* ── Shared activity clock ────────────────────────────────────────────────
|
||||
*
|
||||
* ONE document listener for the whole extension, not one per node. Every
|
||||
* polling widget reads the same timestamp to decide whether the user is
|
||||
* currently doing anything; N listeners for a single global fact would be the
|
||||
* same mistake the per-node intervals already make.
|
||||
*
|
||||
* Capture phase and passive: this only observes, and must not be hidden by a
|
||||
* `stopPropagation()` from ComfyUI's own canvas handling.
|
||||
*/
|
||||
let lastActivityAt = Date.now();
|
||||
function markActivity(): void { lastActivityAt = Date.now(); }
|
||||
if (typeof document !== "undefined") {
|
||||
for (const ev of ["pointerdown", "keydown", "wheel"]) {
|
||||
document.addEventListener(ev, markActivity, { capture: true, passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A polling fallback that stops working when nothing can be happening.
|
||||
*
|
||||
* Shared, because this project has two of these loops and they had drifted
|
||||
* into having nothing in common but the bug. `tick` reports whether it
|
||||
* observed a change, which is what drives the ladder back up.
|
||||
*/
|
||||
export function startAdaptivePoll(baseMs: number, tick: () => "changed" | "quiet"): () => void {
|
||||
let quiet = 0;
|
||||
let stepIdx = 0;
|
||||
let ticks = 0;
|
||||
let wasHidden = false;
|
||||
let seenActivityAt = lastActivityAt;
|
||||
|
||||
const run = (): void => {
|
||||
if (typeof document !== "undefined" && document.hidden) {
|
||||
wasHidden = true;
|
||||
return;
|
||||
}
|
||||
if (wasHidden) {
|
||||
// Coming back to a tab that may have missed edits made elsewhere.
|
||||
wasHidden = false;
|
||||
quiet = 0;
|
||||
stepIdx = 0;
|
||||
tick();
|
||||
return;
|
||||
}
|
||||
if (lastActivityAt !== seenActivityAt) {
|
||||
seenActivityAt = lastActivityAt;
|
||||
quiet = 0;
|
||||
stepIdx = 0;
|
||||
}
|
||||
// Skip ticks to realise the current multiple, rather than tearing down and
|
||||
// recreating the interval at every step.
|
||||
ticks += 1;
|
||||
if (ticks % BACKOFF_STEPS[stepIdx] !== 0) return;
|
||||
|
||||
if (tick() === "quiet") {
|
||||
quiet += 1;
|
||||
if (quiet >= STEP_AFTER_QUIET && stepIdx < BACKOFF_STEPS.length - 1) {
|
||||
stepIdx += 1;
|
||||
quiet = 0;
|
||||
}
|
||||
} else {
|
||||
quiet = 0;
|
||||
stepIdx = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const id = window.setInterval(run, baseMs);
|
||||
return () => window.clearInterval(id);
|
||||
}
|
||||
|
||||
/** Back-off ladder for the polling fallback, in multiples of the base period.
|
||||
* Idle canvases are the common case — a graph sits untouched while a queue
|
||||
* runs — and the fallback exists for edits that fire no event, which cannot
|
||||
* happen while nobody is editing. */
|
||||
const BACKOFF_STEPS = [1, 3, 8];
|
||||
/** Consecutive unchanged polls before stepping down the ladder. At the 400ms
|
||||
* base that is ~4s of stillness per step, ~12s to the slowest rate. */
|
||||
const STEP_AFTER_QUIET = 10;
|
||||
|
||||
export function reactiveFromGraph<T>(
|
||||
node: ConnectableNode,
|
||||
compute: () => T,
|
||||
@@ -83,8 +163,37 @@ export function reactiveFromGraph<T>(
|
||||
// its UI / re-walk the chain after the user keys M/Ctrl-M.
|
||||
const unwatchMode = watchNodeMode(node, refresh);
|
||||
|
||||
// Polling fallback for upstream-chain edits that don't fire on this node.
|
||||
const interval = window.setInterval(refresh, pollMs);
|
||||
/* Polling fallback for upstream-chain edits that don't fire on this node.
|
||||
*
|
||||
* The naive version — `setInterval(refresh, 400)` — runs forever, per node,
|
||||
* regardless of whether anything could have changed. Measured on an 8-node
|
||||
* Context chain carrying 96 variables: 0.59ms per refresh. That is small
|
||||
* alone and it is not what it costs, because EVERY node walks its whole
|
||||
* upstream chain, so a series chain is quadratic in total work and this is
|
||||
* the shape people build. Nine nodes already produce a visible long task.
|
||||
*
|
||||
* Two gates, both of which cost nothing when the user is actually working:
|
||||
*
|
||||
* 1. A hidden tab skips entirely. Nobody can observe a stale widget on a
|
||||
* tab they are not looking at, and browsers already throttle background
|
||||
* intervals — so this is work that was being done specifically at the
|
||||
* moment it was least useful. A single catch-up refresh runs on the way
|
||||
* back to visible.
|
||||
* 2. A still canvas backs off. The fallback covers edits that fire no
|
||||
* event, which cannot happen while nobody is editing, so after ~4s of no
|
||||
* pointer, key or wheel activity AND no observed change, the period
|
||||
* lengthens 3x, then 8x. Any real change or any user activity snaps it
|
||||
* straight back to the base rate.
|
||||
*
|
||||
* The event paths — `onConnectionsChange`, the mode intercept, graph load —
|
||||
* are untouched and still fire instantly. Only the fallback slows, and only
|
||||
* while nothing is happening.
|
||||
*/
|
||||
const stopPoll = startAdaptivePoll(pollMs, () => {
|
||||
const before = state.value;
|
||||
refresh();
|
||||
return equals(before, state.value) ? "quiet" : "changed";
|
||||
});
|
||||
|
||||
// Re-sync on workflow load. Several ticks because ComfyUI restores widget
|
||||
// values asynchronously through cascading callbacks; a single refresh can
|
||||
@@ -99,7 +208,7 @@ export function reactiveFromGraph<T>(
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
window.clearInterval(interval);
|
||||
stopPoll();
|
||||
unsubscribe();
|
||||
unwatchMode();
|
||||
node.onConnectionsChange = orig;
|
||||
|
||||
@@ -19,6 +19,7 @@ import "../components/shared/vue-nodes.css";
|
||||
// (the SPA's rich-text.css isn't loaded on the canvas — see the file header).
|
||||
import "../components/shared/rich-text-canvas.css";
|
||||
import { pushToast } from "../components/shared/toast-store";
|
||||
import { notifyCompletionSettingsChanged } from "../manager/utils/tagSetting";
|
||||
import { openPlayground } from "../components/settings/playground-store";
|
||||
|
||||
export type A11yMode = "auto" | "on" | "off";
|
||||
@@ -84,6 +85,104 @@ export type ComfySettingCustomRenderer = (
|
||||
attrs?: Record<string, unknown>,
|
||||
) => HTMLElement;
|
||||
|
||||
/**
|
||||
* A pill switch matching the one ComfyUI renders for a plain `type: "boolean"`.
|
||||
*
|
||||
* Only needed because this ONE setting cannot be a plain boolean: it disables
|
||||
* itself when no tag list is installed, and a native boolean has no way to say
|
||||
* "on is not available". Every other setting we own uses the real thing.
|
||||
*
|
||||
* EVERY dimension and colour reads PrimeVue's own `--p-toggleswitch-*` custom
|
||||
* property first. Those are injected when their component first mounts — which
|
||||
* has always happened by the time this renders, because our row sits in their
|
||||
* settings dialog among their switches — so at render time this resolves to
|
||||
* literally their pill, and follows their theme when it changes.
|
||||
*
|
||||
* The fallbacks are Aura's defaults (40x24 pill, 16px handle), used only if a
|
||||
* token is missing. An earlier hand-picked 34x18 read as visibly smaller and
|
||||
* duller than the switches directly beneath it.
|
||||
*
|
||||
* Handle travel is a `calc()` over the same tokens rather than a fixed 16px, so
|
||||
* a differently-sized track still lands the knob flush against its end.
|
||||
*
|
||||
* Not reusing `.p-toggleswitch` itself: those classes are ComfyUI's internals,
|
||||
* and borrowing them buys a perfect match today and a control that renders as
|
||||
* nothing the day they restyle or upgrade PrimeVue.
|
||||
*/
|
||||
function buildSwitch(initial: boolean): {
|
||||
root: HTMLElement;
|
||||
setOn: (on: boolean) => void;
|
||||
setDisabled: (off: boolean) => void;
|
||||
} {
|
||||
let on = initial;
|
||||
let disabled = false;
|
||||
|
||||
const root = document.createElement("span");
|
||||
root.setAttribute("role", "switch");
|
||||
root.tabIndex = 0;
|
||||
const knob = document.createElement("span");
|
||||
root.appendChild(knob);
|
||||
|
||||
// Static styling is written ONCE. Rewriting `cssText` on every change — the
|
||||
// first version did — replaces the `transition` declaration mid-flight, so
|
||||
// the browser has nothing to animate between and the knob teleports instead
|
||||
// of sliding. Only the properties that actually change are touched below.
|
||||
root.style.cssText = [
|
||||
"display:inline-flex", "align-items:center", "flex:0 0 auto",
|
||||
"box-sizing:border-box",
|
||||
"width:var(--p-toggleswitch-width, 40px)",
|
||||
"height:var(--p-toggleswitch-height, 24px)",
|
||||
"border-radius:var(--p-toggleswitch-border-radius, 30px)",
|
||||
"padding:var(--p-toggleswitch-gap, 4px)",
|
||||
"border:var(--p-toggleswitch-border-width, 1px) solid "
|
||||
+ "var(--p-toggleswitch-border-color, transparent)",
|
||||
"transition:background-color var(--p-transition-duration, .2s) ease",
|
||||
"outline:none",
|
||||
].join(";");
|
||||
knob.style.cssText = [
|
||||
"display:block",
|
||||
"width:var(--p-toggleswitch-handle-size, 16px)",
|
||||
"height:var(--p-toggleswitch-handle-size, 16px)",
|
||||
"border-radius:var(--p-toggleswitch-handle-border-radius, 50%)",
|
||||
"background:var(--p-toggleswitch-handle-background, #fff)",
|
||||
"transition:transform var(--p-transition-duration, .2s) ease",
|
||||
].join(";");
|
||||
|
||||
const paint = (): void => {
|
||||
root.setAttribute("aria-checked", on ? "true" : "false");
|
||||
root.style.background = on
|
||||
? "var(--p-toggleswitch-checked-background, var(--p-primary-color, #60a5fa))"
|
||||
: "var(--p-toggleswitch-background, var(--p-surface-600, #52525b))";
|
||||
root.style.opacity = disabled ? "var(--p-disabled-opacity, .6)" : "1";
|
||||
root.style.cursor = disabled ? "not-allowed" : "pointer";
|
||||
// Travel is the track minus the handle minus both gaps, computed by the
|
||||
// browser so it stays correct whatever `--p-toggleswitch-*` resolve to.
|
||||
knob.style.transform = on
|
||||
? "translateX(calc(var(--p-toggleswitch-width, 40px)"
|
||||
+ " - var(--p-toggleswitch-handle-size, 16px)"
|
||||
+ " - (var(--p-toggleswitch-gap, 4px) * 2)))"
|
||||
: "translateX(0)";
|
||||
};
|
||||
paint();
|
||||
|
||||
const toggle = (): void => {
|
||||
if (disabled) return;
|
||||
on = !on;
|
||||
paint();
|
||||
root.dispatchEvent(new CustomEvent("wp-switch", { detail: on, bubbles: true }));
|
||||
};
|
||||
root.addEventListener("click", (e) => { e.preventDefault(); toggle(); });
|
||||
root.addEventListener("keydown", (e) => {
|
||||
if (e.key === " " || e.key === "Enter") { e.preventDefault(); toggle(); }
|
||||
});
|
||||
|
||||
return {
|
||||
root,
|
||||
setOn: (next) => { on = next; paint(); },
|
||||
setDisabled: (next) => { disabled = next; paint(); },
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComfySetting {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -758,6 +857,136 @@ export function buildSettings(_app: AppLike): ComfySetting[] {
|
||||
tooltip: "Live preview of every display + a11y setting in one place.",
|
||||
category: ["Wildcard Pipeline", "1. Playground", "Open"],
|
||||
},
|
||||
// Booru tag autocomplete — canvas half of the SPA setting, deliberately
|
||||
// separate. Someone may want suggestions in the full manager and not while
|
||||
// squinting at a node on a canvas, and a ComfyUI user looks for switches
|
||||
// here rather than in our SPA.
|
||||
//
|
||||
// Rendered by hand so it can DISABLE itself when no tag list is installed.
|
||||
// A plain boolean would happily switch on and do nothing, which is the
|
||||
// failure this whole feature keeps having to design around: a control that
|
||||
// implies a capability it does not have.
|
||||
{
|
||||
id: "wildcardPipeline.behavior.tagAutocomplete",
|
||||
name: "Booru tag autocomplete",
|
||||
type: (_name, setter, value, _attrs) => {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.style.cssText =
|
||||
"display:flex; align-items:center; justify-content:flex-end; gap:10px; cursor:pointer";
|
||||
const { root: box, setOn, setDisabled } = buildSwitch(value === true);
|
||||
const note = document.createElement("span");
|
||||
// NOTE FIRST, then the switch. Appended the other way round the control
|
||||
// floated mid-row while every neighbouring toggle sat flush right, and
|
||||
// a row that does not line up reads as broken before it reads as ours.
|
||||
note.style.cssText =
|
||||
"font: 12px/1.3 var(--wp-font-sans, sans-serif); color: var(--descrip-text, #999)";
|
||||
wrap.append(note, box);
|
||||
|
||||
// Ask the server whether a list exists. Until it answers, leave the
|
||||
// control alone rather than flickering it disabled.
|
||||
void fetch("/wp/api/tags/status")
|
||||
.then((r) => r.json())
|
||||
.then((s: { available?: boolean; tag_count?: number }) => {
|
||||
if (s.available) {
|
||||
note.textContent = `${(s.tag_count ?? 0).toLocaleString()} tags installed`;
|
||||
return;
|
||||
}
|
||||
setOn(false);
|
||||
setDisabled(true);
|
||||
wrap.style.cursor = "not-allowed";
|
||||
wrap.title =
|
||||
"No tag list installed. Open the Wildcard Pipeline manager → "
|
||||
+ "Settings → Tag autocomplete and press Download to enable this.";
|
||||
note.textContent = "No tag list — download one in the manager's Settings";
|
||||
})
|
||||
.catch(() => {
|
||||
note.textContent = "";
|
||||
});
|
||||
|
||||
box.addEventListener("wp-switch", (e) => {
|
||||
setter((e as CustomEvent<boolean>).detail);
|
||||
// Editors read this straight out of ComfyUI's settings store, which
|
||||
// Vue cannot track — without the nudge the change took a page reload
|
||||
// to have any effect.
|
||||
notifyCompletionSettingsChanged();
|
||||
});
|
||||
return wrap;
|
||||
},
|
||||
defaultValue: false,
|
||||
tooltip:
|
||||
"Suggest danbooru tag names while typing in Wildcard Pipeline node "
|
||||
+ "editors. Requires a tag list, installed from the manager's Settings. "
|
||||
+ "Only affects this extension's own inputs.",
|
||||
category: ["Wildcard Pipeline", "7. Runtime behavior", "Tag autocomplete"],
|
||||
},
|
||||
// The other two completion sources. Same hand-built control as the tag
|
||||
// setting above, for the same reason it exists: a count beside the switch.
|
||||
// "50 found" answers the question a bare toggle raises — is there anything
|
||||
// for this to suggest? — and it is the question that sends people to the
|
||||
// manager to check.
|
||||
...(
|
||||
[
|
||||
{ id: "wildcardPipeline.behavior.loraAutocomplete", name: "LoRA autocomplete",
|
||||
kind: "lora", noun: "LoRAs",
|
||||
tooltip:
|
||||
"Suggest installed LoRA names while typing in Wildcard Pipeline node "
|
||||
+ "editors, inserting the full <lora:name:1.0> syntax. Reads the models "
|
||||
+ "ComfyUI already knows about. Only affects this extension's own inputs." },
|
||||
{ id: "wildcardPipeline.behavior.embeddingAutocomplete", name: "Embedding autocomplete",
|
||||
kind: "embedding", noun: "embeddings",
|
||||
tooltip:
|
||||
"Suggest installed embedding names while typing in Wildcard Pipeline "
|
||||
+ "node editors, inserting the full embedding:name syntax. Reads the "
|
||||
+ "models ComfyUI already knows about. Only affects this extension's "
|
||||
+ "own inputs." },
|
||||
] as const
|
||||
).map((src) => ({
|
||||
id: src.id,
|
||||
name: src.name,
|
||||
type: ((_name: string, setter: (v: unknown) => void, value: unknown) => {
|
||||
const wrap = document.createElement("label");
|
||||
wrap.style.cssText =
|
||||
"display:flex; align-items:center; justify-content:flex-end; gap:10px; cursor:pointer";
|
||||
const { root: box } = buildSwitch(value === true);
|
||||
const note = document.createElement("span");
|
||||
note.style.cssText =
|
||||
"font: 12px/1.3 var(--wp-font-sans, sans-serif); color: var(--descrip-text, #999)";
|
||||
// Note first so the switch lands flush right, level with every
|
||||
// neighbouring row's control.
|
||||
wrap.append(note, box);
|
||||
|
||||
void fetch("/wp/api/models/status")
|
||||
.then((r) => r.json())
|
||||
.then((s: { sources?: { kind: string; count: number }[] }) => {
|
||||
const count = (s.sources ?? []).find((x) => x.kind === src.kind)?.count ?? 0;
|
||||
note.textContent = count > 0
|
||||
? `${count.toLocaleString()} ${src.noun} found`
|
||||
: `No ${src.noun} found`;
|
||||
})
|
||||
.catch(() => { note.textContent = ""; });
|
||||
|
||||
box.addEventListener("wp-switch", (e) => {
|
||||
setter((e as CustomEvent<boolean>).detail);
|
||||
notifyCompletionSettingsChanged();
|
||||
});
|
||||
return wrap;
|
||||
}) as ComfySettingCustomRenderer,
|
||||
defaultValue: false,
|
||||
tooltip: src.tooltip,
|
||||
category: ["Wildcard Pipeline", "7. Runtime behavior", src.name],
|
||||
})),
|
||||
{
|
||||
id: "wildcardPipeline.behavior.autocompleteSeparator",
|
||||
name: "Append \", \" after a completion",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip:
|
||||
"Insert a comma and a space after picking a suggestion, so the next tag "
|
||||
+ "can be typed straight away. Never applied inside a <lora:…> or "
|
||||
+ "embedding:… reference, where a comma would end the reference.",
|
||||
category: ["Wildcard Pipeline", "7. Runtime behavior", "Autocomplete separator"],
|
||||
onChange: () => notifyCompletionSettingsChanged(),
|
||||
},
|
||||
// Visual axes — sizing, embellishment, identity
|
||||
{
|
||||
id: SETTING_ID_DENSITY,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { walkAllNodes, collectUpstreamVariables, type LiteGraphLike, type LiteNodeLike } from "./graph";
|
||||
import { startAdaptivePoll } from "./reactive";
|
||||
import { scanConflicts, scanTemplateConflicts, labelFor, type Conflict, type Severity } from "./conflicts";
|
||||
import { parseWidgetJson, type ContextWidgetValue } from "../widgets/_shared";
|
||||
import { onGraphLoaded } from "./graph-events";
|
||||
@@ -285,8 +286,10 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
|
||||
return;
|
||||
}
|
||||
|
||||
function recompute() {
|
||||
if (!node.subgraph || !Ctor) return;
|
||||
/** Returns whether anything actually moved, which is what lets the shared
|
||||
* adaptive poll back off on a still canvas. */
|
||||
function recompute(): "changed" | "quiet" {
|
||||
if (!node.subgraph || !Ctor) return "quiet";
|
||||
const next = computeBadgeState(rootGraph, node.subgraph);
|
||||
// Cache key includes both severity AND text — same severity with a new
|
||||
// variable name (e.g. user fixed $foo but $bar is still missing) needs a
|
||||
@@ -295,7 +298,7 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
|
||||
const sameAsBefore =
|
||||
(next === null && prevSeverity === null) ||
|
||||
(!!next && next.severity === prevSeverity && next.text === readBadge(node)?.text);
|
||||
if (sameAsBefore) return;
|
||||
if (sameAsBefore) return "quiet";
|
||||
// Pulse triggers: badge appears (null -> something) or severity worsens.
|
||||
// Same-severity text changes (e.g. $foo -> $bar still warning) get a
|
||||
// silent re-render — the wording change is informational, not urgent.
|
||||
@@ -311,6 +314,7 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
|
||||
if (badge && isAppear) pulseBadge(node, badge, 0.4, 1.12);
|
||||
else if (badge && isEscalation) pulseBadge(node, badge, 1.0, 1.18);
|
||||
}
|
||||
return "changed";
|
||||
}
|
||||
|
||||
// Initial compute happens on next tick — workflow may still be loading
|
||||
@@ -319,7 +323,12 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
|
||||
|
||||
// Polling fallback. 800ms is coarse but the badge isn't latency-critical;
|
||||
// the user is already used to seeing conflict dots within ~1s elsewhere.
|
||||
const interval = window.setInterval(recompute, 800);
|
||||
//
|
||||
// Shared with `reactive.ts` rather than a second bare `setInterval`: this
|
||||
// one scans the whole subgraph on every tick, per SubgraphNode, forever —
|
||||
// including on a hidden tab, where nobody can see a badge, and on a still
|
||||
// canvas, where nothing can have changed the conflicts it reports.
|
||||
const stopPoll = startAdaptivePoll(800, recompute);
|
||||
|
||||
// Recompute on connection changes — wires added/removed inside the
|
||||
// subgraph or to/from the SubgraphNode itself.
|
||||
@@ -340,7 +349,7 @@ export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraph
|
||||
// its observers cleanly. We chain so other extensions still run.
|
||||
const origRemoved = node.onRemoved;
|
||||
const cleanup = () => {
|
||||
window.clearInterval(interval);
|
||||
stopPoll();
|
||||
unsubGraphLoaded();
|
||||
node.onConnectionsChange = origCC;
|
||||
node.onRemoved = origRemoved;
|
||||
|
||||
+16
-3
@@ -12,6 +12,7 @@ const [
|
||||
ctxMod,
|
||||
dbgMod,
|
||||
asmMod,
|
||||
tmplMod,
|
||||
injMod,
|
||||
cleanerMod,
|
||||
varPickerMod,
|
||||
@@ -31,6 +32,7 @@ const [
|
||||
import("./widgets/context"),
|
||||
import("./widgets/debug"),
|
||||
import("./widgets/assembler"),
|
||||
import("./widgets/templateEditor"),
|
||||
import("./widgets/injector"),
|
||||
import("./widgets/cleaner"),
|
||||
import("./widgets/var_picker"),
|
||||
@@ -46,11 +48,14 @@ const [
|
||||
import("./extension/topbar"),
|
||||
import("./components/settings/DisplayPlaygroundModal.vue"),
|
||||
import("./components/settings/playground-store"),
|
||||
// Webfonts (Inter 400/600 + JetBrains Mono 400) — side-effect import,
|
||||
// the module just owns the @font-face CSS chunk.
|
||||
import("./extension/fonts"),
|
||||
]);
|
||||
|
||||
// Webfonts — deliberately NOT awaited above. The module only appends a
|
||||
// <link> for @font-face rules, and `font-display: swap` means text renders in
|
||||
// the system stack until they land. Nothing about registering a node type
|
||||
// depends on a font, so making registration wait on one was pure latency.
|
||||
void import("./extension/fonts");
|
||||
|
||||
// Singleton toast container — one Vue app mounted to a body-level div renders
|
||||
// every toast pushed via shared/toast-store. Each Context node's Vue app
|
||||
// imports the same store module, so toasts surface from anywhere.
|
||||
@@ -89,6 +94,7 @@ type CleanerCreateNode = Parameters<typeof cleanerMod.create>[0];
|
||||
type VarPickerCreateNode = Parameters<typeof varPickerMod.create>[0];
|
||||
type CtxLoopCreateNode = Parameters<typeof ctxLoopMod.create>[0];
|
||||
type SeedListCreateNode = Parameters<typeof seedListMod.create>[0];
|
||||
type TemplateEditorNode = Parameters<typeof tmplMod.create>[0];
|
||||
type AssemblerHelperNode = Parameters<typeof asmMod.mountHelper>[0];
|
||||
|
||||
interface NodeData { name: string }
|
||||
@@ -194,6 +200,13 @@ app.registerExtension({
|
||||
ctxLoopMod.create(node, inputName),
|
||||
WP_SEED_LIST_CONFIG: (node: SeedListCreateNode, inputName: string) =>
|
||||
seedListMod.create(node, inputName),
|
||||
// The assembler's `template`. Unlike every other entry here, this key
|
||||
// is NOT the input's socket type — the socket stays STRING so links
|
||||
// still work, and the node ships `widgetType` in the input spec's
|
||||
// extra dict purely to route the lookup here. See
|
||||
// `widgets/templateEditor.ts`.
|
||||
WP_TEMPLATE_EDITOR: (node: TemplateEditorNode, inputName: string) =>
|
||||
tmplMod.create(node, inputName),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -290,3 +290,76 @@ describe("ConstraintMatrix.vue", () => {
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConstraintMatrix.vue — accepts-axis mark", () => {
|
||||
const groups = { SHOES: ["sneakers", "heels"], REGISTER: ["casual", "formal"] };
|
||||
|
||||
it("marks an accepts source band with the amber pi-sync, classify bands unmarked", () => {
|
||||
const wrap = mount(ConstraintMatrix, {
|
||||
props: {
|
||||
rows: ["sneakers", "heels", "casual", "formal"],
|
||||
cols: ["warm", "cool"],
|
||||
modelValue: {},
|
||||
sourceGroups: groups,
|
||||
sourceGroupKinds: { SHOES: "accepts" },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
const marks = wrap.findAll(".wp-mx-axis-mark");
|
||||
expect(marks.length).toBe(1);
|
||||
// it sits on the SHOES band, not REGISTER
|
||||
const shoesHead = wrap.findAll(".wp-mx-grp-head").find((h) => h.text().includes("SHOES"));
|
||||
expect(shoesHead?.find(".wp-mx-axis-mark").exists()).toBe(true);
|
||||
const registerHead = wrap.findAll(".wp-mx-grp-head").find((h) => h.text().includes("REGISTER"));
|
||||
expect(registerHead?.find(".wp-mx-axis-mark").exists()).toBe(false);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("marks an accepts TARGET column band too", () => {
|
||||
const wrap = mount(ConstraintMatrix, {
|
||||
props: {
|
||||
rows: ["warm", "cool"],
|
||||
cols: ["sneakers", "heels"],
|
||||
modelValue: {},
|
||||
targetGroups: { SHOES: ["sneakers", "heels"] },
|
||||
targetGroupKinds: { SHOES: "accepts" },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
expect(wrap.find(".wp-mx-th-band .wp-mx-axis-mark").exists()).toBe(true);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("shows nothing when no group is accepts", () => {
|
||||
const wrap = mount(ConstraintMatrix, {
|
||||
props: {
|
||||
rows: ["sneakers", "heels"], cols: ["warm", "cool"], modelValue: {},
|
||||
sourceGroups: { SHOES: ["sneakers", "heels"] },
|
||||
sourceGroupKinds: { SHOES: "classify" },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
expect(wrap.findAll(".wp-mx-axis-mark").length).toBe(0);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("picking ONLY writes mode=only with factor 1", async () => {
|
||||
const wrap = mountGrid({}, ["red"], ["warm"]);
|
||||
await wrap.find('[data-test="cell-red-warm"]').trigger("click");
|
||||
await popover(wrap).find("button.pop-btn.b-only").trigger("click");
|
||||
expect(lastEmitted(wrap)?.red?.warm).toEqual({ mode: "only", factor: 1 });
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("marks the neutral cells of an only row as shut out, and only that row", () => {
|
||||
const wrap = mountGrid({ red: { warm: { mode: "only", factor: 1 } } }, ["red", "blue"], ["warm", "cool"]);
|
||||
const only = wrap.find('[data-test="cell-red-warm"]');
|
||||
expect(only.classes()).toContain("s-only");
|
||||
const shut = wrap.find('[data-test="cell-red-cool"]');
|
||||
expect(shut.classes()).toContain("implied-out");
|
||||
expect(shut.text()).toContain("×");
|
||||
expect(shut.attributes("aria-label")).toMatch(/excluded by an Only rule/);
|
||||
expect(wrap.find('[data-test="cell-blue-cool"]').classes()).not.toContain("implied-out");
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
import EditorFrame from "../components/EditorFrame.vue";
|
||||
import type { ModuleHistoryEntry } from "../api/types";
|
||||
@@ -93,3 +94,54 @@ describe("EditorFrame.vue", () => {
|
||||
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditorFrame.vue — failed-save auto-scroll", () => {
|
||||
it("scrolls the first error's field into view when errors first appear", async () => {
|
||||
// The offending section must exist in the DOM to be scrolled to.
|
||||
const section = document.createElement("div");
|
||||
section.id = "editor-section-options";
|
||||
const input = document.createElement("input");
|
||||
section.appendChild(input);
|
||||
document.body.appendChild(section);
|
||||
// jsdom has no scrollIntoView — define it as a spy.
|
||||
const spy = vi.fn();
|
||||
(section as unknown as { scrollIntoView: unknown }).scrollIntoView = spy;
|
||||
|
||||
const wrap = mount(EditorFrame, {
|
||||
props: { title: "T", backRoute: "/wildcards", backLabel: "W", errors: [] },
|
||||
slots: { default: "<div/>" },
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
// Empty -> populated is the "tried to save while invalid" edge.
|
||||
await wrap.setProps({
|
||||
errors: [{ field: "editor-section-options", label: "Options", message: "Required" }],
|
||||
});
|
||||
await nextTick();
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
|
||||
section.remove();
|
||||
});
|
||||
|
||||
it("does not re-scroll while the error list only shrinks/changes", async () => {
|
||||
const section = document.createElement("div");
|
||||
section.id = "editor-section-identity";
|
||||
document.body.appendChild(section);
|
||||
const spy = vi.fn();
|
||||
(section as unknown as { scrollIntoView: unknown }).scrollIntoView = spy;
|
||||
const err = (field: string) => ({ field, label: "X", message: "bad" });
|
||||
|
||||
const wrap = mount(EditorFrame, {
|
||||
props: { title: "T", backRoute: "/wildcards", backLabel: "W",
|
||||
errors: [err("editor-section-identity"), err("editor-section-options")] },
|
||||
slots: { default: "<div/>" },
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
await nextTick();
|
||||
spy.mockClear();
|
||||
// Fixing one error keeps the list non-empty — must NOT yank the scroll.
|
||||
await wrap.setProps({ errors: [err("editor-section-identity")] });
|
||||
await nextTick();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
section.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* QA sweep over `$var.AXIS` authoring in the template editor.
|
||||
*
|
||||
* Written from a real session against the SPA combine editor, where typing an
|
||||
* axis reference misbehaved in several separate ways at once. Each `it` here is
|
||||
* one reported (or adjacent, suspected) interaction, so a regression names the
|
||||
* behaviour that broke rather than "the editor".
|
||||
*/
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import RichTextInput from "../components/RichTextInput.vue";
|
||||
|
||||
const PRODUCERS = new Map([
|
||||
["outfit", {
|
||||
kind: "wildcard", shadowed: 0, moduleName: "QA outfit",
|
||||
axes: [
|
||||
{ axis: "SHOES", tags: ["sneakers", "high_heels", "sandals"], hueIndex: 1 },
|
||||
{ axis: "EXPOSES", tags: ["navel"], hueIndex: 2 },
|
||||
],
|
||||
}],
|
||||
["shoes", { kind: "wildcard", shadowed: 0, moduleName: "QA shoes" }],
|
||||
]);
|
||||
|
||||
function mountEditor(modelValue = "") {
|
||||
return mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue,
|
||||
varSuggestions: ["outfit", "shoes"],
|
||||
varProducers: PRODUCERS,
|
||||
multiline: true,
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
/** Type `text` into the host's trailing text span and fire `input`, which is
|
||||
* how a browser delivers a keystroke to a contenteditable. */
|
||||
async function typeInto(wrap: ReturnType<typeof mountEditor>, text: string) {
|
||||
const host = wrap.find(".wp-rt__host");
|
||||
const spans = (host.element as HTMLElement).querySelectorAll(".wp-rt__text");
|
||||
const target = spans[spans.length - 1] ?? host.element;
|
||||
target.textContent = (target.textContent ?? "") + text;
|
||||
await host.trigger("input");
|
||||
await nextTick();
|
||||
return host;
|
||||
}
|
||||
|
||||
/** Open the `$` popover the way the component exposes for tests. It sets the
|
||||
* trigger without a query, so every suggestion is listed — which is what we
|
||||
* want when asserting that the axis rows EXIST and are shaped correctly.
|
||||
* Query-narrowing is covered at the probe level, where it is deterministic. */
|
||||
async function openVarPopover(wrap: ReturnType<typeof mountEditor>) {
|
||||
await (wrap.vm as unknown as {
|
||||
__triggerAutocompleteForTest: (t: "@" | "$") => Promise<void>;
|
||||
}).__triggerAutocompleteForTest("$");
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
describe("axis authoring — the popover", () => {
|
||||
it("lists each variable's axes as their own rows", async () => {
|
||||
const wrap = mountEditor();
|
||||
await openVarPopover(wrap);
|
||||
const labels = [...document.querySelectorAll(".wp-rt-suggestions__label")]
|
||||
.map((n) => n.textContent ?? "");
|
||||
expect(labels.some((l) => l.includes("outfit"))).toBe(true);
|
||||
expect(labels.some((l) => l.includes("outfit.SHOES"))).toBe(true);
|
||||
expect(labels.some((l) => l.includes("outfit.EXPOSES"))).toBe(true);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("REPORTED: the axis row is indented under its variable", async () => {
|
||||
// A later `padding` SHORTHAND on the base rule was resetting the
|
||||
// modifier's padding-left, so the hierarchy these rows exist to show
|
||||
// rendered flat.
|
||||
const wrap = mountEditor();
|
||||
await openVarPopover(wrap);
|
||||
const axisRow = [...document.querySelectorAll(".wp-rt-suggestions__item")]
|
||||
.find((n) => (n.textContent ?? "").includes("outfit.SHOES")) as HTMLElement | undefined;
|
||||
expect(axisRow).toBeDefined();
|
||||
expect(axisRow!.className).toContain("wp-rt-suggestions__item--axis");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("shows the member tags, which are what tell two axes apart", async () => {
|
||||
const wrap = mountEditor();
|
||||
await openVarPopover(wrap);
|
||||
const axisRow = [...document.querySelectorAll(".wp-rt-suggestions__item")]
|
||||
.find((n) => (n.textContent ?? "").includes("outfit.SHOES"));
|
||||
expect(axisRow?.textContent).toContain("sneakers");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("a variable with no axes contributes exactly one row", async () => {
|
||||
const wrap = mountEditor();
|
||||
await openVarPopover(wrap);
|
||||
const shoesRows = [...document.querySelectorAll(".wp-rt-suggestions__label")]
|
||||
.filter((n) => (n.textContent ?? "").includes("shoes"));
|
||||
expect(shoesRows).toHaveLength(1);
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("axis authoring — the chip", () => {
|
||||
it("REPORTED: $outfit.SHOES renders as ONE var atom, not inert text", async () => {
|
||||
const wrap = mountEditor("wearing $outfit.SHOES today");
|
||||
await nextTick();
|
||||
const host = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
const vars = host.querySelectorAll(".wp-refchip--var, .wp-rt-var");
|
||||
expect(vars.length).toBeGreaterThan(0);
|
||||
expect(host.textContent).toContain("$outfit.SHOES");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("keeps the whole reference in one atom so it deletes as a unit", async () => {
|
||||
const wrap = mountEditor("$outfit.SHOES");
|
||||
await nextTick();
|
||||
const host = wrap.find(".wp-rt__host").element as HTMLElement;
|
||||
// Two atoms would mean backspace eats `.SHOES` and leaves a bare `$outfit`
|
||||
// that silently means something else.
|
||||
const atoms = host.querySelectorAll(".wp-refchip--var, .wp-rt-var");
|
||||
expect(atoms.length).toBe(1);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("round-trips the value unchanged", async () => {
|
||||
const wrap = mountEditor("a $outfit.SHOES b");
|
||||
await nextTick();
|
||||
await wrap.find(".wp-rt__host").trigger("input");
|
||||
const events = wrap.emitted("update:modelValue") ?? [];
|
||||
if (events.length) {
|
||||
expect(events[events.length - 1]?.[0]).toBe("a $outfit.SHOES b");
|
||||
}
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("axis authoring — editing an existing reference", () => {
|
||||
it("REPORTED: backspace deletes a character without committing a chip", async () => {
|
||||
const wrap = mountEditor();
|
||||
await typeInto(wrap, "$outfi");
|
||||
await flushPromises();
|
||||
const host = wrap.find(".wp-rt__host");
|
||||
// The component handles Backspace itself and preventDefaults, so the test
|
||||
// must NOT also remove the character — doing both deletes twice and
|
||||
// "reproduces" a bug that is purely the harness.
|
||||
await host.trigger("keydown", { key: "Backspace" });
|
||||
await flushPromises();
|
||||
// Mid-word text must stay editable text: Backspace must never be the thing
|
||||
// that seals a chip, or a typo costs the whole reference.
|
||||
const chips = (host.element as HTMLElement).querySelectorAll(".wp-refchip");
|
||||
expect(chips.length).toBe(0);
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("an undeclared axis is marked rather than silently empty", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "$outfit.BELTS",
|
||||
varSuggestions: ["outfit", "shoes"],
|
||||
varProducers: PRODUCERS,
|
||||
graphAware: true,
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
await nextTick();
|
||||
const html = (wrap.find(".wp-rt__host").element as HTMLElement).innerHTML;
|
||||
// The engine renders an undeclared axis as "", so the only symptom without
|
||||
// a mark is a word missing from the prompt.
|
||||
expect(html).toContain("wp-refchip__accessor--unknown");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("a declared axis is NOT marked as an error", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "$outfit.SHOES",
|
||||
varSuggestions: ["outfit", "shoes"],
|
||||
varProducers: PRODUCERS,
|
||||
graphAware: true,
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
await nextTick();
|
||||
const html = (wrap.find(".wp-rt__host").element as HTMLElement).innerHTML;
|
||||
expect(html).not.toContain("wp-refchip__accessor--unknown");
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("echo guard — the cause behind the Backspace bug", () => {
|
||||
it("a parent that echoes modelValue back must not re-parse it into chips", async () => {
|
||||
// Found in a real browser, invisible to jsdom: the atom-direct edit paths
|
||||
// sync the host DOM imperatively AFTER updating the model, so the watcher's
|
||||
// `readHostAsText() === next` guard still saw the PRE-edit string, missed,
|
||||
// and re-parsed through parseForSurface — which chipifies. One Backspace
|
||||
// mid-word therefore sealed `$mo` into a chip, closed the popover and threw
|
||||
// the caret onto the host root: three reported symptoms, one echo.
|
||||
const wrap = mountEditor("start");
|
||||
await nextTick();
|
||||
// Emit, then echo the emitted value back exactly as a controlled parent does.
|
||||
const host = wrap.find(".wp-rt__host");
|
||||
const span = (host.element as HTMLElement).querySelector(".wp-rt__text");
|
||||
if (span) span.textContent = "start $mo";
|
||||
await host.trigger("input");
|
||||
await flushPromises();
|
||||
const ev = wrap.emitted("update:modelValue") ?? [];
|
||||
const emitted = ev[ev.length - 1]?.[0] as string;
|
||||
await wrap.setProps({ modelValue: emitted });
|
||||
await flushPromises();
|
||||
// Mid-word text stays text; the echo must be a no-op.
|
||||
expect((host.element as HTMLElement).querySelectorAll(".wp-refchip").length).toBe(0);
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stale node snapshots must not look like errors", () => {
|
||||
it("stays silent when the producer declares no axes at all", async () => {
|
||||
// A canvas node carries its own payload SNAPSHOT, so a wildcard added
|
||||
// before its group was promoted has tag_groups but no tag_group_kinds.
|
||||
// That is indistinguishable from "declares none", and marking either put a
|
||||
// squiggle under a reference that resolves fine once the node refreshes.
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "$shoes.SHOES",
|
||||
varSuggestions: ["outfit", "shoes"],
|
||||
varProducers: PRODUCERS, // `shoes` has no axes
|
||||
graphAware: true,
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
await nextTick();
|
||||
const html = (wrap.find(".wp-rt__host").element as HTMLElement).innerHTML;
|
||||
expect(html).not.toContain("wp-refchip__accessor--unknown");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("still warns when the producer declares axes and this is not one", async () => {
|
||||
const wrap = mount(RichTextInput, {
|
||||
props: {
|
||||
modelValue: "$outfit.BELTS",
|
||||
varSuggestions: ["outfit", "shoes"],
|
||||
varProducers: PRODUCERS, // `outfit` declares SHOES + EXPOSES
|
||||
graphAware: true,
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
await nextTick();
|
||||
const html = (wrap.find(".wp-rt__host").element as HTMLElement).innerHTML;
|
||||
expect(html).toContain("wp-refchip__accessor--unknown");
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the host swap must not read as the user leaving the field", () => {
|
||||
/** Committing a suggestion re-mounts the host element (see the `hostEpoch`
|
||||
* bump in `applyAtoms`). In a real browser that removes the element the
|
||||
* caret lives in, and the browser fires `blur` on the way out — which
|
||||
* `onHostBlur` used to treat as a commit: it read the already-detached
|
||||
* host as "", saw that differ from the atoms it had just applied, and
|
||||
* re-applied the empty parse. Every `$var` commit erased the whole field.
|
||||
* jsdom does not fire blur on removal, so this dispatches the blur the
|
||||
* browser would, in the window before the swap settles. */
|
||||
it("REPORTED: committing a $var suggestion does not erase the field", async () => {
|
||||
const wrap = mountEditor("x ");
|
||||
await nextTick();
|
||||
const vm = wrap.vm as unknown as {
|
||||
__triggerAutocompleteForTest: (t: "@" | "$") => void;
|
||||
__applyAutocompleteForTest: (label: string) => void;
|
||||
};
|
||||
const host = wrap.find(".wp-rt__host");
|
||||
vm.__triggerAutocompleteForTest("$");
|
||||
vm.__applyAutocompleteForTest("outfit");
|
||||
// Synchronous, like the blur the element removal fires during the patch —
|
||||
// BEFORE the nextTick that clears the swap flag.
|
||||
await host.trigger("blur");
|
||||
await flushPromises();
|
||||
// Re-query: the swap replaced the element `host` was captured from.
|
||||
expect(wrap.find(".wp-rt__host").text()).toContain("$outfit");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("commits an axis reference whole", async () => {
|
||||
const wrap = mountEditor("x ");
|
||||
await nextTick();
|
||||
const vm = wrap.vm as unknown as {
|
||||
__triggerAutocompleteForTest: (t: "@" | "$") => void;
|
||||
__applyAutocompleteForTest: (label: string) => void;
|
||||
};
|
||||
const host = wrap.find(".wp-rt__host");
|
||||
vm.__triggerAutocompleteForTest("$");
|
||||
vm.__applyAutocompleteForTest("outfit.SHOES");
|
||||
await host.trigger("blur");
|
||||
await flushPromises();
|
||||
expect(wrap.find(".wp-rt__host").text()).toContain("$outfit.SHOES");
|
||||
wrap.unmount();
|
||||
});
|
||||
|
||||
it("a genuine blur still settles pending text into chips", async () => {
|
||||
// The guard must be narrow: only the swap's own blur is ignored.
|
||||
const wrap = mountEditor("");
|
||||
await nextTick();
|
||||
const host = wrap.find(".wp-rt__host");
|
||||
const span = (host.element as HTMLElement).querySelector(".wp-rt__text");
|
||||
if (span) span.textContent = "$outfit";
|
||||
await host.trigger("input");
|
||||
await host.trigger("blur");
|
||||
await flushPromises();
|
||||
expect((host.element as HTMLElement).querySelectorAll(".wp-refchip").length)
|
||||
.toBeGreaterThan(0);
|
||||
wrap.unmount();
|
||||
});
|
||||
});
|
||||
@@ -2011,4 +2011,49 @@ describe("RichTextInput — cut and copy", () => {
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
// Ctrl+A Ctrl+X on a field that is ALREADY empty selects nothing but the
|
||||
// ZWSP caret pad. Left to the browser, that cut put the invisible pad on the
|
||||
// clipboard, overwriting whatever the user had just cut — so the second
|
||||
// Ctrl+X of the "cut, cut again, paste" repro silently lost their text.
|
||||
// Measured in Firefox 153 against 2.14.0: the paste landed U+200B.
|
||||
it.each(["cut", "copy"] as const)(
|
||||
"a %s over an empty field keeps the clipboard as it was",
|
||||
async (type) => {
|
||||
const w = mount(RichTextInput, {
|
||||
props: { modelValue: "" },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
const host = w.get('[contenteditable="true"]').element as HTMLElement;
|
||||
selectAll(host);
|
||||
const ev = clipEvent(type);
|
||||
host.dispatchEvent(ev);
|
||||
await flushPromises();
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(ev.__data["text/plain"]).toBeUndefined();
|
||||
expect(w.emitted("update:modelValue")).toBeUndefined();
|
||||
w.unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it("paste drops caret-pad ZWSPs instead of writing them into the value", async () => {
|
||||
// Clipboards already holding a pad (cut by an older build, or copied from
|
||||
// another field's DOM) must not smuggle an invisible character into the
|
||||
// prompt text.
|
||||
const w = mount(RichTextInput, {
|
||||
props: { modelValue: "" },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await flushPromises();
|
||||
const host = w.get('[contenteditable="true"]').element as HTMLElement;
|
||||
selectAll(host);
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { getData: () => "red dress" } });
|
||||
host.dispatchEvent(paste);
|
||||
await flushPromises();
|
||||
const emitted = w.emitted("update:modelValue");
|
||||
expect(emitted?.[emitted.length - 1]).toEqual(["red dress"]);
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ModuleRow } from "../api/types";
|
||||
@@ -105,8 +106,18 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/test", name: "test", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function mountRunner() {
|
||||
return mount(TestRunner, { global: { plugins: [] } });
|
||||
return mount(TestRunner, { global: { plugins: [makeRouter()] } });
|
||||
}
|
||||
|
||||
describe("TestRunner.vue", () => {
|
||||
|
||||
@@ -66,4 +66,66 @@ describe("useBulkSelection", () => {
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Select-all under an active filter.
|
||||
*
|
||||
* Before the second argument existed, `toggleAll` selected `getIds()` — every
|
||||
* row, filtered or not. Filtering 40 values down to 3 and clicking the header
|
||||
* checkbox therefore selected all 40, and the delete that usually follows
|
||||
* removed 37 rows that were never on screen. These are the tests that stop
|
||||
* that coming back.
|
||||
*/
|
||||
describe("with a filtered view", () => {
|
||||
const all = ["a", "b", "c", "d"];
|
||||
|
||||
it("selects only the visible rows", () => {
|
||||
const visible = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => all, () => visible.value);
|
||||
s.toggleAll();
|
||||
expect(s.selectedIds()).toEqual(["a", "b"]);
|
||||
expect(s.count.value).toBe(2);
|
||||
});
|
||||
|
||||
it("reports allSelected against the visible rows, not every row", () => {
|
||||
const visible = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => all, () => visible.value);
|
||||
s.toggleAll();
|
||||
// Every VISIBLE row is selected, so the header checkbox is checked —
|
||||
// even though c and d are not selected.
|
||||
expect(s.allSelected.value).toBe(true);
|
||||
});
|
||||
|
||||
it("deselects only the visible rows, leaving a hidden selection intact", () => {
|
||||
const visible = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => all, () => visible.value);
|
||||
s.toggle("d"); // selected while it was visible earlier
|
||||
s.toggleAll(); // + a, b
|
||||
expect(s.selectedIds().sort()).toEqual(["a", "b", "d"]);
|
||||
s.toggleAll(); // all visible selected → remove just those
|
||||
expect(s.selectedIds()).toEqual(["d"]);
|
||||
});
|
||||
|
||||
it("keeps a selection made before the filter changed", () => {
|
||||
const visible = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => all, () => visible.value);
|
||||
s.toggleAll();
|
||||
// User retypes the filter; c is now the only match.
|
||||
visible.value = ["c"];
|
||||
// a and b stay selected and stay deletable — the count is the honest
|
||||
// total, which is what the bulk toolbar shows.
|
||||
expect(s.count.value).toBe(2);
|
||||
expect(s.selectedIds().sort()).toEqual(["a", "b"]);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
});
|
||||
|
||||
it("behaves exactly as before when no visible-getter is passed", () => {
|
||||
const rows = ref(["a", "b", "c"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
s.toggleAll();
|
||||
expect(s.count.value).toBe(3);
|
||||
s.toggleAll();
|
||||
expect(s.count.value).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
} from "@/manager/import-export/single-row-publish";
|
||||
import { parsePayload } from "@/manager/import-export/parse";
|
||||
import {
|
||||
CONSTRAINT_ONLY_SCHEMA_VERSION,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
SP2B_SCHEMA_VERSION,
|
||||
SP3_REACH_SCHEMA_VERSION,
|
||||
TAG_AXES_SCHEMA_VERSION,
|
||||
} from "@/manager/import-export/migrations";
|
||||
import wildcardFixture from "@/validators/fixtures/v1/module-wildcard.json";
|
||||
import axisWildcardRow from "@/validators/fixtures/engine-parity/wildcard.json";
|
||||
|
||||
/** Minimal engine-row constraint module carrying a `target_select`. Carries
|
||||
* every `moduleRowBase` required field so it also passes strict v2
|
||||
@@ -281,4 +284,111 @@ describe("publish body stamping", () => {
|
||||
});
|
||||
expect(body.schema_version).toBe(SP3_REACH_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
// --- Tag axes: stamp catalog v5 ONLY when a wildcard marks a tag group
|
||||
// `accepts`. An absent or all-classify `tag_group_kinds` map is the
|
||||
// pre-axes behaviour and keeps the lower stamp. ---
|
||||
|
||||
function axisRow(kinds?: Record<string, string>): Record<string, unknown> {
|
||||
const row = JSON.parse(JSON.stringify(axisWildcardRow)) as {
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
if (kinds === undefined) delete row.payload.tag_group_kinds;
|
||||
else row.payload.tag_group_kinds = kinds;
|
||||
return row as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("stamps TAG_AXES (5) for a wildcard with an accepts tag group", () => {
|
||||
expect(schemaVersionForPayload(axisRow({ TEXTURE: "accepts" }))).toBe(
|
||||
TAG_AXES_SCHEMA_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps CURRENT for an all-classify or absent tag_group_kinds map", () => {
|
||||
expect(schemaVersionForPayload(axisRow({ TEXTURE: "classify" }))).toBe(
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
);
|
||||
expect(schemaVersionForPayload(axisRow())).toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("finds an accepts axis on a wildcard nested in a bundle child", () => {
|
||||
const bundle = {
|
||||
id: "bd-002abc",
|
||||
name: "axis-bundle",
|
||||
children: [constraintRow(), axisRow({ TEXTURE: "accepts" })],
|
||||
} as Record<string, unknown>;
|
||||
expect(schemaVersionForPayload(bundle)).toBe(TAG_AXES_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("stamps TAG_AXES (5) over non-default reach when both are present", () => {
|
||||
const bundle = {
|
||||
id: "bd-003abc",
|
||||
name: "both-bundle",
|
||||
children: [constraintRow({ mode: "next", count: 2 }), axisRow({ TEXTURE: "accepts" })],
|
||||
} as Record<string, unknown>;
|
||||
expect(schemaVersionForPayload(bundle)).toBe(TAG_AXES_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("buildPublishBody stamps TAG_AXES for a published wildcard with an accepts axis", () => {
|
||||
const body = buildPublishBody({
|
||||
payload: axisRow({ TEXTURE: "accepts" }),
|
||||
name: "demo",
|
||||
description: "",
|
||||
});
|
||||
expect(body.schema_version).toBe(TAG_AXES_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
// --- Linked picks: stamp catalog v6 ONLY when a constraint rule uses the
|
||||
// `only` mode, wherever it lives (library matrix/exceptions, instance
|
||||
// extra exceptions, instance mode overrides). ---
|
||||
|
||||
function withPayload(extra: Record<string, unknown>): Record<string, unknown> {
|
||||
const row = constraintRow() as { payload: Record<string, unknown> };
|
||||
return { ...row, payload: { ...row.payload, ...extra } };
|
||||
}
|
||||
|
||||
it("stamps CONSTRAINT_ONLY (6) for an `only` exception", () => {
|
||||
const row = withPayload({
|
||||
exceptions: [{ source_value: "maid", target_value: "apron", mode: "only", factor: 1 }],
|
||||
});
|
||||
expect(schemaVersionForPayload(row)).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("stamps CONSTRAINT_ONLY (6) for an `only` matrix cell", () => {
|
||||
const row = withPayload({ matrix: { summer: { open: { mode: "only", factor: 1 } } } });
|
||||
expect(schemaVersionForPayload(row)).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("finds `only` in instance overrides and extra exceptions", () => {
|
||||
const withInstance = (instance: Record<string, unknown>) => ({ ...constraintRow(), instance });
|
||||
expect(schemaVersionForPayload(withInstance({ cell_mode_overrides: { k: "only" } })))
|
||||
.toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
|
||||
expect(schemaVersionForPayload(withInstance({ exception_mode_overrides: { k: "only" } })))
|
||||
.toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
|
||||
expect(schemaVersionForPayload(withInstance({
|
||||
extra_exceptions: [{ source: "a", target: "b", mode: "only", factor: 1 }],
|
||||
}))).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
|
||||
expect(schemaVersionForPayload(withInstance({ cell_mode_overrides: { k: "exclude" } })))
|
||||
.toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("keeps the lower stamp when no rule uses `only`", () => {
|
||||
const row = withPayload({
|
||||
matrix: { summer: { open: { mode: "boost", factor: 2 } } },
|
||||
exceptions: [{ source_value: "a", target_value: "b", mode: "exclude", factor: 0 }],
|
||||
});
|
||||
expect(schemaVersionForPayload(row)).toBe(CURRENT_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
it("stamps CONSTRAINT_ONLY (6) over an accepts axis in the same bundle", () => {
|
||||
const bundle = {
|
||||
id: "bd-004abc",
|
||||
name: "linked-bundle",
|
||||
children: [
|
||||
withPayload({ exceptions: [{ source_value: "a", target_value: "b", mode: "only", factor: 1 }] }),
|
||||
axisRow({ TEXTURE: "accepts" }),
|
||||
],
|
||||
} as Record<string, unknown>;
|
||||
expect(schemaVersionForPayload(bundle)).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
MAX_KNOWN_SCHEMA_VERSION,
|
||||
SP3_REACH_SCHEMA_VERSION,
|
||||
CONSTRAINT_ONLY_SCHEMA_VERSION,
|
||||
} from "@/manager/import-export/migrations";
|
||||
import { MAX_KNOWN_SCHEMA_VERSION as MAX_KNOWN_VIA_INSTALL } from "@/manager/import-export/install";
|
||||
import { schemaVersionForPayload } from "@/manager/import-export/single-row-publish";
|
||||
@@ -11,23 +11,23 @@ import { schemaVersionForPayload } from "@/manager/import-export/single-row-publ
|
||||
* Pins the deliberate fork between the two schema constants (see
|
||||
* `docs/superpowers/specs/2026-06-14-schema-gate-max-known-version-design.md`):
|
||||
*
|
||||
* - CURRENT_SCHEMA_VERSION = 2 — head of the migration chain (v2→v3/v3→v4
|
||||
* are no-ops, so the chain genuinely stops at 2).
|
||||
* - MAX_KNOWN_SCHEMA_VERSION = 4 — highest version this runtime can READ +
|
||||
* - CURRENT_SCHEMA_VERSION = 2 — head of the migration chain (v2→v3
|
||||
* through v5→v6 are no-ops, so the chain genuinely stops at 2).
|
||||
* - MAX_KNOWN_SCHEMA_VERSION = 6 — highest version this runtime can READ +
|
||||
* WRITE; the value advertised to the community publish-gate / boot
|
||||
* catalog-probe.
|
||||
*
|
||||
* The community gate predicate is `hostSchema < minCompatible` where
|
||||
* `minCompatible = max(breaking versions) = 3`. Advertising CURRENT (2)
|
||||
* fails the gate (`2 < 3`); advertising MAX_KNOWN (4) clears it.
|
||||
* fails the gate (`2 < 3`); advertising MAX_KNOWN (6) clears it.
|
||||
*/
|
||||
describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
|
||||
it("CURRENT_SCHEMA_VERSION is the migration-chain head (2)", () => {
|
||||
expect(CURRENT_SCHEMA_VERSION).toBe(2);
|
||||
});
|
||||
|
||||
it("MAX_KNOWN_SCHEMA_VERSION is the support ceiling (4)", () => {
|
||||
expect(MAX_KNOWN_SCHEMA_VERSION).toBe(4);
|
||||
it("MAX_KNOWN_SCHEMA_VERSION is the support ceiling (6)", () => {
|
||||
expect(MAX_KNOWN_SCHEMA_VERSION).toBe(6);
|
||||
});
|
||||
|
||||
it("the ceiling is strictly above the chain head (the whole point of the fork)", () => {
|
||||
@@ -40,7 +40,7 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
|
||||
// whatever install.ts re-exports. Pin that it is MAX_KNOWN, identical to
|
||||
// the source-of-truth constant.
|
||||
expect(MAX_KNOWN_VIA_INSTALL).toBe(MAX_KNOWN_SCHEMA_VERSION);
|
||||
expect(MAX_KNOWN_VIA_INSTALL).toBe(4);
|
||||
expect(MAX_KNOWN_VIA_INSTALL).toBe(6);
|
||||
});
|
||||
|
||||
it("a plain v2-content payload still stamps at the chain head (unchanged)", () => {
|
||||
@@ -53,8 +53,8 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
|
||||
|
||||
it("drift-guard: MAX_KNOWN covers the highest version schemaVersionForPayload can stamp", () => {
|
||||
// Build a payload exercising the highest feature the runtime knows how to
|
||||
// content-stamp today: an sp3 constraint with a non-default `target_select`
|
||||
// reach selector → stamps SP3_REACH_SCHEMA_VERSION (4).
|
||||
// content-stamp today: a constraint with an `only` rule → stamps
|
||||
// CONSTRAINT_ONLY_SCHEMA_VERSION (6).
|
||||
const highestFeaturePayload = {
|
||||
id: "cn-001abc",
|
||||
type: "constraint",
|
||||
@@ -62,12 +62,11 @@ describe("schema-version fork: MAX_KNOWN vs CURRENT", () => {
|
||||
source_wildcard_id: "wc-aaa",
|
||||
target_wildcard_id: "wc-bbb",
|
||||
matrix: {},
|
||||
exceptions: [],
|
||||
target_select: { mode: "next", count: 2 },
|
||||
exceptions: [{ source_value: "maid", target_value: "apron", mode: "only", factor: 1 }],
|
||||
},
|
||||
};
|
||||
const stamped = schemaVersionForPayload(highestFeaturePayload);
|
||||
expect(stamped).toBe(SP3_REACH_SCHEMA_VERSION);
|
||||
expect(stamped).toBe(CONSTRAINT_ONLY_SCHEMA_VERSION);
|
||||
|
||||
// The mechanical maintenance contract from the spec: if someone teaches
|
||||
// schemaVersionForPayload a higher stamp without bumping MAX_KNOWN, update
|
||||
|
||||
@@ -70,12 +70,24 @@ describe("pickWeightedOption", () => {
|
||||
expect(aHits / N).toBeLessThan(0.96);
|
||||
});
|
||||
|
||||
it("falls back to first when all weights are zero", () => {
|
||||
it("picks nothing when all weights are zero", () => {
|
||||
// Weight 0 means "disabled", so all of them zero means nothing is
|
||||
// selectable. Returning the first option showed the user a plausible
|
||||
// pick the engine would never actually produce.
|
||||
const opts: WildcardOption[] = [
|
||||
{ id: "a", value: "a", weight: 0, sub_categories: [] },
|
||||
{ id: "b", value: "b", weight: 0, sub_categories: [] },
|
||||
];
|
||||
expect(pickWeightedOption(opts)?.value).toBe("a");
|
||||
expect(pickWeightedOption(opts)).toBeNull();
|
||||
});
|
||||
|
||||
it("still picks the one live option among disabled ones", () => {
|
||||
const opts: WildcardOption[] = [
|
||||
{ id: "a", value: "a", weight: 0, sub_categories: [] },
|
||||
{ id: "b", value: "b", weight: 2, sub_categories: [] },
|
||||
{ id: "c", value: "c", weight: 0, sub_categories: [] },
|
||||
];
|
||||
expect(pickWeightedOption(opts)?.value).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -78,3 +78,23 @@ describe("Button variants × states", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Button — icon-only hover tooltip", () => {
|
||||
it("mirrors aria-label into title when there is no visible text", () => {
|
||||
const wrap = mount(Button, { props: { icon: "pi-pencil", ariaLabel: "Edit" } });
|
||||
expect(wrap.get("button").attributes("title")).toBe("Edit");
|
||||
});
|
||||
|
||||
it("does NOT set a title when the button has visible text", () => {
|
||||
const wrap = mount(Button, {
|
||||
props: { icon: "pi-pencil", ariaLabel: "Edit" },
|
||||
slots: { default: "Edit" },
|
||||
});
|
||||
expect(wrap.get("button").attributes("title")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets no title when there is no aria-label to mirror", () => {
|
||||
const wrap = mount(Button, { props: { icon: "pi-pencil" } });
|
||||
expect(wrap.get("button").attributes("title")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,3 +88,31 @@ describe("uiStore — theme cycle", () => {
|
||||
expect(ui.themeMode).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uiStore · subcatDefault", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("defaults to 'populated'", () => {
|
||||
const ui = useUiStore();
|
||||
expect(ui.subcatDefault).toBe("populated");
|
||||
});
|
||||
|
||||
it("persists a chosen value to localStorage", () => {
|
||||
const ui = useUiStore();
|
||||
ui.setSubcatDefault("always");
|
||||
expect(ui.subcatDefault).toBe("always");
|
||||
expect(localStorage.getItem("wp-subcat-default")).toBe("always");
|
||||
});
|
||||
|
||||
it("reads a stored value on init, ignoring garbage", () => {
|
||||
localStorage.setItem("wp-subcat-default", "never");
|
||||
setActivePinia(createPinia());
|
||||
expect(useUiStore().subcatDefault).toBe("never");
|
||||
localStorage.setItem("wp-subcat-default", "bogus");
|
||||
setActivePinia(createPinia());
|
||||
expect(useUiStore().subcatDefault).toBe("populated");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Fresh module per test: the client keeps its startup-id / version cache
|
||||
// at module scope.
|
||||
async function loadClient() {
|
||||
vi.resetModules();
|
||||
return (await import("../client")).api;
|
||||
}
|
||||
|
||||
function respond(headers: Record<string, string>) {
|
||||
return new Response(JSON.stringify({ items: [], total: 0 }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
describe("api client — installed version check", () => {
|
||||
afterEach(() => { vi.unstubAllGlobals(); });
|
||||
|
||||
it("flags the page stale once, with the new version, when the pack was updated", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(async () =>
|
||||
respond({ "X-WP-Startup-Id": "a", "X-WP-Version": "9.9.9" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const seen: unknown[] = [];
|
||||
const onEvent = (e: Event) => seen.push((e as CustomEvent).detail);
|
||||
window.addEventListener("wp:server-restarted", onEvent);
|
||||
const api = await loadClient();
|
||||
await api.categories.list();
|
||||
await api.categories.list();
|
||||
window.removeEventListener("wp:server-restarted", onEvent);
|
||||
expect(seen).toEqual([{ version: "9.9.9" }]);
|
||||
});
|
||||
|
||||
it("stays quiet when the installed version matches this build", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async () =>
|
||||
respond({ "X-WP-Startup-Id": "a", "X-WP-Version": __APP_VERSION__ })));
|
||||
const onEvent = vi.fn();
|
||||
window.addEventListener("wp:server-restarted", onEvent);
|
||||
const api = await loadClient();
|
||||
await api.categories.list();
|
||||
window.removeEventListener("wp:server-restarted", onEvent);
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
BundleCreateInput, BundleListResponse, BundleRow, BundleUpdateInput,
|
||||
CategoryCreateInput, CategoryRow,
|
||||
DatabaseConfig, DatabaseConfigUpdate,
|
||||
TagStatus, TagSuggestResponse, TagDownloadResult,
|
||||
ModelKind, ModelSourceStatus, ModelSuggestResponse,
|
||||
DatabaseInfo, MaintenanceOp, MaintenanceResult,
|
||||
EmbedBundle,
|
||||
MatchRequest, MatchResponse,
|
||||
@@ -30,7 +32,17 @@ export class ApiError extends Error {
|
||||
*/
|
||||
let cachedStartupId: string | null = null;
|
||||
|
||||
let versionMismatchSent = false;
|
||||
|
||||
function checkStartupId(resp: Response): void {
|
||||
// `X-WP-Version` is the pack version installed on disk. When it differs
|
||||
// from the version this page was built as, the pack was updated under us:
|
||||
// flag the page stale even if we never saw the restart itself.
|
||||
const version = resp.headers.get("X-WP-Version") ?? "";
|
||||
if (version && version !== __APP_VERSION__ && !versionMismatchSent) {
|
||||
versionMismatchSent = true;
|
||||
window.dispatchEvent(new CustomEvent("wp:server-restarted", { detail: { version } }));
|
||||
}
|
||||
const sid = resp.headers.get("X-WP-Startup-Id");
|
||||
if (!sid) return;
|
||||
if (cachedStartupId === null) {
|
||||
@@ -39,7 +51,7 @@ function checkStartupId(resp: Response): void {
|
||||
}
|
||||
if (sid !== cachedStartupId) {
|
||||
cachedStartupId = sid;
|
||||
window.dispatchEvent(new CustomEvent("wp:server-restarted"));
|
||||
window.dispatchEvent(new CustomEvent("wp:server-restarted", { detail: { version } }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +241,65 @@ export const api = {
|
||||
return request<void>(`/wp/api/categories/${id}`, { method: "DELETE" });
|
||||
},
|
||||
},
|
||||
models: {
|
||||
/** How many LoRAs and embeddings ComfyUI knows about. Drives the settings
|
||||
* note — a source with zero files is worth saying so before the user
|
||||
* wonders why nothing appears. */
|
||||
status() {
|
||||
return request<ModelSourceStatus>("/wp/api/models/status", { method: "GET" });
|
||||
},
|
||||
/** Prefix search across the requested kinds only, so a source the user
|
||||
* switched off costs nothing on the wire. */
|
||||
/** `browseAll` asks for the first N of each kind when `q` is empty. Only
|
||||
* set inside a `<lora:`/`embedding:` reference, where the marker itself
|
||||
* is the request — for a bare word an empty query must stay empty. */
|
||||
suggest(q: string, kinds: ModelKind[], limit = 10, browseAll = false) {
|
||||
const query = `?q=${encodeURIComponent(q)}&kinds=${kinds.join(",")}&limit=${limit}`
|
||||
+ (browseAll ? "&all=1" : "");
|
||||
return request<ModelSuggestResponse>(`/wp/api/models/suggest${query}`, {
|
||||
method: "GET",
|
||||
});
|
||||
},
|
||||
/** Re-read both folders. ComfyUI caches its own file lists, so a model
|
||||
* added while the server runs is invisible until something asks again. */
|
||||
refresh() {
|
||||
return request<ModelSourceStatus>("/wp/api/models/refresh", { method: "POST" });
|
||||
},
|
||||
},
|
||||
|
||||
tags: {
|
||||
/** Whether a tag list is installed, and what it contains. Drives Settings:
|
||||
* the toggle is meaningless without a file, so "off" and "impossible"
|
||||
* have to be distinguishable. */
|
||||
status() {
|
||||
return request<TagStatus>("/wp/api/tags/status", { method: "GET" });
|
||||
},
|
||||
/** Prefix search. The list itself is never sent to the browser — it is
|
||||
* several megabytes — so matching happens server-side and this returns
|
||||
* one screenful. */
|
||||
suggest(q: string, limit = 20) {
|
||||
const query = `?q=${encodeURIComponent(q)}&limit=${limit}`;
|
||||
return request<TagSuggestResponse>(`/wp/api/tags/suggest${query}`, {
|
||||
method: "GET",
|
||||
});
|
||||
},
|
||||
/** Fetch the list from our GitHub release. Deliberately takes no
|
||||
* arguments: the URL and the destination are both fixed server-side so
|
||||
* there is nothing a caller can steer. See wp_api/_tag_download.py. */
|
||||
download() {
|
||||
return request<TagDownloadResult>("/wp/api/tags/download", {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
/** Delete the installed list. Takes no arguments for the same reason
|
||||
* `download` does not: the path is fixed server-side. */
|
||||
remove() {
|
||||
return request<{ removed: boolean; path: string }>("/wp/api/tags", {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
database: {
|
||||
info() {
|
||||
return request<DatabaseInfo>("/wp/api/database/info", { method: "GET" });
|
||||
|
||||
@@ -45,6 +45,16 @@ export interface WildcardPayload {
|
||||
* default "ungrouped/other" box. See design §2.2 / §4.3.
|
||||
*/
|
||||
tag_groups?: Record<string, string[]>;
|
||||
/**
|
||||
* Per-group meaning. Absent (and `classify`) is today's behaviour: tags
|
||||
* describe what an option IS and fold with AND in the constraint matrix.
|
||||
* `accepts` marks the group's tags as alternatives the option offers — an
|
||||
* OR-set the fold reads with max, and which `$var.AXIS` rolls one winner
|
||||
* from. Only `accepts` entries are ever stored, so an untouched payload
|
||||
* stays byte-identical. An `accepts` group name must be a valid identifier
|
||||
* so `$var.AXIS` parses.
|
||||
*/
|
||||
tag_group_kinds?: Record<string, "accepts">;
|
||||
/**
|
||||
* The `$varname` other modules use to read this wildcard's resolved value.
|
||||
* Optional — defaults to `slug(name)` when missing/blank. User-editable so
|
||||
@@ -115,7 +125,9 @@ export interface DerivationPayload {
|
||||
rules: DerivationRule[];
|
||||
}
|
||||
|
||||
export type ConstraintMode = "allow" | "exclude" | "boost" | "reduce";
|
||||
/** `only` (linked picks) turns its row into an allow-list: when the source
|
||||
* fires, every target without a rule of its own is excluded. */
|
||||
export type ConstraintMode = "allow" | "exclude" | "boost" | "reduce" | "only";
|
||||
|
||||
export interface ConstraintCell {
|
||||
mode: ConstraintMode;
|
||||
@@ -559,3 +571,72 @@ export interface DatabaseConfigUpdate {
|
||||
/** Omit to leave unchanged; pass `null` to explicitly clear. */
|
||||
pending_move?: PendingMove | null;
|
||||
}
|
||||
|
||||
/* ── Tag autocomplete ─────────────────────────────────────────────────── */
|
||||
|
||||
/** Danbooru's numeric tag categories. `null` when the installed file is the
|
||||
* two-column `name,count` shape, which carries no category at all. */
|
||||
export type TagCategoryName =
|
||||
| "general" | "artist" | "copyright" | "character" | "meta";
|
||||
|
||||
export interface TagSuggestion {
|
||||
/** The tag that gets INSERTED — always the canonical one, even when the
|
||||
* query matched an alias. */
|
||||
name: string;
|
||||
/** What the query actually matched. Differs from `name` only for an alias,
|
||||
* which is how the row can explain why it appeared. */
|
||||
matched: string;
|
||||
count: number;
|
||||
category: number | null;
|
||||
category_name: TagCategoryName | null;
|
||||
}
|
||||
|
||||
export interface TagSuggestResponse {
|
||||
available?: boolean;
|
||||
tags: TagSuggestion[];
|
||||
}
|
||||
|
||||
/** One installed LoRA or embedding, as an autocomplete row. */
|
||||
export interface ModelSuggestion {
|
||||
/** Shown on screen — filename without folder or extension. */
|
||||
name: string;
|
||||
/** What gets INSERTED. The full relative path as ComfyUI knows it: two
|
||||
* folders can hold the same filename and ComfyUI resolves by path, so
|
||||
* inserting the display name would silently pick the wrong file. */
|
||||
path: string;
|
||||
/** Folder prefix, or "" at the root. The row's subtitle, so two same-named
|
||||
* files in different folders are tellable apart. */
|
||||
folder: string;
|
||||
}
|
||||
|
||||
export type ModelKind = "lora" | "embedding";
|
||||
|
||||
export interface ModelSuggestResponse {
|
||||
/** Keyed by kind. A kind the caller did not ask for is simply absent. */
|
||||
results: Partial<Record<ModelKind, ModelSuggestion[]>>;
|
||||
}
|
||||
|
||||
export interface ModelSourceStatus {
|
||||
sources: Array<{ kind: ModelKind; count: number }>;
|
||||
}
|
||||
|
||||
export interface TagStatus {
|
||||
/** A file is installed AND parsed. The user setting is separate — the two
|
||||
* together are what decide whether suggestions appear. */
|
||||
available: boolean;
|
||||
/** Where the file is looked for, even when nothing is there yet. */
|
||||
path: string | null;
|
||||
tag_count: number;
|
||||
/** False for a two-column file. The UI drops the colour column entirely
|
||||
* rather than rendering it grey and meaningless. */
|
||||
has_categories: boolean;
|
||||
}
|
||||
|
||||
export interface TagDownloadResult {
|
||||
path: string;
|
||||
bytes: number;
|
||||
tag_count: number;
|
||||
has_categories: boolean;
|
||||
/** Echoed back so the UI can name exactly where the file came from. */
|
||||
source: string;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,15 @@ describe("community-posts", () => {
|
||||
}));
|
||||
expect(dl.payload).toEqual({ id: "aabb0001", type: "wildcard", name: "Subject", payload: {} });
|
||||
expect(dl.version_number).toBe(1);
|
||||
// No stamp in the response → the community's pre-versioning default (v1).
|
||||
expect(dl.schema_version).toBe(1);
|
||||
});
|
||||
|
||||
it("downloadCommunityVersion carries the version's schema_version", async () => {
|
||||
const dl = await downloadCommunityVersion("author/axes", jsonFetch({
|
||||
data: { payload_json: { id: "aabb0002" }, version_number: 3, schema_version: 5 },
|
||||
}));
|
||||
expect(dl.schema_version).toBe(5);
|
||||
});
|
||||
|
||||
it("throws on a non-ok response", async () => {
|
||||
|
||||
@@ -22,8 +22,14 @@ export interface CommunityPostDetail {
|
||||
export interface CommunityDownload {
|
||||
payload: Record<string, unknown>;
|
||||
version_number: number;
|
||||
/** The shape version the community stamped at publish. */
|
||||
schema_version: number;
|
||||
}
|
||||
|
||||
/** The server stamps every version; a row that somehow lacks one predates
|
||||
* schema versioning, which the community's own grace default treats as v1. */
|
||||
export const UNSTAMPED_SCHEMA_VERSION = 1;
|
||||
|
||||
/** An `Error` that also carries the HTTP status that produced it, so callers
|
||||
* (e.g. `communityPostExists`) can distinguish a definite not-found (404/410)
|
||||
* from a transient failure (5xx) without re-parsing the message string. */
|
||||
@@ -92,10 +98,13 @@ export async function downloadCommunityVersion(
|
||||
): Promise<CommunityDownload> {
|
||||
const r = await fetchFn(`${WPC_API_URL}/api/v1/posts/${slug}/download`);
|
||||
if (!r.ok) throw new Error(`community download ${slug}: HTTP ${r.status}`);
|
||||
const j = (await r.json()) as { data?: { payload_json?: Record<string, unknown>; version_number?: number } };
|
||||
const j = (await r.json()) as {
|
||||
data?: { payload_json?: Record<string, unknown>; version_number?: number; schema_version?: number };
|
||||
};
|
||||
const d = j.data ?? {};
|
||||
return {
|
||||
payload: (d.payload_json ?? {}) as Record<string, unknown>,
|
||||
version_number: typeof d.version_number === "number" ? d.version_number : 0,
|
||||
schema_version: typeof d.schema_version === "number" ? d.schema_version : UNSTAMPED_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("downloadDepsForDangling", () => {
|
||||
const download = vi.fn(async () => ({
|
||||
payload: { id: "aabb0001", type: "wildcard", name: "Subject", payload: { options: [] } },
|
||||
version_number: 1,
|
||||
schema_version: 3,
|
||||
}));
|
||||
const install = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
@@ -22,8 +23,10 @@ describe("downloadDepsForDangling", () => {
|
||||
expect(out.pulled).toEqual(["author/subject"]);
|
||||
expect(install).toHaveBeenCalledTimes(1);
|
||||
// Origin-stamped so the installed dep gets the community badge + update tracking.
|
||||
// Stamped with the download's own schema_version so install migrates it
|
||||
// from where it actually is (was a hard-coded 4).
|
||||
expect(install).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ schema_version: 3, wildcards: [expect.objectContaining({ id: "aabb0001" })] }),
|
||||
expect.anything(),
|
||||
{ post_slug: "author/subject", version_number: 1 },
|
||||
);
|
||||
@@ -46,7 +49,7 @@ describe("downloadDepsForDangling", () => {
|
||||
danglingUuid: "aabb0001",
|
||||
constraintDeps: [{ slug: "author/subject", module_id: "aabb0001" }],
|
||||
fetchDetail: vi.fn(async (slug: string) => ({ slug, latest_version_number: 1, dependencies: [] })),
|
||||
download: vi.fn(async () => ({ payload: { id: "aabb0001", type: "wildcard", name: "S", payload: {} }, version_number: 1 })),
|
||||
download: vi.fn(async () => ({ payload: { id: "aabb0001", type: "wildcard", name: "S", payload: {} }, version_number: 1, schema_version: 2 })),
|
||||
install: vi.fn(async () => ({ ok: false, error: { message: "boom" } })),
|
||||
});
|
||||
expect(out.ok).toBe(false);
|
||||
|
||||
@@ -46,8 +46,10 @@ export interface DownloadDepsResult {
|
||||
}
|
||||
|
||||
/** Group ONE engine-row module payload into the install envelope shape
|
||||
* (mirror of Feature 4's buildExtractEnvelope / install.test.ts RawPayload). */
|
||||
function envelopeFor(payload: Record<string, unknown>): unknown {
|
||||
* (mirror of Feature 4's buildExtractEnvelope / install.test.ts RawPayload),
|
||||
* stamped with the downloaded version's own `schema_version` so install
|
||||
* migrates it from where it actually is. */
|
||||
function envelopeFor(payload: Record<string, unknown>, schemaVersion: number): unknown {
|
||||
const type = typeof payload.type === "string" ? payload.type : "";
|
||||
const bucket: Record<string, string> = {
|
||||
wildcard: "wildcards", fixed_values: "fixed_values", combine: "combines",
|
||||
@@ -60,7 +62,7 @@ function envelopeFor(payload: Record<string, unknown>): unknown {
|
||||
const key = bucket[type];
|
||||
if (key) buckets[key].push(payload);
|
||||
else if (typeof payload.children !== "undefined") buckets.bundles.push(payload);
|
||||
return { schema_version: 4, ...buckets };
|
||||
return { schema_version: schemaVersion, ...buckets };
|
||||
}
|
||||
|
||||
export async function downloadDepsForDangling(
|
||||
@@ -92,7 +94,7 @@ export async function downloadDepsForDangling(
|
||||
const deps: InstallDependencyEdge[] = detail.dependencies
|
||||
.filter((e) => typeof e.module_id === "string" && e.module_id)
|
||||
.map((e) => ({ module_id: e.module_id, slug: e.slug }));
|
||||
const res = await args.install(envelopeFor(dl.payload), deps, {
|
||||
const res = await args.install(envelopeFor(dl.payload, dl.schema_version), deps, {
|
||||
post_slug: slug,
|
||||
version_number: dl.version_number,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import { appendSnapshot, stripHistory } from "../utils/history";
|
||||
import { installEnvelope } from "../import-export/install";
|
||||
import { api } from "../api/client";
|
||||
import { wrapAsEngineExport } from "./engine-export-wrap";
|
||||
import { UNSTAMPED_SCHEMA_VERSION } from "../community/community-posts";
|
||||
|
||||
interface UpdateEntry {
|
||||
entity_id: string;
|
||||
@@ -77,13 +78,23 @@ const oldName = computed(() => {
|
||||
* downloads are open for everyone (anon downloads still increment the
|
||||
* post counter, which is the desired behaviour anyway).
|
||||
*/
|
||||
async function fetchLatestPayload(slug: string): Promise<{ payload: Record<string, unknown>; version: number }> {
|
||||
async function fetchLatestPayload(
|
||||
slug: string,
|
||||
): Promise<{ payload: Record<string, unknown>; version: number; schemaVersion: number }> {
|
||||
const resp = await fetch(`${WPC_API_URL}/api/v1/posts/${slug}/download`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch latest version (HTTP ${resp.status})`);
|
||||
}
|
||||
const body = await resp.json() as { data: { payload_json: Record<string, unknown>; version_number: number } };
|
||||
return { payload: body.data.payload_json, version: body.data.version_number };
|
||||
const body = await resp.json() as {
|
||||
data: { payload_json: Record<string, unknown>; version_number: number; schema_version?: number };
|
||||
};
|
||||
return {
|
||||
payload: body.data.payload_json,
|
||||
version: body.data.version_number,
|
||||
schemaVersion: typeof body.data.schema_version === "number"
|
||||
? body.data.schema_version
|
||||
: UNSTAMPED_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +174,7 @@ async function onUpdateInPlace() {
|
||||
kind: props.entry.entity_kind,
|
||||
subtype,
|
||||
payload: entityPayload,
|
||||
schema_version: fetched.schemaVersion,
|
||||
});
|
||||
const localEntityId = props.entry.entity_id;
|
||||
const result = await installEnvelope(
|
||||
@@ -237,6 +249,7 @@ async function onInstallAsNew() {
|
||||
kind: kindGuess,
|
||||
subtype: subtype as string | null,
|
||||
payload: fetched.payload,
|
||||
schema_version: fetched.schemaVersion,
|
||||
});
|
||||
const result = await installEnvelope(
|
||||
{ envelope },
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
toGroups,
|
||||
} from "../../components/shared/matrix-axis";
|
||||
|
||||
type RuleState = "neutral" | "exclude" | "boost" | "reduce";
|
||||
type RuleState = "neutral" | "exclude" | "boost" | "reduce" | "only";
|
||||
|
||||
interface Props {
|
||||
rows: string[]; // source sub-categories
|
||||
@@ -46,6 +46,12 @@ interface Props {
|
||||
* tinted headers as before. */
|
||||
sourceGroups?: Record<string, string[]>;
|
||||
targetGroups?: Record<string, string[]>;
|
||||
/** axis name → kind for each wildcard. An `"accepts"` axis rolls one tag per
|
||||
* pick and is readable as `$var.AXIS`; the matrix marks those bands so a
|
||||
* reader can tell an axis from an ordinary tag group (which the editor shows
|
||||
* via its accepts button, but the matrix otherwise cannot). */
|
||||
sourceGroupKinds?: Record<string, string>;
|
||||
targetGroupKinds?: Record<string, string>;
|
||||
// Read-only recovery view: the source/target wildcard was deleted, so the
|
||||
// configured rules are shown for understanding only. Cells don't open the
|
||||
// rule popover; reattach a live wildcard to edit.
|
||||
@@ -56,6 +62,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
targetName: "",
|
||||
sourceGroups: () => ({}),
|
||||
targetGroups: () => ({}),
|
||||
sourceGroupKinds: () => ({}),
|
||||
targetGroupKinds: () => ({}),
|
||||
readonly: false,
|
||||
});
|
||||
const emit = defineEmits<{ "update:modelValue": [value: ConstraintMatrix] }>();
|
||||
@@ -65,18 +73,21 @@ const MODE_DEFAULT_FACTOR: Record<ConstraintMode, number> = {
|
||||
exclude: 0,
|
||||
boost: 1.5,
|
||||
reduce: 0.5,
|
||||
only: 1,
|
||||
};
|
||||
const MODE_ICON: Record<ConstraintMode, string> = {
|
||||
allow: "·",
|
||||
exclude: "×",
|
||||
boost: "↑",
|
||||
reduce: "↓",
|
||||
only: "✓",
|
||||
};
|
||||
const MODE_LABEL: Record<ConstraintMode, string> = {
|
||||
allow: "Neutral",
|
||||
exclude: "Exclude",
|
||||
boost: "Boost",
|
||||
reduce: "Reduce",
|
||||
only: "Only",
|
||||
};
|
||||
|
||||
/** Storage uses "allow"; popover speaks "neutral". Translate at boundary. */
|
||||
@@ -100,6 +111,19 @@ function cellAt(row: string, col: string): ConstraintCell {
|
||||
return { mode, factor };
|
||||
}
|
||||
|
||||
/** Rows holding an `only` cell. Their neutral cells are shut out at runtime,
|
||||
* so the grid marks them instead of letting them read as "no rule". */
|
||||
const onlyRows = computed(() => {
|
||||
const out = new Set<string>();
|
||||
for (const [row, byCol] of Object.entries(props.modelValue ?? {})) {
|
||||
if (Object.values(byCol ?? {}).some((c) => c?.mode === "only")) out.add(row);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
function isImpliedOut(row: string, col: string): boolean {
|
||||
return onlyRows.value.has(row) && cellAt(row, col).mode === "allow";
|
||||
}
|
||||
|
||||
function cloneMatrix(src: ConstraintMatrix): ConstraintMatrix {
|
||||
const out: ConstraintMatrix = {};
|
||||
for (const [r, byCol] of Object.entries(src ?? {})) {
|
||||
@@ -139,6 +163,26 @@ const orderedRows = computed(() => orderByGroups(props.rows, props.sourceGroups)
|
||||
const orderedCols = computed(() => orderByGroups(props.cols, props.targetGroups));
|
||||
const rowGroups = computed(() => toGroups(orderedRows.value));
|
||||
const colGroups = computed(() => toGroups(orderedCols.value));
|
||||
|
||||
/** Axis names marked `accepts` on each side. */
|
||||
const sourceAcceptsAxes = computed(() => acceptsSet(props.sourceGroupKinds));
|
||||
const targetAcceptsAxes = computed(() => acceptsSet(props.targetGroupKinds));
|
||||
function acceptsSet(kinds: Record<string, string>): Set<string> {
|
||||
return new Set(Object.entries(kinds).filter(([, k]) => k === "accepts").map(([a]) => a));
|
||||
}
|
||||
/** Whether a band's axis is an accepts axis on the given side. */
|
||||
function isAcceptsBand(grp: { axisName: string }, which: "source" | "target"): boolean {
|
||||
const set = which === "source" ? sourceAcceptsAxes.value : targetAcceptsAxes.value;
|
||||
return set.has(grp.axisName);
|
||||
}
|
||||
/** Hover text for the accepts mark. */
|
||||
function acceptsTip(grp: { axisName: string }): string {
|
||||
return `Accepts axis — rolls one tag per pick, readable as $var.${grp.axisName}`;
|
||||
}
|
||||
/** Any accepts axis at all, so the legend can hide its row otherwise. */
|
||||
const hasAcceptsAxis = computed(
|
||||
() => sourceAcceptsAxes.value.size > 0 || targetAcceptsAxes.value.size > 0,
|
||||
);
|
||||
/** True when at least one column / row axis is a NAMED group — then the header
|
||||
* grows a band/chip and the leftover bucket is worth labelling. A fully-flat
|
||||
* matrix keeps the legacy source/target tint and grows no band/chip. */
|
||||
@@ -159,7 +203,8 @@ watch(colGroups, () => void nextTick(measureBands));
|
||||
function cellAriaLabel(row: string, col: string): string {
|
||||
const c = cellAt(row, col);
|
||||
const factor = (c.mode === "boost" || c.mode === "reduce") ? ` ×${fmtFactor(c.factor)}` : "";
|
||||
return `Rule: ${row} → ${col}, current state ${MODE_LABEL[c.mode]}${factor}. Click to edit.`;
|
||||
const implied = isImpliedOut(row, col) ? " (excluded by an Only rule in this row)" : "";
|
||||
return `Rule: ${row} → ${col}, current state ${MODE_LABEL[c.mode]}${factor}${implied}. Click to edit.`;
|
||||
}
|
||||
|
||||
// ── Popover state ───────────────────────────────────────────────
|
||||
@@ -318,9 +363,14 @@ defineExpose({ cellAt });
|
||||
:class="{ 'wp-mx-th-band--bucket': isBucket(grp) }"
|
||||
:colspan="grp.tags.length"
|
||||
:style="{ '--ax': tagHue(grp, 'target', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
:title="isAcceptsBand(grp, 'target') ? acceptsTip(grp) : groupLabel(grp)"
|
||||
>
|
||||
<span class="chip">{{ groupLabel(grp) }}</span>
|
||||
<span class="chip">{{ groupLabel(grp)
|
||||
}}<i
|
||||
v-if="isAcceptsBand(grp, 'target')"
|
||||
class="pi pi-sync wp-mx-axis-mark"
|
||||
aria-hidden="true"
|
||||
/></span>
|
||||
</th>
|
||||
</template>
|
||||
<!-- Flat cols: tag headers sit directly in the single header row. -->
|
||||
@@ -359,8 +409,13 @@ defineExpose({ cellAt });
|
||||
:class="{ 'wp-mx-grp-head--bucket': isBucket(grp) }"
|
||||
:colspan="orderedCols.length + 1"
|
||||
:style="{ '--ax': tagHue(grp, 'source', true) }"
|
||||
:title="groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp) }}</span></th>
|
||||
:title="isAcceptsBand(grp, 'source') ? acceptsTip(grp) : groupLabel(grp)"
|
||||
><span class="chip">{{ groupLabel(grp)
|
||||
}}<i
|
||||
v-if="isAcceptsBand(grp, 'source')"
|
||||
class="pi pi-sync wp-mx-axis-mark"
|
||||
aria-hidden="true"
|
||||
/></span></th>
|
||||
</tr>
|
||||
<tr v-for="tag in grp.tags" :key="tag">
|
||||
<th
|
||||
@@ -372,7 +427,13 @@ defineExpose({ cellAt });
|
||||
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
|
||||
>
|
||||
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
|
||||
<span class="eye">{{ grp.axisName }}</span>
|
||||
<span class="eye">{{ grp.axisName
|
||||
}}<i
|
||||
v-if="isAcceptsBand(grp, 'source')"
|
||||
class="pi pi-sync wp-mx-axis-mark"
|
||||
:title="acceptsTip(grp)"
|
||||
aria-hidden="true"
|
||||
/></span>
|
||||
<span class="v">{{ tag }}</span>
|
||||
</span>
|
||||
<span v-else class="chip">{{ tag }}</span>
|
||||
@@ -382,7 +443,7 @@ defineExpose({ cellAt });
|
||||
class="wp-mx-cell"
|
||||
:class="[
|
||||
`s-${toState(cellAt(tag, c.tag).mode)}`,
|
||||
{ open: isOpenAt(tag, c.tag) },
|
||||
{ open: isOpenAt(tag, c.tag), 'implied-out': isImpliedOut(tag, c.tag) },
|
||||
]"
|
||||
:data-mode="cellAt(tag, c.tag).mode"
|
||||
:data-test="`cell-${tag}-${c.tag}`"
|
||||
@@ -393,7 +454,7 @@ defineExpose({ cellAt });
|
||||
@keydown.enter.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
|
||||
@keydown.space.prevent="openPopover(tag, c.tag, $event as unknown as MouseEvent)"
|
||||
>
|
||||
<span class="glyph">{{ MODE_ICON[cellAt(tag, c.tag).mode] }}</span>
|
||||
<span class="glyph">{{ isImpliedOut(tag, c.tag) ? "×" : MODE_ICON[cellAt(tag, c.tag).mode] }}</span>
|
||||
<span
|
||||
v-if="cellAt(tag, c.tag).mode === 'boost' || cellAt(tag, c.tag).mode === 'reduce'"
|
||||
class="factor"
|
||||
@@ -419,7 +480,7 @@ defineExpose({ cellAt });
|
||||
Reattach the source to edit these rules.
|
||||
</p>
|
||||
|
||||
<MatrixLegend />
|
||||
<MatrixLegend :show-accepts="hasAcceptsAxis" />
|
||||
|
||||
<!-- Popover teleports to body so its hover state stays isolated
|
||||
from the source cell + any table-level clipping. -->
|
||||
@@ -567,6 +628,16 @@ defineExpose({ cellAt });
|
||||
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-1));
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
|
||||
}
|
||||
/* Accepts-axis mark — a small amber pi-sync after the band/eyebrow label. The
|
||||
band keeps its own group hue (so it never collides with an amber group); the
|
||||
mark is a distinct glyph. Meaning lives in the tooltip + the legend row. */
|
||||
.wp-mx-axis-mark {
|
||||
color: var(--wp-axis, #fbbf24);
|
||||
font-size: 9px;
|
||||
margin-left: 5px;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/* The uncategorized bucket reads as a catch-all, not a real axis: same neutral
|
||||
* tint (via --ax) but a dashed edge instead of the solid axis accent. */
|
||||
.wp-mx-th-band--bucket .chip { border-bottom-style: dashed; }
|
||||
@@ -715,6 +786,17 @@ defineExpose({ cellAt });
|
||||
color: var(--wp-warn);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-warn, #f97316) 45%, transparent);
|
||||
}
|
||||
.wp-mx-cell.s-only {
|
||||
background: color-mix(in srgb, var(--wp-info, #3b82f6) 22%, transparent);
|
||||
color: var(--wp-info, #3b82f6);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 45%, transparent);
|
||||
}
|
||||
/* A neutral cell in a row that has an Only rule: shut out at runtime. Faint
|
||||
* danger hue + dashed border so it reads as "implied", not authored. */
|
||||
.wp-mx-cell.s-neutral.implied-out {
|
||||
color: color-mix(in srgb, var(--wp-danger, #ef4444) 60%, var(--wp-text-dim, #595c66));
|
||||
border-color: color-mix(in srgb, var(--wp-danger, #ef4444) 30%, transparent);
|
||||
}
|
||||
.glyph { font-size: 14px; line-height: 1; }
|
||||
.factor { font-size: 11px; font-weight: 700; }
|
||||
|
||||
@@ -748,6 +830,11 @@ defineExpose({ cellAt });
|
||||
border: 1px solid color-mix(in srgb, var(--wp-danger, #ef4444) 18%, transparent);
|
||||
color: color-mix(in srgb, var(--wp-danger, #ef4444) 70%, var(--wp-text-dim));
|
||||
}
|
||||
.wp-mx--readonly .wp-mx-cell.s-only {
|
||||
background: color-mix(in srgb, var(--wp-info, #3b82f6) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--wp-info, #3b82f6) 18%, transparent);
|
||||
color: color-mix(in srgb, var(--wp-info, #3b82f6) 70%, var(--wp-text-dim));
|
||||
}
|
||||
.wp-mx-readonly-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: var(--wp-text-xs);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* `wp-page__*`, `wp-breadcrumb`, `wp-card`, `wp-footer-bar` tokens that
|
||||
* already live in `tokens.css`.
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch, nextTick } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import Button from "./ui/Button.vue";
|
||||
import HistoryPanel from "./HistoryPanel.vue";
|
||||
@@ -118,6 +118,23 @@ function scrollToField(id: string): void {
|
||||
: el.querySelector<HTMLInputElement>("input, textarea, select");
|
||||
input?.focus();
|
||||
}
|
||||
|
||||
/** A failed Save is the moment the editor flips `errors` from empty to
|
||||
* populated (they gate the rollup behind a "tried to save" flag). The Save
|
||||
* button and the error banner are at opposite ends of a long editor, so
|
||||
* clicking Save on a wildcard with 30 options did nothing the user could see —
|
||||
* they clicked it five times before finding the banner. Jump to the first
|
||||
* offending field on that transition, so the failure is where the eyes are.
|
||||
* Length-guarded to the empty→non-empty edge so re-validation on each
|
||||
* keystroke while fixing it doesn't yank the scroll around. */
|
||||
watch(
|
||||
() => props.errors.length,
|
||||
(now, before) => {
|
||||
if (now > 0 && (before ?? 0) === 0) {
|
||||
void nextTick(() => scrollToField(props.errors[0].field));
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* A var chip's accessor is TWO different things: a pick index (`.0`) and a
|
||||
* tag axis (`.SHOES`). They must not share styling — the index is positional
|
||||
* and, being a runtime-only concept, is nothing the editor can validate, so
|
||||
* tinting it the axis accent read to users as "your index is flagged".
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import RefChip from "./RefChip.vue";
|
||||
|
||||
const AXES = [{ axis: "SHOES", tags: ["sneakers"], hueIndex: 0 }];
|
||||
|
||||
describe("RefChip — accessor index vs axis styling", () => {
|
||||
it("renders the pick index in its own NEUTRAL span, not the axis accent", () => {
|
||||
const w = mount(RefChip, {
|
||||
props: { kind: "var", name: "outfit", resolved: true, index: 0 },
|
||||
});
|
||||
expect(w.find(".wp-refchip__index").exists()).toBe(true);
|
||||
expect(w.find(".wp-refchip__index").text()).toBe(".0");
|
||||
// No axis part → no amber accessor span at all.
|
||||
expect(w.find(".wp-refchip__accessor").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("renders index and axis as SEPARATE spans for `$outfit.0.SHOES`", () => {
|
||||
const w = mount(RefChip, {
|
||||
props: {
|
||||
kind: "var", name: "outfit", resolved: true,
|
||||
index: 0, axis: "SHOES", axisKnown: true,
|
||||
},
|
||||
});
|
||||
expect(w.find(".wp-refchip__index").text()).toBe(".0");
|
||||
expect(w.find(".wp-refchip__accessor").text()).toBe(".SHOES");
|
||||
// The index is NOT inside the amber accessor span.
|
||||
expect(w.find(".wp-refchip__accessor").text()).not.toContain(".0");
|
||||
});
|
||||
|
||||
it("marks only the axis unknown — never the index", () => {
|
||||
const w = mount(RefChip, {
|
||||
props: {
|
||||
kind: "var", name: "outfit", resolved: true,
|
||||
index: 0, axis: "BELTS", axisKnown: false,
|
||||
},
|
||||
});
|
||||
expect(w.find(".wp-refchip__accessor--unknown").exists()).toBe(true);
|
||||
// A bare index with no axis is never unknown-styled.
|
||||
const w2 = mount(RefChip, {
|
||||
props: { kind: "var", name: "outfit", resolved: true, index: 0 },
|
||||
});
|
||||
expect(w2.find(".wp-refchip__accessor--unknown").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("still shows the axis alone (no index) in the amber span", () => {
|
||||
const w = mount(RefChip, {
|
||||
props: {
|
||||
kind: "var", name: "outfit", resolved: true, axis: "SHOES", axisKnown: true,
|
||||
},
|
||||
});
|
||||
expect(w.find(".wp-refchip__index").exists()).toBe(false);
|
||||
expect(w.find(".wp-refchip__accessor").text()).toBe(".SHOES");
|
||||
});
|
||||
|
||||
void AXES;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user