@celestia-island/hikari 0.43.5 → 0.43.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.43.5",
3
+ "version": "0.43.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -347,19 +347,17 @@
347
347
  transition: transform 0.12s ease, box-shadow 0.12s ease;
348
348
  }
349
349
 
350
- /* The scale runs ALONG each list's own drag axis, never across it. A drag
351
- * resolves its landing slot from the items' LIVE rects (usePointerReorder
352
- * .indexAt): the item's MIDPOINT on the drag axis picks the slot, and on
353
- * the axis ACROSS it every item's span decides which line the pointer is
354
- * on — a filter that started out a no-op precisely because every row spans
355
- * the list's full width. A scale is symmetric about the item's centre, so
356
- * scaling along the drag axis leaves both readings exactly as they were;
357
- * growing ACROSS it widens one item's band, and a pointer a few pixels
358
- * outside the strip (the row list's own padding is 6px) would then match
359
- * that item ALONE — every sibling falls out of the line filter, the slot
360
- * collapses back onto the item's own index, and the drag ends as a silent
361
- * no-op with no drop ring anywhere. Chips therefore scale on X (a
362
- * horizontal strip), rows on Y (a vertical list). */
350
+ /* The scale runs ALONG each list's own drag axis — chips on X (a horizontal
351
+ * strip), rows on Y (a vertical list). That split is TASTE, not geometry: a
352
+ * small scale that follows the gesture keeps the lifted item tight in a
353
+ * dense chip row. A drag resolves its landing slot from the items' LIVE
354
+ * rects (usePointerReorder's `indexAt`): the item's MIDPOINT on the drag
355
+ * axis picks the slot, and on the axis ACROSS it every item's span decides
356
+ * which line the pointer is on — a filter that started out a no-op precisely
357
+ * because every row spans the list's full width. The item being dragged may
358
+ * not decide that line (a drop onto its own slot is already a no-op), so a
359
+ * centre-anchored transform on it — along the axis or across it — leaves the
360
+ * resolution exactly where it was. */
363
361
  .hk-tag-input-tag-dragging {
364
362
  transform: scaleX(1.03);
365
363
  }
@@ -152,10 +152,14 @@ type HkTagStop =
152
152
  * with the library's usual reduced-motion opt-outs (the lift's scale
153
153
  * and shadow stay — only their 0.12s ramp is dropped, so a
154
154
  * reduced-motion user keeps the state marker without the motion). The
155
- * lift scales ALONG each list's drag axis and never across it: the drop
156
- * slot is resolved from the items' live rects, so a span grown across
157
- * that axis would hide every sibling from the pointer and silently
158
- * collapse the gesture into a no-op;
155
+ * lift scales ALONG each list's drag axis by TASTE, not by necessity: a
156
+ * small scale that follows the gesture keeps the lifted item's own
157
+ * footprint tight in a dense chip row. The drop slot is resolved from
158
+ * the items' live rects, but the dragged item never decides which line
159
+ * the pointer is on (it is only ever a candidate in the slot loop, and
160
+ * a drop onto its own slot is already a no-op), so a centre-preserving
161
+ * transform on it — along the axis, across it, or an isotropic
162
+ * `scale()` — leaves the resolution exactly where it was;
159
163
  * - a key in `modelValue` that the catalog does not carry (a custom
160
164
  * tag, or an option deleted since) degrades to its raw key as the
161
165
  * tag label — the same rule as HkAffixPicker.tagEntries. Such a key
