Add the TypeScript mirror of engine/modules/_constraint_math.py with
identical semantics, validated against the SAME shared corpus
(tests/fixtures/constraint-corpus.json) so Py === TS parity holds. Vitest
covers 2 unit cases (multi-tag multiply, exclude-absorbing) plus the 4
corpus cases (multi-tag->1, multi-pick->6, exclude->EXCLUDE, exception->0.25).
Also bump bundle TOTAL_LIMIT 405 -> 430 KB for the upcoming SP3 constraint
reach UI; entry/per-file limits unchanged.
The inline `<span class="wp-check">` controls relied on the global `.wp-check`
rule from manager/styles/tokens.css, which is loaded in the SPA but NOT on the
ComfyUI canvas (settings.ts pulls a hand-picked CSS set; tokens.css is not in
it). So in canvas-mounted instance modals the converted checkboxes rendered as
unstyled, zero-size spans — invisible. (`.opt__check` survived because it is
component-scoped CSS, which ships into every bundle the component renders in.)
Fix: extract the wildcard-option box into `WpCheck.vue` — a shared component
whose `<style scoped>` mirrors `.opt__check` verbatim. Scoped CSS is compiled
into the component chunk, so it travels into the SPA *and* the canvas bundle
with no dependency on any global stylesheet. Route PoolSection, DisabledRules-
Section, SubcategoryFilterPicker, Push/PushBundleToLibraryModal, and
DisplayPlaygroundModal through it.
The SPA keeps its global `.wp-check` (ui/Checkbox.vue consumers) untouched.
Reverts the 405 -> 407 KB budget bump: one shared component is leaner than the
13 inline copies, so the total lands back under 405 KB.
Sweep the leftover browser-native `<input type="checkbox">` controls onto the
inline `wp-check` span so every checkmark box reads as the wildcard-option
style. Bumps the bundle budget 405 -> 407 KB for the added markup (~0.8 KB
gzip); the embed entry stays slim.
Converted (true checkboxes):
- DisabledRulesSection: per-rule disable toggles (onToggle now takes a bool).
- SubcategoryFilterPicker: exclude-null (manager twin of the pool toggle).
- PushToLibraryModal: "propagate to bundles".
- PushBundleToLibraryModal: cascade "restore missing refs".
- DisplayPlaygroundModal: 7 display-pref switches (drop the dead wp-pg__check
rule; rows become <div> so no orphaned <label> remains).
Intentionally left as-is — these are toggle SWITCHES / custom toggle buttons,
not checkmark boxes: the row-enable sliders (ModuleRow, InjectorRow,
MockModuleCard, BundleHeader), the internal-flag toggle, and seed-lock.
Tests updated to the aria-checked / click contract.
Chunk F (src/manager/components): SubcategoryFilterPicker reworked into a text-first boolean-expression editor (insert-at-cursor chips/operators, live reads-as + 'N of M match' count + validateExpression, exclude-null toggle; emits {expr, excludeNull}). RefChip shows a compact funnel indicator + hover tooltip instead of an inline subcat suffix. RichTextInput serializes ref atoms to @{uuid#name:expr!null}.
Chunk G (src/components/context/editors/wildcard): probability.ts isEnabled matches the boolean category_filter expression (parse+matches) + exclude_null. OptionRow renders per-tag CATEGORY chips colored by tag_groups axis. PoolSection: grouped quick pills (or within a group, and across) + an advanced boolean editor + exclude-null toggle.
Foundation/cascade: _shared.ts Instance.category_filter string[]->string + exclude_null; prune.ts drops stale tokens from the expression via subcatExprCascade; ContextWidget tooltip renders the expression string. Bundle-size budget 395->405 KB for the graph editor + shared parser (documented). Full vue-tsc + vitest green.
The community TS validators re-implement the engine's payload shapes by
hand, and drifted repeatedly (fixed_values entries-vs-values, wildcard
is_null omitted) -- each drift surfaced as a publish failure for a user.
This makes the engine the single source of truth.
- scripts/dump_engine_shapes.py: builds one sample payload per module
subtype, validates each against that subtype's own engine
Handler.validate_payload (raises if not engine-valid), and writes the
engine-row shape to src/validators/fixtures/engine-parity/*.json. Run
it whenever an engine module shape changes; commit the regenerated
fixtures.
- src/validators/__tests__/engine-parity.test.ts: asserts the STRICT
community validator accepts every engine-dumped fixture. 5/5 pass.
Now drift fails CI on either side: a sample that no longer matches its
handler fails the generator; a validator that rejects the real shape
fails the parity test. CLAUDE.md schema section documents the loop.
Five tightly-related changes to the registry-publishing path.
.comfyignore
Drops dev-only + repo-plumbing files from the registry archive
even though they stay in git: src/ (frontend source), tests/,
.github/, .husky/, public/images/docs/ (doc screenshots used at
SPA build time, baked into web/ anyway), vite + eslint + commitlint
+ semantic-release configs, scripts/, repo metadata (CHANGELOG,
CONTRIBUTING, CODEOWNERS, CODE_OF_CONDUCT, SECURITY). Syntax is
gitignore-style globs per the Registry spec.
[tool.comfy] in pyproject.toml
- Banner: 21:9 svg at public/images/comfy-banner.svg served via
raw.githubusercontent so the listing card on registry.comfy.org
renders the project banner instead of an empty placeholder.
- requires-comfyui = ">=0.3.36" — floor that introduces the V3
io.* schema (io.Schema, io.ComfyNode, @comfytype, ComfyTypeIO)
the entire wp_nodes/ tree uses. Older runtimes would fail at
import; the Manager warns instead.
- includes = ["js", "web"] — both build-output trees are
gitignored, so without this the registry archive would ship
without the canvas widgets (js/) or the manager SPA (web/).
public/images/comfy-banner.svg
Standalone 1400×600 svg (≈2.33:1, matches 21:9). Left half: title +
underline accent + tagline + module-kind badges (Wildcard / Fixed /
Combine / Derivation / Constraint, kind-colored). Right half: WP
Context → emits → resolved $vars → fills → Prompt Assembler flow.
Faint canvas-grid background so it reads as "this lives in
ComfyUI" at a glance.
scripts/pack-release.mjs
Fixes the manager outDir literal: ships web/ (the actual vite
outDir per vite.config.mts), not the stale web_dist/ name in the
CLAUDE.md comment. Without this the github-release zip would
ship without the SPA.
.releaserc.js — collapsed-changelog header / footer partials
release-notes-generator's writerOpts now wraps the per-commit
list in a <details> element with a fixed "Wildcard Pipeline
<version>" + doc/discord/install/issues links header. GitHub
renders <details> natively in release markdown — readers see the
TL;DR + 5 useful links above the fold (well under GitHub's
"Read more" character cutoff) and click to expand the full
feat/fix/perf log. The header text is also the part most likely
to stay useful weeks after the release.
Drops six pieces of repo plumbing in one go.
publish workflow (.github/workflows/publish.yml)
Fires on every published GitHub release + manual dispatch. Builds
the extension + manager SPA, then invokes Comfy-Org/publish-node-action
with REGISTRY_ACCESS_TOKEN so the ComfyUI Manager listing stays in
lockstep with semantic-release tags. Without this the README's
"search Wildcard Pipeline in the ComfyUI Manager" path would never
see new versions.
issue + PR forms (.github/ISSUE_TEMPLATE/, PULL_REQUEST_TEMPLATE.md)
- config.yml: blank issues off, three contact links (Discord first,
Discussions, Wiki).
- bug_report.yml: structured form with version chip, multi-select
surface dropdown covering all 10 nodes + 5 SPA areas + engine +
build, ComfyUI version, OS, Python version, repro, expected vs
actual, traceback (shell render), workflow JSON (json render),
WP Debug warnings paste-box.
- feature_request.yml: problem + proposal + alternatives + scope +
importance dropdown; nudges toward "describe the underlying
problem, not just the solution".
- PULL_REQUEST_TEMPLATE.md: summary, conventional-commit type
checkboxes mapped to release impact, related-issues line,
local-verification gate checklist, screenshots prompt for
visible changes, risk callout box.
release-zip + cleaner notes (.releaserc.js + scripts/pack-release.mjs)
- Switched commit-analyzer + release-notes-generator to the
conventionalcommits preset (already a transitive dep, no install).
- Marked chore/docs/style/test/build/ci as hidden:true so they
don't render in the GitHub release body. Only feat/fix/perf/
refactor/revert show. Past releases (v1.8.0) keep their long
notes; v1.9.0+ ships clean.
- New scripts/pack-release.mjs zips the deployable file set
(engine/, wp_nodes/, wp_api/, js/, web_dist/, public/, docs/help/,
__init__.py, pyproject.toml, README.md, LICENSE) into
dist/ComfyUI-Wildcard-Pipeline-v<version>.zip.
- @semantic-release/exec prepareCmd runs the version-stamping
AND the pack-release.mjs invocation so the zip carries the
right version in its name.
- @semantic-release/github assets config attaches the zip to the
GitHub release page so manual installers can grab a single
artifact instead of cloning at a specific tag.
Notes on what's NOT changed:
- ci.yml + release.yml stay as-is — local versions are a strict
superset of origin's (coverage gate, perf gate, fuzz job,
bundle-size budget). Release workflow already had the env +
permissions needed for semantic-release.
- bundle-size-pr.yml stays (local-only, useful).
Three coordinated changes to the WP_* node family.
1. New WP_SeedList node
Emits a list of N derived seeds for a downstream loop (is_output_list).
Three independent override toggles (override_seed / override_count /
override_strategy) each mirror the wired Loop's base_seed / count /
strategy. Connecting loop_config auto-flips override_count +
override_strategy ON so the natural intent of wiring in a loop gets
the defaults right; override_seed stays explicit opt-in.
All emitted seeds are masked to 50 bits via the new MAX_SAFE_SEED
constant in engine.seed_derive so values sit inside ComfyUI's
frontend randomize range and copy-paste reproduces identically in a
standalone sampler.
2. Context Loop type split + downstream Context seed widget lock
Split WP_CONTEXT_LOOP_CONFIG (wire payload) from a new
WP_CONTEXT_LOOP_WIDGET (DOM-widget-only TYPE). Loop's own widget
mounts on the widget type; the wire type has no factory so consumers
like WP_SeedList render their loop_config socket as a plain input
instead of being hidden by a fallback-undefined widget.
New graph walker hasUpstreamLoopOverridingSeed: WP_Context's widget
glue greys out the local seed stock widget when an upstream Loop has
override_seed ON, so users read "this value is dead while the Loop
drives it" without inspecting the Loop config separately.
3. wp_ prefix sweep + tooltips across every WP_* node input
Custom DOM widget input names now carry the wp_ prefix so canvas
socket labels read as branded WP widgets: modules -> wp_modules,
rows -> wp_rows, viewer -> wp_viewer, cleaner -> wp_cleaner,
context_loop_config -> wp_context_loop_config. Every stock input
across the node family gets a tooltip describing what it does and
when to wire it from another node. Tests + comfy_api stub updated.
CLAUDE.md notes the wp_ convention for future custom widgets.
Bundle size budget bumped 388 -> 395 KB to fit the SeedList Vue chunk
(~4.9 KB net gzip) + override lock UI; entry stays at ~4 KB gzipped.
Socket rows now scope their optional template to their OWN slot only
(top-to-bottom resolution; other $input_M stay literal). Adds a durable
general-template row (kind: "general") that survives socket churn, is
resolved after all socket rows, and composes from both raw sockets
($input_N) and socket-row bindings ($test). Engine processes socket rows
first then general rows in order; general rows honor binding validation +
the internal flag. Frontend: kind discriminator, "Add template row"
affordance, general-aware reconcile that keeps general rows, inline
binding+template editor with a refs hint, conflict-scanner + docs updates.
The bundle-size gate measures only the extension bundle (js/). The
Documentation tab lives in the manager SPA (web_dist/), so it does not
affect this budget; the earlier 395 KB bump was unnecessary and is
reverted to keep the extension gate meaningful.
Load now persists the picked template's {id,name} on node.properties
(workflow-serialized, survives reload), so Save can offer to update the
exact row the user loaded — the template-side analogue of the bundle
push-to-library identity. Clearing the template drops the ref.
Rebuilds both modals on ModalShell with the library look adapted from
ModulePickerModal / PushToLibraryModal:
- LoadTemplateModal: search + category/tags/favorites filter popover +
scrollable rows, single-click pick (no kind tabs / multi-select).
- SaveTemplateModal: name/description/category/tags + live preview, with
a dual footer — "Update existing" (the loaded or same-name row) and
"Save as new entry" (create, auto-suffixed). Saving rebinds the load
identity so the next Save defaults to updating that entry.
Bumps the total bundle budget 380 -> 385 KB for the two heavier lazy
modal chunks (~3 KB net gzip; entry untouched).
- Cross-node drag MOVE-becomes-COPY race: ContextWidget.onDrop sync-claims
consumedBy before any await so the source widget's dragend reads the
set value. dragend filters by _uid (idempotent) instead of stale
srcIdx — fixes the secondary issue where the backup pendingHandoffs
queue + dragend double-removed an adjacent target wildcard.
- pendingHandoffs queue in drag-store: belt-and-suspenders source-side
cleanup that runs even when dragend skips (constraint rows inside the
bundle frame were the trigger).
- Conflict scanner constraint_orphan_target slot allocator: per-target
sorted local positions + remaining downstream count. Each constraint
claims smallest local slot > own index, else a downstream slot. Fixes
duplicated constraints with target downstream cross-node + correctly
flags when only one local target sits BEFORE the constraint.
- collectDownstreamWildcardUuids: per-instance occurrences (not Set-
deduped) so duplicated downstream targets each count as a separate
claimable slot.
- RichTextPreview + ConstraintInstanceModal: cross-Context refs fall
back to preview-resolver cache (sub_categories, option values,
options_by_id, hasNullOption) so the canvas modal renders the actual
wildcard name, sub-cat list, and chip suffix instead of "source"/
"target" placeholders + stale matrix keys.
- Preview-resolver schema extended with subCategories / optionValues /
optionsById / hasNullOption populated from embed-bundle response.
- Constraint save hydrate: wp_api modules + bundles endpoints fill
exception source_value / target_value from source_id / target_id by
looking up the wildcard's option list before validation. Heals
library rows that stored only ids.
- TestRunner constraint table uses RichTextPreview for column headers
+ row values so nested @{uuid} refs render as chips.
- Inverted null filter UI: SubcategoryFilterPicker "Exclude null"
checkbox + RefChip "!null" suffix render.
- Bundle size budget bumped 330 KB to 340 KB.
- Drop stale CHECKPOINT-*.md docs.
User reported: dragging a bundle doesn't show an indicator and won't drop below its current position. Root cause: the resolver's "skip the dragged bundle" filter was too narrow — it excluded the dragged bundle's frame and rows whose bundle_origin === draggedUid, but kept the bundle's NESTED descendants (inner bundles + leaves whose bundle_origin matches an inner bundle of the dragged outer) as candidates.
When dragging the outer "hair_style" bundle, the inner "demo_bundle" and its leaves were still drop targets. Pointer over inner's body or its leaves resolved to zones targeting elements that move with the outer — applyDrop would no-op (self-related zone) or produce a misleading indicator at the source.
Fix:
- New helper `collectMovingBundleUids(draggedUid, bundles)` walks parent_uid descendants and returns the set of bundle uids that move with the dragged range.
- `classifyAgainst` (top-level walker) skips any bundle in that set AND any row whose bundle_origin is in that set.
- `classifyWithinBundle` body walk uses the same filter for its directRows iteration.
- Added a guard at the top of `classifyWithinBundle`: if the bundle the pointer is inside is part of the moving set (e.g. pointer in the inner bundle while dragging the outer), return null entirely. The whole region moves with the drag — no valid drop target.
Bundle-size budget bumped from 312 KB → 316 KB to accommodate the cumulative drag-redesign work (container-scoped resolver + pure applier + indicator SFC + zoom-aware offsetTop walker + moving-range filter). Net add ~1 KB; the +4 KB headroom is for follow-up polish in the same area.
Verified live via Chrome MCP at ComfyUI's 91% canvas zoom:
- Drag outer header → inner middle: returns null (no valid drop in self-region)
- Drag outer header → below outer (past everything): bar at end zone
- dragState clears on dragend ✓
The canvas combine TemplateSection used a plain textarea + a custom $var insert dropdown. The SPA's RichTextInput (atomic-chip editor) had drifted significantly ahead — chip rendering, $/\@ autocomplete with raw-text-space probe, chipify-on-boundary, click-to-edit, picker-anchored-near-chip — and the canvas was the only surface still showing raw $name text without chip visuals.
TemplateSection now mounts RichTextInput via defineAsyncComponent (chunk-split so the combine modal only pulls it in when opened) with surface="combine", varSuggestions=availableVars, and the existing template_override / library-default collapse semantics. The bespoke insert-var dropdown is gone — the chip editor's $-trigger autocomplete surfaces the same list. Detected pills, preview pane, and resolved-preview pane stay (they're separate read-only panes, not editing surfaces).
Bundle budget bumped 300 → 312 KB to fit the lazy-loaded RichTextInput chunk (~9 KB gzipped). Test rewrites use a small stub for RichTextInput so prop wiring and emit-driven updates can be asserted without booting the async resolver in jsdom.
Brings 25 legacy BEM-style class selectors into compliance with the
extension's wp-* CSS namespace convention (per CLAUDE.md "Extension
isolation"). Renames .cbm__* / .cnm__* / .dvm__* / .fvm__* / .wcm__*
in _modal-head.css and .tpl__* in _modal-template-ctrls.css, plus
every consumer reference across .vue / .ts / .css / .test files.
Also renames the root element classes .wcm and .fvm (referenced from
unscoped display-prefs.css) to .wp-wcm / .wp-fvm, and clears both
entries from the LEGACY_ALLOWLIST in check-css-isolation.mjs now that
they are properly prefixed.
pnpm check:css-isolation now exits 0; all gates clean.
Audit only sees src/manager/**/*.vue style blocks. tokens.css and
rich-text.css together hold ~88 raw border-radius values that the
script cannot reach. Spell this out in the header so future readers
don't misread `[check:radius] clean` as global coverage.
Also note the deferred-extraction point for the shared audit-script
helper — at three scripts, premature; revisit at four.
New check:radius audit flags raw border-radius values (including
directional and logical-property variants) in manager SFCs. Migrates
every existing offender to the radius scale (sm 6 / md 8 / lg 12 /
xl 16). Pill/circle exempts (50%, 9999px) left alone.
Audit script catches font-size/line-height longhand but bypasses the
CSS `font:` shorthand. 10 pre-existing instances in manager SFCs are
tagged `audit-exempt: font-shorthand` so a grep surfaces them as
known debt. Header docstring updated with the gap explanation +
CSS relative-size-keyword exemption note.
No code-path change. No new offenders introduced.
Eight-step type scale (11/12/13/14/16/20/24/30) with paired line-heights
and four weight tokens. New check:typography audit flags raw font-size
/ line-height in manager SFCs. Migrates every existing offender.
Off-grid values below 11px (8.5/9/9.5/10px) kept raw with audit-exempt
as micro badges below scale floor. Fractional values (10.5→xs, 11.5→xs,
12.5→sm, 13.5→base) rounded to nearest step, preserving compact density.
Icon accent sizes: 18px→xl(20), 26px→2xl(24), 28px→3xl(30).
- Audit regex now catches padding-inline-start / margin-block-end /
etc. logical properties; previously the single -segment suffix only
matched padding-inline. Confirmed via injection of
`padding-inline-end: 14px` → audit exits 1.
- Docstring + tokens.css comment now correctly distinguish 2px
(= --wp-space-1 token) from 1px (off-grid hairline exemption).
- HAS_PX_RE guard comment lists all three cases it skips.
Pure documentation + latent-bug fix from code review. No consumer
files changed.
- Audit script no longer short-circuits multi-value shorthand when one
piece is a token — every piece must independently be a token or
allowed-raw hairline.
- Strip `audit-exempt` keyword from ~72 migration-history comments
left on lines that successfully tokenized. Reserve the keyword for
actual raw-px exemptions so grep `audit-exempt` stays a reliable
signal.
Adds nine-step spacing scale (0, 2, 4, 6, 8, 12, 16, 24, 32) and a
check:spacing audit that flags raw px in padding/margin/gap inside
manager SFCs. Migrates every existing offender to tokens.
Density work in Phase 1.4 will wrap these values in calc() with a
--wp-density multiplier; this commit establishes the fixed-pixel
baseline.
Pipelines never paid off as a separate module kind, so peel out:
- Engine: drop pipeline_handler, remove "pipeline" from valid module
types in repositories + schema parity.
- Manager: delete Pipelines/PipelineEditor views, PipelineSteps +
PipelineStepPicker components and their tests. Router, sidebar,
topbar, dashboard, TestRunner, ImportExport, resolver and
bundleSelection repointed off the removed routes/types.
- wp_api: drop pipeline branch from modules.py + import_export.py
(and its test).
In its place, add a unified library view:
- src/manager/views/AllItems.vue — modules + bundles in one filter
panel with kind multi-select (OR across the 6 kinds). Reachable
via the Dashboard "View all" link.
- ModuleListView grew the slots/props AllItems needs; BundleAddChild
+ Dashboard + ImportExport updated alongside.
Misc polish along the way: kind-icons / theme tokens / display-prefs
touch-ups, ContextWidget + DebugViewer + AssemblerHelper small fixes,
MockModuleCard + ModulePickerModal tweaks, _shared widget cleanup.
Four-layer extension isolation now documented in CLAUDE.md:
- .wp-* class namespace + .var-N helpers (primary)
- @layer wp-extension wrap on standalone CSS (cascade priority)
- WeakMap stashes for litegraph object metadata (collision-proof)
- node.properties string keys for persistent state only
scripts/check-css-isolation.mjs walks src/components, src/extension,
src/widgets and flags any unscoped top-level class selector that
doesn't start with .wp- or .var-N. Runs via pnpm check:css-isolation.
Two pre-existing BEM-style class roots (.wcm, .fvm) are allowlisted
with TODO notes — renaming them to .wp-* prefix is a follow-up task.
The script is intentionally light (no stylelint dep — ~80MB of dev
deps avoided). Catches the most common convention violation; deeper
issues (e.g. scoped block leaks via :deep, scope-breaking @import)
remain a manual-review concern.
Batch 2 (bundle drag/drop overhaul) lands four new feature surfaces
that the 256 KB gate could not absorb without per-task code-contortion
that ate into clarity: bundle-as-unit drag, before/inside/after drop
indicators, suppressMove FLIP polish, and the regression test surface.
Bumping to 300 KB matches the prior cycle's scope-and-approve pattern
(250→256 KB at 2026-05-10) and leaves headroom for subsequent batches
without forcing another bump per cycle.
Entry budget (30 KB main.js) unchanged.
One-shot Python migration that walks every existing constraint module's
payload.matrix and rewrites rows keyed by source-option VALUE to be
keyed by source SUB_CATEGORY instead. Closes the data-shape gap left
by the d47e50e SPA flip (matrix UI is now subcat × subcat) — without
this script, legacy constraints still have value-keyed rows that
render zero cells in the new editor.
Behavior:
- Already subcat-keyed rows are kept (idempotent, safe to re-run).
- Value rows are remapped via source.payload.options[*].sub_category
lookup; multiple values mapping to the same subcat merge cells (last
cell wins per target column, with a `merged` warning in the log).
- Orphan rows (neither subcat nor known option value) are dropped.
- Missing/broken source_wildcard_id → constraint skipped with a `!`
warning, no crash.
- --dry-run flag prints the plan without writing.
Also updates seed_mock_data.py:shirt_x_color_compat to use the new
subcat-keyed shape (formal × warm reduce) plus carries the original
"white linen × red exclude" intent into the exceptions table — so
re-seeding a fresh DB doesn't reintroduce the legacy shape.
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).