@dumiflex/wp-kit 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +5 -0
  3. package/package.json +4 -3
  4. package/src/Avatar.vue +1 -1
  5. package/src/Banner.vue +4 -4
  6. package/src/Button.vue +73 -44
  7. package/src/Checkbox.vue +201 -63
  8. package/src/Chip.vue +5 -2
  9. package/src/CodeBlock.vue +33 -6
  10. package/src/ColorField.vue +60 -6
  11. package/src/Combobox.vue +10 -14
  12. package/src/ConfirmModal.vue +12 -22
  13. package/src/ContextMenu.vue +11 -30
  14. package/src/CopyButton.vue +1 -1
  15. package/src/DataTable.vue +3 -4
  16. package/src/DatePicker.vue +1 -1
  17. package/src/Disclosure.vue +2 -1
  18. package/src/Drawer.vue +2 -2
  19. package/src/DropZone.vue +2 -2
  20. package/src/HsvPicker.vue +2 -2
  21. package/src/IconPicker.vue +1 -1
  22. package/src/ImageGrid.vue +17 -15
  23. package/src/Lightbox.vue +6 -3
  24. package/src/LoadingMark.vue +93 -51
  25. package/src/Modal.vue +109 -44
  26. package/src/ModalShell.vue +26 -5
  27. package/src/Pagination.vue +4 -4
  28. package/src/Popover.vue +68 -12
  29. package/src/RadioGroup.vue +5 -4
  30. package/src/SearchInput.vue +1 -1
  31. package/src/Select.vue +53 -158
  32. package/src/Skeleton.vue +21 -5
  33. package/src/Slider.vue +166 -55
  34. package/src/SortableList.vue +1 -1
  35. package/src/TagInput.vue +1 -1
  36. package/src/Timeline.vue +2 -2
  37. package/src/WorkingLine.vue +320 -0
  38. package/src/WpIconButton.vue +3 -2
  39. package/src/WpImageZoom.vue +7 -7
  40. package/src/WpSegmented.vue +3 -1
  41. package/src/WpSwitch.vue +1 -1
  42. package/src/WpTabs.vue +1 -1
  43. package/src/_modal-motion.css +1 -1
  44. package/src/button.css +8 -6
  45. package/src/floating.ts +5 -0
  46. package/src/highlight.ts +199 -0
  47. package/src/hsv.ts +1 -1
  48. package/src/loading-mark-motion.ts +249 -0
  49. package/src/menu.css +51 -0
  50. package/src/motion.css +73 -0
  51. package/src/slider-math.ts +45 -1
  52. package/src/theme/looks.css +6 -19
  53. package/src/theme/tokens.css +12 -1108
  54. package/src/tokens.ts +8 -3
  55. package/src/tooltip.css +8 -5
  56. package/src/useDialogFocus.ts +11 -2
  57. package/src/CloseButton.vue +0 -69
  58. package/src/ColorPicker.vue +0 -201
  59. package/src/ConfirmDialog.vue +0 -165
  60. package/src/RangeSlider.vue +0 -183
  61. package/src/Toggle.vue +0 -36
  62. package/src/WpButton.vue +0 -75
  63. package/src/WpCheck.vue +0 -104
  64. package/src/_skeleton.css +0 -50