@@ -40,4 +40,24 @@ describe("HkThemeToggle row lead-cell contract", () => {
40
40
  // target the lead slot class.
41
41
  expect(css).not.toMatch(/\.s-theme-item-btn \.hk-menu-item-icon\s*\{/);
42
42
  });
43
+
44
+ it("aligns the customize row to the lead column without rescaling its glyph", () => {
45
+ const css = read("HkThemeToggle.scss");
46
+ // The customize affordance joins the 28px lead column (2026-09-11
47
+ // field report: its palette sat a full cell left of the rows' bitmap
48
+ // marks), but the glyph must keep the standard menu icon size —
49
+ // mi.icon normalizes svg to the cell, so the box is pinned back.
50
+ const block = css.match(
51
+ /\.s-theme-item-customize \.hk-menu-item-icon\s*\{[^}]*\}/,
52
+ );
53
+ expect(block).not.toBeNull();
54
+ expect(block![0]).toContain("width: 28px");
55
+ expect(block![0]).toContain("height: 28px");
56
+
57
+ const svgBlock = css.match(
58
+ /\.s-theme-item-customize \.hk-menu-item-icon > svg\s*\{[^}]*\}/,
59
+ );
60
+ expect(svgBlock).not.toBeNull();
61
+ expect(svgBlock![0]).toContain("var(--hk-menu-item-icon-box)");
62
+ });
43
63
  });
@@ -210,6 +210,26 @@
210
210
  height: 28px;
211
211
  }
212
212
 
213
+ /* The customize affordance joins the theme rows' 28px lead column so the
214
+ * palette glyph and the row marks sit on one line and every name starts
215
+ * at the same x (2026-09-11 field report: with the lead cell at 28px and
216
+ * this row at the generic box, the selected theme's bitmap mark read a
217
+ * full cell to the right of the customize palette). The cell widens but
218
+ * the GLYPH keeps the standard menu icon size — mi.icon normalizes svg
219
+ * to the cell, so the box is pinned back explicitly; widening a cell
220
+ * must never re-scale its glyph (the 0.42.2 regression class). */
221
+ .s-theme-item-customize .hk-menu-item-icon {
222
+ @include mi.icon;
223
+
224
+ width: 28px;
225
+ height: 28px;
226
+ }
227
+
228
+ .s-theme-item-customize .hk-menu-item-icon > svg {
229
+ width: var(--hk-menu-item-icon-box);
230
+ height: var(--hk-menu-item-icon-box);
231
+ }
232
+
213
233
  /* Custom rows keep the name clear of the overlaid delete button. */
