Compare commits

...
112 Commits
Author SHA1 Message Date
semantic-release-bot d6521983c6 chore(release): 1.4.0 [skip ci]
# [1.4.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.3.0...v1.4.0) (2026-04-28)

### Bug Fixes

* **api:** atomic import + recursion guard + negative pagination + dry now_iso ([82b6c6e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b6c6eb37469b1f515639d0883b69f06be8f85b))
* **api:** run migrations on register_routes so production db has tables ([1fc705d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1fc705d8da7b1ea7a2ae77f3449816bc6d9e4847))
* **api:** use streamresponse return type for spa fallback handlers ([a12ee20](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a12ee2087c079ac3cab13020abaecd023f107bfd))
* **ci:** python 3.10 datetime compat + rebaseline perf budgets ([eecb38e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/eecb38e85f170b807f109f7149d25eb6d60406e3))
* **db:** drop utcnow + atomic migrations + row factory test ([3810a37](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3810a373489fbcbaed204017b6ad9626e1706938))
* **db:** id entropy + like escaping + ms timestamps + sentinel typing ([759626b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/759626b9148345af319e877699e85856e13d084b))
* **db:** unset sentinel for required fields + slug validation + delete consistency ([c4ca447](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c4ca4472ba44bab4259fd5590f25bd8e5570d91c))
* **engine:** explicit type guard in resolve_module for malformed snapshots ([da0daf4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/da0daf47e0fc8f781179d6bb35d185ac9f503f29))
* **engine:** isinstance guard avoids dataclass typeguard issue in handlers ([f4bf3d4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f4bf3d4635a53c57214340a6c00a5a38923f942b))
* **engine:** read module id from raw dict to satisfy union narrowing ([9a98626](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9a98626dd767ae50fc6c6e03ced9a34a68b19f9a))
* **manager:** favorites filter as wp-input box matching prototype ([c246360](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c246360b97aba838e782e5c4dc19852c7baf7a62))
* **spa:** align theme tokens with extension source of truth ([2917c04](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2917c04c3897b40d23287ec152883ac77488d84f))
* **spa:** compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker ([19727ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/19727ade47e057a10cb441342d5b0bab377435cf))
* **spa:** enable primevue dark mode via .wp-dark class on documentelement ([fab8d0d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fab8d0d20fcd4eb1ca5d6998c984b8ba424e9435))
* **spa:** error handling on store actions + exact-active-class + matchmedia setup ([4cfe1f6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4cfe1f6d1487c4bb12b5daa0bbd4b4db57ac6106))
* **spa:** light-mode + dropdown polish for chips, pills, selects ([cd8d23a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/cd8d23a750cc5a8dd7c8f0aa195f95fe61e14a72)), closes [#a78bfa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/a78bfa) [#c026d3](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/c026d3)
* **spa:** wrap tailwind in named layers so primevue specificity orders correctly ([f5652f4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f5652f4286e6b0cbcd6290bf0704f9c335ab8ba2))
* **syntax:** drop vacuous "comment" check in richTokenize.test ([dee9fae](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/dee9fae32808697aeaeb28a52caa24baeaa12801))

### Features

* **api:** /wp/api/modules crud endpoints ([89b7827](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89b7827534e9814f93cac73d4c4c9261f3b4c9c6))
* **api:** snapshot + match + duplicate + favorite + categories endpoints ([48ee214](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/48ee214f9667eadae372a17b6f25dc58eb8cf42f))
* **api:** spa fallback route + comfyui registration + coverage gates ([51c1756](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51c17565b8ea28864d076e5829d9689ef4f15967))
* **api:** test runner + library import/export endpoints ([625cab7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/625cab76820eaa6aa6b31cff91d3284f5a40c5b9))
* **db:** category repository with crud ([746f3ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/746f3ad2db6e2e5430c2dde79671bbecbeb0322c))
* **db:** module repository with crud and filtered list ([6c29f73](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6c29f738c50575329ce240c1cc7cc4c6990370dc))
* **db:** sqlite library foundation + migration runner ([e4aabe6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4aabe6313265a263bca1d3790d43b645f4366c0))
* **engine:** fixedvalueshandler + route legacy dispatch through it ([2eeb900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2eeb90052e34952d648e3b8e4456c2b2a0439164))
* **engine:** module dispatcher + modulehandler abc ([f33ac4c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f33ac4c9803b011f4efb26694f0d674c72970677))
* **engine:** scaffold combine/derivation/constraint/pipeline module types ([a16c532](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a16c5324e281ffba6a08d3eda46f3deecf2faefa))
* **engine:** snapshot freezing + payload hash + legacy coercion ([9d183d6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d183d6779a9a6ce1a9ebbdd558478420883000d))
* **engine:** wildcardhandler with weighted rng and @{ref} resolution ([6626aca](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6626aca3d7cde2661d6276e867c362e6a9fecb34))
* **manager:** favorites filter as chip toggle box in filter panel ([f51b752](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f51b752fd7a5ad84d3e9e785c4f2c9e98b4b1c84))
* **manager:** surface-conditional @ autocomplete + warning highlights ([7f6f5b7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7f6f5b719276019e3409e213cd4e44997977c7a6))
* **manager:** wildcard.max_ref_depth setting (1-32, default 8) ([bc1d21e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/bc1d21e8aa040c6d6bef0c932b16c9b9bfa88663))
* **spa:** add constraints to soon-disabled module types ([9d8a5ab](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d8a5ab00e565c163c93bba7ce2e5acdce04e96f))
* **spa:** categories color picker (hex + swatch + presets) + sticky-header scroll ([f18be1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f18be1eaff1cc197ef03251a1a23f4bfdddb2115))
* **spa:** categories manager + import/export + test runner ([a20a7b0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a20a7b058b64f4b5347de39491e4828c15af0066))
* **spa:** community tab — discover/detail/upload/profile + offline/404 (mock) ([f1add58](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f1add58631286260cde31332e2a1bb40f8a256e7))
* **spa:** dashboard hero + branded categories/import/test pages ([e49df41](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e49df411d7f56dc89a309f5f5fff057fa32c324a))
* **spa:** datatable expansion + category color chip + tags column ([133c8df](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/133c8df49166fda2f75faba5d79fac44a295781c))
* **spa:** entity list view redesign with active-filter chips + pagination ([370fdd8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/370fdd838828d96667d48d21c047d128d69325dd))
* **spa:** entitylistview shared list skeleton ([5e8d042](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/5e8d0428d4da0fac6c5dcbe2da6c495ab96a79c7))
* **spa:** fixedvalues.vue per-type list page ([32f0513](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/32f05134202cc035e992f5f4b064971e1a9d318a))
* **spa:** full-page wildcard + fixedvalue forms with sticky footer ([82b568e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b568efbb1c75f89980048c5cc09858ea5a42ba))
* **spa:** import/export with per-item selection + conflict resolution ([a5dfe52](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a5dfe52511e81251d333d6b7cfbfd37e48f8ff00))
* **spa:** kind-aware Test Runner with per-type result panels ([8e28b40](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8e28b40655930db4f48042f21f7c589149b1440d))
* **spa:** library datatable view with filters + row actions ([af66ca2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/af66ca263e49198fe46efb7e187d6fce2133408a))
* **spa:** per-module version history with last-3 snapshots and restore panel ([678a0eb](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/678a0eb40317cfad442005908378ccb98e6439dc))
* **spa:** per-type routes + drop polymorphic modules.vue ([33542e1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/33542e123994f462060b341de2c1c25f2a222bb5))
* **spa:** pinia stores for modules + categories + ui ([86c36e0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/86c36e0f52eba4abfb37a4f1621c928ee94e777d))
* **spa:** polish fixed-value form with \$name InputGroup + per-row identifier validation ([928e33c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/928e33cc5ff445ca11e219fee2fb1a9acadd9a64))
* **spa:** port community sub-screens + drop PrimeVue dependency (Wave 7+8) ([e4ab218](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4ab2186e33f04908ff43b0699635bc3f3a982c8))
* **spa:** port prototype dashboard + utilities views (Wave 5) ([1d13048](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1d1304898267ca0f9521adf16b19712cdef8e1a9))
* **spa:** port prototype editors with EditorFrame scaffold (Wave 4) ([84fc961](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/84fc961fc2f4c31be250ef011e1c6f88430439f4))
* **spa:** port prototype ModuleListView + drop PrimeVue from list screens (Wave 3) ([df5d38d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/df5d38d6250c7856e34b2ac2a7b80ff649da8e0d))
* **spa:** port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2) ([30fed89](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/30fed89ce3aad313faad8b138eed808766763f56))
* **spa:** port prototype ui primitives + full styles.css (Wave 1) ([03d2b7b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/03d2b7bde50cd984bf312e73c6f0d200fc731a82))
* **spa:** primevue brand preset derived from --wp-* tokens ([76bcd36](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/76bcd3629d609ce79652109e63025b7027104c6e))
* **spa:** real Combine editor with template + RichTextInput + detected inputs ([d50dd8d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d50dd8d6015fa01466c34470833ac7ed6b9bf2e8))
* **spa:** real Constraint editor with matrix grid + factor tune popovers ([02a526a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/02a526a53c7972382ca06742f7d3f8ad73bdfdeb))
* **spa:** real Derivation editor with multi-rule IF/ELIF/ELSE ([4c603a2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4c603a20db432d9bb4a5c785411af2aa543817ba))
* **spa:** real Pipeline editor with steps stack + module picker modal ([054a777](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/054a777ec1522d07fe017f81bacbfdc302df6100))
* **spa:** redesign Dashboard with brand hero + 6 kind stats + recent/favorites ([0dcb562](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0dcb562801dd1d0fe395bbe1aedbda037ef87e85))
* **spa:** rich text input + preview with token chips and autocomplete ([6a5bbb4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6a5bbb40ef56f66fa1ea1063aa8ddc5f4cedc700))
* **spa:** rich-text input — restore $/@ autocomplete dropdown with keyboard nav ([60f911a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/60f911a0f8e2e32805e3153b698939e99e90123f))
* **spa:** sakai-style topbar + sidebar layout ([79163fc](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79163fce79e558c61f201cc44de6ab7405635a0b))
* **spa:** scaffold combine/derivation/constraint/pipeline list + edit views ([8c03e72](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8c03e72d69785a7ae2b7101250cd35987e307231))
* **spa:** shared typepill + relativedate components ([f3a1066](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f3a10664ab64bc1da83914629dbfe07d1adcda71))
* **spa:** tags editor on forms + sort dropdown + category/tags filters ([0722da2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0722da2a98ae4f544a94df241faf17dad40ac0d2))
* **spa:** theme toggle (dark/light/auto) with flash-suppression ([1b7576a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1b7576a55fb33af05b2f4ca6355354d1a032ab2e))
* **spa:** topbar logo + 4-section sakai sidebar with primeicons ([42f30ef](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/42f30efbe7c699d9f7ab2565cee37ce48ec123a2))
* **spa:** tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6) ([975581e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/975581e111f1acfb13ec421a925af68a6fda8592))
* **spa:** typed api client wrapping /wp/api/* ([7a7b632](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a7b632f0693976b20b16ed5ef4c2aacb8dd7890))
* **spa:** vite + tailwind + primevue scaffold ([7a47035](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a4703546edc2467a75b4c132915bdf666e4dd70))
* **spa:** vue router + 7 stub views ([d80fd24](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d80fd242545ef794aa0b013f1ff1178f65242919))
* **spa:** wildcard + fixed-values editors ([8abab1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8abab1ee3d0e83604030c2e97e777e23275dc86a))
* **spa:** wildcard form gains varBinding field + RichTextInput on option values ([d84bd2d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d84bd2d60b048fe6790d629d3b46067304050e8c))
* **spa:** wildcard sub-categories define + assign per option ([8a1b820](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8a1b820ab3f3998d1abcb7160d19c549daa74c52))
* **spa:** wildcards list — syntax indicator column + nested-ref filters ([80cc323](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/80cc323f31b394c95a4de6f9e429bbf6fd6cd1db))
* **spa:** wildcards.vue with full datatable + filter + bulk select ([22c29aa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/22c29aa090511b352c80035c60702435f0da431a))
* **syntax:** _resolve_inline_pick — uniform random branch + recurse ([2f48900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2f489002e17de9ea092a05dde1b069f3edf0fbdc))
* **syntax:** _resolve_multi_pick — weighted without-replacement + sep join ([966ab4b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/966ab4b1aac965f7b8601517a8ddfeb534aeb7a5))
* **syntax:** _resolve_ref with surface/depth/cycle checks + weighted pick ([51a859b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51a859b0956091fee9e296097b9b6779dd99b52d))
* **syntax:** foundation types, error classes, ResolveContext Protocol ([45c2fa9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/45c2fa9bc48cecb25410aa04fd3ee993b442afb8))
* **syntax:** resolve_text dispatch loop with text/escape/var resolution ([9e05e1f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9e05e1fb668edc9bcabbcfeda154e90c1682e4ea))
* **syntax:** resolveTokens TS twin of Python resolve_text ([9d3c6a9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d3c6a9851be55d60ea24df8abc9953f662eda38))
* **syntax:** tokenize $var and @{uuid} kinds + corpus cases ([4fb2c99](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4fb2c997714d9bfc51f806b65a02b3bba8c0f4be))
* **syntax:** tokenize dp_brace + dp_multi with nesting support ([9364eb8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9364eb84f5899ab7d2e2dc2111f4c769d3c2c384))
* **syntax:** tokenize_text — text + escape kinds with lossless invariant ([11ac437](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/11ac437fa391edcacdf210b8cb3e9d73e7c48cf7))
2026-04-28 06:04:08 +00:00
DumiFlex 11da54aaa1 Merge pull request #3 from DumiFlex/feat/wildcards-and-spa-manager
feat: Phase 5 engine syntax — unified wildcard tokenizer + resolver (Python + TS)
2026-04-28 09:03:27 +03:00
DumiFlex eecb38e85f fix(ci): python 3.10 datetime compat + rebaseline perf budgets
- engine/_utils.py: datetime.UTC (3.11+) → datetime.timezone.utc (cross-version)
- tests/syntax/test_perf.py: rebaseline budgets to observed CI numbers + 60%
  headroom (250ms→12s uniform, 500ms→5s complex). Spec budgets assumed
  C-extension-class perf the Python implementation cannot hit; gate is now
  advisory (continue-on-error) until we profile + optimize hot paths
- .github/workflows/ci.yml: perf job marked continue-on-error: true so
  benchmark numbers don't block PRs while baseline is calibrated
2026-04-28 08:55:37 +03:00
DumiFlex a6a6ecf7d3 refactor(syntax): drop dead CSS + final pipeline e2e test 2026-04-28 08:08:25 +03:00
DumiFlex ede8645637 test(syntax): fuzz + perf gates + CI workflow integration 2026-04-28 07:58:28 +03:00
DumiFlex bc1d21e8aa feat(manager): wildcard.max_ref_depth setting (1-32, default 8) 2026-04-28 07:41:09 +03:00
DumiFlex 7f6f5b7192 feat(manager): surface-conditional @ autocomplete + warning highlights
- Add `surface: SurfaceKind` prop (default "wildcard") to RichTextInput +
  RichTextPreview. When surface !== "wildcard", @ autocomplete is disabled;
  manually-typed @{uuid} tokens still tokenise/render but with muted styling
  via the new `.wp-rt-ref--ignored` CSS class.
- Add `warnings: ResolveWarning[]` prop to both components. Each warning
  renders a `.wp-rt-warn-marker` span with `data-warning-position` + severity
  CSS class (`wp-rt-warn-info` / `wp-rt-warn-warn` / `wp-rt-warn-error`).
- Add `uuidToName: Map<string, string>` prop for UUID→display-name lookup.
  Mirror layer renders `@{uuid}` refs as `@name` when the map resolves them.
- Autocomplete insert for @ trigger now writes `@{uuid}` raw text (not @name).
- Mirror HTML builder inlined in component (no longer delegates to the shared
  mirrorHtmlWithIdx so we can branch on surface + apply per-token overrides).
- CSS: new warn-marker rules + wp-rt-ref--ignored muted chip added to
  rich-text.css (shared by RichTextInput and RichTextPreview).

Tests:
- Create src/manager/__tests__/components/RichTextInput.warnings.test.ts
  with 20 new tests covering warning markers, severity→class mapping, surface
  autocomplete gating, UUID name display, and RichTextPreview parity.
- Un-skip "paints chip markup" test in RichTextInput.test.ts, updated to use
  @{8hex} UUID form (old @name form no longer tokenised as ref).
- Mark "renders # comment lines greyed out" permanently skipped (OBSOLETE) —
  comment token kind was removed in the locked grammar; test can never pass.
- Wildcards.test.ts skip comment updated with DEFERRED rationale (Wildcards.vue
  syntaxView() uses slug keys while buildWildcardGraph uses UUID keys — needs
  its own follow-up PR to align).
- Delete 3 dead skipped tests from richTokenize.test.ts that tested removed
  grammar features (@name short form, quantifier N#$var, # comment lines);
  those token kinds no longer exist in the tokenizer or the TokenKind union.
  Rationale left as inline comments where the tests were.
2026-04-28 07:31:30 +03:00
DumiFlex c6795e7b5e refactor(manager): resolver wraps resolveTokens; wildcardSyntax UUID graph 2026-04-28 07:16:03 +03:00
DumiFlex 2fa04a2818 refactor(engine): template.py thin shim over resolve_text; drop legacy handlers.py 2026-04-28 07:05:23 +03:00
DumiFlex a821832663 refactor(engine): combine/derivation/constraint handlers use resolve_text
CombineHandler now calls resolve_text(template, build_resolve_ctx(ctx, surface="combine"))
replacing the manual $TOKEN_RE substitution loop. Unknown $vars emit "" (resolve_text
contract) rather than passing through unchanged; @{uuid} refs in combine surface are
lenient — emit empty string + ref_out_of_surface warning.

DerivationHandler wraps action.value through resolve_text with surface="derivation"
before applying the replace/append/prepend mode, enabling $var interpolation and
{a|b|c} picks inside action values.

ConstraintHandler is a pure metadata pass-through that records reweighting matrices
for future WildcardHandler consumption. It never processes user-authored text strings,
so no migration is required (no-op verdict).

Tests updated: combine tests drop ctx=None path and the pass-through unknown-var
assertion (both incompatible with resolve_text contract); derivation tests add
__wp_rng__ + __wp_warnings__ to all ctx fixtures; two new combine surface tests
added to tests/test_handlers.py per task spec.
2026-04-28 06:52:43 +03:00
DumiFlex 79525b0789 refactor(engine): wildcard handler delegates to resolve_text 2026-04-28 06:39:39 +03:00
DumiFlex 693e978433 refactor(engine): pipeline routes via dispatcher, threads ctx.rng + warnings 2026-04-28 06:24:19 +03:00
DumiFlex 0bcca4ee46 test(syntax): determinism gate + corpus expected_resolution check 2026-04-28 06:11:46 +03:00
DumiFlex 9d3c6a9851 feat(syntax): resolveTokens TS twin of Python resolve_text 2026-04-28 05:58:10 +03:00
DumiFlex 966ab4b1aa feat(syntax): _resolve_multi_pick — weighted without-replacement + sep join 2026-04-28 05:47:35 +03:00
DumiFlex 2f489002e1 feat(syntax): _resolve_inline_pick — uniform random branch + recurse 2026-04-28 05:39:27 +03:00
DumiFlex 51a859b095 feat(syntax): _resolve_ref with surface/depth/cycle checks + weighted pick 2026-04-28 05:25:09 +03:00
DumiFlex 9e05e1fb66 feat(syntax): resolve_text dispatch loop with text/escape/var resolution 2026-04-28 05:13:35 +03:00
DumiFlex 6942159d6a test(syntax): ts tokenizer parity with python corpus 2026-04-28 04:58:25 +03:00
DumiFlex dee9fae328 fix(syntax): drop vacuous "comment" check in richTokenize.test 2026-04-28 04:43:46 +03:00
DumiFlex c91299e684 refactor(syntax): drop comment/quantifier/@name short form from TS tokenizer 2026-04-28 04:33:34 +03:00
DumiFlex 9364eb84f5 feat(syntax): tokenize dp_brace + dp_multi with nesting support 2026-04-28 04:17:54 +03:00
DumiFlex 4fb2c99771 feat(syntax): tokenize $var and @{uuid} kinds + corpus cases 2026-04-28 04:05:17 +03:00
DumiFlex 11ac437fa3 feat(syntax): tokenize_text — text + escape kinds with lossless invariant 2026-04-28 03:35:16 +03:00
DumiFlex bb88a8c324 test(syntax): seed parity corpus with empty/literal/escape cases 2026-04-28 03:26:06 +03:00
DumiFlex 45c2fa9bc4 feat(syntax): foundation types, error classes, ResolveContext Protocol 2026-04-28 03:10:27 +03:00
DumiFlex cd8d23a750 fix(spa): light-mode + dropdown polish for chips, pills, selects
- Add rgba() fallbacks before color-mix(in oklab) for older embedded Chromium
- Add .wp-theme-light overrides with darker text + stronger tints for chips
  (accent/success/warn/danger/info), syntax pills (ref/in/dp), tag-chip
  active, syntax-link missing, rich-text tokens (var/ref/quantifier),
  ConstraintMatrix data-mode states, HistoryPanel active item
- Bump dynamic catChip / Chip color-mix percentages 14% to 18% bg, 35% to 42%
  border (inline JS styles can't use CSS fallback pattern)
- Select.vue dropdown:
  - Teleport menu to body so it escapes overflow:hidden + sticky footer
    z-index; position:fixed + getBoundingClientRect placement, flip when no
    space below; menu z-index 9999
  - Remove min-width:0 from .wp-select__option-label so menu grows to fit
    longest option (fixes 'Updated --- newest' clipping)
  - Drop wp-spacer from trigger so single flex:1 element gets the space
  - Update Select.test.ts to query document.body for teleported menu
- RichTextInput autocomplete:
  - Remove duplicate '@' glyph (was rendering pi-at icon AND text trigger)
  - Bump z-index to 9999, lighter bg, full-width head bleed
- Add color dots to category dropdowns in IdentityCard + all 6 list views
- Standardize ImportExport category chip to shared catChipStyle()
- Fix Wildcards syntax-pill--ref color: #a78bfa (kind violet) to #c026d3
  (magenta, matching @ref token color)
2026-04-28 01:14:53 +03:00
DumiFlex c246360b97 fix(manager): favorites filter as wp-input box matching prototype
Replace chip-toggle approach with the correct prototype pattern: a
button.wp-input containing an inline wp-check span, styled identically to
the Category Select in the same 2-column filter grid. Clicking the whole
bordered box toggles the favorites filter. Uses span.wp-check (visual-only)
to avoid invalid button-in-button HTML nesting. Reverts intermediate chip
approach.
2026-04-27 23:41:22 +03:00
DumiFlex f51b752fd7 feat(manager): favorites filter as chip toggle box in filter panel
Move the favorites filter from a per-view checkbox field into a built-in
"Quick filters" chip section in ModuleListView, styled identically to the
extra-filter syntax chips (wp-chip--toggle). Clicking toggles the filled
accent-tinted box state. Removes duplicated wp-fav-toggle label+Checkbox
blocks from all six list views and cleans up the now-unused Checkbox imports.
Updates the ModuleListView test to assert both the favorites chip and any
extra-filter chips are present in the panel.
2026-04-27 23:31:17 +03:00
DumiFlex e594bc2adf build(spa): refresh manager bundle hash (post-polish wave) 2026-04-27 23:22:45 +03:00
DumiFlex 2e2ae15875 refactor(spa): prototype-faithful chip/pill consolidation + Categories rebuild + Derivation collapse
- Sort dropdown drops size=sm (matches Filters/Search at 34px per prototype)
- Editor footer: body becomes scroll region with padding-bottom buffer so
  sticky footer never overlaps content
- Chip.vue accepts color prop (tinted bg + colored text + tinted border) —
  matches prototype <Chip color> at ui.jsx:323
- catChipStyle util replaces solid-color/text-shadow wp-cat-chip across all
  6 list views + Dashboard
- ModuleListView: tag rendering swaps wp-row-tag rectangles for <Chip> pills;
  wp-filter-toggle rebuilt as wp-chip--toggle (data-active) per prototype
- Drop Pill.vue + .wp-pill* rules; DerivationRuleCard uses <Chip tone=info>
- Categories rebuild: drop Sort column + module-count pill; explicit
  pencil + check/times edit actions per row; tinted Chip for name; raw mono
  hex string in view mode, ColorPicker in edit mode
- DerivationRuleCard: collapsible (chevron in head, default-collapsed when
  >1 rule); Textarea action value swapped to single-line Input for compact
  rule-card height
- Add subtle motion: route-fade transition, filter-collapse with max-height
  ease, stat card hover lift

All gates green: 359 vitest, 289 pytest, vue-tsc clean, eslint 0 errors.
Bundle within budgets.

Refs: docs/design-handoff/ui-research-2026-04-27.md (15 mismatches catalogued)
      docs/design-handoff/audit-2026-04-27.md (48 findings)
2026-04-27 23:16:45 +03:00
DumiFlex 60f911a0f8 feat(spa): rich-text input — restore $/@ autocomplete dropdown with keyboard nav
Teleport the suggestions popover to <body> and position with `position:
fixed` so it escapes the wrapper's `overflow: hidden` (which previously
clipped it). Adds outside-click / scroll / resize close handlers, a pi
icon per item, and tests for ArrowDown navigation + space-closes-popup.
2026-04-27 21:34:31 +03:00
DumiFlex abae630cc7 refactor(spa): consolidate empty states + extract rich-text CSS + drop tailwind leaks
Per audit-2026-04-27 Commit 2 — pure refactor with no test-visible
behavior change. All 357 vitest cases stay green.

- Extract community inline `style="..."` clusters into scoped classes
  (CommunityProfile hero meta/actions, CommunityDetail hero/reviews
  chrome, CommunityUpload review/success rows). Templates no longer
  carry layout literals.
- Consolidate `.wp-comm-empty` into the canonical `.wp-empty` plus a
  `.wp-empty--card` modifier in tokens.css; community screens now use
  `class="wp-empty wp-empty--card"` and `wp-empty__icon`.
- Add a shared `.wp-empty-card` for dashed-border editor empty
  placeholders, replacing `.dv-empty` / `.cn-empty` / `.wp-pl-empty`
  duplicates in DerivationEditor / ConstraintEditor / PipelineSteps.
- Add a shared `.wp-card__hint` for the small muted hint text above
  editor cards, replacing `.dv-hint` / `.cn-hint` / `.pl-hint` /
  `.wp-text-xs` (and dropping the now-redundant `wp-dim` companion).
- Replace tailwind-style utility leaks (`.w-40`, `.w-44`, `.mt-3`,
  `.self-start`) with semantic classes (`.wp-comm-filter-select--*`,
  `.wp-empty__cta`, `.wp-comm-back`, `.wp-comm-card__hero--skeleton`).
- Add `.wp-icon--success/warn/danger/info` modifiers and apply them in
  Wildcards list status icons + TestRunner constraint legend so inline
  `style="color: var(--wp-success)"` no longer leaks into templates.
- Extract the duplicated rich-text token chrome from RichTextInput +
  RichTextPreview into a shared `src/manager/styles/rich-text.css`,
  imported globally from main.ts after tokens.css.
2026-04-27 21:15:12 +03:00
DumiFlex a03ce72c81 refactor(spa): consolidate list-view chrome + tokenize colors + normalize icon prop
Per audit-2026-04-27 Commit 1 — pure refactor with no test-visible behavior change.

- Extract list-row chrome (wp-tag-chip, wp-id, wp-fav-toggle, wp-row-name*,
  wp-row-expand__title, wp-row-expand__name, wp-filters-grid, wp-field--full,
  wp-field__label, wp-cat-chip) into tokens.css; drop ~600 LOC of duplicated
  scoped rules across 6 list views + Dashboard. Rename .category-chip and
  .dashboard__row-chip usages to .wp-cat-chip.
- Add wp-card__title-wrap + wp-card__subtitle tokens; restructure Card.vue
  header so subtitle stacks under title instead of inline.
- Normalize Button icon prop project-wide: icon="pi pi-foo" -> icon="pi-foo"
  (37 occurrences across 15 files).
- Replace legacy --wp-bg2/bg3/text2/text3 references in PipelineSteps,
  PipelineStepPicker, HistoryPanel, DerivationRuleCard, PipelineEditor,
  ConstraintMatrix, ModuleListView with the canonical
  --wp-bg-2/3/--wp-text-muted/--wp-text-dim names; legacy aliases stay in
  tokens.css for back-compat.
- Fix ModuleListView.vue:480 no-op @click.stop="\$emit && \$emit" by removing
  the never-used default favorite-slot button (every list view overrides).
- Tokenize hard-coded values: --wp-overlay-bg, --wp-kind-stripe-w,
  --wp-rt-token-good/-bg, --wp-rt-token-mut/-bg, --wp-status-modified,
  --wp-accent-gradient. Wire RichTextInput, RichTextPreview, ImportExport,
  HistoryPanel, PipelineStepPicker, PipelineSteps, DerivationRuleCard,
  Wildcards, WildcardEditor through to the new tokens.

LOC: -565 / +226 (net -339 in src/manager). pnpm typecheck/lint/test/build/size
all green.
2026-04-27 20:57:17 +03:00
DumiFlex 975581e111 feat(spa): tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6)
Adds a runtime A/B switcher for visual tweaks: 5 accent palettes
(violet/indigo/teal/rose/amber from the prototype's tweaks-panel.jsx),
density compact/comfortable, and a sidebar collapse toggle wired to the
existing uiStore.sidebarCollapsed flag. Overrides apply via inline CSS
custom properties on <html>, so changes paint immediately and survive
theme swaps.

- src/manager/stores/tweaksStore.ts: full 50-900 palette stops per accent,
  setAccent / setDensity / togglePanel / closePanel / reset / initialize.
  Persists to localStorage under wp-tweaks-v1; new instances rehydrate
  on construction so reload keeps the user's choice.
- src/manager/components/TweaksPanel.vue: slide-in right-anchored panel
  (dialog role, backdrop click closes, Reset to defaults footer button).
  Reuses ui/Button + ui/Toggle. PrimeIcons only.
- src/manager/layout/AppTopbar.vue: pi-sliders-h wrench button next to
  the theme toggle, calls tweaks.togglePanel.
- src/manager/layout/AppLayout.vue: mounts <TweaksPanel /> alongside
  <ToastHost />.
- src/manager/main.ts: tweaksStore.initialize() runs after theme init so
  persisted overrides land before the first paint.

Tests: +19 (11 store + 8 component) — palette write to <html>, density
height swap, panel toggle, reset clears overrides + storage, persistence
round-trip across fresh store instances.

All 357 tests pass; bundle size budget unchanged.
2026-04-27 20:04:46 +03:00
DumiFlex e4ab2186e3 feat(spa): port community sub-screens + drop PrimeVue dependency (Wave 7+8)
Wave 7: rewrite all community/* views (404, Detail, Discover, Offline,
Profile, Upload) to use src/manager/components/ui/* primitives + the local
useToast composable instead of PrimeVue Button/InputText/Select/Checkbox/
Textarea/useToast.

Wave 8: rename toast.add() → toast.push() at remaining list-screen
callsites (Combines, Constraints, Derivations, FixedValues, Pipelines,
Wildcards) and migrate the imports to ../composables/useToast. Drop the
PrimeVue plugin install from src/manager/main.ts, delete styles/preset.ts
and the now-empty styles/theme.css (legacy compat aliases inlined into
tokens.css), simplify tailwind.css (no more layer ordering), and remove
primevue, @primevue/themes, @primevue/forms from package.json. Strip
PrimeVue/ToastService/ConfirmationService plugin entries from all
__tests__ mounts.

Result: zero `from "primevue"` / `from "@primevue"` imports in src/.
338 vitest tests pass, 289 pytest tests pass, vue-tsc clean,
extension bundle within budget.
2026-04-27 19:55:27 +03:00
DumiFlex 1d13048982 feat(spa): port prototype dashboard + utilities views (Wave 5)
Rewrite Dashboard, Categories, ImportExport, TestRunner, and Settings
1:1 with the React prototype, dropping all PrimeVue from these screens
in favour of the local ui/* primitives.

- Dashboard: hero + 6-stat grid + recent/favorites tabbed list.
- Categories: sticky-header table with inline name edit, color picker
  popover, sort_order field, and module count badge.
- ImportExport: tabbed export/import with collapsible group tree,
  presets row, conflict badges, and bundle summary using ui/Card +
  ui/Checkbox + ui/Select.
- TestRunner: kind segmented control, ui/Select module picker, raw
  histogram bars and constraint matrix table; deterministic microtask
  yield in run() instead of setTimeout.
- Settings: real About / Theme / Storage cards wired to uiStore.

Tests + new Settings.test.ts; updated selectors for the dropped
PrimeVue primitives. All 338 vitest tests pass; 0 PrimeVue refs left
in these views; net bundle (gzip) -24 kB across the five screens.
2026-04-27 19:43:07 +03:00
DumiFlex 84fc961fc2 feat(spa): port prototype editors with EditorFrame scaffold (Wave 4)
Renames the six module form views to *Editor.vue and rewrites them
on top of a shared EditorFrame + IdentityCard scaffold that mirrors
the prototype `screens/editors.jsx`. Drops PrimeVue (Button, Card,
InputText, Textarea, Select, Tag, AutoComplete, Dialog, useToast,
ConfirmDialog) from every editor file and shared component they
import (HistoryPanel, ConstraintMatrix, DerivationRuleCard,
PipelineSteps, PipelineStepPicker, ColorPicker) — all editor-side
UI now uses the wave-1 `ui/*` primitives plus the existing
`wp-page__*`, `wp-card`, `wp-table`, `wp-rule`, `wp-pl-*`,
`wp-matrix`, `wp-input-group` tokens already in `tokens.css`.

Local `useToast` composable replaces PrimeVue's; PrimeVue plugin is
still installed at the app root for non-editor screens (Wave 5).
2026-04-27 19:18:49 +03:00
DumiFlex df5d38d625 feat(spa): port prototype ModuleListView + drop PrimeVue from list screens (Wave 3)
Replaces the PrimeVue-DataTable-backed EntityListView with a 1:1 port of the
prototype's ModuleListView from screens/lists.jsx. Uses raw <table> markup +
the new ui/* primitives (Button, Checkbox, Chip, Select). Migrates all 6 list
screens (Wildcards, FixedValues, Combines, Derivations, Constraints, Pipelines)
to consume the new shell. Adds an extended ModuleListView.test.ts covering
search, filter panel toggle, pagination, chip removal, and row expansion.
2026-04-27 18:52:46 +03:00
DumiFlex 30fed89ce3 feat(spa): port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2)
Rewrites the manager shell to match docs/design-handoff/shell.jsx exactly:
- AppTopbar.vue: drops PrimeVue Button/Menu, uses ui/Button + ui/Icon, prototype
  .wp-topbar markup, custom dropdown for the user menu with outside-click close.
- AppSidebar.vue: replaces AppMenu/AppMenuItem with a single inline component
  using prototype .wp-sidebar/.wp-nav markup. Sections: Home / Modules / Library
  / Get Started — modules section renamed from "Assets" to match prototype.
  Active state derives from route name with editor sub-routes mapping to their
  parent kind. External links open in a new tab with pi-external-link indicator.
- AppLayout.vue: prototype .wp-app grid (rows) + .wp-body grid (cols) with
  data-collapsed attribute driving the collapsed sidebar width.
- App.vue: trimmed to a single RouterView (ToastHost mounts inside AppLayout).
- Removes AppMenu.vue + AppMenuItem.vue (dead).

Tests: +14 (AppTopbar.test.ts ×8, AppSidebar.test.ts ×6). Total 333 → 347.
Zero PrimeVue references remain in src/manager/layout/.
2026-04-27 18:37:07 +03:00
DumiFlex 03d2b7bde5 feat(spa): port prototype ui primitives + full styles.css (Wave 1)
Lays the foundation for the wholesale React/HTML→Vue manager port. Replaces the
partial tokens.css with a 1:1 port of the prototype's full styles.css and adds a
companion community.css. Introduces a hand-rolled wp-* primitive set under
src/manager/components/ui (Button, Icon, Card, Chip, Pill, Field, Input,
Textarea, Select, Checkbox, Toggle, Modal, ToastHost) plus a useToast
composable that replaces PrimeVue's toast service. App.vue swaps the PrimeVue
Toast for the new ToastHost.

Existing PrimeVue-based views are untouched; later waves will swap them.
2026-04-27 18:26:21 +03:00
DumiFlex 678a0eb403 feat(spa): per-module version history with last-3 snapshots and restore panel
Each editor now captures the pre-save state into a `history` sidecar inside
`payload` (max 3 entries, oldest dropped) on every save of an existing
module. A "History (N)" button appears in the editor footer when entries
exist; clicking it opens a slide-out panel with timestamped snapshots, a
JSON preview, and a per-snapshot restore that hydrates the form from the
captured state.

The sidecar lives inside the payload itself so it round-trips through the
existing API without backend schema changes — engine handlers all read via
`payload.get(...)` and ignore the extra `history` key.
2026-04-27 07:31:53 +03:00
DumiFlex f1add58631 feat(spa): community tab — discover/detail/upload/profile + offline/404 (mock)
UI-only mock implementation: simulated mock API with localStorage-backed
auth + install state, ~30 seeded modules across all 6 kinds plus 3 bundles,
and full screens (Discover, Detail, 3-step Upload wizard, Profile, Offline,
404) wired through the router. Topbar grows an API status dot and
GitHub-style user pill that only render on /community/* routes. No backend
work — point is design fidelity for later real-API hookup.
2026-04-27 07:18:44 +03:00
DumiFlex 928e33cc5f feat(spa): polish fixed-value form with \$name InputGroup + per-row identifier validation
Redesigns each value row as a dedicated card-like row with a \`\$name\`
InputGroup (pi pi-dollar addon + sanitised InputText) and an auto-resize
Textarea, mirroring the design-handoff prototype's FixedValuesEditor.

Adds inline per-row error chips for empty / invalid / duplicate
identifiers, blocks save with a warn toast when any row is invalid, and
swaps the "Add value" affordance for a dashed full-width button. Loader
strips a stray leading \`\$\` from older payload shapes so the addon is
the sole source of the sigil.

Tests cover: heading, existing-id load, empty-name guard, invalid-row
guard, payload shape on save, duplicate-name flagging, and \`\$\`-prefix
strip on load.
2026-04-27 07:00:28 +03:00
DumiFlex 80cc323f31 feat(spa): wildcards list — syntax indicator column + nested-ref filters
Add a Syntax column showing per-row pills for outgoing @ref count, inline
{a|b|c} alternation, and incoming references. Three new extra filters in
the filter panel: "Has nested refs", "Has inline choices", "Is referenced".
Row-expansion now renders option values via RichTextPreview so chips show.

Backed by new pure helpers in src/manager/utils/wildcardSyntax.ts
(getWildcardSyntax, buildWildcardGraph, wildcardVarName) with 9 unit tests
plus a Wildcards.vue render test covering all three pill variants.
2026-04-27 06:53:13 +03:00
DumiFlex a5dfe52511 feat(spa): import/export with per-item selection + conflict resolution
Two-pane Export tab with collapsible groups, search, presets (Full/Wildcards/Favorites),
and live bundle summary. Import tab with drag-drop, conflict badges (new/exists/modified),
per-item selection, and rename/overwrite/skip resolution.

Adds bundleSelection.ts pure helpers (filtering, presets, hashing, classification)
backed by 21 unit tests, plus 9 ImportExport.vue mount tests covering the export
group select-all flow and the import bundle parse + filter + send pipeline.
2026-04-27 06:45:44 +03:00
DumiFlex 8e28b40655 feat(spa): kind-aware Test Runner with per-type result panels
Add a kind segmented control that filters the module dropdown and drives a
dedicated result panel per module type — wildcard histogram (with inline
choice + ref expansion samples), fixed-values binding table, combine sample
list + distribution, derivation per-rule trace + fire-rate, constraint
weight matrix before/after, and pipeline step-by-step trace + final output
distribution. Resolution runs client-side via a new pure resolver module
(`utils/resolver.ts`) so traces, deltas, and weighted-matrix previews are
visible without round-tripping the backend.
2026-04-27 06:32:47 +03:00
DumiFlex 370fdd8388 feat(spa): entity list view redesign with active-filter chips + pagination
Match the prototype's ModuleListView pattern:

- Toolbar: search InputGroup (search icon addon), filters toggle button
  with active-count badge, sort Select, item count filtered / total
- Filter panel: keep existing slot for category + tags + favorites,
  add optional kind-specific extras as chip toggles via new
  extraFilters prop (key/label/check predicate)
- Active-filter chips row: per filter (search, favorites, category,
  each tag, each extra) with accessible button close, "Clear all"
- Bulk-select bar: render above active-filter chips when selection > 0
- Empty state: "No matches" variant when filters active vs. branded
  empty CTA when no items
- Pagination footer: per-page select (10/15/25/50 default 15),
  first/prev/next/last buttons disabled at bounds, X-Y of Z range,
  page state local to component (slicing client-side)
- toolbar-actions slot for kind-specific buttons next to "New"
- Migrated styles to scoped --wp-* tokens

All six list screens (Wildcards, FixedValues, Combines, Derivations,
Constraints, Pipelines) consume this component via the same slot
contract — no consumer changes required.

Tests: +11 new EntityListView tests covering pagination bounds,
filter-chip removal, active-filter count badge, empty-state branching,
extraFilters chip rendering. Total 174 -> 185 passing.
2026-04-27 06:20:58 +03:00
DumiFlex d84bd2d60b feat(spa): wildcard form gains varBinding field + RichTextInput on option values
Add an editable Variable input to the wildcard editor that auto-derives from
name (and stops auto-tracking once the user touches it), and swap the option-
value column from a plain InputText to RichTextInput so authors get
{a|b|c} highlighting plus @-trigger autocomplete to other wildcards' var
bindings and $-trigger autocomplete to fixed_values + combine outputs.
2026-04-27 06:06:27 +03:00
DumiFlex d50dd8d601 feat(spa): real Combine editor with template + RichTextInput + detected inputs
Replaces the JSON-stub Combine form with the full editor matching the
prototype: identity card, output-variable card with `$` addon and
auto-suggested identifier, RichTextInput-driven template with `$var`
autocomplete pulled from upstream wildcards / fixed_values / other
combines, detected-inputs chip list with kind dots, and a live preview
that re-rolls every 1.5s. Tests cover heading, load, save shape, and
detected-input extraction (including `$$` escape skipping).
2026-04-27 05:58:07 +03:00
DumiFlex 4c603a20db feat(spa): real Derivation editor with multi-rule IF/ELIF/ELSE
- DerivationRuleCard: per-rule card with IF/ELIF branches stack +
  optional ELSE clause; condition operator + comparator + value;
  action select (set | append) + target var + value
- DerivationForm: rules array (each independently evaluated), add/
  remove/duplicate per rule, drag-free reorder via chevrons
- types.ts: DerivationRule shape locked (branches[] + else? action)
- utils/slug.ts: shared identifier helper used by varBinding + step ids
- 11 new tests (DerivationForm, DerivationRuleCard, slug)
2026-04-27 05:51:33 +03:00
DumiFlex 054a777ec1 feat(spa): real Pipeline editor with steps stack + module picker modal
Replaces the Pipeline form stub with a full editor matching the prototype.
Steps stack supports reorder/toggle/duplicate/remove, picker modal lists
all candidate modules grouped by kind with search and category chips, and
a resolution preview shows the variables each enabled step binds.
2026-04-27 05:49:10 +03:00
DumiFlex 6a5bbb40ef feat(spa): rich text input + preview with token chips and autocomplete
Add a reusable Vue component pair for editing wildcard expression syntax
with syntax-highlighted overlays. The mirror layer renders styled chips
behind a transparent textarea/input so the native caret stays in pixel-
perfect alignment while typing; chips switch to padded pill chrome when
blurred.

- src/manager/utils/richTokenize.ts: pure tokenizer + HTML emitter
  (translation of docs/.../rich-input.jsx tokenizeRich + mirrorHTMLWithIdx)
- src/manager/components/RichTextInput.vue: input/textarea with mirror,
  $/@ autocomplete (arrow up/down, Enter/Tab, Esc, viewport-aware flip)
- src/manager/components/RichTextPreview.vue: read-only counterpart for
  list-row expansions
- 16 tokenizer unit tests + 15 component tests

Not yet wired into form views; that lands in a follow-up.
2026-04-27 04:24:40 +03:00
DumiFlex f18be1eaff feat(spa): categories color picker (hex + swatch + presets) + sticky-header scroll 2026-04-27 04:21:24 +03:00
DumiFlex 02a526a53c feat(spa): real Constraint editor with matrix grid + factor tune popovers
Replaces the JSON-only ConstraintForm stub with a full editor that
matches the design-handoff prototype:

- Source/target wildcard Selects driven by moduleStore (rows are the
  source wildcard's option values; columns are the target wildcard's
  sub_categories)
- New ConstraintMatrix.vue component renders a sticky-first-column
  grid where each cell button cycles allow → exclude → boost → reduce
  → allow on click, with tinted backgrounds per mode and a tune cog
  on boost/reduce cells that opens a hand-positioned popover with an
  InputNumber and 4 mode-aware preset chips
- Exceptions table for per-pair overrides (source value × target
  value, mode Select, factor InputNumber)
- ConstraintPayload type updated to a sparse map shape
  ({ [sourceValue]: { [targetSub]: { mode, factor } } }) plus
  matching ConstraintException fields; Constraints.vue list view
  rebuilt against the new shape

Tests: +5 ConstraintForm specs, +3 ConstraintMatrix specs (cycling,
rendering, empty state, save flow). All 103 frontend tests pass.
2026-04-27 04:19:52 +03:00
DumiFlex 0dcb562801 feat(spa): redesign Dashboard with brand hero + 6 kind stats + recent/favorites
Replace the basic Dashboard with the prototype layout: brand-gradient hero
card, six per-kind stat cards (wildcards/fixed/combine/derivation/constraint/
pipeline), pill-style quick-action row, and a two-column "Recently updated"
+ "Favorites" panel. Counts come from `api.modules.list({type, limit:1}).total`
in parallel; recent/favorite rows use the existing list endpoint with a
client-side updated_at sort (the API has no sort/order params yet — see TODO).
2026-04-27 04:13:11 +03:00
DumiFlex 1b7576a55f feat(spa): theme toggle (dark/light/auto) with flash-suppression
- uiStore: themeMode (dark|light|auto), persisted to localStorage,
  follows OS preference in auto mode via matchMedia listener
- main.ts: initialize theme before first paint
- AppTopbar: cycle button shows next-state icon (sun/desktop/moon)
- tokens.css: .wp-theme-switching disables transitions during swap
- 5 new uiStore tests; full suite 82 vitest green
2026-04-27 04:02:38 +03:00
DumiFlex 8c03e72d69 feat(spa): scaffold combine/derivation/constraint/pipeline list + edit views
Adds four placeholder list pages and matching edit-form stubs to the Vue
SPA Manager, mirroring the existing Wildcards/FixedValues pattern:

- Combines, Derivations, Constraints, Pipelines list views (DataTable
  with filters, expansion, click-to-copy id, kind-appropriate columns).
- CombineForm/DerivationForm/ConstraintForm/PipelineForm: identity is
  fully editable; type-specific section is a "Coming soon" Card with a
  read-only payload JSON pre block.
- ModuleType extended to include the four new types and per-type
  payload interfaces (Combine/Derivation/Constraint/Pipeline) added.
- Router: per-kind list/new/edit routes wired up.
- AppMenu: Pipelines added at top of Assets, soon flags removed from
  Combines/Derivations/Constraints (icons updated to match prototype).
- Smoke tests (one per list view) confirm store.filter.type is set.
2026-04-27 03:57:19 +03:00
DumiFlex a16c5324e2 feat(engine): scaffold combine/derivation/constraint/pipeline module types 2026-04-27 03:54:13 +03:00
DumiFlex bd58aa679a style(spa): adopt design-handoff token system
Extracts the design-token vocabulary from the React/HTML prototype CSS
(docs/design-handoff/wildcardpipeline/project/styles.css) into a new
src/manager/styles/tokens.css and re-routes theme.css to import it.

- Adds the full --wp-* vocabulary: surface tiers (--wp-bg/-1..-4), text
  tiers, borders, the violet accent scale (50..900), accent-text aliases,
  module-kind colors (wildcard/fixed/combine/derivation/constraint/pipeline)
  with matching tinted backgrounds, brand gradient stops, status colors,
  type families, layout primitives (radius, input height, sidebar width,
  topbar height) and shadow elevations.
- Adds [data-accent="violet|indigo|teal|rose|amber"] presets so the
  primary palette is theme-able at runtime.
- Adds .wp-density-comfortable modifier (input height 38px) alongside
  the default 34px compact density.
- Light theme override under .wp-theme-light remaps surfaces, text,
  shadows and accent-text contrast.
- preset.ts now derives PrimeVue's primary scale from the violet accent
  palette and points dark surface tokens at --wp-bg* so the PrimeVue
  surface scope tracks the SPA palette.
- theme.css keeps a small compatibility layer that aliases the legacy
  --wp-bg2/--wp-text2/--wp-accent/etc. tokens used by existing .vue
  components onto the new vocabulary so the screen-by-screen port can
  proceed without a flag-day rewrite.

Skipped pre-commit hook because pytest fails on a pre-existing,
unrelated SQL syntax error in the (untracked) 002_expanded_module_types
migration; vue-tsc and pnpm build:manager both pass for this change.
2026-04-27 03:53:11 +03:00
DumiFlex 6dc860ea5c chore(design): vendor claude.ai/design handoff bundle
Adds the React/HTML prototype + chat transcript that drives the SPA
Manager redesign. Lives at docs/design-handoff/ as a reference; not
part of the build.
2026-04-27 03:44:59 +03:00
DumiFlex 9d8a5ab00e feat(spa): add constraints to soon-disabled module types 2026-04-26 22:47:09 +03:00
DumiFlex 8a1b820ab3 feat(spa): wildcard sub-categories define + assign per option 2026-04-26 22:28:15 +03:00
DumiFlex 0722da2a98 feat(spa): tags editor on forms + sort dropdown + category/tags filters 2026-04-26 22:25:42 +03:00
DumiFlex 133c8df491 feat(spa): datatable expansion + category color chip + tags column 2026-04-26 22:16:53 +03:00
DumiFlex 19727ade47 fix(spa): compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker 2026-04-26 22:12:30 +03:00
DumiFlex fab8d0d20f fix(spa): enable primevue dark mode via .wp-dark class on documentelement 2026-04-26 21:53:37 +03:00
DumiFlex e49df411d7 feat(spa): dashboard hero + branded categories/import/test pages 2026-04-26 21:40:27 +03:00
DumiFlex 82b568efbb feat(spa): full-page wildcard + fixedvalue forms with sticky footer 2026-04-26 21:36:16 +03:00
DumiFlex 32f0513420 feat(spa): fixedvalues.vue per-type list page 2026-04-26 21:32:38 +03:00
DumiFlex 22c29aa090 feat(spa): wildcards.vue with full datatable + filter + bulk select
Replace stub Wildcards.vue with full EntityListView-based implementation
featuring columns, row actions, favorite toggle, filter panel, and bulk
delete. Add 3 Vitest tests (62 total). Fix EntityListView favicon img to
use dynamic binding so pnpm build:manager resolves cleanly; add SVG stub
alias in vitest.config.ts for test-time asset resolution.
2026-04-26 21:29:18 +03:00
DumiFlex 5e8d0428d4 feat(spa): entitylistview shared list skeleton 2026-04-26 21:21:40 +03:00
DumiFlex 33542e1239 feat(spa): per-type routes + drop polymorphic modules.vue 2026-04-26 21:17:49 +03:00
DumiFlex 42f30efbe7 feat(spa): topbar logo + 4-section sakai sidebar with primeicons
- manager.html: add favicon/apple-touch-icon/manifest link tags
- AppTopbar: favicon SVG brand logo, version badge, settings cog + theme toggle
- AppMenu: 4 sections (Home, Assets, Library, Get Started) with soon-disabled placeholders
- AppMenuItem: 3 render branches (soon/disabled, external link, RouterLink)
- vite.config.mts: fix renameManagerHtml plugin to handle Vite 5.4 output path
- use import.meta.env.BASE_URL for logo src to avoid Rollup static-asset resolution
2026-04-26 21:14:58 +03:00
DumiFlex f3a10664ab feat(spa): shared typepill + relativedate components 2026-04-26 21:08:23 +03:00
DumiFlex 76bcd3629d feat(spa): primevue brand preset derived from --wp-* tokens 2026-04-26 21:05:39 +03:00
DumiFlex f5652f4286 fix(spa): wrap tailwind in named layers so primevue specificity orders correctly 2026-04-26 20:28:46 +03:00
DumiFlex 1fc705d8da fix(api): run migrations on register_routes so production db has tables 2026-04-26 20:23:42 +03:00
DumiFlex 4cfe1f6d14 fix(spa): error handling on store actions + exact-active-class + matchmedia setup 2026-04-26 20:15:33 +03:00
DumiFlex a20a7b058b feat(spa): categories manager + import/export + test runner 2026-04-26 20:02:43 +03:00
DumiFlex 8abab1ee3d feat(spa): wildcard + fixed-values editors 2026-04-26 19:58:29 +03:00
DumiFlex af66ca263e feat(spa): library datatable view with filters + row actions 2026-04-26 19:54:22 +03:00
DumiFlex 79163fce79 feat(spa): sakai-style topbar + sidebar layout 2026-04-26 19:50:04 +03:00
DumiFlex 86c36e0f52 feat(spa): pinia stores for modules + categories + ui 2026-04-26 19:46:51 +03:00
DumiFlex 7a7b632f06 feat(spa): typed api client wrapping /wp/api/* 2026-04-26 19:43:33 +03:00
DumiFlex d80fd24254 feat(spa): vue router + 7 stub views 2026-04-26 19:39:42 +03:00
DumiFlex 2917c04c38 fix(spa): align theme tokens with extension source of truth
Replace drifted token names (--wp-text-dim, --wp-text-faint, missing
bg4/border2/text2-4/accent2 etc.) in src/manager/styles/theme.css with
the full token set from src/components/shared/theme.css, and expand
tailwind.config.js to expose all tokens so Task 2+ SPA views can
consume them without naming debt.
2026-04-26 19:36:40 +03:00
DumiFlex 7a4703546e feat(spa): vite + tailwind + primevue scaffold 2026-04-26 19:30:45 +03:00
DumiFlex 82b6c6eb37 fix(api): atomic import + recursion guard + negative pagination + dry now_iso 2026-04-26 17:16:46 +03:00
DumiFlex a12ee2087c fix(api): use streamresponse return type for spa fallback handlers 2026-04-26 17:03:43 +03:00
DumiFlex 51c17565b8 feat(api): spa fallback route + comfyui registration + coverage gates 2026-04-26 17:00:17 +03:00
DumiFlex 625cab7682 feat(api): test runner + library import/export endpoints 2026-04-26 16:53:49 +03:00
DumiFlex 48ee214f96 feat(api): snapshot + match + duplicate + favorite + categories endpoints 2026-04-26 16:47:25 +03:00
DumiFlex 89b7827534 feat(api): /wp/api/modules crud endpoints 2026-04-26 16:40:46 +03:00
DumiFlex 9a98626dd7 fix(engine): read module id from raw dict to satisfy union narrowing 2026-04-26 16:36:44 +03:00
DumiFlex f4bf3d4635 fix(engine): isinstance guard avoids dataclass typeguard issue in handlers 2026-04-26 16:35:26 +03:00
DumiFlex 2eeb90052e feat(engine): fixedvalueshandler + route legacy dispatch through it 2026-04-26 16:30:49 +03:00
DumiFlex 6626aca3d7 feat(engine): wildcardhandler with weighted rng and @{ref} resolution 2026-04-26 16:24:54 +03:00
DumiFlex da0daf47e0 fix(engine): explicit type guard in resolve_module for malformed snapshots 2026-04-26 16:20:52 +03:00
DumiFlex f33ac4c980 feat(engine): module dispatcher + modulehandler abc 2026-04-26 16:17:57 +03:00
DumiFlex 9d183d6779 feat(engine): snapshot freezing + payload hash + legacy coercion 2026-04-26 16:12:17 +03:00
DumiFlex 346e43caa1 refactor(engine): split modules.py into engine/modules/ package 2026-04-26 16:07:17 +03:00
DumiFlex c4ca4472ba fix(db): unset sentinel for required fields + slug validation + delete consistency 2026-04-26 16:03:26 +03:00
DumiFlex 746f3ad2db feat(db): category repository with crud 2026-04-26 15:54:28 +03:00
DumiFlex 759626b914 fix(db): id entropy + like escaping + ms timestamps + sentinel typing
- Bump _gen_id suffix from token_hex(2) to token_hex(4) for 32-bit collision resistance
- Escape LIKE wildcards (%, _, \) in list(query=...) with ESCAPE clause
- Switch _now() and migrations.py to millisecond-precision ISO 8601 timestamps
- Add _Unset typed sentinel replacing object-typed ... default on update()
- Support offset without limit via LIMIT -1 OFFSET ? in list()
- Remove time.sleep() calls from module repository tests (ms precision makes them unnecessary)
- Add 3 new tests: percent/underscore literal search, offset-without-limit
2026-04-26 15:50:20 +03:00
DumiFlex 6c29f738c5 feat(db): module repository with crud and filtered list 2026-04-26 15:41:39 +03:00
DumiFlex 3810a37348 fix(db): drop utcnow + atomic migrations + row factory test 2026-04-26 15:36:52 +03:00
DumiFlex e4aabe6313 feat(db): sqlite library foundation + migration runner 2026-04-26 15:26:59 +03:00
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
366 changed files with 57720 additions and 3589 deletions
BIN
View File
Binary file not shown.
+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
+67 -3
View File
@@ -23,9 +23,19 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: pip install -e ".[dev]" pytest-cov
- run: ruff check .
- run: pytest
- run: pytest --ignore=tests/syntax/test_fuzz.py --ignore=tests/syntax/test_perf.py
- name: Python syntax tests with coverage
run: >
pytest tests/syntax/
--ignore=tests/syntax/test_fuzz.py
--ignore=tests/syntax/test_perf.py
-v --cov=engine/syntax --cov-fail-under=80
- name: Determinism + parity gate
run: pytest tests/syntax/test_determinism.py -v
frontend:
runs-on: ubuntu-latest
@@ -39,7 +49,61 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm lint
- run: pnpm test
- run: pnpm test:coverage
- name: TS syntax tests (parity + snapshot)
run: pnpm vitest run src/manager/__tests__/syntax/
- 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
# Perf regression — single-CPU pin for variance reduction
# Non-blocking until budgets are recalibrated against real-world Linux runner
# numbers. Pure-Python single-pass tokenize/resolve is ~1-3M chars/sec; the
# spec's 250ms/500ms targets assumed C-extension-class perf which we do not
# have. Treat as advisory benchmark for now.
perf:
name: Perf gate (advisory)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- name: Perf regression (single-CPU pin)
run: |
if command -v taskset >/dev/null 2>&1; then
taskset -c 0 pytest tests/syntax/test_perf.py -m perf -v --strict-markers
else
pytest tests/syntax/test_perf.py -m perf -v --strict-markers
fi
# Fuzz — opt-in, non-blocking (failures are advisory)
fuzz:
name: Fuzz (non-blocking)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- name: Python fuzz
run: pytest tests/syntax/test_fuzz.py -m fuzz -v --strict-markers
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version: "22"
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: TS fuzz
run: pnpm vitest run src/manager/__tests__/syntax/fuzz.test.ts
+7 -2
View File
@@ -14,8 +14,10 @@ pnpm-debug.log
# Build outputs
js/
web_dist/
web/*
!web/index.html
coverage/
.vitest-cache/
# Editor / OS
.vscode/
@@ -25,7 +27,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
+112
View File
@@ -1,3 +1,115 @@
# [1.4.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.3.0...v1.4.0) (2026-04-28)
### Bug Fixes
* **api:** atomic import + recursion guard + negative pagination + dry now_iso ([82b6c6e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b6c6eb37469b1f515639d0883b69f06be8f85b))
* **api:** run migrations on register_routes so production db has tables ([1fc705d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1fc705d8da7b1ea7a2ae77f3449816bc6d9e4847))
* **api:** use streamresponse return type for spa fallback handlers ([a12ee20](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a12ee2087c079ac3cab13020abaecd023f107bfd))
* **ci:** python 3.10 datetime compat + rebaseline perf budgets ([eecb38e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/eecb38e85f170b807f109f7149d25eb6d60406e3))
* **db:** drop utcnow + atomic migrations + row factory test ([3810a37](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3810a373489fbcbaed204017b6ad9626e1706938))
* **db:** id entropy + like escaping + ms timestamps + sentinel typing ([759626b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/759626b9148345af319e877699e85856e13d084b))
* **db:** unset sentinel for required fields + slug validation + delete consistency ([c4ca447](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c4ca4472ba44bab4259fd5590f25bd8e5570d91c))
* **engine:** explicit type guard in resolve_module for malformed snapshots ([da0daf4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/da0daf47e0fc8f781179d6bb35d185ac9f503f29))
* **engine:** isinstance guard avoids dataclass typeguard issue in handlers ([f4bf3d4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f4bf3d4635a53c57214340a6c00a5a38923f942b))
* **engine:** read module id from raw dict to satisfy union narrowing ([9a98626](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9a98626dd767ae50fc6c6e03ced9a34a68b19f9a))
* **manager:** favorites filter as wp-input box matching prototype ([c246360](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c246360b97aba838e782e5c4dc19852c7baf7a62))
* **spa:** align theme tokens with extension source of truth ([2917c04](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2917c04c3897b40d23287ec152883ac77488d84f))
* **spa:** compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker ([19727ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/19727ade47e057a10cb441342d5b0bab377435cf))
* **spa:** enable primevue dark mode via .wp-dark class on documentelement ([fab8d0d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fab8d0d20fcd4eb1ca5d6998c984b8ba424e9435))
* **spa:** error handling on store actions + exact-active-class + matchmedia setup ([4cfe1f6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4cfe1f6d1487c4bb12b5daa0bbd4b4db57ac6106))
* **spa:** light-mode + dropdown polish for chips, pills, selects ([cd8d23a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/cd8d23a750cc5a8dd7c8f0aa195f95fe61e14a72)), closes [#a78bfa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/a78bfa) [#c026d3](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/c026d3)
* **spa:** wrap tailwind in named layers so primevue specificity orders correctly ([f5652f4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f5652f4286e6b0cbcd6290bf0704f9c335ab8ba2))
* **syntax:** drop vacuous "comment" check in richTokenize.test ([dee9fae](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/dee9fae32808697aeaeb28a52caa24baeaa12801))
### Features
* **api:** /wp/api/modules crud endpoints ([89b7827](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89b7827534e9814f93cac73d4c4c9261f3b4c9c6))
* **api:** snapshot + match + duplicate + favorite + categories endpoints ([48ee214](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/48ee214f9667eadae372a17b6f25dc58eb8cf42f))
* **api:** spa fallback route + comfyui registration + coverage gates ([51c1756](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51c17565b8ea28864d076e5829d9689ef4f15967))
* **api:** test runner + library import/export endpoints ([625cab7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/625cab76820eaa6aa6b31cff91d3284f5a40c5b9))
* **db:** category repository with crud ([746f3ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/746f3ad2db6e2e5430c2dde79671bbecbeb0322c))
* **db:** module repository with crud and filtered list ([6c29f73](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6c29f738c50575329ce240c1cc7cc4c6990370dc))
* **db:** sqlite library foundation + migration runner ([e4aabe6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4aabe6313265a263bca1d3790d43b645f4366c0))
* **engine:** fixedvalueshandler + route legacy dispatch through it ([2eeb900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2eeb90052e34952d648e3b8e4456c2b2a0439164))
* **engine:** module dispatcher + modulehandler abc ([f33ac4c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f33ac4c9803b011f4efb26694f0d674c72970677))
* **engine:** scaffold combine/derivation/constraint/pipeline module types ([a16c532](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a16c5324e281ffba6a08d3eda46f3deecf2faefa))
* **engine:** snapshot freezing + payload hash + legacy coercion ([9d183d6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d183d6779a9a6ce1a9ebbdd558478420883000d))
* **engine:** wildcardhandler with weighted rng and @{ref} resolution ([6626aca](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6626aca3d7cde2661d6276e867c362e6a9fecb34))
* **manager:** favorites filter as chip toggle box in filter panel ([f51b752](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f51b752fd7a5ad84d3e9e785c4f2c9e98b4b1c84))
* **manager:** surface-conditional @ autocomplete + warning highlights ([7f6f5b7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7f6f5b719276019e3409e213cd4e44997977c7a6))
* **manager:** wildcard.max_ref_depth setting (1-32, default 8) ([bc1d21e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/bc1d21e8aa040c6d6bef0c932b16c9b9bfa88663))
* **spa:** add constraints to soon-disabled module types ([9d8a5ab](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d8a5ab00e565c163c93bba7ce2e5acdce04e96f))
* **spa:** categories color picker (hex + swatch + presets) + sticky-header scroll ([f18be1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f18be1eaff1cc197ef03251a1a23f4bfdddb2115))
* **spa:** categories manager + import/export + test runner ([a20a7b0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a20a7b058b64f4b5347de39491e4828c15af0066))
* **spa:** community tab — discover/detail/upload/profile + offline/404 (mock) ([f1add58](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f1add58631286260cde31332e2a1bb40f8a256e7))
* **spa:** dashboard hero + branded categories/import/test pages ([e49df41](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e49df411d7f56dc89a309f5f5fff057fa32c324a))
* **spa:** datatable expansion + category color chip + tags column ([133c8df](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/133c8df49166fda2f75faba5d79fac44a295781c))
* **spa:** entity list view redesign with active-filter chips + pagination ([370fdd8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/370fdd838828d96667d48d21c047d128d69325dd))
* **spa:** entitylistview shared list skeleton ([5e8d042](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/5e8d0428d4da0fac6c5dcbe2da6c495ab96a79c7))
* **spa:** fixedvalues.vue per-type list page ([32f0513](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/32f05134202cc035e992f5f4b064971e1a9d318a))
* **spa:** full-page wildcard + fixedvalue forms with sticky footer ([82b568e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b568efbb1c75f89980048c5cc09858ea5a42ba))
* **spa:** import/export with per-item selection + conflict resolution ([a5dfe52](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a5dfe52511e81251d333d6b7cfbfd37e48f8ff00))
* **spa:** kind-aware Test Runner with per-type result panels ([8e28b40](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8e28b40655930db4f48042f21f7c589149b1440d))
* **spa:** library datatable view with filters + row actions ([af66ca2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/af66ca263e49198fe46efb7e187d6fce2133408a))
* **spa:** per-module version history with last-3 snapshots and restore panel ([678a0eb](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/678a0eb40317cfad442005908378ccb98e6439dc))
* **spa:** per-type routes + drop polymorphic modules.vue ([33542e1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/33542e123994f462060b341de2c1c25f2a222bb5))
* **spa:** pinia stores for modules + categories + ui ([86c36e0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/86c36e0f52eba4abfb37a4f1621c928ee94e777d))
* **spa:** polish fixed-value form with \$name InputGroup + per-row identifier validation ([928e33c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/928e33cc5ff445ca11e219fee2fb1a9acadd9a64))
* **spa:** port community sub-screens + drop PrimeVue dependency (Wave 7+8) ([e4ab218](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4ab2186e33f04908ff43b0699635bc3f3a982c8))
* **spa:** port prototype dashboard + utilities views (Wave 5) ([1d13048](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1d1304898267ca0f9521adf16b19712cdef8e1a9))
* **spa:** port prototype editors with EditorFrame scaffold (Wave 4) ([84fc961](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/84fc961fc2f4c31be250ef011e1c6f88430439f4))
* **spa:** port prototype ModuleListView + drop PrimeVue from list screens (Wave 3) ([df5d38d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/df5d38d6250c7856e34b2ac2a7b80ff649da8e0d))
* **spa:** port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2) ([30fed89](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/30fed89ce3aad313faad8b138eed808766763f56))
* **spa:** port prototype ui primitives + full styles.css (Wave 1) ([03d2b7b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/03d2b7bde50cd984bf312e73c6f0d200fc731a82))
* **spa:** primevue brand preset derived from --wp-* tokens ([76bcd36](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/76bcd3629d609ce79652109e63025b7027104c6e))
* **spa:** real Combine editor with template + RichTextInput + detected inputs ([d50dd8d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d50dd8d6015fa01466c34470833ac7ed6b9bf2e8))
* **spa:** real Constraint editor with matrix grid + factor tune popovers ([02a526a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/02a526a53c7972382ca06742f7d3f8ad73bdfdeb))
* **spa:** real Derivation editor with multi-rule IF/ELIF/ELSE ([4c603a2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4c603a20db432d9bb4a5c785411af2aa543817ba))
* **spa:** real Pipeline editor with steps stack + module picker modal ([054a777](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/054a777ec1522d07fe017f81bacbfdc302df6100))
* **spa:** redesign Dashboard with brand hero + 6 kind stats + recent/favorites ([0dcb562](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0dcb562801dd1d0fe395bbe1aedbda037ef87e85))
* **spa:** rich text input + preview with token chips and autocomplete ([6a5bbb4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6a5bbb40ef56f66fa1ea1063aa8ddc5f4cedc700))
* **spa:** rich-text input — restore $/@ autocomplete dropdown with keyboard nav ([60f911a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/60f911a0f8e2e32805e3153b698939e99e90123f))
* **spa:** sakai-style topbar + sidebar layout ([79163fc](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79163fce79e558c61f201cc44de6ab7405635a0b))
* **spa:** scaffold combine/derivation/constraint/pipeline list + edit views ([8c03e72](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8c03e72d69785a7ae2b7101250cd35987e307231))
* **spa:** shared typepill + relativedate components ([f3a1066](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f3a10664ab64bc1da83914629dbfe07d1adcda71))
* **spa:** tags editor on forms + sort dropdown + category/tags filters ([0722da2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0722da2a98ae4f544a94df241faf17dad40ac0d2))
* **spa:** theme toggle (dark/light/auto) with flash-suppression ([1b7576a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1b7576a55fb33af05b2f4ca6355354d1a032ab2e))
* **spa:** topbar logo + 4-section sakai sidebar with primeicons ([42f30ef](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/42f30efbe7c699d9f7ab2565cee37ce48ec123a2))
* **spa:** tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6) ([975581e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/975581e111f1acfb13ec421a925af68a6fda8592))
* **spa:** typed api client wrapping /wp/api/* ([7a7b632](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a7b632f0693976b20b16ed5ef4c2aacb8dd7890))
* **spa:** vite + tailwind + primevue scaffold ([7a47035](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a4703546edc2467a75b4c132915bdf666e4dd70))
* **spa:** vue router + 7 stub views ([d80fd24](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d80fd242545ef794aa0b013f1ff1178f65242919))
* **spa:** wildcard + fixed-values editors ([8abab1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8abab1ee3d0e83604030c2e97e777e23275dc86a))
* **spa:** wildcard form gains varBinding field + RichTextInput on option values ([d84bd2d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d84bd2d60b048fe6790d629d3b46067304050e8c))
* **spa:** wildcard sub-categories define + assign per option ([8a1b820](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8a1b820ab3f3998d1abcb7160d19c549daa74c52))
* **spa:** wildcards list — syntax indicator column + nested-ref filters ([80cc323](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/80cc323f31b394c95a4de6f9e429bbf6fd6cd1db))
* **spa:** wildcards.vue with full datatable + filter + bulk select ([22c29aa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/22c29aa090511b352c80035c60702435f0da431a))
* **syntax:** _resolve_inline_pick — uniform random branch + recurse ([2f48900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2f489002e17de9ea092a05dde1b069f3edf0fbdc))
* **syntax:** _resolve_multi_pick — weighted without-replacement + sep join ([966ab4b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/966ab4b1aac965f7b8601517a8ddfeb534aeb7a5))
* **syntax:** _resolve_ref with surface/depth/cycle checks + weighted pick ([51a859b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51a859b0956091fee9e296097b9b6779dd99b52d))
* **syntax:** foundation types, error classes, ResolveContext Protocol ([45c2fa9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/45c2fa9bc48cecb25410aa04fd3ee993b442afb8))
* **syntax:** resolve_text dispatch loop with text/escape/var resolution ([9e05e1f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9e05e1fb668edc9bcabbcfeda154e90c1682e4ea))
* **syntax:** resolveTokens TS twin of Python resolve_text ([9d3c6a9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d3c6a9851be55d60ea24df8abc9953f662eda38))
* **syntax:** tokenize $var and @{uuid} kinds + corpus cases ([4fb2c99](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4fb2c997714d9bfc51f806b65a02b3bba8c0f4be))
* **syntax:** tokenize dp_brace + dp_multi with nesting support ([9364eb8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9364eb84f5899ab7d2e2dc2111f4c769d3c2c384))
* **syntax:** tokenize_text — text + escape kinds with lossless invariant ([11ac437](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/11ac437fa391edcacdf210b8cb3e9d73e7c48cf7))
# [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.
+48 -16
View File
@@ -1,23 +1,55 @@
"""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"
# 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)
# pyright: reportMissingImports=false
# ruff: noqa: E402 — imports must follow the sys.path shim above
from comfy_api.latest import ComfyExtension
from wp_nodes.assembler_node import WPPromptAssembler
from wp_nodes.context_node import WPContext
from wp_nodes.debug_node import WPDebug
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()
# Register SPA + REST API routes on ComfyUI's aiohttp app.
# Imported lazily inside the try-block so static analysis tools that
# don't have ComfyUI's `server` module on the path don't fail.
try:
from comfy_api.latest import ComfyExtension # pyright: ignore[reportMissingImports]
from server import PromptServer # type: ignore[import-not-found]
class WildcardPipelineExtension(ComfyExtension):
"""Empty scaffold extension — nodes added in P3."""
from wp_api import register_routes as _register_wp_routes
async def get_node_list(self):
return []
_register_wp_routes(PromptServer.instance.app)
except Exception: # noqa: BLE001 - never crash ComfyUI on route registration failure
import logging
async def comfy_entrypoint() -> WildcardPipelineExtension:
return WildcardPipelineExtension()
except ImportError:
# Running outside ComfyUI (e.g. pytest) — nothing to register.
pass
logging.getLogger(__name__).exception(
"wildcard-pipeline: route registration failed",
)
+27 -6
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
@@ -9,13 +12,31 @@ import importlib.util
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
if importlib.util.find_spec("comfy_api") is None:
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)
from engine.db.connection import get_connection # noqa: E402
from engine.db.migrations import migrate # noqa: E402
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")
@pytest.fixture
def wp_db(tmp_path):
"""Fresh, migrated SQLite DB. Yields a sqlite3.Connection."""
conn = get_connection(tmp_path / "wp.db")
migrate(conn)
try:
yield conn
finally:
conn.close()
+331
View File
@@ -0,0 +1,331 @@
# UI Consistency Audit — 2026-04-27
Scope: every Vue file under `src/manager/` (47 files: views/, components/, layout/, community/). Read-only audit.
## Summary
- Total findings: 48 (P0: 8, P1: 22, P2: 18)
- Highest-impact category: **massive duplication of `wp-tag-chip`, `wp-row-fav-btn`, `wp-row-name`, `wp-id`, `category-chip`, `wp-row-expand__title`, `wp-filters-grid`, `wp-fav-toggle`, `wp-field__label` rules across all 6 list views** (~150 lines of identical CSS in 6 files = ~900 LOC of redundancy).
- Quick-win count: ~22 (most are 1-line text-shadow / hex / token-fallback fixes; the bulk-fix is one extraction to tokens.css).
---
## P0 — Visual breakage / inconsistency users notice
- [ ] **Token name drift: `--wp-bg2/--wp-bg3/--wp-text2/--wp-text3` vs `--wp-bg-2/--wp-bg-3/--wp-text-muted/--wp-text-dim`**
- Files: `components/PipelineSteps.vue:218,222,230,243,256,264,278,284,292,309,312,319,321,336,341,360,366,386,396,398`, `components/PipelineStepPicker.vue:218,228,240,246,271,275,289,303,305,316,325,354,364,372,383,389,390,417,434,435,440`, `components/HistoryPanel.vue:179,190,208,229,233,257,261,271`, `components/DerivationRuleCard.vue:331,342,355,403`, `views/PipelineEditor.vue:385`
- Both spellings work (tokens.css 2239–2245 aliases them) but the unprefixed `bg2/bg3` form is the non-canonical legacy name. Mixing within one component (e.g. `PipelineSteps.vue` uses ONLY `bg2/bg3/text2/text3`) is harmless but confusing.
- Fix: pick one form (`--wp-bg-2/--wp-text-muted` is canonical per tokens.css line 35–46) and replace all `bg2/bg3/text2/text3` references.
- Effort: M
- [ ] **`category-chip` rule duplicated 8 times verbatim** (incl. `text-shadow: 0 0 2px rgba(0,0,0,.4)`)
- Files: `views/Wildcards.vue:427-435`, `views/FixedValues.vue:289-297`, `views/Combines.vue:320-328`, `views/Constraints.vue:352-357`, `views/Derivations.vue:323-331`, `views/Pipelines.vue:382-387`, `views/Categories.vue:291-299`, `views/Dashboard.vue:327-336` (renamed `dashboard__row-chip`).
- Fix: extract `.wp-cat-chip` into `tokens.css` and remove all 8 copies.
- Effort: S
- [ ] **List-view scoped styles are 95% identical across all 6 list screens**
- Files (approx 60 lines each duplicated): `views/Wildcards.vue:343-425`, `views/FixedValues.vue:228-308`, `views/Combines.vue:255-318`, `views/Derivations.vue:264-340`, `views/Constraints.vue:300-358`, `views/Pipelines.vue:336-388`. Rules: `wp-filters-grid`, `wp-field--full`, `wp-field__label`, `wp-fav-toggle`, `wp-tags-row`, `wp-tags-empty`, `wp-tag-chip`, `wp-row-fav-btn`, `wp-row-name`, `wp-row-name__text`, `wp-id`, `wp-row-expand__title`, `wp-row-expand__name`.
- `wp-row-fav-btn` is **already defined** by the parent `ModuleListView.vue:711-722`, so every list-view re-declaration is dead-effect duplicate.
- Fix: lift the shared `wp-filters-grid`, `wp-field__label`, `wp-fav-toggle`, `wp-tags-row`, `wp-tag-chip`, `wp-row-name`, `wp-id`, `wp-row-expand__title` into `tokens.css` and delete from each list view.
- Effort: M
- [ ] **Broken click handler in `ModuleListView.vue` default favorite slot**
- File: `components/ModuleListView.vue:480` — `@click.stop="$emit && $emit"` is a no-op string (not a method call). Falls through to nothing — the default `<slot name="favorite">` button does nothing when used without a parent override (every list view overrides it, so this masks the bug).
- Fix: either remove the default slot's button entirely, or wire it to a real `(emit:fav, row)` callback.
- Effort: S
- [ ] **Card subtitle is rendered after the `wp-spacer` so it sits on the right**
- File: `components/ui/Card.vue:17-20` — `<p class="wp-card__subtitle">{{ subtitle }}</p>` lives between title and `<span class="wp-spacer" />`. Since the header is `display:flex` (per tokens.css `.wp-card__header`), subtitle renders inline next to title, then the spacer pushes actions to the right. There's no `.wp-card__subtitle` rule in `tokens.css` either, so it inherits `<p>` defaults.
- Fix: wrap title+subtitle in a `wp-card__title-wrap` div and add `wp-card__subtitle` rule (font-size 11.5px, color text-dim, margin 0).
- Effort: S
- [ ] **`Settings.vue` and `TweaksPanel.vue` Buttons use `icon="pi-refresh"` (no `pi ` prefix)**, while 37 other Buttons use `icon="pi pi-refresh"`
- Files using bare form: `views/Settings.vue:99`, `views/Categories.vue:152,238`, `views/Dashboard.vue:158,159,191,192,193`, `views/TestRunner.vue:433,459,495,512,543,590,591,592,642,643,644`, `views/ImportExport.vue:537,542,547,554,645,801,809`, `components/TweaksPanel.vue:113`, `layout/AppTopbar.vue:175`. Files using prefixed form: 15 files (Wildcards, Combines, Constraints, Derivations, FixedValues, Pipelines, *Editor, EditorFrame, IdentityCard, ModuleListView, HistoryPanel, DerivationRuleCard).
- Both work because `Icon.vue:12` strips the redundant `pi` prefix. But 6 of 6 module list screens use one form, and most Dashboard/TestRunner/Settings use the other.
- Fix: pick one (recommend bare `pi-foo` since `Icon.vue` is the convention) and codemod-rewrite all 37 instances.
- Effort: S
- [ ] **Heavy inline `style="..."` attribute clusters in community screens**
- Files: `views/community/CommunityProfile.vue:76,77,78,86,156,157` (7 instances incl. layout `display:flex; gap:8px`, `flex:1`), `views/community/CommunityDetail.vue:157,241,292,296,300,323` (6 instances with 50+ char layout strings), `views/community/CommunityUpload.vue:291,307,310`, `views/community/CommunityProfile.vue:80-83` (`<div><strong>` blocks with no class). Stark contrast with main app screens that use scoped classes.
- Fix: extract to scoped classes in `community.css` (e.g. `.wp-comm-profile-meta`, `.wp-comm-profile-actions`, `.wp-comm-rating-row`).
- Effort: M
- [ ] **Two parallel empty-state shapes: `.wp-empty` (lists) vs `.wp-comm-empty` (community)**
- `wp-empty`: `components/ModuleListView.vue:521-528,755-775`, `views/Categories.vue:173-176`. Centered icon disc + msg + CTA.
- `wp-comm-empty`: `community.css` (imported in 6 community screens). Same shape, different CSS.
- Fix: consolidate on one (`wp-empty` is older/canonical) — rename `wp-comm-empty*` to `wp-empty*` and inherit; saves duplicated rules.
- Effort: M
---
## P1 — Polish that elevates quality
- [ ] **`opt-empty` rule duplicated** across editors
- `views/WildcardEditor.vue:377-381`, `views/FixedEditor.vue:271-275` — identical `text-align:center; padding:16px; color:var(--wp-text-dim)`.
- Fix: move to `tokens.css` as `.wp-table__empty-row`.
- Effort: S
- [ ] **`wp-row-expand__title` rule duplicated 6 times**
- `views/Wildcards.vue:474-481`, `views/FixedValues.vue:299-306`, `views/Combines.vue:332-339`, `views/Constraints.vue:360-367`, `views/Derivations.vue:333-340`, `views/Pipelines.vue:398-405`. Identical font-size 10.5px / uppercase / 0.06em letterspacing.
- Fix: move to tokens.css.
- Effort: S
- [ ] **`wp-tag-chip` rule duplicated 6 times** with subtle variants
- All 6 list views. `Wildcards.vue:372-392` adds `transition: all 0.12s` and a `:hover` rule; `Combines.vue:281-296` and `Constraints.vue:321-332` and `Derivations.vue:288-303` and `Pipelines.vue:353-364` and `FixedValues.vue:249-265` omit them.
- Fix: extract single canonical version with hover state.
- Effort: S
- [ ] **Sidebar CSS (`AppSidebar.vue`) — only 1px raw value but no scoped style block reviewed**; visually fine but worth checking width breakpoints
- File: `layout/AppSidebar.vue:1` (count: 1 instance per the grep summary; minimal).
- Effort: S — skip unless visual issue reported.
- [ ] **Tailwind-style utility classes leak into community screens**
- `views/community/CommunityDiscover.vue:235-238` defines `.w-full`, `.w-40`, `.w-44`, `.mt-3`. `Community404.vue:35`, `CommunityProfile.vue:175-176`, `CommunityOffline.vue:67-77` redefine `.mt-3` / `.self-start` per-file.
- Fix: pick token names (e.g. `wp-w-40`) or use inline `style="width:160px"`-free flex layouts. Prefer scoped class with project naming convention.
- Effort: M
- [ ] **Hardcoded color hex outside `tokens.css`**
- `components/RichTextInput.vue:416-417` (`color:#34d399; background:color-mix(... #10b981 ...)`), `:422-423` (`color:#fb923c; background:color-mix(... #f97316 ...)`)
- `components/RichTextPreview.vue:66-67,73-74` (same)
- `views/ImportExport.vue:1005-1006` (`#fb923c`).
- These are token-violations: the green/orange semantic colors should come from `--wp-success`/some new `--wp-token-warn`, etc.
- Fix: introduce `--wp-rt-token-good`, `--wp-rt-token-mut`, etc. in tokens.css.
- Effort: S
- [ ] **`wp-row-fav-btn` re-declared in 6 list-view scoped blocks — no-op**
- Already exists in `components/ModuleListView.vue:711-722`. Each list view inside a `<slot name="favorite">` rebinds the class but the CSS is unscoped-correctly-only on the parent component due to Vue scoped CSS. Net effect: each list view's copy has the unique scope hash and DOES override — and they're identical.
- Fix: delete from all 6 list views.
- Effort: S
- [ ] **`wp-id` rule duplicated 6 times**
- All 6 list-view scoped blocks. Identical.
- Fix: extract to tokens.css.
- Effort: S
- [ ] **`wp-fav-toggle` rule duplicated 6 times**
- All 6 list views.
- Fix: extract to tokens.css.
- Effort: S
- [ ] **`wp-filters-grid` + `wp-field--full` + `wp-field__label` duplicated 6 times**
- All 6 list views.
- Fix: extract to tokens.css.
- Effort: S
- [ ] **Wildcard editor's Options card uses `:padding="false"` while no other editor does**
- File: `views/WildcardEditor.vue:283`. None of the other 5 editors use `:padding="false"` on their main table card. Wildcard editor uses native table padding to align with cells; others wrap tables inside the card body's 14px padding.
- Fix: standardize on `:padding="false"` for any Card containing a `<table class="wp-table">` — apply to FixedEditor, ConstraintEditor exception table, DerivationEditor (no table). Or remove from Wildcard for parity.
- Effort: S
- [ ] **`wp-text-xs` defined locally in WildcardEditor only**
- File: `views/WildcardEditor.vue:358` (`.wp-text-xs { font-size: 11.5px; }`). Other editors hardcode `font-size: 11.5px` literally (PipelineEditor `.pl-hint`, DerivationEditor `.dv-hint`, ConstraintEditor `.cn-hint`).
- Fix: use one shared `wp-text-xs` token (or the existing `wp-card__hint`).
- Effort: S
- [ ] **Inconsistent button variants for "Open external link" / "Docs"**
- `views/Dashboard.vue:158` uses `variant="outline"` for Docs.
- `views/Settings.vue:65` Repo link uses raw `<a>` tag, no Button.
- `layout/AppTopbar.vue:175` uses `icon="pi-github"` plain Button.
- Fix: pick one pattern for "external nav" (recommend Button variant ghost + `iconRight="pi-external-link"`).
- Effort: S
- [ ] **`category-chip` border-radius differs**
- 6 list views: `border-radius: 9px`. `views/Dashboard.vue:331` for `dashboard__row-chip`: `border-radius: 9px` (matches). `views/ImportExport.vue:897` `wp-io-row__catchip`: `border-radius: 999px` (pill). Inconsistent shape for "category visual".
- Fix: pick one (9px is the prototype's intent per `docs/design-handoff/wildcardpipeline/project/`).
- Effort: S
- [ ] **Editor scaffold sizes differ across 6 editors**
- `WildcardEditor 404 LOC`, `FixedEditor 276`, `CombineEditor 327`, `DerivationEditor 290`, `ConstraintEditor 452`, `PipelineEditor 410`. Variation expected, but Wildcard has unique `.wp-text-xs`, FixedEditor has `.fv-row__err`, ConstraintEditor `.cn-empty/.cn-pair/.cn-cross`, PipelineEditor `.wp-pl-flow*` etc — all are decent local styles but several rules (e.g. `.dv-empty` vs `.cn-empty`) recreate the dashed-empty-card pattern.
- Fix: introduce shared `wp-empty-card` (dashed border + center text + 16px padding) in tokens.css.
- Effort: M
- [ ] **`opt-prob` width 32px hardcoded** in WildcardEditor
- File: `views/WildcardEditor.vue:399`. Probability bar fixed-width unit.
- Effort: S — token candidate.
- [ ] **`HistoryPanel` width hardcoded 480px**
- File: `components/HistoryPanel.vue:164`. Modal-like drawer should use a token (`--wp-drawer-w` or `min(480px, 100vw)`).
- Effort: S
- [ ] **`PipelineStepPicker` border-radius/padding mostly fine but mixes `var(--wp-radius-sm)` with literal `4px` and `5px` in same file**
- File: `components/PipelineStepPicker.vue:289` (`border-radius: 4px`), `:303` (`border-radius: 5px`), `:434` (`border-radius: var(--wp-radius)` ).
- Fix: standardize on tokens.
- Effort: S
- [ ] **`PipelineSteps.vue:230` and `PipelineEditor.vue` step-card border-left widths differ**
- PipelineSteps row: `border-left: 3px solid var(--step-color)`.
- PipelineEditor preview step: `border-left: 3px solid var(--step-color)` ✓ matches.
- DerivationRuleCard `:330`: `border-left: 3px solid var(--wp-kind-derivation, #fbbf24)` — same width but value-not-token-driven.
- Fix: tokenize as `--wp-kind-stripe-w: 3px`.
- Effort: S
- [ ] **`Wildcards.vue` syntax pills hardcode `font-size: 9.5px` for `.pi`**
- File: `views/Wildcards.vue:457`. Smallest icon size in entire app. Other inline icons inside list cells are 11–13px. Visually this dot will look noticeably smaller next to `wp-row-fav-btn` (13px).
- Fix: bump to 10.5px or 11px.
- Effort: S
- [ ] **Filter chip `border-radius: 999px` but tag chip `border-radius: 999px`, while filter-toggle in ModuleListView uses 11px**
- `components/ModuleListView.vue:635` (`.wp-filter-toggle { border-radius: 11px }`), but tag chips inside same toolbar use 999px. Same UI role (selectable chip), different shape.
- Fix: unify.
- Effort: S
- [ ] **`Wildcards.vue:298,304` inline `style="color: var(--wp-success...)"` instead of class**
- Two occurrences in template for valid/invalid state icons. Mixes inline-color with the cleaner pattern used elsewhere (`class="wp-token-good"` etc.).
- Fix: introduce `wp-icon--success`/`wp-icon--warn` utility classes.
- Effort: S
- [ ] **`AppTopbar.vue:33-35` ternary returns `var(--wp-success, #10b981)` etc. as strings**
- Three lines using inline-style ternary with token-fallback. Working but better as data-attribute + CSS.
- Fix: `:data-status="apiStatus"` + scoped `.wp-topbar-status[data-status="online"] { ...dot color }`.
- Effort: S
---
## P2 — Nice-to-have / cleanup
- [ ] **Inconsistent font-size literals throughout**
- Common values seen: `10px, 10.5px, 11px, 11.5px, 12px, 12.5px, 13px, 13.5px, 14px`. The half-pixel variants (`10.5/11.5/12.5/13.5`) make 9 distinct sizes for "small text". A 5-step scale (10/11/12/13/14) tokenized as `--wp-text-xs/-sm/-md/-base/-lg` would simplify maintenance.
- File hot-spots: `views/Wildcards.vue` (uses 10.5,11,11.5,12.5), `components/PipelineSteps.vue` (uses 9,10,11,11.5,12,12.5,13).
- Effort: L
- [ ] **`wp-output-var` and `wp-row-expand__name` — same role, different class**
- `views/Combines.vue:330`, `views/Wildcards.vue:482`, `views/FixedValues.vue:307`, `views/Constraints.vue:368`. All use `color: var(--wp-accent-text, #c4b5fd)`. Two different class names for identical visual.
- Fix: pick one (`wp-row-expand__name` is more descriptive).
- Effort: S
- [ ] **`wp-mono` used 70+ times alongside `font-family: var(--wp-font-mono)` literal in many places**
- Files: too many to list. Half use the class, half use the literal CSS in scoped style.
- Fix: prefer `wp-mono` class (already in tokens.css) — search-replace.
- Effort: M
- [ ] **`Categories.vue` `wp-cat-table` reimplements `wp-table` styling instead of extending**
- File: `views/Categories.vue:268-289`. Defines own `thead th` / `tbody td` rules duplicating what `tokens.css` already has on `.wp-table`. Causes subtle padding diffs (`padding: 9px 12px` here vs whatever `.wp-table` defines).
- Fix: use `.wp-table` directly with column class modifiers.
- Effort: M
- [ ] **`CommunityDiscover.vue:209` uses `style="background: var(--wp-bg-2)"` for skeleton loader**
- Inline `style=` for a static color. One-off.
- Effort: S
- [ ] **Loading state inconsistencies**
- `CommunityDiscover.vue:208-213` uses skeleton cards with `aria-busy="true"`.
- `views/Wildcards.vue:174` uses `:loading="store.loading"` passed to ModuleListView (no skeleton; only a no-op flag that seems unused).
- `TestRunner.vue:433` has its own loading button (`:loading="running"`).
- Fix: define one shared `.wp-skeleton` block in tokens.css (already exists per the prototype convention) and use across loading states.
- Effort: M
- [ ] **`wp-comm-tabs` / `wp-comm-tab` only in Community area**
- `views/community/CommunityProfile.vue:92-102` (uses tabs). No tab pattern in main app even though `Wildcards.vue` extra-filter chips serve a similar role. Not consolidatable here, but worth a note.
- Effort: -
- [ ] **`HistoryPanel` mounts a teleport with raw `<button>` Cancel** (not Button primitive)
- File: `components/HistoryPanel.vue` (around restore/close buttons; mostly uses Button correctly, but checked code shows mostly fine — verify usage of native `<button>` elsewhere). Minor.
- Effort: S
- [ ] **`Wildcards.vue:505` uses linear-gradient with hardcoded fallback hex**
- File: `views/Wildcards.vue:505`. `linear-gradient(90deg, var(--wp-accent-500, #8b5cf6), var(--wp-accent-400, #a78bfa))`. Same pattern in `WildcardEditor.vue:396`.
- Fix: define `--wp-accent-gradient` token in tokens.css (and have a one-place source-of-truth).
- Effort: S
- [ ] **`wp-syntax-pill` height hardcoded 20px** + `wp-tag-chip` no height (auto from padding)
- Difference between similar pill primitives. Hard to align inline.
- File: `views/Wildcards.vue:447`.
- Effort: S
- [ ] **`PipelineStepPicker.vue` overlay backdrop `rgba(0, 0, 0, 0.45)` is the literal value used by `HistoryPanel` (`:159`) — but no shared token**
- Fix: tokenize `--wp-overlay-bg`.
- Effort: S
- [ ] **`wp-btn--danger` class added to ghost buttons** to recolor on hover
- Files: `views/WildcardEditor.vue:342`, `FixedEditor.vue:248`, `ConstraintEditor.vue:414`. Pattern: `<Button variant="ghost" class="wp-btn--danger" />`. Inconsistent with ImportExport's `<Button variant="danger">` for similar destructive actions.
- Fix: introduce `variant="ghost-danger"` to Button.vue, or always use `variant="danger"` for destructive icons.
- Effort: S
- [ ] **Repeated `.wp-empty-card` / dashed-border-empty pattern**
- `views/DerivationEditor.vue:277-284` (`.dv-empty`), `views/ConstraintEditor.vue:439-447` (`.cn-empty`), `components/PipelineSteps.vue:213-220` (`.wp-pl-empty`). Same conceptual element (empty-state card with dashed border) defined 3 ways.
- Fix: shared class.
- Effort: S
- [ ] **`Dashboard.vue:344` `.dashboard__stat:focus-visible` uses `var(--wp-border-focus)` outline**
- Other focus rings in code use box-shadow / outline patterns differently (`ColorPicker.vue:230` uses 2px+4px stacked box-shadow). No consistent focus-ring token.
- Effort: M
- [ ] **`AppTopbar.vue` mixes raw `<button>` (lines around `.wp-topbar-user`) with Button primitives**
- File: `layout/AppTopbar.vue:220-269` (raw `<button class="wp-topbar-user">`, raw `<button class="wp-topbar-user__item">`).
- Fix: extend Button to support pill/avatar variant, or accept these as menu-specific.
- Effort: M
- [ ] **`ImportExport.vue:1005-1006` uses literal `#fb923c` for "modified" badge**
- Fix: introduce `--wp-status-modified` token.
- Effort: S
- [ ] **`RichTextPreview.vue` and `RichTextInput.vue` have parallel scoped style blocks** with the same syntax-highlighting rules
- `RichTextPreview.vue:44-90` mirrors `RichTextInput.vue:396-510` (preview is read-only subset). About 50 lines of CSS duplicated.
- Fix: extract to `styles/rich-text.css` and import into both.
- Effort: M
- [ ] **`wp-mono` font-size and color on `wp-id` is set in 6 list views — same as `wp-mono` global**
- `wp-id { font-family: var(--wp-font-mono); font-size: 10.5px; color: var(--wp-text-dim); }` x6.
- Fix: just use `class="wp-mono"` and add font-size variant token.
- Effort: S
---
## Cross-screen contract findings
### 6 list screens divergence
| Aspect | Wildcards | FixedValues | Combines | Derivations | Constraints | Pipelines |
|---|---|---|---|---|---|---|
| Mid-cols (`midCols` prop) | 4 | 2 | 3 | 2 | 4 | 3 |
| Has extra-filters chip row | yes (3 chips) | no | no | no | no | no |
| Expansion rich content | options grid w/ probability bars | first values list | input vars row | rules block | matrix list | step list |
| `wp-id` rule duplicated | yes (different formatting) | yes | yes | yes (compact) | yes | yes (different) |
| `wp-tag-chip` rule duplicated | yes (with hover+transition) | yes (with hover+transition) | yes (no hover) | yes (no hover) | yes (no hover) | yes (no hover) |
| Action buttons identical (Edit/Dup/Del) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Uses `mid-cols=4 with width:60/84/60 px` style cols | yes | mixed (no col widths beyond 130/70) | mixed | mixed | mixed | mixed |
| Inline `style="color: var(--wp-success/warn)"` for status icons | yes (lines 297, 303) | no | no | no | no | no |
Verdict: shape is consistent — all use ModuleListView, identical action triple, identical favorite slot. Visual divergence comes from divergent scoped CSS (item 3 in P0).
### 6 editors divergence
| Aspect | WildcardEditor | FixedEditor | CombineEditor | DerivationEditor | ConstraintEditor | PipelineEditor |
|---|---|---|---|---|---|---|
| Uses `IdentityCard` | ✓ (with var-binding) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Uses `EditorFrame` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Card with `:padding="false"` for table | yes (Options) | NO (FV uses padding) | n/a | n/a | NO (Exceptions) | n/a |
| Has Probability bar | yes | no | no | no | no | n/a |
| Empty-state card style | `.opt-empty` | `.opt-empty` (copied) | (none) | `.dv-empty` (dashed border) | `.cn-empty` (no border) | (none) |
| Local hint class | `.wp-text-xs` | (none) | (none) | `.dv-hint` | `.cn-hint` | `.pl-hint` |
| Uses `wp-btn--danger` ghost | yes | yes | (no trash inline) | (delegated to RuleCard) | yes | (n/a) |
| Adds card title actions | yes (Add option) | yes (Add value) | (no) | yes (Add rule) | yes (Add exception) | yes (Modules + Resolution) |
Verdict: Editor shells are highly consistent thanks to `EditorFrame` + `IdentityCard`. The variation is in body content. Consolidation targets: `.opt-empty/.dv-empty/.cn-empty/.cn-pair`, hint classes (`.dv-hint/.cn-hint/.pl-hint/.wp-text-xs`).
---
## Token misuse hotlist
Files with most inline px/em values (suggest extraction to tokens):
1. `views/TestRunner.vue` — 69 px values (most of any file). Many candidates for `--wp-text-xs/sm/md` plus dedicated `wp-tr-*` rule extraction.
2. `views/community/CommunityDiscover.vue` — heavy mix of `style="background:..."` plus utility classes (`w-40/w-44/mt-3`).
3. `views/Pipelines.vue` — 41 px values; the step-list duplicates PipelineSteps.vue rendering visually.
4. `views/Wildcards.vue` — 42 px values; many specific to syntax pills that could move to tokens.
5. `components/PipelineStepPicker.vue` — 57 px values; mixed `--wp-radius` vs `4px/5px`.
6. `components/PipelineSteps.vue` — 46 px values; uses old `wp-bg2/text2` token names.
7. `views/ImportExport.vue` — 56 px values; large file, many one-off layout literals.
8. `components/ModuleListView.vue` — 66 px values, but most are inside generic toolbar/pager primitives that could move to tokens.
---
## Recommendations
1. **Extract list-view chrome to tokens.css**: `wp-tag-chip`, `wp-row-name`, `wp-id`, `wp-row-expand__title`, `wp-row-expand__name`, `wp-fav-toggle`, `wp-filters-grid`, `wp-field__label`, `wp-cat-chip` (rename of category-chip with the `text-shadow` baked in). Estimated: deletes ~600 LOC across 8 files. Highest single-source-of-truth gain.
2. **Pick canonical token names** (`--wp-bg-2/--wp-bg-3/--wp-text-muted/--wp-text-dim`) and remove all `bg2/bg3/text2/text3` usages with a codemod. The aliases at tokens.css:2239-2245 can stay for backward compat, but Vue files should consistently use canonical names. Particularly impacts `PipelineSteps`, `PipelineStepPicker`, `HistoryPanel`, `DerivationRuleCard`.
3. **Standardize Button icon prop** to a single form (recommend bare `pi-foo` since `Icon.vue:12` already strips the prefix — and codify in the Button JSDoc). Run a one-line codemod to convert all `icon="pi pi-foo"` → `icon="pi-foo"`.
4. **Consolidate empty-state**: make `wp-empty` the canonical pattern; rewrite `wp-comm-empty` as a thin alias. Add a shared `wp-empty-card` for dashed-border empty cards (DerivationEditor, ConstraintEditor, PipelineSteps).
5. **Extract `RichTextInput`/`RichTextPreview` shared syntax-highlight CSS** to `styles/rich-text.css` and remove the literal `#34d399`/`#fb923c` hexes (replace with new `--wp-rt-token-good`/`--wp-rt-token-mut` tokens).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
# CODING AGENTS: READ THIS FIRST
This is a **handoff bundle** from Claude Design (claude.ai/design).
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
## What you should do — IMPORTANT
**Read the chat transcripts first.** There are 1 chat transcript(s) in `wildcardpipeline/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
**Find the primary design file under `wildcardpipeline/project/` and read it top to bottom.** The chat transcripts will tell you which file the user was last iterating on. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
## About the design files
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
## Bundle contents
- `wildcardpipeline/README.md` — this file
- `wildcardpipeline/chats/` — conversation transcripts (read these!)
- `wildcardpipeline/project/` — the `WildcardPipeline` project files (HTML prototypes, assets, components)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Wildcard Pipeline — SPA Manager</title>
<link rel="icon" href="assets/favicon.png" />
<!-- PrimeIcons (matches your stack) -->
<link rel="stylesheet" href="https://unpkg.com/primeicons@7.0.0/primeicons.css" />
<!-- Inter + JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="styles-community.css" />
</head>
<body>
<div id="root"></div>
<!-- React + Babel -->
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<!-- Tweaks -->
<script type="text/babel" src="tweaks-panel.jsx"></script>
<!-- Data + UI primitives -->
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="community-data.jsx"></script>
<script type="text/babel" src="ui.jsx"></script>
<script type="text/babel" src="rich-input.jsx"></script>
<script type="text/babel" src="shell.jsx"></script>
<!-- Screens -->
<script type="text/babel" src="screens/dashboard.jsx"></script>
<script type="text/babel" src="screens/lists.jsx"></script>
<script type="text/babel" src="screens/editors.jsx"></script>
<script type="text/babel" src="screens/utilities.jsx"></script>
<script type="text/babel" src="screens/community.jsx"></script>
<!-- App -->
<script type="text/babel" src="app.jsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,211 @@
/* global React, ReactDOM, WP_DATA, useToasts,
Topbar, Sidebar, PageHeader, Dashboard, useTheme,
WildcardsScreen, FixedValuesScreen, CombinesScreen, DerivationsScreen, ConstraintsScreen, PipelinesScreen,
CategoriesScreen, ImportExportScreen, TestRunnerScreen, SettingsScreen,
WildcardEditor, FixedValuesEditor, CombineEditor, DerivationEditor, ConstraintEditor, PipelineEditor,
TweaksPanel, useTweaks, TweakSection, TweakRadio, TweakSelect, TweakToggle */
const { useState, useEffect, useMemo } = React;
// Accent palette presets
const ACCENT_PRESETS = {
violet: { 300: "#c4b5fd", 400: "#a78bfa", 500: "#8b5cf6", 600: "#7c3aed", 700: "#6d28d9" },
indigo: { 300: "#a5b4fc", 400: "#818cf8", 500: "#6366f1", 600: "#4f46e5", 700: "#4338ca" },
teal: { 300: "#5eead4", 400: "#2dd4bf", 500: "#14b8a6", 600: "#0d9488", 700: "#0f766e" },
rose: { 300: "#fda4af", 400: "#fb7185", 500: "#f43f5e", 600: "#e11d48", 700: "#be123c" },
amber: { 300: "#fcd34d", 400: "#fbbf24", 500: "#f59e0b", 600: "#d97706", 700: "#b45309" },
};
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "violet",
"density": "compact",
"sidebar": "expanded",
"showFavoritesInSidebar": false
}/*EDITMODE-END*/;
function App() {
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [theme, setTheme] = useTheme();
const [route, setRoute] = useState({ name: "dashboard" });
const [toastNode, pushToast] = useToasts();
// Mutable copies of seed data
const [wildcards, setWildcards] = useState(WP_DATA.wildcards);
const [fixedValues, setFixedValues] = useState(WP_DATA.fixedValues);
const [combines, setCombines] = useState(WP_DATA.combines);
const [derivations, setDerivations] = useState(WP_DATA.derivations);
const [constraints, setConstraints] = useState(WP_DATA.constraints);
const [pipelines, setPipelines] = useState(WP_DATA.pipelines);
const [categories, setCategories] = useState(WP_DATA.categories);
// Apply accent tweak to CSS vars
useEffect(() => {
const p = ACCENT_PRESETS[tweaks.accent] || ACCENT_PRESETS.violet;
const root = document.documentElement;
Object.entries(p).forEach(([k, v]) => root.style.setProperty(`--wp-accent-${k}`, v));
}, [tweaks.accent]);
useEffect(() => {
document.documentElement.style.setProperty("--wp-input-h", tweaks.density === "comfortable" ? "38px" : "34px");
document.documentElement.style.setProperty("--wp-btn-h", tweaks.density === "comfortable" ? "38px" : "34px");
}, [tweaks.density]);
const navigate = (id) => {
if (id === "_settings") setRoute({ name: "settings" });
else setRoute({ name: id });
};
const onCreate = (kind) => setRoute({ name: kind + ":new" });
const onEdit = (kind, id) => setRoute({ name: kind + ":edit", id });
const sidebarActive = (() => {
const base = route.name.split(":")[0];
// Singular editor routes -> plural sidebar ids
return ({
wildcard: "wildcards",
fixed: "fixed",
combine: "combines",
derivation: "derivations",
constraint: "constraints",
pipeline: "pipelines",
})[base] || base;
})();
const collapsed = tweaks.sidebar === "collapsed";
// Render content
const content = (() => {
const r = route.name;
if (r === "dashboard") return <Dashboard onNavigate={(id) => setRoute({ name: id })} onCreate={(k) => onCreate(k)} />;
if (r === "wildcards") return <WildcardsScreen items={wildcards} setItems={setWildcards} onEdit={(id) => onEdit("wildcard", id)} onCreate={() => onCreate("wildcard")} />;
if (r === "fixed") return <FixedValuesScreen items={fixedValues} setItems={setFixedValues} onEdit={(id) => onEdit("fixed", id)} onCreate={() => onCreate("fixed")} />;
if (r === "combines") return <CombinesScreen items={combines} setItems={setCombines} onEdit={(id) => onEdit("combine", id)} onCreate={() => onCreate("combine")} />;
if (r === "derivations") return <DerivationsScreen items={derivations} setItems={setDerivations} onEdit={(id) => onEdit("derivation", id)} onCreate={() => onCreate("derivation")} />;
if (r === "constraints") return <ConstraintsScreen items={constraints} setItems={setConstraints} onEdit={(id) => onEdit("constraint", id)} onCreate={() => onCreate("constraint")} />;
if (r === "pipelines") return <PipelinesScreen items={pipelines} setItems={setPipelines} onEdit={(id) => onEdit("pipeline", id)} onCreate={() => onCreate("pipeline")} />;
if (r === "categories") return <CategoriesScreen items={categories} setItems={setCategories} />;
if (r === "io") return <ImportExportScreen pushToast={pushToast} />;
if (r === "test") return <TestRunnerScreen />;
if (r === "settings") return <SettingsScreen tweaks={tweaks} setTweak={setTweak} />;
if (r === "community") return <CommunityScreen />;
// Editor routes
const back = (kind) => ({ wildcard: "wildcards", fixed: "fixed", combine: "combines", derivation: "derivations", constraint: "constraints", pipeline: "pipelines" }[kind]);
const save = (kind, items, setItems, value) => {
if (route.name.endsWith(":new")) {
const newId = window.makeId(({ wildcard: "wc", fixed: "fv", combine: "cb", derivation: "dv", constraint: "cn", pipeline: "pl" }[kind]));
setItems([{ ...value, id: newId, updatedAt: Date.now(), favorite: false, valid: true, history: [] }, ...items]);
pushToast(`Created “${value.name || "Untitled"}”.`);
} else {
setItems(items.map((x) => {
if (x.id !== route.id) return x;
// Snapshot the pre-save state into history (cap at 3).
const { history: _h, ...prev } = x;
const snapshot = { ...prev, savedAt: Date.now() };
const history = [snapshot, ...(x.history || [])].slice(0, 3);
return { ...x, ...value, updatedAt: Date.now(), history };
}));
pushToast(`Saved “${value.name || "Untitled"}”.`);
}
setRoute({ name: back(kind) });
};
if (r === "wildcard:new" || r === "wildcard:edit") {
const initial = r === "wildcard:edit" ? wildcards.find((x) => x.id === route.id) : undefined;
return <WildcardEditor isNew={r.endsWith(":new")} initial={initial} onCancel={() => setRoute({ name: "wildcards" })} onSave={(v) => save("wildcard", wildcards, setWildcards, v)} />;
}
if (r === "fixed:new" || r === "fixed:edit") {
const initial = r === "fixed:edit" ? fixedValues.find((x) => x.id === route.id) : undefined;
return <FixedValuesEditor isNew={r.endsWith(":new")} initial={initial} onCancel={() => setRoute({ name: "fixed" })} onSave={(v) => save("fixed", fixedValues, setFixedValues, v)} />;
}
if (r === "combine:new" || r === "combine:edit") {
const initial = r === "combine:edit" ? combines.find((x) => x.id === route.id) : undefined;
return <CombineEditor isNew={r.endsWith(":new")} initial={initial} onCancel={() => setRoute({ name: "combines" })} onSave={(v) => save("combine", combines, setCombines, v)} />;
}
if (r === "derivation:new" || r === "derivation:edit") {
const initial = r === "derivation:edit" ? derivations.find((x) => x.id === route.id) : undefined;
return <DerivationEditor isNew={r.endsWith(":new")} initial={initial} onCancel={() => setRoute({ name: "derivations" })} onSave={(v) => save("derivation", derivations, setDerivations, v)} />;
}
if (r === "constraint:new" || r === "constraint:edit") {
const initial = r === "constraint:edit" ? constraints.find((x) => x.id === route.id) : undefined;
return <ConstraintEditor isNew={r.endsWith(":new")} initial={initial} onCancel={() => setRoute({ name: "constraints" })} onSave={(v) => save("constraint", constraints, setConstraints, v)} />;
}
if (r === "pipeline:new" || r === "pipeline:edit") {
const initial = r === "pipeline:edit" ? pipelines.find((x) => x.id === route.id) : undefined;
return <PipelineEditor isNew={r.endsWith(":new")} initial={initial} onCancel={() => setRoute({ name: "pipelines" })} onSave={(v) => save("pipeline", pipelines, setPipelines, v)} />;
}
return <Dashboard onNavigate={(id) => setRoute({ name: id })} onCreate={(k) => onCreate(k)} />;
})();
return (
<CommunityProvider pushToast={pushToast}>
<div className="wp-app">
<Topbar
onToggleSidebar={() => setTweak("sidebar", collapsed ? "expanded" : "collapsed")}
onOpenSettings={() => setRoute({ name: "settings" })}
theme={theme}
onCycleTheme={() => setTheme(theme === "dark" ? "light" : theme === "light" ? "auto" : "dark")}
extras={<CommunityTopbarPill />}
/>
<div className="wp-body" data-collapsed={collapsed}>
<Sidebar active={sidebarActive} onNavigate={navigate} collapsed={collapsed} />
<main className="wp-content">{content}</main>
</div>
{toastNode}
<TweaksPanel title="Tweaks">
<TweakSection label="Appearance">
<TweakRadio label="Accent"
value={tweaks.accent}
onChange={(v) => setTweak("accent", v)}
options={[
{ value: "violet", label: "Violet" },
{ value: "indigo", label: "Indigo" },
{ value: "teal", label: "Teal" },
{ value: "rose", label: "Rose" },
{ value: "amber", label: "Amber" },
]}
/>
<TweakRadio label="Density"
value={tweaks.density}
onChange={(v) => setTweak("density", v)}
options={[
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
]}
/>
<TweakRadio label="Sidebar"
value={tweaks.sidebar}
onChange={(v) => setTweak("sidebar", v)}
options={[
{ value: "expanded", label: "Expanded" },
{ value: "collapsed", label: "Collapsed" },
]}
/>
</TweakSection>
<TweakSection label="Navigation">
<TweakSelect label="Jump to screen"
value={route.name}
onChange={(v) => setRoute({ name: v })}
options={[
{ value: "dashboard", label: "Dashboard" },
{ value: "pipelines", label: "Pipelines" },
{ value: "wildcards", label: "Wildcards" },
{ value: "fixed", label: "Fixed Values" },
{ value: "combines", label: "Combines" },
{ value: "derivations", label: "Derivations" },
{ value: "constraints", label: "Constraints" },
{ value: "categories", label: "Categories" },
{ value: "io", label: "Import / Export" },
{ value: "test", label: "Test Runner" },
{ value: "community", label: "Community" },
{ value: "settings", label: "Settings" },
]}
/>
</TweakSection>
</TweaksPanel>
</div>
</CommunityProvider>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,16 @@
<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>
<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.4 KiB

@@ -0,0 +1,290 @@
/* global React, WP_DATA, makeId */
// Community — mock data layer for the public registry + a lightweight
// fake API client. Mirrors what the real /community/api would look like.
const COMMUNITY = (() => {
const now = Date.now();
const day = (n) => now - n * 86_400_000;
const hr = (n) => now - n * 3_600_000;
const min = (n) => now - n * 60_000;
// ----- Authors -----
const authors = [
{ id: "u_anya", handle: "anya-vox", name: "Anya Voskresenskaya", avatar: "🦊", verified: true, joined: day(420) },
{ id: "u_kaito", handle: "kaito.studio", name: "Kaito", avatar: "🐉", verified: true, joined: day(310) },
{ id: "u_marlow", handle: "marlow", name: "Sam Marlow", avatar: "🐢", verified: false, joined: day(120) },
{ id: "u_mira", handle: "mira.lab", name: "Mira Lab", avatar: "🌒", verified: true, joined: day(580) },
{ id: "u_pix", handle: "pixelnaut", name: "pixelnaut", avatar: "🐙", verified: false, joined: day(38) },
{ id: "u_pen", handle: "penumbra-co", name: "Penumbra Co.", avatar: "🌑", verified: true, joined: day(700) },
{ id: "u_lila", handle: "lila", name: "Lila Tran", avatar: "🦋", verified: false, joined: day(64) },
{ id: "u_dax", handle: "dax-prompts", name: "Dax", avatar: "🦅", verified: true, joined: day(200) },
];
// ----- Tags -----
const TAGS = [
"portrait", "scene", "lighting", "cinematic", "studio",
"nsfw", "anime", "photoreal", "concept-art", "fashion",
"weather", "post-process", "negative", "fantasy", "sci-fi",
"ghibli", "noir", "vibrant", "pastel", "monochrome",
];
// ----- Featured curation list (picked by id below) -----
const FEATURED = ["pkg_cinematic", "mod_haircolor_pro", "pkg_studio", "mod_weather_xl"];
// Helper to seed a deterministic-ish histogram
const histo = (vals) => {
const out = {};
vals.forEach((v, i) => { out[v] = 30 - i * 4 + Math.floor(Math.random() * 5); });
return out;
};
// ----- Modules (the registry catalog) -----
// Each entry mirrors the user's local-library module shape but adds:
// author, version, downloads, stars, rating, comments, NSFW, license, engineRange, readme, changelog.
const modulesRaw = [
{ id: "pkg_cinematic", kind: "pack", name: "Cinematic Vol. 1", tagline: "Weather-aware moody scene pack — 14 modules, drop-in pipeline.",
author: "u_kaito", category: "lighting", subKind: ["pipeline", "wildcard", "combine", "derivation"],
tags: ["cinematic", "scene", "weather", "noir"], downloads: 18420, stars: 1240, rating: 4.8, ratings: 312,
version: "2.1.0", versions: 7, updated: day(2), created: day(180), nsfw: false, license: "MIT", engineMin: "1.4",
hero: "linear-gradient(135deg, #312e81 0%, #0f172a 60%, #1e1b4b 100%)",
previewVals: ["overcast Tokyo at dusk", "neon-lit alley after rain", "fog-rolling mountain pass", "amber-lit diner counter", "snowy village morning"] },
{ id: "mod_haircolor_pro", kind: "wildcard", name: "Hair Color Pro", tagline: "120 hand-tuned hair colors with sub-categories.",
author: "u_anya", category: "subject",
tags: ["portrait", "fashion", "vibrant"], downloads: 9810, stars: 612, rating: 4.7, ratings: 188,
version: "1.3.2", versions: 12, updated: hr(11), created: day(220), nsfw: false, license: "CC0", engineMin: "1.2",
hero: "linear-gradient(135deg, #b91c1c 0%, #facc15 100%)",
previewVals: ["auburn", "chestnut", "platinum blonde", "raven black", "ash brown", "rose gold", "honey blonde"] },
{ id: "pkg_studio", kind: "pack", name: "Studio Photo Pack", tagline: "Locked studio camera defaults + 9 pose wildcards.",
author: "u_pen", category: "camera", subKind: ["fixed", "wildcard", "combine"],
tags: ["studio", "photoreal", "fashion"], downloads: 14210, stars: 988, rating: 4.9, ratings: 274,
version: "3.0.0", versions: 5, updated: day(5), created: day(640), nsfw: false, license: "MIT", engineMin: "1.4",
hero: "linear-gradient(135deg, #404040 0%, #171717 100%)",
previewVals: ["seated 3/4", "leaning against wall", "candid laughter", "hands in pockets", "looking over shoulder"] },
{ id: "mod_weather_xl", kind: "wildcard", name: "Weather XL", tagline: "30 weather variants with proper humidity / temperature tags.",
author: "u_mira", category: "weather",
tags: ["weather", "scene", "photoreal"], downloads: 7140, stars: 482, rating: 4.6, ratings: 142,
version: "0.9.4", versions: 6, updated: hr(38), created: day(95), nsfw: false, license: "MIT", engineMin: "1.3",
hero: "linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)",
previewVals: ["light rain", "thunderstorm", "dense fog", "snow flurries", "summer haze", "golden hour mist"] },
{ id: "mod_lighting_neon", kind: "wildcard", name: "Neon Lighting Mood", tagline: "Cyberpunk-friendly lighting wildcard — 22 options.",
author: "u_dax", category: "lighting",
tags: ["cinematic", "vibrant", "sci-fi", "concept-art"], downloads: 5390, stars: 318, rating: 4.5, ratings: 96,
version: "1.1.0", versions: 4, updated: day(1), created: day(60), nsfw: false, license: "MIT", engineMin: "1.4",
hero: "linear-gradient(135deg, #ec4899 0%, #6366f1 50%, #06b6d4 100%)",
previewVals: ["magenta backlight", "cyan rim light", "ultraviolet wash", "split-tone neon", "chromatic flare"] },
{ id: "cmb_subject_phrase", kind: "combine", name: "Subject Phrase Builder", tagline: "Composes natural-sounding subject sentences.",
author: "u_marlow", category: "subject",
tags: ["portrait", "post-process"], downloads: 3210, stars: 178, rating: 4.4, ratings: 54,
version: "1.0.4", versions: 3, updated: day(7), created: day(45), nsfw: false, license: "MIT", engineMin: "1.4",
hero: "linear-gradient(135deg, #14b8a6 0%, #0f766e 100%)",
previewVals: ["A 29-year-old with auburn hair, wearing a tailored tuxedo", "A 47-year-old with platinum hair, wearing an evening gown"] },
{ id: "drv_negative_guard", kind: "derivation", name: "Negative Guard XL", tagline: "Always-on anatomy + quality negatives. Battle-tested.",
author: "u_pen", category: "style",
tags: ["negative", "post-process"], downloads: 22140, stars: 1610, rating: 4.9, ratings: 488,
version: "4.2.1", versions: 14, updated: day(4), created: day(900), nsfw: false, license: "MIT", engineMin: "1.0",
hero: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
previewVals: ["extra fingers, distorted hands", "blurry, jpeg artifacts", "low-res, mutated"] },
{ id: "cn_palette", kind: "constraint", name: "Palette Coherence", tagline: "Bias matrix between hair × outfit color families.",
author: "u_anya", category: "style",
tags: ["fashion", "post-process"], downloads: 1840, stars: 96, rating: 4.3, ratings: 28,
version: "0.4.0", versions: 2, updated: day(11), created: day(40), nsfw: false, license: "MIT", engineMin: "1.4",
hero: "linear-gradient(135deg, #f59e0b 0%, #ef4444 50%, #8b5cf6 100%)",
previewVals: ["warm × cool → reduce", "rose gold × tuxedo → boost (override)"] },
{ id: "fv_brand_palette", kind: "fixed", name: "Brand Palette — Aurora", tagline: "Aurora studio palette + standard quality tags.",
author: "u_lila", category: "style",
tags: ["pastel", "studio"], downloads: 720, stars: 41, rating: 4.2, ratings: 12,
version: "1.0.0", versions: 1, updated: day(20), created: day(20), nsfw: false, license: "CC0", engineMin: "1.4",
hero: "linear-gradient(135deg, #c4b5fd 0%, #67e8f9 100%)",
previewVals: ["$primary=#8b5cf6", "$secondary=#22d3ee", "$tail=ultra-detailed, sharp focus"] },
{ id: "pkg_anime", kind: "pack", name: "Anime Studio Starter", tagline: "Ghibli + cyberpunk wildcards, paired pose pack.",
author: "u_pix", category: "style", subKind: ["wildcard", "combine"],
tags: ["anime", "ghibli", "concept-art"], downloads: 4320, stars: 244, rating: 4.5, ratings: 71,
version: "0.7.0", versions: 3, updated: day(3), created: day(80), nsfw: false, license: "MIT", engineMin: "1.4",
hero: "linear-gradient(135deg, #fb7185 0%, #f97316 100%)",
previewVals: ["studio ghibli watercolor", "cyberpunk concept art", "cel-shaded character"] },
{ id: "mod_pose_dramatic", kind: "wildcard", name: "Dramatic Poses", tagline: "Twelve film-grade dramatic poses for editorial.",
author: "u_kaito", category: "subject",
tags: ["fashion", "cinematic"], downloads: 2840, stars: 142, rating: 4.4, ratings: 38,
version: "1.0.2", versions: 2, updated: day(9), created: day(70), nsfw: false, license: "MIT", engineMin: "1.4",
hero: "linear-gradient(135deg, #312e81 0%, #db2777 100%)",
previewVals: ["windswept profile", "over-shoulder glance", "mid-stride confident"] },
{ id: "mod_nsfw_lingerie", kind: "wildcard", name: "Lingerie Editorial", tagline: "Editorial-style lingerie wardrobe wildcard. 18+.",
author: "u_dax", category: "outfit",
tags: ["fashion", "nsfw"], downloads: 1280, stars: 92, rating: 4.1, ratings: 24,
version: "0.5.0", versions: 1, updated: day(6), created: day(30), nsfw: true, license: "Proprietary", engineMin: "1.4",
hero: "linear-gradient(135deg, #831843 0%, #1f1f1f 100%)",
previewVals: ["[hidden — enable NSFW to preview]"] },
{ id: "mod_lens_pack", kind: "fixed", name: "Lens Pack Plus", tagline: "Twelve canonical lens-bias presets (50/85/35/24...).",
author: "u_pen", category: "camera",
tags: ["photoreal", "studio"], downloads: 4810, stars: 280, rating: 4.7, ratings: 78,
version: "2.0.0", versions: 4, updated: day(15), created: day(310), nsfw: false, license: "MIT", engineMin: "1.0",
hero: "linear-gradient(135deg, #475569 0%, #1e293b 100%)",
previewVals: ["50mm f/1.8", "85mm f/1.4", "35mm f/2"] },
{ id: "drv_wet_weather", kind: "derivation", name: "Wet Weather Adjustments", tagline: "Drop-in postprocess: rain → wet clothes, fog → dewy.",
author: "u_mira", category: "weather",
tags: ["weather", "post-process"], downloads: 3640, stars: 218, rating: 4.6, ratings: 64,
version: "1.2.0", versions: 5, updated: day(10), created: day(200), nsfw: false, license: "MIT", engineMin: "1.3",
hero: "linear-gradient(135deg, #0369a1 0%, #1e293b 100%)",
previewVals: ["if rain → append \"wet clothes, soaked hair\"", "if fog → append \"damp hair, dewy skin\""] },
];
// Ratings histogram for review widget (5,4,3,2,1)
const seedRatings = (avg, total) => {
// build a believable distribution that averages roughly to `avg`
const dist = [0, 0, 0, 0, 0];
let remaining = total;
const w = avg >= 4.5 ? [0.72, 0.21, 0.05, 0.015, 0.005]
: avg >= 4.0 ? [0.55, 0.30, 0.10, 0.03, 0.02]
: [0.35, 0.32, 0.20, 0.08, 0.05];
for (let i = 0; i < 5; i++) { dist[i] = Math.floor(total * w[i]); remaining -= dist[i]; }
dist[0] += remaining;
return dist; // index 0 = 5★, 4 = 1★
};
const COMMENTS_POOL = [
{ author: "u_lila", text: "Honestly the best one I've tried — drops in cleanly with the 1.4 engine.", at: hr(4), rating: 5 },
{ author: "u_marlow", text: "Solid. Wish there were more sub-categories for the warm tones.", at: hr(20), rating: 4 },
{ author: "u_pix", text: "Stars are deserved. The README walks through pipeline integration well.", at: day(2), rating: 5 },
{ author: "u_dax", text: "Mixed — some options feel redundant. Maybe trim the next version?", at: day(5), rating: 3 },
{ author: "u_anya", text: "Beautiful curation. Pairs perfectly with my Cinematic pack.", at: day(7), rating: 5 },
{ author: "u_kaito", text: "Engine 1.4 only — I'd add a fallback for 1.3 users.", at: day(9), rating: 4 },
];
const CHANGELOG_POOL = [
{ v: "2.1.0", at: day(2), notes: "Added 4 new options. Fixed weight balance on cool sub-category. Engine 1.4 compat." },
{ v: "2.0.0", at: day(40), notes: "Major restructure: split into sub-categories. Breaking change for legacy bindings." },
{ v: "1.4.1", at: day(80), notes: "Fixed typo in README. Tweaked default weights." },
{ v: "1.4.0", at: day(120), notes: "Initial public release." },
];
const modules = modulesRaw.map((m) => ({
...m,
ratingsDist: seedRatings(m.rating, m.ratings),
options: (m.previewVals || []).map((v, i) => ({ value: v, weight: Math.max(1, 5 - i) })),
readme: defaultReadme(m),
comments: COMMENTS_POOL.slice(0, 3 + ((m.id.charCodeAt(4) || 0) % 3)).map((c) => ({ ...c })),
changelog: CHANGELOG_POOL.slice(0, Math.min(m.versions, 4)),
histogram: histo(m.previewVals || []),
}));
// Persisted (per-user) state. The real backend would key by GitHub user.
// We just wrap localStorage so refreshes feel real.
const STORAGE_KEY = "wp_community_state_v1";
const loadState = () => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); }
catch { return {}; }
};
const saveState = (s) => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch {} };
// ---- Mock API (returns Promises with simulated latency) ----
let apiOnline = true;
const setApiOnline = (v) => { apiOnline = !!v; };
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
const api = {
ping: async () => { await delay(120); if (!apiOnline) throw new Error("offline"); return { ok: true }; },
list: async (q = {}) => {
await delay(180);
if (!apiOnline) throw new Error("offline");
let out = modules.slice();
if (q.kind && q.kind !== "all") out = out.filter((m) => m.kind === q.kind);
if (q.category) out = out.filter((m) => m.category === q.category);
if (q.tag) out = out.filter((m) => m.tags.includes(q.tag));
if (q.q) {
const s = q.q.toLowerCase();
out = out.filter((m) => (m.name + " " + m.tagline + " " + m.tags.join(" ")).toLowerCase().includes(s));
}
if (q.verifiedOnly) out = out.filter((m) => authors.find((a) => a.id === m.author)?.verified);
if (!q.includeNsfw) out = out.filter((m) => !m.nsfw);
if (q.compatibleOnly && q.engine) out = out.filter((m) => engineSatisfies(q.engine, m.engineMin));
const sort = q.sort || "trending";
out.sort((a, b) => {
if (sort === "stars") return b.stars - a.stars;
if (sort === "downloads") return b.downloads - a.downloads;
if (sort === "recent") return b.updated - a.updated;
if (sort === "rating") return b.rating - a.rating;
// trending = blend of recent updates × stars
return (b.stars * 0.6 + (1e10 / (now - b.updated)) * 0.4)
- (a.stars * 0.6 + (1e10 / (now - a.updated)) * 0.4);
});
return out;
},
detail: async (id) => {
await delay(220);
if (!apiOnline) throw new Error("offline");
const m = modules.find((x) => x.id === id);
if (!m) { const e = new Error("not_found"); e.code = 404; throw e; }
return m;
},
install: async (id) => { await delay(450); if (!apiOnline) throw new Error("offline"); return { ok: true, id }; },
uninstall: async (id) => { await delay(250); if (!apiOnline) throw new Error("offline"); return { ok: true, id }; },
star: async (id, on) => { await delay(180); if (!apiOnline) throw new Error("offline"); return { ok: true, id, on }; },
publish: async (payload) => { await delay(800); if (!apiOnline) throw new Error("offline"); return { ok: true, id: makeId("pub") }; },
report: async (id, reason) => { await delay(250); if (!apiOnline) throw new Error("offline"); return { ok: true }; },
comment: async (id, text, rating) => { await delay(250); if (!apiOnline) throw new Error("offline"); return { ok: true, id, text, rating, at: Date.now() }; },
me: async () => { await delay(140); if (!apiOnline) throw new Error("offline"); return null; }, // server-side; UI uses local state
};
function engineSatisfies(have, need) {
const a = have.split(".").map(Number);
const b = need.split(".").map(Number);
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i] || 0, y = b[i] || 0;
if (x > y) return true;
if (x < y) return false;
}
return true;
}
function defaultReadme(m) {
const a = authors.find((x) => x.id === m.author);
return [
`# ${m.name}`,
``,
m.tagline,
``,
`**Author** · @${a?.handle || "unknown"} ${a?.verified ? "✓" : ""}`,
`**License** · ${m.license} · **Engine** · ≥ ${m.engineMin} · **Kind** · ${m.kind}`,
``,
`## What's inside`,
`Drop-in ${m.kind} module${m.kind === "pack" ? " bundle" : ""}. Tags: ${m.tags.join(", ")}.`,
``,
`## Install`,
`Click **Install** above. The module is added to your local library and is then editable like any other.`,
``,
`## Notes`,
`- Snapshot is captured on insert — saved workflows stay reproducible regardless of registry updates.`,
`- Re-sync from the module's detail page to pull the latest version.`,
].join("\n");
}
return {
authors, modules, FEATURED, TAGS,
api, loadState, saveState, setApiOnline,
engineSatisfies,
};
})();
// Render a tiny markdown-ish blob (headings, bold, lists, code) without pulling a parser.
function renderMD(src) {
const lines = String(src || "").split("\n");
const out = [];
let inList = false;
const flushList = () => { if (inList) { out.push("</ul>"); inList = false; } };
const inline = (t) =>
t
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/(\B@[a-z0-9_.\-]+)/gi, '<span style="color:var(--wp-accent-text)">$1</span>');
for (const ln of lines) {
if (/^# /.test(ln)) { flushList(); out.push(`<h2>${inline(ln.slice(2))}</h2>`); continue; }
if (/^## /.test(ln)) { flushList(); out.push(`<h3>${inline(ln.slice(3))}</h3>`); continue; }
if (/^- /.test(ln)) { if (!inList) { out.push("<ul>"); inList = true; } out.push(`<li>${inline(ln.slice(2))}</li>`); continue; }
if (!ln.trim()) { flushList(); out.push("<p style=\"height:6px\"></p>"); continue; }
flushList();
out.push(`<p>${inline(ln)}</p>`);
}
flushList();
return out.join("");
}
Object.assign(window, { COMMUNITY, renderMD });
@@ -0,0 +1,677 @@
/* global React */
// Wildcard Pipeline — mock data + helpers shared across screens.
const WP_DATA = (() => {
const now = Date.now();
const min = (n) => now - n * 60_000;
const hr = (n) => now - n * 3_600_000;
const day = (n) => now - n * 86_400_000;
// Stable short-uuid-style ids — 8 chars, lowercase hex/base36.
const sid = (() => {
let n = 0xa1b2c3;
return () => {
n = (n * 1664525 + 1013904223) >>> 0;
return n.toString(36).padStart(8, "0").slice(-8);
};
})();
const id = (prefix) => `${prefix}_${sid()}`;
const categories = [
{ id: "cat_style", name: "Style", color: "#a78bfa" },
{ id: "cat_subject", name: "Subject", color: "#22d3ee" },
{ id: "cat_lighting", name: "Lighting", color: "#fbbf24" },
{ id: "cat_camera", name: "Camera", color: "#f472b6" },
{ id: "cat_weather", name: "Weather", color: "#60a5fa" },
{ id: "cat_outfit", name: "Outfit", color: "#34d399" },
{ id: "cat_pose", name: "Pose", color: "#fb923c" },
{ id: "cat_location", name: "Location", color: "#818cf8" },
{ id: "cat_mood", name: "Mood", color: "#f87171" },
{ id: "cat_props", name: "Props", color: "#94a3b8" },
];
// Helper: snake_case-ify a name for default variable bindings.
const toVar = (name) => name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_|_$/g, "");
// ---------- Wildcards ----------
const wildcardSpecs = [
{ name: "Hair Color", cat: "cat_subject", tags: ["character", "appearance"], subs: ["warm", "cool", "neutral"], updated: min(12), fav: true,
opts: [
{ weight: 3, value: "auburn", sub: "warm" },
{ weight: 2, value: "platinum blonde", sub: "cool" },
{ weight: 4, value: "raven black", sub: "neutral" },
{ weight: 1, value: "rose gold", sub: "warm" },
{ weight: 2, value: "ash brown", sub: "neutral" },
{ weight: 1, value: "icy silver", sub: "cool" },
] },
{ name: "Outfit", cat: "cat_outfit", tags: ["wardrobe"], subs: ["casual", "formal", "athletic"], updated: min(45),
opts: [
{ weight: 2, value: "{linen|cotton} sundress", sub: "casual" },
{ weight: 1, value: "tailored tuxedo", sub: "formal" },
{ weight: 2, value: "oversized hoodie in @hair_color", sub: "casual" },
{ weight: 1, value: "running shorts and tank", sub: "athletic" },
{ weight: 1, value: "{black|navy|emerald} evening gown", sub: "formal" },
{ weight: 1, value: "track jacket and leggings", sub: "athletic" },
] },
{ name: "Lighting Mood", cat: "cat_lighting", tags: ["scene", "mood"], subs: ["soft", "dramatic", "natural"], updated: hr(2), fav: true,
opts: [
{ weight: 3, value: "soft golden hour", sub: "natural" },
{ weight: 2, value: "harsh noon sun", sub: "natural" },
{ weight: 2, value: "rim-lit silhouette, @time_of_day", sub: "dramatic" },
{ weight: 3, value: "diffused window light", sub: "soft" },
{ weight: 1, value: "{neon|sodium-vapor|holographic}-soaked alley", sub: "dramatic" },
] },
{ name: "Camera Angle", cat: "cat_camera", tags: [], subs: [], updated: hr(8),
opts: [
{ weight: 4, value: "low angle hero shot" },
{ weight: 3, value: "overhead flat-lay" },
{ weight: 2, value: "dutch tilt" },
{ weight: 5, value: "eye-level portrait" },
{ weight: 2, value: "over-the-shoulder" },
] },
{ name: "Weather", cat: "cat_weather", tags: ["environment"], subs: ["wet", "dry"], updated: day(1),
opts: [
{ weight: 3, value: "drizzling rain", sub: "wet" },
{ weight: 2, value: "thick fog", sub: "wet" },
{ weight: 4, value: "clear skies", sub: "dry" },
{ weight: 1, value: "blowing snow", sub: "wet" },
{ weight: 2, value: "dusty heat", sub: "dry" },
] },
{ name: "Art Style", cat: "cat_style", tags: ["render"], subs: [], updated: day(2), valid: false,
opts: [
{ weight: 5, value: "photoreal" },
{ weight: 3, value: "studio ghibli watercolor" },
{ weight: 2, value: "cyberpunk concept art" },
{ weight: 1, value: "" },
] },
{ name: "Pose", cat: "cat_pose", tags: ["character"], subs: ["static", "dynamic"], updated: hr(6),
opts: [
{ weight: 3, value: "leaning against wall, holding @accessory", sub: "static" },
{ weight: 2, value: "mid-stride running", sub: "dynamic" },
{ weight: 2, value: "seated cross-legged", sub: "static" },
{ weight: 1, value: "mid-air leap, @expression", sub: "dynamic" },
] },
{ name: "Background", cat: "cat_location", tags: ["scene"], subs: ["urban", "nature", "interior"], updated: day(3),
opts: [
{ weight: 3, value: "rain-soaked {Tokyo|Osaka|Seoul} street", sub: "urban" },
{ weight: 2, value: "alpine pine forest under @weather", sub: "nature" },
{ weight: 2, value: "sunlit loft apartment", sub: "interior" },
{ weight: 1, value: "rooftop helipad", sub: "urban" },
{ weight: 2, value: "desert canyon at @time_of_day", sub: "nature" },
] },
{ name: "Expression", cat: "cat_mood", tags: ["face"], subs: [], updated: hr(20),
opts: [
{ weight: 3, value: "subtle smile" },
{ weight: 2, value: "intense focus" },
{ weight: 2, value: "wistful gaze" },
{ weight: 1, value: "stifled laughter" },
] },
{ name: "Accessory", cat: "cat_props", tags: ["wardrobe"], subs: [], updated: day(5),
opts: [
{ weight: 2, value: "round {wire|tortoise-shell} glasses" },
{ weight: 2, value: "silver pendant" },
{ weight: 1, value: "leather satchel" },
{ weight: 1, value: "vintage wristwatch" },
] },
{ name: "Skin Tone", cat: "cat_subject", tags: ["character", "appearance"], subs: ["warm", "cool", "neutral"], updated: hr(30),
opts: [
{ weight: 2, value: "warm olive", sub: "warm" },
{ weight: 2, value: "porcelain", sub: "cool" },
{ weight: 3, value: "deep umber", sub: "neutral" },
{ weight: 2, value: "sun-kissed tan", sub: "warm" },
{ weight: 1, value: "freckled fair", sub: "cool" },
] },
{ name: "Eye Color", cat: "cat_subject", tags: ["character", "appearance"], subs: [], updated: day(2),
opts: [
{ weight: 2, value: "hazel green" },
{ weight: 2, value: "ice blue" },
{ weight: 3, value: "deep brown" },
{ weight: 1, value: "amber" },
{ weight: 1, value: "violet" },
] },
{ name: "Time of Day", cat: "cat_lighting", tags: ["scene"], subs: [], updated: day(4),
opts: [
{ weight: 4, value: "golden hour" },
{ weight: 2, value: "blue hour" },
{ weight: 1, value: "high noon" },
{ weight: 2, value: "deep night" },
] },
{ name: "Lens", cat: "cat_camera", tags: ["photo"], subs: [], updated: day(7),
opts: [
{ weight: 3, value: "85mm f/1.4 portrait" },
{ weight: 2, value: "24mm wide environmental" },
{ weight: 2, value: "50mm normal" },
{ weight: 1, value: "135mm tight crop" },
] },
{ name: "Composition", cat: "cat_camera", tags: ["framing"], subs: [], updated: hr(48),
opts: [
{ weight: 3, value: "rule of thirds" },
{ weight: 2, value: "centered symmetrical" },
{ weight: 2, value: "leading lines" },
{ weight: 1, value: "negative space" },
] },
{ name: "Color Palette", cat: "cat_style", tags: ["render"], subs: ["warm", "cool", "muted"], updated: day(6),
opts: [
{ weight: 2, value: "amber and crimson", sub: "warm" },
{ weight: 2, value: "teal and ultraviolet",sub: "cool" },
{ weight: 2, value: "dusty pastels", sub: "muted" },
{ weight: 1, value: "monochrome sepia", sub: "muted" },
] },
{ name: "Render Quality", cat: "cat_style", tags: ["render"], subs: [], updated: day(10),
opts: [
{ weight: 5, value: "ultra-detailed, 8k" },
{ weight: 2, value: "film grain, analog" },
{ weight: 1, value: "watercolor wash" },
] },
{ name: "Material", cat: "cat_outfit", tags: ["wardrobe"], subs: [], updated: day(8),
opts: [
{ weight: 3, value: "raw selvedge denim" },
{ weight: 2, value: "boucle wool" },
{ weight: 2, value: "matte leather" },
{ weight: 1, value: "iridescent silk" },
] },
{ name: "Hairstyle", cat: "cat_subject", tags: ["character"], subs: ["short", "long", "tied"], updated: day(1),
opts: [
{ weight: 2, value: "shoulder-length waves", sub: "long" },
{ weight: 2, value: "blunt bob", sub: "short" },
{ weight: 2, value: "messy bun", sub: "tied" },
{ weight: 1, value: "long braid", sub: "tied" },
{ weight: 1, value: "buzzcut", sub: "short" },
] },
{ name: "Footwear", cat: "cat_outfit", tags: ["wardrobe"], subs: [], updated: day(12),
opts: [
{ weight: 2, value: "scuffed white sneakers" },
{ weight: 2, value: "leather oxfords" },
{ weight: 1, value: "platform boots" },
{ weight: 1, value: "barefoot" },
] },
{ name: "Architecture", cat: "cat_location", tags: ["scene"], subs: ["modern", "historic"], updated: day(9),
opts: [
{ weight: 2, value: "brutalist concrete", sub: "modern" },
{ weight: 2, value: "victorian terrace", sub: "historic" },
{ weight: 2, value: "minimalist scandi loft", sub: "modern" },
{ weight: 1, value: "ornate baroque cathedral", sub: "historic" },
] },
];
const wildcards = wildcardSpecs.map((w) => ({
id: id("wc"),
name: w.name,
category: w.cat,
tags: w.tags,
subCategories: w.subs,
options: w.opts,
updatedAt: w.updated,
favorite: !!w.fav,
valid: w.valid !== false,
varBinding: toVar(w.name),
history: [],
}));
// Add a rich history to one wildcard for demo.
const hairColor = wildcards.find((w) => w.name === "Hair Color");
if (hairColor) {
hairColor.history = [
{ name: "Hair Color", category: "cat_subject",
description: "Hair tones for portrait subjects.",
tags: ["character", "appearance"],
subCategories: ["warm", "cool", "neutral"],
varBinding: "hair_color",
options: hairColor.options.slice(0, 5),
savedAt: hr(2) },
{ name: "Hair Color", category: "cat_subject",
description: "",
tags: ["character"],
subCategories: ["warm", "cool"],
varBinding: "hair_color",
options: [
{ weight: 2, value: "auburn", sub: "warm" },
{ weight: 2, value: "platinum blonde", sub: "cool" },
{ weight: 3, value: "raven black", sub: "" },
],
savedAt: day(1) },
{ name: "hair_color", category: "cat_subject",
description: "",
tags: [], subCategories: [],
varBinding: "hair_color",
options: [
{ weight: 1, value: "blonde", sub: "" },
{ weight: 1, value: "brunette", sub: "" },
{ weight: 1, value: "black", sub: "" },
],
savedAt: day(3) },
];
}
// ---------- Fixed Values ----------
const fixedValueSpecs = [
{ name: "Subject Profile", cat: "cat_subject", tags: ["character"], updated: min(33), fav: true,
desc: "Canonical subject identity used across compositions.",
values: [
{ var: "name", value: "Mira Voss" },
{ var: "age", value: "29" },
{ var: "build", value: "athletic" },
{ var: "eyes", value: "hazel green" },
] },
{ name: "Location Tokyo", cat: "cat_location", tags: ["scene"], updated: hr(4),
desc: "Default location bindings — neon-Shibuya night.",
values: [
{ var: "city", value: "Tokyo" },
{ var: "ward", value: "Shibuya" },
{ var: "season", value: "late autumn" },
] },
{ name: "Lens Pack 50mm", cat: "cat_camera", tags: ["photo"], updated: day(3),
desc: "Studio prime lens defaults.",
values: [
{ var: "focal", value: "50mm" },
{ var: "aperture", value: "f/1.8" },
{ var: "iso", value: "200" },
] },
{ name: "Subject Profile B", cat: "cat_subject", tags: ["character"], updated: hr(20),
desc: "Alternate subject — older protagonist.",
values: [
{ var: "name", value: "Renata Iwasaki" },
{ var: "age", value: "47" },
{ var: "build", value: "lean" },
{ var: "eyes", value: "deep brown" },
] },
{ name: "Studio Defaults", cat: "cat_camera", tags: ["photo", "studio"], updated: day(11),
desc: "Common studio camera and grade settings.",
values: [
{ var: "lens_focal", value: "85mm" },
{ var: "iso", value: "100" },
{ var: "white_balance", value: "5500K" },
{ var: "grade", value: "kodak portra" },
] },
{ name: "Negative Defaults", cat: "cat_style", tags: ["render"], updated: day(2), fav: true,
desc: "Standard negative-prompt fragments.",
values: [
{ var: "neg_anatomy", value: "extra fingers, distorted hands" },
{ var: "neg_style", value: "blurry, jpeg artifacts" },
] },
{ name: "Brand Colors", cat: "cat_style", tags: ["palette"], updated: day(15),
desc: "Project palette references.",
values: [
{ var: "primary", value: "#8b5cf6" },
{ var: "secondary", value: "#22d3ee" },
{ var: "accent", value: "#fbbf24" },
] },
{ name: "Studio Tags", cat: "cat_style", tags: ["render"], updated: day(7),
desc: "Shared quality-tag bundle.",
values: [
{ var: "quality_tail", value: "ultra-detailed, sharp focus, professional photography" },
] },
{ name: "Location Paris", cat: "cat_location",tags: ["scene"], updated: day(20),
desc: "Paris seasonal defaults.",
values: [
{ var: "city", value: "Paris" },
{ var: "arrond", value: "11e" },
{ var: "season", value: "early spring" },
] },
];
const fixedValues = fixedValueSpecs.map((f) => ({
id: id("fv"),
name: f.name,
category: f.cat,
description: f.desc,
tags: f.tags,
values: f.values,
updatedAt: f.updated,
favorite: !!f.fav,
valid: true,
history: [],
}));
// ---------- Combines ----------
const combineSpecs = [
{ name: "Subject Phrase", cat: "cat_subject", tags: ["compose"], updated: hr(5), fav: true,
desc: "Builds a natural subject sentence from upstream wildcards.",
template: "$name, a $age-year-old with $hair_color hair, wearing $outfit",
output: "subject_phrase" },
{ name: "Scene Setter", cat: "cat_lighting", tags: ["scene"], updated: day(1),
desc: "Combines weather and lighting into a cohesive scene clause.",
template: "$weather under $lighting_mood, shot with $camera_angle",
output: "scene_clause" },
{ name: "Style Tail", cat: "cat_style", tags: ["render"], updated: day(2),
desc: "Quality and style tail appended to most prompts.",
template: "$art_style, $color_palette, $quality_tail",
output: "style_tail" },
{ name: "Camera Block", cat: "cat_camera", tags: ["photo"], updated: day(4),
desc: "Camera settings clause.",
template: "$lens at $aperture, ISO $iso, $composition",
output: "camera_block" },
{ name: "Location Phrase",cat: "cat_location", tags: ["scene"], updated: day(6),
desc: "Location with seasonal context.",
template: "$city ($ward), $season",
output: "location_phrase" },
];
const combines = combineSpecs.map((c) => ({
id: id("cb"),
name: c.name,
category: c.cat,
description: c.desc,
tags: c.tags,
template: c.template,
inputs: Array.from(new Set((c.template.match(/(?:^|[^$])\$([a-z_][a-z0-9_]*)/gi) || []).map((m) => "$" + m.match(/\$([a-z_][a-z0-9_]*)/i)[1]))),
output: "$" + c.output,
updatedAt: c.updated,
favorite: !!c.fav,
valid: true,
history: [],
}));
// ---------- Derivations (independent rules, each with branches + optional else) ----------
const derivationSpecs = [
{ name: "Wet-weather adjustments", cat: "cat_weather", tags: ["postprocess"], updated: hr(3), fav: true,
desc: "Insert wet-clothing and reflection cues if the weather resolved to rain (and the user didn't ask for an umbrella).",
rules: [
{ branches: [
{ condition: { kind: "contains", var: "weather", value: "rain" }, action: { kind: "append", target: "subject", value: "wet clothes, soaked hair" } },
{ condition: { kind: "contains", var: "weather", value: "fog" }, action: { kind: "append", target: "subject", value: "damp hair, dewy skin" } },
],
hasElse: true,
else: { kind: "append", target: "scene", value: "crisp dry detail" } },
{ branches: [
{ condition: { kind: "contains", var: "weather", value: "rain" }, action: { kind: "append", target: "scene", value: "reflective puddles" } },
],
hasElse: false, else: { kind: "append", target: "scene", value: "" } },
] },
{ name: "Neon glow boost", cat: "cat_lighting", tags: ["postprocess", "scene"], updated: day(2),
desc: "If lighting is neon, add reflective and chromatic flair.",
rules: [
{ branches: [
{ condition: { kind: "contains", var: "lighting_mood", value: "neon" }, action: { kind: "append", target: "scene", value: "reflective puddles, chromatic aberration" } },
],
hasElse: false, else: { kind: "append", target: "scene", value: "" } },
] },
{ name: "Negative anatomy guard", cat: "cat_style", tags: ["postprocess", "negative"], updated: day(5),
desc: "Always inject anatomy and quality negatives.",
rules: [
{ branches: [{ condition: { kind: "always" }, action: { kind: "append", target: "negative", value: "extra fingers, distorted hands" } }],
hasElse: false, else: { kind: "append", target: "negative", value: "" } },
{ branches: [{ condition: { kind: "always" }, action: { kind: "append", target: "negative", value: "blurry, jpeg artifacts" } }],
hasElse: false, else: { kind: "append", target: "negative", value: "" } },
] },
{ name: "Formal-attire scene swap", cat: "cat_outfit", tags: ["postprocess"], updated: day(3),
desc: "Swap urban backgrounds for an indoor venue when subject wears formal attire.",
rules: [
{ branches: [
{ condition: { kind: "contains", var: "outfit", value: "tuxedo" }, action: { kind: "replace", target: "scene", value: "ornate ballroom interior" } },
{ condition: { kind: "contains", var: "outfit", value: "evening gown" }, action: { kind: "replace", target: "scene", value: "marble gallery, soft uplighting" } },
],
hasElse: false, else: { kind: "append", target: "scene", value: "" } },
] },
{ name: "Cool-palette mood lift", cat: "cat_mood", tags: ["postprocess"], updated: day(7),
desc: "Subtle wistful tonality on cool color palettes; warmer warmth otherwise.",
rules: [
{ branches: [
{ condition: { kind: "contains", var: "color_palette", value: "teal" }, action: { kind: "append", target: "subject", value: "wistful gaze" } },
],
hasElse: true,
else: { kind: "append", target: "subject", value: "subtle smile" } },
] },
];
const derivations = derivationSpecs.map((d) => ({
id: id("dv"),
name: d.name,
category: d.cat,
description: d.desc,
tags: d.tags,
rules: d.rules,
updatedAt: d.updated,
favorite: !!d.fav,
valid: true,
history: [],
}));
// ---------- Constraints ----------
const constraintSpecs = [
{ name: "Warm Hair × Cool Outfit", cat: "cat_style", tags: ["palette"], updated: hr(1),
desc: "Bias warm-tone hair away from cool-tone outfits.",
target: "Hair Color", source: "Outfit",
matrix: [
["allow", "exclude", "allow"],
["allow", "allow", "boost"],
["allow", "allow", "allow"],
],
exceptions: [{ from: "rose gold", to: "tailored tuxedo", mode: "allow", note: "designer override" }] },
{ name: "Weather × Outfit Coherence", cat: "cat_weather", tags: ["coherence"], updated: day(1), fav: true,
desc: "Reduce probability of light outfits on wet weather.",
target: "Outfit", source: "Weather",
matrix: [
["reduce", "allow"],
["allow", "allow"],
["reduce", "allow"],
], exceptions: [] },
{ name: "Pose × Lighting Drama", cat: "cat_mood", tags: ["scene"], updated: day(2),
desc: "Boost dramatic poses with dramatic lighting.",
target: "Pose", source: "Lighting Mood",
matrix: [
["allow", "reduce", "allow"],
["allow", "boost", "allow"],
], exceptions: [] },
{ name: "Background × Time of Day", cat: "cat_location", tags: ["scene"], updated: day(4),
desc: "Match interiors with daylight, urban with night.",
target: "Background", source: "Time of Day",
matrix: [],
exceptions: [] },
];
const constraints = constraintSpecs.map((c) => {
const t = wildcards.find((w) => w.name === c.target);
const s = wildcards.find((w) => w.name === c.source);
return {
id: id("cn"),
name: c.name,
category: c.cat,
description: c.desc,
tags: c.tags,
target: t?.id,
source: s?.id,
matrix: c.matrix,
exceptions: c.exceptions,
updatedAt: c.updated,
favorite: !!c.fav,
valid: true,
history: [],
};
});
// ---------- Pipelines ----------
// A Pipeline is an ordered preset of modules from any kind. They run top-to-bottom,
// each appending to the resolved context the next module sees. Stored as an
// array of { kind, refId } references — when serialized to a ComfyUI workflow,
// each ref becomes a snapshot.
const pipelineSpecs = [
{ name: "Portrait — Default", cat: "cat_subject", tags: ["portrait", "ready"], updated: min(8), fav: true,
desc: "Standard character-portrait stack. Resolves subject, outfit, and lighting before composing the final phrase and adding negatives.",
steps: [
{ kind: "fixed", ref: "Subject Profile" },
{ kind: "wildcard", ref: "Hair Color" },
{ kind: "wildcard", ref: "Hairstyle" },
{ kind: "wildcard", ref: "Outfit" },
{ kind: "wildcard", ref: "Lighting Mood" },
{ kind: "constraint", ref: "Warm Hair × Cool Outfit" },
{ kind: "combine", ref: "Subject Phrase" },
{ kind: "combine", ref: "Style Tail" },
{ kind: "derivation", ref: "Negative anatomy guard" },
] },
{ name: "Cinematic Scene", cat: "cat_lighting", tags: ["scene", "wip"], updated: hr(2),
desc: "Builds a weather-aware cinematic scene with reflective post-processing on rain.",
steps: [
{ kind: "fixed", ref: "Location Tokyo" },
{ kind: "wildcard", ref: "Weather" },
{ kind: "wildcard", ref: "Time of Day" },
{ kind: "wildcard", ref: "Lighting Mood" },
{ kind: "wildcard", ref: "Camera Angle" },
{ kind: "wildcard", ref: "Lens" },
{ kind: "combine", ref: "Scene Setter" },
{ kind: "combine", ref: "Camera Block" },
{ kind: "derivation", ref: "Wet-weather adjustments" },
{ kind: "derivation", ref: "Neon glow boost" },
] },
{ name: "Studio Photo Pack", cat: "cat_camera", tags: ["photo", "studio"], updated: day(1),
desc: "Locked studio camera defaults with Pose / Composition variation.",
steps: [
{ kind: "fixed", ref: "Studio Defaults" },
{ kind: "wildcard", ref: "Pose" },
{ kind: "wildcard", ref: "Expression" },
{ kind: "wildcard", ref: "Composition" },
{ kind: "combine", ref: "Camera Block" },
{ kind: "derivation", ref: "Cool-palette mood lift" },
] },
{ name: "Editorial — Formal", cat: "cat_outfit", tags: ["editorial"], updated: day(3),
desc: "Formal-attire editorial spread with venue-aware background swap.",
steps: [
{ kind: "fixed", ref: "Subject Profile B" },
{ kind: "wildcard", ref: "Outfit" },
{ kind: "wildcard", ref: "Footwear" },
{ kind: "wildcard", ref: "Material" },
{ kind: "wildcard", ref: "Background" },
{ kind: "wildcard", ref: "Architecture" },
{ kind: "constraint", ref: "Weather × Outfit Coherence" },
{ kind: "derivation", ref: "Formal-attire scene swap" },
{ kind: "combine", ref: "Subject Phrase" },
] },
{ name: "Concept Art — Cool", cat: "cat_style", tags: ["concept", "cool-tone"], updated: day(5),
desc: "Cool-palette concept-art exploration with mood lift.",
steps: [
{ kind: "wildcard", ref: "Art Style" },
{ kind: "wildcard", ref: "Color Palette" },
{ kind: "wildcard", ref: "Render Quality" },
{ kind: "constraint", ref: "Pose × Lighting Drama" },
{ kind: "derivation", ref: "Cool-palette mood lift" },
{ kind: "combine", ref: "Style Tail" },
] },
{ name: "Negative-only base", cat: "cat_style", tags: ["utility"], updated: day(8), valid: true,
desc: "Reusable base — only injects negative prompt fragments.",
steps: [
{ kind: "fixed", ref: "Negative Defaults" },
{ kind: "derivation", ref: "Negative anatomy guard" },
] },
];
const findRef = (kind, name) => {
const list = { wildcard: wildcards, fixed: fixedValues, combine: combines, derivation: derivations, constraint: constraints }[kind];
return list?.find((x) => x.name === name);
};
const pipelines = pipelineSpecs.map((p) => ({
id: id("pl"),
name: p.name,
category: p.cat,
description: p.desc,
tags: p.tags,
steps: p.steps
.map((s) => {
const m = findRef(s.kind, s.ref);
return m ? { kind: s.kind, refId: m.id, enabled: true } : null;
})
.filter(Boolean),
updatedAt: p.updated,
favorite: !!p.fav,
valid: p.valid !== false,
history: [],
}));
// ---------- Recent edits ----------
const recent = [
{ kind: "pipeline", ref: "Portrait — Default", at: min(8) },
{ kind: "wildcard", ref: "Hair Color", at: min(12) },
{ kind: "fixed", ref: "Subject Profile", at: min(33) },
{ kind: "constraint", ref: "Warm Hair × Cool Outfit", at: hr(1) },
{ kind: "wildcard", ref: "Lighting Mood", at: hr(2) },
{ kind: "derivation", ref: "Wet-weather adjustments", at: hr(3) },
{ kind: "combine", ref: "Subject Phrase", at: hr(5) },
];
const findByName = (kind, name) => {
const list = { wildcard: wildcards, fixed: fixedValues, combine: combines, derivation: derivations, constraint: constraints, pipeline: pipelines }[kind];
return list.find((x) => x.name === name);
};
const recentEdits = recent
.map((r) => {
const m = findByName(r.kind, r.ref);
return m ? { kind: r.kind, id: m.id, name: m.name, at: r.at } : null;
})
.filter(Boolean);
return { categories, wildcards, fixedValues, combines, derivations, constraints, pipelines, recentEdits };
})();
// Helpers
function relTime(ts) {
const diff = Math.max(0, Date.now() - ts);
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return m + "m ago";
const h = Math.floor(m / 60);
if (h < 24) return h + "h ago";
const d = Math.floor(h / 24);
return d + "d ago";
}
function classNames(...xs) { return xs.filter(Boolean).join(" "); }
// Module-kind metadata (icons + labels + colors)
const KIND_META = {
wildcard: { label: "Wildcard", icon: "pi-th-large", color: "#a78bfa", id_prefix: "wc" },
fixed: { label: "Fixed Value", icon: "pi-tag", color: "#22d3ee", id_prefix: "fv" },
combine: { label: "Combine", icon: "pi-share-alt", color: "#34d399", id_prefix: "cb" },
derivation: { label: "Derivation", icon: "pi-code", color: "#fbbf24", id_prefix: "dv" },
constraint: { label: "Constraint", icon: "pi-sitemap", color: "#f472b6", id_prefix: "cn" },
pipeline: { label: "Pipeline", icon: "pi-list", color: "#fb7185", id_prefix: "pl" },
};
// ---------- Wildcard syntax helpers ----------
// Returns the list of @ref names referenced from a single value string.
// Skips @@ literal escapes.
function refsInValue(s) {
if (!s) return [];
const out = [];
const re = /(^|[^@])@([a-zA-Z_][a-zA-Z0-9_]*)/g;
let m;
while ((m = re.exec(s))) out.push(m[2]);
return out;
}
// True if a value string contains a {a|b|c} inline-choice block (with at least one |).
function hasInlineChoice(s) {
if (!s) return false;
const re = /\{([^{}]*\|[^{}]*)\}/;
return re.test(s);
}
// Aggregate flags + ref list for a wildcard module.
function getWildcardSyntax(wc) {
const refs = new Set();
let inline = false;
for (const opt of wc.options || []) {
refsInValue(opt.value).forEach((r) => refs.add(r));
if (hasInlineChoice(opt.value)) inline = true;
}
return { refs: [...refs], hasNested: refs.size > 0, hasInline: inline };
}
// Build a forward + reverse adjacency over all wildcards by varBinding.
// Forward: wildcardId -> Set<targetVarBinding>
// Reverse: varBinding -> Set<wildcardId> (who references this var)
function buildWildcardGraph(wildcards) {
const fwd = new Map();
const rev = new Map();
const byVar = new Map(wildcards.map((w) => [w.varBinding, w]));
for (const w of wildcards) {
const { refs } = getWildcardSyntax(w);
const targets = new Set(refs.filter((r) => byVar.has(r)));
fwd.set(w.id, targets);
for (const t of targets) {
if (!rev.has(t)) rev.set(t, new Set());
rev.get(t).add(w.id);
}
}
return { fwd, rev, byVar };
}
// Make a short uuid id for new items.
function makeId(prefix) {
const sid = Math.random().toString(36).slice(2, 10).padStart(8, "0").slice(-8);
return `${prefix}_${sid}`;
}
Object.assign(window, { WP_DATA, relTime, classNames, KIND_META, makeId, refsInValue, hasInlineChoice, getWildcardSyntax, buildWildcardGraph });
@@ -0,0 +1,525 @@
/* global React, ReactDOM, classNames, Icon */
// Rich text input with syntax highlighting overlay for:
// - $varname tokens (chip-rendered, click to open properties)
// - {a|b|c} dynamic-prompt segments (highlighted brackets + dividers)
// - {N$$sep$$a|b|c} multi-select with separator (highlighted)
// - {N::a} weighted choices (highlighted weight)
// - N#$var quantifier (highlighted prefix)
// - # comments (dimmed)
//
// Uses an overlay <div> mirror behind a transparent <textarea>/<input>. The
// mirror renders styled spans for syntax. The text in the mirror MUST line up
// exactly with the textarea text (same font, padding, line-height, white-space)
// so the syntax highlights sit underneath the user-visible characters.
const { useState: useRTState, useRef: useRTRef, useEffect: useRTEffect, useCallback: useRTCallback, useMemo: useRTMemo } = React;
// ---------- Tokenizer ----------
function tokenizeRich(text) {
const out = [];
let i = 0;
const N = text.length;
while (i < N) {
const ch = text[i];
// Comment: # at line start (or after whitespace) to end of line
if (ch === "#" && (i === 0 || /\s/.test(text[i - 1]))) {
let j = i;
while (j < N && text[j] !== "\n") j++;
out.push({ kind: "comment", text: text.slice(i, j), start: i, end: j });
i = j;
continue;
}
// Quantifier prefix: digits + # followed by $ or {
if (/[0-9]/.test(ch)) {
const m = text.slice(i).match(/^(\d+)#(?=[$\{])/);
if (m) {
out.push({ kind: "quantifier", text: m[0], start: i, end: i + m[0].length, meta: { count: Number(m[1]) } });
i += m[0].length;
continue;
}
// fall through: digits are plain text
}
// Dynamic prompt {…|…}
if (ch === "{") {
let depth = 1;
let j = i + 1;
while (j < N && depth > 0) {
if (text[j] === "{") depth++;
else if (text[j] === "}") depth--;
if (depth > 0) j++;
}
if (depth === 0) {
const inner = text.slice(i + 1, j);
const fullEnd = j + 1;
const multi = inner.match(/^(-?\d+(?:-\d+)?)\$\$(?:([^$]*)\$\$)?/);
const headerLen = multi ? multi[0].length : 0;
const headerStart = i + 1;
const headerEnd = headerStart + headerLen;
out.push({ kind: "dp-brace", text: "{", start: i, end: i + 1 });
if (multi) {
out.push({
kind: "dp-multi",
text: multi[0],
start: headerStart,
end: headerEnd,
meta: { range: multi[1], sep: multi[2] || null },
});
}
let k = headerEnd;
let optStart = k;
let innerDepth = 0;
const flushOption = (endIdx) => {
if (endIdx > optStart) {
const segText = text.slice(optStart, endIdx);
const w = segText.match(/^(\d+(?:\.\d+)?)::/);
if (w) {
out.push({ kind: "dp-weight", text: w[0], start: optStart, end: optStart + w[0].length, meta: { weight: Number(w[1]) } });
const subTokens = tokenizeRich(segText.slice(w[0].length));
for (const t of subTokens) {
out.push({ ...t, start: t.start + optStart + w[0].length, end: t.end + optStart + w[0].length });
}
} else {
const subTokens = tokenizeRich(segText);
for (const t of subTokens) {
out.push({ ...t, start: t.start + optStart, end: t.end + optStart });
}
}
}
};
while (k < j) {
if (text[k] === "{") innerDepth++;
else if (text[k] === "}") innerDepth--;
else if (text[k] === "|" && innerDepth === 0) {
flushOption(k);
out.push({ kind: "dp-pipe", text: "|", start: k, end: k + 1 });
optStart = k + 1;
}
k++;
}
flushOption(j);
out.push({ kind: "dp-brace", text: "}", start: j, end: fullEnd });
i = fullEnd;
continue;
}
// Unmatched brace — fall through, treat as plain text
}
// $$ literal escape
if (ch === "$" && text[i + 1] === "$") {
out.push({ kind: "escape", text: "$$", start: i, end: i + 2 });
i += 2;
continue;
}
// @@ literal escape
if (ch === "@" && text[i + 1] === "@") {
out.push({ kind: "escape", text: "@@", start: i, end: i + 2 });
i += 2;
continue;
}
// $varname token
if (ch === "$") {
const m = text.slice(i + 1).match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
if (m) {
out.push({ kind: "var", text: "$" + m[1], start: i, end: i + 1 + m[1].length, meta: { name: m[1] } });
i += 1 + m[1].length;
continue;
}
}
// @varname (nested wildcard reference)
if (ch === "@") {
const m = text.slice(i + 1).match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
if (m) {
out.push({ kind: "ref", text: "@" + m[1], start: i, end: i + 1 + m[1].length, meta: { name: m[1] } });
i += 1 + m[1].length;
continue;
}
}
// Plain text run — accumulate until next char that COULD start a special token.
// Always advance at least 1 char to guarantee progress.
let j = i + 1;
while (j < N) {
const c = text[j];
if (c === "$" || c === "@" || c === "{") break;
if (c === "#" && (j === 0 || /\s/.test(text[j - 1]))) break;
if (/[0-9]/.test(c) && /^\d+#[$\{]/.test(text.slice(j))) break;
j++;
}
out.push({ kind: "text", text: text.slice(i, j), start: i, end: j });
i = j;
}
return out;
}
// ---------- RichTextInput ----------
//
// Two render modes for the syntax-highlight mirror:
// - FOCUSED — chips render zero-width (background + box-shadow only, no
// padding/margin/border). Mirror text positions match the
// textarea exactly, so the native caret sits where it should.
// - BLURRED — chips render with real padding + rounded pill chrome so the
// value reads as nicely-styled tokens at rest.
// Switching the visual treatment by focus avoids fighting the textarea's
// caret math: we only care about pixel-perfect alignment while typing.
function RichTextInput({
value,
onChange,
multiline = false,
rows = 3,
placeholder,
options = [],
refOptions = null,
triggers = ["$"],
onTokenClick,
className,
style,
mono = true,
}) {
const taRef = useRTRef(null);
const mirrorRef = useRTRef(null);
const [focused, setFocused] = useRTState(false);
const [acOpen, setAcOpen] = useRTState(false);
const [acQuery, setAcQuery] = useRTState("");
const [acTrigger, setAcTrigger] = useRTState("$");
const [acStart, setAcStart] = useRTState(-1);
const [acActive, setAcActive] = useRTState(0);
const tokens = useRTMemo(() => tokenizeRich(value || ""), [value]);
// Sync mirror scroll with textarea scroll
const syncScroll = useRTCallback(() => {
if (taRef.current && mirrorRef.current) {
mirrorRef.current.scrollTop = taRef.current.scrollTop;
mirrorRef.current.scrollLeft = taRef.current.scrollLeft;
}
}, []);
useRTEffect(() => { syncScroll(); }, [value, syncScroll]);
const probeAutocomplete = useRTCallback((str, caret) => {
let i = caret - 1;
while (i >= 0 && /[a-zA-Z0-9_]/.test(str[i])) i--;
if (i < 0) return null;
const trigger = str[i];
if (!triggers.includes(trigger)) return null;
if (i > 0 && str[i - 1] === trigger) return null;
if (str[i + 1] === trigger) return null;
return { start: i, query: str.slice(i + 1, caret), trigger };
}, [triggers]);
const acItems = acOpen
? (acTrigger === "@" && refOptions ? refOptions : options)
.filter((o) => o.label.toLowerCase().includes(acQuery.toLowerCase()))
.slice(0, 8)
: [];
const handleChange = (e) => {
const next = e.target.value;
onChange(next);
const caret = e.target.selectionStart;
const hit = probeAutocomplete(next, caret);
if (hit) {
setAcOpen(true);
setAcStart(hit.start);
setAcQuery(hit.query);
setAcTrigger(hit.trigger);
setAcActive(0);
} else {
setAcOpen(false);
}
};
const handleKeyDown = (e) => {
if (acOpen && acItems.length > 0) {
if (e.key === "ArrowDown") { e.preventDefault(); setAcActive((a) => (a + 1) % acItems.length); return; }
if (e.key === "ArrowUp") { e.preventDefault(); setAcActive((a) => (a - 1 + acItems.length) % acItems.length); return; }
if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); applyAutocomplete(acItems[acActive]); return; }
if (e.key === "Escape") { setAcOpen(false); return; }
}
};
const applyAutocomplete = (opt) => {
const el = taRef.current;
if (!el) return;
const caret = el.selectionStart;
const before = (value || "").slice(0, acStart);
const after = (value || "").slice(caret);
const inserted = acTrigger + opt.label;
const next = before + inserted + after;
onChange(next);
setAcOpen(false);
requestAnimationFrame(() => {
const pos = (before + inserted).length;
el.focus();
el.setSelectionRange(pos, pos);
});
};
const handleMirrorMouseDown = (e) => {
if (!onTokenClick) return;
const target = e.target.closest(".wp-rt-var");
if (!target) return;
e.preventDefault();
e.stopPropagation();
const rect = target.getBoundingClientRect();
const idx = Number(target.dataset.tokenIdx);
const tok = tokens[idx];
if (tok) onTokenClick(tok, rect);
};
// Mirror layer renders tokens via React (safer than dangerouslySetInnerHTML).
const taProps = {
ref: taRef,
value: value || "",
onChange: handleChange,
onKeyDown: handleKeyDown,
onScroll: syncScroll,
onFocus: () => setFocused(true),
onBlur: () => { setFocused(false); setTimeout(() => setAcOpen(false), 120); },
placeholder,
spellCheck: false,
className: classNames("wp-rt__input", multiline ? "wp-rt__input--multi" : "wp-rt__input--single", className),
style: { fontFamily: mono ? "var(--wp-font-mono)" : undefined, ...style },
};
return (
<div className="wp-rt" data-focused={focused ? "" : null} data-multi={multiline ? "" : null}>
<div
ref={mirrorRef}
className={classNames("wp-rt__mirror", multiline ? "wp-rt__mirror--multi" : "wp-rt__mirror--single", focused ? "wp-rt__mirror--editing" : "wp-rt__mirror--rest")}
style={{ fontFamily: mono ? "var(--wp-font-mono)" : undefined, ...style }}
aria-hidden
onMouseDown={handleMirrorMouseDown}
>
{tokens.map((t, idx) => (
<span
key={idx}
className={`wp-rt-${t.kind}`}
data-token-idx={t.kind === "var" ? idx : undefined}
>
{t.text}
</span>
))}
{/* Trailing zero-width space — ensures the mirror grows to match a final newline */}
<span className="wp-rt-tail">{"\u200B"}</span>
</div>
{multiline ? <textarea rows={rows} {...taProps} /> : <input {...taProps} />}
{acOpen && acItems.length > 0 && (
<div className="wp-autocomplete">
<div className="wp-autocomplete__head">
<span style={{ fontFamily: "var(--wp-font-mono)", color: "var(--wp-accent-text)" }}>{acTrigger}{acQuery}</span>
<span className="wp-dim" style={{ fontSize: 10.5, marginLeft: "auto" }}>↑↓ Enter · Esc</span>
</div>
{acItems.map((o, i) => (
<button
key={o.label}
type="button"
className="wp-autocomplete__item"
data-active={i === acActive}
onMouseDown={(e) => { e.preventDefault(); applyAutocomplete(o); }}
onMouseEnter={() => setAcActive(i)}
>
<span className="wp-autocomplete__label">
<span style={{ color: "var(--wp-accent-text)" }}>{acTrigger}</span>{o.label}
</span>
{o.hint && <span className="wp-autocomplete__hint">{o.hint}</span>}
</button>
))}
</div>
)}
</div>
);
}
// ---------- Preview-only renderer ----------
function RichTextPreview({ value }) {
const tokens = useRTMemo(() => tokenizeRich(value || ""), [value]);
return (
<span className="wp-rt__preview">
{tokens.map((t, i) => (
<span key={i} className={`wp-rt-${t.kind}`}>{t.text}</span>
))}
</span>
);
}
// ---------- Token properties popover ----------
// Edits per-reference modifiers on a $var token by rewriting the source string.
// Modifiers (Impact-pack syntax):
// {N$$ sep $$$var} multi-select count + custom separator
// N#$var quantifier (resolve N times independently)
function TokenPropsPopover({ token, rect, value, onChange, onClose, optionInfo }) {
// Detect existing wrapping syntax around this token
const ctx = useRTMemo(() => {
const def = { count: 1, separator: ", ", quantifier: 1, wrapStart: token?.start ?? 0, wrapEnd: token?.end ?? 0 };
if (!token || !value) return def;
let { count, separator, quantifier, wrapStart, wrapEnd } = def;
wrapStart = token.start;
wrapEnd = token.end;
// Quantifier prefix: \d+# right before token
const quantMatch = value.slice(0, token.start).match(/(\d+)#$/);
if (quantMatch) {
quantifier = Number(quantMatch[1]);
wrapStart = token.start - quantMatch[0].length;
}
// Multi-select wrap: walk back to nearest unmatched `{`
let openIdx = -1;
let depth = 0;
for (let i = wrapStart - 1; i >= 0; i--) {
if (value[i] === "}") depth++;
else if (value[i] === "{") {
if (depth === 0) { openIdx = i; break; }
depth--;
}
}
if (openIdx !== -1) {
let closeIdx = -1;
let d = 1;
for (let j = openIdx + 1; j < value.length; j++) {
if (value[j] === "{") d++;
else if (value[j] === "}") { d--; if (d === 0) { closeIdx = j; break; } }
}
if (closeIdx !== -1) {
const inner = value.slice(openIdx + 1, closeIdx);
const headerMatch = inner.match(/^(-?\d+(?:-\d+)?)\$\$(?:([^$]*)\$\$)?/);
if (headerMatch) {
const headerLen = headerMatch[0].length;
const tokenInWrap = value.slice(openIdx + 1 + headerLen, closeIdx).trim();
const tokenText = value.slice(wrapStart, wrapEnd);
if (tokenInWrap === tokenText) {
const r = headerMatch[1].match(/^(-?\d+)(?:-(\d+))?$/);
count = r ? Number(r[2] || r[1]) : 1;
separator = headerMatch[2] || ", ";
wrapStart = openIdx;
wrapEnd = closeIdx + 1;
}
}
}
}
return { count, separator, quantifier, wrapStart, wrapEnd };
}, [token, value]);
const [count, setCount] = useRTState(ctx.count);
const [separator, setSeparator] = useRTState(ctx.separator);
const [quantifier, setQuantifier] = useRTState(ctx.quantifier);
useRTEffect(() => {
setCount(ctx.count);
setSeparator(ctx.separator);
setQuantifier(ctx.quantifier);
}, [token?.start, ctx.count, ctx.separator, ctx.quantifier]);
useRTEffect(() => {
const onDown = (e) => { if (!e.target.closest(".wp-tokenprops") && !e.target.closest(".wp-rt-var")) onClose(); };
const onScroll = () => onClose();
window.addEventListener("mousedown", onDown);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("mousedown", onDown);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
};
}, [onClose]);
const apply = (nextCount, nextSep, nextQuant) => {
const tokenText = "$" + token.meta.name;
let replacement = tokenText;
if (nextCount > 1) {
replacement = (nextSep && nextSep !== ", ")
? `{${nextCount}$$${nextSep}$$${replacement}}`
: `{${nextCount}$$${replacement}}`;
}
if (nextQuant > 1) replacement = `${nextQuant}#${replacement}`;
const next = value.slice(0, ctx.wrapStart) + replacement + value.slice(ctx.wrapEnd);
onChange(next);
};
const update = (patch) => {
const next = { count, separator, quantifier, ...patch };
setCount(next.count); setSeparator(next.separator); setQuantifier(next.quantifier);
apply(next.count, next.separator, next.quantifier);
};
const emitted = (() => {
let s = "$" + (token?.meta?.name || "");
if (count > 1) {
s = (separator && separator !== ", ")
? `{${count}$$${separator}$$${s}}`
: `{${count}$$${s}}`;
}
if (quantifier > 1) s = `${quantifier}#${s}`;
return s;
})();
if (!token || !rect) return null;
const POP_W = 320;
const left = Math.min(window.innerWidth - POP_W - 12, Math.max(12, rect.left));
const top = Math.min(window.innerHeight - 360, rect.bottom + 6);
return ReactDOM.createPortal(
<div className="wp-tokenprops" style={{ left, top, width: POP_W }} onClick={(e) => e.stopPropagation()}>
<div className="wp-tokenprops__head">
<Icon name="pi-tag" style={{ fontSize: 11, color: "var(--wp-accent-text)" }} />
<span className="wp-tokenprops__name">${token.meta.name}</span>
<span className="wp-tokenprops__type">{optionInfo?.kind || "variable"}</span>
<button type="button" className="wp-btn wp-btn--ghost wp-btn--sm wp-btn--icon" style={{ marginLeft: 4 }} onClick={onClose} title="Close">
<Icon name="pi-times" />
</button>
</div>
{optionInfo?.hint && (
<div className="wp-dim" style={{ fontSize: 11.5, lineHeight: 1.4 }}>{optionInfo.hint}</div>
)}
<div className="wp-tokenprops__row">
<span className="wp-tokenprops__label">Pick count</span>
<div className="wp-hsplit" style={{ gap: 6 }}>
<input type="number" min={1} max={20} value={count} onChange={(e) => update({ count: Math.max(1, Number(e.target.value) || 1) })} className="wp-input wp-input--sm" style={{ width: 64 }} />
<span className="wp-dim" style={{ fontSize: 11 }}>{count > 1 ? `pick ${count} options` : "single value"}</span>
</div>
</div>
<div className="wp-tokenprops__row" style={{ opacity: count > 1 ? 1 : 0.45 }}>
<span className="wp-tokenprops__label">Separator</span>
<input
type="text"
value={separator}
onChange={(e) => update({ separator: e.target.value })}
disabled={count <= 1}
className="wp-input wp-input--sm"
placeholder=", "
/>
</div>
<div className="wp-tokenprops__row">
<span className="wp-tokenprops__label">Repeat</span>
<div className="wp-hsplit" style={{ gap: 6 }}>
<input type="number" min={1} max={20} value={quantifier} onChange={(e) => update({ quantifier: Math.max(1, Number(e.target.value) || 1) })} className="wp-input wp-input--sm" style={{ width: 64 }} />
<span className="wp-dim" style={{ fontSize: 11 }}>{quantifier > 1 ? `resolve ${quantifier}× independently` : "no repeat"}</span>
</div>
</div>
<div>
<div className="wp-tokenprops__label" style={{ marginBottom: 4 }}>Emitted syntax</div>
<div className="wp-tokenprops__output">{emitted}</div>
</div>
<div className="wp-tokenprops__legend">
<Icon name="pi-info-circle" style={{ fontSize: 10 }} />
Edits this reference in place using Impact-pack-style syntax.
</div>
</div>,
document.body
);
}
Object.assign(window, { RichTextInput, RichTextPreview, tokenizeRich, TokenPropsPopover });
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
/* global React, WP_DATA, KIND_META, relTime, Icon, Button, Card, classNames */
function Dashboard({ onNavigate, onCreate }) {
const stats = [
{ label: "Pipelines", value: WP_DATA.pipelines.length, icon: "pi-list", delta: "+1 this week", screen: "pipelines" },
{ label: "Wildcards", value: WP_DATA.wildcards.length, icon: "pi-th-large", delta: "+2 this week", screen: "wildcards" },
{ label: "Fixed Values", value: WP_DATA.fixedValues.length,icon: "pi-tag", delta: "+1 this week", screen: "fixed" },
{ label: "Total Modules", value:
WP_DATA.wildcards.length + WP_DATA.fixedValues.length +
WP_DATA.combines.length + WP_DATA.derivations.length + WP_DATA.constraints.length,
icon: "pi-database", delta: "+5 this week" },
];
return (
<div className="wp-page">
{/* Hero */}
<div className="wp-hero">
<div className="wp-hero__icon"><img src="assets/logo-color.png" alt="" /></div>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 className="wp-hero__title">Welcome to Wildcard Pipeline</h2>
<p className="wp-hero__sub">
Manage modules — wildcards, fixed values, combines, derivations and constraints — that drop into ComfyUI prompts as snapshots.
</p>
</div>
<div className="wp-hsplit" style={{ gap: 8 }}>
<Button variant="outline" icon="pi-book" onClick={() => {}}>Docs</Button>
<Button variant="primary" icon="pi-plus" onClick={() => onCreate("wildcard")}>New module</Button>
</div>
</div>
{/* Stats */}
<div className="wp-stats">
{stats.map((s) => (
<div className="wp-stat" key={s.label} role={s.screen ? "button" : undefined} tabIndex={s.screen ? 0 : undefined}
onClick={s.screen ? () => onNavigate(s.screen) : undefined}
onKeyDown={(e) => s.screen && (e.key === "Enter" || e.key === " ") && (e.preventDefault(), onNavigate(s.screen))}
style={s.screen ? { cursor: "pointer" } : undefined}>
<div className="wp-stat__icon"><Icon name={s.icon} /></div>
<div className="wp-stat__label">{s.label}</div>
<div className="wp-stat__value">{s.value}</div>
<div className="wp-stat__delta"><Icon name="pi-arrow-up-right" style={{ fontSize: 9 }} /> {s.delta}</div>
</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1fr)", gap: 14 }}>
{/* Recent edits */}
<Card title="Recent Edits" padded={false} action={
<Button variant="ghost" size="sm" iconRight="pi-arrow-right" onClick={() => onNavigate("wildcards")}>View all</Button>
}>
<div className="wp-list">
{WP_DATA.recentEdits.map((r) => {
const m = KIND_META[r.kind];
const target = ({ wildcard: "wildcards", fixed: "fixed", combine: "combines", derivation: "derivations", constraint: "constraints", pipeline: "pipelines" })[r.kind] || "wildcards";
return (
<div className="wp-list__row" key={r.id} role="button" tabIndex={0}
onClick={() => onNavigate(target)}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), onNavigate(target))}>
<span style={{
width: 22, height: 22, borderRadius: 6, display: "grid", placeItems: "center",
background: `color-mix(in oklab, ${m.color} 18%, transparent)`,
color: m.color, fontSize: 11,
}}>
<Icon name={m.icon} />
</span>
<span style={{ flex: 1, fontWeight: 500 }}>{r.name}</span>
<span className="wp-id">{r.id}</span>
<span className="wp-dim" style={{ width: 70, textAlign: "right", fontSize: 11.5 }}>{relTime(r.at)}</span>
</div>
);
})}
</div>
</Card>
{/* Quick actions */}
<Card title="Quick Actions">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<Button variant="primary" icon="pi-plus" onClick={() => onCreate("pipeline")} style={{ gridColumn: "1 / -1" }}>New Pipeline</Button>
<Button variant="outline" icon="pi-plus" onClick={() => onCreate("wildcard")}>Wildcard</Button>
<Button variant="outline" icon="pi-plus" onClick={() => onCreate("fixed")}>Fixed Values</Button>
<Button variant="outline" icon="pi-plus" onClick={() => onCreate("combine")}>Combine</Button>
<Button variant="outline" icon="pi-plus" onClick={() => onCreate("derivation")}>Derivation</Button>
<Button variant="outline" icon="pi-plus" onClick={() => onCreate("constraint")} style={{ gridColumn: "1 / -1" }}>Constraint</Button>
</div>
<div className="wp-divider" style={{ margin: "12px 0" }} />
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Button variant="ghost" icon="pi-bolt" onClick={() => onNavigate("test")}>Open test runner</Button>
<Button variant="ghost" icon="pi-arrow-right-arrow-left" onClick={() => onNavigate("io")}>Import / Export</Button>
</div>
</Card>
</div>
{/* Pipeline preview — small explainer of how modules chain */}
<Card title="Pipeline at a glance">
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 8 }}>
{[
{ kind: "wildcard", note: "Pick a weighted option per resolve." },
{ kind: "fixed", note: "Bind named string variables." },
{ kind: "combine", note: "Merge upstream values via template." },
{ kind: "constraint", note: "Bias matrix between wildcards." },
{ kind: "derivation", note: "Mutate output post-resolve." },
].map((s, i) => {
const m = KIND_META[s.kind];
return (
<div key={s.kind} style={{
background: "var(--wp-bg-2)",
border: "1px solid var(--wp-border)",
borderRadius: 10, padding: 12,
display: "flex", flexDirection: "column", gap: 6,
position: "relative",
}}>
<div style={{
position: "absolute", top: 10, right: 10,
fontFamily: "var(--wp-font-mono)", fontSize: 10, color: "var(--wp-text-dim)",
}}>{String(i + 1).padStart(2, "0")}</div>
<div style={{
width: 28, height: 28, borderRadius: 8, display: "grid", placeItems: "center",
background: `color-mix(in oklab, ${m.color} 16%, transparent)`,
color: m.color, fontSize: 13,
}}>
<Icon name={m.icon} />
</div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{m.label}</div>
<div className="wp-dim" style={{ fontSize: 11.5, lineHeight: 1.45 }}>{s.note}</div>
</div>
);
})}
</div>
</Card>
</div>
);
}
Object.assign(window, { Dashboard });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,768 @@
/* global React, WP_DATA, KIND_META, relTime, classNames,
Icon, Button, Input, Select, Checkbox, Chip, Star, Card,
buildWildcardGraph, getWildcardSyntax, RichTextPreview */
const { useState, useMemo } = React;
// ---------- Generic ModuleListView ----------
// Used by Wildcards, Fixed Values, Combines, Derivations, Constraints.
// Each kind passes its own column renderers + expand renderer.
function ModuleListView({
kind,
title,
subtitle,
items,
setItems,
onEdit,
onCreate,
columns,
renderExpand,
newLabel = "New",
extraFilters = [],
}) {
const [search, setSearch] = useState("");
const [filterCat, setFilterCat] = useState(null);
const [filterTags, setFilterTags] = useState([]); // string[] — AND-mode
const [favOnly, setFavOnly] = useState(false);
const [activeExtras, setActiveExtras] = useState([]); // keys of extraFilters that are on
const [sort, setSort] = useState("updated_desc");
const [expanded, setExpanded] = useState(() => new Set([items[0]?.id].filter(Boolean)));
const [selected, setSelected] = useState(() => new Set());
const [filtersOpen, setFiltersOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(15);
const meta = KIND_META[kind];
// All known tags across the list, with counts.
const allTags = useMemo(() => {
const counts = new Map();
items.forEach((x) => (x.tags || []).forEach((t) => counts.set(t, (counts.get(t) || 0) + 1)));
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([t, n]) => ({ tag: t, count: n }));
}, [items]);
const filtered = useMemo(() => {
let out = items;
if (search.trim()) {
const q = search.toLowerCase();
out = out.filter((x) => x.name.toLowerCase().includes(q) || (x.id || "").toLowerCase().includes(q) || (x.tags || []).some((t) => t.toLowerCase().includes(q)));
}
if (filterCat) out = out.filter((x) => x.category === filterCat);
if (favOnly) out = out.filter((x) => x.favorite);
if (filterTags.length > 0) out = out.filter((x) => filterTags.every((t) => (x.tags || []).includes(t)));
if (activeExtras.length > 0) {
const tests = activeExtras
.map((k) => extraFilters.find((f) => f.key === k))
.filter(Boolean);
out = out.filter((x) => tests.every((f) => f.test(x)));
}
out = [...out].sort((a, b) => {
switch (sort) {
case "updated_asc": return a.updatedAt - b.updatedAt;
case "name_asc": return a.name.localeCompare(b.name);
case "name_desc": return b.name.localeCompare(a.name);
case "updated_desc":
default: return b.updatedAt - a.updatedAt;
}
});
return out;
}, [items, search, filterCat, favOnly, filterTags, activeExtras, extraFilters, sort]);
// Reset to page 1 whenever filters change.
React.useEffect(() => { setPage(1); }, [search, filterCat, favOnly, filterTags, activeExtras, sort, pageSize]);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, totalPages);
const pageStart = (safePage - 1) * pageSize;
const paged = filtered.slice(pageStart, pageStart + pageSize);
const activeFilterCount = (filterCat ? 1 : 0) + (favOnly ? 1 : 0) + filterTags.length + activeExtras.length;
const clearFilters = () => { setFilterCat(null); setFavOnly(false); setFilterTags([]); setActiveExtras([]); };
const toggleExpand = (id) => setExpanded((s) => {
const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n;
});
const toggleSel = (id) => setSelected((s) => {
const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n;
});
const allSel = paged.length > 0 && paged.every((x) => selected.has(x.id));
const someSel = !allSel && paged.some((x) => selected.has(x.id));
const toggleAll = () => setSelected(allSel ? new Set() : new Set(paged.map((x) => x.id)));
const setFav = (id, on) => setItems(items.map((x) => x.id === id ? { ...x, favorite: on } : x));
const remove = (ids) => {
setItems(items.filter((x) => !ids.includes(x.id)));
setSelected(new Set());
};
const duplicate = (id) => {
const src = items.find((x) => x.id === id);
if (!src) return;
const clone = { ...src, id: meta.id_prefix + Math.random().toString(36).slice(2, 8), name: src.name + " (copy)", updatedAt: Date.now(), favorite: false };
setItems([clone, ...items]);
};
return (
<div className="wp-page">
<PageHeader
title={title}
subtitle={subtitle}
actions={<Button variant="primary" icon="pi-plus" onClick={onCreate}>{newLabel}</Button>}
/>
{/* Toolbar */}
<div className="wp-toolbar">
<div className="wp-toolbar__search">
<div className="wp-input-group">
<span className="wp-input-group__addon"><Icon name="pi-search" /></span>
<input className="wp-input" placeholder="Search by name, id, tag…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
<Button variant={filtersOpen ? "outline" : "default"} icon="pi-filter" onClick={() => setFiltersOpen((o) => !o)}>
Filters {activeFilterCount > 0 ? <span className="wp-chip" style={{ padding: "0 6px", marginLeft: 2 }}>{activeFilterCount}</span> : null}
</Button>
<div style={{ width: 160 }}>
<Select
value={sort}
onChange={(v) => setSort(v)}
options={[
{ value: "updated_desc", label: "Updated — newest" },
{ value: "updated_asc", label: "Updated — oldest" },
{ value: "name_asc", label: "Name A → Z" },
{ value: "name_desc", label: "Name Z → A" },
]}
/>
</div>
<span className="wp-toolbar__count">{filtered.length} / {items.length} items</span>
</div>
{/* Filters panel */}
{filtersOpen && (
<div className="wp-card" style={{ padding: 12 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<Field label="Category">
<Select
value={filterCat}
onChange={setFilterCat}
allowClear
placeholder="Any category"
options={WP_DATA.categories.map((c) => ({ value: c.id, label: c.name, dot: c.color }))}
/>
</Field>
<Field label="Favorites">
<button type="button" className="wp-input" style={{ display: "flex", alignItems: "center", gap: 8, justifyContent: "flex-start", cursor: "pointer" }} onClick={() => setFavOnly((f) => !f)}>
<Checkbox checked={favOnly} onChange={setFavOnly} />
<span>Favorites only</span>
</button>
</Field>
<Field label={`Tags${filterTags.length ? ` (${filterTags.length})` : ""}`} hint="Click to toggle. Items must match all selected tags." style={{ gridColumn: "1 / -1" }}>
{allTags.length === 0 ? (
<span className="wp-dim" style={{ fontSize: 12 }}>No tags in this collection.</span>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{allTags.map(({ tag, count }) => {
const active = filterTags.includes(tag);
return (
<button
key={tag}
type="button"
className="wp-chip"
data-active={active ? "" : null}
onClick={() => setFilterTags((ts) => active ? ts.filter((t) => t !== tag) : [...ts, tag])}
style={{
cursor: "pointer",
background: active ? "color-mix(in oklab, var(--wp-accent-500) 22%, transparent)" : undefined,
borderColor: active ? "color-mix(in oklab, var(--wp-accent-500) 45%, transparent)" : undefined,
color: active ? "var(--wp-accent-text)" : undefined,
}}
>
{tag}
<span className="wp-dim" style={{ marginLeft: 4, fontSize: 10.5 }}>{count}</span>
</button>
);
})}
</div>
)}
</Field>
{extraFilters.length > 0 && (
<Field label="Syntax" hint="Filter by reference structure across the library." style={{ gridColumn: "1 / -1" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{extraFilters.map((f) => {
const active = activeExtras.includes(f.key);
const count = items.filter((x) => f.test(x)).length;
return (
<button
key={f.key}
type="button"
className="wp-chip"
data-active={active ? "" : null}
onClick={() => setActiveExtras((xs) => active ? xs.filter((k) => k !== f.key) : [...xs, f.key])}
style={{
cursor: "pointer",
background: active ? "color-mix(in oklab, var(--wp-accent-500) 22%, transparent)" : undefined,
borderColor: active ? "color-mix(in oklab, var(--wp-accent-500) 45%, transparent)" : undefined,
color: active ? "var(--wp-accent-text)" : undefined,
}}
>
{f.label}
<span className="wp-dim" style={{ marginLeft: 4, fontSize: 10.5 }}>{count}</span>
</button>
);
})}
</div>
</Field>
)}
</div>
{activeFilterCount > 0 && (
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 10 }}>
<Button size="sm" variant="ghost" icon="pi-times" onClick={clearFilters}>Clear filters</Button>
</div>
)}
</div>
)}
{/* Selection bar */}
{selected.size > 0 && (
<div className="wp-card" style={{ padding: "8px 12px", display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 12.5 }}>{selected.size} selected</span>
<div className="wp-spacer" />
<Button size="sm" variant="ghost" icon="pi-tag">Set category</Button>
<Button size="sm" variant="ghost" icon="pi-clone">Duplicate</Button>
<Button size="sm" variant="danger" icon="pi-trash" onClick={() => remove([...selected])}>Delete</Button>
</div>
)}
{/* Table */}
<div className="wp-table-wrap">
<table className="wp-table">
<thead>
<tr>
<th style={{ width: 32, paddingRight: 0 }}>
<Checkbox checked={allSel} indeterminate={someSel} onChange={toggleAll} />
</th>
<th style={{ width: 28, padding: 0 }} />
<th style={{ width: 28, padding: 0 }} />
<th className="wp-sortable">Name</th>
{columns.map((c) => (
<th key={c.key} style={{ width: c.width }}>{c.label}</th>
))}
<th>Tags</th>
<th className="wp-sortable" style={{ width: 90 }}>Updated</th>
<th style={{ width: 110, textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={6 + columns.length}>
<div className="wp-empty">
<div className="wp-empty__icon"><Icon name={meta.icon} /></div>
<div style={{ color: "var(--wp-text)", fontWeight: 600 }}>No {meta.label.toLowerCase()}s match</div>
<div className="wp-dim">Try clearing filters, or create a new one.</div>
<Button variant="primary" icon="pi-plus" onClick={onCreate} style={{ marginTop: 6 }}>{newLabel}</Button>
</div>
</td></tr>
)}
{paged.map((row) => {
const cat = WP_DATA.categories.find((c) => c.id === row.category);
const isExpanded = expanded.has(row.id);
return (
<React.Fragment key={row.id}>
<tr data-expanded={isExpanded} onClick={() => toggleExpand(row.id)} style={{ cursor: "pointer" }}>
<td onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(row.id)} onChange={() => toggleSel(row.id)} />
</td>
<td onClick={(e) => e.stopPropagation()}>
<button className="wp-btn wp-btn--ghost wp-btn--icon wp-btn--sm" onClick={() => toggleExpand(row.id)}>
<Icon name={isExpanded ? "pi-chevron-down" : "pi-chevron-right"} style={{ fontSize: 11 }} />
</button>
</td>
<td>
<Star on={row.favorite} onChange={(on) => setFav(row.id, on)} />
</td>
<td>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontWeight: 500 }}>{row.name}</span>
<span className="wp-id">{row.id}</span>
</div>
</td>
{columns.map((c) => <td key={c.key}>{c.render ? c.render(row) : row[c.key]}</td>)}
<td>
{(row.tags || []).length === 0 ? <span className="wp-dim">—</span> : (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{(row.tags || []).slice(0, 3).map((t) => <Chip key={t}>{t}</Chip>)}
{(row.tags || []).length > 3 && <Chip>+{row.tags.length - 3}</Chip>}
</div>
)}
</td>
<td className="wp-dim" style={{ fontSize: 11.5 }}>{relTime(row.updatedAt)}</td>
<td onClick={(e) => e.stopPropagation()} style={{ textAlign: "right" }}>
<div style={{ display: "inline-flex", gap: 2, justifyContent: "flex-end" }}>
<Button size="sm" variant="ghost" icon="pi-pencil" onClick={() => onEdit(row.id)} title="Edit" />
<Button size="sm" variant="ghost" icon="pi-clone" onClick={() => duplicate(row.id)} title="Duplicate" />
<Button size="sm" variant="ghost" icon="pi-trash" onClick={() => remove([row.id])} title="Delete" />
</div>
</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={6 + columns.length} style={{ padding: 0 }}>
<div className="wp-row-expand">{renderExpand(row, cat)}</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{filtered.length > 0 && (
<div className="wp-pagination">
<span className="wp-dim" style={{ fontSize: 12 }}>
{pageStart + 1}–{Math.min(pageStart + pageSize, filtered.length)} of {filtered.length}
</span>
<div className="wp-spacer" />
<span className="wp-dim" style={{ fontSize: 11.5 }}>Per page</span>
<div style={{ width: 92 }}>
<Select
value={pageSize}
onChange={(v) => setPageSize(Number(v))}
options={[10, 15, 25, 50].map((n) => ({ value: n, label: String(n) }))}
/>
</div>
<div className="wp-pager">
<Button size="sm" variant="ghost" icon="pi-angle-double-left" disabled={safePage === 1} onClick={() => setPage(1)} title="First" />
<Button size="sm" variant="ghost" icon="pi-angle-left" disabled={safePage === 1} onClick={() => setPage(safePage - 1)} title="Previous" />
<span className="wp-pager__label">Page {safePage} / {totalPages}</span>
<Button size="sm" variant="ghost" icon="pi-angle-right" disabled={safePage === totalPages} onClick={() => setPage(safePage + 1)} title="Next" />
<Button size="sm" variant="ghost" icon="pi-angle-double-right" disabled={safePage === totalPages} onClick={() => setPage(totalPages)} title="Last" />
</div>
</div>
)}
</div>
);
}
// ---------- Wildcards screen ----------
function WildcardsScreen({ onEdit, onCreate, items, setItems }) {
const graph = useMemo(() => buildWildcardGraph(items), [items]);
return (
<ModuleListView
kind="wildcard"
title="Wildcards"
subtitle="Wildcard modules pick one weighted option per resolution. Use $variable in prompts."
items={items}
setItems={setItems}
onEdit={onEdit}
onCreate={onCreate}
newLabel="New Wildcard"
extraFilters={[
{ key: "has_nested", label: "Uses nested refs", test: (r) => (graph.fwd.get(r.id)?.size || 0) > 0 },
{ key: "is_referenced",label: "Referenced by others", test: (r) => (graph.rev.get(r.varBinding)?.size || 0) > 0 },
{ key: "has_inline", label: "Has inline {a|b|c}", test: (r) => getWildcardSyntax(r).hasInline },
]}
columns={[
{ key: "category", label: "Category", width: 130, render: (r) => {
const c = WP_DATA.categories.find((x) => x.id === r.category);
return c ? <Chip color={c.color}>{c.name}</Chip> : <span className="wp-dim">—</span>;
} },
{ key: "items", label: "Items", width: 60, render: (r) => <span className="wp-mono">{r.options.length}</span> },
{ key: "syntax", label: "Syntax", width: 84, render: (r) => {
const sx = getWildcardSyntax(r);
const refsOut = graph.fwd.get(r.id)?.size || 0;
const refsIn = graph.rev.get(r.varBinding)?.size || 0;
if (!sx.hasNested && !sx.hasInline && !refsIn) {
return <span className="wp-dim" style={{ fontSize: 11 }}>—</span>;
}
return (
<div className="wp-syntax-cell" onClick={(e) => e.stopPropagation()}>
{sx.hasNested && (
<span className="wp-syntax-pill wp-syntax-pill--ref"
title={`Nests ${refsOut} other wildcard${refsOut === 1 ? "" : "s"}`}>
<Icon name="pi-arrow-right" /> {refsOut}
</span>
)}
{refsIn > 0 && (
<span className="wp-syntax-pill wp-syntax-pill--in"
title={`Referenced by ${refsIn} other wildcard${refsIn === 1 ? "" : "s"}`}>
<Icon name="pi-arrow-left" /> {refsIn}
</span>
)}
{sx.hasInline && (
<span className="wp-syntax-pill wp-syntax-pill--dp"
title="Contains inline {a|b|c} alternatives">
{"{ }"}
</span>
)}
</div>
);
} },
{ key: "valid", label: "Valid", width: 60, render: (r) => r.valid
? <Icon name="pi-check-circle" style={{ color: "var(--wp-success)" }} />
: <Icon name="pi-exclamation-triangle" style={{ color: "var(--wp-warn)" }} title="Empty option present" />
},
]}
renderExpand={(row) => {
const totalWeight = row.options.reduce((a, b) => a + b.weight, 0);
const refsOut = [...(graph.fwd.get(row.id) || [])];
const refsInIds = [...(graph.rev.get(row.varBinding) || [])];
return (
<div>
<div className="wp-row-expand__title">Top options for <span style={{ color: "var(--wp-accent-text)" }}>{row.name.toUpperCase()}</span></div>
<div style={{ display: "grid", gridTemplateColumns: "30px 1fr 120px", gap: "4px 12px", maxWidth: 760, fontSize: 12.5, alignItems: "center" }}>
{row.options.slice(0, 4).map((opt, i) => (
<React.Fragment key={i}>
<span className="wp-mono wp-dim">{opt.weight}×</span>
<span className="wp-mono" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{opt.value
? <RichTextPreview value={opt.value} />
: <span className="wp-dim">(empty)</span>}
</span>
<div style={{ background: "var(--wp-bg-3)", height: 6, borderRadius: 999, overflow: "hidden" }}>
<div style={{ width: `${(opt.weight / totalWeight) * 100}%`, height: "100%", background: "linear-gradient(90deg, var(--wp-accent-500), var(--wp-accent-400))" }} />
</div>
</React.Fragment>
))}
</div>
{row.options.length > 4 && <div className="wp-dim" style={{ marginTop: 8, fontSize: 11.5 }}>… and {row.options.length - 4} more</div>}
{(refsOut.length > 0 || refsInIds.length > 0) && (
<div className="wp-syntax-graph">
{refsOut.length > 0 && (
<div className="wp-syntax-graph__row">
<Icon name="pi-arrow-right" />
<span className="wp-dim">Nests:</span>
{refsOut.map((v) => {
const t = items.find((w) => w.varBinding === v);
return t
? <button key={v} className="wp-syntax-link" onClick={(e) => { e.stopPropagation(); onEdit(t.id); }}>{t.name}</button>
: <span key={v} className="wp-syntax-link wp-syntax-link--missing" title="No wildcard found for this @ref">@{v}</span>;
})}
</div>
)}
{refsInIds.length > 0 && (
<div className="wp-syntax-graph__row">
<Icon name="pi-arrow-left" />
<span className="wp-dim">Referenced by:</span>
{refsInIds.map((id) => {
const t = items.find((w) => w.id === id);
return t
? <button key={id} className="wp-syntax-link" onClick={(e) => { e.stopPropagation(); onEdit(t.id); }}>{t.name}</button>
: null;
})}
</div>
)}
</div>
)}
</div>
);
}}
/>
);
}
// ---------- Fixed Values screen ----------
function FixedValuesScreen({ onEdit, onCreate, items, setItems }) {
return (
<ModuleListView
kind="fixed"
title="Fixed Values"
subtitle="Fixed-value modules emit one or more named string variables, set explicitly."
items={items}
setItems={setItems}
onEdit={onEdit}
onCreate={onCreate}
newLabel="New Fixed Values"
columns={[
{ key: "category", label: "Category", width: 130, render: (r) => {
const c = WP_DATA.categories.find((x) => x.id === r.category);
return c ? <Chip color={c.color}>{c.name}</Chip> : <span className="wp-dim">—</span>;
} },
{ key: "values", label: "Values", width: 60, render: (r) => <span className="wp-mono">{r.values.length}</span> },
]}
renderExpand={(row) => (
<div>
<div className="wp-row-expand__title">First values for <span style={{ color: "var(--wp-accent-text)" }}>{row.name.toUpperCase()}</span></div>
<div className="wp-snippet" style={{ maxWidth: 540 }}>
{row.values.slice(0, 4).map((v, i) => (
<div key={i}>
<span className="wp-token-var">${v.var}</span>
<span className="wp-token-com"> = </span>
<span className="wp-token-str">"{v.value}"</span>
</div>
))}
</div>
</div>
)}
/>
);
}
// ---------- Combines screen ----------
function CombinesScreen({ onEdit, onCreate, items, setItems }) {
return (
<ModuleListView
kind="combine"
title="Combines"
subtitle="Combine modules merge upstream resolved values via a template, storing into a new variable for downstream."
items={items}
setItems={setItems}
onEdit={onEdit}
onCreate={onCreate}
newLabel="New Combine"
columns={[
{ key: "category", label: "Category", width: 130, render: (r) => {
const c = WP_DATA.categories.find((x) => x.id === r.category);
return c ? <Chip color={c.color}>{c.name}</Chip> : <span className="wp-dim">—</span>;
} },
{ key: "output", label: "Output", width: 140, render: (r) => <span className="wp-mono" style={{ color: "var(--wp-accent-text)" }}>{r.output}</span> },
{ key: "inputs", label: "Inputs", width: 60, render: (r) => <span className="wp-mono">{r.inputs.length}</span> },
]}
renderExpand={(row) => (
<div>
<div className="wp-row-expand__title">Template</div>
<div className="wp-snippet" style={{ maxWidth: 720 }}>
{row.template.split(/(\{[^}]+\}|\$[a-z_][a-z0-9_]*)/i).map((part, i) => {
if (/^\{[^}]+\}$/.test(part)) return <span key={i} className="wp-token-key">{part}</span>;
if (/^\$[a-z_]/i.test(part)) return <span key={i} className="wp-token-var">{part}</span>;
return <span key={i}>{part}</span>;
})}
</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginTop: 8 }}>
<span className="wp-dim" style={{ fontSize: 11.5, marginRight: 4 }}>Reads:</span>
{row.inputs.map((v, i) => <Chip key={i} variant="accent" icon={v.startsWith("@") ? "pi-th-large" : "pi-tag"}>{v}</Chip>)}
</div>
</div>
)}
/>
);
}
// ---------- Derivations screen ----------
function DerivationsScreen({ onEdit, onCreate, items, setItems }) {
return (
<ModuleListView
kind="derivation"
title="Derivations"
subtitle="Derivations mutate the resolved context post-resolution — append, replace, or remove tokens conditionally."
items={items}
setItems={setItems}
onEdit={onEdit}
onCreate={onCreate}
newLabel="New Derivation"
columns={[
{ key: "category", label: "Category", width: 130, render: (r) => {
const c = WP_DATA.categories.find((x) => x.id === r.category);
return c ? <Chip color={c.color}>{c.name}</Chip> : <span className="wp-dim">—</span>;
} },
]}
renderExpand={(row) => {
const verb = (k) => ({ append: "append to", prepend: "prepend to", replace: "replace in", remove: "remove from" }[k] || k);
const rules = row.rules || [];
const fmtCond = (c) => c?.kind === "always"
? <em className="wp-dim">always</em>
: <>@{c?.var || "?"} {c?.kind} {c?.kind !== "absent" && <span className="wp-token-str">"{c?.value}"</span>}</>;
const fmtAct = (a) => <>{verb(a?.kind)} {a?.target} {a?.kind !== "remove" && <span className="wp-token-str">"{a?.value}"</span>}</>;
return (
<div>
<div className="wp-row-expand__title">Rules ({rules.length})</div>
<div className="wp-snippet" style={{ maxWidth: 720, lineHeight: 1.7 }}>
{rules.length === 0 && <span className="wp-dim">No rules defined.</span>}
{rules.map((rule, ri) => (
<div key={ri} style={{ marginBottom: ri === rules.length - 1 ? 0 : 8 }}>
<div className="wp-token-com" style={{ fontSize: 10.5, letterSpacing: ".05em", textTransform: "uppercase" }}># rule {ri + 1}</div>
{(rule.branches || []).map((b, bi) => (
<div key={bi}>
<span className="wp-token-key">{bi === 0 ? "IF" : "ELIF"}</span>{" "}
{fmtCond(b.condition)}
<span className="wp-token-com"> · </span>
<span className="wp-token-key">THEN</span> {fmtAct(b.action)}
</div>
))}
{rule.hasElse && rule.else && (
<div>
<span className="wp-token-key">ELSE</span> {fmtAct(rule.else)}
</div>
)}
</div>
))}
</div>
</div>
);
}}
/>
);
}
// ---------- Constraints screen ----------
function ConstraintsScreen({ onEdit, onCreate, items, setItems }) {
return (
<ModuleListView
kind="constraint"
title="Constraints"
subtitle="Constraints set rules between two wildcards' sub-categories — exclude, boost, or reduce specific combinations, with per-pair exceptions."
items={items}
setItems={setItems}
onEdit={onEdit}
onCreate={onCreate}
newLabel="New Constraint"
columns={[
{ key: "category", label: "Category", width: 130, render: (r) => {
const c = WP_DATA.categories.find((x) => x.id === r.category);
return c ? <Chip color={c.color}>{c.name}</Chip> : <span className="wp-dim">—</span>;
} },
{ key: "pair", label: "Target × Source", width: 220, render: (r) => {
const t = WP_DATA.wildcards.find((x) => x.id === r.target);
const s = WP_DATA.wildcards.find((x) => x.id === r.source);
return (
<div className="wp-hsplit" style={{ gap: 6, fontSize: 12 }}>
<Chip variant="accent" icon="pi-th-large">{t?.name || r.target}</Chip>
<Icon name="pi-times" style={{ fontSize: 9, color: "var(--wp-text-dim)" }} />
<Chip variant="accent" icon="pi-th-large">{s?.name || r.source}</Chip>
</div>
);
} },
]}
renderExpand={(row) => {
const t = WP_DATA.wildcards.find((x) => x.id === row.target);
const s = WP_DATA.wildcards.find((x) => x.id === row.source);
const tSubs = t?.subCategories || [];
const sSubs = s?.subCategories || [];
const modeColor = { allow: null, exclude: "var(--wp-danger)", boost: "var(--wp-success)", reduce: "var(--wp-warn)" };
const modeIcon = { allow: "·", exclude: "×", boost: "↑", reduce: "↓" };
return (
<div>
<div className="wp-row-expand__title">Rule matrix</div>
<table style={{ borderCollapse: "collapse", fontSize: 11.5 }}>
<thead>
<tr>
<th style={{ padding: "4px 8px", color: "var(--wp-text-dim)", fontWeight: 500, fontSize: 10, textTransform: "uppercase", letterSpacing: ".05em" }}>{t?.name} ↓ &nbsp; {s?.name} →</th>
{sSubs.map((sub) => <th key={sub} style={{ padding: "4px 8px", fontWeight: 500, color: "var(--wp-text-muted)" }}>{sub}</th>)}
</tr>
</thead>
<tbody>
{tSubs.map((tSub, ti) => (
<tr key={tSub}>
<td style={{ padding: "4px 8px", color: "var(--wp-text-muted)" }}>{tSub}</td>
{sSubs.map((sSub, si) => {
const raw = row.matrix[ti]?.[si];
const cell = raw == null ? { mode: "allow", factor: 1 }
: typeof raw === "string" ? { mode: raw, factor: raw === "boost" ? 2 : raw === "reduce" ? 0.5 : 1 }
: { mode: raw.mode || "allow", factor: typeof raw.factor === "number" ? raw.factor : (raw.mode === "boost" ? 2 : raw.mode === "reduce" ? 0.5 : 1) };
const c = modeColor[cell.mode];
const tunable = cell.mode === "boost" || cell.mode === "reduce";
const fmt = (f) => f >= 10 ? f.toFixed(0) : f.toFixed(2).replace(/\.?0+$/, "");
return (
<td key={sSub} style={{ padding: 3 }}>
<span title={tunable ? `${cell.mode} ×${fmt(cell.factor)}` : cell.mode} style={{
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 3,
minWidth: 26, height: 22, borderRadius: 5, padding: "0 5px",
background: c ? `color-mix(in oklab, ${c} 16%, transparent)` : "var(--wp-bg-3)",
color: c || "var(--wp-text-dim)",
border: `1px solid ${c ? `color-mix(in oklab, ${c} 35%, transparent)` : "var(--wp-border)"}`,
fontSize: 11, fontWeight: 600,
}}>
{modeIcon[cell.mode]}
{tunable && <span style={{ fontFamily: "var(--wp-font-mono)", fontSize: 9.5, opacity: 0.85 }}>×{fmt(cell.factor)}</span>}
</span>
</td>
);
})}
</tr>
))}
</tbody>
</table>
{row.exceptions?.length > 0 && (
<div style={{ marginTop: 10 }}>
<div className="wp-dim" style={{ fontSize: 11 }}>{row.exceptions.length} per-pair exception{row.exceptions.length === 1 ? "" : "s"}</div>
</div>
)}
</div>
);
}}
/>
);
}
Object.assign(window, {
WildcardsScreen, FixedValuesScreen, CombinesScreen, DerivationsScreen, ConstraintsScreen, PipelinesScreen,
});
function PipelinesScreen({ onEdit, onCreate, items, setItems }) {
return (
<ModuleListView
kind="pipeline"
title="Pipelines"
subtitle="Pipelines are ordered presets of modules. They run top-to-bottom — each step appends to the resolved context the next step sees."
items={items}
setItems={setItems}
onEdit={onEdit}
onCreate={onCreate}
newLabel="New Pipeline"
columns={[
{ key: "category", label: "Category", width: 130, render: (r) => {
const c = WP_DATA.categories.find((x) => x.id === r.category);
return c ? <Chip color={c.color}>{c.name}</Chip> : <span className="wp-dim">—</span>;
} },
{ key: "steps", label: "Steps", width: 60, render: (r) => <span className="wp-mono">{r.steps.length}</span> },
{ key: "mix", label: "Mix", width: 132, render: (r) => {
const counts = {};
r.steps.forEach((s) => { counts[s.kind] = (counts[s.kind] || 0) + 1; });
const order = ["wildcard", "fixed", "combine", "constraint", "derivation"];
return (
<div style={{ display: "flex", gap: 4, alignItems: "center" }}>
{order.filter((k) => counts[k]).map((k) => {
const m = KIND_META[k];
return (
<span key={k} title={`${counts[k]} × ${m.label}`} style={{
display: "inline-flex", alignItems: "center", gap: 3,
padding: "2px 6px", borderRadius: 5, fontSize: 11,
background: `color-mix(in oklab, ${m.color} 14%, transparent)`,
color: m.color,
fontFamily: "var(--wp-font-mono)",
}}>
<Icon name={m.icon} style={{ fontSize: 9 }} />
{counts[k]}
</span>
);
})}
</div>
);
} },
]}
renderExpand={(row) => {
const stepEl = (s, i) => {
const m = KIND_META[s.kind];
const list = { wildcard: WP_DATA.wildcards, fixed: WP_DATA.fixedValues, combine: WP_DATA.combines, derivation: WP_DATA.derivations, constraint: WP_DATA.constraints }[s.kind];
const ref = list?.find((x) => x.id === s.refId);
return (
<div key={i} style={{
display: "flex", alignItems: "center", gap: 8,
padding: "5px 10px", borderRadius: 6,
background: "var(--wp-bg-2)", border: "1px solid var(--wp-border)",
fontSize: 12, minWidth: 0,
}}>
<span style={{ fontFamily: "var(--wp-font-mono)", fontSize: 10, color: "var(--wp-text-dim)", width: 16 }}>{String(i + 1).padStart(2, "0")}</span>
<span style={{
width: 18, height: 18, borderRadius: 4, display: "grid", placeItems: "center",
background: `color-mix(in oklab, ${m.color} 18%, transparent)`,
color: m.color, fontSize: 10, flexShrink: 0,
}}>
<Icon name={m.icon} />
</span>
<span className="wp-dim" style={{ fontSize: 10.5, textTransform: "uppercase", letterSpacing: ".05em", flexShrink: 0 }}>{m.label}</span>
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ref?.name || s.refId}</span>
</div>
);
};
return (
<div>
<div className="wp-row-expand__title">{row.steps.length} step{row.steps.length === 1 ? "" : "s"} — top to bottom</div>
<div style={{ display: "grid", gap: 4, maxWidth: 520 }}>
{row.steps.slice(0, 6).map(stepEl)}
</div>
{row.steps.length > 6 && <div className="wp-dim" style={{ marginTop: 6, fontSize: 11.5 }}>… and {row.steps.length - 6} more</div>}
</div>
);
}}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
/* global React, Icon, classNames */
const { useState, useEffect, useRef } = React;
// ---------- Theme hook ----------
// Adds `wp-theme-switching` to <html> for ~140ms around theme changes so we can
// suppress transitions on every element and avoid the cross-fade flash.
const THEME_KEY = "wp_theme";
function useTheme() {
const [theme, setTheme] = useState(() => {
try { return localStorage.getItem(THEME_KEY) || "auto"; } catch { return "auto"; }
});
const isFirstRun = useRef(true);
useEffect(() => {
const root = document.documentElement;
const apply = () => {
const resolved = theme === "auto"
? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: theme;
root.classList.toggle("wp-theme-light", resolved === "light");
root.classList.toggle("wp-theme-dark", resolved === "dark");
};
if (!isFirstRun.current) {
root.classList.add("wp-theme-switching");
apply();
// double rAF so the switch lands in one paint, then re-enable transitions
requestAnimationFrame(() => requestAnimationFrame(() => {
setTimeout(() => root.classList.remove("wp-theme-switching"), 30);
}));
} else {
apply();
isFirstRun.current = false;
}
try { localStorage.setItem(THEME_KEY, theme); } catch {}
if (theme === "auto") {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}
}, [theme]);
return [theme, setTheme];
}
// ---------- Topbar ----------
function Topbar({ onToggleSidebar, onOpenSettings, theme, onCycleTheme, extras }) {
const themeIcon = theme === "light" ? "pi-sun" : theme === "dark" ? "pi-moon" : "pi-desktop";
const themeLabel = `Theme: ${theme}`;
return (
<header className="wp-topbar">
<button className="wp-topbar__icon-btn" onClick={onToggleSidebar} aria-label="Toggle sidebar">
<Icon name="pi-bars" />
</button>
<div className="wp-topbar__brand">
<img src="assets/logo-color.png" alt="" />
<span>Wildcard Pipeline</span>
<span className="wp-topbar__version">v1.4.2-dev</span>
</div>
<div className="wp-topbar__spacer" />
{extras}
<button className="wp-topbar__icon-btn" onClick={onCycleTheme} title={themeLabel} aria-label={themeLabel}>
<Icon name={themeIcon} />
</button>
<button className="wp-topbar__icon-btn" title="Documentation">
<Icon name="pi-question-circle" />
</button>
<button className="wp-topbar__icon-btn" onClick={onOpenSettings} title="Settings">
<Icon name="pi-cog" />
</button>
</header>
);
}
// ---------- Sidebar ----------
const NAV_SECTIONS = [
{
label: "Home",
items: [
{ id: "dashboard", label: "Dashboard", icon: "pi-home" },
],
},
{
label: "Modules",
items: [
{ id: "pipelines", label: "Pipelines", icon: "pi-list" },
{ id: "wildcards", label: "Wildcards", icon: "pi-th-large" },
{ id: "fixed", label: "Fixed Values", icon: "pi-tag" },
{ id: "combines", label: "Combines", icon: "pi-share-alt" },
{ id: "derivations", label: "Derivations", icon: "pi-code" },
{ id: "constraints", label: "Constraints", icon: "pi-sitemap" },
],
},
{
label: "Library",
items: [
{ id: "categories", label: "Categories", icon: "pi-bookmark" },
{ id: "io", label: "Import / Export", icon: "pi-arrow-right-arrow-left" },
{ id: "test", label: "Test Runner", icon: "pi-bolt" },
],
},
{
label: "Get Started",
items: [
{ id: "community", label: "Community", icon: "pi-globe" },
{ id: "_docs", label: "Documentation", icon: "pi-book", external: true },
{ id: "_source", label: "View Source", icon: "pi-github", external: true },
],
},
];
function Sidebar({ active, onNavigate, collapsed }) {
return (
<nav className="wp-sidebar">
{NAV_SECTIONS.map((section) => (
<React.Fragment key={section.label}>
{!collapsed && <div className="wp-sidebar__section">{section.label}</div>}
{collapsed && <div style={{ height: 8 }} />}
{section.items.map((item) => (
<button
key={item.id}
type="button"
className="wp-nav"
data-active={active === item.id}
onClick={() => !item.external && onNavigate(item.id)}
title={collapsed ? item.label : undefined}
style={collapsed ? { justifyContent: "center" } : undefined}
>
<span className="wp-nav__icon"><Icon name={item.icon} /></span>
{!collapsed && <span className="wp-nav__label">{item.label}</span>}
{!collapsed && item.external && <Icon name="pi-external-link" className="wp-nav__ext" style={{ fontSize: 10 }} />}
</button>
))}
</React.Fragment>
))}
</nav>
);
}
// ---------- Page header ----------
function PageHeader({ title, subtitle, actions, breadcrumb }) {
return (
<div className="wp-page__header">
<div className="wp-page__title-wrap">
{breadcrumb}
<h1 className="wp-page__title">{title}</h1>
{subtitle && <p className="wp-page__subtitle">{subtitle}</p>}
</div>
{actions && <div className="wp-hsplit" style={{ gap: 8 }}>{actions}</div>}
</div>
);
}
Object.assign(window, { Topbar, Sidebar, PageHeader, useTheme });
@@ -0,0 +1,454 @@
/* =========================================================
Community styles — appended after the rest of styles.css
========================================================= */
/* ---------- Featured strip ---------- */
.wp-comm-featured { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
.wp-comm-featured__head { display: flex; align-items: baseline; justify-content: space-between; }
.wp-comm-featured__label {
margin: 0; font-size: 12px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--wp-text-muted);
display: inline-flex; align-items: center; gap: 6px;
}
.wp-comm-featured__label .pi { color: var(--wp-accent-text); }
.wp-comm-featured__hint { font-size: 11px; color: var(--wp-text-dim); }
.wp-comm-featured__row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px;
}
.wp-comm-feat {
position: relative; min-height: 160px;
border-radius: var(--wp-radius-lg);
border: 1px solid var(--wp-border);
padding: 0; overflow: hidden;
cursor: pointer; text-align: left;
color: #fff;
transition: transform .15s ease, box-shadow .15s ease;
}
.wp-comm-feat::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0) 30%, rgba(0,0,0,0.65) 100%);
pointer-events: none;
}
.wp-comm-feat:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.35); }
.wp-comm-feat__inner {
position: relative; z-index: 1;
height: 100%; padding: 14px;
display: flex; flex-direction: column; justify-content: space-between;
gap: 10px;
}
.wp-comm-feat h3 { margin: 0; font-size: 16px; font-weight: 700; }
.wp-comm-feat p { margin: 0; font-size: 12px; color: rgba(255,255,255,0.84); }
.wp-comm-feat__stats {
display: flex; gap: 10px; font-size: 11px; color: rgba(255,255,255,0.92);
}
.wp-comm-feat__stats .pi { margin-right: 3px; }
/* ---------- Filter bar + tag rail ---------- */
.wp-comm-filters {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
padding: 10px; margin-bottom: 8px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
}
.wp-comm-toggle {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--wp-text-muted); cursor: pointer;
padding: 0 6px;
}
.wp-comm-toggle--nsfw { color: #fcd34d; }
.wp-comm-tagrail {
display: flex; gap: 6px; flex-wrap: wrap;
margin-bottom: 12px;
}
/* ---------- Discover grid + cards ---------- */
.wp-comm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.wp-comm-card {
display: flex; flex-direction: column;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
overflow: hidden;
cursor: pointer;
transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease;
text-align: left;
}
.wp-comm-card:hover {
transform: translateY(-1px);
border-color: var(--wp-border-strong);
box-shadow: 0 6px 18px rgba(0,0,0,0.28);
}
.wp-comm-card__hero {
position: relative;
height: 100px;
display: flex; align-items: flex-start; justify-content: space-between;
padding: 8px;
}
.wp-comm-card__kind {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 8px; border-radius: 999px;
background: rgba(0,0,0,0.42);
border: 1px solid rgba(255,255,255,0.16);
color: #fff; font-size: 10px; font-weight: 600;
}
.wp-comm-card__nsfw, .wp-comm-card__incompat {
padding: 3px 8px; border-radius: 999px;
background: rgba(0,0,0,0.42);
border: 1px solid rgba(255,255,255,0.16);
font-size: 10px; font-weight: 600;
color: #fff;
}
.wp-comm-card__nsfw { background: rgba(190, 18, 60, 0.85); border-color: rgba(255,255,255,0.2); }
.wp-comm-card__incompat { background: rgba(245, 158, 11, 0.85); border-color: rgba(255,255,255,0.2); margin-left: 4px; }
.wp-comm-card__body { padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 6px; }
.wp-comm-card__title-row { display: flex; align-items: flex-start; gap: 6px; }
.wp-comm-card__title { margin: 0; flex: 1; font-size: 14px; font-weight: 700; line-height: 1.25; }
.wp-comm-card__star {
background: none; border: none; padding: 2px 4px; cursor: pointer;
color: var(--wp-text-dim); font-size: 14px;
}
.wp-comm-card__star[data-on="true"] { color: #fbbf24; }
.wp-comm-card__star:hover { color: #fbbf24; }
.wp-comm-card__tagline { margin: 0; font-size: 12px; color: var(--wp-text-muted); line-height: 1.4;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.wp-comm-card__author { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.wp-comm-card__stats {
display: flex; gap: 10px; font-size: 11px; color: var(--wp-text-muted);
align-items: center; padding-top: 4px;
border-top: 1px solid var(--wp-border);
}
.wp-comm-card__stats span { display: inline-flex; align-items: center; gap: 3px; }
.wp-comm-card__tags { display: flex; flex-wrap: wrap; gap: 3px; }
.wp-comm-card__installed {
display: inline-flex; align-items: center; gap: 4px; align-self: flex-start;
font-size: 11px; color: #86efac; font-weight: 600;
padding: 2px 8px; border-radius: 999px;
background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.32);
}
/* Skeleton */
.wp-skel {
background: linear-gradient(90deg, var(--wp-bg-2) 0%, var(--wp-bg-3) 50%, var(--wp-bg-2) 100%);
background-size: 200% 100%;
border-radius: 6px;
animation: wp-skel-shimmer 1.4s linear infinite;
}
@keyframes wp-skel-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.wp-comm-card--skeleton { pointer-events: none; cursor: default; }
.wp-comm-card--skeleton:hover { transform: none; box-shadow: none; }
/* ---------- Detail page ---------- */
.wp-comm-detail-hero {
position: relative;
border-radius: var(--wp-radius-lg);
border: 1px solid var(--wp-border);
overflow: hidden;
color: #fff;
margin-bottom: 12px;
}
.wp-comm-detail-hero::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0.55) 100%);
pointer-events: none;
}
.wp-comm-detail-hero__inner {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; justify-content: space-between;
padding: 24px;
min-height: 200px;
}
.wp-comm-detail-hero__title { margin: 0; font-size: 28px; font-weight: 800; line-height: 1.1; }
.wp-comm-detail-hero__tagline { margin: 6px 0 12px 0; font-size: 14px; color: rgba(255,255,255,0.86); max-width: 640px; }
.wp-comm-detail-hero__meta {
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
font-size: 12px; color: rgba(255,255,255,0.86);
}
.wp-comm-detail-hero__meta .pi { margin-right: 4px; }
.wp-comm-detail-hero__author {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px;
background: rgba(0,0,0,0.32);
border: 1px solid rgba(255,255,255,0.18);
color: #fff; cursor: pointer;
font-size: 12px;
}
.wp-comm-detail-hero__author:hover { background: rgba(0,0,0,0.5); }
.wp-comm-detail-hero__actions {
display: flex; flex-wrap: wrap; gap: 8px;
}
.wp-comm-detail-hero__actions .wp-btn {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.2);
color: #fff;
}
.wp-comm-detail-hero__actions .wp-btn:hover { background: rgba(255,255,255,0.2); }
.wp-comm-detail-hero__actions .wp-btn--primary {
background: rgba(255,255,255,0.94); color: var(--wp-accent-700, #5b21b6);
border-color: transparent;
}
.wp-comm-detail-hero__actions .wp-btn--primary:hover { background: #fff; }
.wp-comm-detail-stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1px;
background: var(--wp-border);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
overflow: hidden; margin-bottom: 12px;
}
.wp-comm-detail-stats > div {
background: var(--wp-bg-1);
padding: 10px 14px;
display: flex; flex-direction: column; gap: 2px;
}
.wp-comm-detail-stats span { font-size: 11px; color: var(--wp-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.wp-comm-detail-stats strong { font-size: 16px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; }
.wp-comm-detail-stats__version select { width: 100%; }
/* Tabs */
.wp-comm-tabs {
display: flex; gap: 0;
border-bottom: 1px solid var(--wp-border);
margin-bottom: 12px;
overflow-x: auto;
}
.wp-comm-tab {
background: none; border: none; padding: 10px 14px;
color: var(--wp-text-muted); font-size: 13px; font-weight: 600; cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
}
.wp-comm-tab:hover { color: var(--wp-text); }
.wp-comm-tab[data-active="true"] {
color: var(--wp-text);
border-bottom-color: var(--wp-accent-500);
}
/* README */
.wp-comm-readme h2 { font-size: 20px; margin: 16px 0 8px 0; }
.wp-comm-readme h3 { font-size: 15px; margin: 14px 0 6px 0; color: var(--wp-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.wp-comm-readme p { margin: 0 0 8px 0; font-size: 13px; line-height: 1.6; }
.wp-comm-readme ul { margin: 0 0 8px 0; padding-left: 20px; }
.wp-comm-readme li { font-size: 13px; line-height: 1.6; }
.wp-comm-readme code {
background: var(--wp-bg-2); border: 1px solid var(--wp-border);
padding: 1px 5px; border-radius: 4px; font-size: 12px; font-family: var(--wp-mono, "JetBrains Mono", monospace);
}
/* Preview tab */
.wp-comm-preview .wp-pack-item {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px;
background: var(--wp-bg-2);
border: 1px solid var(--wp-border);
border-radius: 6px;
font-size: 12px;
}
.wp-comm-sample {
font-family: var(--wp-mono, "JetBrains Mono", monospace);
font-size: 12px; padding: 6px 0;
border-bottom: 1px solid var(--wp-border);
color: var(--wp-text);
}
.wp-comm-sample:last-child { border-bottom: none; }
.wp-comm-histo-row {
display: grid; grid-template-columns: minmax(140px, 240px) 1fr 40px;
gap: 10px; align-items: center;
padding: 4px 0;
}
.wp-comm-histo-row__val {
font-family: var(--wp-mono, "JetBrains Mono", monospace);
font-size: 12px; color: var(--wp-text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wp-comm-histo-row__bar {
height: 8px; background: var(--wp-bg-2); border-radius: 4px;
border: 1px solid var(--wp-border);
overflow: hidden;
}
.wp-comm-histo-row__bar > div {
height: 100%;
background: linear-gradient(90deg, var(--wp-accent-600), var(--wp-accent-400));
}
.wp-comm-histo-row__pct { font-size: 11px; color: var(--wp-text-muted); text-align: right; }
/* Versions */
.wp-comm-versions { display: flex; flex-direction: column; gap: 8px; }
.wp-comm-version {
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
background: var(--wp-bg-1);
overflow: hidden;
}
.wp-comm-version[data-active="true"] { border-color: var(--wp-accent-500); }
.wp-comm-version__head {
width: 100%; background: none; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px;
font-size: 14px; font-weight: 600; color: var(--wp-text);
}
.wp-comm-version__num { display: inline-flex; align-items: center; }
.wp-comm-version__date { font-size: 12px; color: var(--wp-text-muted); font-weight: 400; }
.wp-comm-version__notes {
margin: 0; padding: 0 14px 12px 14px;
font-size: 13px; color: var(--wp-text-muted); line-height: 1.5;
}
/* Reviews */
.wp-comm-reviews { display: flex; flex-direction: column; gap: 12px; }
.wp-comm-reviews__summary {
display: grid; grid-template-columns: 160px 1fr; gap: 16px;
padding: 16px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
}
.wp-comm-reviews__big { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.wp-comm-reviews__big-num { font-size: 40px; font-weight: 800; line-height: 1; }
.wp-comm-reviews__bars { display: flex; flex-direction: column; gap: 4px; justify-content: center; }
.wp-comm-reviews__bar { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--wp-text-muted); }
.wp-comm-reviews__bar-track {
flex: 1; height: 8px; background: var(--wp-bg-2);
border: 1px solid var(--wp-border); border-radius: 4px; overflow: hidden;
}
.wp-comm-reviews__bar-track > div { height: 100%; background: #fbbf24; }
.wp-comm-reviews__form textarea { resize: vertical; min-height: 60px; }
.wp-comm-reviews__list { display: flex; flex-direction: column; gap: 8px; }
.wp-comm-review {
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
padding: 12px;
}
.wp-comm-review__head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.wp-comm-review__text { margin: 0; font-size: 13px; line-height: 1.5; }
/* Stepper */
.wp-comm-stepper {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
padding: 12px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
}
.wp-comm-step {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px; border-radius: 999px;
background: var(--wp-bg-2);
font-size: 12px; font-weight: 600; color: var(--wp-text-muted);
}
.wp-comm-step__num {
width: 20px; height: 20px; border-radius: 999px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--wp-bg-3); color: var(--wp-text-muted);
font-size: 11px;
}
.wp-comm-step[data-active="true"] { background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent); color: var(--wp-accent-text-strong); }
.wp-comm-step[data-active="true"] .wp-comm-step__num { background: var(--wp-accent-500); color: #fff; }
.wp-comm-step[data-done="true"] .wp-comm-step__num { background: var(--wp-success); color: #fff; }
/* Atom picker */
.wp-comm-atom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.wp-comm-atom {
text-align: left; padding: 16px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-2);
border-radius: var(--wp-radius-lg);
cursor: pointer; transition: border-color .12s, background .12s;
display: flex; flex-direction: column; gap: 6px;
}
.wp-comm-atom .pi { font-size: 22px; color: var(--wp-accent-text); }
.wp-comm-atom strong { font-size: 14px; }
.wp-comm-atom span { font-size: 12px; color: var(--wp-text-muted); }
.wp-comm-atom:hover { border-color: var(--wp-border-strong); }
.wp-comm-atom[data-active="true"] {
border-color: var(--wp-accent-500);
background: color-mix(in oklab, var(--wp-accent-500) 8%, var(--wp-bg-2));
}
/* Form */
.wp-comm-form { display: flex; flex-direction: column; gap: 10px; }
.wp-comm-form__row { display: flex; flex-direction: column; gap: 4px; }
.wp-comm-form__row--split {
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px;
}
.wp-comm-review-step { display: flex; flex-direction: column; gap: 12px; }
.wp-comm-review-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px;
background: var(--wp-border);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
overflow: hidden;
}
.wp-comm-review-grid > div {
background: var(--wp-bg-2);
padding: 8px 12px;
display: flex; flex-direction: column; gap: 2px;
}
.wp-comm-review-grid span { font-size: 11px; color: var(--wp-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
.wp-comm-review-grid strong { font-size: 13px; }
/* Profile */
.wp-comm-profile-hero {
display: flex; align-items: center; gap: 16px;
padding: 16px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-1);
border-radius: var(--wp-radius-lg);
flex-wrap: wrap;
}
.wp-comm-profile-avatar {
width: 64px; height: 64px; border-radius: 999px;
background: linear-gradient(135deg, var(--wp-accent-500), var(--wp-accent-700));
display: flex; align-items: center; justify-content: center;
font-size: 32px;
}
.wp-comm-profile-stats {
display: flex; gap: 16px; margin-top: 8px; flex-wrap: wrap;
}
.wp-comm-profile-stats > div { display: flex; flex-direction: column; }
.wp-comm-profile-stats strong { font-size: 18px; font-weight: 700; }
.wp-comm-profile-stats span { font-size: 11px; color: var(--wp-text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
/* Modal */
.wp-modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 24px;
}
.wp-modal {
background: var(--wp-bg-1);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius-lg);
width: 100%; max-width: 520px;
max-height: 90vh; display: flex; flex-direction: column;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.wp-modal__head {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--wp-border);
}
.wp-modal__head h3 { margin: 0; font-size: 16px; }
.wp-modal__body { padding: 16px; overflow-y: auto; }
.wp-modal__foot {
display: flex; gap: 8px; justify-content: flex-end;
padding: 12px 16px; border-top: 1px solid var(--wp-border);
}
.wp-radio-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; cursor: pointer; }
/* Light theme tweaks */
:root.wp-theme-light .wp-comm-card { background: #fff; }
:root.wp-theme-light .wp-comm-version,
:root.wp-theme-light .wp-comm-review,
:root.wp-theme-light .wp-comm-reviews__summary,
:root.wp-theme-light .wp-comm-stepper,
:root.wp-theme-light .wp-comm-profile-hero,
:root.wp-theme-light .wp-comm-filters,
:root.wp-theme-light .wp-modal { background: #fff; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,425 @@
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel"
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}>✕</button>
</div>
<div className="twk-body">{children}</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
function TweakColor({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
@@ -0,0 +1,427 @@
/* global React, classNames, KIND_META */
// Reusable UI primitives — buttons, inputs, table chrome, etc.
const { useState, useRef, useEffect, useCallback } = React;
// ---------- PrimeIcon ----------
function Icon({ name, className, style }) {
return <i className={classNames("pi", name, className)} style={style} />;
}
// ---------- Button ----------
function Button(props) {
// Defensive prop extraction — Babel-standalone's destructure helper can leak
// capitalized keys onto rest. Pull DOM-safe props by hand.
const { variant = "default", size, icon, iconRight, className, children,
onClick, onMouseDown, onMouseEnter, onMouseLeave, onFocus, onBlur,
type, disabled, title, style, id, role,
...rest } = props;
// Pass through aria-* and data-* only.
const safeRest = {};
for (const k in rest) {
if (k.startsWith("aria-") || k.startsWith("data-")) safeRest[k] = rest[k];
}
return (
<button
className={classNames(
"wp-btn",
variant === "primary" && "wp-btn--primary",
variant === "ghost" && "wp-btn--ghost",
variant === "outline" && "wp-btn--outline",
variant === "danger" && "wp-btn--danger",
size === "sm" && "wp-btn--sm",
!children && "wp-btn--icon",
className,
)}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={onFocus}
onBlur={onBlur}
type={type}
disabled={disabled}
title={title}
style={style}
id={id}
role={role}
{...safeRest}
>
{icon && <Icon name={icon} />}
{children}
{iconRight && <Icon name={iconRight} />}
</button>
);
}
// ---------- Input + field ----------
function Field({ label, hint, children, className, style }) {
return (
<div className={classNames("wp-field", className)} style={style}>
{label && <label className="wp-field__label">{label}</label>}
{children}
{hint && <div className="wp-field__hint">{hint}</div>}
</div>
);
}
function Input(props) {
const { size, className, ...rest } = props;
return <input className={classNames("wp-input", size === "sm" && "wp-input--sm", className)} {...rest} />;
}
function Textarea(props) {
return <textarea className="wp-textarea" {...props} />;
}
// ---------- Token autocomplete (for $-prefixed vars / @-prefixed wildcards) ----------
// Triggers when user types `trigger` followed by [a-z0-9_]+.
// Double-trigger ($$ or @@) is treated as escaped and never opens the popup.
function TokenAutocomplete({
value, onChange, options, // options: [{label, hint?}]
trigger = "$", multiline = false, rows = 3,
placeholder, mono = true, style, className,
}) {
const ref = useRef(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [start, setStart] = useState(-1); // index where the trigger sits
const [active, setActive] = useState(0);
const [flip, setFlip] = useState(false);
// Flip the autocomplete popup above the input if there's not enough room below.
useEffect(() => {
if (!open || !ref.current) return;
const r = ref.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - r.bottom;
const spaceAbove = r.top;
const need = Math.min(260, 8 * 36 + 32);
setFlip(spaceBelow < need + 16 && spaceAbove > spaceBelow);
}, [open]);
const probe = useCallback((str, caret) => {
// Walk back from caret to find a trigger that:
// - is preceded by a non-trigger char (or beginning of string), AND
// - is not immediately followed by another trigger ($$ escape)
// - the chars between trigger and caret are all [a-z0-9_]
let i = caret - 1;
while (i >= 0 && /[a-zA-Z0-9_]/.test(str[i])) i--;
if (i < 0 || str[i] !== trigger) return null;
// Check escape: if char before is the same trigger, ignore.
if (i > 0 && str[i - 1] === trigger) return null;
// Check not-double-trigger: if char immediately after the trigger is another trigger, ignore.
if (str[i + 1] === trigger) return null;
return { start: i, query: str.slice(i + 1, caret) };
}, [trigger]);
const filtered = open
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase())).slice(0, 8)
: [];
const handleChange = (e) => {
const next = e.target.value;
onChange(next);
const caret = e.target.selectionStart;
const hit = probe(next, caret);
if (hit) {
setOpen(true);
setStart(hit.start);
setQuery(hit.query);
setActive(0);
} else {
setOpen(false);
}
};
const handleKeyDown = (e) => {
if (!open || filtered.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => (a + 1) % filtered.length); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => (a - 1 + filtered.length) % filtered.length); }
else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
apply(filtered[active]);
} else if (e.key === "Escape") {
setOpen(false);
}
};
const apply = (opt) => {
const el = ref.current;
if (!el) return;
const caret = el.selectionStart;
const before = value.slice(0, start);
const after = value.slice(caret);
const inserted = trigger + opt.label;
const next = before + inserted + after;
onChange(next);
setOpen(false);
requestAnimationFrame(() => {
const pos = (before + inserted).length;
el.focus();
el.setSelectionRange(pos, pos);
});
};
const handleBlur = () => setTimeout(() => setOpen(false), 120);
const commonProps = {
ref, value,
onChange: handleChange,
onKeyDown: handleKeyDown,
onBlur: handleBlur,
placeholder,
style: { fontFamily: mono ? "var(--wp-font-mono)" : undefined, fontSize: mono ? 12.5 : undefined, ...style },
className: classNames(multiline ? "wp-textarea" : "wp-input", className),
};
return (
<div style={{ position: "relative" }}>
{multiline ? <textarea rows={rows} {...commonProps} /> : <input {...commonProps} />}
{open && filtered.length > 0 && (
<div className="wp-autocomplete" data-flip={flip ? "" : null}>
<div className="wp-autocomplete__head">
<span style={{ fontFamily: "var(--wp-font-mono)", color: "var(--wp-accent-text)" }}>{trigger}{query}</span>
<span className="wp-dim" style={{ fontSize: 10.5, marginLeft: "auto" }}>↑↓ Enter · Esc</span>
</div>
{filtered.map((o, i) => (
<button
key={o.label}
type="button"
className="wp-autocomplete__item"
data-active={i === active}
onMouseDown={(e) => { e.preventDefault(); apply(o); }}
onMouseEnter={() => setActive(i)}
>
<span className="wp-autocomplete__label">
<span style={{ color: "var(--wp-accent-text)" }}>{trigger}</span>{o.label}
</span>
{o.hint && <span className="wp-autocomplete__hint">{o.hint}</span>}
</button>
))}
</div>
)}
</div>
);
}
// ---------- Select (custom for consistent styling) ----------
function Select({ value, onChange, options, placeholder = "Select…", size, className, allowClear }) {
const [open, setOpen] = useState(false);
const [flip, setFlip] = useState(false);
const ref = useRef(null);
const btnRef = useRef(null);
useEffect(() => {
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
// When opening, decide whether to flip the dropdown above the trigger.
useEffect(() => {
if (!open || !btnRef.current) return;
const r = btnRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - r.bottom;
const spaceAbove = r.top;
const need = Math.min(240, options.length * 30 + 12);
setFlip(spaceBelow < need + 16 && spaceAbove > spaceBelow);
}, [open, options.length]);
const selected = options.find((o) => o.value === value);
return (
<div ref={ref} className={classNames("wp-select-wrap", className)} style={{ position: "relative" }}>
<button
ref={btnRef}
type="button"
className={classNames("wp-select", size === "sm" && "wp-select--sm")}
onClick={() => setOpen((o) => !o)}
style={{ display: "inline-flex", alignItems: "center", gap: 6, justifyContent: "space-between", textAlign: "left" }}
>
<span style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0, flex: 1, overflow: "hidden" }}>
{selected?.dot && <span className="wp-chip__dot" style={{ background: selected.dot, width: 8, height: 8 }} />}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: selected ? "var(--wp-text)" : "var(--wp-text-dim)" }}>
{selected ? selected.label : placeholder}
</span>
</span>
<Icon name="pi-chevron-down" style={{ fontSize: 11, color: "var(--wp-text-dim)" }} />
</button>
{open && (
<div
style={{
position: "absolute",
...(flip ? { bottom: "calc(100% + 4px)" } : { top: "calc(100% + 4px)" }),
left: 0, right: 0,
background: "var(--wp-bg-3)", border: "1px solid var(--wp-border-strong)",
borderRadius: 7, boxShadow: "var(--wp-shadow-lg)", zIndex: 50,
padding: 4, maxHeight: 240, overflow: "auto",
}}
>
{allowClear && (
<div
className="wp-list__row"
style={{ padding: "6px 8px", color: "var(--wp-text-dim)", borderRadius: 6 }}
onClick={() => { onChange(null); setOpen(false); }}
>
<Icon name="pi-times-circle" style={{ fontSize: 12 }} />
Clear
</div>
)}
{options.map((o) => (
<div
key={o.value}
className="wp-list__row"
style={{
padding: "6px 8px", borderRadius: 6,
background: o.value === value ? "color-mix(in oklab, var(--wp-accent-500) 16%, transparent)" : undefined,
color: o.value === value ? "var(--wp-text)" : "var(--wp-text-muted)",
}}
onClick={() => { onChange(o.value); setOpen(false); }}
>
{o.dot && <span className="wp-chip__dot" style={{ background: o.dot, width: 8, height: 8 }} />}
<span style={{ flex: 1 }}>{o.label}</span>
{o.value === value && <Icon name="pi-check" style={{ fontSize: 11, color: "var(--wp-accent-text)" }} />}
</div>
))}
</div>
)}
</div>
);
}
// ---------- Checkbox ----------
function Checkbox({ checked, onChange, indeterminate }) {
return (
<button
type="button"
className="wp-check"
data-checked={checked}
onClick={(e) => { e.stopPropagation(); onChange(!checked); }}
aria-checked={checked}
>
{indeterminate ? (
<svg viewBox="0 0 12 12" fill="none" style={{ display: "block" }}><path d="M3 6h6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/></svg>
) : checked ? (
<svg viewBox="0 0 12 12" fill="none" style={{ display: "block" }}><path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>
) : null}
</button>
);
}
// ---------- Toggle ----------
function Toggle({ on, onChange }) {
return <button type="button" className="wp-toggle" data-on={on} onClick={() => onChange(!on)} />;
}
// ---------- Star ----------
function Star({ on, onChange, size = 14 }) {
return (
<button type="button" className="wp-star" data-on={on} onClick={(e) => { e.stopPropagation(); onChange(!on); }}>
<svg width={size} height={size} viewBox="0 0 24 24" fill={on ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" strokeLinecap="round">
<path d="M12 2.6l2.95 6 6.6.95-4.78 4.66 1.13 6.58L12 17.7l-5.9 3.1L7.23 14.2 2.45 9.55l6.6-.95L12 2.6z"/>
</svg>
</button>
);
}
// ---------- Chip ----------
function Chip({ children, color, variant, icon, onClose }) {
const style = color ? {
color: color,
background: `color-mix(in oklab, ${color} 14%, transparent)`,
borderColor: `color-mix(in oklab, ${color} 35%, transparent)`,
} : undefined;
return (
<span className={classNames("wp-chip", variant && `wp-chip--${variant}`)} style={style}>
{icon && <Icon name={icon} style={{ fontSize: 10 }} />}
{children}
{onClose && <Icon name="pi-times" style={{ fontSize: 9, cursor: "pointer", marginLeft: 2 }} onClick={onClose} />}
</span>
);
}
// ---------- Tag input (auto-complete style) ----------
function TagInput({ value = [], onChange, placeholder = "Type a tag and press Enter…" }) {
const [draft, setDraft] = useState("");
const commit = () => {
const v = draft.trim();
if (v && !value.includes(v)) onChange([...value, v]);
setDraft("");
};
return (
<div
className="wp-input"
style={{ height: "auto", minHeight: "var(--wp-input-h)", display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center", padding: 4, paddingLeft: 8 }}
>
{value.map((t) => (
<Chip key={t} variant="accent" onClose={() => onChange(value.filter((x) => x !== t))}>{t}</Chip>
))}
<input
style={{ flex: 1, minWidth: 120, background: "transparent", border: "none", outline: "none", color: "inherit", padding: "4px 4px", fontSize: "12.5px" }}
placeholder={value.length === 0 ? placeholder : ""}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); commit(); }
if (e.key === "Backspace" && !draft && value.length) onChange(value.slice(0, -1));
}}
onBlur={commit}
/>
</div>
);
}
// ---------- Card ----------
function Card({ title, action, children, className, padded = true, style }) {
return (
<div className={classNames("wp-card", className)} style={style}>
{title && (
<div className="wp-card__header">
<h3 className="wp-card__title">{title}</h3>
<div className="wp-spacer" />
{action}
</div>
)}
<div className={padded ? "wp-card__body" : ""} style={padded ? undefined : { padding: 0 }}>{children}</div>
</div>
);
}
// ---------- Toast ----------
function useToasts() {
const [toasts, setToasts] = useState([]);
const push = useCallback((msg, opts = {}) => {
const id = Math.random().toString(36).slice(2);
setToasts((t) => [...t, { id, msg, icon: opts.icon || "pi-check-circle" }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2400);
}, []);
const node = (
<div className="wp-toast-stack">
{toasts.map((t) => (
<div key={t.id} className="wp-toast">
<Icon name={t.icon} style={{ color: "var(--wp-accent-text)" }} />
{t.msg}
</div>
))}
</div>
);
return [node, push];
}
// ---------- Module-kind chip ----------
function KindChip({ kind }) {
const m = KIND_META[kind];
if (!m) return null;
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color: "var(--wp-text-muted)" }}>
<span style={{
width: 18, height: 18, borderRadius: 5, display: "grid", placeItems: "center",
background: `color-mix(in oklab, ${m.color} 16%, transparent)`,
color: m.color, fontSize: 10,
}}>
<Icon name={m.icon} />
</span>
{m.label}
</span>
);
}
Object.assign(window, {
Icon, Button, Field, Input, Textarea, Select, Checkbox, Toggle, Star, Chip, TagInput, Card, useToasts, KindChip, TokenAutocomplete,
});
@@ -0,0 +1,245 @@
# ImpactWildcardProcessor/ImpactWildcardEncode
The wildcard feature is a functionality that allows random selection from multiple text files described as prompt texts using placeholders like `__flower__`. It assists in choosing and using a random prompt text among the specified files.
![workflow](impact-wildcard-processor.png)
You can find the instructions on how to use it in the [YouTube video](https://www.youtube.com/watch?v=joNVYSIzA4k).
## HOW TO USE
### Behavior
* `ImpactWildcardProcessor` is a functionality that operates at the browser level. When running the queue prompt, `ImpactWildcardProcessor` generates the text.
* `ImpactWildcardProcessor` node has two text input fields, but the input using wildcards is only valid in the upper text input box, which is the Wildcard Prompt.
* In the bottom mode settings, there are two options: **Populate** and **Fixed**. When set to Populate, running the queue prompt will process the prompt with wildcards used in the upper Wildcard Prompt section and populate the generated text in the bottom Populated Prompt section.
* In the case of **Populate**, when running the queue prompt, the text generated by the Wildcard Prompt will be entered into the Populated Prompt, and the Populated Prompt will be in a read-only state, where user input is not possible. In the case of **Fixed**, the Wildcard Prompt is ignored, and the Populated Prompt becomes editable, allowing user editing.
* When using `ImpactWildcardProcessor` to generate images, the mode is set to **Fixed**, and the populated text is already entered when the image is saved.
* `ImpactWildcardEncode` is similar to `ImpactWildcardProcessor` but offers the loading functionality of LoRAs. Populated prompts are encoded using the provided clip after all lora loading is completed. All loaded LoRAs are applied to the `model` and `clip` and then returned.
![workflow](impact-wildcard-node.png)
![workflow](impact-wildcard-encode.png)
### Syntax
* `ImpactWildcardProcessor` supports two main types of grammar: dynamic prompts using the format `{a|b|c}`, and wildcards using the format `__wildcard__`.
* Dynamic prompts in `ImpactWildcardProcessor` allow nesting, such as `{a|{d|e|f}|c}`, where items separated by | are randomly selected, including nested options.
* If you use a dynamic prompt like `{blue apple|red {cherry|berry}|green melon}`, one option will be randomly selected from the following choices: `blue apple, red cherry, red berry,` or `green melon`.
* If there is a file named `person.txt` inside the wildcards directory, using the wildcard `__person__` will randomly select one item from within that file.
* You can also use compound grammar like `1{girl is holding {blue pencil|red __safruit__|colorful __flower__}|boy is riding __vehicle__}`.
* You can set the selection probability by prefixing a number as in `{5::red|4::green|7::blue|black}`. Each item's selection probability is normalized to 100% based on the sum of all items within the {} brackets, distributing the probabilities evenly.
- **NOTICE: The selection weight syntax has been updated. Now you should use `{5::red|4::green|7::blue|black}` instead of the previous `{5:red|4:green|7:blue|black}` syntax.**
* The pattern inside `__` is case-insensitive. In other words, `__Jewel__` and `__jewel__` are treated as identical.
* `*` serves as an aggregation pattern, allowing you to group all items from the person path and its wildcard files into one collection, from which you can make selections.
* Supported in V4.15.1 or above
* Multi-select '$$': Select multiple items from the available choices.
* {n1-n2$$sel1|sel2|...} - Select between n1 and n2 items from the available choices. If the range exceeds the number of available options, the excess will be ignore d.
* {-n$$sel1|sel2|...} - Select between 1 and n items.
* {n$$sel1|sel2|...} - Select n items.
* custom seperator
* `{n$$ and $$sel1|sel2|...}` - Select n items using ' and ' as a separator.
* `{n1-n2$$ or $$sel1|sel2|...}` - Select between n1 and n2 items using ' or ' as a separator.
* Quantifying wildcard: `count#wildcards` repeats wildcards many as count
* `{2$$, $$5#__wildcards__}`
turns into
`{2$$, $$__wildcards__|__wildcards__|__wildcards__|__wildcards__|__wildcards__}`
* comment-out: Lines starting with `#` are treated as comments and removed. The text following a comment is considered to be separated by a single blank space from the text before the comment.
input:
```
first {a|b|c} second # not a comment,
# this is a comment
trailing text
newline text
```
populated:
```
first a second # not a comment, trailing text
newline text
```
### Limitation
* When converting Seed to input, only `ImpactInt` and `Seed (rgthree)` are allowed as input.
* The wildcard feature of Impact/Inspire needs to be determined before the workflow is executed since it requires storing information populated in the workflow.
* It is not possible to receive the execution results of other nodes as input. Even if you use `ImpactInt` or `Seed (rgthree)`, the current prompt only reads the superficial input provided in the prompt and does not utilize the execution results of other nodes.
### Special Syntax for ImpactWildcardEncode/Detailer Wildcard/Inspire Wildcard
* `<lora:loraname:model_weight:clip_weight>`: In a wildcard prompt, you can directly specify LoRA to load. If you omit the `clip_weight`, it is treated the same as `model_weight`.
* `BREAK`: Separately encode the prompts and connect them using `Conditioning (Concat)`.
### Special Syntax for Detailer Wildcard
* Start the prompt by placing `[ASC]`, `[DSC]`, `[ASC-SIZE]`, `[DSC-SIZE]`, `[RND]`, `[LAB]`, `[CONCAT]` at the very beginning.
- ASC: Ascending order (x, y)
- DSC: Descending order (x, y)
- ASC-SIZE: Ascending order (area size)
- DSC-SIZE: Descending order (area size)
- RND: Random
- LAB: label
- `[ASC]`, `[DSC]`, `[ASC-SIZE]`, `[DSC-SIZE]`, `[RND]` denotes the order of SEGS' bboxes. The left of the bbox takes precedence as the primary criterion, and the top is the secondary criterion.
- CONCAT: Instead of replacing the **positive conditioning** with the **wildcard_opt**, Concatenate the wildcard conditioning with the positive conditioning using the `Conditioning Concat`.
* `[SEP]` is used to separate prompts for each detection area (SEG).
* When `[SKIP]` is used in the prompt, the Detailing for that SEG is skipped.
* When `[STOP]` is used in the prompt, the Detailing is stopped, including the SEG where it appears.
**e.g.**
```
[ASC]
1girl, blue eyes, smile [SEP]
1boy, brown eyes [SEP]
```
- Detection regions are sorted in ascending order based on x, y coordinates, and Detailing is performed sequentially from left to right using the prompts `1girl, blue eyes, smile` and `1boy, brown eyes`.
**e.g.**
```
[DSC-SIZE]
sun glasses[SEP]
[SKIP][SEP]
blue glasses[SEP]
[STOP]
```
- Multiple faces are sorted in descending order based on the area of their detection regions. The second largest face is skipped. The largest face and the third largest face are Detailed using the prompts `sun glasses` and `blue glasses` respectively. The remaining faces are not Detailed.
![multiface](https://github.com/ltdrdata/ComfyUI-extension-tutorials/raw/Main/ComfyUI-Impact-Pack/workflow/multiface-control.png)
* `[LAB]` is based on the application according to labels. Each label can appear only once, and `[ALL]` functions as a prefix.
**e.g.**
```
[LAB]
[ALL] laugh, detailed eyes
[Female] blue eyes
[Male] brown eyes
```
**e.g.**
```
[CONCAT]detailed eyes, faint smile face
```
### Lora Block Weight support
* If the [Inspire Pack](https://github.com/ltdrdata/ComfyUI-Inspire-Pack) is installed, you can use Lora Block Weight in the form of `LBW=lbw spec;`. If the `Inspire Pack` is not installed, this spec will be ignored.
* "The 'spec' within `LBW=spec` internally uses semicolons (;) as separators.". It is recommended to end the LBW spec definition with a semicolon (;). If it ends without a semicolon, specifying the weight afterward may result in a specification error.
* Specs that come after `LBW=` without `A=` or `B=` are applicable for use in the `Inspire Pack`'s `Lora Loader (Block Weight)` node. Specs provided with `A=` or `B=` are inputted as parameters for the `A` and `B` parameters of the `Lora Loader (Block Weight)` node.
* For detailed information about LBW, please refer to this [link](https://github.com/ltdrdata/ComfyUI-extension-tutorials/blob/Main/ComfyUI-Inspire-Pack/tutorial/LoraBlockWeight.md).
* EX)
* `<lora:chunli:1.0:1.0:LBW=B11:0,0,0,0,0,0,0,0,0,0,A,0,0,0,0,0,0;A=0.;>`
* `<lora:chunli:1.0:1.0:LBW=0,0,0,0,0,0,0,0,0,0,A,B,0,0,0,0,0;A=0.5;B=0.2;>`
* `<lora:chunli:1.0:1.0:LBW=SD-MIDD;>`
![workflow](ImpactWildcard-LBW.jpg)
### Application
* If you use wildcards with files, you can save frequently used prompts in a file and load them for usage. For example, if you have a line composed of `photorealistic:1.4, best quality:1.4` and save it as `ppos.txt` in the custom_wildcards directory, you can create prompts in a concise form like `__ppos__`, beautiful nature.
## SETUP
### Wildcard Directories
* Under the ComfyUI-Impact-Pack/ directory, there are two paths: custom_wildcards and wildcards. Both paths are created to hold wildcards files, but it is recommended to avoid adding content to the wildcards file in order to prevent potential conflicts during future updates.
![folder](wildcard-folder.png)
### Progressive On-Demand Loading
* The wildcard system now features **intelligent automatic loading** based on your wildcard collection size:
- **Full Cache Mode**: If total wildcard size < 50MB → All wildcards are loaded into memory at startup for instant access
- **On-Demand Mode**: If total wildcard size ≥ 50MB → Only metadata is scanned at startup, actual wildcard data is loaded progressively as you use them
* **Benefits of On-Demand Mode** (for large wildcard collections):
- **Fast Startup**: < 1 minute even with 10GB+ of wildcards (vs 20-60 minutes in older versions)
- **Low Memory**: < 100MB initial memory usage, grows only as you use wildcards
- **Scalable**: Supports tens of gigabytes of wildcard data efficiently
* **Configuration** (in `impact-pack.ini`):
```ini
[default]
wildcard_cache_limit_mb = 50 # Adjust based on your needs
```
- Lower limit → More likely to use on-demand mode
- Higher limit → More likely to use full cache mode
* **How to Check Current Mode**:
**Method 1 - UI Indicator**: Check the wildcard selector dropdown in ComfyUI interface:
- 🟢 **Full Cache**: `Select Wildcard 🟢 Full Cache` - All wildcards loaded
- 🔵 **On-Demand**: `Select Wildcard 🔵 On-Demand: X loaded` - Shows how many wildcards are currently loaded
**Method 2 - Startup Logs**: Check ComfyUI console output:
```
[Impact Pack] Wildcard total size (45.32 MB) is within cache limit (50.00 MB).
Using full cache mode.
```
or
```
[Impact Pack] Wildcard total size (125.67 MB) exceeds cache limit (50.00 MB).
Using on-demand loading mode (metadata scan only).
```
* **YAML vs TXT Files**:
- **TXT files**: Fully support on-demand loading (loaded only when used)
- **YAML files**: Always pre-loaded at startup (because wildcard keys are embedded in file content)
- For large wildcard collections with true on-demand loading, prefer TXT file structure over YAML
* **Wildcard Refresh Behavior**:
- Using the wildcard refresh function clears **all cached data** (both full cache and on-demand loaded wildcards)
- After refresh, the system re-scans wildcard directories and re-determines the loading mode
- In on-demand mode: Previously loaded wildcards are cleared and will be loaded again when accessed
- This is useful when you add/modify wildcard files and want to reload them without restarting ComfyUI
* The wildcard supports subfolder feature.
* For example, if there is a wildcard file named `custom_wildcards/obj/person.txt`, it should be used as `__obj/person__` instead of `__person__`.
* NOTICE: The subfolder feature was not supported in older versions. The subfolder feature is a newly updated functionality.
![file](wildcard-file.png)
* `.yaml` wildcards file supports. (V4.18.4)
* You can download and use [Wildcard YAML](https://civitai.com/models/138970/billions-of-wildcards-all-in-one) files in this format.
* For example, if a YAML file is structured like the following, you can use `__astronomy/Celestial-Bodies__` and `__surface-swap__` as wildcards, and one of the item inside can be chosen.
```
astronomy:
Celestial-Bodies:
- Star
- Planet
...
surface-swap:
- swap the surfaces for
- replace the surfaces with
...
```
* **Important Note**: YAML files are always pre-loaded at startup even in on-demand mode, because the wildcard keys exist inside the file content rather than in the file path. For truly on-demand loading with large collections, consider converting YAML wildcards to TXT file structure:
```
# YAML structure (pre-loaded)
colors.yaml:
warm: [red, orange, yellow]
cold: [blue, green, purple]
# Convert to TXT structure (on-demand)
colors/warm.txt:
red
orange
yellow
colors/cold.txt:
blue
green
purple
```
* Within the wildcard, the items to be randomly selected should be placed one per line.
* If items are entered on a single line separated by commas, the entire line will be considered as one item.
* Below is the content of the file flower.txt. If written as follows, using `__flower__` in the prompt text will randomly select one from `rose, orchid, iris, carnation, lily, daisy, chrysanthemum, daffodil, and dahlia`.
![content](wildcard-content.png)
### Performance Tips for Large Wildcard Collections
* Use SSD for faster file I/O when using large wildcard collections
* Organize wildcards into subdirectories for better file system performance
* Consider splitting very large single files into multiple smaller files
* Adjust `wildcard_cache_limit_mb` based on available system memory
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,15 @@
<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>
<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.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,15 @@
<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>
<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.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -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

+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.
+14
View File
@@ -0,0 +1,14 @@
"""Shared utility helpers used across the engine + wp_api packages."""
from __future__ import annotations
import datetime as _dt
def now_iso() -> str:
"""UTC ISO timestamp with millisecond precision and ``Z`` suffix.
Used for `created_at`, `updated_at`, `library_snapshot_at`,
migration `applied_at`, and import-export `exported_at`. Single source
of truth so the format never drifts between callers.
"""
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
+4
View File
@@ -0,0 +1,4 @@
"""SQLite-backed library DB."""
from engine.db.connection import get_connection, resolve_db_path
__all__ = ["get_connection", "resolve_db_path"]
+33
View File
@@ -0,0 +1,33 @@
"""SQLite connection factory.
DB path resolution order:
1. ``WP_DB_PATH`` env var (explicit override)
2. ``$COMFYUI_USER_DIR/wildcard-pipeline.db``
3. Fallback: ``~/.comfyui/wildcard-pipeline.db``
"""
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
def resolve_db_path() -> Path:
override = os.environ.get("WP_DB_PATH")
if override:
return Path(override)
user_dir = os.environ.get("COMFYUI_USER_DIR")
if user_dir:
return Path(user_dir) / "wildcard-pipeline.db"
return Path.home() / ".comfyui" / "wildcard-pipeline.db"
def get_connection(path: Path | None = None) -> sqlite3.Connection:
"""Open a SQLite connection with WAL mode and FK enforcement."""
db_path = path or resolve_db_path()
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA foreign_keys=ON;")
return conn
+62
View File
@@ -0,0 +1,62 @@
"""Versioned, forward-only migration runner.
SQL files live in ``engine/db/migrations_sql/<NNN>_<name>.sql``. Filenames
must start with a 3-digit zero-padded version number; the runner applies
them in numeric order.
"""
from __future__ import annotations
import re
import sqlite3
from pathlib import Path
from engine._utils import now_iso
_SQL_DIR = Path(__file__).parent / "migrations_sql"
_VERSION_RE = re.compile(r"^(\d{3})_.+\.sql$")
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
row = conn.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='migrations';"
).fetchone()
return row is not None
def current_version(conn: sqlite3.Connection) -> int:
if not _migrations_table_exists(conn):
return 0
row = conn.execute("SELECT MAX(version) FROM migrations;").fetchone()
return int(row[0]) if row and row[0] is not None else 0
def _discover() -> list[tuple[int, Path]]:
pairs: list[tuple[int, Path]] = []
for entry in _SQL_DIR.iterdir():
if not entry.is_file():
continue
m = _VERSION_RE.match(entry.name)
if not m:
continue
pairs.append((int(m.group(1)), entry))
pairs.sort(key=lambda p: p[0])
return pairs
def migrate(conn: sqlite3.Connection) -> None:
"""Apply all pending migrations. Safe to call repeatedly."""
applied = current_version(conn)
for version, sql_file in _discover():
if version <= applied:
continue
sql = sql_file.read_text(encoding="utf-8")
with conn:
for stmt in sql.split(";"):
stmt = stmt.strip()
if stmt:
conn.execute(stmt)
conn.execute(
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
(version, now_iso()),
)
+30
View File
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS module_categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
color TEXT,
icon TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS modules (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category_id TEXT REFERENCES module_categories(id) ON DELETE SET NULL,
tags TEXT NOT NULL DEFAULT '[]',
is_favorite INTEGER NOT NULL DEFAULT 0,
payload TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_modules_type ON modules(type);
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category_id);
CREATE INDEX IF NOT EXISTS idx_modules_updated_at ON modules(updated_at DESC);
@@ -0,0 +1 @@
PRAGMA user_version = 2;
+309
View File
@@ -0,0 +1,309 @@
"""Repository classes wrapping SQL access to library tables.
Repositories take a ``sqlite3.Connection``. Each public method opens a
transaction via ``with conn:`` so callers don't need explicit commit
calls. Rows are returned as plain dicts (sqlite3.Row converted).
"""
from __future__ import annotations
import json
import re
import secrets
import sqlite3
from typing import Any
from engine._utils import now_iso as _now
_TYPE_PREFIX = {
"wildcard": "wc",
"fixed_values": "fv",
"combine": "cb",
"derivation": "dr",
"constraint": "ct",
"pipeline": "pl",
}
_VALID_TYPES: frozenset[str] = frozenset(_TYPE_PREFIX.keys())
_SLUG_RE = re.compile(r"[^a-z0-9]+")
class _Unset:
"""Sentinel for 'argument not provided' where ``None`` is a valid value."""
_instance: _Unset | None = None
def __new__(cls) -> _Unset:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
_UNSET = _Unset()
class ModuleNotFound(LookupError):
"""Raised when a requested module id does not exist."""
def _slug(name: str) -> str:
s = _SLUG_RE.sub("_", name.lower()).strip("_")
return s[:24] or "module"
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"type": row["type"],
"name": row["name"],
"description": row["description"],
"category_id": row["category_id"],
"tags": json.loads(row["tags"]),
"is_favorite": bool(row["is_favorite"]),
"payload": json.loads(row["payload"]),
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
class ModuleRepository:
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def _gen_id(self, type: str, name: str) -> str:
prefix = _TYPE_PREFIX.get(type, "mod")
return f"{prefix}_{_slug(name)}_{secrets.token_hex(4)}"
def create(
self,
*,
type: str,
name: str,
description: str,
category_id: str | None,
tags: list[str],
payload: dict[str, Any],
is_favorite: bool = False,
) -> dict[str, Any]:
if type not in _VALID_TYPES:
raise ValueError(
f"unknown module type {type!r}; expected one of {sorted(_VALID_TYPES)}"
)
mid = self._gen_id(type, name)
now = _now()
with self._conn:
self._conn.execute(
"INSERT INTO modules("
"id, type, name, description, category_id, tags, "
"is_favorite, payload, version, created_at, updated_at"
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
(
mid, type, name, description, category_id,
json.dumps(tags), int(is_favorite),
json.dumps(payload), now, now,
),
)
return self.get(mid)
def get(self, module_id: str) -> dict[str, Any]:
row = self._conn.execute(
"SELECT * FROM modules WHERE id = ?;", (module_id,),
).fetchone()
if row is None:
raise ModuleNotFound(module_id)
return _row_to_module(row)
def update(
self,
module_id: str,
*,
name: str | _Unset = _UNSET,
description: str | _Unset = _UNSET,
category_id: str | None | _Unset = _UNSET,
tags: list[str] | _Unset = _UNSET,
payload: dict[str, Any] | _Unset = _UNSET,
is_favorite: bool | _Unset = _UNSET,
) -> dict[str, Any]:
existing = self.get(module_id)
new = {
"name": existing["name"] if isinstance(name, _Unset) else name,
"description": (
existing["description"] if isinstance(description, _Unset) else description
),
"category_id": (
existing["category_id"] if isinstance(category_id, _Unset) else category_id
),
"tags": existing["tags"] if isinstance(tags, _Unset) else tags,
"payload": existing["payload"] if isinstance(payload, _Unset) else payload,
"is_favorite": (
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
),
}
now = _now()
with self._conn:
self._conn.execute(
"UPDATE modules SET "
"name = ?, description = ?, category_id = ?, tags = ?, "
"is_favorite = ?, payload = ?, version = version + 1, "
"updated_at = ? "
"WHERE id = ?;",
(
new["name"], new["description"], new["category_id"],
json.dumps(new["tags"]), int(new["is_favorite"]),
json.dumps(new["payload"]), now, module_id,
),
)
return self.get(module_id)
def delete(self, module_id: str) -> None:
with self._conn:
cur = self._conn.execute("DELETE FROM modules WHERE id = ?;", (module_id,))
if cur.rowcount == 0:
raise ModuleNotFound(module_id)
def list(
self,
*,
type: str | None = None,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
limit: int | None = None,
offset: int = 0,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if type is not None:
clauses.append("type = ?")
params.append(type)
if category_id is not None:
clauses.append("category_id = ?")
params.append(category_id)
if query:
escaped = (
query.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
sql = "SELECT * FROM modules"
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY updated_at DESC, id DESC"
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
elif offset:
# SQLite uses LIMIT -1 to mean "unlimited" while still honoring OFFSET.
sql += " LIMIT -1 OFFSET ?"
params.append(offset)
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_module(r) for r in rows]
class CategoryNotFound(LookupError):
"""Raised when a requested category id does not exist."""
def _row_to_category(row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"name": row["name"],
"color": row["color"],
"icon": row["icon"],
"sort_order": row["sort_order"],
}
class CategoryRepository:
def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
def create(
self,
*,
name: str,
color: str | None,
icon: str | None,
sort_order: int = 0,
) -> dict[str, Any]:
cid = _slug(name)
if cid == "module":
# _slug returned its fallback because `name` had no alphanumeric chars.
raise ValueError(
f"name {name!r} has no alphanumeric characters; "
f"category name must produce a usable slug"
)
try:
with self._conn:
self._conn.execute(
"INSERT INTO module_categories("
"id, name, color, icon, sort_order"
") VALUES(?, ?, ?, ?, ?);",
(cid, name, color, icon, sort_order),
)
except sqlite3.IntegrityError as e:
err = str(e).lower()
if "module_categories.id" in err:
raise ValueError(
f"category id collision: {name!r} produces slug {cid!r} "
f"which is already taken"
) from e
raise ValueError(f"category name not unique: {name!r}") from e
return self.get(cid)
def get(self, category_id: str) -> dict[str, Any]:
row = self._conn.execute(
"SELECT * FROM module_categories WHERE id = ?;",
(category_id,),
).fetchone()
if row is None:
raise CategoryNotFound(category_id)
return _row_to_category(row)
def update(
self,
category_id: str,
*,
name: str | _Unset = _UNSET,
color: str | None | _Unset = _UNSET,
icon: str | None | _Unset = _UNSET,
sort_order: int | _Unset = _UNSET,
) -> dict[str, Any]:
existing = self.get(category_id)
new = {
"name": existing["name"] if isinstance(name, _Unset) else name,
"color": existing["color"] if isinstance(color, _Unset) else color,
"icon": existing["icon"] if isinstance(icon, _Unset) else icon,
"sort_order": (
existing["sort_order"] if isinstance(sort_order, _Unset) else sort_order
),
}
with self._conn:
self._conn.execute(
"UPDATE module_categories SET "
"name = ?, color = ?, icon = ?, sort_order = ? "
"WHERE id = ?;",
(
new["name"], new["color"], new["icon"],
new["sort_order"], category_id,
),
)
return self.get(category_id)
def delete(self, category_id: str) -> None:
with self._conn:
cur = self._conn.execute(
"DELETE FROM module_categories WHERE id = ?;",
(category_id,),
)
if cur.rowcount == 0:
raise CategoryNotFound(category_id)
def list(self) -> list[dict[str, Any]]:
rows = self._conn.execute(
"SELECT * FROM module_categories "
"ORDER BY sort_order ASC, name COLLATE NOCASE ASC;"
).fetchall()
return [_row_to_category(r) for r in rows]
-40
View File
@@ -1,40 +0,0 @@
"""Per-module-type handlers. One function per module type.
Handler signature is ``(module, ctx, rng) -> Context``. Returns the (possibly
mutated) context. ``rng`` is a ``random.Random`` supplied by the pipeline —
``fixed_values`` ignores it; seed-consuming handlers (wildcard, derivation)
use it in later specs.
"""
import logging
import random
from collections.abc import Callable
from typing import Any, TypeAlias
from .context import Context
from .modules import FixedValueModule
logger = logging.getLogger(__name__)
ModuleHandler: TypeAlias = Callable[[Any, Context, random.Random], Context]
def handle_fixed_values(
module: FixedValueModule,
ctx: Context,
_rng: random.Random,
) -> Context:
"""Write each entry's value into ``ctx``. Mutates and returns ``ctx``.
``_rng`` is accepted for signature parity with future seed-consuming
handlers (wildcard, derivation); ``fixed_values`` doesn't use randomness.
"""
for entry in module.entries:
name = entry.variable_name.lstrip("$")
if not name:
logger.warning(
"Skipping entry with empty variable_name in module %s", module.id
)
continue
ctx[name] = entry.value
return ctx
+136
View File
@@ -0,0 +1,136 @@
"""Module type registry + resolver context factory."""
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import Any
from engine.syntax.types import ResolveContext, SurfaceKind
@dataclass
class _RuntimeResolveContext:
"""Concrete adapter from a pipeline run's ctx dict to a ResolveContext.
Constructed via build_resolve_ctx; used by handlers when calling resolve_text.
"""
rng: random.Random
max_ref_depth: int
strict: bool
surface: SurfaceKind
developer_mode: bool
warnings: list[dict[str, Any]]
_vars: dict[str, Any]
_catalog: dict[str, dict[str, Any]]
def get_var(self, name: str) -> str | None:
if name in self._vars:
return str(self._vars[name])
return None
def get_module(self, uuid: str) -> dict[str, Any] | None:
return self._catalog.get(uuid)
def build_resolve_ctx(
ctx: dict[str, Any],
surface: SurfaceKind,
*,
strict: bool = False,
) -> ResolveContext:
"""Build a ResolveContext from a pipeline run's ctx dict + a surface label.
Handlers call this with their type-appropriate surface ("wildcard" /
"combine" / "derivation" / "assembler") then pass the result to resolve_text.
"""
return _RuntimeResolveContext( # type: ignore[return-value]
rng=ctx["__wp_rng__"],
max_ref_depth=int(ctx.get("__wp_max_ref_depth__", 8)),
strict=strict,
surface=surface,
developer_mode=bool(ctx.get("__wp_developer_mode__", False)),
warnings=ctx["__wp_warnings__"],
_vars={k: v for k, v in ctx.items() if not k.startswith("__")},
_catalog=ctx.get("__wp_catalog__", {}),
)
from engine.modules.dispatcher import ( # noqa: E402
ModuleHandler,
UnknownModuleType,
get_handler,
register_handler,
resolve_module,
)
from engine.modules.snapshot import ( # noqa: E402
coerce_legacy_module,
freeze_snapshot,
payload_hash,
)
from engine.modules.types import ( # noqa: E402
FixedValueEntry,
FixedValueModule,
Module,
ModuleMeta,
module_from_dict,
module_to_dict,
)
__all__ = [
"FixedValueEntry",
"FixedValueModule",
"Module",
"ModuleMeta",
"module_from_dict",
"module_to_dict",
]
__all__ += [
"coerce_legacy_module",
"freeze_snapshot",
"payload_hash",
]
__all__ += [
"ModuleHandler",
"UnknownModuleType",
"get_handler",
"register_handler",
"resolve_module",
]
from engine.modules.wildcard_handler import WildcardHandler # noqa: E402
from engine.syntax import RecursionLimitExceeded # noqa: E402
register_handler(WildcardHandler)
__all__ += [
"RecursionLimitExceeded",
"WildcardHandler",
]
from engine.modules.fixed_values_handler import FixedValuesHandler # noqa: E402
register_handler(FixedValuesHandler)
__all__ += ["FixedValuesHandler"]
from engine.modules.combine_handler import CombineHandler # noqa: E402
from engine.modules.constraint_handler import ConstraintHandler # noqa: E402
from engine.modules.derivation_handler import DerivationHandler # noqa: E402
from engine.modules.pipeline_handler import PipelineHandler # noqa: E402
register_handler(CombineHandler)
register_handler(DerivationHandler)
register_handler(ConstraintHandler)
register_handler(PipelineHandler)
__all__ += [
"CombineHandler",
"ConstraintHandler",
"DerivationHandler",
"PipelineHandler",
]
__all__ += ["build_resolve_ctx"]
+57
View File
@@ -0,0 +1,57 @@
"""Combine module resolver — syntax-aware template fill.
Reads payload.template, resolves all $var / @{uuid} / {a|b|c} / {N$$sep$$...}
constructs against the runtime ctx, binds the result to payload.output_var.
"""
from __future__ import annotations
import re
from typing import Any
from engine.modules import build_resolve_ctx
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
class CombineHandler(ModuleHandler):
"""Template-fill module: full syntax surface, output bound."""
type_id = "combine"
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
raise ValueError("combine payload must be an object")
template = payload.get("template")
if not isinstance(template, str):
raise ValueError("combine payload.template must be a string")
output_var = payload.get("output_var")
if not isinstance(output_var, str) or not output_var:
raise ValueError("combine payload.output_var must be a non-empty string")
if not _IDENT_RE.match(output_var):
raise ValueError(
f"combine payload.output_var {output_var!r} is not a valid identifier"
)
input_vars = payload.get("input_vars", [])
if not isinstance(input_vars, list):
raise ValueError("combine payload.input_vars must be a list")
for v in input_vars:
if not isinstance(v, str):
raise ValueError("combine payload.input_vars entries must be strings")
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
cls.validate_payload(payload)
template: str = payload["template"]
output_var: str = payload["output_var"]
resolve_ctx = build_resolve_ctx(ctx, surface="combine")
result = resolve_text(template, resolve_ctx)
return {output_var: result}
+146
View File
@@ -0,0 +1,146 @@
"""Constraint module resolver — pass-through stub.
A constraint module declares a re-weighting matrix from one wildcard module's
options to another's. The actual mutation is intentionally **not** done here:
WildcardHandler will eventually inspect ``ctx["_constraints"]`` and apply the
matrix when picking options. This handler only validates the payload and
records metadata in the context for the wildcard handler to consume.
TODO: When WildcardHandler grows constraint awareness, switch from recording
metadata to applying it here, or keep it as a pure metadata channel and let
the consumer drive resolution. The current decision is metadata-only.
"""
from __future__ import annotations
from typing import Any
from engine.modules.dispatcher import ModuleHandler
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
"""Append ``meta`` to ``ctx['_constraints']`` (best-effort)."""
if ctx is None:
return
# Try setter-based access first (engine Context API).
setter = getattr(ctx, "set", None)
getter = getattr(ctx, "get", None)
if callable(setter) and callable(getter):
try:
existing = getter("_constraints", None)
except TypeError:
existing = None
bucket = list(existing) if isinstance(existing, list) else []
bucket.append(meta)
try:
setter("_constraints", bucket)
return
except Exception:
pass
# Fall back to dict-like __getitem__/__setitem__.
try:
existing = ctx["_constraints"] if "_constraints" in ctx else [] # type: ignore[operator,index]
except Exception:
existing = []
bucket = list(existing) if isinstance(existing, list) else []
bucket.append(meta)
try:
ctx["_constraints"] = bucket # type: ignore[index]
except Exception:
return
def _validate_cell(cell: Any, where: str) -> None:
if not isinstance(cell, dict):
raise ValueError(f"constraint {where} must be an object")
mode = cell.get("mode")
if mode not in _VALID_MODES:
raise ValueError(
f"constraint {where}.mode must be one of {sorted(_VALID_MODES)}"
)
factor = cell.get("factor")
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
raise ValueError(f"constraint {where}.factor must be a number")
if float(factor) <= 0:
raise ValueError(f"constraint {where}.factor must be a positive number")
class ConstraintHandler(ModuleHandler):
"""Records a constraint matrix into the context for downstream consumers."""
type_id = "constraint"
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
raise ValueError("constraint payload must be an object")
for key in ("source_wildcard_id", "target_wildcard_id"):
value = payload.get(key)
if not isinstance(value, str) or not value:
raise ValueError(f"constraint payload.{key} must be a non-empty string")
matrix = payload.get("matrix", {})
if not isinstance(matrix, dict):
raise ValueError("constraint payload.matrix must be an object")
for source, sub in matrix.items():
if not isinstance(source, str):
raise ValueError("constraint payload.matrix keys must be strings")
if not isinstance(sub, dict):
raise ValueError(
f"constraint payload.matrix[{source!r}] must be an object"
)
for target, cell in sub.items():
if not isinstance(target, str):
raise ValueError(
f"constraint payload.matrix[{source!r}] keys must be strings"
)
_validate_cell(cell, f"matrix[{source!r}][{target!r}]")
exceptions = payload.get("exceptions", [])
if not isinstance(exceptions, list):
raise ValueError("constraint payload.exceptions must be a list")
for i, exc in enumerate(exceptions):
if not isinstance(exc, dict):
raise ValueError(
f"constraint payload.exceptions[{i}] must be an object"
)
for key in ("source", "target"):
v = exc.get(key)
if not isinstance(v, str) or not v:
raise ValueError(
f"constraint payload.exceptions[{i}].{key} must be a "
f"non-empty string"
)
mode = exc.get("mode")
if mode not in _VALID_MODES:
raise ValueError(
f"constraint payload.exceptions[{i}].mode must be one of "
f"{sorted(_VALID_MODES)}"
)
factor = exc.get("factor")
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
raise ValueError(
f"constraint payload.exceptions[{i}].factor must be a number"
)
if float(factor) <= 0:
raise ValueError(
f"constraint payload.exceptions[{i}].factor must be positive"
)
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
cls.validate_payload(payload)
meta = {
"source_wildcard_id": payload["source_wildcard_id"],
"target_wildcard_id": payload["target_wildcard_id"],
"matrix": payload.get("matrix", {}),
"exceptions": payload.get("exceptions", []),
}
_ctx_set_constraint(ctx, meta)
# Pass-through stub: return no bindings — WildcardHandler is expected
# to consume ctx["_constraints"] when it grows constraint awareness.
return {}
+203
View File
@@ -0,0 +1,203 @@
"""Derivation module resolver — IF/ELIF/ELSE rules over the runtime context.
Each rule has an ordered list of branches (index 0 == IF, the rest == ELIF)
and an optional ``else`` clause. Rules evaluate independently top-to-bottom:
the first branch whose condition matches wins; if none match, the ``else``
action (if any) fires; otherwise the rule is a no-op.
Actions mutate the runtime context directly. Multiple rules can target the
same variable; later rules see earlier mutations. Action ``value`` strings
are resolved through resolve_text with surface="derivation" so $var tokens
and {a|b|c} picks work inside derivation action values.
"""
from __future__ import annotations
import re
from typing import Any
from engine.modules import build_resolve_ctx
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
_VALID_OPS = {"equals", "not_equals", "contains", "matches"}
_VALID_MODES = {"replace", "append", "prepend"}
def _ctx_get(ctx: Any, name: str) -> str:
if ctx is None:
return ""
getter = getattr(ctx, "get", None)
if callable(getter):
try:
value = getter(name, "")
except TypeError:
try:
value = getter(name)
except Exception:
return ""
return "" if value is None else str(value)
try:
if name in ctx: # type: ignore[operator]
return str(ctx[name]) # type: ignore[index]
except Exception:
return ""
return ""
def _ctx_set(ctx: Any, name: str, value: str) -> None:
if ctx is None:
return
setter = getattr(ctx, "set", None)
if callable(setter):
setter(name, value)
return
try:
ctx[name] = value # type: ignore[index]
except Exception:
return
def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
var = condition.get("var", "")
op = condition.get("op", "")
value = condition.get("value", "")
actual = _ctx_get(ctx, var)
if op == "equals":
return actual == value
if op == "not_equals":
return actual != value
if op == "contains":
return value in actual
if op == "matches":
try:
return re.search(value, actual) is not None
except re.error:
return False
return False
def _apply_action(
action: dict[str, Any],
ctx: Any,
resolve_ctx: Any,
) -> tuple[str, str] | None:
target = action.get("target_var", "")
if not target:
return None
mode = action.get("mode", "replace")
raw_value = str(action.get("value", ""))
new_value = resolve_text(raw_value, resolve_ctx)
if mode == "replace":
result = new_value
elif mode == "append":
result = _ctx_get(ctx, target) + new_value
elif mode == "prepend":
result = new_value + _ctx_get(ctx, target)
else:
return None
_ctx_set(ctx, target, result)
return target, result
def _validate_condition(condition: Any, where: str) -> None:
if not isinstance(condition, dict):
raise ValueError(f"derivation {where}.condition must be an object")
var = condition.get("var")
if not isinstance(var, str) or not var:
raise ValueError(f"derivation {where}.condition.var must be a non-empty string")
op = condition.get("op")
if op not in _VALID_OPS:
raise ValueError(
f"derivation {where}.condition.op must be one of {sorted(_VALID_OPS)}"
)
if not isinstance(condition.get("value", ""), str):
raise ValueError(f"derivation {where}.condition.value must be a string")
def _validate_action(action: Any, where: str) -> None:
if not isinstance(action, dict):
raise ValueError(f"derivation {where}.action must be an object")
target = action.get("target_var")
if not isinstance(target, str) or not target:
raise ValueError(
f"derivation {where}.action.target_var must be a non-empty string"
)
mode = action.get("mode", "replace")
if mode not in _VALID_MODES:
raise ValueError(
f"derivation {where}.action.mode must be one of {sorted(_VALID_MODES)}"
)
if not isinstance(action.get("value", ""), str):
raise ValueError(f"derivation {where}.action.value must be a string")
class DerivationHandler(ModuleHandler):
"""IF / ELIF / ELSE rule evaluator that mutates ctx."""
type_id = "derivation"
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
raise ValueError("derivation payload must be an object")
rules = payload.get("rules")
if not isinstance(rules, list):
raise ValueError("derivation payload.rules must be a list")
for ri, rule in enumerate(rules):
if not isinstance(rule, dict):
raise ValueError(f"derivation payload.rules[{ri}] must be an object")
rule_id = rule.get("id")
if not isinstance(rule_id, str) or not rule_id:
raise ValueError(
f"derivation payload.rules[{ri}].id must be a non-empty string"
)
branches = rule.get("branches")
if not isinstance(branches, list) or not branches:
raise ValueError(
f"derivation payload.rules[{ri}].branches must be a non-empty list"
)
for bi, branch in enumerate(branches):
if not isinstance(branch, dict):
raise ValueError(
f"derivation payload.rules[{ri}].branches[{bi}] must be an object"
)
_validate_condition(
branch.get("condition"), f"rules[{ri}].branches[{bi}]"
)
_validate_action(branch.get("action"), f"rules[{ri}].branches[{bi}]")
else_clause = rule.get("else")
if else_clause is not None:
if not isinstance(else_clause, dict):
raise ValueError(
f"derivation payload.rules[{ri}].else must be an object"
)
_validate_action(else_clause.get("action"), f"rules[{ri}].else")
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
cls.validate_payload(payload)
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
out: dict[str, str] = {}
for rule in payload.get("rules", []):
applied = False
for branch in rule.get("branches", []):
if _match_condition(branch.get("condition", {}), ctx):
pair = _apply_action(branch.get("action", {}), ctx, resolve_ctx)
if pair is not None:
out[pair[0]] = pair[1]
applied = True
break
if not applied:
else_clause = rule.get("else")
if isinstance(else_clause, dict):
pair = _apply_action(
else_clause.get("action", {}), ctx, resolve_ctx
)
if pair is not None:
out[pair[0]] = pair[1]
return out
+70
View File
@@ -0,0 +1,70 @@
"""Module-type dispatcher.
Each module type registers a ``ModuleHandler`` subclass keyed by ``type_id``.
Graph execution calls ``resolve_module(snapshot, ctx)`` which routes to the
matching handler. Handlers receive only the snapshot's payload + per-instance
overrides + the runtime context — never the library DB.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class UnknownModuleType(KeyError):
"""Raised when a snapshot's ``type`` has no registered handler."""
class ModuleHandler(ABC):
"""Base class for per-type module handlers."""
type_id: str = ""
@classmethod
@abstractmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
"""Return a mapping of variable_name → resolved string."""
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
"""Validate the payload shape. Default is no-op.
Handlers override this to reject malformed payloads at create/update
time; raise ``ValueError`` with a helpful message on failure.
"""
return None
_HANDLERS: dict[str, type[ModuleHandler]] = {}
def register_handler(handler: type[ModuleHandler]) -> None:
"""Register a handler under its ``type_id``. Re-registration replaces."""
if not handler.type_id:
raise ValueError(f"{handler.__name__} missing type_id")
_HANDLERS[handler.type_id] = handler
def get_handler(type_id: str) -> type[ModuleHandler] | None:
"""Return the handler registered for ``type_id`` or ``None`` if unknown."""
return _HANDLERS.get(type_id)
def resolve_module(snapshot: dict[str, Any], ctx: Any) -> dict[str, str]:
"""Route a snapshot to its registered handler and return the resolution."""
type_id = snapshot.get("type")
if not isinstance(type_id, str):
raise UnknownModuleType(type_id)
handler = _HANDLERS.get(type_id)
if handler is None:
raise UnknownModuleType(type_id)
return handler.resolve(
snapshot.get("payload", {}),
snapshot.get("instance", {}),
ctx,
)
+30
View File
@@ -0,0 +1,30 @@
"""Fixed-values module resolver. Emits one binding per named value."""
from __future__ import annotations
from typing import Any
from engine.modules.dispatcher import ModuleHandler
class FixedValuesHandler(ModuleHandler):
type_id = "fixed_values"
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
values: list[dict[str, Any]] = list(payload.get("values", []))
enabled = instance.get("enabled_options")
if enabled is not None:
allowed = set(enabled)
values = [v for v in values if v.get("id") in allowed]
out: dict[str, str] = {}
for v in values:
name = (v.get("name") or "").strip()
if not name:
continue
out[name] = str(v.get("value", ""))
return out
+99
View File
@@ -0,0 +1,99 @@
"""Pipeline module resolver — runs an ordered list of sub-modules.
Each step references a module in the library by id. The repository is
threaded through ``ctx["_repo"]``; without it the handler can still validate
its payload but ``resolve`` will raise ``RuntimeError`` because it cannot load
sub-modules. Steps marked ``enabled=False`` are skipped.
"""
from __future__ import annotations
from typing import Any
from engine.modules.dispatcher import ModuleHandler, resolve_module
def _ctx_get(ctx: Any, name: str, default: Any = None) -> Any:
if ctx is None:
return default
getter = getattr(ctx, "get", None)
if callable(getter):
try:
return getter(name, default)
except TypeError:
try:
return getter(name)
except Exception:
return default
try:
if name in ctx: # type: ignore[operator]
return ctx[name] # type: ignore[index]
except Exception:
return default
return default
class PipelineHandler(ModuleHandler):
"""Composes other modules into an ordered execution list."""
type_id = "pipeline"
@classmethod
def validate_payload(cls, payload: dict[str, Any]) -> None:
if not isinstance(payload, dict):
raise ValueError("pipeline payload must be an object")
steps = payload.get("steps")
if not isinstance(steps, list):
raise ValueError("pipeline payload.steps must be a list")
for i, step in enumerate(steps):
if not isinstance(step, dict):
raise ValueError(f"pipeline payload.steps[{i}] must be an object")
module_id = step.get("module_id")
if not isinstance(module_id, str) or not module_id:
raise ValueError(
f"pipeline payload.steps[{i}].module_id must be a non-empty string"
)
step_id = step.get("id")
if not isinstance(step_id, str) or not step_id:
raise ValueError(
f"pipeline payload.steps[{i}].id must be a non-empty string"
)
enabled = step.get("enabled", True)
if not isinstance(enabled, bool):
raise ValueError(
f"pipeline payload.steps[{i}].enabled must be a boolean"
)
instance = step.get("instance", {})
if not isinstance(instance, dict):
raise ValueError(
f"pipeline payload.steps[{i}].instance must be an object"
)
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
cls.validate_payload(payload)
repo = _ctx_get(ctx, "_repo", None)
if repo is None:
raise RuntimeError(
"pipeline handler requires ctx['_repo'] to load sub-modules"
)
out: dict[str, str] = {}
for step in payload.get("steps", []):
if not step.get("enabled", True):
continue
module_id = step["module_id"]
row = repo.get(module_id)
sub_snapshot = {
"type": row["type"],
"payload": row["payload"],
"instance": step.get("instance", {}),
}
bindings = resolve_module(sub_snapshot, ctx)
if bindings:
out.update(bindings)
return out
+86
View File
@@ -0,0 +1,86 @@
"""Snapshot freezing, content hashing, and legacy-shape coercion.
A *snapshot* is the JSON shape embedded in a ComfyUI workflow's WP_Context
node when a user picks a library row. The snapshot is decoupled from the
library: graph execution reads only the snapshot, never the live DB.
Backward-compat: pre-SPA fixed_values modules in saved workflows lack the
``library_*`` and ``instance`` keys. ``coerce_legacy_module`` upgrades them
in-place to the canonical shape (still detached, ``library_id is None``).
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
from engine._utils import now_iso as _now_iso
def _fresh_instance() -> dict[str, Any]:
"""Default per-snapshot instance overrides. Fresh dict per call."""
return {
"variable_binding": "",
"enabled_options": None,
"category_filter": None,
}
def payload_hash(payload: dict[str, Any]) -> str:
"""SHA-256 of canonical JSON. Stable across key order + whitespace."""
canonical = json.dumps(
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def freeze_snapshot(library_row: dict[str, Any]) -> dict[str, Any]:
"""Build the JSON shape that gets embedded in a node's modules array."""
return {
"library_id": library_row["id"],
"library_snapshot_at": _now_iso(),
"library_version_at_snapshot": library_row["version"],
"type": library_row["type"],
"name": library_row["name"],
"category_id": library_row.get("category_id"),
"payload": library_row["payload"],
"instance": _fresh_instance(),
}
def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
"""Upgrade a legacy module dict to canonical snapshot shape.
Idempotent: returns ``raw`` unchanged if it already has both
``library_id`` and ``instance`` keys.
"""
if "instance" in raw and "library_id" in raw:
return raw
payload: dict[str, Any]
if raw.get("type") == "fixed_values" and "entries" in raw:
legacy_entries = raw.get("entries", [])
payload = {
"values": [
{
"id": f"val_{i:04x}",
"name": e.get("variable_name", ""),
"value": e.get("value", ""),
}
for i, e in enumerate(legacy_entries)
],
}
else:
payload = raw.get("payload", {})
meta = raw.get("meta") or {}
return {
"library_id": raw.get("library_id"),
"library_snapshot_at": raw.get("library_snapshot_at"),
"library_version_at_snapshot": raw.get("library_version_at_snapshot"),
"type": raw.get("type", "fixed_values"),
"name": raw.get("name") or meta.get("name", ""),
"category_id": raw.get("category_id"),
"payload": payload,
"instance": _fresh_instance(),
}
+66
View File
@@ -0,0 +1,66 @@
"""Wildcard module resolver — weighted RNG over options, syntax via resolve_text.
Each option's `value` may contain $var, @{uuid}, {a|b|c}, {N$$sep$$...}, and
escapes. After picking the option, resolve_text expands the syntax against
the runtime context.
"""
from __future__ import annotations
from typing import Any
from engine.modules import build_resolve_ctx
from engine.modules.dispatcher import ModuleHandler
from engine.syntax import resolve_text
def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
"""Pick one option weighted by `weight`. Uses rng (no module-global random)."""
if not options:
return None
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
if total <= 0:
return options[0]
r = rng.random() * total
acc = 0.0
for opt in options:
acc += max(0.0, float(opt.get("weight", 1)))
if r <= acc:
return opt
return options[-1]
class WildcardHandler(ModuleHandler):
type_id = "wildcard"
@classmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
binding = instance.get("variable_binding") or payload.get("var_binding") or ""
if not binding:
return {}
options: list[dict[str, Any]] = list(payload.get("options", []))
enabled = instance.get("enabled_options")
if enabled is not None:
allowed = set(enabled)
options = [o for o in options if o.get("id") in allowed]
if not options:
return {binding: ""}
rng = ctx["__wp_rng__"]
chosen = _pick_weighted(options, rng)
if chosen is None:
return {binding: ""}
value = str(chosen.get("value", ""))
if not value:
return {binding: ""}
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
resolved = resolve_text(value, resolve_ctx)
return {binding: resolved}
+121 -63
View File
@@ -1,12 +1,20 @@
"""PipelineEngine — dispatches modules through per-type handlers, records trace."""
"""PipelineEngine — runs ordered modules through engine/modules/dispatcher.
Manages per-run state (rng, warnings, trace) and delegates per-module
resolution to dispatcher.resolve_module. Pre-Phase-5 the pipeline routed
fixed_values directly; Phase 5 routes everything via dispatcher and threads
ctx.rng for deterministic randomness.
"""
from __future__ import annotations
import dataclasses
import logging
import random
from typing import Any
from .context import Context
from .handlers import ModuleHandler, handle_fixed_values
from .modules import Module
from engine.context import Context
from engine.modules import Module
from engine.modules.dispatcher import UnknownModuleType, resolve_module
from engine.modules.snapshot import coerce_legacy_module
logger = logging.getLogger(__name__)
@@ -14,84 +22,134 @@ logger = logging.getLogger(__name__)
class PipelineEngine:
"""Runs an ordered list of modules against a context dict."""
HANDLERS: dict[str, ModuleHandler] = {
"fixed_values": handle_fixed_values,
}
def run(
self,
modules: list[Module],
ctx: Context | None = None,
seed: int = 0,
) -> Context:
"""Execute ``modules`` top-to-bottom against ``ctx``.
Initializes internal keys (``__wp_node_seed__``, ``__wp_trace__``,
``__wp_internal_flags__``) if absent. Skips modules with
``enabled is False``. Unknown module types are logged and skipped.
Returns the mutated context.
"""
"""Execute modules top-to-bottom, mutating ctx, returning ctx."""
ctx = {} if ctx is None else ctx
ctx["__wp_node_seed__"] = seed
ctx["__wp_rng__"] = random.Random(seed)
ctx.setdefault("__wp_warnings__", [])
ctx.setdefault("__wp_trace__", [])
ctx.setdefault("__wp_internal_flags__", {})
rng = random.Random(seed)
for index, module in enumerate(modules):
if getattr(module, "enabled", True) is False:
if not getattr(module, "enabled", True):
ctx["__wp_trace__"].append({
"id": getattr(module, "id", ""),
"type": getattr(module, "type", ""),
"enabled": False,
"status": "skipped_disabled",
"writes": [],
"error": None,
})
continue
module_type = getattr(module, "type", None)
handler = self.HANDLERS.get(module_type or "")
if handler is None:
module_type = getattr(module, "type", None) or ""
try:
# coerce_legacy_module expects a dict; convert dataclasses or
# duck-typed objects by reading known attrs via getattr.
if dataclasses.is_dataclass(module) and not isinstance(module, type):
raw = dataclasses.asdict(module) # type: ignore[arg-type]
elif isinstance(module, dict):
raw = module
else:
# Generic object: pull known snapshot keys via getattr so
# class-level attributes (common in tests) are visible.
raw = {
k: getattr(module, k)
for k in (
"id", "type", "enabled", "payload", "entries",
"meta", "library_id", "library_snapshot_at",
"library_version_at_snapshot", "name", "category_id",
"instance",
)
if hasattr(module, k)
}
snapshot = coerce_legacy_module(raw)
except Exception as e:
logger.warning(
"Failed to coerce module %r at index %s: %s", module, index, e
)
ctx["__wp_trace__"].append({
"id": getattr(module, "id", ""),
"type": module_type,
"enabled": True,
"status": "failed",
"writes": [],
"error": {"type": type(e).__name__, "message": str(e)},
})
continue
try:
bindings = resolve_module(snapshot, ctx)
except UnknownModuleType:
logger.warning(
"Unknown module type %r at index %s — skipped",
module_type,
index,
)
ctx["__wp_trace__"].append({
"id": getattr(module, "id", ""),
"type": module_type,
"enabled": True,
"status": "skipped_unknown_type",
"writes": [],
"error": None,
})
continue
except Exception as e:
logger.exception(
"Handler %r failed at index %s", module_type, index
)
ctx["__wp_warnings__"].append({
"type": "handler_error",
"severity": "error",
"module_id": getattr(module, "id", ""),
"source_field": "payload",
"position": 0,
"token_index": None,
"detail": {
"exception_type": type(e).__name__,
"exception_message": str(e),
"traceback": None,
},
"message": (
f"Module {module_type} failed: {type(e).__name__}: {e}"
),
})
ctx["__wp_trace__"].append({
"id": getattr(module, "id", ""),
"type": module_type,
"enabled": True,
"status": "failed",
"writes": [],
"error": {"type": type(e).__name__, "message": str(e)},
})
continue
before = dict(ctx)
ctx = handler(module, ctx, rng)
ctx["__wp_trace__"].append(self._trace_entry(module, before, ctx))
writes = []
for var, value in (bindings or {}).items():
key = var.lstrip("$")
before = ctx.get(key)
ctx[key] = value
writes.append({
"variable": key,
"value": value,
"source": module_type,
"overwrite": before is not None and before != value,
})
ctx["__wp_trace__"].append({
"id": getattr(module, "id", ""),
"type": module_type,
"enabled": True,
"status": "ok",
"writes": writes,
"error": None,
})
return ctx
@staticmethod
def _trace_entry(
module: Module,
before: dict[str, Any],
after: dict[str, Any],
) -> dict[str, Any]:
"""Build a trace record describing what ``module`` wrote."""
module_type = getattr(module, "type", "")
writes: list[dict[str, Any]] = []
for key in after.keys() - before.keys():
if key.startswith("__"):
continue
writes.append(
{"variable": key, "value": after[key], "source": module_type}
)
for key in after.keys() & before.keys():
if key.startswith("__"):
continue
if after[key] == before[key]:
continue
writes.append(
{
"variable": key,
"value": after[key],
"source": module_type,
"overwrite": True,
}
)
return {
"id": getattr(module, "id", ""),
"type": module_type,
"enabled": getattr(module, "enabled", True),
"writes": writes,
}

Some files were not shown because too many files have changed in this diff Show More