@dumiflex/wp-kit 0.4.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/DumiFlex/wp-kit/compare/v0.4.0...v0.5.0) (2026-11-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **site:** add an Animations section with speed and pause controls ([#46](https://github.com/DumiFlex/wp-kit/issues/46)) ([c1043ec](https://github.com/DumiFlex/wp-kit/commit/c1043ecd3562fba74c29e4033e9e9c3fd0f058fa))
9
+ * Skeleton chip shape and square row icons; DataTable loads with Skeleton ([#47](https://github.com/DumiFlex/wp-kit/issues/47)) ([b38ec64](https://github.com/DumiFlex/wp-kit/commit/b38ec64a182e25073b59b6c22ef1a7e0246fee52))
10
+ * syntax colours in CodeBlock ([#44](https://github.com/DumiFlex/wp-kit/issues/44)) ([758fecc](https://github.com/DumiFlex/wp-kit/commit/758fecc8f86264290916b6c1b15a0233939ce790))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * a tile still loading can't open an empty Lightbox ([#45](https://github.com/DumiFlex/wp-kit/issues/45)) ([7063e64](https://github.com/DumiFlex/wp-kit/commit/7063e64a89ed8ce006602dd84804af7ff5f216c2))
16
+ * draw LoadingMark's motion on a canvas so it glides in Firefox ([#48](https://github.com/DumiFlex/wp-kit/issues/48)) ([85fc9a0](https://github.com/DumiFlex/wp-kit/commit/85fc9a027214ced5de582bc44d5e50472ee7560b))
17
+
3
18
  ## [0.4.0](https://github.com/DumiFlex/wp-kit/compare/v0.3.0...v0.4.0) (2026-11-02)
4
19
 
5
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dumiflex/wp-kit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Vue 3 component kit shared by the Wildcard Pipeline manager, canvas and community site.",
5
5
  "keywords": [
6
6
  "vue",
package/src/CodeBlock.vue CHANGED
@@ -8,17 +8,27 @@
8
8
  * <CodeBlock :code="json" language="JSON" max-height="240px" />
9
9
  * <CodeBlock :code="log" title="Request" line-numbers v-model:wrap="wrap" />
10
10
  *
11
- * No syntax colours (they cost bundle for little gain here). A block that
11
+ * Syntax colours come from `language` (JSON, TS, Vue/HTML, CSS, shell, the
12
+ * prompt template grammar; see highlight.ts), or from `syntax` when the head
13
+ * shows a title instead of a language tag:
14
+ *
15
+ * <CodeBlock :code="template" title="Template" syntax="prompt" />
16
+ *
17
+ * Unknown languages stay plain. The colours are shared tokens, so a block
18
+ * reads right on the canvas, in the manager and in light mode. A block that
12
19
  * scrolls can be focused, so the keyboard can scroll it too.
13
20
  */
14
21
  import { computed, ref, watch } from "vue";
15
22
  import CopyButton from "./CopyButton.vue";
16
23
  import { vTooltip } from "./tooltip";
24
+ import { highlight, highlightLanguage, highlightLines } from "./highlight";
17
25
 
18
26
  const props = withDefaults(defineProps<{
19
27
  code: string;
20
- /** A small tag in the head, e.g. "JSON". */
28
+ /** A small tag in the head, e.g. "JSON". Also picks the syntax colours. */
21
29
  language?: string | undefined;
30
+ /** Syntax colours without a tag in the head ("prompt", "json", "ts" …). */
31
+ syntax?: string | undefined;
22
32
  /** A name in the head, e.g. "Prompt as sent". */
23
33
  title?: string | undefined;
24
34
  /** Wrap long lines. Bind with v-model:wrap, or leave it to the button. */
@@ -31,7 +41,7 @@ const props = withDefaults(defineProps<{
31
41
  /** Leave out the head (copy and wrap). */
32
42
  bare?: boolean | undefined;
33
43
  size?: "sm" | "md" | undefined;
34
- }>(), { language: undefined, title: undefined, wrap: undefined, maxHeight: undefined, size: "md" });
44
+ }>(), { language: undefined, syntax: undefined, title: undefined, wrap: undefined, maxHeight: undefined, size: "md" });
35
45
 
36
46
  const emit = defineEmits<{ (e: "update:wrap", v: boolean): void }>();
37
47
 
@@ -43,7 +53,8 @@ function toggleWrap(): void {
43
53
  emit("update:wrap", own.value);
44
54
  }
45
55
 
46
- const lines = computed(() => props.code.replace(/\n$/, "").split("\n"));
56
+ const tokens = computed(() => highlight(props.code, highlightLanguage(props.syntax ?? props.language)));
57
+ const lines = computed(() => highlightLines(highlight(props.code.replace(/\n$/, ""), highlightLanguage(props.syntax ?? props.language))));
47
58
  const name = computed(() => props.title ?? props.language ?? "Code");
48
59
  </script>
49
60
 
@@ -69,7 +80,7 @@ const name = computed(() => props.title ?? props.language ?? "Code");
69
80
  tabindex="0"
70
81
  :aria-label="name"
71
82
  :style="{ maxHeight }"
72
- ><code v-if="!lineNumbers" class="wp-code__code">{{ code }}</code><code v-else class="wp-code__code wp-code__code--numbered"><span v-for="(l, i) in lines" :key="i" class="wp-code__line"><span class="wp-code__num" aria-hidden="true">{{ i + 1 }}</span><span class="wp-code__text">{{ l || "\u00a0" }}</span></span></code></pre>
83
+ ><code v-if="!lineNumbers" class="wp-code__code"><template v-for="(t, i) in tokens" :key="i"><span v-if="t.kind !== 'plain'" :class="`wp-code__t--${t.kind}`">{{ t.text }}</span><template v-else>{{ t.text }}</template></template></code><code v-else class="wp-code__code wp-code__code--numbered"><span v-for="(l, i) in lines" :key="i" class="wp-code__line"><span class="wp-code__num" aria-hidden="true">{{ i + 1 }}</span><span class="wp-code__text"><template v-for="(t, j) in l" :key="j"><span v-if="t.kind !== 'plain'" :class="`wp-code__t--${t.kind}`">{{ t.text }}</span><template v-else>{{ t.text }}</template></template><template v-if="!l.length">{{ "\u00a0" }}</template></span></span></code></pre>
73
84
  </figure>
74
85
  </template>
75
86
 
@@ -104,6 +115,8 @@ const name = computed(() => props.title ?? props.language ?? "Code");
104
115
  margin: 0; padding: var(--wp-space-4); overflow: auto;
105
116
  color: var(--wp-text); font: 400 var(--wp-text-sm)/1.55 var(--wp-font-mono);
106
117
  white-space: pre; tab-size: 2;
118
+ /* Code shows as written: no `===` or `<!--` ligatures. */
119
+ font-variant-ligatures: none;
107
120
  }
108
121
  .wp-code[data-size="sm"] .wp-code__pre { padding: var(--wp-space-3); font-size: var(--wp-text-xs); }
109
122
  .wp-code[data-wrap] .wp-code__pre { white-space: pre-wrap; overflow-wrap: anywhere; }
@@ -116,4 +129,18 @@ const name = computed(() => props.title ?? props.language ?? "Code");
116
129
  color: var(--wp-text-dim); user-select: none; font-variant-numeric: tabular-nums;
117
130
  }
118
131
  .wp-code__text { display: table-cell; }
132
+
133
+ /* Syntax colours: shared tokens only, so they hold on the canvas and in light mode. */
134
+ .wp-code__t--keyword { color: var(--wp-violet); }
135
+ .wp-code__t--string { color: var(--wp-green); }
136
+ .wp-code__t--number { color: var(--wp-amber); }
137
+ .wp-code__t--comment { color: var(--wp-text-dim); font-style: italic; }
138
+ .wp-code__t--punct { color: var(--wp-text-muted); }
139
+ .wp-code__t--key, .wp-code__t--attr { color: var(--wp-teal); }
140
+ .wp-code__t--tag { color: var(--wp-rose); }
141
+ .wp-code__t--fn { color: var(--wp-info); }
142
+ .wp-code__t--var { color: var(--wp-accent-text); }
143
+ .wp-code__t--ref { color: var(--wp-teal); }
144
+ .wp-code__t--brace { color: var(--wp-amber); }
145
+ .wp-code__t--escape { color: var(--wp-text-dim); }
119
146
  </style>
package/src/DataTable.vue CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { computed } from "vue";
26
26
  import Checkbox from "./Checkbox.vue";
27
+ import Skeleton from "./Skeleton.vue";
27
28
 
28
29
  export interface DataColumn<R> {
29
30
  key: string;
@@ -202,8 +203,8 @@ const span = computed(() => props.columns.length + (props.selectable ? 1 : 0));
202
203
  </thead>
203
204
  <tbody>
204
205
  <template v-if="loading && rows.length === 0">
205
- <tr v-for="n in loadingRows" :key="`skel-${n}`" class="wp-row-skel" aria-hidden="true">
206
- <td v-for="i in span" :key="i"><span class="wp-skel-bar" :style="{ width: `${40 + ((n * 17 + i * 23) % 50)}%` }" /></td>
206
+ <tr v-for="n in loadingRows" :key="`skel-${n}`" class="wp-data-table__loading" aria-hidden="true">
207
+ <td v-for="i in span" :key="i"><Skeleton :width="`${40 + ((n * 17 + i * 23) % 50)}%`" /></td>
207
208
  </tr>
208
209
  </template>
209
210
  <tr v-else-if="shown.length === 0" class="wp-data-table__empty">
package/src/ImageGrid.vue CHANGED
@@ -8,11 +8,13 @@
8
8
  * <ImageGrid :images="outputs" />
9
9
  * <ImageGrid v-model:selected="keep" :images="frames" selectable :min-size="96" />
10
10
  *
11
- * Tiles are buttons in reading order (one Tab stop each); a tile with no
12
- * `src` draws a Skeleton-like block while it loads.
11
+ * Tiles are buttons in reading order (one Tab stop each). A tile with no
12
+ * `src` yet (a frame still rendering) draws a Skeleton and can't be opened;
13
+ * the Lightbox steps only through the images that are there.
13
14
  */
14
15
  import { computed, ref } from "vue";
15
16
  import Lightbox from "./Lightbox.vue";
17
+ import Skeleton from "./Skeleton.vue";
16
18
  import WpCheck from "./WpCheck.vue";
17
19
 
18
20
  export interface GridImage { id: string; src: string; alt: string; thumb?: string; caption?: string }
@@ -38,12 +40,15 @@ const emit = defineEmits<{
38
40
 
39
41
  const open = ref(false);
40
42
  const at = ref(0);
41
- const shots = computed(() => props.images.map((i) => ({ src: i.src, alt: i.alt, caption: i.caption })));
43
+ /** Only the images that are there: a tile still loading has nothing to show. */
44
+ const ready = computed(() => props.images.map((img, i) => ({ img, i })).filter(({ img }) => !!img.src));
45
+ const shots = computed(() => ready.value.map(({ img }) => ({ src: img.src, alt: img.alt, caption: img.caption })));
42
46
 
43
47
  function show(i: number): void {
48
+ if (!props.images[i]?.src) return;
44
49
  emit("open", i);
45
50
  if (!props.lightbox) return;
46
- at.value = i;
51
+ at.value = ready.value.findIndex((r) => r.i === i);
47
52
  open.value = true;
48
53
  }
49
54
 
@@ -69,11 +74,13 @@ function toggle(id: string, on: boolean): void {
69
74
  <button
70
75
  type="button"
71
76
  class="wp-image-grid__open"
72
- :aria-label="selectable ? `Open ${img.alt}` : img.alt"
77
+ :aria-label="!img.src ? `${img.alt}, still loading` : selectable ? `Open ${img.alt}` : img.alt"
78
+ :aria-disabled="!img.src || undefined"
79
+ :data-loading="!img.src || undefined"
73
80
  @click="show(i)"
74
81
  >
75
82
  <img v-if="img.thumb || img.src" class="wp-image-grid__img" :src="img.thumb || img.src" alt="" loading="lazy">
76
- <span v-else class="wp-image-grid__empty" aria-hidden="true" />
83
+ <Skeleton v-else class="wp-image-grid__empty" shape="block" width="100%" height="100%" />
77
84
  </button>
78
85
  <WpCheck
79
86
  v-if="selectable"
@@ -107,12 +114,8 @@ function toggle(id: string, on: boolean): void {
107
114
  .wp-image-grid__open:focus-visible { outline: none; border-color: var(--wp-border-focus); box-shadow: var(--wp-focus-ring); }
108
115
  .wp-image-grid__tile[data-selected] .wp-image-grid__open { border-color: var(--wp-accent); box-shadow: 0 0 0 2px var(--wp-accent-glow); }
109
116
  .wp-image-grid__img { display: block; width: 100%; height: 100%; object-fit: cover; }
110
- .wp-image-grid__empty {
111
- display: block; width: 100%; height: 100%;
112
- background: linear-gradient(90deg, var(--wp-bg-2) 0%, var(--wp-bg-3) 50%, var(--wp-bg-2) 100%);
113
- background-size: 200% 100%; animation: wp-image-grid-shimmer var(--wp-motion-shimmer) infinite;
114
- }
115
- @keyframes wp-image-grid-shimmer { to { background-position: -200% 0; } }
117
+ .wp-image-grid__open[data-loading] { cursor: progress; }
118
+ .wp-image-grid__empty { width: 100%; height: 100%; }
116
119
  .wp-image-grid__check { position: absolute; top: 6px; left: 6px; }
117
120
  .wp-image-grid__caption {
118
121
  overflow: hidden; color: var(--wp-text-muted); font: 400 var(--wp-text-xs)/1.3 var(--wp-font-sans);
@@ -120,6 +123,5 @@ function toggle(id: string, on: boolean): void {
120
123
  }
121
124
  @media (prefers-reduced-motion: reduce) {
122
125
  .wp-image-grid__open { transition: none; }
123
- .wp-image-grid__empty { animation: none; }
124
126
  }
125
127
  </style>
package/src/Lightbox.vue CHANGED
@@ -17,6 +17,7 @@ import { computed, nextTick, ref, watch } from "vue";
17
17
  import ModalShell from "./ModalShell.vue";
18
18
  import WpImageZoom from "./WpImageZoom.vue";
19
19
  import CloseButton from "./CloseButton.vue";
20
+ import Skeleton from "./Skeleton.vue";
20
21
  import { focusablesIn } from "./useDialogFocus";
21
22
 
22
23
  export interface LightboxImage { src: string; alt: string; caption?: string | undefined }
@@ -106,7 +107,9 @@ watch(() => props.open, async (v, was) => {
106
107
  <CloseButton class="wp-lightbox__close" kbd="Esc" @click="close" />
107
108
  </div>
108
109
  <figure v-if="image" class="wp-lightbox__figure" :data-zoom="zoom || undefined">
109
- <WpImageZoom v-if="zoom" v-model:zoom="level" class="wp-lightbox__zoom" :src="image.src" :alt="image.alt" size="md" minimap />
110
+ <!-- An image with no src yet (still rendering): its shape, not an empty frame. -->
111
+ <Skeleton v-if="!image.src" class="wp-lightbox__loading" shape="block" width="min(70vw, 640px)" height="min(60vh, 480px)" label="Still loading" />
112
+ <WpImageZoom v-else-if="zoom" v-model:zoom="level" class="wp-lightbox__zoom" :src="image.src" :alt="image.alt" size="md" minimap />
110
113
  <img v-else class="wp-lightbox__img" :src="image.src" :alt="image.alt">
111
114
  <figcaption v-if="image.caption" class="wp-lightbox__caption">{{ image.caption }}</figcaption>
112
115
  </figure>
@@ -21,8 +21,9 @@ let nextId = 0;
21
21
  * box, for a panel or page that is loading. `mono` draws it in the text
22
22
  * colour, for a host with its own colours (a busy button).
23
23
  */
24
- import { computed, useSlots } from "vue";
24
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots } from "vue";
25
25
  import { BRAND_COLOURS, RING_A, RING_B, STAR_A, STAR_B, STAR_BIG, type LogoBrand } from "./logo-paths";
26
+ import { BOX, PAD, animate, drawNow, type Animated, type Look } from "./loading-mark-motion";
26
27
 
27
28
  const props = withDefaults(defineProps<{
28
29
  /** Width and height in px. Default 14, or 40 with `block`. */
@@ -52,17 +53,98 @@ const VIOLET = computed(() => BRAND_COLOURS[props.brand][0]);
52
53
  const INDIGO = computed(() => BRAND_COLOURS[props.brand][1]);
53
54
  const TEAL = computed(() => BRAND_COLOURS[props.brand][2]);
54
55
  const fill = (colour: string): string => (props.mono ? "currentColor" : colour);
56
+
57
+ /* The SVG is the still mark (first paint, reduced motion, a page with no
58
+ * canvas). Once mounted, a canvas over it draws the motion: see
59
+ * `loading-mark-motion.ts` for why it is not CSS. */
60
+ const root = ref<HTMLElement | null>(null);
61
+ const canvas = ref<HTMLCanvasElement | null>(null);
62
+ const live = ref(false);
63
+ const drawn = ref(false);
64
+ const canvasStyle = computed(() => {
65
+ const side = (px.value * BOX) / 1024;
66
+ const off = (px.value * PAD) / 1024;
67
+ return { width: `${side}px`, height: `${side}px`, top: `${-off}px`, left: `${-off}px` };
68
+ });
69
+
70
+ let textColour = "";
71
+ let textColourAt = -Infinity;
72
+ function look(): Look {
73
+ if (props.mono) {
74
+ const now = performance.now();
75
+ if (now - textColourAt > 500 && root.value) {
76
+ textColour = getComputedStyle(root.value).color || "currentColor";
77
+ textColourAt = now;
78
+ }
79
+ return { variant: drawing.value, colours: [textColour, textColour, textColour], flat: true };
80
+ }
81
+ return { variant: drawing.value, colours: [VIOLET.value, INDIGO.value, TEAL.value], flat: false };
82
+ }
83
+
84
+ let stop: (() => void) | null = null;
85
+ let observer: IntersectionObserver | null = null;
86
+ let motion: MediaQueryList | null = null;
87
+
88
+ async function start(): Promise<void> {
89
+ if (stop) return;
90
+ live.value = true;
91
+ await nextTick();
92
+ const el = canvas.value;
93
+ if (!el || !live.value) return;
94
+ const mark: Animated = { canvas: el, look, clock: 0, visible: true };
95
+ if (!drawNow(mark)) {
96
+ live.value = false;
97
+ return;
98
+ }
99
+ drawn.value = true;
100
+ stop = animate(mark);
101
+ if (typeof IntersectionObserver === "function") {
102
+ observer = new IntersectionObserver((entries) => {
103
+ for (const e of entries) mark.visible = e.isIntersecting;
104
+ });
105
+ observer.observe(el);
106
+ }
107
+ }
108
+ function halt(): void {
109
+ stop?.();
110
+ stop = null;
111
+ observer?.disconnect();
112
+ observer = null;
113
+ live.value = false;
114
+ drawn.value = false;
115
+ }
116
+ function onMotionChange(): void {
117
+ if (motion?.matches) halt();
118
+ else void start();
119
+ }
120
+
121
+ onMounted(() => {
122
+ // No matchMedia means no way to honour reduced motion, and a test DOM
123
+ // (jsdom) has no canvas and logs an error for every attempt: keep the
124
+ // still SVG in both.
125
+ if (typeof window.matchMedia !== "function" || typeof requestAnimationFrame !== "function") return;
126
+ if (navigator.userAgent.includes("jsdom")) return;
127
+ motion = window.matchMedia("(prefers-reduced-motion: reduce)");
128
+ motion.addEventListener?.("change", onMotionChange);
129
+ if (!motion.matches) void start();
130
+ });
131
+ onBeforeUnmount(() => {
132
+ motion?.removeEventListener?.("change", onMotionChange);
133
+ halt();
134
+ });
55
135
  </script>
56
136
 
57
137
  <template>
58
138
  <span
139
+ ref="root"
59
140
  class="wp-lm"
60
- :class="{ 'wp-lm--block': block }"
141
+ :class="{ 'wp-lm--block': block, 'wp-lm--drawn': drawn }"
61
142
  :role="decorative ? undefined : 'status'"
62
143
  :aria-label="decorative || slots.default ? undefined : label"
63
144
  :aria-hidden="decorative || undefined"
64
145
  data-test="loading-mark"
65
146
  >
147
+ <span class="wp-lm__art" :style="{ width: `${px}px`, height: `${px}px` }">
66
148
  <svg
67
149
  class="wp-lm__svg"
68
150
  :data-variant="drawing"
@@ -96,13 +178,14 @@ const fill = (colour: string): string => (props.mono ? "currentColor" : colour);
96
178
  </g>
97
179
  </g>
98
180
  </svg>
181
+ <canvas v-if="live" ref="canvas" class="wp-lm__canvas" :style="canvasStyle" aria-hidden="true" />
182
+ </span>
99
183
  <span v-if="slots.default" class="wp-lm__text"><slot /></span>
100
184
  </span>
101
185
  </template>
102
186
 
103
187
  <style scoped>
104
188
  .wp-lm {
105
- --wp-lm-t: 2.4s;
106
189
  display: inline-flex;
107
190
  align-items: center;
108
191
  gap: 8px;
@@ -118,57 +201,16 @@ const fill = (colour: string): string => (props.mono ? "currentColor" : colour);
118
201
  color: var(--wp-text-muted);
119
202
  text-align: center;
120
203
  }
121
- .wp-lm__svg { flex-shrink: 0; overflow: visible; }
122
- /* Every move animates a registered custom property, never `transform` or
123
- * `opacity` itself. A browser hands a `transform` / `opacity` animation to
124
- * the compositor, which lifts the path onto its own layer the moment the
125
- * animation starts: the layer is rasterised once, at a fixed scale, and
126
- * snapped to whole pixels, so the crisp mark turned soft and jumped by up to
127
- * half a pixel as it began to move. A custom property animates on the main
128
- * thread, so each frame repaints the vector paths in place, as sharp as the
129
- * mark at rest. The paths are tiny; the repaint costs nothing. */
130
- .wp-lm__ring { transform-box: view-box; transform-origin: 512px 512px; }
131
- .wp-lm__star { transform-box: fill-box; transform-origin: center; }
132
- .wp-lm__ring--a { transform: translate(var(--wp-lm-x), var(--wp-lm-x)); animation: wp-lm-split-a var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
133
- .wp-lm__ring--b { transform: translate(var(--wp-lm-x), var(--wp-lm-x)); animation: wp-lm-split-b var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
134
- .wp-lm__star--big { transform: rotate(var(--wp-lm-r)) scale(var(--wp-lm-s)); animation: wp-lm-dip var(--wp-lm-t) cubic-bezier(0.65, 0, 0.35, 1) infinite; }
135
- .wp-lm__star--tw { opacity: var(--wp-lm-o); transform: scale(var(--wp-lm-s)); animation: wp-lm-twinkle var(--wp-lm-t) ease-in-out infinite; }
136
- .wp-lm__swap {
137
- transform-box: view-box; transform-origin: 512px 512px;
138
- transform: rotate(var(--wp-lm-r));
139
- animation: wp-lm-swap calc(var(--wp-lm-t) * 2) cubic-bezier(0.65, 0, 0.35, 1) infinite;
140
- }
204
+ .wp-lm__art { position: relative; display: inline-block; flex-shrink: 0; line-height: 0; }
205
+ .wp-lm__svg { display: block; overflow: visible; }
206
+ .wp-lm__canvas { position: absolute; pointer-events: none; }
207
+ .wp-lm--drawn .wp-lm__svg { visibility: hidden; }
141
208
 
142
- @property --wp-lm-x { syntax: "<length>"; inherits: false; initial-value: 0px; }
143
- @property --wp-lm-r { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
144
- @property --wp-lm-s { syntax: "<number>"; inherits: false; initial-value: 1; }
209
+ /* Reduced motion: no canvas, the still mark breathes. A registered custom
210
+ * property, not `opacity` itself, so the fade stays off the compositor (a
211
+ * composited fade turns the mark soft). */
145
212
  @property --wp-lm-o { syntax: "<number>"; inherits: false; initial-value: 1; }
146
-
147
- @keyframes wp-lm-split-a {
148
- 0%, 15%, 85%, 100% { --wp-lm-x: 0px; }
149
- 40%, 60% { --wp-lm-x: -70px; }
150
- }
151
- @keyframes wp-lm-split-b {
152
- 0%, 15%, 85%, 100% { --wp-lm-x: 0px; }
153
- 40%, 60% { --wp-lm-x: 70px; }
154
- }
155
- @keyframes wp-lm-dip {
156
- 0%, 15% { --wp-lm-s: 1; --wp-lm-r: 0deg; }
157
- 32% { --wp-lm-s: 0.7; --wp-lm-r: 45deg; }
158
- 50%, 100% { --wp-lm-s: 1; --wp-lm-r: 90deg; }
159
- }
160
- @keyframes wp-lm-swap {
161
- 0%, 15% { --wp-lm-r: 0deg; }
162
- 50%, 65% { --wp-lm-r: 180deg; }
163
- 100% { --wp-lm-r: 360deg; }
164
- }
165
- @keyframes wp-lm-twinkle {
166
- 0%, 100% { --wp-lm-o: 1; --wp-lm-s: 1; }
167
- 50% { --wp-lm-o: 0.35; --wp-lm-s: 0.6; }
168
- }
169
-
170
213
  @media (prefers-reduced-motion: reduce) {
171
- .wp-lm__svg * { animation: none !important; }
172
214
  .wp-lm__svg { opacity: var(--wp-lm-o); animation: wp-lm-fade 2.4s ease-in-out infinite; }
173
215
  @keyframes wp-lm-fade { 0%, 100% { --wp-lm-o: 1; } 50% { --wp-lm-o: 0.55; } }
174
216
  }
package/src/Skeleton.vue CHANGED
@@ -15,8 +15,10 @@
15
15
  import { computed } from "vue";
16
16
 
17
17
  const props = withDefaults(defineProps<{
18
- /** line: text lines (the last one shorter). block: a box. circle: an avatar or icon. row: an icon, a title and a hint line, like a list row. */
19
- shape?: "line" | "block" | "circle" | "row" | undefined;
18
+ /** line: text lines (the last one shorter). block: a box. circle: an avatar or icon. row: an icon, a title and a hint line, like a list row. chip: a run of pills, like a tag or filter row. */
19
+ shape?: "line" | "block" | "circle" | "row" | "chip" | undefined;
20
+ /** The icon a row starts with: an avatar (circle, default) or an icon tile (square). */
21
+ icon?: "circle" | "square" | undefined;
20
22
  /** How many lines or rows. */
21
23
  lines?: number | undefined;
22
24
  /** CSS width of a line, block or circle. */
@@ -25,9 +27,11 @@ const props = withDefaults(defineProps<{
25
27
  height?: string | undefined;
26
28
  /** Say "Loading…" to screen readers with this text. */
27
29
  label?: string | undefined;
28
- }>(), { shape: "line", lines: 1, width: undefined, height: undefined, label: undefined });
30
+ }>(), { shape: "line", lines: 1, icon: "circle", width: undefined, height: undefined, label: undefined });
29
31
 
30
32
  const count = computed(() => Math.max(1, Math.floor(props.lines)));
33
+ /** Pill widths that read as different words. */
34
+ const CHIP_WIDTHS = ["64px", "48px", "80px", "56px", "72px", "40px"];
31
35
  /** The last of several lines is shorter, like the end of a paragraph. */
32
36
  const lineWidth = (i: number): string | undefined =>
33
37
  props.width ?? (count.value > 1 && i === count.value - 1 ? "60%" : undefined);
@@ -41,9 +45,17 @@ const lineWidth = (i: number): string | undefined =>
41
45
  </template>
42
46
  <span v-else-if="shape === 'block'" class="wp-skeleton__bone wp-skeleton__block" :style="{ width, height }" aria-hidden="true" />
43
47
  <span v-else-if="shape === 'circle'" class="wp-skeleton__bone wp-skeleton__circle" :style="{ width: width ?? height, height: height ?? width }" aria-hidden="true" />
48
+ <span v-else-if="shape === 'chip'" class="wp-skeleton__chips" aria-hidden="true">
49
+ <span
50
+ v-for="i in count"
51
+ :key="i"
52
+ class="wp-skeleton__bone wp-skeleton__chip"
53
+ :style="{ width: width ?? CHIP_WIDTHS[(i - 1) % CHIP_WIDTHS.length], height }"
54
+ />
55
+ </span>
44
56
  <template v-else>
45
57
  <span v-for="i in count" :key="i" class="wp-skeleton__row" aria-hidden="true">
46
- <span class="wp-skeleton__bone wp-skeleton__circle" />
58
+ <span class="wp-skeleton__bone" :class="icon === 'square' ? 'wp-skeleton__tile' : 'wp-skeleton__circle'" />
47
59
  <span class="wp-skeleton__row-text">
48
60
  <span class="wp-skeleton__bone wp-skeleton__line" :style="{ width: i % 2 ? '55%' : '42%' }" />
49
61
  <span class="wp-skeleton__bone wp-skeleton__line wp-skeleton__line--hint" :style="{ width: i % 2 ? '80%' : '68%' }" />
@@ -69,6 +81,10 @@ const lineWidth = (i: number): string | undefined =>
69
81
  .wp-skeleton__line--hint { height: 0.6em; opacity: 0.7; }
70
82
  .wp-skeleton__block { width: 100%; height: 64px; border-radius: var(--wp-radius); }
71
83
  .wp-skeleton__circle { flex: none; width: 28px; height: 28px; border-radius: 50%; /* round: stands in for an avatar */ }
84
+ .wp-skeleton__tile { flex: none; width: 28px; height: 28px; border-radius: var(--wp-radius-sm); }
85
+ .wp-skeleton__chips { display: flex; flex-wrap: wrap; gap: var(--wp-space-2); }
86
+ /* A Chip's height (xs, 19px); pass height for a sm (24px) filter row. */
87
+ .wp-skeleton__chip { height: 19px; border-radius: var(--wp-radius-cap, 999px); }
72
88
  .wp-skeleton__row { display: flex; align-items: center; gap: var(--wp-space-4); padding: var(--wp-space-2) 0; }
73
89
  .wp-skeleton__row-text { display: flex; flex-direction: column; gap: var(--wp-space-2); flex: 1; min-width: 0; }
74
90
  .wp-skeleton__sr {
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Syntax colours for CodeBlock: a small lexer per language, no dependency.
3
+ * It splits code into tokens of a few kinds; CodeBlock draws each kind in a
4
+ * shared colour token, so a block wears the colours of the side it is on.
5
+ *
6
+ * highlight('{"a": 1}', "json") → [{ kind: "punct", text: "{" }, { kind: "key", text: '"a"' }, …]
7
+ *
8
+ * Languages: json, ts (also js), html (also vue, xml), css, sh (also bash,
9
+ * shell, Modelfile), prompt (the Wildcard Pipeline template grammar: `$var`,
10
+ * `@{ref}`, `{a|b}`, `{2$$a|b}`, `$$` / `@@` escapes, `(word:1.2)` weights). Anything
11
+ * else comes back as one plain token. Every character of the input is in
12
+ * exactly one token, in order, so joining the tokens gives the input back.
13
+ */
14
+ import { tokenizeRich } from "./rich-tokenize";
15
+
16
+ export type HighlightKind =
17
+ | "plain" | "keyword" | "string" | "number" | "comment" | "punct"
18
+ | "key" | "tag" | "attr" | "fn" | "var" | "ref" | "brace" | "escape";
19
+
20
+ export interface HighlightToken {
21
+ kind: HighlightKind;
22
+ text: string;
23
+ }
24
+
25
+ export type HighlightLanguage = "json" | "ts" | "html" | "css" | "sh" | "prompt";
26
+
27
+ const ALIASES: Record<string, HighlightLanguage> = {
28
+ json: "json",
29
+ ts: "ts", typescript: "ts", js: "ts", javascript: "ts", tsx: "ts", jsx: "ts",
30
+ html: "html", vue: "html", xml: "html", svg: "html",
31
+ css: "css",
32
+ sh: "sh", bash: "sh", shell: "sh", zsh: "sh", console: "sh", modelfile: "sh", dockerfile: "sh",
33
+ prompt: "prompt", template: "prompt", wildcard: "prompt",
34
+ };
35
+
36
+ /** The grammar for a language name ("JSON", "Vue", "bash" …), or null. */
37
+ export function highlightLanguage(name: string | null | undefined): HighlightLanguage | null {
38
+ if (!name) return null;
39
+ return ALIASES[name.trim().toLowerCase()] ?? null;
40
+ }
41
+
42
+ type Rule = [HighlightKind, RegExp];
43
+
44
+ const TS_KEYWORDS = /^(?:import|from|export|default|const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|class|extends|interface|type|enum|as|in|of|typeof|instanceof|async|await|try|catch|finally|throw|void|this|null|undefined|true|false|readonly|public|private|protected|static|satisfies|keyof)$/;
45
+
46
+ const RULES: Record<Exclude<HighlightLanguage, "prompt" | "html">, Rule[]> = {
47
+ json: [
48
+ ["key", /"(?:[^"\\\n]|\\.)*"(?=\s*:)/y],
49
+ ["string", /"(?:[^"\\\n]|\\.)*"/y],
50
+ ["number", /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y],
51
+ ["keyword", /\b(?:true|false|null)\b/y],
52
+ ["punct", /[{}[\],:]/y],
53
+ ],
54
+ ts: [
55
+ ["comment", /\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)/y],
56
+ ["string", /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/y],
57
+ ["number", /\b\d+(?:\.\d+)?\b/y],
58
+ ["fn", /[A-Za-z_$][\w$]*(?=\s*\()/y],
59
+ ["plain", /[A-Za-z_$][\w$]*/y],
60
+ ["punct", /[{}()[\];,.<>=!&|?:+\-*/%]+/y],
61
+ ],
62
+ css: [
63
+ ["comment", /\/\*[\s\S]*?(?:\*\/|$)/y],
64
+ ["string", /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/y],
65
+ ["key", /[\w-]+(?=\s*:[^:{]*[;}\n])/y],
66
+ ["var", /--[\w-]+/y],
67
+ ["number", /#[\da-fA-F]{3,8}\b|-?\d*\.?\d+(?:px|em|rem|%|vh|vw|s|ms|deg|fr)?\b/y],
68
+ ["fn", /[\w-]+(?=\()/y],
69
+ ["tag", /[.#]?[\w-]+(?::{1,2}[\w-]+)*(?=[^{};]*\{)/y],
70
+ ["punct", /[{}();:,>+~*]/y],
71
+ ],
72
+ sh: [
73
+ ["comment", /#[^\n]*/y],
74
+ ["string", /"(?:[^"\\\n]|\\.)*"|'[^'\n]*'/y],
75
+ ["var", /\$\{?[\w]+\}?/y],
76
+ ["attr", /(?<=\s)--?[\w-]+/y],
77
+ ["fn", /(?<=^|\n|[|;&]\s*)[A-Za-z_][\w.-]*/y],
78
+ ["punct", /[|&;<>()]+/y],
79
+ ],
80
+ };
81
+
82
+ function lex(code: string, rules: Rule[]): HighlightToken[] {
83
+ const out: HighlightToken[] = [];
84
+ let plain = "";
85
+ let i = 0;
86
+ const flush = (): void => { if (plain) { out.push({ kind: "plain", text: plain }); plain = ""; } };
87
+ outer: while (i < code.length) {
88
+ for (const [kind, re] of rules) {
89
+ re.lastIndex = i;
90
+ const m = re.exec(code);
91
+ if (m && m[0].length > 0) {
92
+ let k = kind;
93
+ if (rules === RULES.ts && kind === "plain" && TS_KEYWORDS.test(m[0])) k = "keyword";
94
+ if (k === "plain") plain += m[0];
95
+ else { flush(); out.push({ kind: k, text: m[0] }); }
96
+ i += m[0].length;
97
+ continue outer;
98
+ }
99
+ }
100
+ plain += code[i];
101
+ i += 1;
102
+ }
103
+ flush();
104
+ return out;
105
+ }
106
+
107
+ /** HTML and Vue templates: tags, attributes, their values, `{{ }}` as TS. */
108
+ function lexHtml(code: string): HighlightToken[] {
109
+ const out: HighlightToken[] = [];
110
+ const re = /<!--[\s\S]*?(?:-->|$)|<\/?[A-Za-z][\w.:-]*|\/?>|\{\{[\s\S]*?(?:\}\}|$)/g;
111
+ let at = 0;
112
+ let inTag = false;
113
+ const text = (s: string): void => {
114
+ if (!s) return;
115
+ if (!inTag) { out.push({ kind: "plain", text: s }); return; }
116
+ // Attributes inside a tag: name, =, "value".
117
+ const attr = /("[^"]*"|'[^']*')|([:@#]?[\w.:@#-]+)|(=)|(\s+)|([^\s"'=]+)/g;
118
+ for (const m of s.matchAll(attr)) {
119
+ if (m[1]) out.push({ kind: "string", text: m[1] });
120
+ else if (m[2]) out.push({ kind: "attr", text: m[2] });
121
+ else if (m[3]) out.push({ kind: "punct", text: m[3] });
122
+ else out.push({ kind: "plain", text: m[0] });
123
+ }
124
+ };
125
+ for (const m of code.matchAll(re)) {
126
+ const idx = m.index ?? 0;
127
+ text(code.slice(at, idx));
128
+ const t = m[0];
129
+ if (t.startsWith("<!--")) out.push({ kind: "comment", text: t });
130
+ else if (t.startsWith("{{") && !inTag) {
131
+ out.push({ kind: "punct", text: "{{" });
132
+ const inner = t.slice(2, t.endsWith("}}") ? -2 : undefined);
133
+ out.push(...lex(inner, RULES.ts));
134
+ if (t.endsWith("}}")) out.push({ kind: "punct", text: "}}" });
135
+ } else if (t.startsWith("<")) {
136
+ out.push({ kind: "punct", text: t.startsWith("</") ? "</" : "<" });
137
+ out.push({ kind: "tag", text: t.replace(/^<\/?/, "") });
138
+ inTag = true;
139
+ } else if (t.endsWith(">")) {
140
+ out.push({ kind: "punct", text: t });
141
+ inTag = false;
142
+ } else {
143
+ text(t);
144
+ }
145
+ at = idx + t.length;
146
+ }
147
+ text(code.slice(at));
148
+ return out;
149
+ }
150
+
151
+ /** The template grammar, through the kit's own tokenizer; `(word:1.2)` weights too. */
152
+ function lexPrompt(code: string): HighlightToken[] {
153
+ const out: HighlightToken[] = [];
154
+ const plain = (text: string): void => {
155
+ // Plain text: pick out `:1.2)` weights.
156
+ let at = 0;
157
+ for (const m of text.matchAll(/:(-?\d+(?:\.\d+)?)(?=\))/g)) {
158
+ const idx = (m.index ?? 0) + 1;
159
+ if (idx > at) out.push({ kind: "plain", text: text.slice(at, idx) });
160
+ out.push({ kind: "number", text: m[1] ?? "" });
161
+ at = idx + (m[1]?.length ?? 0);
162
+ }
163
+ if (at < text.length) out.push({ kind: "plain", text: text.slice(at) });
164
+ };
165
+ let at = 0;
166
+ for (const t of tokenizeRich(code)) {
167
+ // The tokenizer may leave gaps (it reports what it recognises); keep them as text.
168
+ if (t.start > at) plain(code.slice(at, t.start));
169
+ const text = code.slice(t.start, t.end);
170
+ if (t.kind === "var") out.push({ kind: "var", text });
171
+ else if (t.kind === "ref") out.push({ kind: "ref", text });
172
+ else if (t.kind === "dp-brace" || t.kind === "dp-multi") out.push({ kind: "brace", text });
173
+ else if (t.kind === "escape") out.push({ kind: "escape", text });
174
+ else plain(text);
175
+ at = Math.max(at, t.end);
176
+ }
177
+ if (at < code.length) plain(code.slice(at));
178
+ return out;
179
+ }
180
+
181
+ export function highlight(code: string, language: HighlightLanguage | null): HighlightToken[] {
182
+ if (!language || !code) return [{ kind: "plain", text: code }];
183
+ if (language === "html") return lexHtml(code);
184
+ if (language === "prompt") return lexPrompt(code);
185
+ return lex(code, RULES[language]);
186
+ }
187
+
188
+ /** Tokens cut at line breaks, for numbered lines. */
189
+ export function highlightLines(tokens: HighlightToken[]): HighlightToken[][] {
190
+ const lines: HighlightToken[][] = [[]];
191
+ for (const t of tokens) {
192
+ const parts = t.text.split("\n");
193
+ parts.forEach((p, i) => {
194
+ if (i > 0) lines.push([]);
195
+ if (p) lines[lines.length - 1]?.push({ kind: t.kind, text: p });
196
+ });
197
+ }
198
+ return lines;
199
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * LoadingMark's motion, drawn on a canvas.
3
+ *
4
+ * The mark used to move its SVG paths with CSS. A keyframed `transform` /
5
+ * `opacity` goes to the compositor, which blurs the path and snaps it to whole
6
+ * pixels as it starts; animating registered custom properties fixed that in
7
+ * Chromium, but Firefox still lays each frame of an SVG transform on the pixel
8
+ * grid, so the rings stepped and twitched as they slid. A canvas puts every
9
+ * shape at its exact 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
+ import { RING_A, RING_B, STAR_A, STAR_B, STAR_BIG } from "./logo-paths";
17
+
18
+ /** One loop of the logo, in ms; the sparkle's swap takes two. */
19
+ export const LOOP_MS = 2400;
20
+ /** Room around the 1024 box for the rings as they slide apart (70 units)
21
+ * and for anti-aliasing at the edge. */
22
+ export const PAD = 82;
23
+ export const BOX = 1024 + 2 * PAD;
24
+
25
+ /** CSS `cubic-bezier(x1, y1, x2, y2)` as a function of progress. */
26
+ export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (x: number) => number {
27
+ return (x) => {
28
+ if (x <= 0) return 0;
29
+ if (x >= 1) return 1;
30
+ let lo = 0;
31
+ let hi = 1;
32
+ let t = x;
33
+ for (let k = 0; k < 30; k++) {
34
+ t = (lo + hi) / 2;
35
+ const cx = 3 * x1 * t * (1 - t) ** 2 + 3 * x2 * t * t * (1 - t) + t ** 3;
36
+ if (cx < x) lo = t;
37
+ else hi = t;
38
+ }
39
+ return 3 * y1 * t * (1 - t) ** 2 + 3 * y2 * t * t * (1 - t) + t ** 3;
40
+ };
41
+ }
42
+
43
+ const SMOOTH = cubicBezier(0.65, 0, 0.35, 1);
44
+ const EASE_IN_OUT = cubicBezier(0.42, 0, 0.58, 1);
45
+
46
+ type Stop = readonly [at: number, values: readonly number[]];
47
+
48
+ /** Value of a keyframe track at progress `p` (0..1), eased per segment the
49
+ * way CSS eases between two keyframes. */
50
+ export function track(stops: readonly Stop[], p: number, ease: (x: number) => number): number[] {
51
+ for (let k = 0; k < stops.length - 1; k++) {
52
+ const a = stops[k];
53
+ const b = stops[k + 1];
54
+ if (!a || !b) break;
55
+ if (p >= a[0] && p <= b[0]) {
56
+ if (b[0] === a[0]) return [...b[1]];
57
+ const e = ease((p - a[0]) / (b[0] - a[0]));
58
+ return a[1].map((v, j) => v + ((b[1][j] ?? v) - v) * e);
59
+ }
60
+ }
61
+ return [...(stops[stops.length - 1]?.[1] ?? [])];
62
+ }
63
+
64
+ /** The rings' slide apart (units of the 1024 box). */
65
+ const SPLIT: readonly Stop[] = [[0, [0]], [0.15, [0]], [0.4, [70]], [0.6, [70]], [0.85, [0]], [1, [0]]];
66
+ /** The big star's dip: [scale, turn in degrees]. */
67
+ const DIP: readonly Stop[] = [[0, [1, 0]], [0.15, [1, 0]], [0.32, [0.7, 45]], [0.5, [1, 90]], [1, [1, 90]]];
68
+ /** The small stars' twinkle: [opacity, scale]. */
69
+ const TWINKLE: readonly Stop[] = [[0, [1, 1]], [0.5, [0.35, 0.6]], [1, [1, 1]]];
70
+ /** The sparkle's small stars trading places (degrees, over two loops). */
71
+ const SWAP: readonly Stop[] = [[0, [0]], [0.15, [0]], [0.5, [180]], [0.65, [180]], [1, [360]]];
72
+
73
+ export interface Pose {
74
+ split: number;
75
+ bigScale: number;
76
+ bigTurn: number;
77
+ twinkleAlpha: number;
78
+ twinkleScale: number;
79
+ swap: number;
80
+ }
81
+
82
+ /** Where every part is `ms` into the animation. */
83
+ export function poseAt(ms: number): Pose {
84
+ const p = (((ms % LOOP_MS) + LOOP_MS) % LOOP_MS) / LOOP_MS;
85
+ const p2 = (((ms % (2 * LOOP_MS)) + 2 * LOOP_MS) % (2 * LOOP_MS)) / (2 * LOOP_MS);
86
+ const [split = 0] = track(SPLIT, p, SMOOTH);
87
+ const [bigScale = 1, bigTurn = 0] = track(DIP, p, SMOOTH);
88
+ const [twinkleAlpha = 1, twinkleScale = 1] = track(TWINKLE, p, EASE_IN_OUT);
89
+ const [swap = 0] = track(SWAP, p2, SMOOTH);
90
+ return { split, bigScale, bigTurn, twinkleAlpha, twinkleScale, swap };
91
+ }
92
+
93
+ /** Centres of the stars in the 1024 box (from their bounding boxes). */
94
+ const C_BIG = [512, 512] as const;
95
+ const C_A = [360, 664] as const;
96
+ const C_B = [664, 360] as const;
97
+
98
+ let paths: Record<"ra" | "rb" | "big" | "a" | "b", Path2D> | null = null;
99
+ function shapes(): Record<"ra" | "rb" | "big" | "a" | "b", Path2D> {
100
+ paths ??= { ra: new Path2D(RING_A), rb: new Path2D(RING_B), big: new Path2D(STAR_BIG), a: new Path2D(STAR_A), b: new Path2D(STAR_B) };
101
+ return paths;
102
+ }
103
+
104
+ export interface Look {
105
+ variant: "logo" | "sparkle";
106
+ /** [violet, indigo, teal], or three times the text colour for `mono`. */
107
+ colours: readonly [string, string, string];
108
+ /** Flat colours (mono): no ring gradients. */
109
+ flat: boolean;
110
+ }
111
+
112
+ function around(ctx: CanvasRenderingContext2D, [x, y]: readonly [number, number], scale: number, turn: number, path: Path2D, fill: string, alpha: number): void {
113
+ ctx.save();
114
+ ctx.translate(x, y);
115
+ ctx.rotate((turn * Math.PI) / 180);
116
+ ctx.scale(scale, scale);
117
+ ctx.translate(-x, -y);
118
+ ctx.globalAlpha = alpha;
119
+ ctx.fillStyle = fill;
120
+ ctx.fill(path);
121
+ ctx.restore();
122
+ }
123
+
124
+ /** Draws one frame on a canvas `BOX` units square (scaled to its pixels). */
125
+ export function drawPose(ctx: CanvasRenderingContext2D, width: number, look: Look, pose: Pose): void {
126
+ const k = width / BOX;
127
+ const [violet, indigo, teal] = look.colours;
128
+ const p = shapes();
129
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
130
+ ctx.clearRect(0, 0, width, width);
131
+ ctx.setTransform(k, 0, 0, k, PAD * k, PAD * k);
132
+ if (look.variant === "logo") {
133
+ const ringA = (() => {
134
+ if (look.flat) return violet;
135
+ const g = ctx.createLinearGradient(312.56, 897.19, 312.56, 2);
136
+ g.addColorStop(0, violet);
137
+ g.addColorStop(1, indigo);
138
+ return g;
139
+ })();
140
+ const ringB = (() => {
141
+ if (look.flat) return violet;
142
+ const g = ctx.createLinearGradient(711.44, 126.81, 711.44, 1022);
143
+ g.addColorStop(0, indigo);
144
+ g.addColorStop(1, teal);
145
+ return g;
146
+ })();
147
+ ctx.save();
148
+ ctx.translate(-pose.split, -pose.split);
149
+ ctx.fillStyle = ringA;
150
+ ctx.fill(p.ra);
151
+ ctx.restore();
152
+ ctx.save();
153
+ ctx.translate(pose.split, pose.split);
154
+ ctx.fillStyle = ringB;
155
+ ctx.fill(p.rb);
156
+ ctx.restore();
157
+ around(ctx, C_BIG, pose.bigScale, pose.bigTurn, p.big, violet, 1);
158
+ around(ctx, C_A, pose.twinkleScale, 0, p.a, indigo, pose.twinkleAlpha);
159
+ around(ctx, C_B, pose.twinkleScale, 0, p.b, teal, pose.twinkleAlpha);
160
+ return;
161
+ }
162
+ ctx.translate(512, 512);
163
+ ctx.scale(1.45, 1.45);
164
+ ctx.translate(-512, -512);
165
+ around(ctx, C_BIG, pose.bigScale, pose.bigTurn, p.big, violet, 1);
166
+ ctx.save();
167
+ ctx.translate(512, 512);
168
+ ctx.rotate((pose.swap * Math.PI) / 180);
169
+ ctx.translate(-512, -512);
170
+ ctx.fillStyle = indigo;
171
+ ctx.fill(p.a);
172
+ ctx.fillStyle = teal;
173
+ ctx.fill(p.b);
174
+ ctx.restore();
175
+ }
176
+
177
+ /** A mark the loop draws. */
178
+ export interface Animated {
179
+ canvas: HTMLCanvasElement;
180
+ look: () => Look;
181
+ /** ms of animation shown so far, advanced by the loop. */
182
+ clock: number;
183
+ /** False while off screen: the clock still runs, nothing is drawn. */
184
+ visible: boolean;
185
+ }
186
+
187
+ const marks = new Set<Animated>();
188
+ let frame = 0;
189
+ let last = 0;
190
+
191
+ /** Speed and pause from the nearest host that sets them. */
192
+ export function motionOf(el: Element): { speed: number; paused: boolean } {
193
+ const host = el.closest("[data-wp-motion-speed], [data-wp-motion-paused]");
194
+ if (!host) return { speed: 1, paused: false };
195
+ const paused = host.closest("[data-wp-motion-paused]") !== null;
196
+ const speedHost = host.closest("[data-wp-motion-speed]");
197
+ const speed = Number(speedHost?.getAttribute("data-wp-motion-speed") ?? "1");
198
+ return { speed: Number.isFinite(speed) && speed >= 0 ? speed : 1, paused };
199
+ }
200
+
201
+ function fit(c: HTMLCanvasElement): void {
202
+ const css = c.clientWidth;
203
+ const want = Math.max(1, Math.round(css * (window.devicePixelRatio || 1)));
204
+ if (c.width !== want) {
205
+ c.width = want;
206
+ c.height = want;
207
+ }
208
+ }
209
+
210
+ function tick(now: number): void {
211
+ const dt = last ? Math.min(now - last, 100) : 0;
212
+ last = now;
213
+ for (const m of marks) {
214
+ const { speed, paused } = motionOf(m.canvas);
215
+ if (!paused) m.clock += dt * speed;
216
+ if (!m.visible) continue;
217
+ const ctx = m.canvas.getContext("2d");
218
+ if (!ctx) continue;
219
+ fit(m.canvas);
220
+ drawPose(ctx, m.canvas.width, m.look(), poseAt(m.clock));
221
+ }
222
+ frame = marks.size ? requestAnimationFrame(tick) : 0;
223
+ }
224
+
225
+ /** Starts drawing a mark; returns the function that stops it. */
226
+ export function animate(mark: Animated): () => void {
227
+ marks.add(mark);
228
+ if (!frame) {
229
+ last = 0;
230
+ frame = requestAnimationFrame(tick);
231
+ }
232
+ return () => {
233
+ marks.delete(mark);
234
+ if (!marks.size && frame) {
235
+ cancelAnimationFrame(frame);
236
+ frame = 0;
237
+ }
238
+ };
239
+ }
240
+
241
+ /** Draws one still frame right away (the first frame, before the loop runs,
242
+ * so the mark never shows blank). */
243
+ export function drawNow(mark: Animated): boolean {
244
+ const ctx = mark.canvas.getContext("2d");
245
+ if (!ctx) return false;
246
+ fit(mark.canvas);
247
+ drawPose(ctx, mark.canvas.width, mark.look(), poseAt(mark.clock));
248
+ return true;
249
+ }
package/src/tokens.ts CHANGED
@@ -45,7 +45,7 @@ export const SHARED_TOKENS = [
45
45
  export const OPTIONAL_TOKENS = ["--wp-bg-deep", "--wp-bg2"] as const;
46
46
 
47
47
  /** Set by the piece itself, on its own root, not by the host. */
48
- export const LOCAL_TOKENS = ["--wp-hsv-hue", "--wp-lm-t", "--wp-lm-x", "--wp-lm-r", "--wp-lm-s", "--wp-lm-o", "--wp-select-line-h", "--wp-list-row-depth", "--wp-list-row-icon", "--wp-chip-c", "--wp-field-h"] as const;
48
+ export const LOCAL_TOKENS = ["--wp-hsv-hue", "--wp-lm-o", "--wp-select-line-h", "--wp-list-row-depth", "--wp-list-row-icon", "--wp-chip-c", "--wp-field-h"] as const;
49
49
 
50
50
  /** Pieces that need a page with src/theme/ loaded (not the canvas). */
51
51
  export const PAGE_PIECES = [