@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,41 @@
1
+ /**
2
+ * File checks for DropZone: does a file (or a dragged item, whose name is
3
+ * not known until the drop) match an `accept` list like the file input's
4
+ * ("image/*,.txt,application/json")?
5
+ */
6
+ export function acceptList(accept: string | undefined): string[] {
7
+ return (accept ?? "").split(",").map((a) => a.trim().toLowerCase()).filter(Boolean);
8
+ }
9
+
10
+ function typeMatches(type: string, rule: string): boolean {
11
+ if (!type) return false;
12
+ const t = type.toLowerCase();
13
+ return rule.endsWith("/*") ? t.startsWith(rule.slice(0, -1)) : t === rule;
14
+ }
15
+
16
+ /** A dropped file: by its extension or its type. An empty list takes all. */
17
+ export function fileAccepted(file: { name: string; type: string }, accept: string | undefined): boolean {
18
+ const rules = acceptList(accept);
19
+ if (!rules.length) return true;
20
+ const name = file.name.toLowerCase();
21
+ return rules.some((r) => (r.startsWith(".") ? name.endsWith(r) : typeMatches(file.type, r)));
22
+ }
23
+
24
+ /**
25
+ * A dragged item, before the drop: only its type is known. Returns false
26
+ * only when it surely won't be taken; an item with no type, or a list that
27
+ * names extensions (which we can't see yet), gets the benefit of the doubt.
28
+ */
29
+ export function dragItemMaybeAccepted(type: string, accept: string | undefined): boolean {
30
+ const rules = acceptList(accept);
31
+ if (!rules.length || !type) return true;
32
+ if (rules.some((r) => r.startsWith("."))) return true;
33
+ return rules.some((r) => typeMatches(type, r));
34
+ }
35
+
36
+ /** "2.4 MB", "820 KB". */
37
+ export function formatBytes(n: number): string {
38
+ if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(n >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
39
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`;
40
+ return `${n} B`;
41
+ }
package/src/flip.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Manual FLIP-move helper + reduce-motion gate. Shared across every
3
+ * row-list surface in the extension — moved from
4
+ * `src/components/context/bundles/flip.ts` (which was bundle-coupled
5
+ * by directory but not by code) so InjectorRow + future row UIs can
6
+ * reuse the same animation primitives instead of reinventing them.
7
+ *
8
+ * Used by ContextWidget's drop / picker-add / trash-remove paths to
9
+ * animate row reordering, plus cross-container fade-slide and bundle
10
+ * collapse/expand. Pure functions — no Vue imports, unit-testable.
11
+ *
12
+ * Reduce-motion contract: every helper checks `shouldAnimate()` which
13
+ * returns false when `.wp-a11y-no-motion` is on `document.body`. CSS
14
+ * transitions/animations are handled by a separate blanket rule in
15
+ * src/components/shared/a11y.css; this module handles the JS side.
16
+ *
17
+ * Motion constants mirror the CSS tokens in
18
+ * src/components/shared/theme.css (--wp-motion-*). Keep these in sync.
19
+ *
20
+ * Class-name customization: every animation helper accepts an
21
+ * `opts.classes` field so different consumers can use their own
22
+ * BEM-style modifier names (e.g. ModuleRow uses `wp-module--*`,
23
+ * InjectorRow uses `wp-inj-row--*`). Defaults preserve the original
24
+ * Context/Bundle behavior so ContextWidget keeps working without
25
+ * any callsite change.
26
+ */
27
+
28
+ export const MOTION_COLLAPSE_MS = 450;
29
+ export const MOTION_FLIP_MS = 280;
30
+ export const MOTION_FADE_MS = 180;
31
+ export const MOTION_PULSE_MS = 420;
32
+ export const MOTION_STAGGER_MS = 60;
33
+ export const MOTION_CURVE_FLIP = "cubic-bezier(0.22, 1, 0.36, 1)";
34
+
35
+ type FlipOptions = { duration?: number; ease?: string };
36
+
37
+ /** Per-consumer class name vocabulary. Each animation helper applies
38
+ * these classes to mark rows mid-transition; the consumer's CSS
39
+ * defines what the classes look like. Defaults to the original
40
+ * ContextWidget/ModuleRow names for back-compat. */
41
+ export interface AnimationClasses {
42
+ arriving: string;
43
+ arrived: string;
44
+ leaving: string;
45
+ flash: string;
46
+ }
47
+
48
+ const DEFAULT_CLASSES: AnimationClasses = {
49
+ arriving: "wp-module--arriving",
50
+ arrived: "wp-module--arrived",
51
+ leaving: "wp-module--leaving",
52
+ flash: "wp-module--flash",
53
+ };
54
+
55
+ /** Resolve user-supplied classes against the defaults so callers can
56
+ * override only the names that differ. Internal helper. */
57
+ function resolveClasses(partial?: Partial<AnimationClasses>): AnimationClasses {
58
+ if (!partial) return DEFAULT_CLASSES;
59
+ return { ...DEFAULT_CLASSES, ...partial };
60
+ }
61
+
62
+ /** Whether motion should animate. False when user has reduce-motion ON. */
63
+ export function shouldAnimate(): boolean {
64
+ return !document.body.classList.contains("wp-a11y-no-motion");
65
+ }
66
+
67
+ /** Capture each child's bounding rect, keyed by the caller's key function. */
68
+ export function captureRects(
69
+ container: HTMLElement,
70
+ key: (el: HTMLElement) => string | null,
71
+ ): Map<string, DOMRect> {
72
+ const out = new Map<string, DOMRect>();
73
+ for (const child of Array.from(container.children)) {
74
+ const el = child as HTMLElement;
75
+ const k = key(el);
76
+ if (k != null) out.set(k, el.getBoundingClientRect());
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * After a DOM mutation, apply inverse-transform to children whose
83
+ * position changed, then transition back to identity. Skip silently
84
+ * when reduce-motion is on.
85
+ */
86
+ export function applyFlip(
87
+ container: HTMLElement,
88
+ before: Map<string, DOMRect>,
89
+ key: (el: HTMLElement) => string | null,
90
+ opt: FlipOptions = {},
91
+ ): void {
92
+ if (!shouldAnimate()) return;
93
+ const dur = opt.duration ?? MOTION_FLIP_MS;
94
+ const ease = opt.ease ?? MOTION_CURVE_FLIP;
95
+ for (const child of Array.from(container.children)) {
96
+ const el = child as HTMLElement;
97
+ const k = key(el);
98
+ if (k == null) continue;
99
+ const beforeRect = before.get(k);
100
+ if (!beforeRect) continue;
101
+ const afterRect = el.getBoundingClientRect();
102
+ const dx = beforeRect.left - afterRect.left;
103
+ const dy = beforeRect.top - afterRect.top;
104
+ if (dx === 0 && dy === 0) continue;
105
+ el.style.transition = "none";
106
+ el.style.transform = `translate(${dx}px, ${dy}px)`;
107
+ requestAnimationFrame(() => {
108
+ el.style.transition = `transform ${dur}ms ${ease}`;
109
+ el.style.transform = "translate(0, 0)";
110
+ });
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Mutate state then animate the newly-inserted element (matched by uid)
116
+ * with --arriving → --arrived class transition. The arriving class is
117
+ * applied after the mutation + a microtask so the browser registers
118
+ * the "from" state; arrived flips the next frame so the transition fires.
119
+ * No-op (mutate-only) when reduce-motion is on.
120
+ */
121
+ export async function withEnterAnimation(
122
+ uid: string,
123
+ container: HTMLElement,
124
+ mutate: () => void,
125
+ opts?: { classes?: Partial<AnimationClasses> },
126
+ ): Promise<void> {
127
+ mutate();
128
+ if (!shouldAnimate()) return;
129
+ const cls = resolveClasses(opts?.classes);
130
+ // Wait one rAF so Vue commits the mutation to DOM before we query.
131
+ // A bare `await Promise.resolve()` schedules a microtask that can
132
+ // race Vue's own microtask-based reactivity scheduler — rAF runs
133
+ // after the microtask queue drains and before paint.
134
+ await new Promise<void>(r => requestAnimationFrame(() => r()));
135
+ const el = container.querySelector<HTMLElement>(`[data-uid="${uid}"]`);
136
+ if (!el) return;
137
+ el.classList.add(cls.arriving);
138
+ await new Promise<void>(r => requestAnimationFrame(() => r()));
139
+ el.classList.add(cls.arrived);
140
+ const cleanup = (): void => {
141
+ el.classList.remove(cls.arriving, cls.arrived);
142
+ el.removeEventListener("transitionend", cleanup);
143
+ };
144
+ el.addEventListener("transitionend", cleanup, { once: true });
145
+ }
146
+
147
+ /**
148
+ * Animate a batch of newly-inserted elements (matched by uid array)
149
+ * with --arriving → --arrived class lifecycle. Caller has already
150
+ * mutated state and is responsible for waiting nextTick if needed —
151
+ * this helper waits one rAF before applying classes so Vue's commit
152
+ * has landed.
153
+ *
154
+ * Two-pass design: apply all --arriving classes first (so every row
155
+ * is at the from-state simultaneously), then apply all --arrived
156
+ * classes on the next frame (so they all transition together). Avoids
157
+ * a staircase effect where rows animate sequentially.
158
+ *
159
+ * No-op when reduce-motion is on.
160
+ */
161
+ export async function animateEnterBatch(
162
+ uids: readonly (string | null | undefined)[],
163
+ container: HTMLElement,
164
+ opts?: { classes?: Partial<AnimationClasses> },
165
+ ): Promise<void> {
166
+ if (!shouldAnimate()) return;
167
+ const cls = resolveClasses(opts?.classes);
168
+ await new Promise<void>(r => requestAnimationFrame(() => r()));
169
+ const elements: HTMLElement[] = [];
170
+ for (const uid of uids) {
171
+ if (!uid) continue;
172
+ const el = container.querySelector<HTMLElement>(`[data-uid="${uid}"]`);
173
+ if (el) elements.push(el);
174
+ }
175
+ if (elements.length === 0) return;
176
+ for (const el of elements) el.classList.add(cls.arriving);
177
+ await new Promise<void>(r => requestAnimationFrame(() => r()));
178
+ for (const el of elements) {
179
+ el.classList.add(cls.arrived);
180
+ const cleanup = (): void => {
181
+ el.classList.remove(cls.arriving, cls.arrived);
182
+ el.removeEventListener("transitionend", cleanup);
183
+ };
184
+ el.addEventListener("transitionend", cleanup, { once: true });
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Flash a batch of rows with the existing wp-module--flash green ring
190
+ * keyframe (1500ms) — used to indicate library-side mutations (refresh,
191
+ * reset, fork). Staggered so bulk refresh cascades. No-op when reduce-
192
+ * motion is on.
193
+ */
194
+ const FLASH_DURATION_MS = MOTION_PULSE_MS; // 420ms — matches drop pulse timing
195
+ export async function flashRows(
196
+ uids: readonly (string | null | undefined)[],
197
+ container: HTMLElement,
198
+ staggerMs: number = MOTION_STAGGER_MS,
199
+ opts?: { classes?: Partial<AnimationClasses> },
200
+ ): Promise<void> {
201
+ if (!shouldAnimate()) return;
202
+ const cls = resolveClasses(opts?.classes);
203
+ await new Promise<void>(r => requestAnimationFrame(() => r()));
204
+ for (let i = 0; i < uids.length; i++) {
205
+ const uid = uids[i];
206
+ if (!uid) continue;
207
+ const el = container.querySelector<HTMLElement>(`[data-uid="${uid}"]`);
208
+ if (!el) continue;
209
+ const delay = i * staggerMs;
210
+ setTimeout(() => {
211
+ el.classList.add(cls.flash);
212
+ setTimeout(() => el.classList.remove(cls.flash), FLASH_DURATION_MS);
213
+ }, delay);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Animate an element (matched by uid) with --leaving class, wait for the
219
+ * fade to complete, then run mutate to remove from state. Mutate is
220
+ * called immediately when reduce-motion is on (no class added).
221
+ */
222
+ export async function withLeaveAnimation(
223
+ uid: string,
224
+ container: HTMLElement,
225
+ mutate: () => void,
226
+ opts?: { classes?: Partial<AnimationClasses> },
227
+ ): Promise<void> {
228
+ if (!shouldAnimate()) {
229
+ mutate();
230
+ return;
231
+ }
232
+ const cls = resolveClasses(opts?.classes);
233
+ const el = container.querySelector<HTMLElement>(`[data-uid="${uid}"]`);
234
+ if (el) el.classList.add(cls.leaving);
235
+ await new Promise<void>(r => setTimeout(r, MOTION_FADE_MS));
236
+ mutate();
237
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Where a floating panel (popover, menu, tooltip) goes next to its anchor:
3
+ * on the preferred side when it fits, on the other side when it doesn't,
4
+ * and always kept inside the viewport. Pure maths, no DOM, so it is the
5
+ * same on the canvas and in the manager. `Popover`, `ContextMenu` and the
6
+ * tooltip directive all place themselves through it.
7
+ */
8
+
9
+ export type Side = "top" | "bottom" | "left" | "right";
10
+ export type Align = "start" | "center" | "end";
11
+ export type Placement = Side | `${Side}-${Exclude<Align, "center">}`;
12
+
13
+ export interface Rect {
14
+ left: number;
15
+ top: number;
16
+ width: number;
17
+ height: number;
18
+ }
19
+
20
+ export interface Size {
21
+ width: number;
22
+ height: number;
23
+ }
24
+
25
+ export interface PlaceOptions {
26
+ placement?: Placement;
27
+ /** Space between the anchor and the panel. */
28
+ gap?: number;
29
+ /** Space the panel keeps from the viewport edge. */
30
+ margin?: number;
31
+ }
32
+
33
+ export interface Placed {
34
+ x: number;
35
+ y: number;
36
+ /** The side it ended up on, after any flip. */
37
+ side: Side;
38
+ }
39
+
40
+ const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
41
+
42
+ export function splitPlacement(p: Placement): { side: Side; align: Align } {
43
+ const [side, align] = p.split("-") as [Side, Exclude<Align, "center"> | undefined];
44
+ return { side, align: align ?? "center" };
45
+ }
46
+
47
+ function room(side: Side, a: Rect, view: Size, gap: number, margin: number): number {
48
+ switch (side) {
49
+ case "top": return a.top - gap - margin;
50
+ case "bottom": return view.height - (a.top + a.height) - gap - margin;
51
+ case "left": return a.left - gap - margin;
52
+ case "right": return view.width - (a.left + a.width) - gap - margin;
53
+ }
54
+ }
55
+
56
+ function clamp(v: number, min: number, max: number): number {
57
+ return max < min ? min : Math.min(Math.max(v, min), max);
58
+ }
59
+
60
+ export function placeFloating(anchor: Rect, panel: Size, view: Size, opts: PlaceOptions = {}): Placed {
61
+ const gap = opts.gap ?? 6;
62
+ const margin = opts.margin ?? 8;
63
+ const { side: want, align } = splitPlacement(opts.placement ?? "bottom-start");
64
+ const vertical = want === "top" || want === "bottom";
65
+ const need = vertical ? panel.height : panel.width;
66
+
67
+ // Flip only when the other side has more room; a panel too big for both
68
+ // stays on the side with more space and gets clamped.
69
+ let side = want;
70
+ if (room(want, anchor, view, gap, margin) < need) {
71
+ const other = OPPOSITE[want];
72
+ if (room(other, anchor, view, gap, margin) > room(want, anchor, view, gap, margin)) side = other;
73
+ }
74
+
75
+ let x: number;
76
+ let y: number;
77
+ if (vertical) {
78
+ y = side === "bottom" ? anchor.top + anchor.height + gap : anchor.top - gap - panel.height;
79
+ x = align === "start" ? anchor.left
80
+ : align === "end" ? anchor.left + anchor.width - panel.width
81
+ : anchor.left + (anchor.width - panel.width) / 2;
82
+ } else {
83
+ x = side === "right" ? anchor.left + anchor.width + gap : anchor.left - gap - panel.width;
84
+ y = align === "start" ? anchor.top
85
+ : align === "end" ? anchor.top + anchor.height - panel.height
86
+ : anchor.top + (anchor.height - panel.height) / 2;
87
+ }
88
+
89
+ return {
90
+ x: Math.round(clamp(x, margin, view.width - margin - panel.width)),
91
+ y: Math.round(clamp(y, margin, view.height - margin - panel.height)),
92
+ side,
93
+ };
94
+ }
95
+
96
+ /** The viewport, for callers that have a window. */
97
+ export function viewportSize(): Size {
98
+ return { width: window.innerWidth, height: window.innerHeight };
99
+ }
package/src/hsv.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * HSV / RGB / hex color conversion utilities.
3
+ *
4
+ * `h` in `[0, 360)`, `s` and `v` in `[0, 1]`. Hex strings are `#rrggbb`
5
+ * lowercase. Functions are tolerant of out-of-range input — they clamp
6
+ * rather than throw, which keeps the picker UI robust under fast drags.
7
+ *
8
+ * Lives in shared/ so both the manager-side ColorPicker / TweaksPanel
9
+ * AND the canvas-side bundle modal can drive the same HsvPicker
10
+ * without a cross-package import.
11
+ */
12
+
13
+ export interface Hsv {
14
+ h: number;
15
+ s: number;
16
+ v: number;
17
+ }
18
+
19
+ export interface Rgb {
20
+ r: number;
21
+ g: number;
22
+ b: number;
23
+ }
24
+
25
+ const HEX_PATTERN = /^#?([0-9a-fA-F]{6})$/;
26
+
27
+ function clamp(n: number, lo: number, hi: number): number {
28
+ if (Number.isNaN(n)) return lo;
29
+ return Math.min(hi, Math.max(lo, n));
30
+ }
31
+
32
+ export function hsvToRgb(hsv: Hsv): Rgb {
33
+ const H = (((hsv.h % 360) + 360) % 360);
34
+ const S = clamp(hsv.s, 0, 1);
35
+ const V = clamp(hsv.v, 0, 1);
36
+
37
+ const C = V * S;
38
+ const X = C * (1 - Math.abs(((H / 60) % 2) - 1));
39
+ const M = V - C;
40
+ let r1 = 0;
41
+ let g1 = 0;
42
+ let b1 = 0;
43
+ if (H < 60) { r1 = C; g1 = X; b1 = 0; }
44
+ else if (H < 120) { r1 = X; g1 = C; b1 = 0; }
45
+ else if (H < 180) { r1 = 0; g1 = C; b1 = X; }
46
+ else if (H < 240) { r1 = 0; g1 = X; b1 = C; }
47
+ else if (H < 300) { r1 = X; g1 = 0; b1 = C; }
48
+ else { r1 = C; g1 = 0; b1 = X; }
49
+ return {
50
+ r: Math.round((r1 + M) * 255),
51
+ g: Math.round((g1 + M) * 255),
52
+ b: Math.round((b1 + M) * 255),
53
+ };
54
+ }
55
+
56
+ export function rgbToHsv(rgb: Rgb): Hsv {
57
+ const R = clamp(rgb.r, 0, 255) / 255;
58
+ const G = clamp(rgb.g, 0, 255) / 255;
59
+ const B = clamp(rgb.b, 0, 255) / 255;
60
+ const max = Math.max(R, G, B);
61
+ const min = Math.min(R, G, B);
62
+ const delta = max - min;
63
+
64
+ let h = 0;
65
+ if (delta > 0) {
66
+ if (max === R) h = ((G - B) / delta) % 6;
67
+ else if (max === G) h = (B - R) / delta + 2;
68
+ else h = (R - G) / delta + 4;
69
+ h *= 60;
70
+ if (h < 0) h += 360;
71
+ }
72
+ const s = max === 0 ? 0 : delta / max;
73
+ const v = max;
74
+ return { h, s, v };
75
+ }
76
+
77
+ export function rgbToHex(rgb: Rgb): string {
78
+ const part = (n: number): string => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
79
+ return `#${part(rgb.r)}${part(rgb.g)}${part(rgb.b)}`;
80
+ }
81
+
82
+ export function hexToRgb(hex: string): Rgb | null {
83
+ const matches = hex.trim().match(HEX_PATTERN);
84
+ if (!matches) return null;
85
+ const v = matches[1];
86
+ return {
87
+ r: parseInt(v.slice(0, 2), 16),
88
+ g: parseInt(v.slice(2, 4), 16),
89
+ b: parseInt(v.slice(4, 6), 16),
90
+ };
91
+ }
92
+
93
+ export function hexToHsv(hex: string): Hsv | null {
94
+ const rgb = hexToRgb(hex);
95
+ return rgb ? rgbToHsv(rgb) : null;
96
+ }
97
+
98
+ export function hsvToHex(hsv: Hsv): string {
99
+ return rgbToHex(hsvToRgb(hsv));
100
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Curated PrimeIcons glyphs offered by `IconPicker.vue`.
3
+ *
4
+ * PrimeIcons ships ~314 glyphs; this is a subset. The full set would need its
5
+ * own search UI, and most of it (chevrons, spinners, media transport, brand
6
+ * logos) means nothing as a library label. The groups below follow the kinds
7
+ * of things people actually categorise prompt modules by.
8
+ *
9
+ * Names are PrimeIcons ids MINUS the `pi-` prefix (`"user"`, not `"pi-user"`)
10
+ * — that is what the engine's `icon` column holds and what `Icon.vue` expects.
11
+ *
12
+ * In a plain module rather than inside the SFC so `icon-catalog.test.ts` can
13
+ * check every name against the installed `primeicons.css` without mounting a
14
+ * component. That test is the point: a name with no matching glyph renders as
15
+ * an EMPTY CELL, which looks like a layout bug rather than a typo, and `brush`
16
+ * shipped that way because nothing verified the list.
17
+ */
18
+ export interface IconGroup {
19
+ label: string;
20
+ icons: string[];
21
+ }
22
+
23
+ export const ICON_GROUPS: IconGroup[] = [
24
+ {
25
+ label: "Subject",
26
+ icons: ["user", "users", "heart", "star", "eye", "face-smile", "crown", "id-card"],
27
+ },
28
+ {
29
+ // `sliders-h` stands in for the art-supply glyph PrimeIcons doesn't have —
30
+ // there is no brush/paint icon in the set, and `palette` already carries
31
+ // "art style". Sliders read as "tune the look".
32
+ label: "Style & look",
33
+ icons: ["palette", "pencil", "sliders-h", "camera", "image", "sparkles", "sun", "moon"],
34
+ },
35
+ {
36
+ label: "Scene",
37
+ icons: ["home", "building", "map", "map-marker", "globe", "compass", "car", "cloud"],
38
+ },
39
+ {
40
+ label: "Structure",
41
+ icons: ["box", "folder", "book", "tag", "tags", "list", "sitemap", "objects-column"],
42
+ },
43
+ {
44
+ label: "Signals",
45
+ icons: ["bolt", "flag", "bell", "shield", "lock", "wrench", "filter", "info-circle"],
46
+ },
47
+ ];
48
+
49
+ /** Every offered glyph, flattened — for tests and any future search box. */
50
+ export function allCuratedIcons(): string[] {
51
+ return ICON_GROUPS.flatMap((g) => g.icons);
52
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The maths behind `WpImageZoom`: one transform, `translate(x, y) scale(s)`
3
+ * with its origin at the image's top-left corner. Pure, so it is tested on
4
+ * its own.
5
+ */
6
+
7
+ export interface Size { width: number; height: number }
8
+ export interface View { scale: number; x: number; y: number }
9
+
10
+ /** Zoom never goes below this share of Fit (or of 100% for a tiny image), nor above MAX_SCALE. */
11
+ export const MAX_SCALE = 8;
12
+ /** The steps the − / + buttons jump through, as real-size factors. */
13
+ export const ZOOM_STEPS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8] as const;
14
+ /** How long `WpImageZoom`'s bar stays after the last pointer move or key, with `autohide`. */
15
+ export const IDLE_MS = 2000;
16
+ /** One wheel notch. */
17
+ export const WHEEL_FACTOR = 1.2;
18
+
19
+ /** The scale that shows the whole image in the stage (it may be above 1 for a small image). */
20
+ export function fitScale(image: Size, stage: Size): number {
21
+ if (image.width <= 0 || image.height <= 0 || stage.width <= 0 || stage.height <= 0) return 1;
22
+ return Math.min(stage.width / image.width, stage.height / image.height);
23
+ }
24
+
25
+ export function minScale(image: Size, stage: Size): number {
26
+ return Math.min(fitScale(image, stage), 1) / 2;
27
+ }
28
+
29
+ export function clampScale(scale: number, image: Size, stage: Size): number {
30
+ return Math.min(MAX_SCALE, Math.max(minScale(image, stage), scale));
31
+ }
32
+
33
+ /** Keep the image in view: centred on an axis where it is smaller than the
34
+ * stage, otherwise never showing an empty edge. */
35
+ export function clampView(view: View, image: Size, stage: Size): View {
36
+ const axis = (pos: number, img: number, room: number): number => {
37
+ const size = img * view.scale;
38
+ if (size <= room) return (room - size) / 2;
39
+ return Math.min(0, Math.max(room - size, pos));
40
+ };
41
+ return { scale: view.scale, x: axis(view.x, image.width, stage.width), y: axis(view.y, image.height, stage.height) };
42
+ }
43
+
44
+ /** Set the scale, keeping the image point under `at` (stage px) where it is. */
45
+ export function zoomAt(view: View, scale: number, at: { x: number; y: number }, image: Size, stage: Size): View {
46
+ const s = clampScale(scale, image, stage);
47
+ const k = s / view.scale;
48
+ return clampView({ scale: s, x: at.x - (at.x - view.x) * k, y: at.y - (at.y - view.y) * k }, image, stage);
49
+ }
50
+
51
+ /** The view at `scale` centred on the image point `centre` (fractions 0..1). */
52
+ export function viewAround(scale: number, centre: { x: number; y: number }, image: Size, stage: Size): View {
53
+ const s = clampScale(scale, image, stage);
54
+ return clampView({
55
+ scale: s,
56
+ x: stage.width / 2 - centre.x * image.width * s,
57
+ y: stage.height / 2 - centre.y * image.height * s,
58
+ }, image, stage);
59
+ }
60
+
61
+ /** The image point (fractions 0..1) at the middle of the stage. */
62
+ export function centreOf(view: View, image: Size, stage: Size): { x: number; y: number } {
63
+ if (image.width <= 0 || image.height <= 0) return { x: 0.5, y: 0.5 };
64
+ const f = (mid: number, pos: number, img: number): number => Math.min(1, Math.max(0, (mid - pos) / (img * view.scale)));
65
+ return { x: f(stage.width / 2, view.x, image.width), y: f(stage.height / 2, view.y, image.height) };
66
+ }
67
+
68
+ /** The next step up (dir 1) or down (dir -1) from `scale`, with Fit slotted in among the steps. */
69
+ export function stepScale(scale: number, dir: 1 | -1, fit: number): number {
70
+ const steps = [...ZOOM_STEPS, fit].sort((a, b) => a - b);
71
+ const eps = 1e-3;
72
+ if (dir > 0) return steps.find((s) => s > scale + eps) ?? MAX_SCALE;
73
+ return [...steps].reverse().find((s) => s < scale - eps) ?? steps[0];
74
+ }
75
+
76
+ /** "100%", "35%", "250%". */
77
+ export function percent(scale: number): string {
78
+ return `${Math.round(scale * 100)}%`;
79
+ }
80
+
81
+ /** The part of the image the stage shows, as fractions 0..1 of the image (for a mini-map). */
82
+ export function visibleRect(view: View, image: Size, stage: Size): { x: number; y: number; w: number; h: number } {
83
+ if (image.width <= 0 || image.height <= 0) return { x: 0, y: 0, w: 1, h: 1 };
84
+ const axis = (pos: number, img: number, room: number): [number, number] => {
85
+ const size = img * view.scale;
86
+ const a = Math.min(1, Math.max(0, -pos / size));
87
+ const b = Math.min(1, Math.max(0, (room - pos) / size));
88
+ return [a, b - a];
89
+ };
90
+ const [x, w] = axis(view.x, image.width, stage.width);
91
+ const [y, h] = axis(view.y, image.height, stage.height);
92
+ return { x, y, w, h };
93
+ }
@@ -0,0 +1,21 @@
1
+ /** The Wildcard Pipeline logo, split into its parts so a drawing can move them
2
+ * on their own (viewBox 0 0 1024 1024). Same shapes as `wp-logo.svg`. */
3
+ export const RING_A =
4
+ "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";
5
+ export const RING_B =
6
+ "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";
7
+ export const STAR_BIG =
8
+ "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";
9
+ export const STAR_A =
10
+ "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";
11
+ export const STAR_B =
12
+ "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";
13
+
14
+ /** The logo's colours: [big star and first ring start, middle, small star and
15
+ * second ring end]. Brand colours, not theme tokens: the mark looks the same
16
+ * in light and dark. `community` is the community site's rust counterpart. */
17
+ export const BRAND_COLOURS = {
18
+ extension: ["#a970ff", "#6366f1", "#33d6c6"],
19
+ community: ["#da7550", "#f1636c", "#ad33d6"],
20
+ } as const;
21
+ export type LogoBrand = keyof typeof BRAND_COLOURS;