@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
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
  */
@@ -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/menu.css ADDED
@@ -0,0 +1,51 @@
1
+ /* Menu panel: the one look of every floating list in the kit (Select's list,
2
+ * ContextMenu / DropdownMenu and Combobox's list). Pulled in scoped by
3
+ * Popover (whose `variant="menu"` panel is the menu), ContextMenu, Select and
4
+ * Combobox. It reads only the SHARED_TOKENS names, so a menu wears the
5
+ * manager colours on a page and the canvas colours in a node.
6
+ *
7
+ * <div class="wp-menu"> the panel
8
+ * <li class="wp-menu__item" data-active="true" aria-selected="true">
9
+ *
10
+ * Item states: hover and `[data-active="true"]` (the keyboard's row) share one
11
+ * tint; `[aria-selected="true"]` is a lighter one; `[aria-disabled="true"]` is
12
+ * dim and takes no hover. A piece keeps its own row layout (icons, meta,
13
+ * subtitles, ticks); the sheet sets everything you see around it.
14
+ */
15
+
16
+ .wp-menu {
17
+ z-index: 10020; /* above every modal overlay (they run up to 10010) */
18
+ box-sizing: border-box;
19
+ margin: 0;
20
+ padding: 4px;
21
+ list-style: none;
22
+ background: var(--wp-bg-2);
23
+ color: var(--wp-text);
24
+ border: 1px solid var(--wp-border-strong);
25
+ border-radius: min(8px, var(--wp-radius-cap, 999px));
26
+ box-shadow: var(--wp-shadow-md);
27
+ font-family: var(--wp-font-sans, sans-serif);
28
+ font-size: 12px;
29
+ }
30
+
31
+ .wp-menu__item {
32
+ padding: 6px 8px;
33
+ border-radius: min(6px, var(--wp-radius-cap, 999px));
34
+ color: var(--wp-text);
35
+ font-size: 12px;
36
+ cursor: pointer;
37
+ user-select: none;
38
+ }
39
+ .wp-menu__item[aria-selected="true"] {
40
+ background: color-mix(in oklab, var(--wp-accent) 12%, transparent);
41
+ }
42
+ .wp-menu__item:hover,
43
+ .wp-menu__item[data-active="true"] {
44
+ background: var(--wp-accent-glow);
45
+ color: var(--wp-text);
46
+ }
47
+ .wp-menu__item[aria-disabled="true"] {
48
+ color: var(--wp-text-dim);
49
+ background: transparent;
50
+ cursor: not-allowed;
51
+ }
package/src/motion.css ADDED
@@ -0,0 +1,73 @@
1
+ /*
2
+ * Entrances and nudges for things that appear while you watch: a streamed
3
+ * reply's new words, a reaction, a chip by the selection, a question that
4
+ * needs an answer. Add the class to the element as it mounts.
5
+ *
6
+ * .wp-enter-fade fades in (150 ms): each new run of streamed text.
7
+ * .wp-enter-pop pops in with a small overshoot (320 ms): a reaction.
8
+ * .wp-enter-rise rises 4 px into place (140 ms): a chip above a
9
+ * selection; add data-from="below" when it sits under it.
10
+ * .wp-enter-drop drops 6 px into place (500 ms): a panel that asks for
11
+ * something.
12
+ * .wp-attention a soft accent ring that pulses three times (after
13
+ * .wp-enter-drop when both are set).
14
+ *
15
+ * The moving element keeps its own position: set --wp-enter-x for an extra
16
+ * horizontal shift (a chip centred with -50%), and it goes into the same
17
+ * transform.
18
+ *
19
+ * Every entrance animates registered custom properties, never `opacity` or
20
+ * `transform` themselves: those go to the compositor, which lifts the element
21
+ * onto its own layer while it moves and drops it after, so text went soft
22
+ * during the motion and snapped half a pixel at its end.
23
+ */
24
+ @property --wp-enter-o { syntax: "<number>"; inherits: false; initial-value: 1; }
25
+ @property --wp-enter-s { syntax: "<number>"; inherits: false; initial-value: 1; }
26
+ @property --wp-enter-y { syntax: "<length>"; inherits: false; initial-value: 0px; }
27
+
28
+ @layer wp-extension {
29
+ .wp-enter-fade {
30
+ opacity: var(--wp-enter-o);
31
+ animation: wp-enter-fade 150ms ease-out;
32
+ }
33
+ .wp-enter-pop {
34
+ opacity: var(--wp-enter-o);
35
+ transform: translateX(var(--wp-enter-x, 0px)) scale(var(--wp-enter-s));
36
+ animation: wp-enter-pop 0.32s cubic-bezier(0.3, 1.6, 0.5, 1) both;
37
+ }
38
+ .wp-enter-rise {
39
+ opacity: var(--wp-enter-o);
40
+ transform: translate(var(--wp-enter-x, 0px), var(--wp-enter-y));
41
+ animation: wp-enter-rise 0.14s ease-out both;
42
+ }
43
+ .wp-enter-rise[data-from="below"] { animation-name: wp-enter-rise-below; }
44
+ .wp-enter-drop {
45
+ opacity: var(--wp-enter-o);
46
+ transform: translate(var(--wp-enter-x, 0px), var(--wp-enter-y));
47
+ animation: wp-enter-drop 0.5s ease-out;
48
+ }
49
+ .wp-attention { animation: wp-attention 2.4s ease-in-out 3; }
50
+ .wp-enter-drop.wp-attention {
51
+ animation: wp-enter-drop 0.5s ease-out, wp-attention 2.4s ease-in-out 0.5s 3;
52
+ }
53
+
54
+ @keyframes wp-enter-fade { from { --wp-enter-o: 0; } to { --wp-enter-o: 1; } }
55
+ @keyframes wp-enter-pop {
56
+ from { --wp-enter-s: 0.3; --wp-enter-o: 0; }
57
+ to { --wp-enter-s: 1; --wp-enter-o: 1; }
58
+ }
59
+ @keyframes wp-enter-rise {
60
+ from { --wp-enter-o: 0; --wp-enter-y: 4px; }
61
+ to { --wp-enter-o: 1; --wp-enter-y: 0px; }
62
+ }
63
+ @keyframes wp-enter-rise-below {
64
+ from { --wp-enter-o: 0; --wp-enter-y: -4px; }
65
+ to { --wp-enter-o: 1; --wp-enter-y: 0px; }
66
+ }
67
+ @keyframes wp-enter-drop { from { --wp-enter-o: 0; --wp-enter-y: -6px; } }
68
+ @keyframes wp-attention { 50% { box-shadow: 0 0 0 6px var(--wp-accent-glow); } }
69
+
70
+ @media (prefers-reduced-motion: reduce) {
71
+ .wp-enter-fade, .wp-enter-pop, .wp-enter-rise, .wp-enter-drop, .wp-attention { animation: none; }
72
+ }
73
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Number helpers for sliders (Slider now, the two-thumb range later): clamp
2
+ * Number helpers for Slider (one thumb or a pair): clamp
3
3
  * to the ends, snap to the step counted from `min`, and turn a pointer
4
4
  * position into a value. Steps like 0.1 snap without float fuzz.
5
5
  */
@@ -43,3 +43,47 @@ export function keyStep(key: string, v: number, min: number, max: number, step:
43
43
  default: return null;
44
44
  }
45
45
  }
