Compare commits

...
Author SHA1 Message Date
semantic-release-bot c5d11c03ec chore(release): 1.3.0 [skip ci]
# [1.3.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.2.0...v1.3.0) (2026-04-26)

### Features

* i18n locales (en) + per-node help pages ([#2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/2)) ([5216547](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/52165471c0da315db59d2b2fb72537d7b06d62ef))
2026-04-26 02:52:22 +00:00
DumiFlex 52165471c0 feat: i18n locales (en) + per-node help pages (#2)
Wires two ComfyUI extension points so the nodes are discoverable to
casual users without reading source.

* i18n — `locales/en/` follows ComfyUI's auto-scan layout:
  - `nodeDefs.json` — display names, descriptions, input/output names,
    tooltips for WP_Context, WP_PromptAssembler, WP_Debug.
  - `settings.json` — Reduce motion + Contrast accessibility combos
    keyed as `wildcardPipeline_a11y_*`.
  - `main.json` — settings category labels (Wildcard Pipeline,
    Accessibility).
  English baseline only; structure ready for community PRs to add
  zh/ja/fr/etc. without code changes.
  Docs: https://docs.comfy.org/custom-nodes/i18n

* Help pages — `docs/help/<NodeId>.md` (markdown) gets copied to
  `js/docs/` (the WEB_DIRECTORY) at build time via
  `scripts/copy-help-docs.mjs`. ComfyUI auto-scans
  `${WEB_DIRECTORY}/docs/<NodeId>.md` and surfaces the rendered
  markdown in the node-help panel. Three pages written in casual-user
  voice: what the node does, how to use it, what icons mean — no dev
  jargon (no "last-write-wins", "idempotent", regex, etc.).
  Docs: https://docs.comfy.org/custom-nodes/help_page

* Build pipeline: `pnpm build:extension` now chains
  `vite build && pnpm docs:copy` so a fresh `js/` always carries the
  latest help pages alongside the bundled JS chunks. CI picks this up
  automatically (the same script ran by .github/workflows/ci.yml).
2026-04-26 05:51:57 +03:00
semantic-release-bot 03f61a2b5e chore(release): 1.2.0 [skip ci]
# [1.2.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.1.0...v1.2.0) (2026-04-26)

### Features

* polish suite — MVP frontend + subgraph support + DX + a11y ([79bdb45](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79bdb457cbc98e191766e3fa7c84df2e1106ecc2))
2026-04-26 02:39:48 +00:00
DumiFlex 79bdb457cb feat: polish suite — MVP frontend + subgraph support + DX + a11y
Lands the P4 frontend MVP plus 9 follow-up polish batches (88 commits squashed).

* Pipeline Context, Prompt Assembler, Debug Viewer widgets
* PIPELINE_CONTEXT chain crosses subgraph boundaries
* Conflict badges on SubgraphNodes containing WP nodes
* Empty-state hero, soft-delete + Undo toasts, corrupt-workflow recovery
* Bundled Inter + JetBrains Mono variable fonts, brand identity end-to-end
* Settings panel: Reduce motion + Contrast (a11y) with live updates
* DX: coverage gate, stricter ESLint, pre-commit pnpm size, PR size-delta workflow, contract doc

Closes the polish-brainstorm spec.
2026-04-26 05:39:22 +03:00
88 changed files with 7514 additions and 3353 deletions
+77
View File
@@ -0,0 +1,77 @@
name: Bundle size delta
on:
pull_request:
branches: [main]
paths:
- "src/**"
- "package.json"
- "pnpm-lock.yaml"
- "vite.config.*"
- "scripts/check-bundle-size.mjs"
- ".github/workflows/bundle-size-pr.yml"
# Compute the gzip-bundle-size delta between the PR head and the base branch
# and post it as a single sticky comment on the pull request. Lets reviewers
# spot regressions ("did this 5 KB ship intentionally?") without digging
# through CI logs.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
size-delta:
runs-on: ubuntu-latest
permissions:
# Setting permissions: explicitly REMOVES default scopes — must list
# contents: read so actions/checkout can fetch the repo, otherwise
# the runner gets "Repository not found" with a 404.
contents: read
pull-requests: write
steps:
- name: Checkout PR head
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
path: head
- name: Checkout base
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
- uses: pnpm/action-setup@v4
with:
# Explicit version because both checkouts land in subdirs
# (head/, base/) so there's no package.json at the runner's CWD
# root for the action to autodetect "packageManager" from.
version: 9
- uses: actions/setup-node@v5
with:
node-version: "22"
cache: pnpm
cache-dependency-path: head/pnpm-lock.yaml
- name: Build + measure HEAD
working-directory: head
run: |
pnpm install --frozen-lockfile
pnpm build:extension
node scripts/check-bundle-size.mjs --json ../sizes/head.json
- name: Build + measure BASE
working-directory: base
run: |
pnpm install --frozen-lockfile
pnpm build:extension
# The size script resolves ../js relative to its own file URL,
# so we overwrite base/scripts/check-bundle-size.mjs with the head
# version to guarantee the JSON manifest format matches what
# bundle-size-diff expects, while keeping the relative-path read.
mkdir -p scripts
cp ../head/scripts/check-bundle-size.mjs scripts/check-bundle-size.mjs
node scripts/check-bundle-size.mjs --json ../sizes/base.json
continue-on-error: true
- name: Diff manifests
run: node head/scripts/bundle-size-diff.mjs sizes/base.json sizes/head.json > report.md
- name: Upsert PR comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: bundle-size
path: report.md
+8 -1
View File
@@ -39,7 +39,14 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm test
- run: pnpm test:coverage
- run: pnpm build:extension
- run: pnpm size
- run: pnpm build:manager
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
retention-days: 14
+5 -1
View File
@@ -16,6 +16,7 @@ pnpm-debug.log
js/
web_dist/
coverage/
.vitest-cache/
# Editor / OS
.vscode/
@@ -25,7 +26,10 @@ Thumbs.db
# Claude / Superpowers workspace artifacts
.superpowers/
.claude/worktrees/
.claude/
docs/superpowers/
# Backups dropped by /caveman:compress next to compressed files
*.original.md
# Semantic release
.releaserc.local.js
+6
View File
@@ -3,6 +3,12 @@ pnpm lint || exit 1
pnpm typecheck || exit 1
pnpm test || exit 1
# Bundle size budget — build the extension chunk and run the size check so
# regressions get caught locally before the CI flags them on PR. Skips when
# js/ output already matches the source by mtime to keep repeat commits fast.
pnpm build:extension || exit 1
pnpm size || exit 1
# Python checks — only if ruff/pytest reachable on PATH
if command -v ruff >/dev/null 2>&1; then
ruff check . || exit 1
+14
View File
@@ -1,3 +1,17 @@
# [1.3.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.2.0...v1.3.0) (2026-04-26)
### Features
* i18n locales (en) + per-node help pages ([#2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/2)) ([5216547](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/52165471c0da315db59d2b2fb72537d7b06d62ef))
# [1.2.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.1.0...v1.2.0) (2026-04-26)
### Features
* polish suite — MVP frontend + subgraph support + DX + a11y ([79bdb45](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79bdb457cbc98e191766e3fa7c84df2e1106ecc2))
# [1.1.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.0.0...v1.1.0) (2026-04-24)
+35 -20
View File
@@ -1,10 +1,10 @@
# CLAUDE.md — project conventions
This file teaches future Claude (and human) contributors the load-bearing conventions of this codebase. Keep it short and honest.
File teach future Claude (and human) contributors load-bearing conventions of codebase. Keep short, honest.
## Stack
- Python ≥ 3.10 (target 3.10/3.11/3.12 in CI; embedded runtime is 3.12.10).
- Python ≥ 3.10 (target 3.10/3.11/3.12 in CI; embedded runtime 3.12.10).
- ComfyUI V3 API (`comfy_api.latest`) — no V1 `NODE_CLASS_MAPPINGS`.
- Vue 3 + Vite 5 + pnpm. Strict TypeScript.
- Vitest for frontend, pytest for Python.
@@ -12,11 +12,12 @@ This file teaches future Claude (and human) contributors the load-bearing conven
## Directory contract
- `engine/` — pure Python, **zero ComfyUI imports**. Testable with vanilla pytest.
- `nodes/` — ComfyUI V3 nodes. All ComfyUI imports wrapped in `try/except ImportError` so the package remains importable outside ComfyUI for tests.
- `src/` — Vue/TS frontend. Two Vite targets: `extension` (critical path, budget-gated) and `manager` (post-MVP SPA).
- `tests/` — Python tests. Uses `conftest.py` sys.path shim.
- `scripts/` — Node helpers (bundle-size gate, release helpers).
- `docs/superpowers/{specs,plans}/` — design docs and implementation plans.
- `wp_nodes/` — ComfyUI V3 nodes. Named `wp_nodes` (not `nodes`) because ComfyUI repo-level `nodes.py` shadow any top-level `nodes/` package on `sys.path`.
- `src/` — Vue/TS frontend. Two Vite targets: `extension` (critical path, budget-gated) + `manager` (post-MVP SPA).
- `tests/` — Python tests. Use `conftest.py` sys.path shim.
- `scripts/` — Node helpers (bundle-size gate, size-delta diff, release helpers).
- `docs/widget-context-contract.md` — single canonical engine ↔ widget JSON shape doc. Update both Python (`engine/modules.py`) + TS (`src/widgets/_shared.ts`) sides in one PR when shape change.
- `docs/superpowers/{specs,plans}/` — gitignored per-contributor design docs + impl plans.
## Commands
@@ -24,31 +25,45 @@ This file teaches future Claude (and human) contributors the load-bearing conven
pnpm build:extension # produces js/main.js
pnpm build:manager # produces web_dist/
pnpm test # Vitest
pnpm test:coverage # Vitest + v8 coverage (with thresholds)
pnpm typecheck # vue-tsc --noEmit
pnpm lint # ESLint flat config
pnpm size # bundle-size gate
pnpm size # bundle-size gate (--json <path> for manifest)
pytest # Python unit tests
ruff check . # Python lint
```
## Load-bearing conventions
- **Engine isolation** — never import anything from `nodes/`, `comfy_api`, or `torch` in `engine/`. Engine is dict-free of ComfyUI globals. Breaking this breaks tests.
- **Internal keys** — context keys starting with `__` are engine-internal; strip them at socket boundaries (see `nodes/types.py:strip_internals`).
- **Module IDs** — 8-hex-char short UUIDs. Matches the wildcard `@{uuid}` reference syntax planned for the next spec.
- **Engine isolation** — never import from `wp_nodes/`, `comfy_api`, or `torch` in `engine/`. Engine dict-free of ComfyUI globals. Break this break tests.
- **Internal keys** — context keys starting with `__` engine-internal; strip at socket boundaries (see `wp_nodes/types.py:strip_internals`).
- **Module IDs** — 8-hex-char short UUIDs. Match wildcard `@{uuid}` reference syntax planned for next spec.
- **V3 node IDs** — prefix `WP_`, category `wildcard-pipeline`.
- **Custom widget types** — `WP_CONTEXT_MODULES`, `WP_DEBUG_VIEWER`. Registered via `app.registerExtension({ getCustomWidgets })`. The Assembler helper widget is injected via `beforeRegisterNodeDef → onNodeCreated`, not `getCustomWidgets`, because it sits alongside a native STRING template widget.
- **Conflict scanner** — advisory only; never block execution. Runtime is last-write-wins.
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Raising the budget requires explicit PR discussion.
- **Caching** — `WP_Context` and `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
- **Conventional Commits** — enforced by commitlint. semantic-release reads the log to cut versions.
- **Custom widget types** — `WP_CONTEXT_MODULES`, `WP_DEBUG_VIEWER`. Registered via `app.registerExtension({ getCustomWidgets })`. Assembler helper widget injected via `beforeRegisterNodeDef → onNodeCreated`, not `getCustomWidgets`, because sit alongside native STRING template widget.
- **Toast singleton** — one Vue app mounted to body-level div renders all toasts pushed via `src/components/shared/toast-store.ts`. Every Context node import same store so toasts surface from anywhere. Position mirror ComfyUI native PrimeVue toast anchor (rect-relative to `.graph-canvas-container`).
- **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. Three types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning).
- **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.
- **Conventional Commits** — enforced by commitlint. semantic-release read log to cut versions.
## Frontend overview
- **Entry** `src/main.ts` → `js/main.js` (~1.6 KB gzip). Top-level await preload widget chunks so `getCustomWidgets` factories run sync (Promise return → `{widget: undefined}` and widget never mount). Register extension + lazy-import widget code only when ComfyUI hand matching node.
- **Lazy chunks** `src/widgets/{context,debug,assembler}.ts` — one mount glue per widget. Each `import()` become own asset chunk, plus sibling SFC chunk (`ContextWidget`, `DebugViewer`, `AssemblerHelper`).
- **Shared** `src/widgets/_shared.ts` expose `createDomWidgetHost`, JSON helpers (`parseWidgetJson`, `parseWidgetJsonWithRecovery` for corrupt-workflow recovery path), shared types. Largest shared chunk (`_shared`, ~33 KB gzip) is Vue runtime + plugin-vue helper, pulled in by every widget on first use.
- **Graph + conflicts** `src/extension/{graph,conflicts,subgraph-badge,reactive,graph-events}.ts` — pure logic, no DOM. Imported by widgets, easy to unit-test isolated.
- **Reactivity** `extension/reactive.ts:reactiveFromGraph` — wires `onConnectionsChange` chain + 400ms polling fallback + `afterConfigureGraph` re-sync so widgets recompute on graph edits + workflow loads without flashing stale state.
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). Use `import("./widgets/<name>")` so stay out of entry chunk + size gate keep holding.
## Anti-patterns (inherited from reference project `ComfyUI-Wildcard-Pipeline`)
- Never use V1 `NODE_CLASS_MAPPINGS` / `EXTENSION_WEB_DIRS`.
- Never `console.log` for user warnings — use the ComfyUI toast API.
- Never `console.log` for user warnings — use ComfyUI toast API.
- Never use `getInputNode()` / `findInputSlot()` — walk via `node.inputs[].link → app.graph.links[linkId] → graph.getNodeById(link.origin_id)`.
- Never use `requestAnimationFrame` in `onConnectionsChange`; the link is already established when the callback fires.
- Never register an SPA catch-all route before API routes.
- Never use `requestAnimationFrame` in `onConnectionsChange`; link already established when callback fires.
- Never register SPA catch-all route before API routes.
- Never suppress types with `as any`, `@ts-ignore`, `@ts-expect-error`.
- Never `cssInjectedByJsPlugin` on the extension bundle — inflates critical-path load.
- `cssInjectedByJsPlugin` required, not optional. Vite library mode emit CSS files but never inject `<link>` tags, and ComfyUI load our `main.js` as bare ES module so `<link rel="modulepreload">`-style auto-loading does not apply. Per-chunk runtime injection (`relativeCSSInjection: true`) keep entry chunk CSS-free — styles only get injected when owning lazy chunk loads. This what every Vue-based ComfyUI extension in wild does (see `ComfyUI-Easy-Use/web_version/v2/easyuse.js`).
+7 -1
View File
@@ -1,4 +1,10 @@
# ComfyUI-WildcardPipeline
<p align="center">
<img src="public/images/favicon.svg" width="120" alt="wildcard-pipeline logo" />
</p>
<h1 align="center">wildcard-pipeline</h1>
<p align="center"><em>Composable prompt context for ComfyUI.</em></p>
Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines.
+31 -16
View File
@@ -1,23 +1,38 @@
"""ComfyUI-WildcardPipeline — modular procedural prompt generation.
"""ComfyUI-WildcardPipeline — modular procedural prompt generation."""
MVP scaffold. Real node registration lands in Plan P3. This stub exists
so ComfyUI's custom-node loader doesn't error on directory load.
"""
import os
import sys
from __future__ import annotations
# Tells ComfyUI to serve files from this folder under
# /extensions/<package-name>/ and auto-load any `*.js` it finds.
WEB_DIRECTORY = "./js"
try:
from comfy_api.latest import ComfyExtension # pyright: ignore[reportMissingImports]
# Expose engine/ and wp_nodes/ as top-level packages so internal absolute imports
# (``from engine.X import ...``, ``from wp_nodes.X import ...``) resolve the same
# whether ComfyUI's custom-node loader or pytest invokes us. ComfyUI sets up
# ``submodule_search_locations`` on this package but does not add the package
# dir to ``sys.path``, so absolute imports to ``engine``/``wp_nodes`` would fail
# without this shim. ``wp_nodes`` (not ``nodes``) avoids collision with
# ComfyUI's top-level ``nodes.py`` module.
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
if _PKG_DIR not in sys.path:
sys.path.insert(0, _PKG_DIR)
class WildcardPipelineExtension(ComfyExtension):
"""Empty scaffold extension — nodes added in P3."""
# pyright: reportMissingImports=false
# ruff: noqa: E402 — imports must follow the sys.path shim above
from comfy_api.latest import ComfyExtension
async def get_node_list(self):
return []
from wp_nodes.assembler_node import WPPromptAssembler
from wp_nodes.context_node import WPContext
from wp_nodes.debug_node import WPDebug
async def comfy_entrypoint() -> WildcardPipelineExtension:
return WildcardPipelineExtension()
except ImportError:
# Running outside ComfyUI (e.g. pytest) — nothing to register.
pass
class WildcardPipelineExtension(ComfyExtension):
"""Registers all WP nodes with ComfyUI."""
async def get_node_list(self):
return [WPContext, WPPromptAssembler, WPDebug]
async def comfy_entrypoint() -> WildcardPipelineExtension:
return WildcardPipelineExtension()
+12 -7
View File
@@ -1,6 +1,9 @@
"""Root conftest — lets pytest import engine/nodes without installing the package.
"""Root conftest — lets pytest import engine/wp_nodes without installing the package.
Also collects-ignores ComfyUI-dependent directories when comfy_api is absent.
If ``comfy_api`` is not importable (running outside ComfyUI), inject the
``tests/stubs/comfy_api_stub`` module into ``sys.modules`` as both
``comfy_api`` and ``comfy_api.latest`` so production code that does
``from comfy_api.latest import ComfyExtension, io`` succeeds in tests.
"""
from __future__ import annotations
@@ -13,9 +16,11 @@ ROOT = Path(__file__).parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
collect_ignore: list[str] = []
# Tests for node wrappers require comfy_api; skip them when it's unavailable
# (e.g. running pytest outside ComfyUI's Python environment).
if importlib.util.find_spec("comfy_api") is None:
collect_ignore.append("tests/test_nodes.py")
from tests.stubs import comfy_api_stub as _stub
sys.modules.setdefault("comfy_api", _stub)
sys.modules.setdefault("comfy_api.latest", _stub)
sys.modules.setdefault("comfy_api.latest._io", _stub.io._io)
collect_ignore: list[str] = []
+41
View File
@@ -0,0 +1,41 @@
# WP Context
Hold a list of named values you can use later in your prompt. Each value gets a name (like `style` or `subject`) and the value you want it to be (like `watercolor` or `a cat`). Then a **WP Prompt Assembler** can plug those values into your prompt wherever you write `$style` or `$subject`.
## Adding values
A **module** is one small group of values. Click the big purple **Add your first module** button, or **+ add module** if you already have some.
Each module appears as a small card. Right-click the card and pick **Edit** to:
- Give it a name (just a label so you remember what's inside, like "Character details")
- Add as many `name = value` lines as you need
- Paste a bunch of lines at once if you have them ready
Press **Enter** on the value field to add another row quickly. **Ctrl+Enter** saves and closes.
## What the icons on a card mean
- **☰** Drag handle — drag a card to reorder, or drag onto another Context node to move it there
- **▾ / ▸** Show or hide the values preview
- **Toggle (checkbox)** — turn a module off without deleting it. Useful for trying variations.
- **✕** Remove the module. Don't worry about misclicks — an **Undo** button appears for a few seconds.
## Stacking multiple Contexts
Connect another **WP Context** node into the **upstream** input. The upstream Context's values flow through. If both define the same name, this Context wins (it's the closer one to your prompt).
This lets you build a library: "character" Context → "scene" Context → "style" Context → Prompt Assembler.
## Conflict dots
A card may show a small colored dot:
- 🔵 **Blue (info)** — You're overriding something an upstream Context already provides. Usually that's exactly what you want.
- 🟡 **Amber (warning)** — Two of your modules in this Context use the same name. One quietly overwrites the other; check that's intentional.
Hover the dot to see exactly which variable.
## Output
Wire **context** out into a **WP Prompt Assembler** (or another **WP Context** to chain).
+22
View File
@@ -0,0 +1,22 @@
# WP Debug
Drop this anywhere in your pipeline to see exactly what variables are flowing through that point. Like a window into the chain.
## How to use
1. Connect the **context** input to any WP node (Context or Prompt Assembler).
2. Run the workflow once.
3. The viewer below shows every variable name and its value at that point in the chain.
That's it. There's nothing to wire on the output side — Debug is read-only.
## When it's useful
- **"Why is `$style` showing as empty?"** Drop a Debug after each Context to see where the value gets lost or overridden.
- **"Did my new module override the old one correctly?"** The trace shows which module set each variable.
- **"Are upstream variables coming through the chain?"** Drop a Debug right before your Prompt Assembler to confirm.
## Tips
- The viewer fills the node body. Drag the bottom-right corner of the node to make it bigger if you have lots of variables.
- Debug nodes always re-run, even when nothing visually changed. Helpful when you're cycling seeds and want to see the values shift.
+47
View File
@@ -0,0 +1,47 @@
# WP Prompt Assembler
Write your prompt as a fill-in-the-blank template. Anywhere you put `$something`, the Assembler swaps in the matching value from the upstream Context.
## Writing a template
Type your prompt in the **template** box. Use `$name` to reference any variable from the connected Context.
**Example template:**
```
a portrait of $subject, $style, $mood
```
If the upstream Context provides:
- `subject = a cat`
- `style = watercolor`
- `mood = sleepy`
…the final prompt becomes:
```
a portrait of a cat, watercolor, sleepy
```
## The helper below the template
**Variables** lists every value available from the upstream Context. Click any chip to insert `$name` into your template at the cursor — saves typing and avoids typos.
**Preview** shows what the final prompt will look like, color-coded:
- **Filled in (purple highlight)** — the variable is resolved, you see its actual value.
- **Outlined chip** — the name will resolve, just briefly during loading.
- **Amber wavy underline** — missing! Nothing upstream provides this name, so it'll render empty.
If your template is empty, the preview shows a placeholder instead.
## Special characters
- Use `$$` if you want a literal `$` in your prompt (e.g. `$$10` to mean "ten dollars" rather than a variable named `10`).
- Variable names can contain letters, numbers, and underscores only. `$my_var` works; `$my-var` doesn't.
## Output
**prompt** is the final assembled string. Connect it to a **CLIP Text Encode** node (or any text input).
## Pre-flight warnings
When you press the **Run** button, the extension scans your template for `$variables` that aren't provided anywhere upstream. If it finds any, you'll see one toast at the top of the screen listing them. The run still happens — missing variables just become empty strings — but it's a heads-up in case you have a typo.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,733 +0,0 @@
# ComfyUI-WildcardPipeline — MVP Walking Skeleton (Design)
- **Date:** 2026-04-24
- **Author:** DumiFlex
- **Status:** Approved — ready for implementation planning
- **Scope class:** MVP (follow-up sub-specs listed in §9)
- **Reference project (not to be copied wholesale):** `E:\AI\ComfyUI\custom_nodes\ComfyUI-Wildcard-Pipeline`
## 1. Vision & MVP Goals
**Project vision.** Modular, procedural prompt generation for ComfyUI — *not* chaotic random wildcards. Users compose reusable logic (wildcards, constraints, derivations, combines, fixed values) inside a `WP_Context` node, chain an unlimited number of context nodes for layered generation, and feed the resulting structured context to a `WP_PromptAssembler` that fills `$var` placeholders in a user-authored template. A separate SPA (post-MVP) makes modules and whole pipelines reusable/shareable. Workflows always remain portable because module data is serialized inline into the workflow JSON.
**MVP goals (this spec).** Prove the walking skeleton end-to-end:
1. Render a Vue-powered custom DOM widget inside a ComfyUI V3 node.
2. Manage an ordered list of modules of a single type — **`fixed_values`** — via that widget.
3. Serialize module list into `node.widgets_values` so workflow save/load round-trips.
4. Custom `PIPELINE_CONTEXT` socket type carries context across an unlimited chain of `WP_Context` nodes.
5. Downstream-overwrite semantics: runtime silently last-write-wins; UI warns via conflict scanner.
6. `WP_PromptAssembler` consumes context, resolves `$var` and `$$` escape.
7. `WP_Debug` node renders context as a readable Vue viewer widget.
8. Engine stays pure Python (`engine/` with zero ComfyUI imports), typed via dataclasses + `TypedDict`.
9. Tests: engine unit tests + conflict scanner tests, no ComfyUI runtime required.
10. Extension frontend bundle cold-loads fast (hard budget, see §6).
**MVP non-goals (deferred to named follow-up specs in §9).** Wildcards, constraints, derivations, combines, `WP_ContextInject` node, sqlite library, SPA manager, pipeline presets, `WP_DEBUG` env socket, instance-override UI, save-to-library context menu, module version history.
## 2. Architecture Overview
Three-layer split, same isolation principle as the reference project.
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (Vue 3 + Vite + PrimeVue + Pinia) │
│ src/main.ts — ComfyUI extension entry │
│ src/extension/widgets.ts — Vue-in-DOMWidget factory │
│ src/extension/graph.ts — upstream/downstream walkers │
│ src/extension/conflicts.ts — conflict scanner │
│ src/components/context/* — Context widget + module list │
│ src/components/assembler/* — Assembler helper widget │
│ src/components/debug/* — Debug viewer widget │
└─────────────────────────────────────────────────────────────┘
↕ serialize JSON string
┌─────────────────────────────────────────────────────────────┐
│ Nodes (ComfyUI V3 — comfy_api.latest) │
│ nodes/context_node.py — WP_Context │
│ nodes/assembler_node.py — WP_PromptAssembler │
│ nodes/debug_node.py — WP_Debug │
│ nodes/types.py — PipelineContext custom type │
└─────────────────────────────────────────────────────────────┘
↕ dataclass from_dict / to_dict
┌─────────────────────────────────────────────────────────────┐
│ Engine (pure Python, zero ComfyUI imports) │
│ engine/context.py — Context TypedDict + helpers │
│ engine/modules.py — Module dataclasses + union │
│ engine/pipeline.py — PipelineEngine.run() │
│ engine/handlers.py — fixed_values handler │
│ engine/template.py — resolve_variables ($var,$$) │
└─────────────────────────────────────────────────────────────┘
```
**Boundary contracts.**
- Frontend ⇄ Nodes: only via ComfyUI widget serialization (a single JSON string per node stored in `widgets_values`).
- Nodes ⇄ Engine: nodes deserialize the widget JSON into engine dataclasses, call `PipelineEngine.run()`, then wrap the returned context into a `ContextPayload` and emit it on the `PIPELINE_CONTEXT` socket.
- Engine ⇄ anything else: pure. Imports stdlib only. Testable with plain `pytest`, no ComfyUI required.
**Execution dataflow per workflow run.**
```
upstream WP_Context output ──▶ PIPELINE_CONTEXT socket ──┐
▼
WP_Context.execute()
├── inherit upstream ctx
├── deserialize modules widget
├── engine.run(modules, ctx, seed)
└── emit ContextPayload(ctx, debug)
│
▼
WP_PromptAssembler.execute()
└── resolve $vars in template
│
▼
STRING output
```
No cross-cutting side channels. Every boundary is a single serialized payload.
## 3. Data Model
### 3.1 Context (engine-side)
Flat dict of string-keyed vars plus `__`-prefixed internal keys. A `TypedDict` documents the reserved internals; user vars remain open.
```python
# engine/context.py
from typing import TypedDict, Any
class ContextInternals(TypedDict, total=False):
__wp_node_seed__: int # current node's seed
__wp_internal_flags__: dict[str, bool] # varname → internal?
__wp_trace__: list[dict[str, Any]] # per-module execution log
Context = dict[str, Any] # union of user vars + ContextInternals keys
```
### 3.2 Modules (engine-side)
Dataclass per type + discriminated union via `type: Literal[...]`. MVP ships one type plus a shared metadata block.
```python
# engine/modules.py
from dataclasses import dataclass, field
from typing import Literal, Union
@dataclass
class ModuleMeta:
name: str = "" # free-form display label, duplicates allowed
description: str = ""
category: str = "" # user-chosen grouping
tags: list[str] = field(default_factory=list)
@dataclass
class FixedValueEntry:
variable_name: str
value: str
@dataclass
class FixedValueModule:
id: str # 8-hex-char short UUID (~4.3B space)
type: Literal["fixed_values"] = "fixed_values"
enabled: bool = True
meta: ModuleMeta = field(default_factory=ModuleMeta)
entries: list[FixedValueEntry] = field(default_factory=list)
Module = Union[FixedValueModule] # widens as new types land
def module_from_dict(d: dict) -> Module: ... # dispatch on d["type"]
def module_to_dict(m: Module) -> dict: ... # stdlib asdict
```
Short UUID format matches the reference project's `@{uuid}` nested-wildcard reference syntax — forward-compatible when the Wildcards spec lands.
### 3.3 Widget payload (frontend ↔ node)
One DOM widget per `WP_Context` node holds the ordered module list as a JSON string in `widgets_values`.
```jsonc
// widget value (JSON string)
{
"version": 1,
"modules": [
{
"id": "…",
"type": "fixed_values",
"enabled": true,
"meta": { "name": "style defaults", "tags": ["character"] },
"entries": [{ "variable_name": "style", "value": "photorealistic" }]
}
]
}
```
`version` enables forward-compatible migrations when new module types land.
### 3.4 `PIPELINE_CONTEXT` socket payload
```python
# nodes/types.py
from dataclasses import dataclass, field
from typing import Any
from comfy_api.latest._io import comfytype, ComfyTypeIO
@dataclass(frozen=True)
class ContextPayload:
context: dict[str, Any] = field(default_factory=dict) # user vars only
debug: dict[str, Any] = field(default_factory=dict) # trace + meta
@comfytype(io_type="PIPELINE_CONTEXT")
class PipelineContext(ComfyTypeIO):
Type = ContextPayload
```
Internals (`__…`) are stripped at the socket boundary so downstream nodes can't accidentally depend on private state.
### 3.5 Seed model
`WP_Context` has a standard `io.Int` seed widget (with `control_after_generate`). Engine stores it as `ctx["__wp_node_seed__"]` and passes through. `fixed_values` doesn't consume it. The Wildcards follow-up spec will hash `(node_seed, capture_name)` via SHA-256 as the reference project does.
### 3.6 Debug JSON shape (MVP)
```jsonc
{
"node_seed": 12345,
"modules": [
{
"id": "…",
"type": "fixed_values",
"enabled": true,
"writes": [
{ "variable": "style", "value": "photorealistic", "source": "fixed_values" }
]
}
],
"conflicts": []
}
```
## 4. Engine Internals
Pure Python, stdlib only. One file per concern.
### 4.1 `engine/template.py`
```python
def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
"""Replace $var with ctx[var]. $$ escapes literal $. __-keys skipped."""
```
Rules: `$var` → `str(ctx[var])` when present, otherwise left as-is; `$$` → literal `$`; `$__internal` never substituted.
### 4.2 `engine/handlers.py`
```python
ModuleHandler = Callable[[Module, Context, random.Random], Context]
def handle_fixed_values(module: FixedValueModule, ctx: Context, rng: random.Random) -> Context:
for entry in module.entries:
name = entry.variable_name.lstrip("$")
if not name:
continue
ctx[name] = entry.value
return ctx
```
`fixed_values` ignores `rng`. Signature keeps parity with future seed-consuming handlers (wildcard, derivation).
### 4.3 `engine/pipeline.py`
```python
class PipelineEngine:
HANDLERS: dict[str, ModuleHandler] = {
"fixed_values": handle_fixed_values,
}
def run(self, modules: list[Module], ctx: Context | None = None,
seed: int = 0) -> Context:
ctx = ctx if ctx is not None else {}
ctx["__wp_node_seed__"] = seed
ctx.setdefault("__wp_trace__", [])
rng = random.Random(seed)
for index, module in enumerate(modules):
if not module.enabled:
continue
handler = self.HANDLERS.get(module.type)
if handler is None:
logger.warning("Unknown module type '%s' at index %s", module.type, index)
continue
before = dict(ctx)
ctx = handler(module, ctx, rng)
ctx["__wp_trace__"].append(self._trace_entry(module, before, ctx))
return ctx
```
### 4.4 Tracing
`_trace_entry(module, before, after)` yields one dict per handled module:
```python
{
"id": module.id,
"type": module.type,
"enabled": module.enabled,
"writes": (
[
{"variable": k, "value": after[k], "source": module.type}
for k in after.keys() - before.keys()
if not k.startswith("__")
]
+ [
{"variable": k, "value": after[k], "source": module.type, "overwrite": True}
for k in after.keys() & before.keys()
if not k.startswith("__") and after[k] != before[k]
]
),
}
```
### 4.5 Chain behaviour
`WP_Context.execute(upstream, seed, modules_json)`:
1. `ctx = dict(upstream.context) if upstream else {}` — seed from upstream user vars.
2. `PipelineEngine().run(deserialize(modules_json), ctx, seed=seed)` — mutate with this node's modules.
3. Emit `ContextPayload(context=strip_internals(ctx), debug={…})`.
Runtime silently last-write-wins. Conflict scanner (§7) warns in UI pre-execution.
### 4.6 Isolation invariants
- `engine/` imports stdlib only — no `torch`, no ComfyUI, no frontend.
- No `print`; only `logging.getLogger(__name__)`.
- No global mutable state. `PipelineEngine.HANDLERS` is a class attribute; extending requires subclass or a future registration API.
- Pure functions where possible; handlers mutate context in place (performance over immutability — chains get long).
## 5. ComfyUI V3 Nodes
All three under category `wildcard-pipeline`; node IDs prefixed `WP_`.
### 5.1 Registration
```python
# __init__.py
from comfy_api.latest import ComfyExtension, io
from .nodes.context_node import WPContext
from .nodes.assembler_node import WPPromptAssembler
from .nodes.debug_node import WPDebug
class WildcardPipelineExtension(ComfyExtension):
async def get_node_list(self):
return [WPContext, WPPromptAssembler, WPDebug]
async def comfy_entrypoint():
return WildcardPipelineExtension()
```
All ComfyUI imports are wrapped in `try/except ImportError` so `pytest` can import the package outside ComfyUI (ref. project pattern).
### 5.2 `WP_Context`
```python
ContextModulesInput = io.Custom("WP_CONTEXT_MODULES") # widget-only custom type
class WPContext(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_Context",
display_name="WP Context",
category="wildcard-pipeline",
inputs=[
PipelineContext.Input("upstream", optional=True),
io.Int.Input("seed", default=0, min=0,
max=0xFFFFFFFFFFFFFFFF, control_after_generate=True),
ContextModulesInput.Input("modules", socketless=True),
],
outputs=[PipelineContext.Output("context")],
not_idempotent=True,
)
@classmethod
def execute(cls, seed, modules, upstream=None):
# `modules` arrives as the serialized JSON string produced by the DOM widget
upstream_ctx = upstream.context if upstream else {}
upstream_dbg = upstream.debug if upstream else {}
module_list = deserialize_modules(modules)
ctx = dict(upstream_ctx)
ctx = PipelineEngine().run(module_list, ctx, seed=seed)
payload = ContextPayload(
context=strip_internals(ctx),
debug={"upstream": upstream_dbg, "node_seed": seed,
"trace": ctx.get("__wp_trace__", [])},
)
return io.NodeOutput(payload)
```
- `upstream` is optional — root context nodes work without an input connection.
- `modules` is a **custom-typed, `socketless=True` input** named `WP_CONTEXT_MODULES`. The frontend registers a widget factory under that exact type key via `app.registerExtension({ getCustomWidgets() })`. The factory creates the DOM element, mounts Vue, and manages JSON serialization into `widgets_values` via `node.addDOMWidget(inputName, "custom", container, { serialize: true, getValue, setValue })`.
- `not_idempotent=True` prevents ComfyUI caching so seed cycling always re-executes.
- Internals stripped at the socket boundary (§3.4).
### 5.3 `WP_PromptAssembler`
```python
class WPPromptAssembler(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_PromptAssembler",
display_name="WP Prompt Assembler",
category="wildcard-pipeline",
inputs=[
PipelineContext.Input("context"),
io.String.Input("template", multiline=True,
default="A $style portrait of $subject"),
],
outputs=[io.String.Output("prompt")],
)
@classmethod
def execute(cls, context, template):
return io.NodeOutput(resolve_variables(template, context.context))
```
**DOM helper widget.** Template stays a native multiline STRING widget — *explicitly kept* so users can right-click → *Convert to Input* and wire a string-producing node into it. A separate read-only Vue helper widget is injected *after* the template widget via the `beforeRegisterNodeDef` lifecycle hook (matches reference project's assembler pattern — `getCustomWidgets` can't add widgets to existing typed inputs, but `onNodeCreated` can call `node.addDOMWidget(...)` directly after the node's default widgets are built):
```ts
// simplified — full code in src/extension/widgets.ts
app.registerExtension({
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "WP_PromptAssembler") return;
const origOnCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
origOnCreated?.apply(this, args);
import("./widgets/assembler").then(m => m.mountHelper(this));
};
},
});
```
The helper widget contains:
- **Variable chips row.** Pills for each variable captured by upstream chain (collected via `collectUpstreamVariables`). Click → inserts `$var` into the textarea at cursor position. Hover shows origin node name. When template is converted-to-input (no textarea in DOM), chip-insert handler feature-detects and disables; chips remain visible in read-only mode.
- **Preview pane.** Static analysis only: renders template text with each `$var` tagged ✅ *defined* if found in upstream captured set, ⚠️ *unresolved* otherwise. No actual value substitution in MVP (that requires running the engine client-side or an API call — deferred to Wildcards spec via a dedicated preview endpoint).
Helper widget calls `addDOMWidget(..., { serialize: false })` — template value is owned exclusively by the native template widget, preventing double-source-of-truth.
### 5.4 `WP_Debug`
```python
DebugViewerInput = io.Custom("WP_DEBUG_VIEWER") # widget-only custom type
class WPDebug(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_Debug",
display_name="WP Debug",
category="wildcard-pipeline",
inputs=[
PipelineContext.Input("context"),
DebugViewerInput.Input("viewer", socketless=True),
],
outputs=[],
is_output_node=True,
not_idempotent=True,
)
@classmethod
def execute(cls, context, viewer):
import json
dumped = json.dumps(
{"context": context.context, "debug": context.debug},
default=str, indent=2,
)
return io.NodeOutput(ui={"wp_debug_snapshot": [dumped]})
```
**DOM viewer widget (`WP_DEBUG_VIEWER`).** Registered via `getCustomWidgets` keyed by the custom input type name. Two tabs:
1. **Context tab.** Tree / key-value viewer of user vars. Collapsible, type badges (string/number/bool/object). Copy-value button per row.
2. **Trace tab.** Ordered list of module executions with per-module writes (variable, value, overwrite flag), node seed. Filter/search bar. Colour-coded by source module type.
Data source: the `ui.wp_debug_snapshot` payload. Widget subscribes to the ComfyUI `executed` event bus, filters by its own node id, parses the snapshot, re-renders. Serialises nothing into `widgets_values` — debug output is transient per run (widget uses `addDOMWidget(..., { serialize: false })`).
## 6. DOM Widgets, Vue Mount Pattern, Bundle Strategy
Frontend cold-load is a first-class constraint: the reference project takes ~30–60 s to load on first canvas open. MVP hard budget: **entry bundle ≤ 30 KB gzipped; widget chunks lazy-loaded; total extension assets ≤ 250 KB gzipped**.
### 6.1 Bundle targets
| Target | Entry | Output | Loaded when | Budget |
|---|---|---|---|---|
| **Extension** | `src/main.ts` | `js/main.js` + `js/assets/*.js` | ComfyUI graph opens (critical path) | ≤ 30 KB gzipped entry; widget chunks lazy |
| **Manager SPA** | `src/manager.ts` | `web_dist/` | User visits `/wildcard-pipeline/` | Route-split; no hard cap (MVP ships empty stub) |
### 6.2 Reference-project cold-load diagnosis
- `minify: false` shipped un-minified JS.
- `cssInjectedByJsPlugin` forced CSS through JS parser.
- No manual chunking — one monolithic `main.js` carried Vue + Pinia + every widget + PrimeVue even when no WP nodes were on canvas.
- `sourcemap: true` inline inflated download.
- Pinia + PrimeVue imported eagerly for widgets that didn't need them.
### 6.3 Extension bundle rules (hard requirements)
1. **Tiny eager entry.** `src/main.ts` only calls `app.registerExtension({name, getCustomWidgets})`. No Vue imports at top level. No widget component imports at top level.
2. **Lazy-load widget modules.** Two injection paths, both dynamic-imported so first use pulls the chunk:
```ts
app.registerExtension({
name: "wildcard-pipeline",
// Custom-typed inputs → widget factory keyed by type name
async getCustomWidgets() {
return {
WP_CONTEXT_MODULES: (node, inputName) =>
import("./widgets/context").then(m => m.create(node, inputName)),
WP_DEBUG_VIEWER: (node, inputName) =>
import("./widgets/debug").then(m => m.create(node, inputName)),
};
},
// Native-typed inputs on a specific node → mount helper after default widgets
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "WP_PromptAssembler") return;
const origOnCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (...args) {
origOnCreated?.apply(this, args);
import("./widgets/assembler").then(m => m.mountHelper(this));
};
},
});
```
3. **No PrimeVue in extension bundle.** Widgets use plain HTML + scoped CSS. PrimeVue + its Aria/theme peers are reserved for the manager SPA.
4. **No Pinia in extension bundle.** Widget state is local (`ref` / `reactive`). Cross-widget coordination via graph walking.
5. **Vue runtime-only.** Vite alias `vue → vue/dist/vue.runtime.esm-bundler.js`. Templates pre-compiled from `.vue` files.
6. **`minify: "esbuild"` + `sourcemap: "hidden"`.** Production ships minified JS with an out-of-band sourcemap file.
7. **`cssCodeSplit: true`.** Per-chunk CSS loaded via dynamic-import auto `<link>`, not inlined in JS.
8. **Externalise where possible.** Check `/scripts/app.js` interop. If ComfyUI exposes a global Vue, add to `rollupOptions.external`; otherwise bundle Vue exactly once.
9. **Tree-shakeable imports.** No barrel files. Named imports only.
### 6.4 Shared utility chunk
`src/widgets/_shared.ts` holds the Vue-in-DOMWidget factory, JSON serialiser, and conflict-scanner glue. Imported by each widget module → Vite de-duplicates into one shared chunk, so Vue exists exactly once in memory.
### 6.5 Manager SPA (stub now, budgeted from day one)
- Separate HTML entry (`src/manager.html` + `src/manager.ts`).
- Route-based code splitting via dynamic `import()`.
- PrimeVue + Pinia permitted; `unplugin-vue-components` with `PrimeVueResolver` for tree-shaking.
- Served via aiohttp static handler with gzip response.
- MVP ships an empty stub to validate the build target; fully implemented in the **Manager SPA** sub-spec.
### 6.6 CI-enforced performance gate
`scripts/check-bundle-size.mjs` runs in CI after `pnpm build:extension`. Fails the build if:
- entry gzip > 30 KB, **or**
- total `js/` gzip > 250 KB.
Hard gate prevents regression. Numbers reviewable per PR.
## 7. Conflict Scanner (MVP scope)
Pure static analysis in the browser, recomputed on widget change. Three conflict types apply while only `fixed_values` exists:
| Type | Severity | Trigger | Scope |
|---|---|---|---|
| `context_overwrite` | error | New entry writes a var that an **upstream node** already exported | cross-node (chain) |
| `duplicate_variable` | warning | Entry writes a var that an **earlier module in the same node** already wrote | intra-node |
| `missing_template_variable` | warning | Assembler template uses `$var` with no writer in its upstream chain | template resolution |
Mutually exclusive per capture (upstream check → same-node check → else clean), matching the reference project's `conflicts.ts` `if/else if` pattern.
**Examples.**
```
[Context A: style=photo] → [Context B: style=cinematic]
→ B raises context_overwrite on its entry.
[Context A with 2 fixed_values modules both writing $style]
→ 2nd module raises duplicate_variable.
[Context A: style=photo] → [Assembler: "a $stle image"]
→ Assembler raises missing_template_variable on $stle.
```
**Severity rationale.** `context_overwrite` is an *error* because silently stomping upstream is almost never intentional. `duplicate_variable` is a *warning* because intra-node overrides are sometimes deliberate. `missing_template_variable` is a *warning* because it's common during editing.
All advisory. No runtime enforcement — matches the last-write-wins runtime behaviour approved for the chain.
**Implementation notes.** Graph walkers live in `src/extension/graph.ts` under the same API names as the reference project (`collectUpstreamVariables`, `findDownstreamAssemblers`) but reimplemented avoiding reference-project anti-patterns: walk via `node.inputs[].link → app.graph.links[linkId] → graph.getNodeById(link.origin_id)`; never via `getInputNode` / `findInputSlot`. Deferred conflict types: `unresolved_constraint_target`, `unresolved_constraint_when_variable`, wildcard-specific rules.
Scanner output (`Conflict[]`) feeds widget UI as per-module-card badges + hover tooltips. Optional "Jump to module" click scrolls the offending module into view.
## 8. Repo Layout, Build, Tooling, Tests
### 8.1 Repo layout
```
ComfyUI-WildcardPipeline/
├── __init__.py # V3 comfy_entrypoint + extension
├── pyproject.toml # package metadata + ruff + pytest
├── package.json # pnpm, Vue deps, scripts
├── pnpm-workspace.yaml # single-package workspace (future-proof)
├── vite.config.mts # --mode extension | manager
├── tsconfig.json # strict, alias @→./src
├── conftest.py # sys.path shim for test-without-ComfyUI
├── .releaserc.js # semantic-release config
├── commitlint.config.js # conventional commits
├── .husky/ # pre-commit + commit-msg hooks
├── .github/workflows/
│ ├── ci.yml # pytest matrix + frontend typecheck/build + size gate
│ └── release.yml # semantic-release on main push
├── .gitignore # js/, web_dist/, node_modules/, .pytest_cache/, __pycache__/, .superpowers/
│
├── engine/
│ ├── __init__.py
│ ├── context.py
│ ├── modules.py
│ ├── handlers.py
│ ├── pipeline.py
│ └── template.py
│
├── nodes/
│ ├── __init__.py
│ ├── types.py
│ ├── context_node.py
│ ├── assembler_node.py
│ └── debug_node.py
│
├── src/
│ ├── main.ts
│ ├── manager.ts # stub for post-MVP
│ ├── env.d.ts
│ ├── typings/comfyui.d.ts
│ ├── widgets/
│ │ ├── _shared.ts
│ │ ├── context.ts
│ │ ├── assembler.ts
│ │ └── debug.ts
│ ├── extension/
│ │ ├── graph.ts
│ │ └── conflicts.ts
│ └── components/
│ ├── context/ContextWidget.vue
│ ├── assembler/AssemblerHelper.vue
│ └── debug/DebugViewer.vue
│
├── tests/
│ ├── test_template.py
│ ├── test_modules.py
│ ├── test_engine.py
│ └── test_nodes.py
│
├── docs/
│ └── superpowers/specs/
│
├── js/ # built extension (gitignored)
└── web_dist/ # built manager (gitignored, empty in MVP)
```
### 8.2 Package manager & scripts
**pnpm** is the package manager. `packageManager` field pinned in `package.json`. `pnpm-workspace.yaml` declares the root package.
```jsonc
{
"scripts": {
"dev": "vite build --watch --mode extension",
"build:extension": "vite build --mode extension",
"build:manager": "vite build --mode manager",
"build": "pnpm build:extension && pnpm build:manager",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint src --ext .ts,.vue",
"size": "node scripts/check-bundle-size.mjs",
"test": "vitest run",
"test:py": "pytest",
"prepare": "husky"
}
}
```
### 8.3 Python tooling
- Python ≥ 3.10 (floor). Runtime target on this machine: embedded 3.12.10 at `E:\ComfyUIDev\ComfyUI-Easy-Install\python_embeded\python.exe`.
- `pyproject.toml` with `[project]` metadata, `[tool.ruff]` (line-length 100, select `E/F/I/UP/B`), `[tool.pytest.ini_options]`.
- No runtime deps beyond stdlib in `engine/`. Nodes depend on `comfy_api` (provided by ComfyUI host, not a pip install).
- Dev deps (extras group `dev`): `pytest`, `pytest-asyncio`, `ruff`.
### 8.4 Dev-runtime environment separation
| Context | Python used | Purpose |
|---|---|---|
| **Runtime** (ComfyUI loads package) | embedded `python_embeded\python.exe` 3.12.10 | Runs nodes |
| **Dev / tests** | `.venv` with system Python OR embedded | `pytest`, `ruff`, tooling |
| **CI** (GitHub Actions) | `setup-python` matrix 3.10/3.11/3.12 | Cross-version pytest + typecheck + build |
Engine's stdlib-only constraint means embedded Python loads the package without any `pip install`. CI's lowest matrix row (3.10) is authoritative for what ships.
Two dev paths documented in `CONTRIBUTING.md`:
- **Recommended:** `python -m venv .venv` with system Python ≥ 3.10, then `.venv\Scripts\pip install -e ".[dev]"`.
- **Zero-install:** `"$EMBED\python.exe" -m pip install pytest ruff` into embedded. Works, but mixes dev deps into ComfyUI's interpreter.
Husky pre-commit resolves Python via `PATH`; if missing, fails fast with a helpful message. Helper batch script `scripts/run-tests.cmd` auto-detects `.venv` then falls back to embedded.
### 8.5 Open-source tooling
**`.releaserc.js` — semantic-release.** Branches: `main`. Plugins: `@semantic-release/commit-analyzer`, `release-notes-generator`, `changelog`, `github`, `git`. Versioning via Conventional Commits → SemVer bump. Artifacts published to GitHub Releases; ComfyUI Registry publish via `comfy-cli` in `release.yml` after tag.
**`commitlint.config.js`.** `extends: ["@commitlint/config-conventional"]`. Enforced on `commit-msg` hook.
**Husky hooks (`.husky/`).**
- `pre-commit`: `pnpm typecheck && pnpm lint && pnpm test` (frontend) + `ruff check` + `pytest -q` (backend).
- `commit-msg`: `commitlint --edit $1`.
**GitHub Actions.**
- `ci.yml` — pytest on Py 3.10/3.11/3.12, frontend typecheck + build + bundle-size gate.
- `release.yml` — on `main` push, semantic-release → tag + changelog + Registry publish.
**Repo hygiene.** `CODEOWNERS → @DumiFlex`. `LICENSE` (to confirm; reference project used GPL-3.0). `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md` ported and adapted.
### 8.6 Tests (MVP coverage)
- **engine/** — unit tests for `resolve_variables`, `module_from_dict`, `PipelineEngine.run` with `fixed_values`, chain merge behaviour, trace shape.
- **nodes/** — mock `comfy_api` stubs in `conftest.py`; verify `execute()` wires upstream → engine → `ContextPayload` correctly.
- **src/extension/conflicts.ts** — Vitest unit tests for the 3 conflict types.
- **src/widgets/** — no UI tests in MVP; manual QA + later Playwright sub-spec.
CI badge + coverage badge added to `README.md`.
### 8.7 CLAUDE.md
Written at the **start of implementation** (not now). Captures: build/test commands, conventions, anti-patterns inherited from the reference project, engine isolation invariant, bundle-size gate, custom type name, embedded-vs-dev Python split. Writing earlier risks encoding decisions that haven't survived first contact with code.
## 9. Out of Scope / Future Sub-Specs
Every item below is a distinct follow-up brainstorming session → spec → plan → implementation cycle. Ordering is by dependency; items marked *flexible* can swap with neighbours based on demand.
1. **Wildcards module + seed propagation.** `wildcard` module type with weighted options + categories. Nested `@{uuid}` refs with cycle detection + depth limit. Per-capture seed via `sha256(node_seed, capture_name)`. Pick-mode (pin a specific value).
2. **Combine module.** *(flexible with #3)* `combine` handler: `template + capture_as`. Adds `missing_combine_variable` to scanner.
3. **Constraints module.** *(flexible with #2)* Category rule matrix + exceptions. Applied to wildcard options pre-sampling. Adds `unresolved_constraint_target` + `unresolved_constraint_when_variable` to scanner.
4. **Derivations module.** Conditional mutations after wildcards. To brainstorm: any/all conditions, priority/rounds, output templates, `sample_from`.
5. **`WP_ContextInject` node.** 3 optional socket inputs + autogrow-style slot-to-variable mapping. Widget: connected-slot detector + variable-name editor.
6. **Library — sqlite backend + REST API.** Schema (alembic migrations): modules, pipelines, history (≤3 versions). aiohttp routes under `/wildcard-pipeline/api/*`. Inline serialised module remains authoritative in workflows; library `id` is a weak link; UI warns on missing. Context menu: *Save module to library*, *Link instance to library entry*, *Edit instance overrides*.
7. **Manager SPA.** Vue 3 + PrimeVue + Pinia at `/wildcard-pipeline/`. Routes: modules list (per type), module editor, pipelines list, pipeline editor, test playground, import/export. Route-based code splitting. Playwright e2e.
8. **Pipeline presets.** Node right-click menu *Save as pipeline* / *Import pipeline* (append/replace). Sharable JSON + library storage. Rename node on import.
9. **`WP_DEBUG` env socket + advanced debug UX.** `os.environ["WP_DEBUG"]` toggles an extra DEBUG_JSON socket output on every node. `WP_Debug` gains a second input accepting debug JSON directly. Viewer: timeline view, diff view between runs.
10. **Instance overrides UI.** Per-instance overrides on library-linked modules (weight tweaks, enable/disable values). Diff indicator in widget. Revert-to-library button.
### Explicitly not planned
- Workflow migration from `ComfyUI-Wildcard-Pipeline` (users re-author).
- Multi-user / collaboration features.
- Cloud sync for library.
- Non-Vue widgets (React/Svelte).
### Governance
Each spec starts with its own brainstorming → spec doc in `docs/superpowers/specs/` → implementation plan → PR. No implementation of #1 begins until MVP lands and has surfaced real constraints from use.
+146
View File
@@ -0,0 +1,146 @@
# Widget ↔ Engine JSON Contract
The **single canonical shape** flowing between the frontend Vue widgets and the
Python engine for `WP_Context` (and any future Pipeline Context node) is the
`ContextWidgetValue` JSON object below. Both sides MUST round-trip cleanly:
- Frontend: `parseWidgetJson` / `serializeWidgetJson` in
`src/widgets/_shared.ts`
- Backend: `module_from_dict` / `module_to_dict` in `engine/modules.py`
If you change the shape on one side, change it on the other in the same PR
**and** add round-trip tests covering the new field.
---
## `ContextWidgetValue`
```jsonc
{
"version": 1,
"modules": [ /* ModuleEntry[] */ ]
}
```
| Field | Type | Notes |
|---|---|---|
| `version` | `1` (literal) | Bump on breaking shape changes; readers should fall back to empty when unknown. |
| `modules` | `ModuleEntry[]` | Ordered list. Order is significant — runtime applies modules top→bottom (last write wins per variable). |
### Module ordering semantics
Order in the `modules` array IS the runtime application order. The reference
project's last-write-wins rule means downstream-ordered modules override
upstream ones for the same variable. Reorder operations in the UI must
preserve this contract.
---
## `ModuleEntry` (discriminated by `type`)
```jsonc
{
"id": "ab12cd34",
"type": "fixed_values",
"enabled": true,
"collapsed": false,
"meta": {
"name": "style",
"description": "",
"tags": []
},
"entries": [
{ "variable_name": "subject", "value": "a cat" },
{ "variable_name": "style", "value": "watercolor" }
]
}
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `id` | `string` | yes | 8-char hex. Stable per-module across saves. Generated by `newModuleId()` (`crypto.getRandomValues`) — never reused. |
| `type` | `"fixed_values"` (extensible) | yes | Discriminator. New module types add new literals to this union; readers MUST treat unknown types as no-ops. |
| `enabled` | `boolean` | yes | When `false`, the runtime skips the module entirely. Frontend conflict scans also skip disabled modules. |
| `collapsed` | `boolean` | no (UI-only) | Persists the card's collapsed state across reloads. Engine ignores it. |
| `meta` | `ModuleMeta` | yes | Display metadata (name shown in card header, description for tooltips, tags for SPA library filtering). |
| `entries` | depends on `type` | yes | Type-specific payload. For `fixed_values`: `Array<{variable_name, value}>`. |
### `ModuleMeta`
| Field | Type | Default | Notes |
|---|---|---|---|
| `name` | `string` | `""` | Human-friendly label for the card. Empty renders as `"(unnamed)"`. |
| `description` | `string` | `""` | Optional long-form text. Reserved for SPA library tooltips. |
| `category` | `string` | `""` | SPA library categorization. Frontend currently doesn't render this. |
| `tags` | `string[]` | `[]` | SPA library filtering. Frontend currently doesn't render this. |
### `fixed_values` entries
```jsonc
{ "variable_name": "subject", "value": "a cat" }
```
| Field | Type | Notes |
|---|---|---|
| `variable_name` | `string` | Stored **bare** — no leading `$`. The sigil is rendering chrome only, applied by the UI when displaying the chip / preview. Engine reads bare names too. |
| `value` | `string` | Literal string the variable resolves to. Empty strings are valid (drop a variable to "nothing"). |
---
## Variable name normalization
Both sides MUST normalize variable names identically:
```python
# engine + frontend
name = raw_name.replace("$", "", 1).strip()
```
- Strip a single leading `$` (defensive — historical workflows that stored
the sigil should still load).
- Strip surrounding whitespace.
- Empty after normalization → variable is dropped from the runtime context.
The frontend regex used for entry validation:
```ts
/^[A-Za-z_][A-Za-z0-9_]*$/
```
Names that don't match this regex still SAVE (we don't want to lose user
edits mid-typing) but render with an invalid badge in the editor. The
engine drops invalid names silently from the runtime context.
---
## Recovery & versioning
When the widget loads malformed JSON:
1. `parseWidgetJsonWithRecovery` returns `{value: emptyContextValue(), error, raw}`.
2. The Context node renders a recovery panel (red border, "View raw" / "Reset
to empty" buttons) instead of the normal module list.
3. The user can copy the raw payload before resetting.
4. Reset clears the value and pushes an info toast confirming the action.
Engine-side equivalent: `module_from_dict` raises `ValueError` for missing
`id`/`type` or unknown `type`. The runtime should catch and skip rather
than abort the whole graph evaluation; an unrelated downstream node
shouldn't fail because one Context has corrupt data.
---
## Adding a new module type
1. **Engine:** add a dataclass in `engine/modules.py`, extend the
`Module` union, add a branch in `module_from_dict`.
2. **Frontend types:** extend `ModuleEntry["type"]` literal in
`src/widgets/_shared.ts`.
3. **Frontend UI:** extend the `kinds` array in
`ModulePickerModal.vue`, add an icon + color, add an editor pane in
`ModuleEditModal.vue`.
4. **Theme:** pick a hue from the brand palette in `theme.css` and add a
`.type-<new_type>` rule for the card's type icon.
5. **Round-trip test:** Python `pytest` round-trips a fixture; Vitest
verifies parseWidgetJson + serializeWidgetJson preserves shape.
6. **This doc:** add the new type's entry shape under the "ModuleEntry"
section.
+19 -5
View File
@@ -7,13 +7,22 @@ from typing import Any
_VAR_RE = re.compile(r"\$(\w+)")
_SENTINEL = "\x00DOLLAR\x00"
# Two-or-more whitespace runs left by removed variables. Single space stays.
_WS_RUN = re.compile(r"[ \t]{2,}")
# Trailing/leading whitespace inside a comma-separated list left by drops.
_COMMA_GAP = re.compile(r",\s*,")
_TRIM_PUNCT = re.compile(r"\s+([,.;:!?])")
def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
"""Replace ``$var`` with ``str(ctx[var])``. ``$$`` escapes a literal ``$``.
"""Replace ``$var`` with ``str(ctx[var])``.
Keys beginning with ``__`` are engine internals and are never substituted.
Missing vars are left unchanged.
- ``$$`` escapes a literal ``$``.
- Keys beginning with ``__`` are engine internals — never substituted.
- Missing vars are dropped (replaced with empty string), and the
surrounding whitespace is normalized so the output reads cleanly:
doubled spaces collapse to one, ``", ,"`` collapses to ``","``,
whitespace before punctuation is trimmed.
"""
result = template.replace("$$", _SENTINEL)
@@ -23,7 +32,12 @@ def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
return match.group(0)
if name in ctx:
return str(ctx[name])
return match.group(0)
return ""
result = _VAR_RE.sub(_replace, result)
return result.replace(_SENTINEL, "$")
result = result.replace(_SENTINEL, "$")
# Cleanup gaps left by dropped variables.
result = _WS_RUN.sub(" ", result)
result = _COMMA_GAP.sub(",", result)
result = _TRIM_PUNCT.sub(r"\1", result)
return result.strip()
+45 -2
View File
@@ -2,10 +2,33 @@ import vue from "eslint-plugin-vue";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import vueParser from "vue-eslint-parser";
import a11y from "eslint-plugin-vuejs-accessibility";
// Rules picked from typescript-eslint's "strict" preset that are SAFE
// without `parserOptions.project` (so we don't pay the cost of a full
// type-checker pass on every lint). The type-checked variants are skipped;
// they need a TS project, and our typecheck script already covers them.
const STRICTER_TS_RULES = {
"@typescript-eslint/ban-ts-comment": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-empty-object-type": "error",
"@typescript-eslint/no-unsafe-function-type": "error",
"@typescript-eslint/no-wrapper-object-types": "error",
"@typescript-eslint/no-extraneous-class": "error",
"@typescript-eslint/no-namespace": "error",
};
const GENERAL_STRICT_RULES = {
eqeqeq: ["error", "always", { null: "ignore" }],
"prefer-const": "error",
"no-var": "error",
"no-console": ["warn", { allow: ["warn", "error", "info"] }],
};
export default [
{
ignores: ["js/**", "web_dist/**", "node_modules/**", "dist/**"],
ignores: ["js/**", "web_dist/**", "node_modules/**", "dist/**", "coverage/**"],
},
{
files: ["src/**/*.ts"],
@@ -19,6 +42,8 @@ export default [
plugins: { "@typescript-eslint": tsPlugin },
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
...STRICTER_TS_RULES,
...GENERAL_STRICT_RULES,
},
},
{
@@ -31,9 +56,27 @@ export default [
sourceType: "module",
},
},
plugins: { vue },
plugins: { vue, "vuejs-accessibility": a11y, "@typescript-eslint": tsPlugin },
rules: {
...vue.configs["vue3-recommended"].rules,
...a11y.configs.recommended.rules,
...STRICTER_TS_RULES,
...GENERAL_STRICT_RULES,
// Single-word component names are fine for our generic shared components
// (Logo, Toast); enforcing the multi-word rule would force renames with
// no ergonomic benefit.
"vue/multi-word-component-names": "off",
// Our context menu / hero / drag handles are intentionally ARIA-light —
// raise to "warn" so issues surface in CI but don't block development
// until we audit each one individually.
"vuejs-accessibility/click-events-have-key-events": "warn",
"vuejs-accessibility/no-static-element-interactions": "warn",
"vuejs-accessibility/anchor-has-content": "warn",
// Wrap-style labels (`<label><input/></label>`) are accessible but the
// rule prefers explicit `for=`. Downgrade to warn pending an audit.
"vuejs-accessibility/label-has-for": "warn",
"vuejs-accessibility/interactive-supports-focus": "warn",
"vuejs-accessibility/mouse-events-have-key-events": "warn",
},
},
];
+6
View File
@@ -0,0 +1,6 @@
{
"settingsCategories": {
"Wildcard Pipeline": "Wildcard Pipeline",
"Accessibility": "Accessibility"
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"WP_Context": {
"display_name": "WP Context",
"description": "Holds named values you can plug into a prompt template. Wire into a WP Prompt Assembler to use them. Stack multiple Contexts to layer values from different sources.",
"inputs": {
"upstream": {
"name": "upstream",
"tooltip": "Optional: another WP Context to inherit values from. Values you set here override matching ones from upstream."
},
"seed": {
"name": "seed",
"tooltip": "Reserved for upcoming randomized module types. Has no effect on the current fixed-values modules."
},
"modules": {
"name": "modules",
"tooltip": "Your stack of value modules. Right-click a card → Edit to add or change values."
}
},
"outputs": {
"0": {
"name": "context",
"tooltip": "Wire into a WP Prompt Assembler, or chain into another WP Context."
}
}
},
"WP_PromptAssembler": {
"display_name": "WP Prompt Assembler",
"description": "Write your prompt as a template with $variable tokens. The Assembler fills them in from the connected Context, then outputs the final prompt string.",
"inputs": {
"context": {
"name": "context",
"tooltip": "The WP Context that provides values for your $variables."
},
"template": {
"name": "template",
"tooltip": "Your prompt template. Use $name to reference variables. Click chips below to insert at the cursor."
}
},
"outputs": {
"0": {
"name": "prompt",
"tooltip": "Final prompt string with all $variables filled in. Connect to a CLIP Text Encode (or any text input)."
}
}
},
"WP_Debug": {
"display_name": "WP Debug",
"description": "Read-only window into a WP Context. Shows every variable + value flowing through this point of the chain. Useful for confirming what your Prompt Assembler will see.",
"inputs": {
"context": {
"name": "context",
"tooltip": "The WP Context (or Assembler input) to inspect."
},
"viewer": {
"name": "viewer",
"tooltip": "Read-only display. Nothing to edit here."
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"wildcardPipeline_a11y_reduceMotion": {
"name": "Reduce motion",
"tooltip": "Disables Wildcard Pipeline animations and transitions. Match system honors the OS prefers-reduced-motion setting.",
"options": {
"auto": "Match system (prefers-reduced-motion)",
"on": "Always reduce",
"off": "Always allow"
}
},
"wildcardPipeline_a11y_contrast": {
"name": "Contrast",
"tooltip": "Bumps border + text contrast on Wildcard Pipeline widgets. Match system honors the OS prefers-contrast setting.",
"options": {
"auto": "Match system (prefers-contrast)",
"on": "High contrast",
"off": "Standard"
}
}
}
+10 -2
View File
@@ -1,6 +1,6 @@
{
"name": "comfyui-wildcard-pipeline",
"version": "1.1.0",
"version": "1.3.0",
"private": true,
"description": "Modular procedural prompt generation for ComfyUI",
"author": "DumiFlex",
@@ -9,14 +9,16 @@
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev": "vite build --watch --mode extension",
"build:extension": "vite build --mode extension",
"build:extension": "vite build --mode extension && pnpm docs:copy",
"build:manager": "vite build --mode manager",
"build": "pnpm build:extension && pnpm build:manager",
"docs:copy": "node scripts/copy-help-docs.mjs",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint \"src/**/*.{ts,vue}\"",
"size": "node scripts/check-bundle-size.mjs",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:py": "pytest",
"prepare": "husky"
},
@@ -24,6 +26,7 @@
"vue": "^3.5.12"
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.42.15",
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@semantic-release/changelog": "^6.0.3",
@@ -32,13 +35,18 @@
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vitest/coverage-v8": "^2.1.9",
"@vue/test-utils": "^2.4.8",
"@vue/tsconfig": "^0.5.1",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.31.0",
"eslint-plugin-vuejs-accessibility": "^2.5.0",
"husky": "^9.1.6",
"jsdom": "^29.0.2",
"semantic-release": "^25.0.3",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-css-injected-by-js": "^3.5.2",
"vitest": "^2.1.4",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^2.1.10"
+688 -2
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 1024 1024" width="1024" height="1024"><defs>
<style>
.cls-1 {
fill: url(#linear-gradient-2);
}
.cls-2 {
fill: #6366f1;
}
.cls-3 {
fill: #33d6c6;
}
.cls-4 {
fill: #a970ff;
}
.cls-5 {
fill: url(#linear-gradient);
}
</style>
<linearGradient id="linear-gradient" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#a970ff"></stop>
<stop offset="1" stop-color="#6366f1"></stop>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6366f1"></stop>
<stop offset="1" stop-color="#33d6c6"></stop>
</linearGradient>
</defs><path class="cls-5" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z"></path><path class="cls-1" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z"></path><g>
<path class="cls-4" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z"></path>
<path class="cls-2" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
<path class="cls-3" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
</g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-wildcard-pipeline"
version = "1.1.0"
version = "1.3.0"
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
readme = "README.md"
requires-python = ">=3.10"
@@ -47,7 +47,7 @@ ignore = []
# conftest.py prepends the project root to sys.path; mirror that for Pyright
# so `from engine.template import ...` resolves in tests and editor.
extraPaths = ["."]
include = ["engine", "nodes", "tests", "conftest.py"]
include = ["engine", "wp_nodes", "tests", "conftest.py"]
exclude = ["node_modules", "js", "web_dist", ".venv", "**/__pycache__"]
# Dispatch-pattern handlers (engine.handlers.handle_*) share a uniform
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
// Diff two bundle-size manifests (produced by check-bundle-size.mjs --json)
// and emit a Markdown report on stdout. Used by the bundle-size-pr workflow
// to comment on PRs with the size delta.
//
// node scripts/bundle-size-diff.mjs <base.json> <head.json> > report.md
//
// Output is compact: total + per-file delta table. Files unchanged are
// omitted; new files show as "+" and removed as "−".
import { readFileSync } from "node:fs";
const [basePath, headPath] = process.argv.slice(2);
if (!basePath || !headPath) {
console.error("Usage: bundle-size-diff <base.json> <head.json>");
process.exit(2);
}
const base = JSON.parse(readFileSync(basePath, "utf-8"));
const head = JSON.parse(readFileSync(headPath, "utf-8"));
function fmtKB(bytes) {
return `${(bytes / 1024).toFixed(2)} KB`;
}
function fmtDelta(delta) {
if (delta === 0) return "—";
const sign = delta > 0 ? "+" : "−";
const kb = Math.abs(delta) / 1024;
return `${sign}${kb.toFixed(2)} KB`;
}
const allKeys = new Set([...Object.keys(base.files), ...Object.keys(head.files)]);
const rows = [];
for (const key of [...allKeys].sort()) {
const b = base.files[key] ?? 0;
const h = head.files[key] ?? 0;
if (b === h) continue;
const status = b === 0 ? "🆕 new" : h === 0 ? "🗑️ removed" : "📦 changed";
rows.push({ key, base: b, head: h, delta: h - b, status });
}
const totalDelta = head.total - base.total;
const entryDelta = (head.entry ?? 0) - (base.entry ?? 0);
let md = "## Bundle size report\n\n";
md += `| | base | head | delta |\n`;
md += `|---|---:|---:|---:|\n`;
md += `| **Total (gzip)** | ${fmtKB(base.total)} | ${fmtKB(head.total)} | **${fmtDelta(totalDelta)}** |\n`;
md += `| **Entry (main.js)** | ${fmtKB(base.entry ?? 0)} | ${fmtKB(head.entry ?? 0)} | ${fmtDelta(entryDelta)} |\n`;
if (rows.length === 0) {
md += `\n_No per-chunk changes._\n`;
} else {
md += `\n### Per-chunk changes\n\n`;
md += `| file | base | head | delta | |\n`;
md += `|---|---:|---:|---:|---|\n`;
for (const r of rows) {
md += `| \`${r.key}\` | ${fmtKB(r.base)} | ${fmtKB(r.head)} | ${fmtDelta(r.delta)} | ${r.status} |\n`;
}
}
console.log(md);
+25 -2
View File
@@ -1,11 +1,18 @@
#!/usr/bin/env node
import { gzipSync } from "node:zlib";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const JS_DIR = fileURLToPath(new URL("../js", import.meta.url));
// `--json <path>` writes a machine-readable manifest alongside the human
// console report. Used by .github/workflows/bundle-size-pr.yml to compute
// the delta between base and head.
const args = process.argv.slice(2);
const jsonIdx = args.indexOf("--json");
const jsonPath = jsonIdx >= 0 ? args[jsonIdx + 1] : null;
// Budgets (gzipped bytes) — tune only with explicit review
const ENTRY_LIMIT = 30 * 1024; // 30 KB
const TOTAL_LIMIT = 250 * 1024; // 250 KB
@@ -31,18 +38,28 @@ let failed = false;
const files = allJsFiles(JS_DIR);
let total = 0;
const manifest = { entry: null, total: 0, files: {} };
console.log(`\nBundle size report (gzipped):\n`);
for (const file of files) {
const size = gzipSize(file);
total += size;
const rel = file.substring(JS_DIR.length + 1);
// Strip the rolled-up content hash so PR ↔ base file names match. Vite
// emits `assets/<name>-<hash>.js` per chunk; the hash changes with any
// 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("\\", "/");
manifest.files[stable] = size;
console.log(` ${rel.padEnd(40)} ${(size / 1024).toFixed(2).padStart(7)} KB`);
}
manifest.total = total;
console.log(` ${"TOTAL".padEnd(40)} ${(total / 1024).toFixed(2).padStart(7)} KB`);
const entry = files.find((f) => f.endsWith("main.js"));
if (entry) {
const entrySize = gzipSize(entry);
manifest.entry = entrySize;
if (entrySize > ENTRY_LIMIT) {
console.error(`\n✗ Entry main.js gzip ${entrySize} bytes exceeds budget ${ENTRY_LIMIT}`);
failed = true;
@@ -58,4 +75,10 @@ if (total > TOTAL_LIMIT) {
console.log(`✓ Total within budget (${total} / ${TOTAL_LIMIT} bytes gzipped)`);
}
if (jsonPath) {
mkdirSync(dirname(jsonPath), { recursive: true });
writeFileSync(jsonPath, JSON.stringify(manifest, null, 2));
console.log(`\nWrote size manifest to ${jsonPath}`);
}
process.exit(failed ? 1 : 0);
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// Copy markdown help pages from docs/help/ to js/docs/ so ComfyUI's
// node-help-page system can serve them under /extensions/<package>/docs/.
// ComfyUI auto-scans `${WEB_DIRECTORY}/docs/<NodeId>.md` (plus optional
// `<NodeId>/<locale>.md` overrides) — see
// https://docs.comfy.org/custom-nodes/help_page
//
// Run as part of `pnpm build:extension` so a fresh `js/` always has the
// latest docs alongside the bundled JS chunks.
import { readdirSync, statSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const SRC = join(ROOT, "docs", "help");
const DST = join(ROOT, "js", "docs");
function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
try {
rmSync(DST, { recursive: true, force: true });
const files = walk(SRC);
for (const file of files) {
const rel = relative(SRC, file);
const target = join(DST, rel);
mkdirSync(join(target, ".."), { recursive: true });
copyFileSync(file, target);
console.log(` docs/help/${rel} → js/docs/${rel}`);
}
console.log(`Copied ${files.length} help file(s).`);
} catch (err) {
console.error("[copy-help-docs] failed:", err);
process.exit(1);
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import AssemblerHelper from "./AssemblerHelper.vue";
const baseProps = {
upstreamVars: [] as string[],
upstreamValues: {} as Record<string, string>,
template: "",
onInsert: () => {},
};
describe("AssemblerHelper.vue", () => {
it("renders a chip for each upstream variable", () => {
const wrapper = mount(AssemblerHelper, {
props: { ...baseProps, upstreamVars: ["style", "subject"] },
});
expect(wrapper.findAll('[data-testid="chip"]')).toHaveLength(2);
});
it("calls onInsert with $var when chip is clicked", async () => {
let inserted = "";
const wrapper = mount(AssemblerHelper, {
props: { ...baseProps, upstreamVars: ["style"], onInsert: (s: string) => { inserted = s; } },
});
await wrapper.find('[data-testid="chip"]').trigger("click");
expect(inserted).toBe("$style");
});
it("renders an empty hint when no upstream vars", () => {
const wrapper = mount(AssemblerHelper, { props: baseProps });
expect(wrapper.text()).toContain("Connect a Pipeline Context");
});
it("highlights resolved values in the preview HTML", () => {
const wrapper = mount(AssemblerHelper, {
props: {
...baseProps,
upstreamVars: ["style", "subject"],
upstreamValues: { style: "cinematic", subject: "fox" },
template: "A $style portrait of $subject",
},
});
const html = wrapper.find(".wp-asm__rendered").html();
expect(html).toContain("wp-tok-resolved");
expect(html).toContain("cinematic");
expect(html).toContain("fox");
});
it("flags unresolved $name with missing token + UNRESOLVED chip", () => {
const wrapper = mount(AssemblerHelper, {
props: {
...baseProps,
upstreamValues: { style: "noir" },
upstreamVars: ["style"],
template: "$style and $missing",
},
});
const rendered = wrapper.find(".wp-asm__rendered").html();
expect(rendered).toContain("wp-tok-resolved");
expect(rendered).toContain("wp-tok-miss");
expect(wrapper.find('[data-testid="missing-chip"]').text()).toBe("$missing");
});
it("marks chips of in-template upstream vars as `used` (green) and others as `available` (blue)", () => {
const wrapper = mount(AssemblerHelper, {
props: {
...baseProps,
upstreamVars: ["style", "subject"],
upstreamValues: { style: "noir", subject: "knight" },
template: "$style only",
},
});
const chips = wrapper.findAll('[data-testid="chip"]');
expect(chips[0].classes()).toContain("used");
expect(chips[1].classes()).toContain("available");
});
it("$$ escape stays literal and does not produce a missing chip", () => {
const wrapper = mount(AssemblerHelper, {
props: { ...baseProps, template: "$$cost is 5" },
});
expect(wrapper.find(".wp-asm__rendered").text()).toContain("$cost is 5");
expect(wrapper.find('[data-testid="missing-chip"]').exists()).toBe(false);
});
it("escapes HTML in upstream values to prevent injection", () => {
const wrapper = mount(AssemblerHelper, {
props: {
...baseProps,
upstreamVars: ["x"],
upstreamValues: { x: "<script>alert(1)</script>" },
template: "$x",
},
});
const html = wrapper.find(".wp-asm__rendered").html();
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
});
@@ -0,0 +1,328 @@
<script setup lang="ts">
import { computed, ref } from "vue";
const props = defineProps<{
upstreamVars: string[];
upstreamValues: Record<string, string>;
template: string;
onInsert: (token: string) => void;
}>();
// Sentinel survives both regex passes — � is the Unicode replacement
// character; doubled, it can't appear in real prompts.
const ESCAPE_PLACEHOLDER = "��";
const ESCAPE_RE = /��/g;
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Stable per-name hue. djb2-ish hash → 0..359°. Deterministic across runs
// so the same variable always gets the same color in chip + preview.
function hueFor(name: string): number {
let h = 5381;
for (let i = 0; i < name.length; i++) h = ((h << 5) + h + name.charCodeAt(i)) >>> 0;
return h % 360;
}
function chipColor(name: string): string {
return `hsl(${hueFor(name)}, 65%, 65%)`;
}
function chipBg(name: string): string {
return `hsla(${hueFor(name)}, 65%, 55%, 0.15)`;
}
const templateVars = computed(() => {
const sanitized = props.template.replace(/\$\$/g, ESCAPE_PLACEHOLDER);
const matches = sanitized.match(VAR_RE);
if (!matches) return [];
return [...new Set(matches.map((m) => m.slice(1)).filter((v) => !v.startsWith("__")))];
});
const missingVars = computed(() =>
templateVars.value.filter((v) => !Object.prototype.hasOwnProperty.call(props.upstreamValues, v)),
);
function isUsed(v: string): boolean {
return templateVars.value.includes(v);
}
const previewHtml = computed(() => {
if (!props.template) {
return '<span class="wp-asm-empty-inline">Template is empty.</span>';
}
const escaped = props.template.replace(/\$\$/g, ESCAPE_PLACEHOLDER);
const safe = escapeHtml(escaped).replace(VAR_RE, (full, name: string) => {
if (name.startsWith("__")) return full;
const hue = hueFor(name);
const colorStyle = `style="color: hsl(${hue}, 65%, 70%); background: hsla(${hue}, 65%, 55%, 0.15);"`;
if (Object.prototype.hasOwnProperty.call(props.upstreamValues, name)) {
return `<span class="wp-tok-resolved" ${colorStyle}>${escapeHtml(props.upstreamValues[name])}</span>`;
}
if (props.upstreamVars.includes(name)) {
return `<span class="wp-tok-ok" ${colorStyle}>$${name}</span>`;
}
return `<span class="wp-tok-miss">$${name}</span>`;
});
return safe.replace(ESCAPE_RE, "$");
});
// Ripple effect — track most-recent click coords per chip element to drive
// the radial-gradient animation. Cleared after the animation duration.
const ripples = ref<Map<string, { x: number; y: number; t: number }>>(new Map());
function onChipClick(ev: MouseEvent, v: string) {
const el = ev.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const x = ev.clientX - rect.left;
const y = ev.clientY - rect.top;
ripples.value.set(v, { x, y, t: Date.now() });
// Clear after animation finishes so the chip re-renders without it.
setTimeout(() => {
const cur = ripples.value.get(v);
if (cur && Date.now() - cur.t >= 380) ripples.value.delete(v);
}, 420);
props.onInsert(`$${v}`);
}
function rippleStyle(v: string): Record<string, string> {
const r = ripples.value.get(v);
if (!r) return {};
return {
"--wp-ripple-x": `${r.x}px`,
"--wp-ripple-y": `${r.y}px`,
};
}
</script>
<template>
<div class="wp-asm">
<div v-if="upstreamVars.length" class="wp-asm__section">
<label class="wp-asm__label">VARIABLES</label>
<div class="wp-asm__chips">
<button
v-for="v in upstreamVars" :key="v"
type="button"
class="wp-asm__chip"
:class="[isUsed(v) ? 'used' : 'available', { 'wp-asm__chip--ripple': ripples.has(v) }]"
:style="{ ...rippleStyle(v), color: chipColor(v), background: chipBg(v) }"
data-testid="chip"
:title="upstreamValues[v] ?? ''"
@click="(ev) => onChipClick(ev, v)"
>${{ v }}</button>
</div>
</div>
<div v-else class="wp-asm__section wp-asm__section--empty">
<span class="wp-asm__empty">Connect a Pipeline Context to see available variables</span>
</div>
<div class="wp-asm__section">
<label class="wp-asm__label">PREVIEW</label>
<div v-if="!template" class="wp-asm__skeleton">
<span class="wp-asm__skel-line"></span>
<span class="wp-asm__skel-line wp-asm__skel-line--short"></span>
</div>
<div v-else class="wp-asm__rendered" v-html="previewHtml"></div>
</div>
<div v-if="missingVars.length" class="wp-asm__section">
<label class="wp-asm__label wp-asm__label--warn">UNRESOLVED · dropped from prompt</label>
<div class="wp-asm__chips">
<span
v-for="v in missingVars"
:key="v"
class="wp-asm__chip missing"
data-testid="missing-chip"
>${{ v }}</span>
</div>
</div>
</div>
</template>
<style>
@import "../shared/theme.css";
</style>
<style scoped>
.wp-asm, .wp-asm * { box-sizing: border-box; }
.wp-asm {
display: flex;
flex-direction: column;
width: 100%;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
cursor: default;
user-select: none;
}
.wp-asm__section {
padding: 8px 10px;
}
.wp-asm__section + .wp-asm__section {
border-top: 1px solid var(--wp-border);
}
.wp-asm__label {
font-size: 9px;
font-family: var(--wp-font-mono, monospace);
color: var(--wp-text3);
letter-spacing: 0.08em;
margin-bottom: 6px;
display: block;
}
.wp-asm__label--warn { color: var(--wp-amber); }
.wp-asm__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.wp-asm__chip {
font-size: 10px;
font-family: var(--wp-font-mono, monospace);
padding: 2px 7px;
border-radius: 3px;
user-select: none;
border: none;
transition: background 0.12s, color 0.12s, transform 0.12s;
position: relative;
overflow: hidden;
}
.wp-asm__chip.available { cursor: pointer; }
.wp-asm__chip.used { cursor: pointer; }
.wp-asm__chip.used::after {
/* Tiny indicator pinned to the top-right corner. No padding override —
* chip stays the same width as available chips. The dot sits in the
* tight corner gap above the variable glyphs' ascender. */
content: "";
position: absolute;
top: 2px;
right: 2px;
width: 3px;
height: 3px;
border-radius: 50%;
background: currentColor;
}
.wp-asm__chip.available:hover,
.wp-asm__chip.used:hover {
/* filter brightens whatever inline hsla() the chip got from its name hash —
* works without overriding the inline style. */
filter: brightness(1.3) saturate(1.15);
}
.wp-asm__chip.missing {
background: var(--wp-amber-bg) !important;
color: var(--wp-amber) !important;
cursor: default;
}
/* Ripple — radial expanding overlay anchored to click coords */
.wp-asm__chip--ripple::before {
content: "";
position: absolute;
left: var(--wp-ripple-x, 50%);
top: var(--wp-ripple-y, 50%);
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
transform: translate(-50%, -50%);
animation: wp-asm-ripple 0.4s ease-out;
pointer-events: none;
}
@keyframes wp-asm-ripple {
0% { width: 0; height: 0; opacity: 0.6; }
100% { width: 120px; height: 120px; opacity: 0; }
}
.wp-asm__rendered {
background: var(--wp-bg3);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
padding: 6px 8px;
font-family: var(--wp-font-mono, monospace);
font-size: 11px;
line-height: 1.6;
color: var(--wp-text2);
word-break: break-word;
white-space: pre-wrap;
min-height: 32px;
}
/* Skeleton when template is empty (also briefly visible during async chunk
* resolution before Vue patches the props in). */
.wp-asm__skeleton {
background: var(--wp-bg3);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
padding: 8px;
min-height: 32px;
display: flex;
flex-direction: column;
gap: 6px;
}
.wp-asm__skel-line {
display: block;
height: 8px;
border-radius: 3px;
background: linear-gradient(
90deg,
var(--wp-bg2) 0%,
var(--wp-bg4) 50%,
var(--wp-bg2) 100%
);
background-size: 200% 100%;
animation: wp-asm-shimmer 1.4s ease-in-out infinite;
}
.wp-asm__skel-line--short { width: 60%; }
@keyframes wp-asm-shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.wp-asm__section--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 32px;
}
.wp-asm__empty {
color: var(--wp-text3);
font-size: 11px;
font-style: italic;
}
</style>
<style>
/* Unscoped — rendered via v-html. */
.wp-tok-resolved {
padding: 1px 4px;
border-radius: 3px;
/* 600 not 500 — we only bundle Inter 400 + 600 to keep the chunk small.
* 500 would substitute via system fallback and look out of family. */
font-weight: 600;
}
.wp-tok-ok {
padding: 1px 3px;
border-radius: 3px;
}
.wp-tok-miss {
color: var(--wp-amber, #d4a843);
background: var(--wp-amber-bg, rgba(212, 168, 67, 0.1));
padding: 1px 3px;
border-radius: 3px;
text-decoration: underline wavy;
}
.wp-asm-empty-inline {
color: var(--wp-text3, #5a5a72);
font-style: italic;
}
</style>
@@ -0,0 +1,90 @@
import { describe, it, expect, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import ContextWidget from "./ContextWidget.vue";
afterEach(() => {
// Modals teleport to body — strip leftover overlays between tests.
document.body.innerHTML = "";
});
const moduleJson = (vars: Array<{ name: string; value: string }>): string =>
JSON.stringify({
version: 1,
modules: [
{
id: "m1",
type: "fixed_values",
enabled: true,
meta: { name: "test mod", description: "", tags: [] },
entries: vars.map((v) => ({ variable_name: v.name, value: v.value })),
},
],
});
describe("ContextWidget.vue", () => {
it("renders an empty state when no modules", () => {
const wrapper = mount(ContextWidget, {
props: { nodeId: 1, initialJson: '{"version":1,"modules":[]}', upstreamVars: [], onChange: () => {} },
});
// Empty hero shows the brand title + the "Add your first module" CTA.
expect(wrapper.text()).toContain("Wildcard Pipeline");
expect(wrapper.text()).toContain("Add your first module");
});
it("opens picker, picks fixed_values, emits new JSON via onChange", async () => {
let last = "";
const wrapper = mount(ContextWidget, {
attachTo: document.body,
props: { nodeId: 2, initialJson: '{"version":1,"modules":[]}', upstreamVars: [], onChange: (v: string) => { last = v; } },
});
await wrapper.find('[data-testid="open-picker"]').trigger("click");
const pick = document.querySelector<HTMLButtonElement>('[data-testid="pick-fixed_values"]');
expect(pick).not.toBeNull();
pick!.click();
await wrapper.vm.$nextTick();
expect(last).toMatch(/"type":"fixed_values"/);
});
it("renders a summary line per module — first 2 vars + '+N more' for the rest", () => {
const wrapper = mount(ContextWidget, {
props: {
nodeId: 3,
initialJson: moduleJson([
{ name: "style", value: "noir" },
{ name: "subject", value: "fox" },
{ name: "mood", value: "calm" },
{ name: "light", value: "soft" },
]),
upstreamVars: [],
onChange: () => {},
},
});
const summary = wrapper.find(".wp-summary");
expect(summary.exists()).toBe(true);
expect(summary.text()).toBe("$style, $subject, +2 more");
});
it("module name renders read-only as text (not an input)", () => {
const wrapper = mount(ContextWidget, {
props: { nodeId: 4, initialJson: moduleJson([]), upstreamVars: [], onChange: () => {} },
});
expect(wrapper.find("input.wp-module-name").exists()).toBe(false);
expect(wrapper.find(".wp-module-name").text()).toBe("test mod");
});
it("summary line is read-only — not a button, no click handler", () => {
const wrapper = mount(ContextWidget, {
props: {
nodeId: 5,
initialJson: moduleJson([{ name: "style", value: "noir" }]),
upstreamVars: [],
onChange: () => {},
},
});
const summary = wrapper.find(".wp-summary");
expect(summary.exists()).toBe(true);
// Read-only div, not a button — edit access is via right-click → Edit
// or Enter on the focused card.
expect(summary.element.tagName).toBe("DIV");
});
});
File diff suppressed because it is too large Load Diff
+504
View File
@@ -0,0 +1,504 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from "vue";
import ModalShell from "../shared/ModalShell.vue";
import type { ModuleEntry } from "../../widgets/_shared";
const props = defineProps<{
visible: boolean;
module: ModuleEntry | null;
/** Variable names defined upstream — used for autocomplete + validity checks. */
upstreamVars?: string[];
/** Variable names defined by OTHER modules in the same node. */
siblingVars?: string[];
}>();
const emit = defineEmits<{
(e: "save", value: ModuleEntry): void;
(e: "close"): void;
}>();
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
// `module` prop is the source-of-truth snapshot at open time; we deep-clone
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
const draft = ref<ModuleEntry | null>(null);
const firstNameInput = ref<HTMLInputElement | null>(null);
const moduleNameInput = ref<HTMLInputElement | null>(null);
watch(() => props.visible, async (v) => {
if (v && props.module) {
draft.value = JSON.parse(JSON.stringify(props.module));
await nextTick();
if (draft.value && draft.value.entries.length > 0) {
firstNameInput.value?.focus();
firstNameInput.value?.select();
} else {
moduleNameInput.value?.focus();
moduleNameInput.value?.select();
}
window.addEventListener("keydown", onKeydown);
} else {
window.removeEventListener("keydown", onKeydown);
draft.value = null;
}
});
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
function onKeydown(ev: KeyboardEvent) {
if (!props.visible) return;
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
ev.preventDefault();
save();
}
}
function iconFor(type: ModuleEntry["type"]): string {
if (type === "fixed_values") return "pi-tag";
return "pi-question";
}
// Per-entry validity:
// ok — name is set and unique
// empty — name not set yet (no glyph)
// shadow — name exists upstream OR in another module in this node
// (informational; runtime is last-write-wins anyway)
// dup — same name appears earlier in THIS module (real bug — the
// second write inside the same module is silently ignored)
type EntryStatus = "ok" | "empty" | "shadow" | "dup";
const entryStatuses = computed<EntryStatus[]>(() => {
if (!draft.value) return [];
const known = new Set([...(props.upstreamVars ?? []), ...(props.siblingVars ?? [])]);
const seen = new Set<string>();
return draft.value.entries.map((e) => {
const name = e.variable_name.trim();
if (!name) return "empty";
if (seen.has(name)) return "dup";
seen.add(name);
if (known.has(name)) return "shadow";
return "ok";
});
});
function statusTooltip(s: EntryStatus): string {
if (s === "ok") return "Unique name";
if (s === "shadow") return "Already defined upstream or in another module here";
if (s === "dup") return "Duplicate name in this module";
return "";
}
function addEntry() {
if (!draft.value) return;
draft.value.entries.push({ variable_name: "", value: "" });
}
function removeEntry(idx: number) {
if (!draft.value) return;
draft.value.entries.splice(idx, 1);
}
function updateVarName(idx: number, v: string) {
if (!draft.value) return;
draft.value.entries[idx].variable_name = v.replace(/^\$+/, "");
}
function onValueEnter(idx: number, ev: KeyboardEvent) {
if (!draft.value) return;
ev.preventDefault();
const isLast = idx === draft.value.entries.length - 1;
if (isLast) {
addEntry();
nextTick(() => {
const inputs = document.querySelectorAll<HTMLInputElement>(".wp-medit__entry-var");
inputs[inputs.length - 1]?.focus();
});
} else {
nextTick(() => {
const inputs = document.querySelectorAll<HTMLInputElement>(".wp-medit__entry-var");
inputs[idx + 1]?.focus();
});
}
}
// Bulk paste — multi-line `name=value` text becomes N entries auto-parsed.
// Triggered when the user pastes into a variable-name input. If the paste
// is single-line, defer to native input behavior.
function onNamePaste(idx: number, ev: ClipboardEvent) {
if (!draft.value) return;
const text = ev.clipboardData?.getData("text") ?? "";
if (!text.includes("\n") && !text.includes("=")) return; // single token — let browser paste
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
if (lines.length === 0) return;
// Each line: name=value, name : value, or just name
const parsed = lines.map((line) => {
const m = /^\s*\$?([A-Za-z_][A-Za-z0-9_]*)\s*[=:]\s*(.*?)\s*$/.exec(line);
if (m) return { variable_name: m[1], value: m[2] };
const nameOnly = /^\s*\$?([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(line);
if (nameOnly) return { variable_name: nameOnly[1], value: "" };
return null;
}).filter((x): x is { variable_name: string; value: string } => x !== null);
if (parsed.length === 0) return;
ev.preventDefault();
// Replace the current row with first parsed entry, splice the rest after.
draft.value.entries[idx] = parsed[0];
if (parsed.length > 1) {
draft.value.entries.splice(idx + 1, 0, ...parsed.slice(1));
}
}
function save() {
if (!draft.value) return;
emit("save", JSON.parse(JSON.stringify(draft.value)));
}
function cancel() {
emit("close");
}
// HTML <datalist> id needs to be unique per modal mount so multiple Context
// nodes don't share a list (though only one modal is visible at a time).
const datalistId = `wp-medit-vars-${Math.random().toString(36).slice(2, 8)}`;
// Autocomplete options = upstream + sibling modules' variables, deduped.
const autocompleteOptions = computed<string[]>(() => {
const set = new Set([...(props.upstreamVars ?? []), ...(props.siblingVars ?? [])]);
return [...set].sort();
});
</script>
<template>
<ModalShell :visible="visible" @close="cancel">
<div v-if="draft" class="wp-medit">
<header class="wp-medit__head">
<i :class="['pi', iconFor(draft.type), 'wp-medit__head-icon', `type-${draft.type}`]" aria-hidden="true"></i>
<input
ref="moduleNameInput"
v-model="draft.meta.name"
class="wp-medit__name-input"
placeholder="module name"
spellcheck="false"
/>
<button type="button" class="wp-medit__close" aria-label="Close" @click="cancel">
<i class="pi pi-times" aria-hidden="true"></i>
</button>
</header>
<div class="wp-medit__body">
<section class="wp-medit__section">
<label class="wp-medit__section-label">DESCRIPTION</label>
<input
v-model="draft.meta.description"
class="wp-medit__meta-input"
placeholder="optional — what this module is for"
/>
</section>
<section class="wp-medit__section">
<label class="wp-medit__section-label">ENTRIES</label>
<p class="wp-medit__hint-line">
Tip — paste multi-line <code>name=value</code> text into a name field to bulk-add.
</p>
<div v-if="draft.entries.length === 0" class="wp-medit__empty">
No entries yet.
</div>
<TransitionGroup v-else tag="div" name="wp-medit-list" class="wp-medit__entries">
<datalist :id="datalistId" key="datalist">
<option v-for="v in autocompleteOptions" :key="v" :value="v"></option>
</datalist>
<div v-for="(e, i) in draft.entries" :key="`row-${i}`" class="wp-medit__entry">
<span
class="wp-medit__status"
:class="`wp-medit__status--${entryStatuses[i]}`"
:title="statusTooltip(entryStatuses[i])"
aria-hidden="true"
>
<i v-if="entryStatuses[i] === 'ok'" class="pi pi-check"></i>
<i v-else-if="entryStatuses[i] === 'dup'" class="pi pi-exclamation-circle"></i>
<i v-else-if="entryStatuses[i] === 'shadow'" class="pi pi-info-circle"></i>
</span>
<div class="wp-medit__entry-var-wrap">
<span class="wp-medit__entry-prefix" aria-hidden="true">$</span>
<input
:ref="(el) => { if (i === 0) firstNameInput = el as HTMLInputElement }"
class="wp-medit__entry-input wp-medit__entry-var"
:value="e.variable_name"
placeholder="name"
spellcheck="false"
:list="datalistId"
@input="(ev) => updateVarName(i, (ev.target as HTMLInputElement).value)"
@paste="(ev) => onNamePaste(i, ev)"
/>
</div>
<input
v-model="e.value"
class="wp-medit__entry-input wp-medit__entry-value"
placeholder="value"
@keydown.enter="(ev) => onValueEnter(i, ev)"
/>
<button
type="button"
aria-label="remove entry"
title="Remove entry"
class="wp-medit__entry-remove"
@click="removeEntry(i)"
><i class="pi pi-times" aria-hidden="true"></i></button>
</div>
</TransitionGroup>
<button type="button" class="wp-medit__add-entry" @click="addEntry">
<i class="pi pi-plus" aria-hidden="true"></i> add entry
</button>
</section>
</div>
<footer class="wp-medit__foot">
<span class="wp-medit__hint">Esc to cancel · Ctrl+Enter to save</span>
<div class="wp-medit__buttons">
<button type="button" class="wp-medit__btn" @click="cancel">Cancel</button>
<button type="button" class="wp-medit__btn wp-medit__btn--primary" @click="save">Save</button>
</div>
</footer>
</div>
</ModalShell>
</template>
<style>
@import "../shared/theme.css";
</style>
<style scoped>
.wp-medit, .wp-medit * { box-sizing: border-box; }
.wp-medit {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
width: 540px;
max-width: 100%;
max-height: 80vh;
display: flex;
flex-direction: column;
color: var(--wp-text);
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
}
.wp-medit__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient);
position: relative;
}
.wp-medit__head::after {
content: "";
position: absolute;
inset: 0;
background: rgba(35, 35, 35, 0.85);
pointer-events: none;
}
.wp-medit__head > * { position: relative; z-index: 1; }
.wp-medit__head-icon {
font-size: 14px;
color: var(--wp-text2);
flex-shrink: 0;
}
.wp-medit__head-icon.type-fixed_values { color: var(--wp-rose); }
.wp-medit__name-input {
flex: 1;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
color: var(--wp-text);
font-family: var(--wp-font-sans, sans-serif);
font-size: 13px;
font-weight: 600;
padding: 4px 8px;
min-width: 0;
}
.wp-medit__name-input:focus { outline: none; border-color: var(--wp-accent); }
.wp-medit__close {
background: none;
border: none;
color: var(--wp-text3);
font-size: 14px;
cursor: pointer;
padding: 4px 6px;
}
.wp-medit__close:hover { color: var(--wp-text); }
.wp-medit__body {
padding: 12px 14px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
flex: 1 1 auto;
min-height: 0;
}
.wp-medit__section { display: flex; flex-direction: column; gap: 6px; }
.wp-medit__section-label {
font-size: 9px;
font-family: var(--wp-font-mono, monospace);
color: var(--wp-text3);
letter-spacing: 0.08em;
font-weight: 600;
}
.wp-medit__hint-line {
font-size: 11px;
color: var(--wp-text3);
margin: 0 0 2px;
}
.wp-medit__hint-line code {
font-family: var(--wp-font-mono, monospace);
background: var(--wp-bg);
padding: 0 4px;
border-radius: 2px;
}
.wp-medit__meta-input {
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
color: var(--wp-text);
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
padding: 5px 8px;
width: 100%;
}
.wp-medit__meta-input:focus { outline: none; border-color: var(--wp-accent); }
.wp-medit__empty {
color: var(--wp-text3);
font-style: italic;
padding: 6px 0;
}
.wp-medit__entries {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.wp-medit__entry {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.wp-medit__entry > .wp-medit__entry-var-wrap,
.wp-medit__entry > .wp-medit__entry-value {
flex: 1 1 0;
min-width: 0;
}
/* Per-entry validity glyph */
.wp-medit__status {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
flex-shrink: 0;
cursor: help;
}
.wp-medit__status--empty,
.wp-medit__status--empty i { display: none; }
.wp-medit__status--ok i { color: var(--wp-green); }
.wp-medit__status--shadow i { color: var(--wp-accent); }
.wp-medit__status--dup i { color: var(--wp-red); }
.wp-medit__entry-var-wrap {
position: relative;
min-width: 0;
}
.wp-medit__entry-prefix {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
color: var(--wp-accent);
font-family: var(--wp-font-mono, monospace);
font-size: 12px;
font-weight: 600;
pointer-events: none;
}
.wp-medit__entry-var { padding-left: 16px !important; }
.wp-medit__entry-input {
background: var(--wp-bg);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
color: var(--wp-text);
font-family: var(--wp-font-mono, monospace);
font-size: 12px;
padding: 5px 8px;
width: 100%;
min-width: 0;
}
.wp-medit__entry-input:focus { outline: none; border-color: var(--wp-accent); }
.wp-medit__entry-remove {
background: none;
border: none;
color: var(--wp-text3);
cursor: pointer;
font-size: 12px;
padding: 0 4px;
}
.wp-medit__entry-remove:hover { color: var(--wp-red); }
.wp-medit__add-entry {
background: none;
border: 1px dashed var(--wp-border2);
border-radius: var(--wp-radius-sm);
color: var(--wp-text2);
cursor: pointer;
font-size: 11px;
padding: 5px;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
}
.wp-medit__add-entry:hover { color: var(--wp-accent); border-color: var(--wp-accent); }
.wp-medit__foot {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-top: 1px solid var(--wp-border);
gap: 12px;
}
.wp-medit__hint {
font-size: 10px;
color: var(--wp-text3);
font-family: var(--wp-font-mono, monospace);
}
.wp-medit__buttons { display: flex; gap: 8px; }
.wp-medit__btn {
background: var(--wp-bg3);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
color: var(--wp-text);
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
padding: 5px 14px;
cursor: pointer;
transition: all 0.15s;
}
.wp-medit__btn:hover { border-color: var(--wp-border2); }
.wp-medit__btn--primary {
background: var(--wp-accent);
border-color: var(--wp-accent);
color: #fff;
font-weight: 600;
}
.wp-medit__btn--primary:hover { background: var(--wp-accent2); border-color: var(--wp-accent2); }
/* Entry-row enter + reorder animations. Leave is instant — fade-out felt
* laggy when chained with a FLIP move to fill the gap. */
.wp-medit-list-move { transition: transform 0.2s ease-out; }
.wp-medit-list-enter-active { transition: opacity 0.18s, transform 0.18s; }
.wp-medit-list-enter-from { opacity: 0; transform: translateY(-6px); }
</style>
@@ -0,0 +1,163 @@
<template>
<ModalShell :visible="visible" @close="$emit('close')">
<div class="wp-picker">
<header class="wp-picker__head">
<Logo :size="32" />
<div class="wp-picker__head-text">
<div class="wp-picker__title">Add module</div>
<div class="wp-picker__subtitle">Composable prompt context for ComfyUI</div>
</div>
<button type="button" class="wp-picker__close" aria-label="Close" @click="$emit('close')">
<i class="pi pi-times" aria-hidden="true"></i>
</button>
</header>
<div class="wp-picker__body">
<button
v-for="kind in kinds"
:key="kind.type"
type="button"
class="wp-picker__item"
:data-testid="`pick-${kind.type}`"
@click="$emit('select', kind.type)"
>
<div class="wp-picker__item-row">
<i
:class="['pi', kind.icon, 'wp-picker__item-icon']"
:style="{ color: kind.color }"
aria-hidden="true"
></i>
<span class="wp-picker__item-name">{{ kind.label }}</span>
</div>
<div class="wp-picker__item-desc">{{ kind.description }}</div>
</button>
</div>
</div>
</ModalShell>
</template>
<script setup lang="ts">
import ModalShell from "../shared/ModalShell.vue";
import Logo from "../shared/Logo.vue";
defineProps<{ visible: boolean }>();
defineEmits<{ (e: "select", type: "fixed_values"): void; (e: "close"): void }>();
// MVP only ships fixed_values. New types append here; the type-icon mapping
// lives in shared/theme.css comments + this kinds array (color via CSS class
// per type, icon via PrimeIcons name).
const kinds = [
{
type: "fixed_values" as const,
label: "Fixed values",
icon: "pi-tag",
color: "var(--wp-rose)",
description: "Hard-coded $variable = value pairs.",
},
];
</script>
<style>
@import "../shared/theme.css";
</style>
<style scoped>
.wp-picker {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
width: 480px;
max-width: 100%;
max-height: 80vh;
display: flex;
flex-direction: column;
color: var(--wp-text);
}
.wp-picker__head {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--wp-border);
background: var(--wp-brand-gradient);
background-size: 200% 200%;
position: relative;
}
.wp-picker__head::after {
/* Translucent overlay so the gradient sits behind a dark wash — same
* effect as the rest of the modal but with a hint of brand color. */
content: "";
position: absolute;
inset: 0;
background: rgba(35, 35, 35, 0.85);
pointer-events: none;
}
.wp-picker__head > * { position: relative; z-index: 1; }
.wp-picker__head-text { flex: 1; min-width: 0; }
.wp-picker__title {
font-size: 14px;
font-weight: 600;
}
.wp-picker__subtitle {
font-size: 11px;
color: var(--wp-text2);
margin-top: 2px;
}
.wp-picker__close {
background: none;
border: none;
color: var(--wp-text3);
font-size: 14px;
line-height: 1;
cursor: pointer;
padding: 4px 6px;
}
.wp-picker__close:hover { color: var(--wp-text); }
.wp-picker__body {
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.wp-picker__item {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--wp-bg3);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
padding: 10px 12px;
cursor: pointer;
text-align: left;
color: var(--wp-text);
transition: all 0.15s;
}
.wp-picker__item:hover {
border-color: var(--wp-accent);
background: var(--wp-bg4);
}
.wp-picker__item-row {
display: flex;
align-items: center;
gap: 10px;
}
.wp-picker__item-icon {
font-size: 14px;
color: var(--wp-text2);
width: 16px;
text-align: center;
flex-shrink: 0;
}
.wp-picker__item-name {
font-size: 13px;
font-weight: 600;
}
.wp-picker__item-desc {
font-size: 11px;
color: var(--wp-text2);
padding-left: 26px;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
import { ref } from "vue";
import type { ModuleEntry } from "../../widgets/_shared";
// Module-scoped reactive state shared across every ContextWidget Vue app
// instance on the page. Each Context node mounts its own Vue app, so we
// can't use provide/inject — but plain module imports give us a single
// source of truth for "what is currently being dragged".
export interface DragPayload {
sourceNodeId: number;
module: ModuleEntry;
/** Set by the receiving node on cross-node drop so source can clean up. */
consumedBy?: number;
}
export const dragState = ref<DragPayload | null>(null);
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import DebugViewer from "./DebugViewer.vue";
describe("DebugViewer.vue", () => {
it("renders a placeholder when no snapshot has been received", () => {
const wrapper = mount(DebugViewer, { props: { snapshot: "" } });
expect(wrapper.text()).toContain("Run the graph");
});
it("renders the snapshot JSON pretty-printed", () => {
const wrapper = mount(DebugViewer, { props: { snapshot: '{"context":{"a":1},"debug":{}}' } });
expect(wrapper.text()).toContain('"a": 1');
});
});
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{ snapshot: string }>();
const pretty = computed(() => {
if (!props.snapshot) return "";
try { return JSON.stringify(JSON.parse(props.snapshot), null, 2); }
catch { return props.snapshot; }
});
</script>
<template>
<div class="wp-debug">
<pre v-if="pretty" class="wp-debug__pre">{{ pretty }}</pre>
<p v-else class="wp-debug__empty">Run the graph to capture a snapshot.</p>
</div>
</template>
<style>
@import "../shared/theme.css";
</style>
<style scoped>
.wp-debug {
font-family: var(--wp-font-mono, monospace);
font-size: 11px;
line-height: 1.4;
padding: 6px;
color: var(--wp-text);
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.wp-debug__pre {
background: var(--wp-bg);
color: var(--wp-text);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
padding: 8px;
margin: 0;
flex: 1;
min-height: 0;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.wp-debug__empty {
color: var(--wp-text3);
font-style: italic;
font-family: var(--wp-font-sans, sans-serif);
text-align: center;
margin: auto 0;
}
</style>
+182
View File
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch, nextTick } from "vue";
export interface ContextMenuItem {
label: string;
/** PrimeIcons class (e.g. "pi-trash"). ComfyUI loads PrimeIcons globally. */
icon?: string;
/** Optional separator above the item. */
divider?: boolean;
/** Render the label in red (destructive action). */
danger?: boolean;
disabled?: boolean;
onSelect: () => void;
}
const props = defineProps<{
visible: boolean;
x: number;
y: number;
items: ContextMenuItem[];
}>();
const emit = defineEmits<{ (e: "close"): void }>();
const activeIndex = ref(-1);
function pickableIndices(): number[] {
const out: number[] = [];
for (let i = 0; i < props.items.length; i++) {
const it = props.items[i];
if (!it.disabled) out.push(i);
}
return out;
}
function moveActive(delta: 1 | -1) {
const idxs = pickableIndices();
if (!idxs.length) return;
const cur = idxs.indexOf(activeIndex.value);
const next = cur < 0 ? (delta > 0 ? 0 : idxs.length - 1) : (cur + delta + idxs.length) % idxs.length;
activeIndex.value = idxs[next];
}
function gotoEdge(edge: "first" | "last") {
const idxs = pickableIndices();
if (!idxs.length) return;
activeIndex.value = edge === "first" ? idxs[0] : idxs[idxs.length - 1];
}
function onWindowClick() {
emit("close");
}
function onKey(ev: KeyboardEvent) {
switch (ev.key) {
case "Escape": ev.preventDefault(); emit("close"); break;
case "ArrowDown": ev.preventDefault(); moveActive(1); break;
case "ArrowUp": ev.preventDefault(); moveActive(-1); break;
case "Home": ev.preventDefault(); gotoEdge("first"); break;
case "End": ev.preventDefault(); gotoEdge("last"); break;
case "Enter":
case " ": {
ev.preventDefault();
const it = props.items[activeIndex.value];
if (it && !it.disabled) pick(it);
break;
}
}
}
watch(() => props.visible, async (v) => {
if (v) {
activeIndex.value = -1;
window.addEventListener("click", onWindowClick);
window.addEventListener("keydown", onKey);
// Defer one tick so the click that opened the menu doesn't immediately
// close it — and so the first ArrowDown lands on the first item.
await nextTick();
} else {
window.removeEventListener("click", onWindowClick);
window.removeEventListener("keydown", onKey);
}
});
onBeforeUnmount(() => {
window.removeEventListener("click", onWindowClick);
window.removeEventListener("keydown", onKey);
});
function pick(item: ContextMenuItem) {
if (item.disabled) return;
item.onSelect();
emit("close");
}
</script>
<template>
<Teleport to="body">
<ul
v-if="visible"
class="wp-ctxmenu"
:style="{ left: `${x}px`, top: `${y}px` }"
role="menu"
@click.stop
@contextmenu.prevent
>
<template v-for="(it, i) in items" :key="i">
<li v-if="it.divider" class="wp-ctxmenu__sep" aria-hidden="true"></li>
<li
class="wp-ctxmenu__item"
:class="{
'wp-ctxmenu__item--danger': it.danger,
'wp-ctxmenu__item--disabled': it.disabled,
'wp-ctxmenu__item--active': activeIndex === i,
}"
role="menuitem"
:aria-disabled="it.disabled || undefined"
@mouseenter="!it.disabled && (activeIndex = i)"
@click="pick(it)"
>
<i v-if="it.icon" :class="['pi', it.icon, 'wp-ctxmenu__icon']" aria-hidden="true"></i>
<span v-else class="wp-ctxmenu__icon-spacer" aria-hidden="true"></span>
<span class="wp-ctxmenu__label">{{ it.label }}</span>
</li>
</template>
</ul>
</Teleport>
</template>
<style>
@import "./theme.css";
</style>
<style scoped>
.wp-ctxmenu {
position: fixed;
z-index: 10000;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-sm);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
padding: 4px 0;
min-width: 180px;
list-style: none;
margin: 0;
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
user-select: none;
}
.wp-ctxmenu__item {
padding: 5px 12px;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
}
.wp-ctxmenu__item--active,
.wp-ctxmenu__item:hover { background: var(--wp-accent-glow); color: var(--wp-accent); }
.wp-ctxmenu__item--danger { color: var(--wp-red); }
.wp-ctxmenu__item--danger.wp-ctxmenu__item--active,
.wp-ctxmenu__item--danger:hover { background: var(--wp-red-bg); color: var(--wp-red); }
.wp-ctxmenu__item--disabled {
color: var(--wp-text3);
cursor: not-allowed;
}
.wp-ctxmenu__item--disabled:hover { background: transparent; color: var(--wp-text3); }
.wp-ctxmenu__icon,
.wp-ctxmenu__icon-spacer {
width: 14px;
font-size: 11px;
text-align: center;
flex-shrink: 0;
}
.wp-ctxmenu__label { flex: 1; }
.wp-ctxmenu__sep {
height: 1px;
background: var(--wp-border);
margin: 4px 0;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
withDefaults(defineProps<{ size?: number }>(), { size: 28 });
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1024 1024"
:width="size"
:height="size"
role="img"
aria-label="Wildcard Pipeline"
class="wp-logo"
>
<defs>
<linearGradient id="wp-logo-grad-1" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#a970ff" />
<stop offset="1" stop-color="#6366f1" />
</linearGradient>
<linearGradient id="wp-logo-grad-2" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6366f1" />
<stop offset="1" stop-color="#33d6c6" />
</linearGradient>
</defs>
<path fill="url(#wp-logo-grad-1)" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z" />
<path fill="url(#wp-logo-grad-2)" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z" />
<path fill="#a970ff" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z" />
<path fill="#6366f1" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z" />
<path fill="#33d6c6" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z" />
</svg>
</template>
<style scoped>
.wp-logo { display: block; flex-shrink: 0; }
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<Teleport to="body">
<div v-if="visible" class="wp-modal-overlay" @click="$emit('close')">
<div class="wp-modal-wrapper" role="dialog" aria-modal="true" @click.stop>
<slot></slot>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { watch, onBeforeUnmount } from "vue";
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{ (e: "close"): void }>();
function onKeydown(event: KeyboardEvent) {
if (props.visible && event.key === "Escape") {
event.preventDefault();
emit("close");
}
}
watch(
() => props.visible,
(visible) => {
if (visible) window.addEventListener("keydown", onKeydown);
else window.removeEventListener("keydown", onKeydown);
},
{ immediate: true },
);
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
</script>
<style>
@import "./theme.css";
</style>
<style scoped>
.wp-modal-overlay,
.wp-modal-overlay * {
box-sizing: border-box;
}
.wp-modal-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.62);
font-family: var(--wp-font-sans, sans-serif);
cursor: default;
}
.wp-modal-wrapper {
display: flex;
justify-content: center;
width: 100%;
max-height: 100%;
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";
import { toasts, dismissToast, type Toast } from "./toast-store";
function severityIcon(s: Toast["severity"]): string {
if (s === "success") return "pi-check-circle";
if (s === "warning") return "pi-exclamation-triangle";
if (s === "error") return "pi-times-circle";
return "pi-info-circle";
}
function pickAction(t: Toast) {
t.action?.onSelect();
dismissToast(t.id);
}
// Anchor the stack relative to ComfyUI's graph canvas container, matching
// the native PrimeVue Toast positioning. Reading GlobalToast.js: top = rect.top
// + 100px (clears the menu bar), right = innerWidth - rect.right + 20px (sits
// just inside the canvas right edge). Updates on resize + canvas mutations.
const stackTop = ref<number>(56);
const stackRight = ref<number>(16);
function updatePosition() {
const canvas = document.querySelector<HTMLElement>(".graph-canvas-container");
if (!canvas) return;
const r = canvas.getBoundingClientRect();
stackTop.value = r.top + 100;
stackRight.value = Math.max(8, window.innerWidth - (r.left + r.width) + 20);
}
let observer: ResizeObserver | null = null;
onMounted(() => {
updatePosition();
window.addEventListener("resize", updatePosition);
// Recompute when the canvas resizes (sidebar toggle, panel resize, etc.).
const canvas = document.querySelector(".graph-canvas-container");
if (canvas && typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(updatePosition);
observer.observe(canvas);
}
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updatePosition);
observer?.disconnect();
});
</script>
<template>
<Teleport to="body">
<TransitionGroup
tag="div"
name="wp-toast"
class="wp-toast-stack"
:style="{ top: `${stackTop}px`, right: `${stackRight}px` }"
aria-live="polite"
>
<div
v-for="t in toasts"
:key="t.id"
class="wp-toast"
:class="`wp-toast--${t.severity}`"
role="status"
>
<i :class="['pi', severityIcon(t.severity), 'wp-toast__icon']" aria-hidden="true"></i>
<span class="wp-toast__msg">{{ t.message }}</span>
<button
v-if="t.action"
type="button"
class="wp-toast__action"
@click="pickAction(t)"
>{{ t.action.label }}</button>
<button
type="button"
class="wp-toast__close"
aria-label="Dismiss"
@click="dismissToast(t.id)"
><i class="pi pi-times" aria-hidden="true"></i></button>
</div>
</TransitionGroup>
</Teleport>
</template>
<style>
@import "./theme.css";
</style>
<style scoped>
.wp-toast-stack {
position: fixed;
/* top + right set inline so we mirror ComfyUI's native toast anchor —
* relative to .graph-canvas-container so sidebars don't push us off. */
z-index: 10001;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
max-width: min(420px, calc(100vw - 32px));
pointer-events: none;
}
.wp-toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-left: 3px solid var(--wp-accent);
border-radius: var(--wp-radius-sm);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
font-family: var(--wp-font-sans, sans-serif);
font-size: 12px;
color: var(--wp-text);
}
.wp-toast--success { border-left-color: var(--wp-green); }
.wp-toast--warning { border-left-color: var(--wp-amber); }
.wp-toast--error { border-left-color: var(--wp-red); }
.wp-toast__icon {
font-size: 14px;
flex-shrink: 0;
}
.wp-toast--info .wp-toast__icon { color: var(--wp-accent); }
.wp-toast--success .wp-toast__icon { color: var(--wp-green); }
.wp-toast--warning .wp-toast__icon { color: var(--wp-amber); }
.wp-toast--error .wp-toast__icon { color: var(--wp-red); }
.wp-toast__msg {
flex: 1;
min-width: 0;
}
.wp-toast__action {
background: var(--wp-accent-glow);
border: 1px solid var(--wp-accent);
border-radius: var(--wp-radius-sm);
color: var(--wp-accent);
font-family: var(--wp-font-sans, sans-serif);
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
cursor: pointer;
transition: filter 0.12s;
flex-shrink: 0;
}
.wp-toast__action:hover { filter: brightness(1.2); }
.wp-toast__close {
background: none;
border: none;
color: var(--wp-text3);
font-size: 12px;
cursor: pointer;
padding: 2px 4px;
flex-shrink: 0;
}
.wp-toast__close:hover { color: var(--wp-text); }
/* Slide-in from the right; instant out for snappiness. */
.wp-toast-enter-active { transition: opacity 0.2s, transform 0.2s; }
.wp-toast-enter-from { opacity: 0; transform: translateX(20px); }
.wp-toast-move { transition: transform 0.2s ease-out; }
</style>
+53
View File
@@ -0,0 +1,53 @@
/* Accessibility overrides driven by ComfyUI settings (see
* src/extension/settings.ts). The `.wp-a11y-*` markers are toggled on
* <body>; rules here scope the override to our own widgets so we don't
* mutate ComfyUI core or other extensions. */
/* ── Reduce motion ──────────────────────────────────────────────────── */
.wp-a11y-no-motion .wp-context,
.wp-a11y-no-motion .wp-context *,
.wp-a11y-no-motion .wp-asm,
.wp-a11y-no-motion .wp-asm *,
.wp-a11y-no-motion .wp-toast-stack,
.wp-a11y-no-motion .wp-toast-stack *,
.wp-a11y-no-motion .wp-emptystate,
.wp-a11y-no-motion .wp-emptystate *,
.wp-a11y-no-motion .wp-medit,
.wp-a11y-no-motion .wp-medit *,
.wp-a11y-no-motion .wp-picker,
.wp-a11y-no-motion .wp-picker * {
/* Keep just enough duration that Vue's transitionend listeners still
* fire and cleanly tear down — instant 0ms can confuse them. */
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
/* ── High contrast — bump border + text tones, thicken focus outlines ── */
.wp-a11y-high-contrast {
--wp-border: #6e6e6e;
--wp-border2: #9a9a9a;
--wp-text2: #e6e6e6;
--wp-text3: #b0b0b0;
--wp-text4: #c5c5c5;
}
.wp-a11y-high-contrast .wp-module,
.wp-a11y-high-contrast .wp-recovery,
.wp-a11y-high-contrast .wp-asm__rendered,
.wp-a11y-high-contrast .wp-recovery__btn,
.wp-a11y-high-contrast .wp-emptystate__cta,
.wp-a11y-high-contrast .wp-add-btn,
.wp-a11y-high-contrast .wp-toast {
border-width: 2px !important;
}
.wp-a11y-high-contrast .wp-module:focus,
.wp-a11y-high-contrast .wp-module:focus-visible,
.wp-a11y-high-contrast .wp-emptystate__cta:focus-visible,
.wp-a11y-high-contrast .wp-add-btn:focus-visible {
outline: 3px solid var(--wp-violet) !important;
outline-offset: 2px;
}
.wp-a11y-high-contrast .wp-toggle-mark {
border-width: 2px !important;
}
+57
View File
@@ -0,0 +1,57 @@
/* Theme tokens shared across WP widgets.
* Brand palette pinned to public/images/favicon.svg — indigo / violet / teal.
*/
:root {
/* Backgrounds — match ComfyUI's node interior */
--wp-bg: #1e1e1e;
--wp-bg2: #232323;
--wp-bg3: #2a2a2a;
--wp-bg4: #333;
/* Borders */
--wp-border: #3a3a3a;
--wp-border2: #4a4a4a;
/* Text — four tiers from primary to ghost.
* text3 stays #666 to preserve every existing usage; text4 #888 is the
* new "slightly brighter ghost" available for cases that need a bit
* more readability without bumping all the way to text2. */
--wp-text: #ddd;
--wp-text2: #aaa;
--wp-text3: #666;
--wp-text4: #888;
/* Brand — logo palette (favicon.svg) */
--wp-accent: #6366f1; /* indigo, primary brand */
--wp-accent2: #a970ff; /* violet, hover / second tier */
--wp-accent-glow: rgba(99, 102, 241, 0.15);
--wp-violet: #a970ff; /* alias for accent2 — used by module-type tags */
--wp-violet-bg: rgba(169, 112, 255, 0.12);
--wp-teal: #33d6c6; /* exact logo teal */
--wp-teal-bg: rgba(51, 214, 198, 0.12);
--wp-brand-gradient: linear-gradient(135deg, #6366f1, #33d6c6);
/* Semantic — crisper red/amber/green for sharper alerting on dark */
--wp-green: #6bc96f;
--wp-green-bg: rgba(107, 201, 111, 0.12);
--wp-amber: #fbbf24;
--wp-amber-bg: rgba(251, 191, 36, 0.12);
--wp-red: #f87171;
--wp-red-bg: rgba(248, 113, 113, 0.12);
/* Module-type colors — one hue per type so the icon alone identifies the
* module at a glance. fixed_values uses rose because it's a tag/label
* module; the other types reuse brand colors per the type-mapping spec. */
--wp-rose: #f472b6;
--wp-rose-bg: rgba(244, 114, 182, 0.12);
/* Geometry */
--wp-radius: 6px;
--wp-radius-sm: 4px;
/* Typography — bundled webfonts (Inter + JetBrains Mono) loaded via
* src/extension/fonts.ts. System stacks remain as fallback so the UI
* stays readable if the woff2 files fail to load. */
--wp-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--wp-font-mono: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
}
+44
View File
@@ -0,0 +1,44 @@
import { ref } from "vue";
export type ToastSeverity = "info" | "success" | "warning" | "error";
export interface Toast {
id: number;
message: string;
severity: ToastSeverity;
/** Optional inline action — e.g. "Undo" with a callback. */
action?: { label: string; onSelect: () => void };
/** Auto-dismiss after this many ms. 0 = sticky (must be dismissed manually). */
lifeMs: number;
createdAt: number;
}
let nextId = 1;
export const toasts = ref<Toast[]>([]);
interface PushToastOptions {
severity?: ToastSeverity;
action?: Toast["action"];
lifeMs?: number;
}
export function pushToast(message: string, options: PushToastOptions = {}): number {
const id = nextId++;
const t: Toast = {
id,
message,
severity: options.severity ?? "info",
action: options.action,
lifeMs: options.lifeMs ?? 5000,
createdAt: Date.now(),
};
toasts.value = [...toasts.value, t];
if (t.lifeMs > 0) {
window.setTimeout(() => dismissToast(id), t.lifeMs);
}
return id;
}
export function dismissToast(id: number): void {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 1024 1024" width="1024" height="1024"><defs>
<style>
.cls-1 {
fill: url(#linear-gradient-2);
}
.cls-2 {
fill: #6366f1;
}
.cls-3 {
fill: #33d6c6;
}
.cls-4 {
fill: #a970ff;
}
.cls-5 {
fill: url(#linear-gradient);
}
</style>
<linearGradient id="linear-gradient" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#a970ff"></stop>
<stop offset="1" stop-color="#6366f1"></stop>
</linearGradient>
<linearGradient id="linear-gradient-2" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6366f1"></stop>
<stop offset="1" stop-color="#33d6c6"></stop>
</linearGradient>
</defs><path class="cls-5" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z"></path><path class="cls-1" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z"></path><g>
<path class="cls-4" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z"></path>
<path class="cls-2" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
<path class="cls-3" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
</g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+7 -1
View File
@@ -2,6 +2,12 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
// `Record<string, never>` instead of `{}` — same intent (no defined props
// to constrain the SFC default-export typing) but doesn't trip
// typescript-eslint's no-empty-object-type rule.
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}
// Side-effect CSS imports (cssInjectedByJsPlugin handles runtime injection).
declare module "*.css";
+75
View File
@@ -0,0 +1,75 @@
// Single ComfyUI About-panel badge for the extension. Other extensions
// (rgthree-comfy, EasyUse, ComfyUI-Manager, …) follow the convention
// "<extension name> v<version>" with monochrome icons that pick up the
// panel's text color. We mirror that so the row blends with surrounding
// badges instead of standing out as a colored sticker.
//
// The icon slot accepts a CSS class name; PrimeIcons (`pi pi-github`)
// work out of the box but here we register a custom rule that uses our
// favicon SVG as a CSS mask — `background-color: currentColor` then
// fills it with whatever color the panel applies to the icon, so the
// shape stays branded but the tone matches surrounding badges.
//
// Docs: https://docs.comfy.org/custom-nodes/js/javascript_about_panel_badges
// `?raw` returns the file contents as a string — Vite inlines into the
// JS chunk so we don't depend on a runtime asset URL. Stays self-contained.
import wpLogoSvg from "../components/shared/wp-logo.svg?raw";
import pkg from "../../package.json" with { type: "json" };
export interface AboutPageBadge {
label: string;
url: string;
/** CSS class — PrimeIcons or our own. */
icon: string;
}
const REPO = "https://github.com/DumiFlex/ComfyUI-WildcardPipeline";
const ICON_CLASS = "wp-about-badge-icon";
let styleInjected = false;
/**
* Inject the icon-class rule once. Idempotent — safe to call from main
* boot even though we only register a single badge today.
*/
function ensureIconStyle(): void {
if (styleInjected || typeof document === "undefined") return;
styleInjected = true;
const dataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(wpLogoSvg)}")`;
const style = document.createElement("style");
style.id = "wp-about-badge-style";
// mask-image renders the SVG silhouette only; background-color fills
// it with currentColor so the icon picks up the panel's badge text
// color and matches the surrounding monochrome icons (pi pi-github,
// pi pi-discord, etc.). 14px square matches PrimeIcons sizing on the
// About panel badges. -webkit-* prefixed properties cover Safari /
// older WebKit which haven't standardized the unprefixed names yet.
style.textContent = `
.${ICON_CLASS} {
display: inline-block;
width: 14px;
height: 14px;
background-color: currentColor;
mask-image: ${dataUri};
-webkit-mask-image: ${dataUri};
mask-size: contain;
-webkit-mask-size: contain;
mask-repeat: no-repeat;
-webkit-mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-position: center;
vertical-align: -2px;
}
`;
document.head.appendChild(style);
}
ensureIconStyle();
export const ABOUT_BADGES: AboutPageBadge[] = [
{
label: `Wildcard Pipeline v${pkg.version}`,
url: REPO,
icon: ICON_CLASS,
},
];
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { scanConflicts } from "./conflicts";
import type { ContextWidgetValue } from "../widgets/_shared";
const mod = (id: string, vars: string[]): ContextWidgetValue["modules"][number] => ({
id, type: "fixed_values", enabled: true, meta: { name: "" },
entries: vars.map((v) => ({ variable_name: v, value: "x" })),
});
describe("scanConflicts", () => {
it("flags shadows_upstream (info) when a module writes a var present upstream", () => {
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["style"])] };
const out = scanConflicts(value, ["style"]);
expect(out).toEqual([{ moduleId: "m1", variable: "style", type: "shadows_upstream", severity: "info" }]);
});
it("flags duplicate_variable (warning) when two modules in same node both write the same var", () => {
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["style"]), mod("m2", ["style"])] };
const out = scanConflicts(value, []);
expect(out).toEqual([{ moduleId: "m2", variable: "style", type: "duplicate_variable", severity: "warning" }]);
});
it("upstream shadow wins over in-node duplicate (mutually exclusive)", () => {
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["style"]), mod("m2", ["style"])] };
const out = scanConflicts(value, ["style"]);
expect(out.every((c) => c.type === "shadows_upstream")).toBe(true);
expect(out).toHaveLength(2);
});
it("does not flag unrelated names — `sty` does not shadow `style`", () => {
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["sty"])] };
expect(scanConflicts(value, ["style"])).toEqual([]);
});
});
+53
View File
@@ -0,0 +1,53 @@
import type { ContextWidgetValue } from "../widgets/_shared";
// `shadows_upstream` — informational: this module overrides a value that
// already exists upstream. Last-write-wins is the *intended* runtime
// behavior, so this is a low-key hint, not an error.
// `duplicate_variable` — warning: two enabled modules in the SAME node both
// write the same name. Almost always a bug in this node's local config.
// `missing_template_variable` — warning: assembler template references a
// variable nothing upstream provides; it'll render literally as `$name`.
export type ConflictType = "shadows_upstream" | "duplicate_variable" | "missing_template_variable";
export type Severity = "info" | "warning" | "error";
export interface Conflict {
moduleId: string;
variable: string;
type: ConflictType;
severity: Severity;
}
export function scanConflicts(value: ContextWidgetValue, upstreamVars: string[]): Conflict[] {
const upstream = new Set(upstreamVars);
const written = new Set<string>();
const out: Conflict[] = [];
for (const m of value.modules) {
if (!m.enabled) continue;
for (const e of m.entries) {
const name = e.variable_name.replace(/^\$/, "").trim();
if (!name) continue;
if (upstream.has(name)) {
// Intended override — surface it but don't scream.
out.push({ moduleId: m.id, variable: name, type: "shadows_upstream", severity: "info" });
} else if (written.has(name)) {
out.push({ moduleId: m.id, variable: name, type: "duplicate_variable", severity: "warning" });
} else {
written.add(name);
}
}
}
return out;
}
const TEMPLATE_VAR = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
export function scanTemplateConflicts(template: string, knownVars: string[]): Conflict[] {
const known = new Set(knownVars);
const out: Conflict[] = [];
for (const m of template.matchAll(TEMPLATE_VAR)) {
const v = m[1];
if (!known.has(v)) {
out.push({ moduleId: "", variable: v, type: "missing_template_variable", severity: "warning" });
}
}
return out;
}
+50
View File
@@ -0,0 +1,50 @@
// 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/...`.
//
// Trade-off vs. fontsource @import:
// - Single latin subset only — no cyrillic/greek/vietnamese coverage.
// If a future module entry uses non-latin glyphs they'll fall back
// to the system stack, which is acceptable for the dev tool surface.
// - 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;
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);
}
ensureFontFaces();
+14
View File
@@ -0,0 +1,14 @@
// Vanilla-JS event registry shared between main.ts (publisher) and the Vue
// widget chunks (subscribers). Lives in its own module so importing it from
// main.ts doesn't drag Vue into the entry bundle.
const graphLoadListeners = new Set<() => void>();
export function onGraphLoaded(listener: () => void): () => void {
graphLoadListeners.add(listener);
return () => graphLoadListeners.delete(listener);
}
export function notifyGraphLoaded(): void {
for (const fn of graphLoadListeners) fn();
}
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect } from "vitest";
import {
collectUpstreamVariables,
findDownstreamAssemblers,
type LiteGraphLike,
type LiteNodeLike,
} from "./graph";
function fakeContextNode(id: number, vars: string[], upstreamLink?: number): LiteNodeLike {
return {
id,
type: "WP_Context",
inputs: [{ name: "upstream", link: upstreamLink ?? null }],
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
widgets: [{
name: "modules",
value: JSON.stringify({
version: 1,
modules: [{
id: "a",
type: "fixed_values",
enabled: true,
meta: { name: "" },
entries: vars.map((v) => ({ variable_name: v, value: "x" })),
}],
}),
}],
};
}
describe("collectUpstreamVariables", () => {
it("returns the upstream chain's writes only — never the starting node's own", () => {
const a = fakeContextNode(1, ["style"]);
const b = fakeContextNode(2, ["mood"], 100);
const graph: LiteGraphLike = {
_nodes: [a, b],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 1: a, 2: b } as Record<number, LiteNodeLike>)[id] ?? null,
};
// From b's POV: only "style" comes from upstream; b's own "mood" is not included.
expect(collectUpstreamVariables(graph, b)).toEqual(["style"]);
});
it("returns empty when no upstream link, regardless of own writes", () => {
const a = fakeContextNode(1, ["style"]);
const graph: LiteGraphLike = { _nodes: [a], links: {}, getNodeById: () => null };
expect(collectUpstreamVariables(graph, a)).toEqual([]);
});
it("walks via assembler's `context` input too — any PIPELINE_CONTEXT slot works", () => {
const ctx = fakeContextNode(1, ["style", "subject"]);
const asm: LiteNodeLike = {
id: 2,
type: "WP_PromptAssembler",
// Note: input is named "context", not "upstream".
inputs: [{ name: "context", link: 300 }],
};
const graph: LiteGraphLike = {
_nodes: [ctx, asm],
links: { 300: { id: 300, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
};
expect(collectUpstreamVariables(graph, asm).sort()).toEqual(["style", "subject"]);
});
});
describe("findDownstreamAssemblers", () => {
it("returns assembler ids reachable via PIPELINE_CONTEXT outgoing links", () => {
const ctx: LiteNodeLike = {
id: 1,
type: "WP_Context",
outputs: [{ name: "context", links: [200], type: "PIPELINE_CONTEXT" }],
};
const asm: LiteNodeLike = { id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 200 }] };
const graph: LiteGraphLike = {
_nodes: [ctx, asm],
links: { 200: { id: 200, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
};
expect(findDownstreamAssemblers(graph, ctx).map((n) => n.id)).toEqual([2]);
});
});
// ── Subgraph boundary traversal ────────────────────────────────────────
// The chain crosses litegraph subgraph boundaries via two proxy nodes:
// - SubgraphInputNode (id = -10) — boundary inside the subgraph that
// proxies external inputs.
// - SubgraphOutputNode (id = -20) — boundary inside the subgraph that
// proxies external outputs.
// We model both shapes here and assert the walk doesn't get stuck on either.
describe("collectUpstreamVariables across subgraph boundaries", () => {
it("steps OUT of a subgraph to reach an upstream Context in the parent graph", () => {
// Layout:
// root: ctx(1) ──[100]──▶ subNode(10)
// inside subNode.subgraph: -10 ──[200]──▶ asm(2)
// subNode's "ctx" input maps by name to the inputNode's "ctx" slot.
const ctx = fakeContextNode(1, ["style"]);
ctx.outputs = [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }];
const innerAsm: LiteNodeLike = {
id: 2,
type: "WP_PromptAssembler",
inputs: [{ name: "context", link: 200 }],
};
const subgraph: LiteGraphLike = {
id: "sg-1",
_nodes: [innerAsm],
links: { 200: { id: 200, origin_id: -10, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 2: innerAsm } as Record<number, LiteNodeLike>)[id] ?? null,
inputNode: { id: -10, slots: [{ name: "ctx", linkIds: [200] }] },
outputNode: { id: -20, slots: [] },
};
innerAsm.graph = subgraph;
const subNode: LiteNodeLike = {
id: 10,
type: "Subgraph",
inputs: [{ name: "ctx", link: 100 }],
outputs: [{ name: "ctx_out", links: [], type: "PIPELINE_CONTEXT" }],
subgraph,
isSubgraphNode: () => true,
};
const root: LiteGraphLike = {
_nodes: [ctx, subNode],
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 10, target_slot: 0 } },
getNodeById: (id) => ({ 1: ctx, 10: subNode } as Record<number, LiteNodeLike>)[id] ?? null,
};
ctx.graph = root;
subNode.graph = root;
expect(collectUpstreamVariables(root, innerAsm)).toEqual(["style"]);
});
it("steps INTO a subgraph when the upstream Context lives inside one", () => {
// Layout:
// inside subgraph: ctx(1) ──[200]──▶ -20 (output proxy, slot "ctx_out")
// root: subNode(10) ──[300]──▶ asm(2)
const innerCtx = fakeContextNode(1, ["style"]);
innerCtx.outputs = [{ name: "context", links: [200], type: "PIPELINE_CONTEXT" }];
const subgraph: LiteGraphLike = {
id: "sg-1",
_nodes: [innerCtx],
links: { 200: { id: 200, origin_id: 1, origin_slot: 0, target_id: -20, target_slot: 0 } },
getNodeById: (id) => ({ 1: innerCtx } as Record<number, LiteNodeLike>)[id] ?? null,
inputNode: { id: -10, slots: [] },
outputNode: { id: -20, slots: [{ name: "ctx_out", linkIds: [200] }] },
};
innerCtx.graph = subgraph;
const subNode: LiteNodeLike = {
id: 10,
type: "Subgraph",
inputs: [],
outputs: [{ name: "ctx_out", links: [300], type: "PIPELINE_CONTEXT" }],
subgraph,
isSubgraphNode: () => true,
};
const asm: LiteNodeLike = {
id: 2,
type: "WP_PromptAssembler",
inputs: [{ name: "context", link: 300 }],
};
const root: LiteGraphLike = {
_nodes: [subNode, asm],
links: { 300: { id: 300, origin_id: 10, origin_slot: 0, target_id: 2, target_slot: 0 } },
getNodeById: (id) => ({ 10: subNode, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
};
subNode.graph = root;
asm.graph = root;
expect(collectUpstreamVariables(root, asm)).toEqual(["style"]);
});
});
+403
View File
@@ -0,0 +1,403 @@
import { parseWidgetJson, type ContextWidgetValue } from "../widgets/_shared";
// ── Subgraph boundary primer ────────────────────────────────────────────
// ComfyUI subgraphs are nested LGraph instances attached to a parent node
// via `node.subgraph`. They have boundary proxy nodes:
// - SubgraphInputNode (id = -10) — sits inside the subgraph; inner nodes
// wire INPUTs from it. Each slot maps by name to the parent SubgraphNode's
// external input slot.
// - SubgraphOutputNode (id = -20) — sits inside the subgraph; inner nodes
// wire OUTPUTs into it. Each slot maps by name to the parent SubgraphNode's
// external output slot.
// - The IO nodes are NOT in `subgraph._nodes` — only on `subgraph.inputNode`
// / `subgraph.outputNode`. Inner links can still reference them by id.
// To traverse the PIPELINE_CONTEXT chain across boundaries we transparently
// "step in" through SubgraphOutputNode and "step out" through SubgraphInputNode.
const SUBGRAPH_INPUT_NODE_ID = -10;
const SUBGRAPH_OUTPUT_NODE_ID = -20;
const BOUNDARY_RECURSION_LIMIT = 32;
export interface LiteLink {
id: number;
origin_id: number;
origin_slot: number;
target_id: number;
target_slot: number;
}
export interface SubgraphIOSlot {
name: string;
type?: string;
/** Inner-graph link ids touching this slot. */
linkIds?: number[];
}
export interface SubgraphIONode {
id: number;
slots: SubgraphIOSlot[];
}
export interface LiteNodeLike {
id: number;
type: string;
inputs?: { name: string; link: number | null; type?: string }[];
outputs?: { name: string; links?: number[] | null; type?: string }[];
widgets?: { name: string; value: unknown }[];
/** Inner LGraph for SubgraphNodes. Mirrors `node.subgraph` from litegraph. */
subgraph?: LiteGraphLike;
/** Litegraph SubgraphNode marker. Also detected via `subgraph != null`. */
isSubgraphNode?(): boolean;
/** Containing LGraph — set by litegraph on every node that's been added. */
graph?: LiteGraphLike;
}
/**
* Litegraph LGraph surface we touch. Both root and subgraph instances satisfy
* this. `id` is undefined on the root graph and a UUID on subgraphs.
*/
export interface LiteGraphLike {
id?: string;
_nodes: LiteNodeLike[];
/** In current litegraph this is a Proxy over a Map; bracket access works. */
links: Record<number, LiteLink>;
getNodeById(id: number): LiteNodeLike | null;
/** Boundary IO nodes — only populated on subgraph LGraphs. */
inputNode?: SubgraphIONode;
outputNode?: SubgraphIONode;
}
function widgetValue(node: LiteNodeLike, name: string): string {
const w = node.widgets?.find((x) => x.name === name);
return typeof w?.value === "string" ? w.value : "";
}
function moduleWrites(node: LiteNodeLike): string[] {
const v = parseWidgetJson<ContextWidgetValue>(widgetValue(node, "modules"), { version: 1, modules: [] });
const out: string[] = [];
for (const m of v.modules) {
if (!m.enabled) continue;
for (const e of m.entries) {
const name = e.variable_name.replace(/^\$/, "").trim();
if (name) out.push(name);
}
}
return out;
}
function isSubgraphNode(n: LiteNodeLike): boolean {
if (typeof n.isSubgraphNode === "function") {
try { return n.isSubgraphNode() === true; } catch { /* fall through */ }
}
return n.subgraph != null;
}
/** Containing LGraph — falls back to passed graph when litegraph hasn't set it. */
function graphOf(node: LiteNodeLike, fallback: LiteGraphLike): LiteGraphLike {
return node.graph ?? fallback;
}
type SubgraphParent = { node: LiteNodeLike; graph: LiteGraphLike };
/**
* Walk root → leaves once and record each subgraph's parent SubgraphNode +
* the LGraph that contains it. Used to step OUT through SubgraphInputNode
* boundaries: given a subgraph id, we know which outer node + graph to
* resume traversal from.
*/
function buildSubgraphParents(root: LiteGraphLike): Map<string, SubgraphParent> {
const out = new Map<string, SubgraphParent>();
function descend(g: LiteGraphLike) {
for (const n of g._nodes ?? []) {
const sg = n.subgraph;
if (sg && sg.id) {
out.set(sg.id, { node: n, graph: g });
descend(sg);
}
}
}
descend(root);
return out;
}
interface BoundaryStep {
graph: LiteGraphLike;
node: LiteNodeLike;
/** Output slot index on `node` we just arrived through. */
originSlot: number;
}
/**
* Step INTO a SubgraphNode: given the outer SubgraphNode and the output slot
* we're walking against, find the inner producer feeding that output via the
* SubgraphOutputNode slot of the same name.
*/
function stepIntoSubgraph(subNode: LiteNodeLike, originSlot: number): BoundaryStep | null {
const out = subNode.outputs?.[originSlot];
const subgraph = subNode.subgraph;
const outputNode = subgraph?.outputNode;
if (!out || !subgraph || !outputNode) return null;
// Slot names match between the outer SubgraphNode and the inner
// SubgraphOutputNode — that's how litegraph routes the boundary.
const slot = outputNode.slots.find((s) => s.name === out.name);
if (!slot || !slot.linkIds?.length) return null;
// Take the first wired inner link. PIPELINE_CONTEXT is a single-source
// chain so multi-write at this boundary would be a graph error anyway.
const link = subgraph.links[slot.linkIds[0]];
if (!link) return null;
const inner = subgraph.getNodeById(link.origin_id);
if (!inner) return null;
return { graph: subgraph, node: inner, originSlot: link.origin_slot };
}
/**
* Step OUT through a SubgraphInputNode: given the inner LGraph and the
* input-slot index on its inputNode that the link arrived from, resolve the
* matching named input on the parent SubgraphNode and follow ITS link in
* the parent graph.
*/
function stepOutOfSubgraph(
innerGraph: LiteGraphLike,
inputNodeSlot: number,
parents: Map<string, SubgraphParent>,
): BoundaryStep | null {
if (!innerGraph.id) return null;
const parent = parents.get(innerGraph.id);
const inputNode = innerGraph.inputNode;
if (!parent || !inputNode) return null;
const innerSlot = inputNode.slots[inputNodeSlot];
if (!innerSlot) return null;
// Find the parent SubgraphNode's input slot with the same name.
const outerSlot = parent.node.inputs?.find((s) => s.name === innerSlot.name);
if (!outerSlot || outerSlot.link == null) return null;
const outerLink = parent.graph.links[outerSlot.link];
if (!outerLink) return null;
const outer = parent.graph.getNodeById(outerLink.origin_id);
if (!outer) return null;
return { graph: parent.graph, node: outer, originSlot: outerLink.origin_slot };
}
/**
* Resolve a (graph, node, originSlot) tuple through any number of stacked
* subgraph boundaries until it points at a real producer (not an IO proxy
* and not a SubgraphNode wrapper). Returns null if any hop fails.
*/
function resolveThroughBoundaries(
start: BoundaryStep,
parents: Map<string, SubgraphParent>,
): BoundaryStep | null {
let cur: BoundaryStep | null = start;
for (let i = 0; cur && i < BOUNDARY_RECURSION_LIMIT; i++) {
if (cur.node.id === SUBGRAPH_INPUT_NODE_ID) {
cur = stepOutOfSubgraph(cur.graph, cur.originSlot, parents);
continue;
}
if (isSubgraphNode(cur.node)) {
cur = stepIntoSubgraph(cur.node, cur.originSlot);
continue;
}
return cur;
}
return null;
}
/**
* Find the upstream PIPELINE_CONTEXT producer for `node`, transparently
* stepping through subgraph boundaries. Returns the node WITH its containing
* graph since the chain can leave the starting subgraph.
*/
function pipelineUpstreamOf(
node: LiteNodeLike,
graph: LiteGraphLike,
parents: Map<string, SubgraphParent>,
): { graph: LiteGraphLike; node: LiteNodeLike } | null {
for (const slot of node.inputs ?? []) {
if (slot.link == null) continue;
const link = graph.links[slot.link];
if (!link) continue;
let step: BoundaryStep | null;
if (link.origin_id === SUBGRAPH_INPUT_NODE_ID) {
// Boundary at this very link — step OUT before we can even read origin.
step = stepOutOfSubgraph(graph, link.origin_slot, parents);
} else {
const origin = graph.getNodeById(link.origin_id);
if (!origin) continue;
step = { graph, node: origin, originSlot: link.origin_slot };
}
const resolved = step ? resolveThroughBoundaries(step, parents) : null;
if (!resolved) continue;
// Confirm we landed on a PIPELINE_CONTEXT producer slot.
const out = resolved.node.outputs?.[resolved.originSlot];
if (out?.type === "PIPELINE_CONTEXT") return { graph: resolved.graph, node: resolved.node };
}
return null;
}
/**
* Walk upstream from `node` collecting variable names written by every
* upstream WP_Context. Does NOT include the starting node's own writes — the
* caller (a Context widget rendering its own conflict UI) wants to know what
* is coming IN from upstream so it can compare against its own writes.
*
* Crosses subgraph boundaries transparently: if the upstream Context lives
* outside a subgraph the starting node is in, we step OUT through the
* SubgraphInputNode proxy; if the upstream chain crosses INTO a subgraph,
* we step IN through the SubgraphOutputNode proxy.
*
* `rootGraph` should be `app.graph` (top-level). The starting node may live
* anywhere in the nested graph tree.
*/
export function collectUpstreamVariables(rootGraph: LiteGraphLike, node: LiteNodeLike): string[] {
const parents = buildSubgraphParents(rootGraph);
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
const vars = new Set<string>();
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
while (cur && !seen.has(locator(cur.graph, cur.node))) {
seen.add(locator(cur.graph, cur.node));
if (cur.node.type === "WP_Context") moduleWrites(cur.node).forEach((v) => vars.add(v));
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
}
return Array.from(vars);
}
/**
* Walk upstream collecting (variable → value) pairs. Last-write-wins matches
* runtime semantics: chain order is upstream-first → downstream-last; we
* reverse the walk so downstream values override upstream ones in the map.
*
* Crosses subgraph boundaries identically to `collectUpstreamVariables`.
*/
export function collectUpstreamValues(rootGraph: LiteGraphLike, node: LiteNodeLike): Record<string, string> {
const parents = buildSubgraphParents(rootGraph);
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
const chain: LiteNodeLike[] = [];
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
while (cur && !seen.has(locator(cur.graph, cur.node))) {
seen.add(locator(cur.graph, cur.node));
chain.push(cur.node);
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
}
const out: Record<string, string> = {};
for (let i = chain.length - 1; i >= 0; i--) {
const n = chain[i];
if (n.type !== "WP_Context") continue;
const v = parseWidgetJson<ContextWidgetValue>(widgetValue(n, "modules"), { version: 1, modules: [] });
for (const m of v.modules) {
if (!m.enabled) continue;
for (const e of m.entries) {
const name = e.variable_name.replace(/^\$/, "").trim();
if (!name) continue;
out[name] = e.value;
}
}
}
return out;
}
/**
* Locator — node ids are unique only within a single LGraph; nested
* subgraphs reuse the same id space. Pair `(graph.id, node.id)` to dedupe
* across the whole nested tree.
*/
function locator(graph: LiteGraphLike, node: LiteNodeLike): string {
return `${graph.id ?? "root"}:${node.id}`;
}
/**
* BFS downstream from `node` along PIPELINE_CONTEXT edges, returning every
* WP_PromptAssembler in reach. Walks through subgraph boundaries: a
* SubgraphNode in the chain is unwrapped by following its inputNode slot
* inside; an outgoing link to the SubgraphOutputNode (target_id === -20)
* resumes from the parent SubgraphNode's matching named output in the
* parent graph.
*/
export function findDownstreamAssemblers(rootGraph: LiteGraphLike, node: LiteNodeLike): LiteNodeLike[] {
const parents = buildSubgraphParents(rootGraph);
const out: LiteNodeLike[] = [];
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
const queue: { graph: LiteGraphLike; node: LiteNodeLike }[] = [
{ graph: graphOf(node, rootGraph), node },
];
while (queue.length) {
const cur = queue.shift();
if (!cur) break;
for (const o of cur.node.outputs ?? []) {
if (o.type !== "PIPELINE_CONTEXT") continue;
for (const linkId of o.links ?? []) {
const link = cur.graph.links[linkId];
if (!link) continue;
const stepped = downstreamStep(cur.graph, link, parents);
if (!stepped) continue;
const key = locator(stepped.graph, stepped.node);
if (seen.has(key)) continue;
seen.add(key);
if (stepped.node.type === "WP_PromptAssembler") out.push(stepped.node);
else queue.push({ graph: stepped.graph, node: stepped.node });
}
}
}
return out;
}
/**
* Resolve a downstream link target through any subgraph boundaries:
* - target_id === -20: link enters the SubgraphOutputNode → resume from
* the parent SubgraphNode's matching output in the parent graph.
* - target is a SubgraphNode: link enters via SubgraphInputNode proxy →
* fan out to each inner consumer (we pick the first; downstream walk
* recurses anyway via the BFS queue).
*/
function downstreamStep(
graph: LiteGraphLike,
link: LiteLink,
parents: Map<string, SubgraphParent>,
): { graph: LiteGraphLike; node: LiteNodeLike } | null {
if (link.target_id === SUBGRAPH_OUTPUT_NODE_ID) {
// Find which output of the parent SubgraphNode this proxies, then resume.
const innerSlot = graph.outputNode?.slots[link.target_slot];
if (!innerSlot || !graph.id) return null;
const parent = parents.get(graph.id);
if (!parent) return null;
return { graph: parent.graph, node: parent.node };
}
const target = graph.getNodeById(link.target_id);
if (!target) return null;
if (isSubgraphNode(target)) {
// Step into subgraph — find SubgraphInputNode slot matching the input
// slot's name and follow its first inner link.
const targetInputName = target.inputs?.[link.target_slot]?.name;
const inputNode = target.subgraph?.inputNode;
if (!targetInputName || !target.subgraph || !inputNode) return null;
const slot = inputNode.slots.find((s) => s.name === targetInputName);
if (!slot || !slot.linkIds?.length) return null;
const innerLink = target.subgraph.links[slot.linkIds[0]];
if (!innerLink) return null;
const inner = target.subgraph.getNodeById(innerLink.target_id);
if (!inner) return null;
return { graph: target.subgraph, node: inner };
}
return { graph, node: target };
}
/**
* Yield every node in `rootGraph` and any nested subgraph, paired with the
* LGraph that contains it. Use for graph-wide scans (e.g. pre-run
* validation finding every WP_PromptAssembler regardless of nesting depth).
*/
export function* walkAllNodes(
rootGraph: LiteGraphLike,
): Generator<{ graph: LiteGraphLike; node: LiteNodeLike }> {
const stack: LiteGraphLike[] = [rootGraph];
while (stack.length) {
const g = stack.pop();
if (!g) break;
for (const n of g._nodes ?? []) {
yield { graph: g, node: n };
if (n.subgraph) stack.push(n.subgraph);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { ref, type Ref, onScopeDispose } from "vue";
import { onGraphLoaded } from "./graph-events";
export { notifyGraphLoaded } from "./graph-events";
/**
* Re-evaluate `compute` whenever the host node's input/output connections
* change. Returns a reactive ref that mirrors the latest computation result.
*
* LiteGraph dispatches `onConnectionsChange(type, slot, connected, link, slot)`
* on each affected node when a wire is added/removed. We chain onto whatever
* was already there so other extensions keep working. We also re-run on a
* coarse polling interval to catch upstream-chain mutations that don't fire
* the local node's callback (e.g. a wire added two nodes upstream).
*
* Workflow-load races: the snapshot computed at first mount sees upstream
* widgets in their pre-restore state. We subscribe to a global "graph just
* fully loaded" signal (fired from main.ts via afterConfigureGraph) so every
* reactive widget re-syncs the moment ComfyUI finishes restoring values —
* no flash of stale state.
*/
export interface ConnectableNode {
onConnectionsChange?: (...args: unknown[]) => void;
}
export function reactiveFromGraph<T>(
node: ConnectableNode,
compute: () => T,
equals: (a: T, b: T) => boolean = Object.is,
pollMs = 400,
): Ref<T> {
const state = ref(compute()) as Ref<T>;
function refresh() {
const next = compute();
if (!equals(state.value, next)) state.value = next;
}
const orig = node.onConnectionsChange;
node.onConnectionsChange = function (...args: unknown[]) {
orig?.apply(this, args);
refresh();
};
// Polling fallback for upstream-chain edits that don't fire on this node.
const interval = window.setInterval(refresh, pollMs);
// Re-sync on workflow load. Several ticks because ComfyUI restores widget
// values asynchronously through cascading callbacks; a single refresh can
// still catch us mid-cascade. requestAnimationFrame chain settles within
// two frames in practice — cheap insurance against a late restore.
const unsubscribe = onGraphLoaded(() => {
refresh();
requestAnimationFrame(() => {
refresh();
requestAnimationFrame(refresh);
});
});
onScopeDispose(() => {
window.clearInterval(interval);
unsubscribe();
node.onConnectionsChange = orig;
});
return state;
}
/** Cheap structural equality for string[] (order matters). */
export function stringArrayEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
+145
View File
@@ -0,0 +1,145 @@
// Settings registered into ComfyUI's settings panel via
// app.registerExtension({ settings: [...] }) — see
// https://docs.comfy.org/custom-nodes/js/javascript_settings
//
// Two accessibility toggles, both defaulting to "auto" so they respect
// OS-level prefers-reduced-motion / prefers-contrast media queries until
// the user overrides them. Each setting writes a class onto <body>; CSS
// targets `.wp-*` descendants of that class to apply or strip the
// matching style.
// Side-effect import — owns the a11y CSS chunk so cssInjectedByJsPlugin
// can attach the runtime injection to a JS module. A bare CSS import
// from main.ts emits a chunk with no JS owner and the plugin warns.
import "../components/shared/a11y.css";
export type A11yMode = "auto" | "on" | "off";
export interface ComfySetting {
id: string;
name: string;
type: "boolean" | "text" | "number" | "slider" | "combo" | "color" | "image" | "hidden";
defaultValue: unknown;
tooltip?: string;
options?: Array<{ text: string; value: string }>;
category?: string[];
onChange?: (newVal: unknown, oldVal: unknown) => void;
}
const SETTING_ID_REDUCE_MOTION = "wildcardPipeline.a11y.reduceMotion";
const SETTING_ID_HIGH_CONTRAST = "wildcardPipeline.a11y.contrast";
const MOTION_OPTIONS = [
{ text: "Match system (prefers-reduced-motion)", value: "auto" },
{ text: "Always reduce", value: "on" },
{ text: "Always allow", value: "off" },
];
const CONTRAST_OPTIONS = [
{ text: "Match system (prefers-contrast)", value: "auto" },
{ text: "High contrast", value: "on" },
{ text: "Standard", value: "off" },
];
interface ExtensionManager {
setting?: { get(id: string): unknown };
}
interface AppLike {
extensionManager?: ExtensionManager;
}
// Module-state holds the current values directly. Updated synchronously
// by onChange callbacks so the marker classes flip without waiting for
// extensionManager.setting.get() to reflect the new value (it can lag
// the onChange fire by a tick, which made settings only "stick" after a
// hard refresh in the live UI).
const state: { reduceMotion: A11yMode; contrast: A11yMode } = {
reduceMotion: "auto",
contrast: "auto",
};
function asMode(v: unknown, fallback: A11yMode): A11yMode {
return v === "on" || v === "off" || v === "auto" ? v : fallback;
}
/**
* Apply current state + matchMedia to the body marker classes. Pure read
* of the in-memory `state` map — no async layer. CSS keys off the markers:
* .wp-a11y-no-motion .wp-* { transition-duration: 0 !important; }
* .wp-a11y-high-contrast { --wp-border: ...; }
*/
function syncMarkers(): void {
const reduceMotion = state.reduceMotion === "on"
|| (state.reduceMotion === "auto" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches);
const highContrast = state.contrast === "on"
|| (state.contrast === "auto" && window.matchMedia?.("(prefers-contrast: more)").matches);
document.body.classList.toggle("wp-a11y-no-motion", !!reduceMotion);
document.body.classList.toggle("wp-a11y-high-contrast", !!highContrast);
}
/**
* Read both settings from extensionManager (used for initial sync at boot)
* and re-apply markers. After this, `onChange` callbacks own state updates
* directly via their newVal arg.
*/
export function applyA11yClasses(app: AppLike): void {
state.reduceMotion = asMode(app.extensionManager?.setting?.get(SETTING_ID_REDUCE_MOTION), "auto");
state.contrast = asMode(app.extensionManager?.setting?.get(SETTING_ID_HIGH_CONTRAST), "auto");
syncMarkers();
}
/**
* Wire matchMedia listeners so "auto" mode reflects OS-level changes
* without requiring a page reload (e.g. user toggles "Reduce motion" in
* macOS System Settings while ComfyUI is open).
*/
export function watchA11ySystemPrefs(): () => void {
const motionMQ = window.matchMedia?.("(prefers-reduced-motion: reduce)");
const contrastMQ = window.matchMedia?.("(prefers-contrast: more)");
const handler = () => syncMarkers();
motionMQ?.addEventListener?.("change", handler);
contrastMQ?.addEventListener?.("change", handler);
return () => {
motionMQ?.removeEventListener?.("change", handler);
contrastMQ?.removeEventListener?.("change", handler);
};
}
export function buildSettings(_app: AppLike): ComfySetting[] {
return [
{
id: SETTING_ID_REDUCE_MOTION,
name: "Reduce motion",
type: "combo",
options: MOTION_OPTIONS,
defaultValue: "auto",
tooltip:
"Disables Wildcard Pipeline animations and transitions. " +
"Match system honors the OS prefers-reduced-motion setting.",
category: ["Wildcard Pipeline", "Accessibility", "Reduce motion"],
// Use newVal directly — extensionManager.setting.get() can lag the
// onChange fire by a tick, which delayed the marker swap until a
// page reload in the live UI.
onChange: (newVal) => {
state.reduceMotion = asMode(newVal, "auto");
syncMarkers();
},
},
{
id: SETTING_ID_HIGH_CONTRAST,
name: "Contrast",
type: "combo",
options: CONTRAST_OPTIONS,
defaultValue: "auto",
tooltip:
"Bumps border + text contrast on Wildcard Pipeline widgets. " +
"Match system honors the OS prefers-contrast setting.",
category: ["Wildcard Pipeline", "Accessibility", "Contrast"],
onChange: (newVal) => {
state.contrast = asMode(newVal, "auto");
syncMarkers();
},
},
];
}
+254
View File
@@ -0,0 +1,254 @@
import { walkAllNodes, collectUpstreamVariables, type LiteGraphLike, type LiteNodeLike } from "./graph";
import { scanConflicts, scanTemplateConflicts, type Conflict, type Severity } from "./conflicts";
import { parseWidgetJson, type ContextWidgetValue } from "../widgets/_shared";
import { onGraphLoaded } from "./graph-events";
// ── Subgraph conflict badge ────────────────────────────────────────────
// Users can drop our nodes inside a subgraph. From the parent graph, the
// SubgraphNode is opaque — opening it is the only way to spot a problem.
// We push an LGraphBadge onto `node.badges` for every SubgraphNode that
// contains WP_Context or WP_PromptAssembler descendants and surface the
// worst severity found in their conflict scan.
//
// Why LGraphBadge and not onDrawForeground:
// - Badges render ABOVE the title bar (drawBadges places them at
// y = -(NODE_TITLE_HEIGHT + gap) - height), so they never overlap the
// built-in enter_subgraph button which lives INSIDE the title bar.
// - Built-in stacking + positioning handles multi-badge cases for free
// if other extensions also want to badge the same node.
interface LGraphBadgeOpts {
text?: string;
fgColor?: string;
bgColor?: string;
fontSize?: number;
padding?: number;
height?: number;
cornerRadius?: number;
xOffset?: number;
yOffset?: number;
}
interface LGraphBadgeInstance {
text: string;
fgColor: string;
bgColor: string;
}
interface LGraphBadgeCtor {
new (opts: LGraphBadgeOpts): LGraphBadgeInstance;
}
/** ComfyUI exposes the badge class on window via useGlobalLitegraph(). */
function getBadgeCtor(): LGraphBadgeCtor | null {
return (window as unknown as { LGraphBadge?: LGraphBadgeCtor }).LGraphBadge ?? null;
}
type SubgraphNodeLike = LiteNodeLike & {
size?: [number, number] | number[];
badges?: unknown[];
setDirtyCanvas?: (foreground: boolean, background: boolean) => void;
onConnectionsChange?: (...args: unknown[]) => void;
onRemoved?: (...args: unknown[]) => void;
/** Internal: latest computed severity so we can avoid pushing duplicate badges. */
_wpBadgeSeverity?: Severity | null;
/** Internal: badge instance currently in `node.badges` (or null). */
_wpBadge?: LGraphBadgeInstance | null;
/** Internal: cleanup handle so we can detach observers on node removal. */
_wpBadgeCleanup?: () => void;
};
interface SeverityColors { bg: string; fg: string }
const SEVERITY_COLORS: Record<Severity, SeverityColors> = {
info: { bg: "#6366f1", fg: "#ffffff" }, // indigo, --wp-accent
warning: { bg: "#fbbf24", fg: "#0F1F0F" }, // amber, --wp-amber
error: { bg: "#f87171", fg: "#ffffff" }, // red, --wp-red
};
interface BadgeState {
severity: Severity;
/** Human-readable label that explains the issue at a glance. */
text: string;
}
/**
* Format the badge label. Casual users won't recognize "i" or "!" — they
* need a phrase. We pick the worst-severity conflict and either name the
* specific variable (when there's a single issue) or report the count.
*/
function formatBadgeText(worst: Conflict, sameSeverityCount: number): string {
if (sameSeverityCount > 1) {
if (worst.severity === "error") return `${sameSeverityCount} errors`;
if (worst.severity === "warning") return `${sameSeverityCount} conflicts`;
return `${sameSeverityCount} overrides`;
}
// Single issue — name the variable so the user knows exactly what to fix.
if (worst.type === "missing_template_variable") return `missing $${worst.variable}`;
if (worst.type === "duplicate_variable") return `duplicate $${worst.variable}`;
if (worst.type === "shadows_upstream") return `shadows $${worst.variable}`;
return worst.severity;
}
const SEVERITY_RANK: Record<Severity, number> = { info: 1, warning: 2, error: 3 };
function widgetValue(node: LiteNodeLike, name: string): string {
const w = node.widgets?.find((x) => x.name === name);
return typeof w?.value === "string" ? w.value : "";
}
/**
* Walk every WP node nested anywhere inside `subgraph` (across nested
* subgraphs too) and collect all conflicts found. `rootGraph` is needed
* because Context/Assembler conflict scans walk upstream across subgraph
* boundaries — they need the root to build the subgraph-parents map.
*/
function collectInnerConflicts(rootGraph: LiteGraphLike, subgraph: LiteGraphLike): Conflict[] {
const out: Conflict[] = [];
for (const { node } of walkAllNodes(subgraph)) {
if (node.type === "WP_Context") {
const v = parseWidgetJson<ContextWidgetValue>(widgetValue(node, "modules"), { version: 1, modules: [] });
const upstream = collectUpstreamVariables(rootGraph, node);
// Only enabled modules count — disabled ones don't ship to runtime.
const enabledOnly: ContextWidgetValue = { ...v, modules: v.modules.filter((m) => m.enabled) };
out.push(...scanConflicts(enabledOnly, upstream));
} else if (node.type === "WP_PromptAssembler") {
const tmpl = widgetValue(node, "template");
if (!tmpl) continue;
const upstream = collectUpstreamVariables(rootGraph, node);
out.push(...scanTemplateConflicts(tmpl, upstream));
}
}
return out;
}
/**
* Roll a list of conflicts up into a single badge state — picks the
* worst severity, formats a human-readable label. Returns null when
* there's nothing to surface.
*/
function computeBadgeState(rootGraph: LiteGraphLike, subgraph: LiteGraphLike): BadgeState | null {
const conflicts = collectInnerConflicts(rootGraph, subgraph);
if (!conflicts.length) return null;
let worst = conflicts[0];
for (const c of conflicts) {
if (SEVERITY_RANK[c.severity] > SEVERITY_RANK[worst.severity]) worst = c;
}
const sameSeverityCount = conflicts.filter((c) => c.severity === worst.severity).length;
return { severity: worst.severity, text: formatBadgeText(worst, sameSeverityCount) };
}
function removeBadge(node: SubgraphNodeLike) {
if (!node._wpBadge || !Array.isArray(node.badges)) return;
const idx = node.badges.indexOf(node._wpBadge);
if (idx >= 0) node.badges.splice(idx, 1);
node._wpBadge = null;
}
function applyBadge(node: SubgraphNodeLike, state: BadgeState, BadgeCtor: LGraphBadgeCtor) {
const colors = SEVERITY_COLORS[state.severity];
// Reuse the existing instance when possible — mutating fields keeps
// ComfyUI's draw loop happy without churning the badges array.
if (node._wpBadge) {
node._wpBadge.text = state.text;
node._wpBadge.bgColor = colors.bg;
node._wpBadge.fgColor = colors.fg;
return;
}
const badge = new BadgeCtor({
text: state.text,
bgColor: colors.bg,
fgColor: colors.fg,
fontSize: 12,
padding: 6,
height: 18,
cornerRadius: 4,
});
if (!Array.isArray(node.badges)) node.badges = [];
node.badges.push(badge);
node._wpBadge = badge;
}
/**
* Attach badge logic to a single SubgraphNode. Idempotent — a node already
* attached gets its existing observer left alone. Cleanup is wired through
* `node.onRemoved` so we don't leak intervals on node deletion.
*/
export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraphLike): void {
if (node._wpBadgeCleanup) return; // already attached
// Captured into a const here so the closure below sees a non-nullable
// type — avoids the no-non-null-assertion lint while staying as cheap
// as the previous `BadgeCtor!`.
const Ctor: LGraphBadgeCtor | null = getBadgeCtor();
if (!Ctor) {
console.warn("[wildcard-pipeline] LGraphBadge not on window — badge skipped");
return;
}
function recompute() {
if (!node.subgraph || !Ctor) return;
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
// re-render even though severity is unchanged.
const sameAsBefore =
(next === null && node._wpBadgeSeverity === null) ||
(!!next && next.severity === node._wpBadgeSeverity && next.text === node._wpBadge?.text);
if (sameAsBefore) return;
node._wpBadgeSeverity = next?.severity ?? null;
if (!next) removeBadge(node);
else applyBadge(node, next, Ctor);
node.setDirtyCanvas?.(true, true);
}
// Initial compute happens on next tick — workflow may still be loading
// and the inner subgraph's nodes might not have widget values restored yet.
queueMicrotask(recompute);
// 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);
// Recompute on connection changes — wires added/removed inside the
// subgraph or to/from the SubgraphNode itself.
const origCC = node.onConnectionsChange;
node.onConnectionsChange = function (...args: unknown[]) {
origCC?.apply(this, args);
recompute();
};
// Recompute when ComfyUI finishes restoring a workflow — a node loaded
// mid-cascade may not have widget values yet on first compute.
const unsubGraphLoaded = onGraphLoaded(() => {
recompute();
requestAnimationFrame(recompute);
});
// Cleanup wired through onRemoved so a deleted SubgraphNode tears down
// its observers cleanly. We chain so other extensions still run.
const origRemoved = node.onRemoved;
node._wpBadgeCleanup = () => {
window.clearInterval(interval);
unsubGraphLoaded();
node.onConnectionsChange = origCC;
node.onRemoved = origRemoved;
removeBadge(node);
node._wpBadgeCleanup = undefined;
};
node.onRemoved = function (...args: unknown[]) {
node._wpBadgeCleanup?.();
origRemoved?.apply(this, args);
};
}
/**
* Walk the whole graph (root + nested subgraphs) and attach a badge to
* every SubgraphNode. Used on workflow load, when nodes pre-exist.
*/
export function attachAllSubgraphBadges(rootGraph: LiteGraphLike): void {
for (const { node } of walkAllNodes(rootGraph)) {
if ((node as SubgraphNodeLike).subgraph) {
attachSubgraphBadge(node as SubgraphNodeLike, rootGraph);
}
}
}
+171
View File
@@ -1,7 +1,178 @@
import { app } from "#comfyui/app";
import { createApp } from "vue";
import type { LiteGraphLike } from "./extension/graph";
// ComfyUI's `getCustomWidgets` invokes each factory synchronously and destructures
// the return value. Returning a Promise from `import()` yields `{ widget: undefined }`
// and the widget never mounts. Resolve all widget chunks at module load via top-level
// await so factories can run sync. Vite still emits the chunks separately, so the
// entry stays small (~0.5 KB gzip) and the heavy `_shared` chunk only fetches once.
const [
ctxMod,
dbgMod,
asmMod,
graphEventsMod,
ToastModule,
graphMod,
toastStoreMod,
badgeMod,
settingsMod,
aboutMod,
] = await Promise.all([
import("./widgets/context"),
import("./widgets/debug"),
import("./widgets/assembler"),
import("./extension/graph-events"),
import("./components/shared/Toast.vue"),
import("./extension/graph"),
import("./components/shared/toast-store"),
import("./extension/subgraph-badge"),
import("./extension/settings"),
import("./extension/about-badges"),
// Webfonts (Inter 400/600 + JetBrains Mono 400) — side-effect import,
// the module just owns the @font-face CSS chunk.
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.
const toastRoot = document.createElement("div");
toastRoot.id = "wp-toast-root";
document.body.appendChild(toastRoot);
createApp(ToastModule.default).mount(toastRoot);
// ComfyUI hands us untyped LiteGraph nodes; we only care about the surface
// the glue files import. Typing the param as the parameter of `create` /
// `mountHelper` makes TS pick the right shape without explicit casts.
type ContextCreateNode = Parameters<typeof ctxMod.create>[0];
type DebugCreateNode = Parameters<typeof dbgMod.create>[0];
type AssemblerHelperNode = Parameters<typeof asmMod.mountHelper>[0];
interface NodeData { name: string }
interface NodeType { prototype: { onNodeCreated?: (this: AssemblerHelperNode, ...args: unknown[]) => void } }
// Build the settings array eagerly — `app` is the same singleton ComfyUI
// will hand back at runtime, so onChange callbacks captured here read the
// extensionManager via the same reference.
const settings = settingsMod.buildSettings(app);
// Apply current a11y prefs on load + listen for OS-level changes so
// "Match system" reflects toggles in OS settings without a page reload.
// Note: ComfyUI also fires each setting's onChange on page load with the
// stored value, so applyA11yClasses is mostly belt-and-suspenders for
// the case where the extensionManager is ready before the load-fire.
settingsMod.applyA11yClasses(app);
settingsMod.watchA11ySystemPrefs();
app.registerExtension({
name: "wildcard-pipeline",
// About panel — small badges under our extension's row, link out to
// the repo, issue tracker, and docs. Uses PrimeIcons shipped by ComfyUI.
aboutPageBadges: aboutMod.ABOUT_BADGES,
// ComfyUI Settings panel entries — accessibility tri-state combos. Their
// onChange handlers re-run applyA11yClasses to flip the body markers.
settings,
getCustomWidgets() {
return {
WP_CONTEXT_MODULES: (node: ContextCreateNode, inputName: string) =>
ctxMod.create(node, inputName),
WP_DEBUG_VIEWER: (node: DebugCreateNode, inputName: string) =>
dbgMod.create(node, inputName),
};
},
beforeRegisterNodeDef(nodeType: unknown, nodeData: NodeData) {
if (nodeData.name !== "WP_PromptAssembler") return;
const nt = nodeType as NodeType;
const orig = nt.prototype.onNodeCreated;
nt.prototype.onNodeCreated = function (this: AssemblerHelperNode, ...args: unknown[]) {
orig?.apply(this, args);
asmMod.mountHelper(this);
};
},
// Fires after ComfyUI restores all widget values from the loaded workflow.
// Without this, assembler's snapshot is computed BEFORE upstream Context
// values land — preview briefly shows missing-var warnings then settles
// ~400ms later when the polling fallback catches up. Notify all reactive
// widgets to recompute immediately.
afterConfigureGraph() {
graphEventsMod.notifyGraphLoaded();
// Re-attach badges in case the loaded workflow contained SubgraphNodes
// that bypassed nodeCreated (loaded path uses different hooks).
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
if (root) badgeMod.attachAllSubgraphBadges(root);
},
// Fires for every node when it's instantiated — including SubgraphNodes,
// which beforeRegisterNodeDef does NOT see (litegraph registers them at
// runtime, not via ComfyUI's node-def pipeline). We attach a corner
// conflict badge so users can spot WP issues nested inside a subgraph
// without opening it.
nodeCreated(node: unknown) {
const n = node as { isSubgraphNode?: () => boolean };
if (!n.isSubgraphNode?.()) return;
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
if (root) badgeMod.attachSubgraphBadge(node as Parameters<typeof badgeMod.attachSubgraphBadge>[0], root);
},
// Same intent as nodeCreated but fires for nodes restored from a saved
// workflow. ComfyUI splits the lifecycle so we cover both paths.
loadedGraphNode(node: unknown) {
const n = node as { isSubgraphNode?: () => boolean };
if (!n.isSubgraphNode?.()) return;
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
if (root) badgeMod.attachSubgraphBadge(node as Parameters<typeof badgeMod.attachSubgraphBadge>[0], root);
},
});
// Pre-run validation — when the user queues a prompt, scan every
// WP_PromptAssembler in the graph for $vars in its template that no
// upstream Context provides. Surface a single toast listing them so the
// user can fix-or-accept (the runtime drops missing vars cleanly anyway).
interface AppWithQueue {
queuePrompt?: (...args: unknown[]) => unknown;
graph?: unknown;
}
const appQ = app as unknown as AppWithQueue;
const origQueuePrompt = appQ.queuePrompt;
if (typeof origQueuePrompt === "function") {
appQ.queuePrompt = function (...args: unknown[]) {
try {
const graph = appQ.graph as unknown as LiteGraphLike;
const TEMPLATE_VAR = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
const allMissing = new Set<string>();
// walkAllNodes recurses into nested subgraphs so assemblers inside a
// subgraph still get their templates scanned. collectUpstreamVariables
// crosses boundaries the other direction.
for (const { node } of graphMod.walkAllNodes(graph)) {
if (node.type !== "WP_PromptAssembler") continue;
const w = node.widgets?.find((x: { name: string; value: unknown }) => x.name === "template");
const tmpl = typeof w?.value === "string" ? w.value : "";
if (!tmpl) continue;
const upstream = new Set(graphMod.collectUpstreamVariables(graph, node));
for (const m of tmpl.matchAll(TEMPLATE_VAR)) {
const name = m[1];
if (!name.startsWith("__") && !upstream.has(name)) allMissing.add(name);
}
}
if (allMissing.size > 0) {
const list = [...allMissing].map((v) => `$${v}`).join(", ");
toastStoreMod.pushToast(`Unresolved in templates: ${list} — will render empty.`, {
severity: "warning",
lifeMs: 6000,
});
}
} catch (err) {
// Validation must never block the queue — log and proceed.
console.warn("[wildcard-pipeline] pre-run scan failed:", err);
}
return origQueuePrompt.apply(this, args);
};
}
console.info("[wildcard-pipeline] extension loaded");
+19
View File
@@ -0,0 +1,19 @@
// Stub the ComfyUI app module so widgets can import without a live ComfyUI.
import { vi } from "vitest";
vi.mock("#comfyui/app", () => ({
app: {
graph: { _nodes: [], links: {}, getNodeById: () => null },
registerExtension: vi.fn(),
},
}));
// jsdom (<26) doesn't ship ResizeObserver. createDomWidgetHost uses it for
// content-driven autosize; without a stub, instantiation throws.
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as never;
}
+33 -2
View File
@@ -1,11 +1,42 @@
// Stubs for ComfyUI's frontend globals, extended as needed.
// Stubs for ComfyUI's frontend globals, extended as needed. We keep our
// hand-rolled shim instead of re-exporting `@comfyorg/comfyui-frontend-types`
// directly because the upstream types insist on `NodeId = string | number`
// and `LGraphNode` shape, while our internal `LiteNodeLike` uses `id: number`
// throughout (subgraphs aside, our walkers handle string IDs explicitly via
// the locator helper). The official types live in node_modules as a
// reference for new hook signatures — copy fields in here as needed.
declare module "#comfyui/app" {
export interface ComfyApp {
graph: unknown;
canvas?: { graph?: unknown };
/** Runtime-only — surfaced when ComfyUI mounts the Vue settings panel. */
extensionManager?: {
setting?: { get(id: string): unknown };
};
registerExtension(ext: {
name: string;
getCustomWidgets?: () => Promise<Record<string, unknown>> | Record<string, unknown>;
beforeRegisterNodeDef?: (nodeType: unknown, nodeData: unknown) => Promise<void> | void;
beforeRegisterNodeDef?: (nodeType: unknown, nodeData: { name: string }) => Promise<void> | void;
/** Called after ComfyUI restores all widget values on workflow load. */
afterConfigureGraph?: (...args: unknown[]) => void;
/** Fires for every node when instantiated, including SubgraphNodes that
* bypass beforeRegisterNodeDef. */
nodeCreated?: (node: unknown) => Promise<void> | void;
/** Fires for every node restored from a saved workflow. */
loadedGraphNode?: (node: unknown, app: unknown) => Promise<void> | void;
/** Badges shown in the "About" panel under our extension row. */
aboutPageBadges?: Array<{ label: string; url: string; icon: string }>;
/** Entries in the ComfyUI Settings panel. */
settings?: Array<{
id: string;
name: string;
type: "boolean" | "text" | "number" | "slider" | "combo" | "color" | "image" | "hidden";
defaultValue: unknown;
tooltip?: string;
options?: Array<{ text: string; value: string }>;
category?: string[];
onChange?: (newVal: unknown, oldVal: unknown) => void;
}>;
}): void;
}
export const app: ComfyApp;
+230
View File
@@ -0,0 +1,230 @@
import { createApp, type App, type Component } from "vue";
export interface DomWidget {
element: HTMLElement;
options?: Record<string, unknown>;
}
export interface DomWidgetHost {
widget: DomWidget;
element: HTMLElement;
app: App;
unmount: () => void;
getValue: () => string;
setValue: (v: string) => void;
}
export interface MountTargetNode {
addDOMWidget(name: string, type: string, el: HTMLElement, opts?: Record<string, unknown>): DomWidget & {
computeLayoutSize?: (n: unknown) => { minWidth: number; minHeight: number };
};
}
export interface CreateDomWidgetHostOptions<P extends Record<string, unknown>> {
initialValue?: string;
/** Notified whenever ComfyUI's value setter is invoked — e.g. workflow load. */
onValueRestored?: (v: string) => void;
componentProps?: P;
/** Minimum widget height in pixels. Skips per-frame DOM measurement. */
minHeight?: number;
/** Force the parent node to be at least this wide (px). LoRA Manager docs §4.3. */
minWidth?: number;
/**
* Fill the host's allocated height instead of growing the node to fit
* content. Use for read-only viewers (debug snapshot) where the node size
* should drive the viewer, not the other way around. Disables autosize
* observation; component CSS should set `height: 100%` on its root.
*/
fillHost?: boolean;
}
interface ResizableNode {
size?: number[];
computeSize?: () => [number, number] | number[];
setSize?: (size: [number, number] | number[]) => void;
setDirtyCanvas?: (foreground: boolean, background: boolean) => void;
}
export function createDomWidgetHost<P extends Record<string, unknown>>(
node: MountTargetNode,
widgetName: string,
component: Component<P>,
options: CreateDomWidgetHostOptions<P> = {},
): DomWidgetHost {
const host = document.createElement("div");
host.classList.add("wp-widget");
host.style.width = "100%";
host.style.boxSizing = "border-box";
// Inner element receives Vue's mount. Its natural (content) height is what
// we feed back as the node's required height. Observing the outer host
// would only ever report ComfyUI's own assigned height (== getMinHeight),
// which gives a tautology — height never grows because we measure the
// value we just set.
const inner = document.createElement("div");
inner.classList.add("wp-widget__inner");
inner.style.width = "100%";
inner.style.boxSizing = "border-box";
if (options.fillHost) {
// Stretch to whatever ComfyUI gives us so the component can flex into the
// available space.
inner.style.height = "100%";
host.style.height = "100%";
}
host.appendChild(inner);
let state = options.initialValue ?? "";
const baseMin = options.minHeight ?? 80;
let minHeight = baseMin;
const widgetOpts: Record<string, unknown> = {
socketless: true,
getValue: () => state,
setValue: (v: string) => {
state = v;
options.onValueRestored?.(v);
},
// Cached number so ComfyUI skips per-frame getComputedStyle (perf hint
// from LoRA Manager's DOM widget guide).
getMinHeight: () => minHeight,
};
const widget = node.addDOMWidget(widgetName, "wp-dom", host, widgetOpts);
// Force a minimum node width if requested. LoRA Manager docs §4.3 — only
// way to widen the node from a DOM widget is overriding computeLayoutSize.
if (options.minWidth) {
const minWidth = options.minWidth;
widget.computeLayoutSize = () => ({ minWidth, minHeight });
}
// createApp's prop overload requires the second arg's keys to extend the
// component's prop keys. With componentProps?: P (defaulting to {}), TS
// won't narrow back to P at this call site. Cast through `unknown` once
// here — the public API on createDomWidgetHost is still strict in P.
const app = createApp(component, (options.componentProps ?? {}) as unknown as P);
app.mount(inner);
// Observe inner content height. When Vue grows the DOM, bump minHeight (so
// ComfyUI's getMinHeight callback returns the right value) and grow the node
// ONLY when its current height is below what the content needs.
//
// Important: never call setSize unconditionally. computeSize() returns the
// *minimum* layout size; calling setSize(computeSize()) on every observer
// fire would (a) override the user's manual drag-larger, and (b) override
// the saved-workflow size when ComfyUI restores it on page load.
const resizable = node as unknown as ResizableNode;
let scheduled = false;
function pushSize(measured: number) {
if (scheduled) return;
scheduled = true;
queueMicrotask(() => {
scheduled = false;
const next = Math.max(baseMin, measured);
if (next !== minHeight) minHeight = next;
const min = resizable.computeSize?.();
const cur = resizable.size;
if (!min || !cur || !resizable.setSize) return;
// Always match content size on the height axis. Width is left to the
// user (drag wider preserved). If user wants more vertical space they
// collapse modules / use whitespace.
if (cur[1] !== min[1]) {
resizable.setSize([Math.max(cur[0], min[0]), min[1]]);
resizable.setDirtyCanvas?.(true, true);
}
});
}
// Skip the autosize observer in fill mode — node size is user-controlled,
// content fills whatever space is given.
const observer = options.fillHost
? null
: new ResizeObserver((entries) => {
for (const e of entries) {
const h = Math.ceil(e.borderBoxSize?.[0]?.blockSize ?? e.contentRect.height);
if (h > 0) pushSize(h);
}
});
if (observer) queueMicrotask(() => observer.observe(inner));
return {
widget,
element: inner,
app,
unmount: () => {
observer?.disconnect();
app.unmount();
},
getValue: () => state,
setValue: (v) => {
state = v;
},
};
}
export interface ContextWidgetValue {
version: 1;
modules: ModuleEntry[];
}
export interface ModuleEntry {
id: string;
type: "fixed_values";
enabled: boolean;
/** UI-only: persists collapsed state so cards stay collapsed across reload. */
collapsed?: boolean;
meta: { name: string; description?: string; tags?: string[] };
entries: { variable_name: string; value: string }[];
}
export function parseWidgetJson<T>(raw: string, fallback: T): T {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed as T;
} catch {
// fall through to default
}
return fallback;
}
export interface ParseRecovery<T> {
/** Parsed value, or the fallback if parsing failed / shape was wrong. */
value: T;
/** Human-readable error message, or null on success. */
error: string | null;
/** Original raw input — preserved so a "View raw" UI can show the bad data. */
raw: string;
}
/**
* Parse with recovery info attached. Use when the widget needs to surface a
* "corrupt workflow" panel: callers branch on `error` and render a
* View raw / Reset UI without throwing away the original payload.
*
* Empty input is NOT an error — fresh nodes have no value yet and should
* just receive the fallback silently.
*/
export function parseWidgetJsonWithRecovery<T>(raw: string, fallback: T): ParseRecovery<T> {
if (!raw) return { value: fallback, error: null, raw };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return { value: parsed as T, error: null, raw };
return { value: fallback, error: `Expected an object, got ${typeof parsed}.`, raw };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { value: fallback, error: msg, raw };
}
}
export function serializeWidgetJson(value: unknown): string {
return JSON.stringify(value);
}
export function emptyContextValue(): ContextWidgetValue {
return { version: 1, modules: [] };
}
export function newModuleId(): string {
// spec §3.2 — 8-char hex
const bytes = new Uint8Array(4);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
+97
View File
@@ -0,0 +1,97 @@
import { defineAsyncComponent, h, type Component } from "vue";
import { app } from "#comfyui/app";
import { createDomWidgetHost, type MountTargetNode } from "./_shared";
import {
collectUpstreamVariables,
collectUpstreamValues,
type LiteGraphLike,
type LiteNodeLike,
} from "../extension/graph";
import { reactiveFromGraph, stringArrayEqual } from "../extension/reactive";
const AssemblerHelper = defineAsyncComponent(() => import("../components/assembler/AssemblerHelper.vue"));
interface AssemblerNode extends LiteNodeLike, MountTargetNode {
widgets?: { name: string; value: unknown }[];
}
function templateOf(node: AssemblerNode): string {
const w = node.widgets?.find((x) => x.name === "template");
return typeof w?.value === "string" ? w.value : "";
}
interface UpstreamSnapshot {
vars: string[];
values: Record<string, string>;
template: string;
}
function snapshotEqual(a: UpstreamSnapshot, b: UpstreamSnapshot): boolean {
if (a.template !== b.template) return false;
if (!stringArrayEqual(a.vars, b.vars)) return false;
for (const k of a.vars) if (a.values[k] !== b.values[k]) return false;
return true;
}
export function mountHelper(node: AssemblerNode) {
const wrapper: Component = {
setup() {
// Bundle vars + values + template into one snapshot — they all change
// together (graph edits, module edits, template typing) and the polling
// fallback covers all three uniformly.
const snapshot = reactiveFromGraph<UpstreamSnapshot>(
node as unknown as Parameters<typeof reactiveFromGraph>[0],
() => {
const g = app.graph as unknown as LiteGraphLike;
return {
vars: collectUpstreamVariables(g, node),
values: collectUpstreamValues(g, node),
template: templateOf(node),
};
},
snapshotEqual,
);
return () => h(AssemblerHelper, {
upstreamVars: snapshot.value.vars,
upstreamValues: snapshot.value.values,
template: snapshot.value.template,
onInsert: (token: string) => insertIntoTemplate(node, token),
});
},
};
createDomWidgetHost(node, "assembler-helper", wrapper, {
minHeight: 80,
minWidth: 280,
});
}
function insertIntoTemplate(node: AssemblerNode, token: string) {
const w = node.widgets?.find((x) => x.name === "template") as
| { name: string; value: unknown; inputEl?: HTMLTextAreaElement | HTMLInputElement }
| undefined;
if (!w || typeof w.value !== "string") return;
// Multiline STRING widgets in ComfyUI back the input with a real <textarea>
// exposed at widget.inputEl. If we can find it, splice the token at the
// current caret. If not (collapsed canvas mode, etc.), fall back to a
// smart-append.
const el = w.inputEl;
if (el && typeof el.selectionStart === "number") {
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? start;
const needsLeadingSpace = start > 0 && !/\s$/.test(el.value.slice(0, start));
const insert = `${needsLeadingSpace ? " " : ""}${token}`;
el.value = el.value.slice(0, start) + insert + el.value.slice(end);
w.value = el.value;
// Restore focus + place caret immediately after the inserted token.
el.focus();
const caret = start + insert.length;
el.setSelectionRange(caret, caret);
el.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
// Fallback: append with a single-space separator.
const cur = w.value;
w.value = `${cur}${cur.endsWith(" ") || !cur ? "" : " "}${token}`;
}
+49
View File
@@ -0,0 +1,49 @@
import { defineAsyncComponent, h, ref, type Component } from "vue";
import { app } from "#comfyui/app";
import {
createDomWidgetHost, parseWidgetJson, serializeWidgetJson, emptyContextValue,
type ContextWidgetValue, type MountTargetNode,
} from "./_shared";
import { collectUpstreamVariables, type LiteGraphLike, type LiteNodeLike } from "../extension/graph";
import { reactiveFromGraph, stringArrayEqual } from "../extension/reactive";
const ContextWidget = defineAsyncComponent(() => import("../components/context/ContextWidget.vue"));
type ContextNode = LiteNodeLike & MountTargetNode;
export function create(node: ContextNode, inputName: string) {
const initial = serializeWidgetJson(parseWidgetJson<ContextWidgetValue>("", emptyContextValue()));
// Reactive `initialJson` prop — workflow load races (setValue can fire
// before the async SFC chunk mounts) are handled by the SFC's watch on this
// prop. No imperative remount, no focus loss on edits.
const currentJson = ref(initial);
const wrapper: Component = {
setup() {
const upstreamVars = reactiveFromGraph(
node as unknown as Parameters<typeof reactiveFromGraph>[0],
() => collectUpstreamVariables(app.graph as unknown as LiteGraphLike, node),
stringArrayEqual,
);
return () => h(ContextWidget, {
nodeId: node.id,
initialJson: currentJson.value,
upstreamVars: upstreamVars.value,
onChange: (json: string) => host.setValue(json),
});
},
};
const host = createDomWidgetHost(node, inputName, wrapper, {
initialValue: initial,
minHeight: 80,
minWidth: 280,
onValueRestored: (v: string) => {
// Workflow load — push the restored value into the reactive prop so the
// SFC picks it up whether it has already mounted or not.
if (v !== currentJson.value) currentJson.value = v;
},
});
return host;
}
+32
View File
@@ -0,0 +1,32 @@
import { defineAsyncComponent, h, ref, type Component } from "vue";
import { createDomWidgetHost, type MountTargetNode } from "./_shared";
const DebugViewer = defineAsyncComponent(() => import("../components/debug/DebugViewer.vue"));
interface DebugNode extends MountTargetNode {
onExecuted?: (output: { wp_debug_snapshot?: string[] }) => void;
}
export function create(node: DebugNode, inputName: string) {
const snapshot = ref("");
const wrapper: Component = {
setup: () => () => h(DebugViewer, { snapshot: snapshot.value }),
};
// Backend declares `viewer` as required but ignores it at runtime — seed an
// empty string so workflow serialization and prompt validation both pass.
const host = createDomWidgetHost(node, inputName, wrapper, {
initialValue: "",
// Fill mode — viewer fills whatever node size the user gives it. Snapshot
// doesn't push the node larger; oversized snapshots scroll inside.
fillHost: true,
minHeight: 200,
minWidth: 280,
});
const orig = node.onExecuted;
node.onExecuted = function (output) {
orig?.call(this, output);
const snap = output?.wp_debug_snapshot;
if (Array.isArray(snap) && typeof snap[0] === "string") snapshot.value = snap[0];
};
return host;
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { defineComponent, h } from "vue";
import { createDomWidgetHost, parseWidgetJson, serializeWidgetJson } from "./_shared";
describe("createDomWidgetHost", () => {
it("mounts a Vue component into a fresh div and returns the host element", () => {
const Hello = defineComponent({
props: { msg: { type: String, required: true } },
setup: (p) => () => h("span", { class: "hi" }, p.msg),
});
const fakeNode = { addDOMWidget: (_n: string, _t: string, el: HTMLElement) => ({ element: el }) };
const { element, unmount } = createDomWidgetHost(fakeNode, "modules", Hello, {
componentProps: { msg: "ok" },
});
expect(element.querySelector(".hi")?.textContent).toBe("ok");
unmount();
expect(element.querySelector(".hi")).toBeNull();
});
it("exposes getValue/setValue backed by the closure state", () => {
const Empty = defineComponent({ setup: () => () => h("span") });
let captured: { getValue?: () => string; setValue?: (v: string) => void } = {};
const fakeNode = {
addDOMWidget: (_n: string, _t: string, el: HTMLElement, opts: Record<string, unknown> | undefined) => {
captured = opts as never;
return { element: el };
},
};
const restored: string[] = [];
const host = createDomWidgetHost(fakeNode, "modules", Empty, {
initialValue: "seed",
onValueRestored: (v) => restored.push(v),
});
expect(host.getValue()).toBe("seed");
expect(captured.getValue?.()).toBe("seed");
captured.setValue?.("from-workflow");
expect(host.getValue()).toBe("from-workflow");
expect(restored).toEqual(["from-workflow"]);
host.setValue("from-host");
expect(captured.getValue?.()).toBe("from-host");
host.unmount();
});
});
describe("parseWidgetJson / serializeWidgetJson", () => {
it("round-trips, returns parsed value, and falls back on invalid input", () => {
const fallback = { version: 1, modules: [] };
const real = { version: 1, modules: [{ id: "abc12345" }] };
expect(parseWidgetJson(JSON.stringify(real), fallback)).toEqual(real);
expect(parseWidgetJson("not-json", fallback)).toEqual(fallback);
expect(parseWidgetJson("null", fallback)).toEqual(fallback);
expect(serializeWidgetJson({ version: 1, modules: [] })).toBe('{"version":1,"modules":[]}');
});
});
View File
+235
View File
@@ -0,0 +1,235 @@
"""Minimal stub of the subset of ``comfy_api.latest`` the project uses.
Exposes:
- ``ComfyExtension`` — base class with ``async get_node_list()``.
- ``io.ComfyNode`` — base class for V3 nodes.
- ``io.Schema`` — dataclass capturing node_id, display_name, category,
inputs, outputs, and boolean flags like ``not_idempotent``,
``is_output_node``.
- ``io.Int``, ``io.String`` — built-in widget types.
- ``io.Custom(name)`` — factory for custom socket/widget types.
- ``io.NodeOutput`` — container for execute() return values.
- ``io._io.comfytype`` / ``ComfyTypeIO`` — decorator + base for strongly
typed custom types.
The stub aims for **structural** parity: attribute access + .Input()/.Output()
factory calls succeed, and io.NodeOutput carries the positional values plus
an optional ``ui`` dict. Actual runtime semantics (caching, widget rendering,
socket wiring) are not simulated — those are verified via manual QA in ComfyUI.
"""
from dataclasses import dataclass, field
from typing import Any
# ---------------------------------------------------------------- Schema ---
@dataclass
class Schema:
node_id: str
display_name: str = ""
category: str = ""
description: str = ""
inputs: list[Any] = field(default_factory=list)
outputs: list[Any] = field(default_factory=list)
hidden: list[Any] = field(default_factory=list)
is_output_node: bool = False
not_idempotent: bool = False
is_experimental: bool = False
is_deprecated: bool = False
# ------------------------------------------------------------ NodeOutput ---
class NodeOutput:
"""Structural stub — matches real ``io.NodeOutput`` surface."""
def __init__(self, *values: Any, ui: dict[str, Any] | None = None):
self.values = values
self.ui = ui
# ------------------------------------------------------------- ComfyNode ---
class ComfyNode:
"""V3 node base class. Real ComfyUI instantiates these via classmethods."""
@classmethod
def define_schema(cls) -> Schema: # pragma: no cover - overridden
raise NotImplementedError
@classmethod
def execute(cls, *args: Any, **kwargs: Any) -> NodeOutput: # pragma: no cover
raise NotImplementedError
# ----------------------------------------- Input/Output descriptor helpers ---
@dataclass
class _Slot:
"""A single input or output slot descriptor captured by tests."""
kind: str # "input" or "output"
type_name: str
name: str = ""
default: Any = None
optional: bool = False
multiline: bool = False
socketless: bool = False
min: Any = None
max: Any = None
control_after_generate: bool = False
class _IOType:
"""Factory for a concrete io type (Int, String, custom)."""
def __init__(self, type_name: str):
self._type_name = type_name
def Input(self, name: str = "", **kwargs: Any) -> _Slot:
return _Slot(kind="input", type_name=self._type_name, name=name, **kwargs)
def Output(self, name: str = "") -> _Slot:
return _Slot(kind="output", type_name=self._type_name, name=name)
Int = _IOType("INT")
String = _IOType("STRING")
def Custom(type_name: str) -> _IOType:
"""Create an io type with the given custom type name."""
return _IOType(type_name)
# ------------------------------------------------- @comfytype / ComfyTypeIO ---
class ComfyTypeIO:
"""Base for typed custom types — set ``Type = ...`` in subclass."""
Type: Any = None
@classmethod
def Input(cls, name: str = "", **kwargs: Any) -> _Slot:
return _Slot(
kind="input",
type_name=getattr(cls, "_io_type", "CUSTOM"),
name=name,
**kwargs,
)
@classmethod
def Output(cls, name: str = "") -> _Slot:
return _Slot(
kind="output",
type_name=getattr(cls, "_io_type", "CUSTOM"),
name=name,
)
def comfytype(io_type: str):
"""Decorator that tags a class (and any nested Input/Output) with an io type."""
def _wrap(cls):
cls._io_type = io_type
# Propagate io_type to nested Input/Output subclasses so ``WidgetInput``
# subclass instances expose ``.type_name`` correctly without manual wiring.
# Only propagate when ``Input``/``Output`` is an actual class — the
# ``ComfyTypeIO`` base exposes them as classmethods which can't take
# arbitrary attributes.
nested_input = getattr(cls, "Input", None)
if isinstance(nested_input, type):
nested_input._io_type = io_type
nested_output = getattr(cls, "Output", None)
if isinstance(nested_output, type):
nested_output._io_type = io_type
return cls
return _wrap
# ------------------------------------------------------------------ io ns ---
class WidgetInput:
"""Stub matching ``comfy_api.latest.WidgetInput`` surface.
Custom widget types in ``nodes/types.py`` subclass this so they can accept
``socketless`` at declaration time — a kwarg the base ``Input`` does not
support.
"""
# Subclass should set this (by convention, via @comfytype descending through
# the ``Parent.io_type`` chain in the real API). In our stub, tests check
# ``type_name`` directly, so subclasses of WidgetInput should set
# ``type_name`` on ``self`` in their ``__init__`` after calling super, OR
# the @comfytype decorator propagates an ``_io_type`` class attribute.
_io_type: str = "WIDGET_INPUT"
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: Any = None,
socketless: bool | None = None,
widget_type: str | None = None,
force_input: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
self.id = id
self.name = id # back-compat for tests
self.type_name = self._io_type
self.display_name = display_name
self.optional = optional
self.tooltip = tooltip
self.lazy = lazy
self.default = default
self.socketless = socketless
self.widget_type = widget_type
self.force_input = force_input
self.extra_dict = extra_dict
self.raw_link = raw_link
self.advanced = advanced
class _IONamespace:
"""Mirrors ``comfy_api.latest.io``. Attribute access for types."""
ComfyNode = ComfyNode
Schema = Schema
NodeOutput = NodeOutput
Int = Int
String = String
Custom = staticmethod(Custom)
WidgetInput = WidgetInput
_io: "_IOInternal"
# Sub-namespace: io._io exposes @comfytype + ComfyTypeIO (real module path).
class _IOInternal:
comfytype = staticmethod(comfytype)
ComfyTypeIO = ComfyTypeIO
io = _IONamespace()
io._io = _IOInternal()
# ----------------------------------------------------------- ComfyExtension ---
class ComfyExtension:
"""Real base class exposes async ``get_node_list``. Stub mirrors shape."""
async def get_node_list(self) -> list[type[ComfyNode]]: # pragma: no cover
return []
+31
View File
@@ -0,0 +1,31 @@
"""Unit test for the extension's comfy_entrypoint wiring."""
import asyncio
import importlib.util
import sys
from pathlib import Path
def _load_root_init():
"""Load the project-root __init__.py as a package-aware module."""
project_root = Path(__file__).parent.parent
init_path = project_root / "__init__.py"
spec = importlib.util.spec_from_file_location(
"wildcard_pipeline_root",
init_path,
submodule_search_locations=[str(project_root)],
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules["wildcard_pipeline_root"] = module
spec.loader.exec_module(module)
return module
def test_entrypoint_exposes_three_node_classes():
ext = _load_root_init()
extension = asyncio.run(ext.comfy_entrypoint())
node_list = asyncio.run(extension.get_node_list())
ids = sorted(cls.define_schema().node_id for cls in node_list)
assert ids == ["WP_Context", "WP_Debug", "WP_PromptAssembler"]
+62
View File
@@ -0,0 +1,62 @@
"""Unit tests for nodes.assembler_node.WPPromptAssembler."""
from wp_nodes.assembler_node import WPPromptAssembler
from wp_nodes.types import ContextPayload
class TestWPPromptAssemblerSchema:
def test_schema_basic(self):
schema = WPPromptAssembler.define_schema()
assert schema.node_id == "WP_PromptAssembler"
assert schema.display_name == "WP Prompt Assembler"
assert schema.category == "wildcard-pipeline"
def test_inputs(self):
schema = WPPromptAssembler.define_schema()
names = [s.name for s in schema.inputs]
assert names == ["context", "template"]
ctx_in, tmpl_in = schema.inputs
assert ctx_in.type_name == "PIPELINE_CONTEXT"
assert tmpl_in.type_name == "STRING"
assert tmpl_in.multiline is True
def test_outputs(self):
schema = WPPromptAssembler.define_schema()
assert len(schema.outputs) == 1
assert schema.outputs[0].type_name == "STRING"
class TestWPPromptAssemblerExecute:
def test_resolves_single_var(self):
payload = ContextPayload(context={"style": "photoreal"}, debug={})
out = WPPromptAssembler.execute(context=payload, template="A $style shot")
assert out.values == ("A photoreal shot",)
def test_resolves_multiple_vars(self):
payload = ContextPayload(
context={"style": "photo", "subject": "knight", "light": "soft"},
debug={},
)
out = WPPromptAssembler.execute(
context=payload,
template="A $style $subject in $light light",
)
assert out.values == ("A photo knight in soft light",)
def test_missing_var_dropped_with_whitespace_cleanup(self):
# Missing vars resolve to empty string and surrounding whitespace is
# collapsed so the prompt stays clean.
payload = ContextPayload(context={}, debug={})
out = WPPromptAssembler.execute(context=payload, template="$unknown here")
assert out.values == ("here",)
def test_dollar_escape(self):
payload = ContextPayload(context={"x": "1"}, debug={})
out = WPPromptAssembler.execute(context=payload, template="$$ costs $x")
assert out.values == ("$ costs 1",)
def test_empty_template(self):
payload = ContextPayload(context={"x": "1"}, debug={})
out = WPPromptAssembler.execute(context=payload, template="")
assert out.values == ("",)
+123
View File
@@ -0,0 +1,123 @@
"""Unit tests for nodes.context_node.WPContext."""
import json
import pytest
from wp_nodes.context_node import WPContext
from wp_nodes.types import ContextPayload
class TestWPContextSchema:
def test_schema_basic(self):
schema = WPContext.define_schema()
assert schema.node_id == "WP_Context"
assert schema.display_name == "WP Context"
assert schema.category == "wildcard-pipeline"
assert schema.not_idempotent is True
def test_schema_inputs(self):
schema = WPContext.define_schema()
names = [s.name for s in schema.inputs]
assert names == ["upstream", "seed", "modules"]
upstream, seed, modules = schema.inputs
assert upstream.type_name == "PIPELINE_CONTEXT"
assert upstream.optional is True
assert seed.type_name == "INT"
assert modules.type_name == "WP_CONTEXT_MODULES"
assert modules.socketless is True
def test_schema_outputs(self):
schema = WPContext.define_schema()
assert len(schema.outputs) == 1
assert schema.outputs[0].type_name == "PIPELINE_CONTEXT"
class TestWPContextExecute:
def _modules_json(self, entries):
return json.dumps({
"version": 1,
"modules": [{
"id": "m1",
"type": "fixed_values",
"entries": entries,
}],
})
def test_root_context_no_upstream(self):
payload = self._modules_json([
{"variable_name": "style", "value": "photo"},
])
out = WPContext.execute(seed=0, modules=payload, upstream=None)
assert len(out.values) == 1
result = out.values[0]
assert isinstance(result, ContextPayload)
assert result.context == {"style": "photo"}
assert result.debug["node_seed"] == 0
assert len(result.debug["trace"]) == 1
assert result.debug["upstream"] == {}
def test_with_upstream_inherits_vars(self):
upstream = ContextPayload(
context={"subject": "knight"},
debug={"node_seed": 7, "trace": [{"id": "up"}]},
)
payload = self._modules_json([
{"variable_name": "style", "value": "painted"},
])
out = WPContext.execute(seed=42, modules=payload, upstream=upstream)
result = out.values[0]
assert result.context == {"subject": "knight", "style": "painted"}
assert result.debug["node_seed"] == 42
assert result.debug["upstream"] == {
"node_seed": 7,
"trace": [{"id": "up"}],
}
assert [entry["id"] for entry in result.debug["trace"]] == ["m1"]
def test_empty_modules_still_emits_payload(self):
out = WPContext.execute(
seed=0,
modules='{"version":1,"modules":[]}',
upstream=None,
)
result = out.values[0]
assert result.context == {}
assert result.debug["trace"] == []
def test_malformed_modules_raises(self):
with pytest.raises(ValueError):
WPContext.execute(seed=0, modules="{not json", upstream=None)
def test_downstream_overwrite_flows(self):
first = WPContext.execute(
seed=1,
modules=json.dumps({
"version": 1,
"modules": [{
"id": "a",
"type": "fixed_values",
"entries": [{"variable_name": "style", "value": "photo"}],
}],
}),
upstream=None,
).values[0]
second = WPContext.execute(
seed=2,
modules=json.dumps({
"version": 1,
"modules": [{
"id": "b",
"type": "fixed_values",
"entries": [{"variable_name": "style", "value": "painted"}],
}],
}),
upstream=first,
).values[0]
assert second.context == {"style": "painted"}
assert second.debug["upstream"]["node_seed"] == 1
+70
View File
@@ -0,0 +1,70 @@
"""Unit tests for nodes.debug_node.WPDebug."""
import json
from wp_nodes.debug_node import WPDebug
from wp_nodes.types import ContextPayload
class TestWPDebugSchema:
def test_schema_basic(self):
schema = WPDebug.define_schema()
assert schema.node_id == "WP_Debug"
assert schema.display_name == "WP Debug"
assert schema.category == "wildcard-pipeline"
assert schema.is_output_node is True
assert schema.not_idempotent is True
def test_inputs(self):
schema = WPDebug.define_schema()
names = [s.name for s in schema.inputs]
assert names == ["context", "viewer"]
ctx_in, viewer_in = schema.inputs
assert ctx_in.type_name == "PIPELINE_CONTEXT"
assert viewer_in.type_name == "WP_DEBUG_VIEWER"
assert viewer_in.socketless is True
def test_no_outputs(self):
schema = WPDebug.define_schema()
assert schema.outputs == []
class TestWPDebugExecute:
def _parse_snapshot(self, out):
snapshot_list = out.ui["wp_debug_snapshot"]
assert len(snapshot_list) == 1
return json.loads(snapshot_list[0])
def test_emits_ui_payload(self):
payload = ContextPayload(
context={"style": "photo"},
debug={"node_seed": 42, "trace": []},
)
out = WPDebug.execute(context=payload, viewer=None)
assert out.ui is not None
assert "wp_debug_snapshot" in out.ui
snapshot = self._parse_snapshot(out)
assert snapshot == {
"context": {"style": "photo"},
"debug": {"node_seed": 42, "trace": []},
}
def test_complex_values_serialized_via_str(self):
class Weird:
def __str__(self): return "weird"
payload = ContextPayload(
context={"obj": Weird()},
debug={},
)
out = WPDebug.execute(context=payload, viewer=None)
snapshot = self._parse_snapshot(out)
assert snapshot["context"]["obj"] == "weird"
def test_empty_context(self):
payload = ContextPayload()
out = WPDebug.execute(context=payload, viewer=None)
snapshot = self._parse_snapshot(out)
assert snapshot == {"context": {}, "debug": {}}
+72
View File
@@ -0,0 +1,72 @@
"""End-to-end node integration — simulate a small ComfyUI workflow."""
import json
from wp_nodes.assembler_node import WPPromptAssembler
from wp_nodes.context_node import WPContext
from wp_nodes.debug_node import WPDebug
from wp_nodes.types import ContextPayload
def _mods(entries):
return json.dumps({
"version": 1,
"modules": [{
"id": "auto",
"type": "fixed_values",
"entries": entries,
}],
})
def test_chain_of_two_context_nodes_into_assembler():
# Upstream context sets style + subject.
upstream = WPContext.execute(
seed=1,
modules=_mods([
{"variable_name": "style", "value": "photoreal"},
{"variable_name": "subject", "value": "knight"},
]),
upstream=None,
).values[0]
# Downstream context adds environment vars + overrides style.
downstream = WPContext.execute(
seed=2,
modules=_mods([
{"variable_name": "style", "value": "painted"},
{"variable_name": "location", "value": "forest"},
]),
upstream=upstream,
).values[0]
assert isinstance(downstream, ContextPayload)
assert downstream.context == {
"subject": "knight",
"style": "painted",
"location": "forest",
}
# Assembler renders the final template.
assembled = WPPromptAssembler.execute(
context=downstream,
template="A $style $subject in the $location",
)
assert assembled.values == ("A painted knight in the forest",)
def test_debug_node_snapshots_downstream_context():
upstream = WPContext.execute(
seed=7,
modules=_mods([
{"variable_name": "style", "value": "photoreal"},
]),
upstream=None,
).values[0]
out = WPDebug.execute(context=upstream, viewer=None)
snapshot = json.loads(out.ui["wp_debug_snapshot"][0])
assert snapshot["context"] == {"style": "photoreal"}
assert snapshot["debug"]["node_seed"] == 7
assert len(snapshot["debug"]["trace"]) == 1
+87
View File
@@ -0,0 +1,87 @@
"""Unit tests for nodes.types."""
import json
from dataclasses import FrozenInstanceError
import pytest
from engine.modules import FixedValueEntry, FixedValueModule
from wp_nodes.types import (
ContextPayload,
build_payload,
deserialize_modules,
)
class TestContextPayload:
def test_defaults(self):
payload = ContextPayload()
assert payload.context == {}
assert payload.debug == {}
def test_frozen(self):
payload = ContextPayload(context={"a": 1}, debug={"b": 2})
with pytest.raises(FrozenInstanceError):
payload.context = {} # type: ignore[misc]
class TestDeserializeModules:
def test_empty_list(self):
assert deserialize_modules('{"version":1,"modules":[]}') == []
def test_single_fixed_values(self):
payload = json.dumps({
"version": 1,
"modules": [{
"id": "abcd1234",
"type": "fixed_values",
"entries": [{"variable_name": "style", "value": "photo"}],
}],
})
modules = deserialize_modules(payload)
assert len(modules) == 1
assert isinstance(modules[0], FixedValueModule)
assert modules[0].entries == [FixedValueEntry("style", "photo")]
def test_missing_modules_key_treated_as_empty(self):
assert deserialize_modules('{"version":1}') == []
def test_invalid_json_raises(self):
with pytest.raises(ValueError):
deserialize_modules("{not valid json")
def test_non_dict_root_raises(self):
with pytest.raises(ValueError):
deserialize_modules("[]")
class TestBuildPayload:
def test_strips_internals(self):
ctx = {
"style": "photo",
"__wp_node_seed__": 42,
"__wp_trace__": [{"id": "m1"}],
"__wp_internal_flags__": {},
}
payload = build_payload(ctx, upstream_debug={}, seed=42)
assert payload.context == {"style": "photo"}
def test_debug_contains_seed_and_trace(self):
ctx = {"style": "photo", "__wp_trace__": [{"id": "m1"}]}
payload = build_payload(ctx, upstream_debug={}, seed=42)
assert payload.debug["node_seed"] == 42
assert payload.debug["trace"] == [{"id": "m1"}]
def test_upstream_debug_preserved(self):
ctx = {"__wp_trace__": []}
payload = build_payload(
ctx, upstream_debug={"node_seed": 1, "trace": [{"id": "prev"}]}, seed=2
)
assert payload.debug["upstream"] == {
"node_seed": 1,
"trace": [{"id": "prev"}],
}
def test_missing_trace_defaults_to_empty_list(self):
payload = build_payload({}, upstream_debug={}, seed=0)
assert payload.debug["trace"] == []
+13 -2
View File
@@ -11,8 +11,19 @@ class TestResolveVariables:
def test_basic_substitution(self):
assert resolve_variables("$name", {"name": "Alice"}) == "Alice"
def test_missing_var_left_as_is(self):
assert resolve_variables("$unknown", {}) == "$unknown"
def test_missing_var_dropped(self):
# Missing vars resolve to empty string so the prompt sent to the
# model never contains literal "$unknown" tokens.
assert resolve_variables("$unknown", {}) == ""
def test_missing_var_collapses_surrounding_whitespace(self):
assert resolve_variables("a $missing b", {}) == "a b"
def test_missing_var_collapses_comma_gap(self):
assert resolve_variables("photo, $missing, cinematic", {}) == "photo, cinematic"
def test_missing_var_trims_trailing_punct(self):
assert resolve_variables("portrait of $subject.", {}) == "portrait of."
def test_dollar_escape(self):
assert resolve_variables("$$name", {"name": "Alice"}) == "$name"
+17 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
import { resolve } from "node:path";
// Two build targets controlled by --mode flag:
@@ -25,7 +26,17 @@ export default defineConfig(({ mode }) => {
if (isExtension) {
return {
...common,
// cssInjectedByJsPlugin with relativeCSSInjection inlines each chunk's
// CSS into that chunk's JS so dynamic imports auto-inject styles at
// runtime. Vite library mode emits CSS files but never <link>s them, and
// ComfyUI loads main.js as a bare ES module — no auto-loading. See
// CLAUDE.md "Frontend overview" for the why. Entry chunk stays CSS-free
// because all our styles live in lazy widget chunks.
plugins: [vue(), cssInjectedByJsPlugin({ relativeCSSInjection: true })],
build: {
// ES2022 needed for top-level `await` in main.ts (preloads widget chunks
// before registerExtension so getCustomWidgets factories can run sync).
target: "es2022",
lib: {
entry: resolve(__dirname, "src/main.ts"),
formats: ["es"] as const,
@@ -38,6 +49,9 @@ export default defineConfig(({ mode }) => {
entryFileNames: "main.js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
// Rewrite the alias to ComfyUI's runtime URL so the browser can
// resolve it. ComfyUI serves /scripts/app.js as the live app.
paths: { "#comfyui/app": "/scripts/app.js" },
},
},
minify: "esbuild",
@@ -47,8 +61,10 @@ export default defineConfig(({ mode }) => {
emptyOutDir: true,
},
test: {
environment: "node",
environment: "jsdom",
globals: true,
include: ["src/**/*.test.ts"],
setupFiles: ["./src/test-setup.ts"],
},
};
}
+66
View File
@@ -0,0 +1,66 @@
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
import { resolve } from "node:path";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
vue: "vue/dist/vue.runtime.esm-bundler.js",
},
},
test: {
environment: "jsdom",
globals: true,
include: ["src/**/*.test.ts"],
setupFiles: ["./src/test-setup.ts"],
coverage: {
// V8 coverage works against the Vite test pipeline without a separate
// transform — the runtime emits coverage data the reporter reads.
provider: "v8",
reporter: ["text", "html", "json-summary"],
// Coverage scope: testable surface only. Files mounted against
// ComfyUI runtime APIs (window.LGraphBadge, app.extensionManager,
// app.graph, addDOMWidget, matchMedia listeners, Vue Teleport) can't
// be unit-tested under jsdom because those APIs don't exist there —
// they'd need integration tests against a live ComfyUI instance,
// which is its own batch. Excluding them keeps thresholds anchored
// to genuinely testable logic (scanners, traversal, helpers, SFCs).
include: ["src/**/*.{ts,vue}"],
exclude: [
"src/**/*.test.ts",
"src/test-setup.ts",
"src/typings/**",
// Entry glue + manager stub — top-level await, app singleton.
"src/main.ts",
"src/manager.ts",
// Widget mount glue — calls createDomWidgetHost which needs
// node.addDOMWidget at runtime.
"src/widgets/context.ts",
"src/widgets/debug.ts",
"src/widgets/assembler.ts",
// Extension hooks tied to ComfyUI globals + DOM events.
"src/extension/about-badges.ts",
"src/extension/fonts.ts",
"src/extension/graph-events.ts",
"src/extension/reactive.ts",
"src/extension/settings.ts",
"src/extension/subgraph-badge.ts",
// Toast singleton uses Teleport + ResizeObserver + canvas rect.
"src/components/shared/Toast.vue",
],
// Anchored to the measured baseline of the testable surface.
// Tighten alongside new tests; loosen only with explicit PR
// justification. Functions stays low because every Vue SFC has
// helper functions only invoked under specific user-interaction
// paths the existing tests don't cover yet.
thresholds: {
lines: 60,
functions: 40,
branches: 65,
statements: 60,
},
},
},
});
+1
View File
@@ -0,0 +1 @@
"""ComfyUI V3 node wrappers around the pure-Python engine."""
+32
View File
@@ -0,0 +1,32 @@
"""WP_PromptAssembler — fills $var placeholders from a PipelineContext."""
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from engine.template import resolve_variables
from wp_nodes.types import PipelineContext
class WPPromptAssembler(io.ComfyNode):
"""Template-fills $var placeholders using the incoming PipelineContext."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_PromptAssembler",
display_name="WP Prompt Assembler",
category="wildcard-pipeline",
inputs=[
PipelineContext.Input("context"),
io.String.Input(
"template",
multiline=True,
default="A $style portrait of $subject",
),
],
outputs=[io.String.Output("prompt")],
)
@classmethod
def execute(cls, context, template):
resolved = resolve_variables(template, context.context)
return io.NodeOutput(resolved)
+48
View File
@@ -0,0 +1,48 @@
"""WP_Context — runs a module list and emits a PipelineContext payload."""
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from engine.pipeline import PipelineEngine
from wp_nodes.types import (
ContextModulesInput,
PipelineContext,
build_payload,
deserialize_modules,
)
class WPContext(io.ComfyNode):
"""Context node: runs an ordered list of modules, emits PipelineContext."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_Context",
display_name="WP Context",
category="wildcard-pipeline",
inputs=[
PipelineContext.Input("upstream", optional=True),
io.Int.Input(
"seed",
default=0,
min=0,
max=0xFFFFFFFFFFFFFFFF,
control_after_generate=True,
),
ContextModulesInput.Input("modules", socketless=True),
],
outputs=[PipelineContext.Output("context")],
not_idempotent=True,
)
@classmethod
def execute(cls, seed, modules, upstream=None):
upstream_ctx: dict = upstream.context if upstream is not None else {}
upstream_debug: dict = upstream.debug if upstream is not None else {}
module_list = deserialize_modules(modules)
ctx: dict = dict(upstream_ctx)
ctx = PipelineEngine().run(module_list, ctx=ctx, seed=int(seed))
payload = build_payload(ctx, upstream_debug=upstream_debug, seed=int(seed))
return io.NodeOutput(payload)
+36
View File
@@ -0,0 +1,36 @@
"""WP_Debug — terminal node that emits the PipelineContext for UI inspection."""
import json
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from wp_nodes.types import DebugViewerInput, PipelineContext
class WPDebug(io.ComfyNode):
"""Inspect the context at any point in the chain. Terminal output node."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_Debug",
display_name="WP Debug",
category="wildcard-pipeline",
inputs=[
PipelineContext.Input("context"),
DebugViewerInput.Input("viewer", socketless=True),
],
outputs=[],
is_output_node=True,
not_idempotent=True,
)
@classmethod
def execute(cls, context, viewer):
del viewer # accepted for widget binding parity; no runtime use
snapshot = json.dumps(
{"context": context.context, "debug": context.debug},
default=str,
indent=2,
)
return io.NodeOutput(ui={"wp_debug_snapshot": [snapshot]})
+140
View File
@@ -0,0 +1,140 @@
"""Custom ComfyUI socket type ``PIPELINE_CONTEXT`` and widget-only types."""
import json
from dataclasses import dataclass, field
from typing import Any
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from comfy_api.latest._io import ( # pyright: ignore[reportMissingImports]
ComfyTypeIO,
comfytype,
)
from engine.context import strip_internals
from engine.modules import Module, module_from_dict
@dataclass(frozen=True)
class ContextPayload:
"""Value carried across ``PIPELINE_CONTEXT`` sockets.
``context`` holds user-facing variables (internals stripped). ``debug``
carries run metadata (upstream snapshot, seed, trace) for the debug node.
"""
context: dict[str, Any] = field(default_factory=dict)
debug: dict[str, Any] = field(default_factory=dict)
@comfytype(io_type="PIPELINE_CONTEXT")
class PipelineContext(ComfyTypeIO):
"""Socket type carrying ContextPayload across nodes."""
Type = ContextPayload
@comfytype(io_type="WP_CONTEXT_MODULES")
class ContextModulesInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_CONTEXT_MODULES"]``."""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None, # widget_type
None, # force_input
extra_dict,
raw_link,
advanced,
)
@comfytype(io_type="WP_DEBUG_VIEWER")
class DebugViewerInput:
"""Widget-only custom type — frontend binds ``getCustomWidgets["WP_DEBUG_VIEWER"]``."""
Type = str
class Input(io.WidgetInput):
def __init__(
self,
id: str,
display_name: str | None = None,
optional: bool = False,
tooltip: str | None = None,
lazy: bool | None = None,
default: str | None = None,
socketless: bool | None = None,
extra_dict: dict[str, Any] | None = None,
raw_link: bool | None = None,
advanced: bool | None = None,
):
super().__init__(
id,
display_name,
optional,
tooltip,
lazy,
default,
socketless,
None,
None,
extra_dict,
raw_link,
advanced,
)
def deserialize_modules(payload: str) -> list[Module]:
"""Parse the widget JSON payload into a list of engine module dataclasses.
Raises ``ValueError`` on malformed JSON or a non-dict root.
"""
try:
data = json.loads(payload)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid modules JSON: {exc.msg}") from exc
if not isinstance(data, dict):
raise ValueError(
f"Modules payload must be a JSON object, got {type(data).__name__}"
)
raw_modules = data.get("modules") or []
return [module_from_dict(entry) for entry in raw_modules]
def build_payload(
ctx: dict[str, Any],
upstream_debug: dict[str, Any],
seed: int,
) -> ContextPayload:
"""Construct the socket-boundary payload. Strips internals from context."""
return ContextPayload(
context=strip_internals(ctx),
debug={
"upstream": upstream_debug,
"node_seed": seed,
"trace": ctx.get("__wp_trace__", []),
},
)