@@ -0,0 +1,320 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * WorkingLine: "the model is working", in one line like Claude Code's. A
4
+ * small mark drawn from the logo's sparkles, a word that crossfades when it
5
+ * changes, and the time so far. It only draws: the app decides the word,
6
+ * the time and the tempo (the extension's AiWorking cycles playful words
7
+ * and the real status, and reads the request's phase).
8
+ *
9
+ * <WorkingLine word="Thinking" time="12s" tempo="mid" />
10
+ * <WorkingLine :word="word" :word-key="phase" variant="orbit" inline />
11
+ *
12
+ * Marks (`variant`): typing (three stars hop in turn), shuffle (the small
13
+ * stars swap places round the big one), orbit (the logo's four nodes circle
14
+ * the stars) or burst (the small stars fly out of the big one and back).
15
+ * `tempo` sets their speed: slow while the model reads, mid while it thinks,
16
+ * fast while it writes.
17
+ *
18
+ * The word crossfades in place when `wordKey` changes (default: the word),
19
+ * so a token count ticking up can keep its key and not flicker. Its box is
20
+ * never narrower than the word: it widens at once and narrows smoothly once
21
+ * the old word has mostly faded.
22
+ *
23
+ * Every mark animates registered custom properties, never `transform` or
24
+ * `opacity` themselves, so the paths repaint sharp each frame instead of
25
+ * going to a compositor layer that blurs them and snaps them to whole pixels.
26
+ */
27
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
28
+ import { BRAND_COLOURS, STAR_A, STAR_B, STAR_BIG, type LogoBrand } from "./logo-paths";
29
+
30
+ export type WorkingVariant = "typing" | "shuffle" | "orbit" | "burst";
31
+
32
+ const props = withDefaults(defineProps<{
33
+ /** What is shown, without the "…" (the line adds it). */
34
+ word: string;
35
+ /** What the crossfade keys on; defaults to the word. */
36
+ wordKey?: string | undefined;
37
+ /** Time so far, already formatted ("42s"). Left out, no time shows. */
38
+ time?: string | undefined;
39
+ variant?: WorkingVariant | undefined;
40
+ tempo?: "slow" | "mid" | "fast" | undefined;
41
+ /** The mark's size in px. */
42
+ size?: number | undefined;
43
+ /** Smaller text and a capped word width, for a top bar. */
44
+ inline?: boolean | undefined;
45
+ /** The whole status, for screen readers and the tooltip; defaults to the word. */
46
+ label?: string | undefined;
47
+ brand?: LogoBrand | undefined;
48
+ }>(), { variant: "typing", tempo: "slow", size: 22, inline: false, brand: "extension" });
49
+
50
+ const key = computed(() => props.wordKey ?? props.word);
51
+ const name = computed(() => props.label ?? props.word);
52
+
53
+ const colours = computed(() => BRAND_COLOURS[props.brand]);
54
+ const BIG = computed(() => colours.value[0]);
55
+ const A = computed(() => colours.value[1]);
56
+ const B = computed(() => colours.value[2]);
57
+ /** The logo's four nodes for the orbit, with steps between the colours. */
58
+ const nodes = computed(() => [
59
+ [911, 512, B.value],
60
+ [512, 911, `color-mix(in srgb, ${B.value} 70%, ${A.value})`],
61
+ [113, 512, `color-mix(in srgb, ${BIG.value} 60%, ${A.value})`],
62
+ [512, 113, A.value],
63
+ ] as const);
64
+ /** Typing stars: the big sparkle drawn small three times in a row. */
65
+ const typing = computed(() => [[250, A.value], [512, BIG.value], [774, B.value]] as const);
66
+
67
+ const measure = ref<HTMLElement | null>(null);
68
+ const boxWidth = ref<number | null>(null);
69
+ const growing = ref(false);
70
+ async function fit(): Promise<void> {
71
+ await nextTick();
72
+ // Rounded up, plus a pixel to spare: one pixel short is enough for the
73
+ // browser to swap the word's end for its own "…", which Firefox draws
74
+ // invisibly on this gradient text.
75
+ const raw = measure.value?.getBoundingClientRect().width ?? 0;
76
+ const w = raw ? Math.ceil(raw) + 1 : 0;
77
+ if (!w || w === boxWidth.value) return;
78
+ growing.value = boxWidth.value === null || w > boxWidth.value;
79
+ boxWidth.value = w;
80
+ }
81
+ watch(() => props.word, () => { void fit(); });
82
+
83
+ // The word's width can also change on its own, when the font loads late.
84
+ let resized: ResizeObserver | null = null;
85
+ onMounted(() => {
86
+ void fit();
87
+ if (typeof ResizeObserver !== "undefined" && measure.value) {
88
+ resized = new ResizeObserver(() => { void fit(); });
89
+ resized.observe(measure.value);
90
+ }
91
+ });
92
+ onBeforeUnmount(() => resized?.disconnect());
93
+ </script>
94
+
95
+ <template>
96
+ <div
97
+ class="wp-wl"
98
+ :data-tempo="tempo"
99
+ :data-variant="variant"
100
+ :data-inline="inline ? 'true' : undefined"
101
+ role="status"
102
+ :aria-label="name"
103
+ :title="name"
104
+ :style="{ '--wp-wl-big': BIG, '--wp-wl-a': A, '--wp-wl-b': B }"
105
+ >
106
+ <svg class="wp-wl__logo" viewBox="0 0 1024 1024" :width="size" :height="size" aria-hidden="true">
107
+ <template v-if="variant === 'typing'">
108
+ <g v-for="([x, c], i) in typing" :key="i" class="wp-wl__hop" :style="{ '--wp-wl-k': i }">
109
+ <path class="wp-wl__star" :style="{ fill: c }" :transform="`translate(${x - 256} 256) scale(.5)`" :d="STAR_BIG" />
110
+ </g>
111
+ </template>
112
+ <g v-else-if="variant === 'shuffle'" transform="translate(512 512) scale(1.45) translate(-512 -512)">
113
+ <path class="wp-wl__star wp-wl__star--big wp-wl__dip" :style="{ fill: BIG }" :d="STAR_BIG" />
114
+ <g class="wp-wl__vb wp-wl__swap">
115
+ <path class="wp-wl__star" :style="{ fill: A }" :d="STAR_A" />
116
+ <path class="wp-wl__star" :style="{ fill: B }" :d="STAR_B" />
117
+ </g>
118
+ </g>
119
+ <template v-else-if="variant === 'orbit'">
120
+ <g class="wp-wl__vb wp-wl__spin">
121
+ <circle
122
+ v-for="([x, y, c], i) in nodes" :key="i" class="wp-wl__dot"
123
+ :style="{ '--wp-wl-k': i, fill: c }" :cx="x" :cy="y" r="70"
124
+ />
125
+ </g>
126
+ <path class="wp-wl__star wp-wl__star--big wp-wl__breathe" :style="{ fill: BIG }" :d="STAR_BIG" />
127
+ <path class="wp-wl__star" :style="{ fill: A }" :d="STAR_A" />
128
+ <path class="wp-wl__star" :style="{ fill: B }" :d="STAR_B" />
129
+ </template>
130
+ <g v-else transform="translate(512 512) scale(1.3) translate(-512 -512)">
131
+ <path class="wp-wl__star wp-wl__star--big wp-wl__pop" :style="{ fill: BIG }" :d="STAR_BIG" />
132
+ <path class="wp-wl__star wp-wl__burst" :style="{ fill: A, '--wp-wl-dx': '152px', '--wp-wl-dy': '-152px' }" :d="STAR_A" />
133
+ <path class="wp-wl__star wp-wl__burst" :style="{ fill: B, '--wp-wl-dx': '-152px', '--wp-wl-dy': '152px' }" :d="STAR_B" />
134
+ </g>
135
+ </svg>
136
+ <span
137
+ class="wp-wl__words"
138
+ :data-grow="growing ? 'true' : undefined"
139
+ :style="boxWidth ? { width: `${boxWidth}px` } : undefined"
140
+ >
141
+ <Transition name="wp-wl-word" type="transition">
142
+ <span :key="key" class="wp-wl__word" data-test="working-line-word">{{ word }}…</span>
143
+ </Transition>
144
+ <span ref="measure" class="wp-wl__word wp-wl__measure" aria-hidden="true">{{ word }}…</span>
145
+ </span>
146
+ <span v-if="time !== undefined" class="wp-wl__time" data-test="working-line-time">{{ time }}</span>
147
+ </div>
148
+ </template>
149
+
150
+ <style scoped>
151
+ .wp-wl {
152
+ --wp-wl-t: 2.8s;
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 8px;
156
+ min-width: 0;
157
+ color: var(--wp-text-muted);
158
+ font-size: 13px;
159
+ white-space: nowrap;
160
+ }
161
+ .wp-wl[data-tempo="mid"] { --wp-wl-t: 2.2s; }
162
+ .wp-wl[data-tempo="fast"] { --wp-wl-t: 1.5s; }
163
+ .wp-wl__logo { flex-shrink: 0; overflow: visible; }
164
+ .wp-wl__words {
165
+ position: relative;
166
+ display: inline-flex;
167
+ align-items: center;
168
+ min-width: 0;
169
+ max-width: 100%;
170
+ overflow: hidden;
171
+ /* Narrows once the old word has mostly faded; widens at once. */
172
+ transition: width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) 0.3s;
173
+ }
174
+ .wp-wl__words[data-grow="true"] { transition: none; }
175
+ .wp-wl__word {
176
+ display: inline-block;
177
+ min-width: 0;
178
+ overflow: hidden;
179
+ text-overflow: ellipsis;
180
+ font-weight: 500;
181
+ /* A light band sweeps over the gradient text, like Claude's own line.
182
+ The band is a fixed white, like the logo's brand colours. */
183
+ background:
184
+ linear-gradient(100deg, transparent 35%, rgba(255, 255, 255, 0.75) 50%, transparent 65%) no-repeat,
185
+ linear-gradient(90deg, var(--wp-wl-big), var(--wp-wl-a) 45%, var(--wp-wl-b));
186
+ background-size: 250% 100%, 100% 100%;
187
+ background-position: 150% 0, 0 0;
188
+ -webkit-background-clip: text; background-clip: text; color: transparent;
189
+ animation: wp-wl-shimmer 2.6s linear infinite;
190
+ }
191
+ @keyframes wp-wl-shimmer {
192
+ 0% { background-position: 150% 0, 0 0; }
193
+ 70%, 100% { background-position: -50% 0, 0 0; }
194
+ }
195
+ /* Sized like the word, never seen: what the box grows or shrinks to. */
196
+ .wp-wl__measure {
197
+ position: absolute; left: 0; top: 0;
198
+ visibility: hidden; pointer-events: none; animation: none;
199
+ max-width: none; overflow: visible;
200
+ }
201
+ .wp-wl__time { flex-shrink: 0; font-variant-numeric: tabular-nums; color: var(--wp-text-dim); }
202
+ .wp-wl[data-inline="true"] { font-size: 12px; }
203
+ .wp-wl[data-inline="true"] .wp-wl__words { max-width: 240px; }
204
+ /* Crossfade in place: the old word fades out on top of the new one. The
205
+ * fade and blur run through registered custom properties (see the marks
206
+ * below for why): an `opacity` / `filter` transition lifts the word onto a
207
+ * compositor layer, which re-rasterises the text and snaps it to whole
208
+ * pixels as the fade begins. The leaving word keeps its exact spot (top 0,
209
+ * the box is one word tall), with no -50% translate to land between pixels. */
210
+ .wp-wl-word-enter-active, .wp-wl-word-leave-active {
211
+ opacity: var(--wp-wl-wo);
212
+ filter: blur(var(--wp-wl-wb));
213
+ transition: --wp-wl-wo 0.45s ease, --wp-wl-wb 0.45s ease;
214
+ }
215
+ .wp-wl-word-leave-active { position: absolute; left: 0; top: 0; }
216
+ .wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wo: 0; --wp-wl-wb: 2px; }
217
+ @media (prefers-reduced-motion: reduce) {
218
+ .wp-wl__word { animation: none; }
219
+ .wp-wl__words { transition: none; }
220
+ .wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wb: 0px; }
221
+ }
222
+
223
+ /* Every mark animates registered custom properties, never `transform` or
224
+ * `opacity` themselves. A `transform` / `opacity` animation goes to the
225
+ * compositor, which lifts each path onto its own layer as it starts:
226
+ * rasterised once at a fixed scale and snapped to whole pixels, so the sharp
227
+ * mark went soft and jumped half a pixel the moment it began to move. A
228
+ * custom property animates on the main thread and each frame repaints the
229
+ * vector paths in place, as sharp as at rest. */
230
+ @property --wp-wl-x { syntax: "<length>"; inherits: false; initial-value: 0px; }
231
+ @property --wp-wl-f { syntax: "<number>"; inherits: false; initial-value: 0; }
232
+ @property --wp-wl-r { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
233
+ @property --wp-wl-s { syntax: "<number>"; inherits: false; initial-value: 1; }
234
+ @property --wp-wl-o { syntax: "<number>"; inherits: false; initial-value: 1; }
235
+ @property --wp-wl-wo { syntax: "<number>"; inherits: false; initial-value: 1; }
236
+ @property --wp-wl-wb { syntax: "<length>"; inherits: false; initial-value: 0px; }
237
+
238
+ .wp-wl__star, .wp-wl__dot, .wp-wl__hop { transform-box: fill-box; transform-origin: center; }
239
+ .wp-wl__vb { transform-box: view-box; transform-origin: 512px 512px; }
240
+
241
+ /* Typing stars: each hops in turn. */
242
+ .wp-wl__hop {
243
+ transform: translateY(var(--wp-wl-x)) scale(var(--wp-wl-s));
244
+ animation: wp-wl-hop var(--wp-wl-t) ease-in-out infinite;
245
+ animation-delay: calc(var(--wp-wl-t) * var(--wp-wl-k) * 0.15);
246
+ }
247
+ @keyframes wp-wl-hop {
248
+ 0%, 40%, 100% { --wp-wl-x: 0px; --wp-wl-s: 1; }
249
+ 20% { --wp-wl-x: -110px; --wp-wl-s: 1.12; }
250
+ }
251
+
252
+ /* Shuffle: the small stars swap places, the big one dips and turns. */
253
+ .wp-wl__swap {
254
+ transform: rotate(var(--wp-wl-r));
255
+ animation: wp-wl-swap calc(var(--wp-wl-t) * 2) cubic-bezier(0.65, 0, 0.35, 1) infinite;
256
+ }
257
+ .wp-wl__dip {
258
+ transform: rotate(var(--wp-wl-r)) scale(var(--wp-wl-s));
259
+ animation: wp-wl-dip var(--wp-wl-t) cubic-bezier(0.65, 0, 0.35, 1) infinite;
260
+ }
261
+ @keyframes wp-wl-swap {
262
+ 0%, 15% { --wp-wl-r: 0deg; }
263
+ 50%, 65% { --wp-wl-r: 180deg; }
264
+ 100% { --wp-wl-r: 360deg; }
265
+ }
266
+ @keyframes wp-wl-dip {
267
+ 0%, 15% { --wp-wl-s: 1; --wp-wl-r: 0deg; }
268
+ 32% { --wp-wl-s: 0.7; --wp-wl-r: 45deg; }
269
+ 50%, 100% { --wp-wl-s: 1; --wp-wl-r: 90deg; }
270
+ }
271
+
272
+ /* Orbit: the four nodes circle the stars and twinkle in turn. */
273
+ .wp-wl__spin {
274
+ transform: rotate(var(--wp-wl-r));
275
+ animation: wp-wl-spin calc(var(--wp-wl-t) * 2) cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
276
+ }
277
+ .wp-wl__dot {
278
+ opacity: var(--wp-wl-o);
279
+ transform: scale(var(--wp-wl-s));
280
+ animation: wp-wl-twinkle var(--wp-wl-t) ease-in-out infinite;
281
+ animation-delay: calc(var(--wp-wl-t) * var(--wp-wl-k) * -0.25);
282
+ }
283
+ .wp-wl__breathe {
284
+ transform: scale(var(--wp-wl-s));
285
+ animation: wp-wl-breathe var(--wp-wl-t) ease-in-out infinite;
286
+ }
287
+ @keyframes wp-wl-spin { from { --wp-wl-r: 0deg; } to { --wp-wl-r: 360deg; } }
288
+ @keyframes wp-wl-twinkle {
289
+ 0%, 100% { --wp-wl-o: 1; --wp-wl-s: 1; }
290
+ 50% { --wp-wl-o: 0.35; --wp-wl-s: 0.6; }
291
+ }
292
+ @keyframes wp-wl-breathe { 0%, 100% { --wp-wl-s: 1; } 50% { --wp-wl-s: 0.86; } }
293
+
294
+ /* Burst: the small stars fly out of the big one and back. `--wp-wl-f` is
295
+ * how far out they are (1 = at --wp-wl-dx/dy, 0 = home). */
296
+ .wp-wl__burst {
297
+ opacity: var(--wp-wl-o);
298
+ transform:
299
+ translate(calc(var(--wp-wl-dx) * var(--wp-wl-f)), calc(var(--wp-wl-dy) * var(--wp-wl-f)))
300
+ scale(var(--wp-wl-s));
301
+ animation: wp-wl-burst var(--wp-wl-t) cubic-bezier(0.3, 0, 0.2, 1) infinite;
302
+ }
303
+ .wp-wl__pop {
304
+ transform: scale(var(--wp-wl-s));
305
+ animation: wp-wl-pop var(--wp-wl-t) ease-in-out infinite;
306
+ }
307
+ @keyframes wp-wl-burst {
308
+ 0% { --wp-wl-f: 1; --wp-wl-s: 0; --wp-wl-o: 0; }
309
+ 15% { --wp-wl-o: 1; }
310
+ 45%, 75% { --wp-wl-f: 0; --wp-wl-s: 1.15; --wp-wl-o: 1; }
311
+ 100% { --wp-wl-f: 1; --wp-wl-s: 0; --wp-wl-o: 0; }
312
+ }
313
+ @keyframes wp-wl-pop { 0%, 100% { --wp-wl-s: 1.1; } 40% { --wp-wl-s: 0.8; } }
314
+
315
+ @media (prefers-reduced-motion: reduce) {
316
+ .wp-wl__logo * { animation: none !important; }
317
+ .wp-wl__logo { opacity: var(--wp-wl-o); animation: wp-wl-fade 2.4s ease-in-out infinite; }
318
+ @keyframes wp-wl-fade { 0%, 100% { --wp-wl-o: 1; } 50% { --wp-wl-o: 0.55; } }
319
+ }
320
+ </style>
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * WpIconButton: the 20 px square icon button inside a canvas row (module,
4
4
  * injector, bundle and model rows, the Context toolbar). Rows are too tight
5
- * for the 24 px `WpButton size="sm"`, so this is the one smaller control on
5
+ * for the 24 px `Button size="sm"`, so this is the one smaller control on
6
6
  * the canvas, still at the 20 px hit-target floor.
7
7
  *
8
8
  * <WpIconButton icon="pi-trash" label="Remove module" tone="danger" @click="remove" />
@@ -75,7 +75,8 @@ const emit = defineEmits<{ click: [evt: MouseEvent] }>();
75
75
  .wp-icon-btn[data-tone="accent"] { --tone: var(--wp-accent); }
76
76
  .wp-icon-btn[data-tone="warn"] { --tone: var(--wp-warn); }
77
77
  .wp-icon-btn[data-tone="danger"] { --tone: var(--wp-danger); }
78
- .wp-icon-btn .pi { font-size: 11px; }
78
+ /* Even icon in the 18px inside of the box: an odd size sits half a pixel off. */
79
+ .wp-icon-btn .pi { font-size: 12px; }
79
80
  .wp-icon-btn__glyph { font: 600 11px/1 var(--wp-font-mono); }
80
81
  .wp-icon-btn:hover:not(:disabled) { background: var(--wp-bg-4); color: var(--tone); }
81
82
  .wp-icon-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
@@ -25,7 +25,7 @@
25
25
  * <WpImageZoom v-model:zoom="zoom" :src="url" alt="Frame 3" />
26
26
  */
27
27
  import { computed, onBeforeUnmount, ref, watch } from "vue";
28
- import WpButton from "./WpButton.vue";
28
+ import Button from "./Button.vue";
29
29
  import {
30
30
  centreOf, clampView, fitScale, percent, stepScale, viewAround, visibleRect, zoomAt, IDLE_MS, type Size, type View,
31
31
  } from "./image-zoom";
@@ -298,13 +298,13 @@ const at = (s: number): boolean => !isFit.value && Math.abs(view.value.scale - s
298
298
  @focusout="chromeLeave"
299
299
  >
300
300
  <slot name="bar-start" />
301
- <WpButton variant="ghost" :size="size" icon="pi-minus" aria-label="Zoom out" kbd="−" data-test="imgzoom-out" @click="zoomOut" />
302
- <WpButton :variant="isFit ? 'secondary' : 'ghost'" :size="size" kbd="0" hint="Show the whole picture" :aria-pressed="isFit" data-test="imgzoom-fit" @click="setZoom(null)">Fit</WpButton>
303
- <WpButton :variant="at(1) ? 'secondary' : 'ghost'" :size="size" kbd="1" hint="Real size: one image pixel per screen pixel" :aria-pressed="at(1)" data-test="imgzoom-100" @click="setZoom(1)">100%</WpButton>
304
- <WpButton :variant="at(2) ? 'secondary' : 'ghost'" :size="size" kbd="2" hint="Twice the real size" :aria-pressed="at(2)" data-test="imgzoom-200" @click="setZoom(2)">200%</WpButton>
305
- <WpButton variant="ghost" :size="size" icon="pi-plus" aria-label="Zoom in" kbd="+" data-test="imgzoom-in" @click="zoomIn" />
301
+ <Button variant="ghost" :size="size" icon="pi-minus" aria-label="Zoom out" kbd="−" data-test="imgzoom-out" @click="zoomOut" />
302
+ <Button :variant="isFit ? 'secondary' : 'ghost'" :size="size" kbd="0" hint="Show the whole picture" :aria-pressed="isFit" data-test="imgzoom-fit" @click="setZoom(null)">Fit</Button>
303
+ <Button :variant="at(1) ? 'secondary' : 'ghost'" :size="size" kbd="1" hint="Real size: one image pixel per screen pixel" :aria-pressed="at(1)" data-test="imgzoom-100" @click="setZoom(1)">100%</Button>
304
+ <Button :variant="at(2) ? 'secondary' : 'ghost'" :size="size" kbd="2" hint="Twice the real size" :aria-pressed="at(2)" data-test="imgzoom-200" @click="setZoom(2)">200%</Button>
305
+ <Button variant="ghost" :size="size" icon="pi-plus" aria-label="Zoom in" kbd="+" data-test="imgzoom-in" @click="zoomIn" />
306
306
  <span class="wp-imgzoom__level" aria-live="polite" data-test="imgzoom-level">{{ level }}</span>
307
- <WpButton
307
+ <Button
308
308
  v-if="canFull"
309
309
  variant="ghost"
310
310
  :size="size"
@@ -168,7 +168,9 @@ function onKeydown(ev: KeyboardEvent): void {
168
168
  .wp-segmented__opt[data-active="true"] .pi { color: var(--wp-accent-text); }
169
169
  .wp-segmented__opt:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
170
170
  .wp-segmented__opt:disabled { opacity: 0.45; cursor: not-allowed; }
171
- .wp-segmented__opt .pi { font-size: 0.95em; }
171
+ /* Even sizes: an icon-only option is a square, and an odd icon sits half a pixel off in it. */
172
+ .wp-segmented__opt .pi { font-size: 12px; }
173
+ .wp-segmented[data-size="lg"] .wp-segmented__opt .pi { font-size: 14px; }
172
174
  @media (prefers-reduced-motion: reduce) {
173
175
  .wp-segmented__opt { transition: none; }
174
176
  }
package/src/WpSwitch.vue CHANGED
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * The on/off switch for canvas widgets and the manager (Toggle.vue wraps it).
3
+ * The one on/off switch, for canvas widgets and the manager (size="md" there).
4
4
  *
5
5
  * <WpSwitch v-model="held" label="Hold across run" /> track
6
6
  * <WpSwitch v-model="held" variant="chip" icon="pi-link" label="Hold across run" />
package/src/WpTabs.vue CHANGED
@@ -165,7 +165,7 @@ function onKeydown(ev: KeyboardEvent): void {
165
165
  .wp-tabset__tab[data-active="true"] { color: var(--wp-text); border-bottom-color: var(--wp-accent-400); }
166
166
  .wp-tabset__tab:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--wp-border-focus); border-radius: var(--wp-radius-sm); }
167
167
  .wp-tabset__tab:disabled { opacity: 0.45; cursor: not-allowed; }
168
- .wp-tabset__tab .pi { font-size: 0.95em; }
168
+ .wp-tabset__tab .pi { font-size: 12px; }
169
169
  .wp-tabset__count {
170
170
  min-width: 16px;
171
171
  padding: 1px 5px;
@@ -6,7 +6,7 @@
6
6
  * the dialog grows into place.
7
7
  *
8
8
  * Selector for the panel is `> *` (immediate child of the overlay
9
- * element). Works for ModalShell's `.wp-modal-wrapper`, ConfirmDialog's
9
+ * element). Works for ModalShell's `.wp-modal-wrapper` (Modal, ConfirmModal), the old
10
10
  * `.wp-confirm`, InjectorBindingModal's `.ibm`, and any future modal
11
11
  * that follows the overlay-with-one-child layout.
12
12
  *
package/src/button.css CHANGED
@@ -1,13 +1,12 @@
1
- /* Button — the one .wp-btn stylesheet, shared by the manager Button
2
- * (manager/components/ui/Button.vue, loaded globally by manager/main.ts) and
3
- * the canvas twin WpButton (components/shared/WpButton.vue, component
4
- * standard rule 8), which pulls it in scoped. It reads only the
1
+ /* Button — the one .wp-btn stylesheet. Button.vue pulls it in scoped (so it
2
+ * works on the canvas), and the manager and the community site also load it
3
+ * globally for the few plain elements styled as buttons (a router-link). It reads only the
5
4
  * SHARED_TOKENS names, so it wears the manager colours on a page and the
6
5
  * canvas colours in a node.
7
6
  *
8
7
  * Unlayered on purpose: the manager loads it beside tokens.css, where an
9
8
  * unlayered reset would otherwise beat it. The canvas never loads it
10
- * globally: WpButton pulls in a scoped copy, so its classes never meet
9
+ * globally: Button pulls in a scoped copy, so its classes never meet
11
10
  * another extension's `.wp-btn`.
12
11
  *
13
12
  * Heights follow the control scale: sm 24 / md 32 / lg 40.
@@ -21,6 +20,8 @@
21
20
  */
22
21
 
23
22
  .wp-btn {
23
+ /* A link drawn as a button (<a class="wp-btn">, Button as="a") loses the browser's underline; the link variant adds its own on hover. */
24
+ text-decoration: none;
24
25
  height: var(--wp-control-h-md);
25
26
  padding: 0 12px;
26
27
  border-radius: min(7px, var(--wp-radius-cap, 999px));
@@ -46,7 +47,8 @@
46
47
  outline: none;
47
48
  box-shadow: var(--wp-focus-ring);
48
49
  }
49
- .wp-btn .pi, .wp-btn svg { font-size: 13px; }
50
+ /* Even icon sizes in the even control heights: an odd one sits half a pixel off. */
51
+ .wp-btn .pi, .wp-btn svg { font-size: 14px; }
50
52
  /* The label box (not on a link, which has no fixed height) is trimmed to
51
53
  * the cap height where the browser can, so the
52
54
  * flex centring puts the capitals, not the ascender-to-descender box, on the
package/src/floating.ts CHANGED
@@ -53,6 +53,11 @@ function room(side: Side, a: Rect, view: Size, gap: number, margin: number): num
53
53
  }
54
54
  }
55
55
 
56
+ /** Space on one side of the anchor for a panel, after the gap and margin. */
57
+ export function roomOn(side: Side, anchor: Rect, view: Size, opts: Pick<PlaceOptions, "gap" | "margin"> = {}): number {
58
+ return room(side, anchor, view, opts.gap ?? 6, opts.margin ?? 8);
59
+ }
60
+
56
61
  function clamp(v: number, min: number, max: number): number {
57
62
  return max < min ? min : Math.min(Math.max(v, min), max);
58
63
  }
@@ -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
+ }