@dumiflex/wp-kit 0.5.0 → 0.7.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 (89) 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/BundlePreview.vue +254 -0
  7. package/src/Button.vue +73 -44
  8. package/src/Checkbox.vue +201 -63
  9. package/src/Chip.vue +5 -2
  10. package/src/CodeBlock.vue +1 -1
  11. package/src/ColorField.vue +60 -6
  12. package/src/CombinePreview.vue +150 -0
  13. package/src/Combobox.vue +10 -14
  14. package/src/ConfirmModal.vue +12 -22
  15. package/src/ConstraintMatrixPreview.vue +6 -22
  16. package/src/ConstraintPreview.vue +259 -0
  17. package/src/ContextMenu.vue +11 -30
  18. package/src/CopyButton.vue +1 -1
  19. package/src/DataTable.vue +0 -2
  20. package/src/DatePicker.vue +1 -1
  21. package/src/DerivationCondition.vue +68 -0
  22. package/src/DerivationPreview.vue +345 -0
  23. package/src/Disclosure.vue +2 -1
  24. package/src/Drawer.vue +2 -2
  25. package/src/DropZone.vue +2 -2
  26. package/src/FixedValuesPreview.vue +184 -0
  27. package/src/HsvPicker.vue +2 -2
  28. package/src/IconPicker.vue +32 -103
  29. package/src/IconPickerPanel.vue +132 -0
  30. package/src/ImageGrid.vue +2 -2
  31. package/src/Lightbox.vue +2 -2
  32. package/src/LoadingMark.vue +6 -61
  33. package/src/Modal.vue +109 -44
  34. package/src/ModalShell.vue +26 -5
  35. package/src/Pagination.vue +4 -4
  36. package/src/PayloadPreview.vue +154 -0
  37. package/src/Popover.vue +68 -12
  38. package/src/PostDetailSkeleton.vue +65 -0
  39. package/src/PostGridSkeleton.vue +61 -0
  40. package/src/PreviewGroup.vue +75 -0
  41. package/src/ProfileSkeleton.vue +73 -0
  42. package/src/RadioGroup.vue +5 -4
  43. package/src/RichText.vue +61 -0
  44. package/src/SearchInput.vue +1 -1
  45. package/src/Select.vue +53 -158
  46. package/src/SelectionBar.vue +90 -0
  47. package/src/SettingGroup.vue +57 -0
  48. package/src/SettingRow.vue +127 -0
  49. package/src/Skeleton.vue +1 -1
  50. package/src/Slider.vue +166 -55
  51. package/src/SortableList.vue +1 -1
  52. package/src/TagInput.vue +1 -1
  53. package/src/Timeline.vue +2 -2
  54. package/src/WildcardPreview.vue +607 -0
  55. package/src/WorkingLine.vue +234 -0
  56. package/src/WpIconButton.vue +3 -2
  57. package/src/WpImageZoom.vue +7 -7
  58. package/src/WpSegmented.vue +3 -1
  59. package/src/WpSwitch.vue +1 -1
  60. package/src/WpTabs.vue +1 -1
  61. package/src/_modal-motion.css +1 -1
  62. package/src/button.css +8 -6
  63. package/src/canvas-motion.ts +251 -0
  64. package/src/floating.ts +5 -0
  65. package/src/hsv.ts +1 -1
  66. package/src/kind-colour.ts +24 -1
  67. package/src/linkify.ts +142 -0
  68. package/src/loading-mark-motion.ts +12 -155
  69. package/src/matrix-axis.ts +28 -0
  70. package/src/menu.css +51 -0
  71. package/src/motion.css +73 -0
  72. package/src/pi-icons.ts +51 -0
  73. package/src/preview-readers.ts +401 -0
  74. package/src/preview.css +194 -0
  75. package/src/slider-math.ts +45 -1
  76. package/src/theme/looks.css +6 -19
  77. package/src/theme/tokens.css +12 -1108
  78. package/src/tokens.ts +15 -4
  79. package/src/tooltip.css +8 -5
  80. package/src/useDialogFocus.ts +11 -2
  81. package/src/working-line-motion.ts +135 -0
  82. package/src/CloseButton.vue +0 -69
  83. package/src/ColorPicker.vue +0 -201
  84. package/src/ConfirmDialog.vue +0 -165
  85. package/src/RangeSlider.vue +0 -183
  86. package/src/Toggle.vue +0 -36
  87. package/src/WpButton.vue +0 -75
  88. package/src/WpCheck.vue +0 -104
  89. package/src/_skeleton.css +0 -50
