fix: post-merge CI + docs/ ignore
@@ -28,9 +28,12 @@ Thumbs.db
|
||||
# Claude / Superpowers workspace artifacts
|
||||
.superpowers/
|
||||
.claude/
|
||||
docs/superpowers/
|
||||
# Backups dropped by /caveman:compress next to compressed files
|
||||
*.original.md
|
||||
|
||||
# Docs — ignore everything except per-node help pages
|
||||
/docs/*
|
||||
!/docs/help/
|
||||
|
||||
# Semantic release
|
||||
.releaserc.local.js
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,47 +0,0 @@
|
||||
<!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>
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -1,211 +0,0 @@
|
||||
/* 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 />);
|
||||
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
@@ -1,16 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,290 +0,0 @@
|
||||
/* 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.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 });
|
||||
@@ -1,677 +0,0 @@
|
||||
/* 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 });
|
||||
@@ -1,525 +0,0 @@
|
||||
/* 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 });
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,134 +0,0 @@
|
||||
/* 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 });
|
||||
@@ -1,768 +0,0 @@
|
||||
/* 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} ↓ {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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/* 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 });
|
||||
@@ -1,454 +0,0 @@
|
||||
/* =========================================================
|
||||
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; }
|
||||
@@ -1,425 +0,0 @@
|
||||
|
||||
// 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,
|
||||
});
|
||||
@@ -1,427 +0,0 @@
|
||||
/* 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,
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
# 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.
|
||||
|
||||

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

|
||||

|
||||
|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
* `[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;>`
|
||||
|
||||

|
||||
|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
* `.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`.
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
@@ -1,15 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,15 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 31 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 31 KiB |
@@ -1,146 +0,0 @@
|
||||
# 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.
|
||||
@@ -5,10 +5,15 @@ import datetime as _dt
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""UTC ISO timestamp with millisecond precision and ``Z`` suffix.
|
||||
"""UTC ISO timestamp with microsecond 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.
|
||||
|
||||
Microsecond precision (vs. millisecond) prevents collisions when
|
||||
two ops fire inside the same millisecond on fast CI runners — the
|
||||
`updated_at > created_at` invariant in `ModuleRepository.update`
|
||||
must hold for any sequential create+update pair.
|
||||
"""
|
||||
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
|
||||