@dumiflex/wp-kit 0.6.0 → 0.7.1

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.
@@ -20,14 +20,16 @@
20
20
  * never narrower than the word: it widens at once and narrows smoothly once
21
21
  * the old word has mostly faded.
22
22
  *
23
- * Every mark animates registered custom properties, never `transform` or
24
- * `opacity` themselves, so the paths repaint sharp each frame instead of
25
- * going to a compositor layer that blurs them and snaps them to whole pixels.
23
+ * The marks move on a canvas over the still SVG, so they stay sharp and
24
+ * glide in every browser (`canvas-motion.ts` says why); the SVG is the first
25
+ * paint and stays under reduced motion, where it only breathes.
26
26
  */
27
27
  import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
28
28
  import { BRAND_COLOURS, STAR_A, STAR_B, STAR_BIG, type LogoBrand } from "./logo-paths";
29
+ import { canvasBox, useCanvasMotion } from "./canvas-motion";
30
+ import { TEMPO_MS, TYPING_X, drawWorking, orbitNodes, type WorkingVariant } from "./working-line-motion";
29
31
 
30
- export type WorkingVariant = "typing" | "shuffle" | "orbit" | "burst";
32
+ export type { WorkingVariant } from "./working-line-motion";
31
33
 
32
34
  const props = withDefaults(defineProps<{
33
35
  /** What is shown, without the "…" (the line adds it). */
@@ -55,14 +57,14 @@ const BIG = computed(() => colours.value[0]);
55
57
  const A = computed(() => colours.value[1]);
56
58
  const B = computed(() => colours.value[2]);
57
59
  /** The logo's four nodes for the orbit, with steps between the colours. */
58
- const nodes = computed(() => [
59
- [911, 512, B.value],
60
- [512, 911, `color-mix(in srgb, ${B.value} 70%, ${A.value})`],
61
- [113, 512, `color-mix(in srgb, ${BIG.value} 60%, ${A.value})`],
62
- [512, 113, A.value],
63
- ] as const);
60
+ const nodes = computed(() => orbitNodes(colours.value));
64
61
  /** Typing stars: the big sparkle drawn small three times in a row. */
65
- const typing = computed(() => [[250, A.value], [512, BIG.value], [774, B.value]] as const);
62
+ const typing = computed(() => TYPING_X.map((x, i) => [x, [A.value, BIG.value, B.value][i] ?? BIG.value] as const));
63
+
64
+ const canvas = ref<HTMLCanvasElement | null>(null);
65
+ const canvasStyle = computed(() => canvasBox(props.size));
66
+ const { live, drawn } = useCanvasMotion(canvas, (ctx, width, ms) =>
67
+ drawWorking(ctx, width, ms, props.variant, TEMPO_MS[props.tempo], colours.value));
66
68
 
67
69
  const measure = ref<HTMLElement | null>(null);
68
70
  const boxWidth = ref<number | null>(null);
@@ -98,41 +100,39 @@ onBeforeUnmount(() => resized?.disconnect());
98
100
  :data-tempo="tempo"
99
101
  :data-variant="variant"
100
102
  :data-inline="inline ? 'true' : undefined"
103
+ :data-drawn="drawn ? 'true' : undefined"
101
104
  role="status"
102
105
  :aria-label="name"
103
106
  :title="name"
104
107
  :style="{ '--wp-wl-big': BIG, '--wp-wl-a': A, '--wp-wl-b': B }"
105
108
  >
109
+ <span class="wp-wl__art" :style="{ width: `${size}px`, height: `${size}px` }">
106
110
  <svg class="wp-wl__logo" viewBox="0 0 1024 1024" :width="size" :height="size" aria-hidden="true">
107
111
  <template v-if="variant === 'typing'">
108
- <g v-for="([x, c], i) in typing" :key="i" class="wp-wl__hop" :style="{ '--wp-wl-k': i }">
109
- <path class="wp-wl__star" :style="{ fill: c }" :transform="`translate(${x - 256} 256) scale(.5)`" :d="STAR_BIG" />
110
- </g>
112
+ <path
113
+ v-for="([x, c], i) in typing" :key="i" :style="{ fill: c }"
114
+ :transform="`translate(${x - 256} 256) scale(.5)`" :d="STAR_BIG"
115
+ />
111
116
  </template>
112
117
  <g v-else-if="variant === 'shuffle'" transform="translate(512 512) scale(1.45) translate(-512 -512)">
113
- <path class="wp-wl__star wp-wl__star--big wp-wl__dip" :style="{ fill: BIG }" :d="STAR_BIG" />
114
- <g class="wp-wl__vb wp-wl__swap">
115
- <path class="wp-wl__star" :style="{ fill: A }" :d="STAR_A" />
116
- <path class="wp-wl__star" :style="{ fill: B }" :d="STAR_B" />
117
- </g>
118
+ <path :style="{ fill: BIG }" :d="STAR_BIG" />
119
+ <path :style="{ fill: A }" :d="STAR_A" />
120
+ <path :style="{ fill: B }" :d="STAR_B" />
118
121
  </g>
119
122
  <template v-else-if="variant === 'orbit'">
120
- <g class="wp-wl__vb wp-wl__spin">
121
- <circle
122
- v-for="([x, y, c], i) in nodes" :key="i" class="wp-wl__dot"
123
- :style="{ '--wp-wl-k': i, fill: c }" :cx="x" :cy="y" r="70"
124
- />
125
- </g>
126
- <path class="wp-wl__star wp-wl__star--big wp-wl__breathe" :style="{ fill: BIG }" :d="STAR_BIG" />
127
- <path class="wp-wl__star" :style="{ fill: A }" :d="STAR_A" />
128
- <path class="wp-wl__star" :style="{ fill: B }" :d="STAR_B" />
123
+ <circle v-for="([x, y, c], i) in nodes" :key="i" :style="{ fill: c }" :cx="x" :cy="y" r="70" />
124
+ <path :style="{ fill: BIG }" :d="STAR_BIG" />
125
+ <path :style="{ fill: A }" :d="STAR_A" />
126
+ <path :style="{ fill: B }" :d="STAR_B" />
129
127
  </template>
130
128
  <g v-else transform="translate(512 512) scale(1.3) translate(-512 -512)">
131
- <path class="wp-wl__star wp-wl__star--big wp-wl__pop" :style="{ fill: BIG }" :d="STAR_BIG" />
132
- <path class="wp-wl__star wp-wl__burst" :style="{ fill: A, '--wp-wl-dx': '152px', '--wp-wl-dy': '-152px' }" :d="STAR_A" />
133
- <path class="wp-wl__star wp-wl__burst" :style="{ fill: B, '--wp-wl-dx': '-152px', '--wp-wl-dy': '152px' }" :d="STAR_B" />
129
+ <path :style="{ fill: BIG }" :d="STAR_BIG" />
130
+ <path :style="{ fill: A }" :d="STAR_A" />
131
+ <path :style="{ fill: B }" :d="STAR_B" />
134
132
  </g>
135
133
  </svg>
134
+ <canvas v-if="live" ref="canvas" class="wp-wl__canvas" :style="canvasStyle" aria-hidden="true" />
135
+ </span>
136
136
  <span
137
137
  class="wp-wl__words"
138
138
  :data-grow="growing ? 'true' : undefined"
@@ -149,7 +149,6 @@ onBeforeUnmount(() => resized?.disconnect());
149
149
 
150
150
  <style scoped>
151
151
  .wp-wl {
152
- --wp-wl-t: 2.8s;
153
152
  display: flex;
154
153
  align-items: center;
155
154
  gap: 8px;
@@ -158,9 +157,7 @@ onBeforeUnmount(() => resized?.disconnect());
158
157
  font-size: 13px;
159
158
  white-space: nowrap;
160
159
  }
161
- .wp-wl[data-tempo="mid"] { --wp-wl-t: 2.2s; }
162
- .wp-wl[data-tempo="fast"] { --wp-wl-t: 1.5s; }
163
- .wp-wl__logo { flex-shrink: 0; overflow: visible; }
160
+ .wp-wl__logo { overflow: visible; }
164
161
  .wp-wl__words {
165
162
  position: relative;
166
163
  display: inline-flex;
@@ -220,100 +217,17 @@ onBeforeUnmount(() => resized?.disconnect());
220
217
  .wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wb: 0px; }
221
218
  }
222
219
 
223
- /* Every mark animates registered custom properties, never `transform` or
224
- * `opacity` themselves. A `transform` / `opacity` animation goes to the
225
- * compositor, which lifts each path onto its own layer as it starts:
226
- * rasterised once at a fixed scale and snapped to whole pixels, so the sharp
227
- * mark went soft and jumped half a pixel the moment it began to move. A
228
- * custom property animates on the main thread and each frame repaints the
229
- * vector paths in place, as sharp as at rest. */
230
- @property --wp-wl-x { syntax: "<length>"; inherits: false; initial-value: 0px; }
231
- @property --wp-wl-f { syntax: "<number>"; inherits: false; initial-value: 0; }
232
- @property --wp-wl-r { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
233
- @property --wp-wl-s { syntax: "<number>"; inherits: false; initial-value: 1; }
220
+ /* The marks move on the canvas; the SVG is the still mark under it. */
221
+ .wp-wl__art { position: relative; display: inline-block; flex-shrink: 0; line-height: 0; }
222
+ .wp-wl__logo { display: block; }
223
+ .wp-wl__canvas { position: absolute; pointer-events: none; }
224
+ .wp-wl[data-drawn="true"] .wp-wl__logo { visibility: hidden; }
225
+
234
226
  @property --wp-wl-o { syntax: "<number>"; inherits: false; initial-value: 1; }
235
227
  @property --wp-wl-wo { syntax: "<number>"; inherits: false; initial-value: 1; }
236
228
  @property --wp-wl-wb { syntax: "<length>"; inherits: false; initial-value: 0px; }
237
229
 
238
- .wp-wl__star, .wp-wl__dot, .wp-wl__hop { transform-box: fill-box; transform-origin: center; }
239
- .wp-wl__vb { transform-box: view-box; transform-origin: 512px 512px; }
240
-
241
- /* Typing stars: each hops in turn. */
242
- .wp-wl__hop {
243
- transform: translateY(var(--wp-wl-x)) scale(var(--wp-wl-s));
244
- animation: wp-wl-hop var(--wp-wl-t) ease-in-out infinite;
245
- animation-delay: calc(var(--wp-wl-t) * var(--wp-wl-k) * 0.15);
246
- }
247
- @keyframes wp-wl-hop {
248
- 0%, 40%, 100% { --wp-wl-x: 0px; --wp-wl-s: 1; }
249
- 20% { --wp-wl-x: -110px; --wp-wl-s: 1.12; }
250
- }
251
-
252
- /* Shuffle: the small stars swap places, the big one dips and turns. */
253
- .wp-wl__swap {
254
- transform: rotate(var(--wp-wl-r));
255
- animation: wp-wl-swap calc(var(--wp-wl-t) * 2) cubic-bezier(0.65, 0, 0.35, 1) infinite;
256
- }
257
- .wp-wl__dip {
258
- transform: rotate(var(--wp-wl-r)) scale(var(--wp-wl-s));
259
- animation: wp-wl-dip var(--wp-wl-t) cubic-bezier(0.65, 0, 0.35, 1) infinite;
260
- }
261
- @keyframes wp-wl-swap {
262
- 0%, 15% { --wp-wl-r: 0deg; }
263
- 50%, 65% { --wp-wl-r: 180deg; }
264
- 100% { --wp-wl-r: 360deg; }
265
- }
266
- @keyframes wp-wl-dip {
267
- 0%, 15% { --wp-wl-s: 1; --wp-wl-r: 0deg; }
268
- 32% { --wp-wl-s: 0.7; --wp-wl-r: 45deg; }
269
- 50%, 100% { --wp-wl-s: 1; --wp-wl-r: 90deg; }
270
- }
271
-
272
- /* Orbit: the four nodes circle the stars and twinkle in turn. */
273
- .wp-wl__spin {
274
- transform: rotate(var(--wp-wl-r));
275
- animation: wp-wl-spin calc(var(--wp-wl-t) * 2) cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
276
- }
277
- .wp-wl__dot {
278
- opacity: var(--wp-wl-o);
279
- transform: scale(var(--wp-wl-s));
280
- animation: wp-wl-twinkle var(--wp-wl-t) ease-in-out infinite;
281
- animation-delay: calc(var(--wp-wl-t) * var(--wp-wl-k) * -0.25);
282
- }
283
- .wp-wl__breathe {
284
- transform: scale(var(--wp-wl-s));
285
- animation: wp-wl-breathe var(--wp-wl-t) ease-in-out infinite;
286
- }
287
- @keyframes wp-wl-spin { from { --wp-wl-r: 0deg; } to { --wp-wl-r: 360deg; } }
288
- @keyframes wp-wl-twinkle {
289
- 0%, 100% { --wp-wl-o: 1; --wp-wl-s: 1; }
290
- 50% { --wp-wl-o: 0.35; --wp-wl-s: 0.6; }
291
- }
292
- @keyframes wp-wl-breathe { 0%, 100% { --wp-wl-s: 1; } 50% { --wp-wl-s: 0.86; } }
293
-
294
- /* Burst: the small stars fly out of the big one and back. `--wp-wl-f` is
295
- * how far out they are (1 = at --wp-wl-dx/dy, 0 = home). */
296
- .wp-wl__burst {
297
- opacity: var(--wp-wl-o);
298
- transform:
299
- translate(calc(var(--wp-wl-dx) * var(--wp-wl-f)), calc(var(--wp-wl-dy) * var(--wp-wl-f)))
300
- scale(var(--wp-wl-s));
301
- animation: wp-wl-burst var(--wp-wl-t) cubic-bezier(0.3, 0, 0.2, 1) infinite;
302
- }
303
- .wp-wl__pop {
304
- transform: scale(var(--wp-wl-s));
305
- animation: wp-wl-pop var(--wp-wl-t) ease-in-out infinite;
306
- }
307
- @keyframes wp-wl-burst {
308
- 0% { --wp-wl-f: 1; --wp-wl-s: 0; --wp-wl-o: 0; }
309
- 15% { --wp-wl-o: 1; }
310
- 45%, 75% { --wp-wl-f: 0; --wp-wl-s: 1.15; --wp-wl-o: 1; }
311
- 100% { --wp-wl-f: 1; --wp-wl-s: 0; --wp-wl-o: 0; }
312
- }
313
- @keyframes wp-wl-pop { 0%, 100% { --wp-wl-s: 1.1; } 40% { --wp-wl-s: 0.8; } }
314
-
315
230
  @media (prefers-reduced-motion: reduce) {
316
- .wp-wl__logo * { animation: none !important; }
317
231
  .wp-wl__logo { opacity: var(--wp-wl-o); animation: wp-wl-fade 2.4s ease-in-out infinite; }
318
232
  @keyframes wp-wl-fade { 0%, 100% { --wp-wl-o: 1; } 50% { --wp-wl-o: 0.55; } }
319
233
  }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Looping marks drawn on a canvas (LoadingMark, WorkingLine).
3
+ *
4
+ * A keyframed `transform` / `opacity` on an SVG path goes to the compositor,
5
+ * which blurs the path and snaps it to whole pixels as it starts. Animating
6
+ * registered custom properties fixed that in Chromium, but Firefox still lays
7
+ * each frame of an SVG transform on the pixel grid, so small marks stepped
8
+ * and twitched as they moved. A canvas puts every shape at its exact
9
+ * fractional position each frame, sharp and smooth in both.
10
+ *
11
+ * One `requestAnimationFrame` loop draws every mark on the page. Each mark
12
+ * keeps its own clock, so a host can slow or pause the marks inside it with
13
+ * `data-wp-motion-speed` (a number) and `data-wp-motion-paused` on an
14
+ * ancestor, as the kit site's Animations section does.
15
+ *
16
+ * A mark keeps its still SVG for the first paint, under reduced motion and
17
+ * in a test DOM; `useCanvasMotion` puts the canvas over it once it can draw.
18
+ */
19
+ import { nextTick, onBeforeUnmount, onMounted, ref, type Ref } from "vue";
20
+
21
+ /** Room around the 1024 box for parts that move past its edge, and for
22
+ * anti-aliasing there. */
23
+ export const PAD = 82;
24
+ export const BOX = 1024 + 2 * PAD;
25
+
26
+ /** CSS `cubic-bezier(x1, y1, x2, y2)` as a function of progress. */
27
+ export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (x: number) => number {
28
+ return (x) => {
29
+ if (x <= 0) return 0;
30
+ if (x >= 1) return 1;
31
+ let lo = 0;
32
+ let hi = 1;
33
+ let t = x;
34
+ for (let k = 0; k < 30; k++) {
35
+ t = (lo + hi) / 2;
36
+ const cx = 3 * x1 * t * (1 - t) ** 2 + 3 * x2 * t * t * (1 - t) + t ** 3;
37
+ if (cx < x) lo = t;
38
+ else hi = t;
39
+ }
40
+ return 3 * y1 * t * (1 - t) ** 2 + 3 * y2 * t * t * (1 - t) + t ** 3;
41
+ };
42
+ }
43
+
44
+ export const EASE = cubicBezier(0.25, 0.1, 0.25, 1);
45
+ export const EASE_IN_OUT = cubicBezier(0.42, 0, 0.58, 1);
46
+ export const SMOOTH = cubicBezier(0.65, 0, 0.35, 1);
47
+
48
+ export type Stop = readonly [at: number, values: readonly number[]];
49
+
50
+ /** Value of a keyframe track at progress `p` (0..1), eased per segment the
51
+ * way CSS eases between two keyframes. */
52
+ export function track(stops: readonly Stop[], p: number, ease: (x: number) => number): number[] {
53
+ for (let k = 0; k < stops.length - 1; k++) {
54
+ const a = stops[k];
55
+ const b = stops[k + 1];
56
+ if (!a || !b) break;
57
+ if (p >= a[0] && p <= b[0]) {
58
+ if (b[0] === a[0]) return [...b[1]];
59
+ const e = ease((p - a[0]) / (b[0] - a[0]));
60
+ return a[1].map((v, j) => v + ((b[1][j] ?? v) - v) * e);
61
+ }
62
+ }
63
+ return [...(stops[stops.length - 1]?.[1] ?? [])];
64
+ }
65
+
66
+ /** Progress (0..1) through a loop of `period` ms, `ms` into the animation.
67
+ * A positive `delay` holds the start pose until it has passed, like CSS. */
68
+ export function loop(ms: number, period: number, delay = 0): number {
69
+ const t = ms - delay;
70
+ if (t < 0) return 0;
71
+ return (t % period) / period;
72
+ }
73
+
74
+ /** Mixes two `#rrggbb` colours in sRGB, like `color-mix(in srgb, a w%, b)`. */
75
+ export function mix(a: string, b: string, weightA: number): string {
76
+ const ch = (hex: string, i: number): number => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
77
+ const out = [0, 1, 2].map((i) => Math.round(ch(a, i) * weightA + ch(b, i) * (1 - weightA)));
78
+ return `#${out.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
79
+ }
80
+
81
+ /** Fills `path` scaled and turned around `[x, y]`. */
82
+ export function fillAround(
83
+ ctx: CanvasRenderingContext2D,
84
+ [x, y]: readonly [number, number],
85
+ scale: number,
86
+ turn: number,
87
+ path: Path2D,
88
+ fill: string | CanvasGradient,
89
+ alpha = 1,
90
+ ): void {
91
+ ctx.save();
92
+ ctx.translate(x, y);
93
+ ctx.rotate((turn * Math.PI) / 180);
94
+ ctx.scale(scale, scale);
95
+ ctx.translate(-x, -y);
96
+ ctx.globalAlpha = alpha;
97
+ ctx.fillStyle = fill;
98
+ ctx.fill(path);
99
+ ctx.restore();
100
+ }
101
+
102
+ /** Clears the canvas and sets it up to draw in the 1024 box. */
103
+ export function begin(ctx: CanvasRenderingContext2D, width: number): void {
104
+ const k = width / BOX;
105
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
106
+ ctx.clearRect(0, 0, width, width);
107
+ ctx.setTransform(k, 0, 0, k, PAD * k, PAD * k);
108
+ }
109
+
110
+ /** Draws one frame `ms` into the animation on a square canvas `width` px wide. */
111
+ export type Draw = (ctx: CanvasRenderingContext2D, width: number, ms: number) => void;
112
+
113
+ /** A mark the loop draws. */
114
+ export interface Animated {
115
+ canvas: HTMLCanvasElement;
116
+ draw: Draw;
117
+ /** ms of animation shown so far, advanced by the loop. */
118
+ clock: number;
119
+ /** False while off screen: the clock still runs, nothing is drawn. */
120
+ visible: boolean;
121
+ }
122
+
123
+ const marks = new Set<Animated>();
124
+ let frame = 0;
125
+ let last = 0;
126
+
127
+ /** Speed and pause from the nearest host that sets them. */
128
+ export function motionOf(el: Element): { speed: number; paused: boolean } {
129
+ const paused = el.closest("[data-wp-motion-paused]") !== null;
130
+ const speedHost = el.closest("[data-wp-motion-speed]");
131
+ const speed = Number(speedHost?.getAttribute("data-wp-motion-speed") ?? "1");
132
+ return { speed: Number.isFinite(speed) && speed >= 0 ? speed : 1, paused };
133
+ }
134
+
135
+ function fit(c: HTMLCanvasElement): void {
136
+ const want = Math.max(1, Math.round(c.clientWidth * (window.devicePixelRatio || 1)));
137
+ if (c.width !== want) {
138
+ c.width = want;
139
+ c.height = want;
140
+ }
141
+ }
142
+
143
+ function paint(m: Animated): boolean {
144
+ const ctx = m.canvas.getContext("2d");
145
+ if (!ctx) return false;
146
+ fit(m.canvas);
147
+ m.draw(ctx, m.canvas.width, m.clock);
148
+ return true;
149
+ }
150
+
151
+ function tick(now: number): void {
152
+ const dt = last ? Math.min(now - last, 100) : 0;
153
+ last = now;
154
+ for (const m of marks) {
155
+ const { speed, paused } = motionOf(m.canvas);
156
+ if (!paused) m.clock += dt * speed;
157
+ if (m.visible) paint(m);
158
+ }
159
+ frame = marks.size ? requestAnimationFrame(tick) : 0;
160
+ }
161
+
162
+ /** Starts drawing a mark; returns the function that stops it. */
163
+ export function animate(mark: Animated): () => void {
164
+ marks.add(mark);
165
+ if (!frame) {
166
+ last = 0;
167
+ frame = requestAnimationFrame(tick);
168
+ }
169
+ return () => {
170
+ marks.delete(mark);
171
+ if (!marks.size && frame) {
172
+ cancelAnimationFrame(frame);
173
+ frame = 0;
174
+ }
175
+ };
176
+ }
177
+
178
+ /** Draws one frame right away (before the loop runs, so the mark never
179
+ * shows blank). False when there is no 2D canvas. */
180
+ export function drawNow(mark: Animated): boolean {
181
+ return paint(mark);
182
+ }
183
+
184
+ /** Inline style for a canvas over a `px`-wide mark, reaching PAD past it. */
185
+ export function canvasBox(px: number): Record<string, string> {
186
+ const side = (px * BOX) / 1024;
187
+ const off = (px * PAD) / 1024;
188
+ return { width: `${side}px`, height: `${side}px`, top: `${-off}px`, left: `${-off}px` };
189
+ }
190
+
191
+ /**
192
+ * Runs a mark's canvas while motion is allowed. Render
193
+ * `<canvas v-if="live" ref="canvas">` over the still SVG, and hide the SVG
194
+ * while `drawn`.
195
+ */
196
+ export function useCanvasMotion(canvas: Ref<HTMLCanvasElement | null>, draw: Draw): { live: Ref<boolean>; drawn: Ref<boolean> } {
197
+ const live = ref(false);
198
+ const drawn = ref(false);
199
+ let stop: (() => void) | null = null;
200
+ let observer: IntersectionObserver | null = null;
201
+ let motion: MediaQueryList | null = null;
202
+
203
+ async function start(): Promise<void> {
204
+ if (stop) return;
205
+ live.value = true;
206
+ await nextTick();
207
+ const el = canvas.value;
208
+ if (!el || !live.value) return;
209
+ const mark: Animated = { canvas: el, draw, clock: 0, visible: true };
210
+ if (!drawNow(mark)) {
211
+ live.value = false;
212
+ return;
213
+ }
214
+ drawn.value = true;
215
+ stop = animate(mark);
216
+ if (typeof IntersectionObserver === "function") {
217
+ observer = new IntersectionObserver((entries) => {
218
+ for (const e of entries) mark.visible = e.isIntersecting;
219
+ });
220
+ observer.observe(el);
221
+ }
222
+ }
223
+ function halt(): void {
224
+ stop?.();
225
+ stop = null;
226
+ observer?.disconnect();
227
+ observer = null;
228
+ live.value = false;
229
+ drawn.value = false;
230
+ }
231
+ function onMotionChange(): void {
232
+ if (motion?.matches) halt();
233
+ else void start();
234
+ }
235
+
236
+ onMounted(() => {
237
+ // No matchMedia means no way to honour reduced motion, and a test DOM
238
+ // (jsdom) has no canvas and logs an error for every attempt: keep the
239
+ // still SVG in both.
240
+ if (typeof window.matchMedia !== "function" || typeof requestAnimationFrame !== "function") return;
241
+ if (navigator.userAgent.includes("jsdom")) return;
242
+ motion = window.matchMedia("(prefers-reduced-motion: reduce)");
243
+ motion.addEventListener?.("change", onMotionChange);
244
+ if (!motion.matches) void start();
245
+ });
246
+ onBeforeUnmount(() => {
247
+ motion?.removeEventListener?.("change", onMotionChange);
248
+ halt();
249
+ });
250
+ return { live, drawn };
251
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The colour of each module type, for a Chip (or anything else) that names
3
3
  * one: the `--wp-kind-*` colour the extension uses for that type, bundles
4
- * slate. There is no kind chip: a kind is a Chip with this colour.
4
+ * slate, and its icon (`kindIcon`). There is no kind chip: a kind is a Chip
5
+ * with this colour.
5
6
  *
6
7
  * <Chip :color="kindColour(itemType(post.kind, post.subtype))">{{ itemType(post.kind, post.subtype) }}</Chip>
7
8
  */
@@ -26,3 +27,25 @@ export function itemType(kind: "module" | "bundle", subtype?: ItemType | null):
26
27
  export function kindColour(type: ItemType): string {
27
28
  return COLOURS[type];
28
29
  }
30
+
31
+ /** The PrimeIcons name (for Icon / ListRow `icon`) of each item type: the
32
+ * same icons the extension's kind-icons map uses. */
33
+ const ICONS: Record<ItemType, string> = {
34
+ wildcard: "sparkles",
35
+ fixed_values: "tag",
36
+ combine: "link",
37
+ derivation: "arrow-right-arrow-left",
38
+ constraint: "filter",
39
+ bundle: "box",
40
+ };
41
+
42
+ /** The icon of a type read from a payload (any string): `circle` for a type
43
+ * the kit doesn't know. */
44
+ export function kindIcon(type: string): string {
45
+ return (ICONS as Record<string, string>)[type] ?? "circle";
46
+ }
47
+
48
+ /** A payload's type string as a module subtype, or null when it isn't one. */
49
+ export function moduleSubtype(type: string): ModuleSubtype | null {
50
+ return type === "fixed_values" || type === "combine" || type === "wildcard" || type === "constraint" || type === "derivation" ? type : null;
51
+ }
package/src/linkify.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Dependency-free, pure rich-text segmenter for prose (post descriptions).
3
+ *
4
+ * Splits a plain-text string into an ordered list of {@link RichSeg}s so the
5
+ * renderer (`RichText.vue`) can paint each piece as a Vue node, never via
6
+ * `v-html`. Because every segment is interpolated as a text node
7
+ * or a plain `<a>`/`<code>` with attribute-bound values, the output is
8
+ * XSS-safe by construction: a `<script>` in the input is just text.
9
+ *
10
+ * A single left-to-right scan recognises, in priority order:
11
+ * 1. `@{...}` wildcard-ref code token (e.g. `@{color:cool and dark}`)
12
+ * 2. `$name(.field)` var/accessor code token (e.g. `$color`, `$color.2`)
13
+ * 3. `https?://…` explicit URL
14
+ * 4. bare URL a `domain.tld/path` or `www.…` (must carry a `/path`)
15
+ * Everything else accumulates as literal text. Adjacent text runs collapse
16
+ * into one `text` segment.
17
+ *
18
+ * URL handling:
19
+ * - Trailing punctuation `.,;:!?)` is peeled off the match and pushed back
20
+ * as text, so `(see https://x.com).` doesn't swallow the `).`.
21
+ * - http(s) matches keep their scheme as the href; bare/`www.` matches are
22
+ * prefixed with `https://`.
23
+ * - A `link` segment is emitted ONLY when the resulting href starts with
24
+ * `http://` or `https://`; anything else degrades to text.
25
+ *
26
+ * Lossless invariant: concatenating every `.text` reproduces the input.
27
+ *
28
+ * Why not rich-tokenize.ts: that is the wildcard GRAMMAR lexer (picks,
29
+ * escapes, `@{8hex}` refs only), and a description is prose: `{a|b}` must
30
+ * stay text, `$$` is just two dollars, and a mention like `@{color:cool}`
31
+ * names a wildcard in words. This scanner only marks mentions and links.
32
+ */
33
+
34
+ export type RichSeg = {
35
+ kind: "text" | "link" | "code";
36
+ text: string;
37
+ /** Present only on `link` segments — always an `http(s)://` absolute URL. */
38
+ href?: string;
39
+ };
40
+
41
+ // Code token: `@{...}` (ref). Non-greedy body, no nested `}`.
42
+ const RE_REF = /^@\{[^}]*\}/;
43
+ // Code token: `$name` with an optional single `.field` accessor.
44
+ const RE_VAR = /^\$[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)?/;
45
+ // Explicit URL with scheme — runs to the next whitespace.
46
+ const RE_URL = /^https?:\/\/[^\s]+/;
47
+ // Bare URL: either a `www.` host, OR a `domain.tld/` (a dotted host directly
48
+ // followed by a `/path`). Requires the `/` so prose like `e.g.` or `node.com`
49
+ // without a path is NOT linkified (kept conservative on purpose).
50
+ const RE_BARE = /^(?:www\.|[a-z0-9-]+(?:\.[a-z0-9-]+)+\/)[^\s]+/i;
51
+
52
+ /** Trailing punctuation peeled off a matched URL and returned as text. */
53
+ const TRAILING = new Set([".", ",", ";", ":", "!", "?", ")"]);
54
+
55
+ /**
56
+ * Segment `input` into ordered text / link / code runs. Returns an empty
57
+ * array for empty input; the caller decides what to render for `[]`.
58
+ */
59
+ export function parseRichText(input: string): RichSeg[] {
60
+ if (!input) return [];
61
+
62
+ const segs: RichSeg[] = [];
63
+ let buf = "";
64
+
65
+ /** Flush the pending literal-text run (if any) as one `text` segment. */
66
+ const flush = (): void => {
67
+ if (buf) {
68
+ segs.push({ kind: "text", text: buf });
69
+ buf = "";
70
+ }
71
+ };
72
+
73
+ let i = 0;
74
+ const n = input.length;
75
+ while (i < n) {
76
+ const rest = input.slice(i);
77
+ const ch = input[i];
78
+
79
+ // 1. @{...} ref code token.
80
+ if (ch === "@") {
81
+ const m = rest.match(RE_REF);
82
+ if (m) {
83
+ flush();
84
+ segs.push({ kind: "code", text: m[0] });
85
+ i += m[0].length;
86
+ continue;
87
+ }
88
+ }
89
+
90
+ // 2. $var code token.
91
+ if (ch === "$") {
92
+ const m = rest.match(RE_VAR);
93
+ if (m) {
94
+ flush();
95
+ segs.push({ kind: "code", text: m[0] });
96
+ i += m[0].length;
97
+ continue;
98
+ }
99
+ }
100
+
101
+ // 3 + 4. URLs — explicit scheme first, then bare/`www.`.
102
+ //
103
+ // A bare match is only attempted at a word boundary (start of input or
104
+ // after whitespace) so we don't linkify the tail of a longer token.
105
+ const atBoundary = i === 0 || /\s/.test(input[i - 1] ?? "");
106
+ const urlMatch =
107
+ rest.match(RE_URL) ?? (atBoundary ? rest.match(RE_BARE) : null);
108
+ if (urlMatch) {
109
+ let raw = urlMatch[0];
110
+ // Peel trailing punctuation back into the text stream.
111
+ let trailer = "";
112
+ let last = raw.slice(-1);
113
+ while (last && TRAILING.has(last)) {
114
+ trailer = last + trailer;
115
+ raw = raw.slice(0, -1);
116
+ last = raw.slice(-1);
117
+ }
118
+ if (raw.length > 0) {
119
+ const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
120
+ // Only emit a link when the href is genuinely http(s); otherwise the
121
+ // whole match degrades to text (defensive — the prefix above always
122
+ // satisfies this, but keeps the contract explicit).
123
+ if (/^https?:\/\//i.test(href)) {
124
+ flush();
125
+ segs.push({ kind: "link", text: raw, href });
126
+ } else {
127
+ buf += raw;
128
+ }
129
+ if (trailer) buf += trailer;
130
+ i += raw.length + trailer.length;
131
+ continue;
132
+ }
133
+ }
134
+
135
+ // Default: accumulate one literal character.
136
+ buf += ch;
137
+ i += 1;
138
+ }
139
+
140
+ flush();
141
+ return segs;
142
+ }