@@ -0,0 +1,234 @@
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
+ * The marks move on a canvas over the still SVG, so they stay sharp and
24
+ * glide in every browser (`canvas-motion.ts` says why); the SVG is the first
25
+ * paint and stays under reduced motion, where it only breathes.
26
+ */
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
+ import { canvasBox, useCanvasMotion } from "./canvas-motion";
30
+ import { TEMPO_MS, TYPING_X, drawWorking, orbitNodes, type WorkingVariant } from "./working-line-motion";
31
+
32
+ export type { WorkingVariant } from "./working-line-motion";
33
+
34
+ const props = withDefaults(defineProps<{
35
+ /** What is shown, without the "…" (the line adds it). */
36
+ word: string;
37
+ /** What the crossfade keys on; defaults to the word. */
38
+ wordKey?: string | undefined;
39
+ /** Time so far, already formatted ("42s"). Left out, no time shows. */
40
+ time?: string | undefined;
41
+ variant?: WorkingVariant | undefined;
42
+ tempo?: "slow" | "mid" | "fast" | undefined;
43
+ /** The mark's size in px. */
44
+ size?: number | undefined;
45
+ /** Smaller text and a capped word width, for a top bar. */
46
+ inline?: boolean | undefined;
47
+ /** The whole status, for screen readers and the tooltip; defaults to the word. */
48
+ label?: string | undefined;
49
+ brand?: LogoBrand | undefined;
50
+ }>(), { variant: "typing", tempo: "slow", size: 22, inline: false, brand: "extension" });
51
+
52
+ const key = computed(() => props.wordKey ?? props.word);
53
+ const name = computed(() => props.label ?? props.word);
54
+
55
+ const colours = computed(() => BRAND_COLOURS[props.brand]);
56
+ const BIG = computed(() => colours.value[0]);
57
+ const A = computed(() => colours.value[1]);
58
+ const B = computed(() => colours.value[2]);
59
+ /** The logo's four nodes for the orbit, with steps between the colours. */
60
+ const nodes = computed(() => orbitNodes(colours.value));
61
+ /** Typing stars: the big sparkle drawn small three times in a row. */
62
+ const typing = computed(() => TYPING_X.map((x, i) => [x, [A.value, BIG.value, B.value][i] ?? BIG.value] as const));
63
+
64
+ const canvas = ref<HTMLCanvasElement | null>(null);
65
+ const canvasStyle = computed(() => canvasBox(props.size));
66
+ const { live, drawn } = useCanvasMotion(canvas, (ctx, width, ms) =>
67
+ drawWorking(ctx, width, ms, props.variant, TEMPO_MS[props.tempo], colours.value));
68
+
69
+ const measure = ref<HTMLElement | null>(null);
70
+ const boxWidth = ref<number | null>(null);
71
+ const growing = ref(false);
72
+ async function fit(): Promise<void> {
73
+ await nextTick();
74
+ // Rounded up, plus a pixel to spare: one pixel short is enough for the
75
+ // browser to swap the word's end for its own "…", which Firefox draws
76
+ // invisibly on this gradient text.
77
+ const raw = measure.value?.getBoundingClientRect().width ?? 0;
78
+ const w = raw ? Math.ceil(raw) + 1 : 0;
79
+ if (!w || w === boxWidth.value) return;
80
+ growing.value = boxWidth.value === null || w > boxWidth.value;
81
+ boxWidth.value = w;
82
+ }
83
+ watch(() => props.word, () => { void fit(); });
84
+
85
+ // The word's width can also change on its own, when the font loads late.
86
+ let resized: ResizeObserver | null = null;
87
+ onMounted(() => {
88
+ void fit();
89
+ if (typeof ResizeObserver !== "undefined" && measure.value) {
90
+ resized = new ResizeObserver(() => { void fit(); });
91
+ resized.observe(measure.value);
92
+ }
93
+ });
94
+ onBeforeUnmount(() => resized?.disconnect());
95
+ </script>
96
+
97
+ <template>
98
+ <div
99
+ class="wp-wl"
100
+ :data-tempo="tempo"
101
+ :data-variant="variant"
102
+ :data-inline="inline ? 'true' : undefined"
103
+ :data-drawn="drawn ? 'true' : undefined"
104
+ role="status"
105
+ :aria-label="name"
106
+ :title="name"
107
+ :style="{ '--wp-wl-big': BIG, '--wp-wl-a': A, '--wp-wl-b': B }"
108
+ >
109
+ <span class="wp-wl__art" :style="{ width: `${size}px`, height: `${size}px` }">
110
+ <svg class="wp-wl__logo" viewBox="0 0 1024 1024" :width="size" :height="size" aria-hidden="true">
111
+ <template v-if="variant === 'typing'">
112
+ <path
113
+ v-for="([x, c], i) in typing" :key="i" :style="{ fill: c }"
114
+ :transform="`translate(${x - 256} 256) scale(.5)`" :d="STAR_BIG"
115
+ />
116
+ </template>
117
+ <g v-else-if="variant === 'shuffle'" transform="translate(512 512) scale(1.45) translate(-512 -512)">
118
+ <path :style="{ fill: BIG }" :d="STAR_BIG" />
119
+ <path :style="{ fill: A }" :d="STAR_A" />
120
+ <path :style="{ fill: B }" :d="STAR_B" />
121
+ </g>
122
+ <template v-else-if="variant === 'orbit'">
123
+ <circle v-for="([x, y, c], i) in nodes" :key="i" :style="{ fill: c }" :cx="x" :cy="y" r="70" />
124
+ <path :style="{ fill: BIG }" :d="STAR_BIG" />
125
+ <path :style="{ fill: A }" :d="STAR_A" />
126
+ <path :style="{ fill: B }" :d="STAR_B" />
127
+ </template>
128
+ <g v-else transform="translate(512 512) scale(1.3) translate(-512 -512)">
129
+ <path :style="{ fill: BIG }" :d="STAR_BIG" />
130
+ <path :style="{ fill: A }" :d="STAR_A" />
131
+ <path :style="{ fill: B }" :d="STAR_B" />
132
+ </g>
133
+ </svg>
134
+ <canvas v-if="live" ref="canvas" class="wp-wl__canvas" :style="canvasStyle" aria-hidden="true" />
135
+ </span>
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
+ display: flex;
153
+ align-items: center;
154
+ gap: 8px;
155
+ min-width: 0;
156
+ color: var(--wp-text-muted);
157
+ font-size: 13px;
158
+ white-space: nowrap;
159
+ }
160
+ .wp-wl__logo { overflow: visible; }
161
+ .wp-wl__words {
162
+ position: relative;
163
+ display: inline-flex;
164
+ align-items: center;
165
+ min-width: 0;
166
+ max-width: 100%;
167
+ overflow: hidden;
168
+ /* Narrows once the old word has mostly faded; widens at once. */
169
+ transition: width 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) 0.3s;
170
+ }
171
+ .wp-wl__words[data-grow="true"] { transition: none; }
172
+ .wp-wl__word {
173
+ display: inline-block;
174
+ min-width: 0;
175
+ overflow: hidden;
176
+ text-overflow: ellipsis;
177
+ font-weight: 500;
178
+ /* A light band sweeps over the gradient text, like Claude's own line.
179
+ The band is a fixed white, like the logo's brand colours. */
180
+ background:
181
+ linear-gradient(100deg, transparent 35%, rgba(255, 255, 255, 0.75) 50%, transparent 65%) no-repeat,
182
+ linear-gradient(90deg, var(--wp-wl-big), var(--wp-wl-a) 45%, var(--wp-wl-b));
183
+ background-size: 250% 100%, 100% 100%;
184
+ background-position: 150% 0, 0 0;
185
+ -webkit-background-clip: text; background-clip: text; color: transparent;
186
+ animation: wp-wl-shimmer 2.6s linear infinite;
187
+ }
188
+ @keyframes wp-wl-shimmer {
189
+ 0% { background-position: 150% 0, 0 0; }
190
+ 70%, 100% { background-position: -50% 0, 0 0; }
191
+ }
192
+ /* Sized like the word, never seen: what the box grows or shrinks to. */
193
+ .wp-wl__measure {
194
+ position: absolute; left: 0; top: 0;
195
+ visibility: hidden; pointer-events: none; animation: none;
196
+ max-width: none; overflow: visible;
197
+ }
198
+ .wp-wl__time { flex-shrink: 0; font-variant-numeric: tabular-nums; color: var(--wp-text-dim); }
199
+ .wp-wl[data-inline="true"] { font-size: 12px; }
200
+ .wp-wl[data-inline="true"] .wp-wl__words { max-width: 240px; }
201
+ /* Crossfade in place: the old word fades out on top of the new one. The
202
+ * fade and blur run through registered custom properties (see the marks
203
+ * below for why): an `opacity` / `filter` transition lifts the word onto a
204
+ * compositor layer, which re-rasterises the text and snaps it to whole
205
+ * pixels as the fade begins. The leaving word keeps its exact spot (top 0,
206
+ * the box is one word tall), with no -50% translate to land between pixels. */
207
+ .wp-wl-word-enter-active, .wp-wl-word-leave-active {
208
+ opacity: var(--wp-wl-wo);
209
+ filter: blur(var(--wp-wl-wb));
210
+ transition: --wp-wl-wo 0.45s ease, --wp-wl-wb 0.45s ease;
211
+ }
212
+ .wp-wl-word-leave-active { position: absolute; left: 0; top: 0; }
213
+ .wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wo: 0; --wp-wl-wb: 2px; }
214
+ @media (prefers-reduced-motion: reduce) {
215
+ .wp-wl__word { animation: none; }
216
+ .wp-wl__words { transition: none; }
217
+ .wp-wl-word-enter-from, .wp-wl-word-leave-to { --wp-wl-wb: 0px; }
218
+ }
219
+
220
+ /* The marks move on the canvas; the SVG is the still mark under it. */
221
+ .wp-wl__art { position: relative; display: inline-block; flex-shrink: 0; line-height: 0; }
222
+ .wp-wl__logo { display: block; }
223
+ .wp-wl__canvas { position: absolute; pointer-events: none; }
224
+ .wp-wl[data-drawn="true"] .wp-wl__logo { visibility: hidden; }
225
+
226
+ @property --wp-wl-o { syntax: "<number>"; inherits: false; initial-value: 1; }
227
+ @property --wp-wl-wo { syntax: "<number>"; inherits: false; initial-value: 1; }
228
+ @property --wp-wl-wb { syntax: "<length>"; inherits: false; initial-value: 0px; }
229
+
230
+ @media (prefers-reduced-motion: reduce) {
231
+ .wp-wl__logo { opacity: var(--wp-wl-o); animation: wp-wl-fade 2.4s ease-in-out infinite; }
232
+ @keyframes wp-wl-fade { 0%, 100% { --wp-wl-o: 1; } 50% { --wp-wl-o: 0.55; } }
233
+ }
234
+ </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
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Looping marks drawn on a canvas (LoadingMark, WorkingLine).
3
+ *
4
+ * A keyframed `transform` / `opacity` on an SVG path goes to the compositor,
5
+ * which blurs the path and snaps it to whole pixels as it starts. Animating
6
+ * registered custom properties fixed that in Chromium, but Firefox still lays
7
+ * each frame of an SVG transform on the pixel grid, so small marks stepped
8
+ * and twitched as they moved. A canvas puts every shape at its exact
9
+ * fractional position each frame, sharp and smooth in both.
10
+ *
11
+ * One `requestAnimationFrame` loop draws every mark on the page. Each mark
12
+ * keeps its own clock, so a host can slow or pause the marks inside it with
13
+ * `data-wp-motion-speed` (a number) and `data-wp-motion-paused` on an
14
+ * ancestor, as the kit site's Animations section does.
15
+ *
16
+ * A mark keeps its still SVG for the first paint, under reduced motion and
17
+ * in a test DOM; `useCanvasMotion` puts the canvas over it once it can draw.
18
+ */
19
+ import { nextTick, onBeforeUnmount, onMounted, ref, type Ref } from "vue";
20
+
21
+ /** Room around the 1024 box for parts that move past its edge, and for
22
+ * anti-aliasing there. */
23
+ export const PAD = 82;
24
+ export const BOX = 1024 + 2 * PAD;
25
+
26
+ /** CSS `cubic-bezier(x1, y1, x2, y2)` as a function of progress. */
27
+ export function cubicBezier(x1: number, y1: number, x2: number, y2: number): (x: number) => number {
28
+ return (x) => {
29
+ if (x <= 0) return 0;
30
+ if (x >= 1) return 1;
31
+ let lo = 0;
32
+ let hi = 1;
33
+ let t = x;
34
+ for (let k = 0; k < 30; k++) {
35
+ t = (lo + hi) / 2;
36
+ const cx = 3 * x1 * t * (1 - t) ** 2 + 3 * x2 * t * t * (1 - t) + t ** 3;
37
+ if (cx < x) lo = t;
38
+ else hi = t;
39
+ }
40
+ return 3 * y1 * t * (1 - t) ** 2 + 3 * y2 * t * t * (1 - t) + t ** 3;
41
+ };
42
+ }
43
+
44
+ export const EASE = cubicBezier(0.25, 0.1, 0.25, 1);
45
+ export const EASE_IN_OUT = cubicBezier(0.42, 0, 0.58, 1);
46
+ export const SMOOTH = cubicBezier(0.65, 0, 0.35, 1);
47
+
48
+ export type Stop = readonly [at: number, values: readonly number[]];
49
+
50
+ /** Value of a keyframe track at progress `p` (0..1), eased per segment the
51
+ * way CSS eases between two keyframes. */
52
+ export function track(stops: readonly Stop[], p: number, ease: (x: number) => number): number[] {
53
+ for (let k = 0; k < stops.length - 1; k++) {
54
+ const a = stops[k];
55
+ const b = stops[k + 1];
56
+ if (!a || !b) break;
57
+ if (p >= a[0] && p <= b[0]) {
58
+ if (b[0] === a[0]) return [...b[1]];
59
+ const e = ease((p - a[0]) / (b[0] - a[0]));
60
+ return a[1].map((v, j) => v + ((b[1][j] ?? v) - v) * e);
61
+ }
62
+ }
63
+ return [...(stops[stops.length - 1]?.[1] ?? [])];
64
+ }
65
+
66
+ /** Progress (0..1) through a loop of `period` ms, `ms` into the animation.
67
+ * A positive `delay` holds the start pose until it has passed, like CSS. */
68
+ export function loop(ms: number, period: number, delay = 0): number {
69
+ const t = ms - delay;
70
+ if (t < 0) return 0;
71
+ return (t % period) / period;
72
+ }
73
+
74
+ /** Mixes two `#rrggbb` colours in sRGB, like `color-mix(in srgb, a w%, b)`. */
75
+ export function mix(a: string, b: string, weightA: number): string {
76
+ const ch = (hex: string, i: number): number => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
77
+ const out = [0, 1, 2].map((i) => Math.round(ch(a, i) * weightA + ch(b, i) * (1 - weightA)));
78
+ return `#${out.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
79
+ }
80
+
81
+ /** Fills `path` scaled and turned around `[x, y]`. */
82
+ export function fillAround(
83
+ ctx: CanvasRenderingContext2D,
84
+ [x, y]: readonly [number, number],
85
+ scale: number,
86
+ turn: number,
87
+ path: Path2D,
88
+ fill: string | CanvasGradient,
89
+ alpha = 1,
90
+ ): void {
91
+ ctx.save();
92
+ ctx.translate(x, y);
93
+ ctx.rotate((turn * Math.PI) / 180);
94
+ ctx.scale(scale, scale);
95
+ ctx.translate(-x, -y);
96
+ ctx.globalAlpha = alpha;
97
+ ctx.fillStyle = fill;
98
+ ctx.fill(path);
99
+ ctx.restore();
100
+ }
101
+
102
+ /** Clears the canvas and sets it up to draw in the 1024 box. */
103
+ export function begin(ctx: CanvasRenderingContext2D, width: number): void {
104
+ const k = width / BOX;
105
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
106
+ ctx.clearRect(0, 0, width, width);
107
+ ctx.setTransform(k, 0, 0, k, PAD * k, PAD * k);
108
+ }
109
+
110
+ /** Draws one frame `ms` into the animation on a square canvas `width` px wide. */
111
+ export type Draw = (ctx: CanvasRenderingContext2D, width: number, ms: number) => void;
112
+
113
+ /** A mark the loop draws. */
114
+ export interface Animated {
115
+ canvas: HTMLCanvasElement;
116
+ draw: Draw;
117
+ /** ms of animation shown so far, advanced by the loop. */
118
+ clock: number;
119
+ /** False while off screen: the clock still runs, nothing is drawn. */
120
+ visible: boolean;
121
+ }
122
+
123
+ const marks = new Set<Animated>();
124
+ let frame = 0;
125
+ let last = 0;
126
+
127
+ /** Speed and pause from the nearest host that sets them. */
128
+ export function motionOf(el: Element): { speed: number; paused: boolean } {
129
+ const paused = el.closest("[data-wp-motion-paused]") !== null;
130
+ const speedHost = el.closest("[data-wp-motion-speed]");
131
+ const speed = Number(speedHost?.getAttribute("data-wp-motion-speed") ?? "1");
132
+ return { speed: Number.isFinite(speed) && speed >= 0 ? speed : 1, paused };
133
+ }
134
+
135
+ function fit(c: HTMLCanvasElement): void {
136
+ const want = Math.max(1, Math.round(c.clientWidth * (window.devicePixelRatio || 1)));
137
+ if (c.width !== want) {
138
+ c.width = want;
139
+ c.height = want;
140
+ }
141
+ }
142
+
143
+ function paint(m: Animated): boolean {
144
+ const ctx = m.canvas.getContext("2d");
145
+ if (!ctx) return false;
146
+ fit(m.canvas);
147
+ m.draw(ctx, m.canvas.width, m.clock);
148
+ return true;
149
+ }
150
+
151
+ function tick(now: number): void {
152
+ const dt = last ? Math.min(now - last, 100) : 0;
153
+ last = now;
154
+ for (const m of marks) {
155
+ const { speed, paused } = motionOf(m.canvas);
156
+ if (!paused) m.clock += dt * speed;
157
+ if (m.visible) paint(m);
158
+ }
159
+ frame = marks.size ? requestAnimationFrame(tick) : 0;
160
+ }
161
+
162
+ /** Starts drawing a mark; returns the function that stops it. */
163
+ export function animate(mark: Animated): () => void {
164
+ marks.add(mark);
165
+ if (!frame) {
166
+ last = 0;
167
+ frame = requestAnimationFrame(tick);
168
+ }
169
+ return () => {
170
+ marks.delete(mark);
171
+ if (!marks.size && frame) {
172
+ cancelAnimationFrame(frame);
173
+ frame = 0;
174
+ }
175
+ };
176
+ }
177
+
178
+ /** Draws one frame right away (before the loop runs, so the mark never
179
+ * shows blank). False when there is no 2D canvas. */
180
+ export function drawNow(mark: Animated): boolean {
181
+ return paint(mark);
182
+ }
183
+
184
+ /** Inline style for a canvas over a `px`-wide mark, reaching PAD past it. */
185
+ export function canvasBox(px: number): Record<string, string> {
186
+ const side = (px * BOX) / 1024;
187
+ const off = (px * PAD) / 1024;
188
+ return { width: `${side}px`, height: `${side}px`, top: `${-off}px`, left: `${-off}px` };
189
+ }
190
+
191
+ /**
192
+ * Runs a mark's canvas while motion is allowed. Render
193
+ * `<canvas v-if="live" ref="canvas">` over the still SVG, and hide the SVG
194
+ * while `drawn`.
195
+ */
196
+ export function useCanvasMotion(canvas: Ref<HTMLCanvasElement | null>, draw: Draw): { live: Ref<boolean>; drawn: Ref<boolean> } {
197
+ const live = ref(false);
198
+ const drawn = ref(false);
199
+ let stop: (() => void) | null = null;
200
+ let observer: IntersectionObserver | null = null;
201
+ let motion: MediaQueryList | null = null;
202
+
203
+ async function start(): Promise<void> {
204
+ if (stop) return;
205
+ live.value = true;
206
+ await nextTick();
207
+ const el = canvas.value;
208
+ if (!el || !live.value) return;
209
+ const mark: Animated = { canvas: el, draw, clock: 0, visible: true };
210
+ if (!drawNow(mark)) {
211
+ live.value = false;
212
+ return;
213
+ }
214
+ drawn.value = true;
215
+ stop = animate(mark);
216
+ if (typeof IntersectionObserver === "function") {
217
+ observer = new IntersectionObserver((entries) => {
218
+ for (const e of entries) mark.visible = e.isIntersecting;
219
+ });
220
+ observer.observe(el);
221
+ }
222
+ }
223
+ function halt(): void {
224
+ stop?.();
225
+ stop = null;
226
+ observer?.disconnect();
227
+ observer = null;
228
+ live.value = false;
229
+ drawn.value = false;
230
+ }
231
+ function onMotionChange(): void {
232
+ if (motion?.matches) halt();
233
+ else void start();
234
+ }
235
+
236
+ onMounted(() => {
237
+ // No matchMedia means no way to honour reduced motion, and a test DOM
238
+ // (jsdom) has no canvas and logs an error for every attempt: keep the
239
+ // still SVG in both.
240
+ if (typeof window.matchMedia !== "function" || typeof requestAnimationFrame !== "function") return;
241
+ if (navigator.userAgent.includes("jsdom")) return;
242
+ motion = window.matchMedia("(prefers-reduced-motion: reduce)");
243
+ motion.addEventListener?.("change", onMotionChange);
244
+ if (!motion.matches) void start();
245
+ });
246
+ onBeforeUnmount(() => {
247
+ motion?.removeEventListener?.("change", onMotionChange);
248
+ halt();
249
+ });
250
+ return { live, drawn };
251
+ }
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
  }
package/src/hsv.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * lowercase. Functions are tolerant of out-of-range input — they clamp
6
6
  * rather than throw, which keeps the picker UI robust under fast drags.
7
7
  *
8
- * Lives in shared/ so both the manager-side ColorPicker / TweaksPanel
8
+ * Lives in shared/ so both the ColorField / TweaksPanel
9
9
  * AND the canvas-side bundle modal can drive the same HsvPicker
10
10
  * without a cross-package import.
11
11
  */
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The colour of each module type, for a Chip (or anything else) that names
3
3
  * one: the `--wp-kind-*` colour the extension uses for that type, bundles
4
- * slate. There is no kind chip: a kind is a Chip with this colour.
4
+ * slate, and its icon (`kindIcon`). There is no kind chip: a kind is a Chip
5
+ * with this colour.
5
6
  *
6
7
  * <Chip :color="kindColour(itemType(post.kind, post.subtype))">{{ itemType(post.kind, post.subtype) }}</Chip>
7
8
  */
@@ -26,3 +27,25 @@ export function itemType(kind: "module" | "bundle", subtype?: ItemType | null):
26
27
  export function kindColour(type: ItemType): string {
27
28
  return COLOURS[type];
28
29
  }
30
+
31
+ /** The PrimeIcons name (for Icon / ListRow `icon`) of each item type: the
32
+ * same icons the extension's kind-icons map uses. */
33
+ const ICONS: Record<ItemType, string> = {
34
+ wildcard: "sparkles",
35
+ fixed_values: "tag",
36
+ combine: "link",
37
+ derivation: "arrow-right-arrow-left",
38
+ constraint: "filter",
39
+ bundle: "box",
40
+ };
41
+
42
+ /** The icon of a type read from a payload (any string): `circle` for a type
43
+ * the kit doesn't know. */
44
+ export function kindIcon(type: string): string {
45
+ return (ICONS as Record<string, string>)[type] ?? "circle";
46
+ }
47
+
48
+ /** A payload's type string as a module subtype, or null when it isn't one. */
49
+ export function moduleSubtype(type: string): ModuleSubtype | null {
50
+ return type === "fixed_values" || type === "combine" || type === "wildcard" || type === "constraint" || type === "derivation" ? type : null;
51
+ }