@celestia-island/hikari 0.55.113 → 0.55.114

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.55.113",
3
+ "version": "0.55.114",
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",
@@ -640,7 +640,9 @@ describe("HkModalBreadcrumb overflow fold", () => {
640
640
  // The strip is teleported into the host's zoomed root: the header's
641
641
  // rect is VISUAL px while the inline top it writes is LOCAL px, so the
642
642
  // height is divided by the zoom. Forgetting the division drifts the
643
- // strip (zoom − 1) · height / 2 down its window.
643
+ // strip (zoom − 1) · height / 2 down its window. The strip's OWN height
644
+ // rides the same conversion (clearance branch), so it is mocked here
645
+ // too: 88 visual px = 44 local at zoom 2.
644
646
  const app = document.createElement("div");
645
647
  app.id = "app";
646
648
  app.style.top = "10px";
@@ -665,21 +667,91 @@ describe("HkModalBreadcrumb overflow fold", () => {
665
667
  const rect = (height: number): DOMRect =>
666
668
  ({ x: 0, y: 0, width: 200, height, top: 0, left: 0, right: 200, bottom: height, toJSON: () => ({}) }) as DOMRect;
667
669
  vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
668
- return this === header ? rect(96) : rect(0);
670
+ if (this === header) return rect(96);
671
+ if (this.classList.contains("hk-modal-breadcrumb")) return rect(88);
672
+ return rect(0);
669
673
  });
670
674
  try {
671
675
  setViewport(1200);
672
676
  manager.register("modal", true, "第一层");
673
677
  manager.register("modal", true, "第二层");
674
678
  await mountStrip();
675
- // 10 (app top, local) + (96 visual / 2 zoom) / 2
676
- expect(strip()!.style.top).toBe("34px");
679
+ // 10 (app top, local) + the lower of: header centre (96 visual / 2
680
+ // zoom) / 2 = 24, and the clearance 12 + (88 visual / 2 zoom) / 2 =
681
+ // 34 — the clearance sits lower and wins.
682
+ expect(strip()!.style.top).toBe("44px");
677
683
  } finally {
678
684
  vi.restoreAllMocks();
679
685
  app.remove();
680
686
  }
681
687
  });
682
688
 