214
234
  .s-theme-item-row[data-custom] .s-theme-item-name {
215
235
  margin-right: 2rem;
@@ -332,7 +332,7 @@ export const HkThemeToggle = defineComponent({
332
332
 
333
333
  <button
334
334
  type="button"
335
- class="s-theme-item-btn"
335
+ class="s-theme-item-btn s-theme-item-customize"
336
336
  onClick={() => {
337
337
  menuOpen.value = false;
338
338
  if (props.externalCustomize) {
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
  import { effectScope, type EffectScope } from "vue";
3
3
 
4
- import { usePointerReorder, type PointerReorder } from "./usePointerReorder";
4
+ import { usePointerReorder, indexAt, type PointerReorder } from "./usePointerReorder";
5
5
 
6
6
  /** Items with PINNED rects — happy-dom ships no layout engine, so every
7
7
  * item is placed by hand along the axis under test (size 40, the same
@@ -34,6 +34,22 @@ function strip(axis: "x" | "y", count = 3, size = 40) {
34
34
  return { container, items };
35
35
  }
36
36
 
37
+ /** Items with hand-placed boxes on BOTH axes — the wrapping-strip pattern,
38
+ * for the geometries only a transform produces (a band widened across the
39
+ * strip while the item keeps its place along it). */
40
+ function boxes(geometry: Array<{ left: number; right: number; top: number; bottom: number }>): HTMLElement[] {
41
+ return geometry.map((box) => {
42
+ const el = document.createElement("div");
43
+ Object.defineProperty(el, "getBoundingClientRect", {
44
+ configurable: true,
45
+ value: () =>
46
+ ({ ...box, width: box.right - box.left, height: box.bottom - box.top, x: box.left, y: box.top }) as DOMRect,
47
+ });
48
+ document.body.appendChild(el);
49
+ return el;
50
+ });
51
+ }
52
+
37
53
  /** Waits out `frames` animation frames — the auto-scroll loop steps once
38
54
  * per frame (the repo's useApproachEnd pattern). */
39
55
  async function frames(count: number): Promise<void> {
@@ -395,6 +411,112 @@ describe("usePointerReorder", () => {
395
411
  ]);
396
412
  });
397
413
 
414
+ it("resolves past a dragged chip whose own band is widened across the strip", () => {
415
+ // One line of three chips, the DRAGGED one (index 1) reporting a band
416
+ // widened ACROSS the strip — which is what any transform on it does: a
417
+ // host's `scale()` on a wrapper, a zoomed container, a theme animating a
418
+ // chip. The pointer sits 3px outside the SIBLINGS' band (43 against
419
+ // 0-40) and inside the widened one, so the dragged chip is the unique
420
+ // zero-gap match: left to define the line on its own it filters every
421
+ // sibling out and the slot collapses onto its own index — a silent
422
+ // no-op, no reorder and no drop ring.
423
+ const items = boxes([
424
+ { left: 0, right: 100, top: 0, bottom: 40 },
425
+ { left: 100, right: 200, top: -8, bottom: 48 },
426
+ { left: 200, right: 300, top: 0, bottom: 40 },
427
+ ]);
428
+ const { handle, drops } = harness("x", items);
429
+
430
+ press(handle, items, 1, { x: 150, y: 20 });
431
+ move(260, 43);
432
+ expect(handle.dragging.value).toBe(true);
433
+ expect(handle.dragOver.value, "260 is past item 2's midpoint of 250").toBe(2);
434
+ up(260, 43);
435
+ expect(drops).toEqual([[1, 2]]);
436
+ });
437
+
438
+ it("resolves the panel rows the same way when the dragged row's band is widened", () => {
439
+ // The same defect on the vertical strip, whose cross axis is x: the
440
+ // dragged row (index 1) reports its band widened from 0-40 to -8-48, so
441
+ // the pointer at x 44 is 4px outside the siblings' band and inside the
442
+ // dragged one. Both ends of the strip answer — the leading edge, then
443
+ // the last row.
444
+ const items = boxes([
445
+ { left: 0, right: 40, top: 0, bottom: 40 },
446
+ { left: -8, right: 48, top: 40, bottom: 80 },
447
+ { left: 0, right: 40, top: 80, bottom: 120 },
448
+ ]);
449
+ const { handle, drops } = harness("y", items);
450
+
451
+ press(handle, items, 1, { x: 20, y: 60 });
452
+ move(44, 5);
453
+ expect(handle.dragging.value).toBe(true);
454
+ expect(handle.dragOver.value, "5 is before every row's midpoint").toBe(0);
455
+ up(44, 5);
456
+ expect(drops).toEqual([[1, 0]]);
457
+
458
+ // …and 140 is past the last row's midpoint of 100.
459
+ press(handle, items, 1, { x: 20, y: 60 });
460
+ move(44, 140);
461
+ expect(handle.dragOver.value).toBe(2);
462
+ up(44, 140);
463
+ expect(drops).toEqual([
464
+ [1, 0],
465
+ [1, 2],
466
+ ]);
467
+ });
468
+
469
+ it("resolves the no-drag path (from = -1) exactly as it always did", () => {
470
+ // `from = -1` is the call with no drag in flight: nothing is the item
471
+ // being dragged, so nothing may be left out of the line — the exclusion
472
+ // is INERT there, not a rewrite of the resolution. The widened band
473
+ // therefore still owns the line on its own, which is the pre-fix value
474
+ // the strip resolved before the dragged item was ever excluded (the same
475
+ // gesture with `from = 1` answers 0 instead — see the panel-rows test).
476
+ const items = boxes([
477
+ { left: 0, right: 40, top: 0, bottom: 40 },
478
+ { left: -8, right: 48, top: 40, bottom: 80 },
479
+ { left: 0, right: 40, top: 80, bottom: 120 },
480
+ ]);
481
+ const rects = items.map((item) => item.getBoundingClientRect());
482
+
483
+ expect(indexAt(rects, "y", 5, 44, -1), "the widened row still owns the line").toBe(1);
484
+ // …and the geometry the fix never touched: the ends, and an empty strip.
485
+ expect(indexAt(rects, "y", 5000, 20, -1), "past every midpoint").toBe(3);
486
+ expect(indexAt(rects, "y", -5, 20, -1), "before every midpoint").toBe(0);
487
+ expect(indexAt([], "x", 0, 0, -1), "an empty strip has no slot").toBe(-1);
488
+
489
+ // The user-visible half of the same invariant: with no gesture at all, a
490
+ // pointer move resolves nothing and publishes nothing.
491
+ const { handle, drops } = harness("y", items);
492
+ move(44, 5);
493
+ expect(handle.dragOver.value).toBe(-1);
494
+ expect(handle.dragFrom.value).toBe(-1);
495
+ expect(drops).toEqual([]);
496
+ });
497
+
498
+ it("lets the dragged item speak for a line no sibling shares", () => {
499
+ // A chip wrapped onto a line of its own (index 2) while it is dragged:
500
+ // no sibling is on that line, so the dragged item's band IS the line the
501
+ // pointer is on and a drag within it stays the no-op it always was.
502
+ // Ignoring the dragged item's band outright instead would let the
503
+ // nearest OTHER line — a full row above — answer, and a 90px drag along
504
+ // its own line would jump the chip up into it.
505
+ const items = boxes([
506
+ { left: 0, right: 100, top: 0, bottom: 40 },
507
+ { left: 100, right: 200, top: 0, bottom: 40 },
508
+ { left: 0, right: 60, top: 40, bottom: 80 },
509
+ ]);
510
+ const { handle, drops } = harness("x", items);
511
+
512
+ press(handle, items, 2, { x: 5, y: 60 });
513
+ move(100, 60);
514
+ expect(handle.dragging.value).toBe(true);
515
+ expect(handle.dragOver.value, "the pointer is still on the chip's own line").toBe(2);
516
+ up(100, 60);
517
+ expect(drops).toEqual([]);
518
+ });
519
+
398
520
  it("retires a stale click trap when a new press starts", () => {
399
521
  const { items } = strip("x");
400
522
  const { handle } = harness("x", items);
@@ -77,6 +77,95 @@ export interface PointerReorder {
77
77
  start: (event: PointerEvent, index: number) => void;
78
78
  }
79
79
 
80
+ /** The display index the dragged item lands ON for a pointer at
81
+ * (`main`, `cross`) — the pointer's coordinates split into the strip's
82
+ * axis and the axis across it — over `rects`, starting from index `from`
83
+ * (`-1` while no drag is in flight). The composable's own resolution, kept
84
+ * pure so its tests can pin the no-drag path: this module is internal to
85
+ * the package, so the export is not part of the published surface.
86
+ *
87
+ * Resolved in two steps. First the LINE: items whose cross-axis span
88
+ * holds the pointer (or is nearest to it) are the candidates, which is
89
+ * what makes a WRAPPING chip row behave — a chip on the second line can
90
+ * never be matched by a pointer on the first. A single-file strip (the
91
+ * panel's column, whose rows all span the list width) has every item in
92
+ * one span, so filtering by line is a strict no-op there — it only ever
93
+ * separates items that really are on different lines.
94
+ *
95
+ * Then the INSERTION POINT: the first candidate whose midpoint still
96
+ * lies beyond the pointer (past the line's end when it is beyond every
97
+ * candidate midpoint), which becomes a landing index by accounting for
98
+ * the dragged item's own removal — dragging past one neighbour's
99
+ * midpoint swaps with that neighbour instead of skipping it. -1 for an
100
+ * empty strip. */
101
+ export function indexAt(
102
+ rects: readonly DOMRect[],
103
+ axis: PointerReorderAxis,
104
+ main: number,
105
+ cross: number,
106
+ from: number,
107
+ ): number {
108
+ if (rects.length === 0) return -1;
109
+ /** An item's span ACROSS the strip — the axis the line is read on. */
110
+ const band = (rect: DOMRect) =>
111
+ axis === "x" ? { lo: rect.top, hi: rect.bottom } : { lo: rect.left, hi: rect.right };
112
+ // Nearest line wins: items in the pointer's own band score 0, and a
113
+ // pointer between two bands takes the closer one. The half-pixel
114
+ // tolerance keeps the siblings of ONE wrapped line together, whose
115
+ // bands can differ by sub-pixel rounding.
116
+ const lineGap = rects.map((rect) => {
117
+ const { lo, hi } = band(rect);
118
+ return cross < lo ? lo - cross : cross > hi ? cross - hi : 0;
119
+ });
120
+ // The line is decided by the items OTHER than the one being dragged: a
121
+ // drop onto the dragged item's own slot is already a no-op, so it must
122
+ // never be the geometry that decides which line the pointer is on —
123
+ // which is what makes the resolution invariant to whatever transform the
124
+ // host paints on the item being moved. Its live rect reflects that
125
+ // transform (the drag lift, a zoomed container, a `scale()` on a wrapper),
126
+ // so a band widened across the strip would otherwise be the unique
127
+ // zero-gap match: every sibling filtered out, the slot collapsed onto the
128
+ // item's own index, and the whole drag a silent no-op.
129
+ let nearest = Number.POSITIVE_INFINITY;
130
+ for (let i = 0; i < lineGap.length; i += 1) {
131
+ if (i !== from && lineGap[i] < nearest) nearest = lineGap[i];
132
+ }
133
+ // …unless the dragged item is ALONE on its line — no sibling whose band
134
+ // holds its centre. A transform about the item's own centre (this
135
+ // library's lift, a zoomed container, a `scale()` on a wrapper) leaves
136
+ // that centre on the line the item was laid out on, whatever the band
137
+ // grew to, so a lone item's band IS that line: the exclusion above cannot
138
+ // see the line at all and the item speaks for itself — a single-file
139
+ // strip, or a chip wrapped onto a line of its own, keeps the answer it
140
+ // had. A transform that moves the centre OFF that line (an edge-anchored
141
+ // scale of ~2x or more, a translate of a whole line — nothing this
142
+ // library paints) is the one case these rects cannot read: the centre
143
+ // then decides from the line it landed on.
144
+ if (from >= 0 && from < rects.length) {
145
+ const own = band(rects[from]);
146
+ const centre = (own.lo + own.hi) / 2;
147
+ const shared = rects.some((rect, i) => {
148
+ if (i === from) return false;
149
+ const { lo, hi } = band(rect);
150
+ return centre >= lo - 0.5 && centre <= hi + 0.5;
151
+ });
152
+ if (!shared && lineGap[from] < nearest) nearest = lineGap[from];
153
+ }
154
+ let slot = -1;
155
+ for (let i = 0; i < rects.length; i += 1) {
156
+ if (lineGap[i] > nearest + 0.5) continue;
157
+ const rect = rects[i];
158
+ const mid = axis === "x" ? rect.left + rect.width / 2 : rect.top + rect.height / 2;
159
+ if (main < mid) {
160
+ slot = i;
161
+ break;
162
+ }
163
+ slot = i + 1;
164
+ }
165
+ if (slot < 0) slot = rects.length;
166
+ return from >= 0 && slot > from ? slot - 1 : slot;
167
+ }
168
+
80
169
  /**
81
170
  * usePointerReorder — pointer drag-to-reorder for a strip of elements.
82
171
  *
@@ -146,53 +235,19 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
146
235
  return options.items().filter((el): el is HTMLElement => el != null);
147
236
  }
148
237
 
149
- /** The display index the dragged item lands ON for a pointer at
150
- * (`main`, `cross`) — the pointer's coordinates split into the strip's
151
- * axis and the axis across it — starting from index `from`.
152
- *
153
- * Resolved in two steps. First the LINE: items whose cross-axis span
154
- * holds the pointer (or is nearest to it) are the candidates, which is
155
- * what makes a WRAPPING chip row behave — a chip on the second line can
156
- * never be matched by a pointer on the first. A single-file strip (the
157
- * panel's column, whose rows all span the list width) has every item in
158
- * one span, so filtering by line is a strict no-op there — it only ever
159
- * separates items that really are on different lines.
160
- *
161
- * Then the INSERTION POINT: the first candidate whose midpoint still
162
- * lies beyond the pointer (past the line's end when it is beyond every
163
- * candidate midpoint), which becomes a landing index by accounting for
164
- * the dragged item's own removal — dragging past one neighbour's
165
- * midpoint swaps with that neighbour instead of skipping it. -1 for an
166
- * empty strip. */
167
- function indexAt(main: number, cross: number, from: number): number {
238
+ /** The slot the live strip resolves for a pointer at (`main`, `cross`),
239
+ * starting from index `from` — the elements measured, then handed to
240
+ * `indexAt`, which carries the geometry's rules. */
241
+ function slotAt(main: number, cross: number, from: number): number {
168
242
  const items = liveItems();
169
243
  if (items.length === 0) return -1;
170
- const rects = items.map((item) => item.getBoundingClientRect());
171
- // Nearest line wins: items in the pointer's own band score 0, and a
172
- // pointer between two bands takes the closer one. The half-pixel
173
- // tolerance keeps the siblings of ONE wrapped line together, whose
174
- // bands can differ by sub-pixel rounding.
175
- let nearest = Number.POSITIVE_INFINITY;
176
- const lineGap = rects.map((rect) => {
177
- const lo = axis === "x" ? rect.top : rect.left;
178
- const hi = axis === "x" ? rect.bottom : rect.right;
179
- const gap = cross < lo ? lo - cross : cross > hi ? cross - hi : 0;
180
- if (gap < nearest) nearest = gap;
181
- return gap;
182
- });
183
- let slot = -1;
184
- for (let i = 0; i < rects.length; i += 1) {
185
- if (lineGap[i] > nearest + 0.5) continue;
186
- const rect = rects[i];
187
- const mid = axis === "x" ? rect.left + rect.width / 2 : rect.top + rect.height / 2;
188
- if (main < mid) {
189
- slot = i;
190
- break;
191
- }
192
- slot = i + 1;
193
- }
194
- if (slot < 0) slot = items.length;
195
- return from >= 0 && slot > from ? slot - 1 : slot;
244
+ return indexAt(
245
+ items.map((item) => item.getBoundingClientRect()),
246
+ axis,
247
+ main,
248
+ cross,
249
+ from,
250
+ );
196
251
  }
197
252
 
198
253
  /** The pointer's coordinates split by axis: the position along the strip
@@ -248,7 +303,7 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
248
303
  } else {
249
304
  container.scrollTop = next;
250
305
  }
251
- const over = indexAt(lastMain, lastCross, pressedIndex);
306
+ const over = slotAt(lastMain, lastCross, pressedIndex);
252
307
  if (over >= 0) dragOver.value = over;
253
308
  scrollFrameId = requestAnimationFrame(scrollTick);
254
309
  }
@@ -368,7 +423,7 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
368
423
  const at = pointerAt(event);
369
424
  lastMain = at.main;
370
425
  lastCross = at.cross;
371
- const over = indexAt(lastMain, lastCross, pressedIndex);
426
+ const over = slotAt(lastMain, lastCross, pressedIndex);
372
427
  if (over >= 0) dragOver.value = over;
373
428
  syncAutoScroll();
374
429
  }
@@ -377,7 +432,7 @@ export function usePointerReorder(options: PointerReorderOptions): PointerReorde
377
432
  if (!pressed || event.pointerId !== pointerId) return;
378
433
  const from = pressedIndex;
379
434
  const at = pointerAt(event);
380
- const to = moved ? indexAt(at.main, at.cross, from) : from;
435
+ const to = moved ? slotAt(at.main, at.cross, from) : from;
381
436
  const dropped = moved && to >= 0 && to !== from;
382
437
  // The drag is over either way — the trap only guards the click the
383
438
  // browser is about to deliver.