@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 +15 -0
- package/package.json +1 -1
- package/src/CodeBlock.vue +32 -5
- package/src/DataTable.vue +3 -2
- package/src/ImageGrid.vue +15 -13
- package/src/Lightbox.vue +4 -1
- package/src/LoadingMark.vue +93 -51
- package/src/Skeleton.vue +20 -4
- package/src/highlight.ts +199 -0
- package/src/loading-mark-motion.ts +249 -0
- package/src/tokens.ts +1 -1
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
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
|
-
*
|
|
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
|
|
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">{{
|
|
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-
|
|
206
|
-
<td v-for="i in span" :key="i"><
|
|
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)
|
|
12
|
-
* `src` draws a Skeleton
|
|
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
|
-
|
|
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
|
-
<
|
|
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-
|
|
111
|
-
|
|
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
|
-
|
|
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>
|
package/src/LoadingMark.vue
CHANGED
|
@@ -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-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 {
|
package/src/highlight.ts
ADDED
|
@@ -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-
|
|
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 = [
|