@dumiflex/wp-kit 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/LICENSE +674 -0
  3. package/README.md +76 -3
  4. package/package.json +77 -6
  5. package/src/Avatar.vue +83 -0
  6. package/src/Badge.vue +97 -0
  7. package/src/Banner.vue +118 -0
  8. package/src/BookmarkButton.vue +27 -0
  9. package/src/BundleMeta.vue +51 -0
  10. package/src/Button.vue +103 -0
  11. package/src/Card.vue +68 -0
  12. package/src/Checkbox.vue +107 -0
  13. package/src/Chip.vue +97 -0
  14. package/src/ChipToggle.vue +92 -0
  15. package/src/ChoiceCards.vue +133 -0
  16. package/src/CloseButton.vue +69 -0
  17. package/src/CodeBlock.vue +119 -0
  18. package/src/ColorField.vue +206 -0
  19. package/src/ColorPicker.vue +201 -0
  20. package/src/Combobox.vue +334 -0
  21. package/src/ConfirmDialog.vue +163 -0
  22. package/src/ConfirmModal.vue +147 -0
  23. package/src/ConstraintMatrixPreview.vue +708 -0
  24. package/src/ContextMenu.vue +379 -0
  25. package/src/CopyButton.vue +156 -0
  26. package/src/DataTable.vue +292 -0
  27. package/src/DatePicker.vue +234 -0
  28. package/src/DescriptionList.vue +73 -0
  29. package/src/DiffView.vue +68 -0
  30. package/src/Disclosure.vue +166 -0
  31. package/src/DragHandle.vue +49 -0
  32. package/src/Drawer.vue +131 -0
  33. package/src/DropZone.vue +196 -0
  34. package/src/DropdownMenu.vue +72 -0
  35. package/src/EmptyState.vue +111 -0
  36. package/src/Field.vue +28 -0
  37. package/src/HsvPicker.vue +208 -0
  38. package/src/Icon.vue +39 -0
  39. package/src/IconPicker.vue +209 -0
  40. package/src/ImageGrid.vue +125 -0
  41. package/src/InlineEdit.vue +183 -0
  42. package/src/Input.vue +99 -0
  43. package/src/Kbd.vue +56 -0
  44. package/src/KindChip.vue +53 -0
  45. package/src/Lightbox.vue +173 -0
  46. package/src/ListFilter.vue +85 -0
  47. package/src/ListRow.vue +204 -0
  48. package/src/LoadingMark.vue +161 -0
  49. package/src/Logo.vue +60 -0
  50. package/src/Modal.vue +76 -0
  51. package/src/ModalShell.vue +107 -0
  52. package/src/Pagination.vue +106 -0
  53. package/src/Popover.vue +171 -0
  54. package/src/PostCard.vue +211 -0
  55. package/src/ProgressBar.vue +90 -0
  56. package/src/RadioGroup.vue +177 -0
  57. package/src/RangeSlider.vue +183 -0
  58. package/src/RatingBadge.vue +42 -0
  59. package/src/RelativeDate.vue +35 -0
  60. package/src/ResizeHandle.vue +143 -0
  61. package/src/SearchInput.vue +199 -0
  62. package/src/Select.vue +641 -0
  63. package/src/Separator.vue +54 -0
  64. package/src/Skeleton.vue +82 -0
  65. package/src/Slider.vue +198 -0
  66. package/src/SortableList.vue +260 -0
  67. package/src/SplitPane.vue +68 -0
  68. package/src/StarButton.vue +40 -0
  69. package/src/Stepper.vue +141 -0
  70. package/src/SupportLinks.vue +93 -0
  71. package/src/TagInput.vue +300 -0
  72. package/src/Textarea.vue +57 -0
  73. package/src/Timeline.vue +92 -0
  74. package/src/ToastCard.vue +119 -0
  75. package/src/ToastHost.vue +40 -0
  76. package/src/Toggle.vue +36 -0
  77. package/src/TokenizedValue.vue +310 -0
  78. package/src/Toolbar.vue +110 -0
  79. package/src/VirtualList.vue +87 -0
  80. package/src/VisuallyHidden.vue +29 -0
  81. package/src/WpButton.vue +75 -0
  82. package/src/WpCheck.vue +102 -0
  83. package/src/WpIconButton.vue +107 -0
  84. package/src/WpImageZoom.vue +544 -0
  85. package/src/WpNumber.vue +183 -0
  86. package/src/WpSegmented.vue +175 -0
  87. package/src/WpSelect.vue +89 -0
  88. package/src/WpSwitch.vue +163 -0
  89. package/src/WpTabs.vue +188 -0
  90. package/src/_modal-motion.css +33 -0
  91. package/src/_skeleton.css +50 -0
  92. package/src/axis-color.ts +42 -0
  93. package/src/button.css +169 -0
  94. package/src/calendar.ts +59 -0
  95. package/src/clipboard.ts +50 -0
  96. package/src/colour-presets.ts +12 -0
  97. package/src/diff.ts +51 -0
  98. package/src/drop-zone.ts +41 -0
  99. package/src/flip.ts +237 -0
  100. package/src/floating.ts +99 -0
  101. package/src/hsv.ts +100 -0
  102. package/src/icon-catalog.ts +52 -0
  103. package/src/image-zoom.ts +93 -0
  104. package/src/logo-paths.ts +21 -0
  105. package/src/matrix-axis.ts +91 -0
  106. package/src/pagination.ts +27 -0
  107. package/src/platform-keys.ts +29 -0
  108. package/src/rich-tokenize.ts +357 -0
  109. package/src/select-types.ts +32 -0
  110. package/src/slider-math.ts +45 -0
  111. package/src/sortable.ts +22 -0
  112. package/src/theme/fonts/OFL-Fraunces.txt +93 -0
  113. package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
  114. package/src/theme/fonts/OFL-Manrope.txt +93 -0
  115. package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
  116. package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
  117. package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
  118. package/src/theme/looks.css +428 -0
  119. package/src/theme/palettes.css +254 -0
  120. package/src/theme/tokens.css +2894 -0
  121. package/src/tokens.ts +56 -0
  122. package/src/tooltip.css +43 -0
  123. package/src/tooltip.ts +146 -0
  124. package/src/useDialogFocus.ts +73 -0
  125. package/src/useGrowableField.ts +357 -0
  126. package/src/useModalKeyShield.ts +116 -0
  127. package/src/useToast.ts +48 -0
  128. package/src/wp-logo.svg +14 -0
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Constraint-matrix axis grouping — the pure logic shared by both matrix
3
+ * surfaces (the SPA `ConstraintMatrix.vue` library editor and the canvas
4
+ * `MatrixSection.vue` instance modal). Keeping it here means the two grids
5
+ * order, group, label and colour their axes identically; only their template
6
+ * + CSS differ.
7
+ *
8
+ * A wildcard's `tag_groups` (axis name → member sub-categories) drives the
9
+ * layout: tags are ordered by axis, each axis becomes a coloured band
10
+ * (columns) or pinned header chip (rows), and the leftover run of tags with
11
+ * no axis becomes the labelled "uncategorized" bucket instead of a blank.
12
+ */
13
+ import { axisHueAt } from "./axis-color";
14
+
15
+ /** One tag stamped with its axis + whether it opens a new group. Cell lookups
16
+ * stay keyed by tag name, so reordering for display is safe. */
17
+ export interface TagMeta {
18
+ tag: string;
19
+ axisIndex: number; // -1 = ungrouped
20
+ axisName: string;
21
+ groupStart: boolean; // first tag of its axis
22
+ }
23
+
24
+ /** A contiguous run of tags belonging to one axis (or the trailing ungrouped
25
+ * run). Columns render one band per group; row groups with >1 tag get a
26
+ * header chip, solo named groups fold the name into the tag. */
27
+ export interface AxisGroup {
28
+ axisIndex: number; // -1 = ungrouped
29
+ axisName: string;
30
+ tags: string[];
31
+ grouped: boolean;
32
+ }
33
+
34
+ /** Order `tags` by axis (grouped first, in axis order; ungrouped last) and
35
+ * stamp each with its axis index + whether it opens a new group. */
36
+ export function orderByGroups(tags: string[], groups: Record<string, string[]>): TagMeta[] {
37
+ const out: TagMeta[] = [];
38
+ const seen = new Set<string>();
39
+ Object.keys(groups).forEach((axis, ai) => {
40
+ let first = true;
41
+ for (const t of groups[axis] ?? []) {
42
+ if (seen.has(t) || !tags.includes(t)) continue;
43
+ seen.add(t);
44
+ out.push({ tag: t, axisIndex: ai, axisName: axis, groupStart: first });
45
+ first = false;
46
+ }
47
+ });
48
+ let firstUngrouped = true;
49
+ for (const t of tags) {
50
+ if (seen.has(t)) continue;
51
+ out.push({ tag: t, axisIndex: -1, axisName: "", groupStart: firstUngrouped });
52
+ firstUngrouped = false;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** Collapse an ordered `TagMeta[]` into contiguous axis groups. */
58
+ export function toGroups(ordered: TagMeta[]): AxisGroup[] {
59
+ const groups: AxisGroup[] = [];
60
+ for (const m of ordered) {
61
+ if (m.groupStart || groups.length === 0) {
62
+ groups.push({ axisIndex: m.axisIndex, axisName: m.axisName, tags: [], grouped: m.axisIndex >= 0 });
63
+ }
64
+ groups[groups.length - 1].tags.push(m.tag);
65
+ }
66
+ return groups;
67
+ }
68
+
69
+ /** A "bucket" is the catch-all uncategorized run: no axis, or a blank axis
70
+ * name. Buckets are labelled "uncategorized" + tinted neutral rather than
71
+ * rendered blank. */
72
+ export function isBucket(grp: AxisGroup): boolean {
73
+ return !grp.grouped || grp.axisName.trim().length === 0;
74
+ }
75
+
76
+ /** Display label for a group's band / chip. */
77
+ export function groupLabel(grp: AxisGroup): string {
78
+ return isBucket(grp) ? "uncategorized" : grp.axisName;
79
+ }
80
+
81
+ /** Neutral grey for the uncategorized bucket — distinct from the per-axis
82
+ * palette so it doesn't read as a real named axis. */
83
+ export const UNGROUPED_HUE = "var(--wp-text-muted, #8a8a9a)";
84
+
85
+ /** Hue for a group's chips / band / tags. Named axes take the shared palette;
86
+ * the uncategorized bucket goes neutral grey when it's labelled (mixed
87
+ * layout), else keeps the legacy source/target tint (fully-flat matrix). */
88
+ export function groupHue(grp: AxisGroup, role: "source" | "target", labeled: boolean): string {
89
+ if (!isBucket(grp)) return axisHueAt(grp.axisIndex);
90
+ return labeled ? UNGROUPED_HUE : role === "source" ? "var(--wp-constraint-source)" : "var(--wp-constraint-target)";
91
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The page numbers a Pagination shows: always the first and last page, the
3
+ * current page with `siblings` on each side, and "…" where pages are left
4
+ * out. A gap of exactly one page shows that page instead of "…", so the row
5
+ * keeps the same length as you move through it.
6
+ */
7
+ export type PageItem = number | "gap-start" | "gap-end";
8
+
9
+ export function pageItems(page: number, pages: number, siblings = 1): PageItem[] {
10
+ if (pages <= 0) return [];
11
+ const slots = siblings * 2 + 5;
12
+ if (pages <= slots) return Array.from({ length: pages }, (_, i) => i + 1);
13
+ const cur = Math.min(Math.max(1, page), pages);
14
+ const left = Math.max(cur - siblings, 1);
15
+ const right = Math.min(cur + siblings, pages);
16
+ const showStart = left > 3;
17
+ const showEnd = right < pages - 2;
18
+ if (!showStart) {
19
+ const n = siblings * 2 + 3;
20
+ return [...Array.from({ length: n }, (_, i) => i + 1), "gap-end", pages];
21
+ }
22
+ if (!showEnd) {
23
+ const n = siblings * 2 + 3;
24
+ return [1, "gap-start", ...Array.from({ length: n }, (_, i) => pages - n + 1 + i)];
25
+ }
26
+ return [1, "gap-start", ...Array.from({ length: right - left + 1 }, (_, i) => left + i), "gap-end", pages];
27
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Platform-correct labels for the modifier we actually listen for.
3
+ *
4
+ * The save shortcut in our modals is `(ev.ctrlKey || ev.metaKey) && Enter`, so
5
+ * it has always worked on both platforms — but the hint rendered a hardcoded
6
+ * `⌘`, which reads as a Mac-only binding to the ~90% of ComfyUI users on
7
+ * Windows and Linux.
8
+ */
9
+
10
+ /** True on macOS/iPadOS, where the command key is the accelerator. */
11
+ export function isApplePlatform(): boolean {
12
+ if (typeof navigator === "undefined") return false;
13
+ const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
14
+ // `navigator.platform` is deprecated; prefer the UA-CH hint and fall back to
15
+ // the user-agent string, which still carries "Macintosh" / "iPhone".
16
+ const hint = nav.userAgentData?.platform ?? "";
17
+ if (hint) return /mac/i.test(hint);
18
+ return /mac|iphone|ipad|ipod/i.test(nav.userAgent ?? "");
19
+ }
20
+
21
+ /** The accelerator key's symbol: `⌘` on Apple platforms, `Ctrl` elsewhere. */
22
+ export function accelLabel(): string {
23
+ return isApplePlatform() ? "⌘" : "Ctrl";
24
+ }
25
+
26
+ /** The full "save" chord as shown in modal footers — `⌘↵` or `Ctrl+↵`. */
27
+ export function saveChordLabel(): string {
28
+ return isApplePlatform() ? "⌘↵" : "Ctrl+↵";
29
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Wildcard Pipeline rich-text tokenizer: the pure tokenizing surface of the
3
+ * extension's `src/widgets/richTokenize.ts` lexer (same grammar). It has no
4
+ * resolution helpers and no HTML emitters: TokenizedValue walks these tokens
5
+ * in a template, so nothing here needs `v-html`.
6
+ *
7
+ * Recognises the following syntactic surface (locked grammar, Tasks 3-7):
8
+ *
9
+ * $varname subject placeholder (var)
10
+ * @{8hex} nested wildcard reference by UUID (ref)
11
+ * $$ / @@ literal-escape sequences (escape)
12
+ * {a|b|c} inline-choice block (dp-brace, single token)
13
+ * {N$$sep$$a|b|c} multi-select with optional separator (dp-multi)
14
+ *
15
+ * Single-token emission for brace blocks: `{a|b|c}` produces ONE `dp-brace`
16
+ * token whose `meta.branches` lists the raw branch strings. This mirrors the
17
+ * Python tokenizer exactly and is the contract the corpus (tests/fixtures/
18
+ * syntax-corpus.json) locks in.
19
+ *
20
+ * Lossless invariant: joining all `.raw` fields reproduces the input exactly.
21
+ *
22
+ * REMOVED (legacy syntax no longer supported):
23
+ * @name short-form ref - use @{8hex} UUID form instead
24
+ * N#$var / N#@ref / N#{...} quantifier prefix
25
+ * # comment to end of line greyed-out comment line
26
+ * dp-pipe / dp-weight sub-tokens inside brace blocks
27
+ *
28
+ * Note: `dp-pipe` and `dp-weight` remain in the TokenKind union for backwards
29
+ * compatibility with any consumers that reference the type, but the tokenizer
30
+ * no longer emits them.
31
+ */
32
+
33
+ export type TokenKind =
34
+ | "text"
35
+ | "var"
36
+ | "ref"
37
+ | "dp-brace"
38
+ | "dp-pipe" // retained in type only; not emitted by tokenizeRich
39
+ | "dp-multi"
40
+ | "dp-weight" // retained in type only; not emitted by tokenizeRich
41
+ | "escape";
42
+ // REMOVED: "comment", "quantifier" - dropped in locked grammar (Tasks 3-5)
43
+
44
+ export interface TokenMeta {
45
+ // var tokens
46
+ name?: string;
47
+ /** SP2a list accessor: `$name.K` -> 0-based index K (omitted when absent). */
48
+ index?: number;
49
+ /** Schema v8: `$name.neg` / `$name.K.neg` reads the variable's negatives. */
50
+ neg?: boolean;
51
+ // ref tokens
52
+ uuid?: string;
53
+ sub_categories?: string[];
54
+ // escape tokens
55
+ literal?: string;
56
+ // dp-brace / dp-multi tokens
57
+ branches?: string[];
58
+ // dp-multi tokens
59
+ count?: number;
60
+ sep?: string;
61
+ /** SP2b: count range — `min`==`max` for a fixed `{N$$}`, `min<max` for a
62
+ * `{N-M$$}` range. `independent` is the `~` flag (repeats allowed). */
63
+ min?: number;
64
+ max?: number;
65
+ independent?: boolean;
66
+ // dp-weight tokens (type kept for compat; not emitted)
67
+ weight?: number;
68
+ range?: string;
69
+ }
70
+
71
+ /** SP2a: reduce a var reference to its BASE name — drop an optional leading
72
+ * `$` and an optional trailing `.K` list accessor. `$mood.0` -> `mood`;
73
+ * `mood` -> `mood`; `weird.name` (non-digit suffix) -> unchanged. Used by
74
+ * validation + conflict scanning so a `.K` accessor resolves against the
75
+ * bound base var, not a phantom `mood.0`. */
76
+ export function varBaseName(raw: string): string {
77
+ return raw.replace(/^\$/, "").replace(/\.neg$/, "").replace(/\.\d+$/, "").trim();
78
+ }
79
+
80
+ export interface RichToken {
81
+ kind: TokenKind;
82
+ start: number;
83
+ end: number;
84
+ raw: string;
85
+ meta?: TokenMeta;
86
+ }
87
+
88
+ /** Peeled view of a nested ref's `:`-filter body. */
89
+ export interface RefFilterParts {
90
+ /** Boolean sub-category expression, with the `!null` marker removed. */
91
+ expr: string;
92
+ /** True when the body carried the trailing `!null` exclude-null marker. */
93
+ excludeNull: boolean;
94
+ }
95
+
96
+ /**
97
+ * Peel a trailing `!null` exclude marker off a ref's raw `:`-body, returning
98
+ * the pure boolean expression + the exclude-null flag:
99
+ * `"warm or intense!null"` → `{ expr: "warm or intense", excludeNull: true }`
100
+ * `"warm or cool"` → `{ expr: "warm or cool", excludeNull: false }`
101
+ *
102
+ * Sub-category names and the boolean expression never contain `!` (see the
103
+ * grammar in `subcatFilter.ts`), so the only `!` in a serialized body is the
104
+ * null marker. Single source for the peel that RefChip, the canvas OptionRow,
105
+ * and RichTextInput's editor model all need — keeps them from re-deriving it
106
+ * (and drifting) independently.
107
+ */
108
+ export function splitRefFilter(body: string): RefFilterParts {
109
+ const bang = body.lastIndexOf("!");
110
+ if (bang >= 0) {
111
+ return { expr: body.slice(0, bang).trim(), excludeNull: body.slice(bang + 1) === "null" };
112
+ }
113
+ return { expr: body.trim(), excludeNull: false };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Internal helpers (mirrors Python engine/syntax/tokenize.py)
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Split `s` on `|` at brace-depth zero only. Nested `{a|b}` stays intact.
122
+ * Mirrors Python `_split_top_level_pipes`.
123
+ */
124
+ function splitTopLevelPipes(s: string): string[] {
125
+ const parts: string[] = [];
126
+ let depth = 0;
127
+ let last = 0;
128
+ for (let idx = 0; idx < s.length; idx++) {
129
+ const c = s[idx];
130
+ if (c === "{") depth++;
131
+ else if (c === "}") depth--;
132
+ else if (c === "|" && depth === 0) {
133
+ parts.push(s.slice(last, idx));
134
+ last = idx + 1;
135
+ }
136
+ }
137
+ parts.push(s.slice(last));
138
+ return parts;
139
+ }
140
+
141
+ // Multi-pick prefix: {N$$sep$$ or {N-M~$$sep$$ — group 1 is the count (fixed
142
+ // `N` or a `N-M` range), group 2 is the optional `~` independent flag (absent
143
+ // = unique), group 3 is the separator. Applied against the whole raw block
144
+ // including the leading `{`. SP2b.
145
+ const MULTI_PREFIX_RE = /^\{(\d+(?:-\d+)?)(~?)\$\$([\s\S]*?)\$\$/;
146
+
147
+ /**
148
+ * Try to scan a `{...}` block starting at `text[start]` (must be `{`).
149
+ *
150
+ * Returns `[endIndex, branches, count | null, sep | null]` on success, or
151
+ * `null` if the block is malformed (unclosed, or no top-level pipes for a
152
+ * plain pick). Mirrors Python `_scan_brace_block`.
153
+ */
154
+ function scanBraceBlock(
155
+ text: string,
156
+ start: number,
157
+ ): [number, string[], [number, number, boolean] | null, string | null] | null {
158
+ const n = text.length;
159
+ if (start >= n || text[start] !== "{") return null;
160
+
161
+ // Walk to the matching `}` tracking depth.
162
+ let i = start + 1;
163
+ let depth = 1;
164
+ while (i < n && depth > 0) {
165
+ const c = text[i];
166
+ if (c === "{") depth++;
167
+ else if (c === "}") depth--;
168
+ if (depth > 0) i++;
169
+ }
170
+ if (depth !== 0) return null; // unclosed
171
+
172
+ const bodyStart = start + 1;
173
+ const bodyEnd = i; // index of closing `}`
174
+ const endIndex = i + 1;
175
+ const body = text.slice(bodyStart, bodyEnd);
176
+
177
+ // Detect multi-pick prefix: {N$$sep$$...
178
+ const raw = text.slice(start, endIndex);
179
+ const multiMatch = raw.match(MULTI_PREFIX_RE);
180
+ if (multiMatch) {
181
+ const countRaw = multiMatch[1]; // "N" or "N-M"
182
+ const independent = multiMatch[2] === "~"; // SP2b: repeats allowed
183
+ const sep = multiMatch[3];
184
+ // Prefix length excluding the leading `{`
185
+ const prefixLen = multiMatch[0].length - 1;
186
+ const rest = body.slice(prefixLen);
187
+ const branches = splitTopLevelPipes(rest);
188
+ const [lo, hi] = countRaw.includes("-")
189
+ ? countRaw.split("-")
190
+ : [countRaw, countRaw];
191
+ let cmin = parseInt(lo, 10);
192
+ let cmax = parseInt(hi, 10);
193
+ if (cmin > cmax) { const t = cmin; cmin = cmax; cmax = t; }
194
+ return [endIndex, branches, [cmin, cmax, independent], sep];
195
+ }
196
+
197
+ // Plain pick: must contain at least one top-level `|`
198
+ const branches = splitTopLevelPipes(body);
199
+ if (branches.length < 2) return null; // fall back to literal text
200
+
201
+ return [endIndex, branches, null, null];
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Public API
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Walk `text` once and emit a flat list of {@link RichToken}s.
210
+ *
211
+ * Token shapes mirror the Python tokenizer (`engine/syntax/tokenize.py`) so
212
+ * the corpus contract holds across both languages.
213
+ */
214
+ export function tokenizeRich(text: string): RichToken[] {
215
+ const out: RichToken[] = [];
216
+ if (!text) return out;
217
+
218
+ const N = text.length;
219
+ let i = 0;
220
+ // Accumulated start of current literal-text run (null = no run in progress)
221
+ let textStart: number | null = null;
222
+
223
+ function flushText(endAt: number): void {
224
+ if (textStart === null) return;
225
+ if (endAt > textStart) {
226
+ out.push({
227
+ kind: "text",
228
+ raw: text.slice(textStart, endAt),
229
+ start: textStart,
230
+ end: endAt,
231
+ meta: {},
232
+ });
233
+ }
234
+ textStart = null;
235
+ }
236
+
237
+ while (i < N) {
238
+ const ch = text[i];
239
+
240
+ // -- Escape sequences: $$ -> literal $, @@ -> literal @ ----------------
241
+ if (ch === "$" && i + 1 < N && text[i + 1] === "$") {
242
+ flushText(i);
243
+ out.push({ kind: "escape", raw: "$$", start: i, end: i + 2, meta: { literal: "$" } });
244
+ i += 2;
245
+ continue;
246
+ }
247
+ if (ch === "@" && i + 1 < N && text[i + 1] === "@") {
248
+ flushText(i);
249
+ out.push({ kind: "escape", raw: "@@", start: i, end: i + 2, meta: { literal: "@" } });
250
+ i += 2;
251
+ continue;
252
+ }
253
+
254
+ // -- Variable: $name or $name.K (SP2a list accessor) --------------------
255
+ if (ch === "$") {
256
+ // `.match` (not `.exec`) keeps the matcher off the security-hook's radar
257
+ // while giving the same match-array shape. Group 2 = optional `.K` index.
258
+ // Group 3 = optional `.neg` (schema v8 negatives read).
259
+ const m = text
260
+ .slice(i + 1)
261
+ .match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?(\.neg(?![A-Za-z0-9_]))?/);
262
+ if (m) {
263
+ flushText(i);
264
+ const raw =
265
+ "$" + m[1] + (m[2] !== undefined ? "." + m[2] : "") + (m[3] ?? "");
266
+ const meta: { name: string; index?: number; neg?: boolean } = { name: m[1] };
267
+ if (m[2] !== undefined) meta.index = parseInt(m[2], 10);
268
+ if (m[3] !== undefined) meta.neg = true;
269
+ out.push({
270
+ kind: "var",
271
+ raw,
272
+ start: i,
273
+ end: i + raw.length,
274
+ meta,
275
+ });
276
+ i += raw.length;
277
+ continue;
278
+ }
279
+ // Lone $ or $ not followed by ident -- fall through to text accumulation
280
+ }
281
+
282
+ // -- Ref: @{8hex} or @{8hex#name:expr!null} -----------------------------
283
+ // Optional `:expr` is an SP1 boolean sub-category filter (`warm or
284
+ // intense`, `not cool`, parens; comma = or) with an optional trailing
285
+ // `!null` exclude-null marker. The lexer does NOT parse the grammar — it
286
+ // just captures the raw `:`-body, comma-split into `sub_categories` for
287
+ // legacy callers. Downstream (RichTextInput, validateModule) rejoins on
288
+ // `,`, peels `!null`, and hands the rest to the shared subcat-filter
289
+ // parser. Keeps one library wildcard reusable while each call site
290
+ // narrows surgically (e.g. `@{color:warm}` vs `@{color:cool or warm}`).
291
+ // Empty filter → no filter.
292
+ if (ch === "@") {
293
+ // Groups: 1=uuid, 2=optional `#name` cache, 3=optional subcat filter.
294
+ const refMatch = text.slice(i).match(/^@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}/);
295
+ if (refMatch) {
296
+ flushText(i);
297
+ const filterRaw = refMatch[3];
298
+ const subCategories =
299
+ typeof filterRaw === "string"
300
+ ? filterRaw.split(",").map((s) => s.trim()).filter(Boolean)
301
+ : [];
302
+ const meta: { uuid: string; name?: string; sub_categories?: string[] } = { uuid: refMatch[1] };
303
+ if (refMatch[2]) meta.name = refMatch[2];
304
+ if (subCategories.length > 0) meta.sub_categories = subCategories;
305
+ out.push({
306
+ kind: "ref",
307
+ raw: refMatch[0],
308
+ start: i,
309
+ end: i + refMatch[0].length,
310
+ meta,
311
+ });
312
+ i += refMatch[0].length;
313
+ continue;
314
+ }
315
+ // Not a ref -- fall through to text accumulation
316
+ }
317
+
318
+ // -- Brace block: {a|b|c} or {N$$sep$$a|b|c} ---------------------------
319
+ if (ch === "{") {
320
+ const scanned = scanBraceBlock(text, i);
321
+ if (scanned !== null) {
322
+ const [endIndex, branches, count, sep] = scanned;
323
+ flushText(i);
324
+ if (count === null) {
325
+ out.push({
326
+ kind: "dp-brace",
327
+ raw: text.slice(i, endIndex),
328
+ start: i,
329
+ end: endIndex,
330
+ meta: { branches },
331
+ });
332
+ } else {
333
+ const [cmin, cmax, independent] = count;
334
+ out.push({
335
+ kind: "dp-multi",
336
+ raw: text.slice(i, endIndex),
337
+ start: i,
338
+ end: endIndex,
339
+ // `count` kept == max for back-compat; min/max carry the range,
340
+ // independent carries the `~` flag (SP2b). Mirrors the Python meta.
341
+ meta: { min: cmin, max: cmax, independent, count: cmax, sep: sep ?? "", branches },
342
+ });
343
+ }
344
+ i = endIndex;
345
+ continue;
346
+ }
347
+ // Malformed brace -- fall through to literal text
348
+ }
349
+
350
+ // -- Default: accumulate literal text ------------------------------------
351
+ if (textStart === null) textStart = i;
352
+ i++;
353
+ }
354
+
355
+ flushText(N);
356
+ return out;
357
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `Select.vue`'s option shape, in a plain module.
3
+ *
4
+ * It was declared inside `<script setup>`, which works for `.vue` importers
5
+ * (vue-tsc resolves SFC exports) but not for plain `.ts` ones — those see the
6
+ * `*.vue` ambient shim, whose only export is the default component. Building
7
+ * option lists in a `.ts` helper needs the type, so it lives here and
8
+ * `Select.vue` re-exports it; every existing
9
+ * `import type { SelectOption } from "./Select.vue"` keeps working.
10
+ */
11
+ export interface SelectOption {
12
+ value: string | number | null;
13
+ label: string;
14
+ /** Optional color dot shown before the label (e.g. category color). Used
15
+ * on its own when the option has no `icon`. */
16
+ dot?: string;
17
+ /** Optional PrimeIcons slug (no `pi-` prefix) shown before the label. When
18
+ * set it REPLACES the dot rather than sitting next to it, tinted with
19
+ * `dot` so the colour survives — a category is far easier to recognise by
20
+ * its glyph than by an 8px circle, especially once a library holds more
21
+ * categories than there are distinguishable hues. */
22
+ icon?: string;
23
+ /** Optional native tooltip surfaced via `title` on the option row.
24
+ * Used by derivation op dropdown to explain semantics — e.g.
25
+ * "matches" shows "Python regex via re.search". */
26
+ title?: string;
27
+ /** Optional dim right-aligned detail that DISAMBIGUATES rows sharing a
28
+ * label — e.g. a wildcard's option count + short uuid, when a library
29
+ * holds five entries all named "Outfit". Also matched by the type-to-
30
+ * filter query, so typing a uuid finds its row. */
31
+ meta?: string;
32
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Number helpers for sliders (Slider now, the two-thumb range later): clamp
3
+ * to the ends, snap to the step counted from `min`, and turn a pointer
4
+ * position into a value. Steps like 0.1 snap without float fuzz.
5
+ */
6
+ export function decimals(step: number): number {
7
+ const s = String(step);
8
+ const i = s.indexOf(".");
9
+ return i < 0 ? 0 : s.length - i - 1;
10
+ }
11
+
12
+ export function snap(v: number, min: number, max: number, step: number): number {
13
+ if (!Number.isFinite(v)) return min;
14
+ const clamped = Math.min(max, Math.max(min, v));
15
+ if (step <= 0) return clamped;
16
+ const n = Math.round((clamped - min) / step);
17
+ const out = Math.min(max, min + n * step);
18
+ return Number(out.toFixed(Math.max(decimals(step), decimals(min))));
19
+ }
20
+
21
+ /** Where `v` sits between `min` and `max`, 0 to 100. */
22
+ export function percentOf(v: number, min: number, max: number): number {
23
+ if (max <= min) return 0;
24
+ return Math.min(100, Math.max(0, ((v - min) / (max - min)) * 100));
25
+ }
26
+
27
+ /** The value under a pointer at `x` on a track from `left`, `width` wide. */
28
+ export function valueAt(x: number, left: number, width: number, min: number, max: number, step: number): number {
29
+ const t = width > 0 ? (x - left) / width : 0;
30
+ return snap(min + t * (max - min), min, max, step);
31
+ }
32
+
33
+ /** The value one key press away, or null for a key the slider ignores. */
34
+ export function keyStep(key: string, v: number, min: number, max: number, step: number, big = 10): number | null {
35
+ const s = step > 0 ? step : (max - min) / 100;
36
+ switch (key) {
37
+ case "ArrowRight": case "ArrowUp": return snap(v + s, min, max, step);
38
+ case "ArrowLeft": case "ArrowDown": return snap(v - s, min, max, step);
39
+ case "PageUp": return snap(v + s * big, min, max, step);
40
+ case "PageDown": return snap(v - s * big, min, max, step);
41
+ case "Home": return min;
42
+ case "End": return max;
43
+ default: return null;
44
+ }
45
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Pure helpers for SortableList: move one item in a list, and work out where
3
+ * a dragged row lands from the pointer and the rows' boxes.
4
+ */
5
+ export function moveItem<T>(list: readonly T[], from: number, to: number): T[] {
6
+ const out = [...list];
7
+ if (from < 0 || from >= out.length) return out;
8
+ const [it] = out.splice(from, 1);
9
+ out.splice(Math.min(Math.max(0, to), out.length), 0, it);
10
+ return out;
11
+ }
12
+
13
+ /**
14
+ * The index the dragged row ends up at: the number of OTHER rows whose
15
+ * middle is above the pointer. `mids` are the rows' vertical middles in
16
+ * list order, the dragged row's included.
17
+ */
18
+ export function dropIndex(y: number, mids: readonly number[], from: number): number {
19
+ let n = 0;
20
+ mids.forEach((m, i) => { if (i !== from && m < y) n++; });
21
+ return n;
22
+ }