689
+ it("keeps daylight between the app's top edge and the strip", async () => {
690
+ // Centred on a standard short header, the strip's own upper half used
691
+ // to kiss the very top edge (2026-10-05 user report): a ~24px centre
692
+ // line is less than the strip's half height. The top is now the LOWER
693
+ // of "header centre" and the topGap clearance; the boxless test DOM
694
+ // exercises the rule on the two layout fallbacks (48px header, 43px
695
+ // strip). The app top is deliberately non-zero (5px) so NO expectation
696
+ // coincides with the pre-resync initial ref — each leg proves resyncTop
697
+ // actually wrote: 5 + max(48/2, gap + 43/2).
698
+ const app = document.createElement("div");
699
+ app.id = "app";
700
+ app.style.top = "5px";
701
+ document.body.appendChild(app);
702
+ const unmountAll = () => {
703
+ for (const a of mounts.splice(0)) a.unmount();
704
+ };
705
+ try {
706
+ setViewport(1200);
707
+ manager.register("modal", true, "第一层");
708
+ manager.register("modal", true, "第二层");
709
+ await mountStrip();
710
+ expect(strip()!.style.top).toBe("38.5px");
711
+ unmountAll();
712
+
713
+ // A host pinning the legacy, header-centred position passes 0:
714
+ // 5 + max(24, 0 + 21.5) — the centre branch wins again.
715
+ await mountStrip({ topGap: 0 });
716
+ expect(strip()!.style.top).toBe("29px");
717
+ unmountAll();
718
+
719
+ // And a host wanting more air simply raises the gap.
720
+ await mountStrip({ topGap: 40 });
721
+ expect(strip()!.style.top).toBe("66.5px");
722
+ unmountAll();
723
+
724
+ // A NaN gap is host junk: it falls back to the default instead of
725
+ // poisoning Math.max into `top: NaNpx` (which the browser drops).
726
+ await mountStrip({ topGap: Number.NaN });
727
+ expect(strip()!.style.top).toBe("38.5px");
728
+ } finally {
729
+ unmountAll();
730
+ app.remove();
731
+ }
732
+ });
733
+
734
+ it("paints the fallback position before any resync can run", async () => {
735
+ // A host whose app root id never resolves leaves resyncTop at its
736
+ // early-return, so the strip paints the INITIAL ref: that line must
737
+ // already encode the clearance rule on the two layout fallbacks
738
+ // (48px header, 43px strip), not a legacy literal. Every other
739
+ // style.top assertion lives in tests that create #app, where the
740
+ // pre-mount resync overwrites the init before the first render
741
+ // (mutation round M5: reverting the init to a bare 24 passed
742
+ // everything else green).
743
+ setViewport(1200);
744
+ manager.register("modal", true, "第一层");
745
+ manager.register("modal", true, "第二层");
746
+ await mountStrip({ appRootId: "no-such-root" });
747
+ expect(strip()!.style.top).toBe("33.5px");
748
+ for (const a of mounts.splice(0)) a.unmount();
749
+
750
+ // The init follows the prop the same way the resync does.
751
+ await mountStrip({ appRootId: "no-such-root", topGap: 0 });
752
+ expect(strip()!.style.top).toBe("21.5px");
753
+ });
754
+
683
755
  it("writes the viewport fence in the strip's own px", async () => {
684
756
  // A vw-authored cap would be scaled by a host root zoom at paint, so the
685
757
  // fence is re-derived from the live viewport (minus the shared gutter)
@@ -36,6 +36,13 @@ const GAP_PX = 8;
36
36
  const SIDE_PADDING_PX = 32;
37
37
  const BORDER_PX = 1;
38
38
  const MORE_PX = 24;
39
+ /** The strip's own height for the moments no box can be measured (first
40
+ * resync, unit DOM): the stylesheet declares 12px vertical padding, a
41
+ * ~17px label line and a 1px border — 12·2 + 17 + 1·2 ≈ 43. Models the
42
+ * UNFOLDED strip; a folded one is ~50px (the 24px trigger line) — the
43
+ * fallback only serves boxless DOMs, a real browser measures the live
44
+ * nav and the slow tick re-derives from it. */
45
+ const STRIP_HEIGHT_PX = 43;
39
46
 
40
47
  interface Crumb {
41
48
  /** Popup-registry id this crumb can navigate back to. */
@@ -66,12 +73,33 @@ export default defineComponent({
66
73
  * hidden-layers menu.
67
74
  */
68
75
  maxLabelUnits: { type: Number, default: 10 },
76
+ /**
77
+ * Least daylight, in px, between the app's top edge and the strip's own
78
+ * top edge. The strip is centred on the app header, and on the standard
79
+ * short header that centre line sits ~24px from the top — less than the
80
+ * strip's half height, so its upper half used to kiss the very top edge
81
+ * (2026-10-05 user report). The top is now the lower of "centred on the
82
+ * header" and "this clearance"; a header whose centre already sits at
83
+ * least this far down keeps the strip centred on it. Authored in the
84
+ * strip's LOCAL px like every length here — a host root zoom scales it
85
+ * at paint, so it can only over-deliver daylight. Below 8 the entrance
86
+ * keyframe's own 8px slide transiently pokes back above the app top.
87
+ */
88
+ topGap: { type: Number, default: 12 },
69
89
  },
70
90
  setup(props) {
71
91
  const manager = usePopupManager();
72
92
  const { t } = useI18n();
73
93
  const { isMobile } = useBreakpoint();
74
94
 
95
+ /** Sanitized once: a host passing NaN would otherwise poison both the
96
+ * pre-resync ref and every resync (Math.max(x, NaN) is NaN, and the
97
+ * browser drops `top: NaNpx`, parking the strip at its static
98
+ * position). A NEGATIVE gap stays the host's choice: it lowers the
99
+ * clearance candidate below the centre line, which then wins — i.e.
100
+ * "I don't care about daylight". */
101
+ const topGapPx = computed(() => (Number.isFinite(props.topGap) ? props.topGap : 12));
102
+
75
103
  /** Which entries the strip navigates. Windows (modal/drawer) always;
76
104
  * dropdown-kind surfaces only while they BLOCK like a window — the
77
105
  * mobile bottom sheet a menu/popover becomes when viewport space
@@ -375,7 +403,10 @@ export default defineComponent({
375
403
  });
376
404
  onBeforeUnmount(() => document.removeEventListener("keydown", onSurfaceKeydown));
377
405
 
378
- const topPx = ref(24);
406
+ /** Pre-resync paint position: the clearance rule evaluated on the two
407
+ * layout fallbacks (48px header, 43px strip). The first nextTick
408
+ * resync replaces it with measured numbers. */
409
+ const topPx = ref(topGapPx.value + STRIP_HEIGHT_PX / 2);
379
410
  function resyncTop() {
380
411
  const app = document.getElementById(props.appRootId);
381
412
  if (!app) return;
@@ -397,7 +428,20 @@ export default defineComponent({
397
428
  const headerH = header
398
429
  ? header.getBoundingClientRect().height / z
399
430
  : props.headerFallbackHeight;
400
- topPx.value = appTop + headerH / 2;
431
+ // The strip's own height comes back in the same VISUAL px the
432
+ // header's rect does (it teleports into the same zoomed subtree), so
433
+ // it divides by the zoom read at the strip itself. No box (first
434
+ // resync, unit DOM) falls back to the stylesheet-declared height.
435
+ let stripH = STRIP_HEIGHT_PX;
436
+ if (navRef.value) {
437
+ const navH = navRef.value.getBoundingClientRect().height;
438
+ if (navH > 0) stripH = navH / ancestorZoom(navRef.value);
439
+ }
440
+ // Centre on the header, but keep the promised daylight: whichever
441
+ // candidate parks the strip LOWER wins, so a centre line near the
442
+ // viewport top can no longer push the strip's upper half against
443
+ // (or past) the top edge.
444
+ topPx.value = appTop + Math.max(headerH / 2, topGapPx.value + stripH / 2);
401
445
  }
402
446
 
403
447
  /** Viewport fence, in the strip's OWN px. The stylesheet cap is
@@ -463,6 +507,9 @@ export default defineComponent({
463
507
  if (!handle) handle = scheduleEvery(onSlowTick, 1000);
464
508
  window.addEventListener("resize", onViewportChange);
465
509
  void nextTick(() => {
510
+ // The strip's own box exists only now: re-derive the top with
511
+ // a measured height (the pre-mount resync ran on fallbacks).
512
+ resyncBox();
466
513
  measureStrip();
467
514
  observeClone();
468
515
  });
@@ -495,7 +542,9 @@ export default defineComponent({
495
542
  if (!visible.value) return;
496
543
  // Synchronous first measure: the collapsed shape is committed in the
497
544
  // same task as the mount, so the strip never paints one frame wide
498
- // enough to spill before folding.
545
+ // enough to spill before folding. The top is re-derived with the
546
+ // strip's now-measurable height for the same reason.
547
+ resyncBox();
499
548
  measureStrip();
500
549
  observeClone();
501
550
  });