46
+
47
+ /**
48
+ * Mark snapping. `snap: true` keeps the value on a mark (the nearest one);
49
+ * a number pulls it onto a mark only within that distance, so the thumb
50
+ * moves freely by its step elsewhere and "clicks" into a mark near one.
51
+ * With no marks, or snap off, the value comes back as it was.
52
+ */
53
+ export function snapToMarks(v: number, marks: readonly number[], snapTo: boolean | number | undefined): number {
54
+ if (!snapTo || !marks.length) return v;
55
+ let best = marks[0] ?? v;
56
+ for (const m of marks) if (Math.abs(m - v) < Math.abs(best - v)) best = m;
57
+ if (snapTo === true) return best;
58
+ return Math.abs(best - v) <= snapTo ? best : v;
59
+ }
60
+
61
+ /**
62
+ * A key press on a slider that only takes marks (`snap: true`): arrows go to
63
+ * the next mark up or down, PageUp/PageDown jump `big` marks, Home/End the
64
+ * first and last. Null for a key the slider ignores.
65
+ */
66
+ export function keyToMark(key: string, v: number, marks: readonly number[], big = 3): number | null {
67
+ const sorted = [...new Set(marks)].sort((a, b) => a - b);
68
+ if (!sorted.length) return null;
69
+ // Where v sits: the index of the mark at it, or between two marks.
70
+ const at = sorted.findIndex((m) => m >= v);
71
+ const exact = at >= 0 && sorted[at] === v;
72
+ const up = (n: number): number => {
73
+ const from = at < 0 ? sorted.length : exact ? at : at - 1;
74
+ return sorted[Math.min(sorted.length - 1, from + n)] ?? v;
75
+ };
76
+ const down = (n: number): number => {
77
+ const from = at < 0 ? sorted.length : at;
78
+ return sorted[Math.max(0, from - n)] ?? v;
79
+ };
80
+ switch (key) {
81
+ case "ArrowRight": case "ArrowUp": return up(1);
82
+ case "ArrowLeft": case "ArrowDown": return down(1);
83
+ case "PageUp": return up(big);
84
+ case "PageDown": return down(big);
85
+ case "Home": return sorted[0] ?? v;
86
+ case "End": return sorted[sorted.length - 1] ?? v;
87
+ default: return null;
88
+ }
89
+ }
@@ -47,8 +47,10 @@
47
47
  /* --wp-radius-cap: the roundest a corner may get in this look. Fixed radii
48
48
  in components are written min(Npx, var(--wp-radius-cap, 999px)) and
49
49
  pills var(--wp-radius-cap, 999px), so Orbit draws them as authored and a
50
- square look squares them without a rule per component. Circles (50%)
51
- stay circles. The theme audit (scripts/theme-audit) fails on a corner
50
+ square look squares them without a rule per component. Circles are
51
+ min(50%, var(--wp-radius-cap, 999px)): round in Orbit, softened in Crisp,
52
+ square in Terminal and TUI (dots, radios, avatars, the timeline).
53
+ src/__tests__/corners.test.ts fails on a raw radius. The theme audit (scripts/theme-audit) fails on a corner
52
54
  rounder than the cap. */
53
55
  html[data-wp-look="crisp"] { --wp-radius-cap: 4px; }
54
56
  html[data-wp-look="terminal"],
@@ -362,10 +364,8 @@ html[data-wp-look="tui"] .wp-check:is([aria-checked="true"], [data-checked="true
362
364
  html[data-wp-look="tui"] .wp-check:is([aria-checked="mixed"], [data-indeterminate="true"]):not([data-checked="true"])::before { content: "[-]"; color: var(--wp-accent-text); }
363
365
  html[data-wp-look="tui"] .wp-page__title { font-size: 16px; font-weight: 700; letter-spacing: 0; }
364
366
  /* Radios read ( ) / (*) beside the [ ] / [x] checkboxes; the drawn dot
365
- hides. RadioGroup marks the picked option on the row (data-checked), the
366
- plain .wp-radio is a real input (:checked). */
367
- html[data-wp-look="tui"] .wp-radio-group__opt .wp-radio-group__dot,
368
- html[data-wp-look="tui"] .wp-radio {
367
+ hides. RadioGroup marks the picked option on the row (data-checked). */
368
+ html[data-wp-look="tui"] .wp-radio-group__opt .wp-radio-group__dot {
369
369
  width: auto;
370
370
  min-width: 3ch;
371
371
  height: auto;
@@ -381,19 +381,6 @@ html[data-wp-look="tui"] .wp-radio-group__opt[data-checked] .wp-radio-group__dot
381
381
  html[data-wp-look="tui"] .wp-radio-group__dot::before { content: "( )"; }
382
382
  html[data-wp-look="tui"] .wp-radio-group__opt[data-checked] .wp-radio-group__dot::before { content: "(*)"; color: var(--wp-accent-text); }
383
383
  html[data-wp-look="tui"] .wp-radio-group__opt[aria-disabled="true"][data-checked] .wp-radio-group__dot::before { color: var(--wp-text-dim); }
384
- html[data-wp-look="tui"] .wp-radio::before,
385
- html[data-wp-look="tui"] .wp-radio:disabled::before {
386
- content: "( )";
387
- width: auto;
388
- height: auto;
389
- border-radius: 0;
390
- background: transparent;
391
- transform: none;
392
- }
393
- html[data-wp-look="tui"] .wp-radio:checked::before { content: "(*)"; color: var(--wp-accent-text); }
394
- html[data-wp-look="tui"] .wp-radio:disabled:checked::before { content: "(*)"; color: var(--wp-text-dim); }
395
- html[data-wp-look="tui"] .wp-radio:checked,
396
- html[data-wp-look="tui"] .wp-radio:disabled:checked { background: transparent; }
397
384
 
398
385
  /* ================= Canvas previews ignore the look =================
399
386
  The canvas keeps ComfyUI's ground in every look (only the accent follows