@celestia-island/hikari 0.47.0 → 0.48.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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Source contract for the select popout host's position-independent width
3
+ * (2026-09-14 user report: the "jump to date" tooltip on a screen-edge
4
+ * FAB collapsed to a one-glyph column — the same shrink-to-fit class the
5
+ * popover fixed in #421. HkSelectPanel's desktop popout was the last
6
+ * anchored surface still sizing against `viewport - left`; an anchor
7
+ * near the right edge squeezed the panel down to the leftover space).
8
+ *
9
+ * The host must keep:
10
+ * - `width: max-content` — size to the rows wherever the anchor sits;
11
+ * - the viewport cap on `--viewport-gutter` (16px desktop / 8px
12
+ * mobile) — the panel can never exceed the space between the
13
+ * gutters, matching .hk-popover-panel;
14
+ * - the `min-width: 180px` floor — which must stay BEATABLE by the
15
+ * inline matchAnchorWidth style (stylesheet loses to inline on the
16
+ * same element; declaration ORDER inside this block is irrelevant,
17
+ * so it is deliberately not pinned here).
18
+ */
19
+ import { describe, expect, it } from "vitest";
20
+ import { readFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const scss = readFileSync(join(here, "HkSelect.scss"), "utf-8");
26
+
27
+ describe("HkSelectPanel popout viewport width contract", () => {
28
+ const block = scss.match(/\.hk-select-popout-host\s*{[\s\S]*?^\}/m)?.[0] ?? "";
29
+
30
+ it("declares the host block", () => {
31
+ expect(block).not.toBe("");
32
+ });
33
+
34
+ it("sizes the popout to its content (width: max-content)", () => {
35
+ expect(block).toContain("width: max-content;");
36
+ });
37
+
38
+ it("caps the popout at the viewport gutter on both edges", () => {
39
+ expect(block).toContain(
40
+ "max-width: calc(100vw - 2 * var(--viewport-gutter, 16px));",
41
+ );
42
+ });
43
+
44
+ it("keeps the 180px floor on the host (narrow anchors never collapse the panel)", () => {
45
+ expect(block).toContain("min-width: 180px;");
46
+ });
47
+ });
@@ -324,13 +324,14 @@ describe("HkSelectPanel custom invocation", () => {
324
324
  expect(host.dataset.side).toBe("top");
325
325
 
326
326
  // A centered panel poking past the left edge clamps to the viewport
327
- // pad instead of mirroring the overflow to both sides: anchor
328
- // 0..40 → raw left (20 - 180/2) = -70 → clamped to 8.
327
+ // gutter instead of mirroring the overflow to both sides: anchor
328
+ // 0..40 → raw left (20 - 180/2) = -70 → clamped to the desktop
329
+ // gutter (16px; happy-dom's viewport is desktop-width).
329
330
  anchor.getBoundingClientRect = () =>
330
331
  ({ top: 500, bottom: 520, left: 0, right: 40, width: 40, height: 20 }) as DOMRect;
331
332
  window.dispatchEvent(new Event("resize"));
332
333
  await nextTick();
333
- expect(host.style.left).toBe("8px");
334
+ expect(host.style.left).toBe("16px");
334
335
  });
335
336
 
336
337
  it("reports center alignment through an auto-flip so the pop origin follows", async () => {
@@ -371,7 +372,8 @@ describe("HkSelectPanel custom invocation", () => {
371
372
 
372
373
  it("clamps a centered popout at the right viewport pad near the right edge", async () => {
373
374
  // Anchor hugging the right edge: raw balanced left 1100 + (80 - 180)/2
374
- // = 1050 would poke past 1200 - 8 - 180 = 1012 → pinned at the pad.
375
+ // = 1050 would poke past 1200 - 16 - 180 = 1004 → pinned at the
376
+ // desktop gutter.
375
377
  // The expected left edge is viewport arithmetic, so pin the width here
376
378
  // (happy-dom's bare default is 1024 — the afterEach reset only helps
377
379
  // full-file runs, not -t filtering; afterEach re-asserts 1200 anyway).
@@ -386,7 +388,7 @@ describe("HkSelectPanel custom invocation", () => {
386
388
  await nextTick();
387
389
 
388
390
  const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
389
- expect(host.style.left).toBe("1012px");
391
+ expect(host.style.left).toBe("1004px");
390
392
  });
391
393
 
392
394
  it("clamps a tall flipped popout into the viewport instead of going negative", async () => {
@@ -419,11 +421,11 @@ describe("HkSelectPanel custom invocation", () => {
419
421
  await nextTick();
420
422
 
421
423
  const top = Number.parseInt(host.style.top, 10);
422
- // Whole panel on-screen: top edge at/inside the viewport pad and the
423
- // bottom edge inside it too — overflow beyond that is the panel's
424
- // own internal scroll, never off-screen geometry.
425
- expect(top).toBeGreaterThanOrEqual(8);
426
- expect(top + 576).toBeLessThanOrEqual(800 - 8);
424
+ // Whole panel on-screen: top edge at/inside the viewport gutter and
425
+ // the bottom edge inside it too — overflow beyond that is the
426
+ // panel's own internal scroll, never off-screen geometry.
427
+ expect(top).toBeGreaterThanOrEqual(16);
428
+ expect(top + 576).toBeLessThanOrEqual(800 - 16);
427
429
  } finally {
428
430
  window.innerHeight = prevHeight;
429
431
  }
@@ -14,6 +14,7 @@ import { useOverlay } from "../runtime/useOverlay";
14
14
  import { useBreakpoint } from "../runtime/useBreakpoint";
15
15
  import { createBackGuard } from "../runtime/backStack";
16
16
  import { ancestorZoom } from "../runtime/cssZoom";
17
+ import { clampWithGutter, viewportGutterPx } from "../runtime/viewportGutter";
17
18
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
18
19
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
19
20
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
@@ -72,8 +73,6 @@ export type SelectPanelPlacement =
72
73
  | "top-center"
73
74
  | "top-end";
74
75
 
75
- const VIEWPORT_PAD = 8;
76
-
77
76
  export default defineComponent({
78
77
  name: "HkSelectPanel",
79
78
  props: {
@@ -542,16 +541,19 @@ export default defineComponent({
542
541
  const phRaw = panelRef.value?.offsetHeight || 0;
543
542
  const pw = pwRaw > 0 ? pwRaw * z : Math.max(r.width, 180);
544
543
  const ph = phRaw > 0 ? phRaw * z : 200;
544
+ // The shared viewport gutter (--viewport-gutter: 8px mobile / 16px
545
+ // desktop), read per positioning pass.
546
+ const pad = viewportGutterPx();
545
547
  let side: "top" | "bottom" = props.placement.startsWith("top-") ? "top" : "bottom";
546
548
  let top =
547
549
  side === "top"
548
550
  ? r.top - props.offset - ph
549
551
  : r.bottom + props.offset;
550
552
  // Auto-flip when the chosen side cannot host the panel.
551
- if (side === "bottom" && top + ph > window.innerHeight - VIEWPORT_PAD) {
553
+ if (side === "bottom" && top + ph > window.innerHeight - pad) {
552
554
  side = "top";
553
555
  top = r.top - props.offset - ph;
554
- } else if (side === "top" && top < VIEWPORT_PAD) {
556
+ } else if (side === "top" && top < pad) {
555
557
  side = "bottom";
556
558
  top = r.bottom + props.offset;
557
559
  }
@@ -566,22 +568,20 @@ export default defineComponent({
566
568
  // bottom→top into a negative top that was applied verbatim. Clamp so
567
569
  // the whole panel stays on-screen; when content exceeds the CSS cap
568
570
  // the panel's own internal scroll takes over.
569
- const maxTop = Math.max(VIEWPORT_PAD, window.innerHeight - VIEWPORT_PAD - ph);
570
- top = Math.min(Math.max(top, VIEWPORT_PAD), maxTop);
571
+ top = clampWithGutter(top, ph, window.innerHeight, pad);
571
572
  // -center balances the panel on the anchor's horizontal midpoint
572
573
  // (still clamped, so a half-off-screen anchor keeps the panel
573
574
  // readable instead of mirroring the overflow to both edges).
574
- let left =
575
+ const left =
575
576
  align === "center"
576
577
  ? r.left + (r.width - pw) / 2
577
578
  : props.placement.endsWith("-end")
578
579
  ? r.right - pw
579
580
  : r.left;
580
- const maxLeft = Math.max(VIEWPORT_PAD, window.innerWidth - VIEWPORT_PAD - pw);
581
- left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft);
581
+ const clampedLeft = clampWithGutter(left, pw, window.innerWidth, pad);
582
582
  coords.value = {
583
583
  top: `${Math.round(top / z)}px`,
584
- left: `${Math.round(left / z)}px`,
584
+ left: `${Math.round(clampedLeft / z)}px`,
585
585
  ...(props.matchAnchorWidth ? { minWidth: `${Math.round(r.width / z)}px` } : {}),
586
586
  };
587
587
  }
@@ -1,7 +1,9 @@
1
1
  .hk-toast-container {
2
2
  position: fixed;
3
3
  top: 4rem;
4
- inset-inline-end: var(--space-16, 1rem);
4
+ /* Same viewport gutter the anchored popups clamp to (16px desktop /
5
+ * 8px mobile) — the toast stack is a floating layer too. */
6
+ inset-inline-end: var(--viewport-gutter, 1rem);
5
7
  /* Toast band — the topmost popup z band (POPUP_Z_BANDS.toast; mirror in
6
8
  theme.scss). The live value arrives inline from the popup manager. */
7
9
  z-index: var(--hk-z-toast, 4000);
@@ -16,6 +16,14 @@
16
16
  padding: var(--space-4) var(--space-8);
17
17
  border: none;
18
18
  border-radius: var(--radius-sm);
19
+ /* Width is position-independent (same contract as .hk-popover-panel):
20
+ * max-content sizes the bubble to its text wherever it sits — a fixed
21
+ * element otherwise shrink-to-fits against `viewport - left` and a
22
+ * trigger near the screen edge squeezes it into a one-glyph column.
23
+ * The inline style from runtime/tooltipPosition carries the same
24
+ * declaration; this sheet rule keeps the bridge/standalone faces
25
+ * covered even where the inline pass does not run. */
26
+ width: max-content;
19
27
  /* Default cap lives on the popup so the inline maxWidth prop
20
28
  legitimately overrides it (the content div would shadow the prop). */
21
29
  max-width: 280px;
@@ -223,4 +223,16 @@ describe("HkTooltip anchoring", () => {
223
223
  await settle();
224
224
  expect(popup().style.maxWidth).toBe("220px");
225
225
  });
226
+
227
+ it("sizes the popup to its content (max-content) for the gutter clamp", async () => {
228
+ // The compression fix: the popup box must be position-independent
229
+ // (width: max-content) so a screen-edge trigger cannot shrink it to
230
+ // the containing block's leftover space; applyTooltipPosition then
231
+ // clamps the measured box into the viewport gutter (no-op here —
232
+ // happy-dom reports a zero rect).
233
+ const { container } = mount({ text: "sized", delay: 0 });
234
+ enter(container);
235
+ await settle();
236
+ expect(popup().style.width).toBe("max-content");
237
+ });
226
238
  });
@@ -1,6 +1,6 @@
1
1
  import { computed, defineComponent, onBeforeUnmount, onMounted, ref, Teleport, type CSSProperties, type PropType } from "vue";
2
2
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
3
- import { tooltipPositionStyle, type TooltipPlacement } from "../runtime/tooltipPosition";
3
+ import { applyTooltipPosition, type TooltipPlacement } from "../runtime/tooltipPosition";
4
4
  import "./HkTooltip.scss";
5
5
 
6
6
  export default defineComponent({
@@ -14,7 +14,7 @@ export default defineComponent({
14
14
  setup(props, { slots }) {
15
15
  const visible = ref(false);
16
16
  const wrapperRef = ref<HTMLElement | null>(null);
17
- const tooltipStyle = ref<CSSProperties>({});
17
+ const popupRef = ref<HTMLElement | null>(null);
18
18
  let showTimer: ReturnType<typeof setTimeout> | null = null;
19
19
 
20
20
  // Registers with the popup manager (kind "tooltip") so tooltips hold
@@ -31,13 +31,16 @@ export default defineComponent({
31
31
  });
32
32
 
33
33
  function updatePosition() {
34
- if (!wrapperRef.value) return;
34
+ if (!wrapperRef.value || !popupRef.value) return;
35
35
  const rect = wrapperRef.value.getBoundingClientRect();
36
36
  // Placement geometry lives in the shared runtime helper (it also
37
37
  // serves the document-level tooltip bridge) — including the
38
38
  // ancestor-zoom division that keeps teleported popups pinned to
39
- // their trigger inside scaled roots.
40
- tooltipStyle.value = tooltipPositionStyle(rect, props.placement, props.maxWidth);
39
+ // their trigger inside scaled roots, and the measure-and-clamp pass
40
+ // that keeps the measured bubble inside the viewport gutter (flip,
41
+ // cap, shift) instead of spilling off-screen or shrinking to the
42
+ // containing block's leftover space.
43
+ applyTooltipPosition(popupRef.value, rect, props.placement, props.maxWidth);
41
44
  }
42
45
 
43
46
  function show() {
@@ -74,10 +77,12 @@ export default defineComponent({
74
77
  visible.value ? "hk-tooltip-visible" : "",
75
78
  ]);
76
79
 
77
- const popupStyle = computed<CSSProperties>(() => ({
78
- ...tooltipStyle.value,
79
- ...(zIndex.value != null ? { zIndex: zIndex.value } : {}),
80
- }));
80
+ // Only the popup-manager z rides the vnode; the geometry is written
81
+ // straight onto the element by applyTooltipPosition (same as the
82
+ // tooltip bridge) so a re-render can never clobber a clamp shift.
83
+ const popupStyle = computed<CSSProperties>(() =>
84
+ zIndex.value != null ? { zIndex: zIndex.value } : {},
85
+ );
81
86
 
82
87
  return () => (
83
88
  <span
@@ -94,6 +99,7 @@ export default defineComponent({
94
99
  </span>
95
100
  <Teleport to="body">
96
101
  <div
102
+ ref={popupRef}
97
103
  class={tooltipCls.value}
98
104
  style={popupStyle.value}
99
105
  >
@@ -13,9 +13,19 @@ export {
13
13
  } from "./imageFallback";
14
14
  export {
15
15
  tooltipPositionStyle,
16
+ applyTooltipPosition,
17
+ resolveTooltipFlip,
16
18
  TOOLTIP_GAP_PX,
17
19
  type TooltipPlacement,
18
20
  } from "./tooltipPosition";
21
+ export {
22
+ viewportGutterPx,
23
+ clampWithGutter,
24
+ VIEWPORT_GUTTER_VAR,
25
+ MOBILE_GUTTER_PX,
26
+ DESKTOP_GUTTER_PX,
27
+ GUTTER_MOBILE_MAX_WIDTH,
28
+ } from "./viewportGutter";
19
29
  export {
20
30
  sanitizeHistoryUrl,
21
31
  installHistorySafetyNet,
@@ -9,8 +9,8 @@
9
9
  // → the element's title is moved aside into `data-hk-title` and the
10
10
  // attribute is REMOVED — the native tooltip can no longer appear
11
11
  // → after the delay the shared `.hk-tooltip-popup` element is filled,
12
- // positioned (tooltipPositionStyle: same placement/zoom math as
13
- // HkTooltip) and faded in
12
+ // positioned (applyTooltipPosition: same placement/zoom math as
13
+ // HkTooltip, plus the viewport-gutter clamp) and faded in
14
14
  // disengage (pointerout / focusout / scroll / resize / Esc); pointerdown
15
15
  // only hides the popup — the hold stays so no native tooltip flashes
16
16
  // → the popup fades out and the title attribute is RESTORED, so
@@ -28,7 +28,7 @@
28
28
  // popups share the tooltip z band with component tooltips. Installing
29
29
  // twice on the same document is idempotent: the second install returns
30
30
  // the first install's uninstall.
31
- import { tooltipPositionStyle, type TooltipPlacement } from "./tooltipPosition";
31
+ import { applyTooltipPosition, type TooltipPlacement } from "./tooltipPosition";
32
32
  import { usePopupManager, type PopupHandle } from "./usePopupManager";
33
33
  import { reportHkRuntime, type HkRuntimeHandle } from "./registry";
34
34
  // The popup reuses HkTooltip's popup classes — carry the sheet so a host
@@ -127,12 +127,16 @@ function showPopup(state: BridgeState, el: Element) {
127
127
  if (!text || !state.popup.isConnected) return;
128
128
  state.content.textContent = text;
129
129
  // Same geometry as component tooltips: the fixed popup pins to the
130
- // trigger rect with the shared placement switch (ancestor zoom aware).
131
- Object.assign(state.popup.style, tooltipPositionStyle(
130
+ // trigger rect with the shared placement switch (ancestor zoom aware),
131
+ // then the measured box is clamped into the viewport gutter — a title
132
+ // trigger hugging the screen edge shows a full-width bubble shifted
133
+ // inside the gutter, never the squeezed leftover-space column.
134
+ applyTooltipPosition(
135
+ state.popup,
132
136
  el.getBoundingClientRect(),
133
137
  state.placement,
134
138
  state.maxWidth,
135
- ));
139
+ );
136
140
  state.popup.classList.add("hk-tooltip-visible");
137
141
  el.setAttribute("aria-describedby", state.describedBy);
138
142
  state.shown += 1;
@@ -1,6 +1,11 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- import { TOOLTIP_GAP_PX, tooltipPositionStyle } from "./tooltipPosition";
3
+ import {
4
+ TOOLTIP_GAP_PX,
5
+ applyTooltipPosition,
6
+ resolveTooltipFlip,
7
+ tooltipPositionStyle,
8
+ } from "./tooltipPosition";
4
9
 
5
10
  /** A 100x40 rect at (200,100) — big enough for every placement side. */
6
11
  function rect(): DOMRect {
@@ -11,6 +16,34 @@ function rect(): DOMRect {
11
16
  } as DOMRect;
12
17
  }
13
18
 
19
+ function box(left: number, top: number, width: number, height: number): DOMRect {
20
+ return {
21
+ left, top, width, height,
22
+ right: left + width, bottom: top + height,
23
+ x: left, y: top,
24
+ toJSON: () => ({}),
25
+ } as DOMRect;
26
+ }
27
+
28
+ /** A probe element whose gBCR answers with a queued rect sequence. */
29
+ function probePopup(rects: DOMRect[]): HTMLElement {
30
+ const el = document.createElement("div");
31
+ el.dataset.tooltipProbe = "true";
32
+ document.body.appendChild(el);
33
+ let i = 0;
34
+ el.getBoundingClientRect = () => rects[Math.min(i++, rects.length - 1)]!;
35
+ return el;
36
+ }
37
+
38
+ const PREV_INNER = { width: window.innerWidth, height: window.innerHeight };
39
+
40
+ afterEach(() => {
41
+ window.innerWidth = PREV_INNER.width;
42
+ window.innerHeight = PREV_INNER.height;
43
+ vi.restoreAllMocks();
44
+ document.querySelectorAll(".hk-tooltip-popup, [data-tooltip-probe]").forEach((el) => el.remove());
45
+ });
46
+
14
47
  describe("tooltipPositionStyle", () => {
15
48
  it("pins the popup 8px outside the rect with per-placement transforms", () => {
16
49
  expect(TOOLTIP_GAP_PX).toBe(8);
@@ -38,4 +71,182 @@ describe("tooltipPositionStyle", () => {
38
71
  expect(style.maxWidth).toBe("180px");
39
72
  expect(tooltipPositionStyle(rect(), "top").maxWidth).toBeUndefined();
40
73
  });
74
+
75
+ it("sizes the popup to its content so the containing block cannot compress it", () => {
76
+ // THE compression fix: a fixed element shrink-to-fits against
77
+ // `viewport - left`, so a trigger near the right edge used to squeeze
78
+ // the bubble into a one-glyph column. max-content is position-
79
+ // independent (same contract as .hk-popover-panel).
80
+ expect(tooltipPositionStyle(rect(), "top").width).toBe("max-content");
81
+ expect(tooltipPositionStyle(rect(), "right", "120px").width).toBe("max-content");
82
+ });
83
+ });
84
+
85
+ describe("applyTooltipPosition without layout", () => {
86
+ it("degrades to the pure placement style when the box measures zero", () => {
87
+ const popup = probePopup([box(0, 0, 0, 0)]);
88
+ applyTooltipPosition(popup, rect(), "top");
89
+ expect(popup.style.top).toBe("92px");
90
+ expect(popup.style.left).toBe("250px");
91
+ expect(popup.style.transform).toBe("translate(-50%, -100%)");
92
+ expect(popup.style.width).toBe("max-content");
93
+ // No clamp pass ran: no caps, no gutter writes.
94
+ expect(popup.style.maxWidth).toBe("");
95
+ expect(popup.style.maxHeight).toBe("");
96
+ });
97
+
98
+ it("still honors the maxWidth prop on the zero-layout path", () => {
99
+ const popup = probePopup([box(0, 0, 0, 0)]);
100
+ applyTooltipPosition(popup, rect(), "top", "220px");
101
+ expect(popup.style.maxWidth).toBe("220px");
102
+ });
103
+ });
104
+
105
+ describe("applyTooltipPosition viewport clamping", () => {
106
+ it("shifts a centered bubble back inside the right-edge gutter", () => {
107
+ // Mobile viewport (375px → 8px gutter). Trigger at 300..340, the
108
+ // centered 120px bubble would span 260..380 — 13px past the gutter.
109
+ window.innerWidth = 375;
110
+ window.innerHeight = 667;
111
+ const trigger = box(300, 300, 40, 40);
112
+ const popup = probePopup([box(260, 348, 120, 32)]);
113
+ applyTooltipPosition(popup, trigger, "bottom");
114
+ // The shift lands on the WRITTEN anchor (style.left is the box
115
+ // center under translate(-50%)): 320 - 13 = 307, visual box 247..367.
116
+ expect(popup.style.left).toBe("307px");
117
+ expect(popup.style.top).toBe("348px");
118
+ expect(popup.style.maxWidth).toBe("");
119
+ });
120
+
121
+ it("flips to the opposite side when the preferred one is starved", () => {
122
+ // Trigger hugging the top edge: a top popup cannot clear the gutter,
123
+ // bottom has room → the popup flips below the trigger.
124
+ window.innerWidth = 375;
125
+ window.innerHeight = 667;
126
+ const trigger = box(100, 2, 40, 18);
127
+ const popup = probePopup([box(120, -22, 200, 32), box(120, 28, 200, 32)]);
128
+ applyTooltipPosition(popup, trigger, "top");
129
+ expect(popup.style.transform).toBe("translate(-50%, 0)");
130
+ expect(popup.style.top).toBe("28px"); // rect.bottom + 8
131
+ expect(popup.style.left).toBe("120px"); // hcenter, unshifted
132
+ });
133
+
134
+ it("caps a bubble that cannot fit between the gutters, then shifts", () => {
135
+ // A 400px bubble on a 375px viewport: max-width caps it to
136
+ // 375 - 2*8 = 359px and the centered remainder shifts inside.
137
+ window.innerWidth = 375;
138
+ window.innerHeight = 667;
139
+ const trigger = box(100, 300, 40, 40);
140
+ const popup = probePopup([box(-80, 232, 400, 60), box(-80, 232, 359, 60)]);
141
+ applyTooltipPosition(popup, trigger, "top");
142
+ expect(popup.style.maxWidth).toBe("359px");
143
+ // Base anchor 120 - shift dx 88 (visual -80 → gutter 8) = 208.
144
+ expect(popup.style.left).toBe("208px");
145
+ });
146
+
147
+ it("clips a viewport-tall tooltip instead of spilling past the edge", () => {
148
+ window.innerWidth = 375;
149
+ window.innerHeight = 667;
150
+ // Preferred side keeps the flip away (top space 392 > bottom 219) so
151
+ // the case isolates the height cap + clip.
152
+ const trigger = box(160, 400, 40, 40);
153
+ const popup = probePopup([box(100, 20, 160, 700), box(100, 20, 160, 651)]);
154
+ applyTooltipPosition(popup, trigger, "top");
155
+ expect(popup.style.maxHeight).toBe("651px"); // 667 - 2*8
156
+ expect(popup.style.overflow).toBe("hidden");
157
+ });
158
+
159
+ it("writes the shift divided by the cumulative root zoom", () => {
160
+ // chest's root-level DPI zoom: style px are local (divided by the
161
+ // zoom) while the measured box and the viewport are visual.
162
+ window.innerWidth = 1200;
163
+ window.innerHeight = 800;
164
+ const original = window.getComputedStyle.bind(window);
165
+ vi.spyOn(window, "getComputedStyle").mockImplementation(((el: Element) => {
166
+ const decl = original(el);
167
+ return new Proxy(decl, {
168
+ get(target, prop, recv) {
169
+ if (prop === "zoom") return el === document.documentElement ? "2" : undefined;
170
+ const v = Reflect.get(target, prop, recv);
171
+ return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
172
+ },
173
+ });
174
+ }) as typeof window.getComputedStyle);
175
+ const trigger = box(1180, 100, 20, 40);
176
+ // Base right placement at zoom 2: left = (1200+8)/2 = 604 local, the
177
+ // 300px bubble spans visual 1208..1508 — past the gutter AND the
178
+ // starved right side, so it flips left and shifts the remainder.
179
+ const popup = probePopup([box(1208, 100, 300, 40), box(886, 100, 300, 40)]);
180
+ applyTooltipPosition(popup, trigger, "right");
181
+ expect(popup.style.transform).toBe("translate(-100%, -50%)");
182
+ // Flip rewrite: (1180 - 8)/2 = 586 local; visual box 886..1186, 2px
183
+ // past the 1184 gutter line → dx -2 visual = -1 local: 586 - 1 = 585.
184
+ expect(popup.style.left).toBe("585px");
185
+ });
186
+
187
+ it("keeps the width cap through a flip (cap + flip combined)", () => {
188
+ // R1 verification defect: applyBase on the flipped side used to reset
189
+ // maxWidth, so the uncapped box was measured and pinned — one edge at
190
+ // the gutter, the opposite 33px PAST the viewport. The cap belongs to
191
+ // the box, not the side: it must survive the flip.
192
+ window.innerWidth = 375;
193
+ window.innerHeight = 667;
194
+ const trigger = box(300, 300, 40, 40); // right edge at 340
195
+ const popup = probePopup([
196
+ box(348, 304, 400, 32), // base "right": 400px wide → cap 359px
197
+ box(348, 304, 359, 32), // capped re-measure → right side starved
198
+ box(-67, 304, 359, 32), // flipped "left": uncapped-width visual box
199
+ ]);
200
+ applyTooltipPosition(popup, trigger, "right");
201
+ expect(popup.style.maxWidth).toBe("359px"); // must survive the flip
202
+ expect(popup.style.transform).toBe("translate(-100%, -50%)");
203
+ // Shift pins 292 + 75 = 367 → visual box 8..367, exactly inside the
204
+ // gutters on BOTH edges (375 - 8 = 367).
205
+ expect(popup.style.left).toBe("367px");
206
+ });
207
+
208
+ it("keeps the height cap and clip through a flip (cap + flip combined)", () => {
209
+ // The near-top-edge tall tooltip: guaranteed flip (the top side
210
+ // cannot hold it) — the height cap must not be reset by the flip
211
+ // rewrite, or the pinned box spills 41px past the bottom edge.
212
+ window.innerWidth = 375;
213
+ window.innerHeight = 667;
214
+ const trigger = box(160, 4, 40, 18);
215
+ const popup = probePopup([
216
+ box(100, -704, 160, 700), // base "top": 700px tall → cap 651px
217
+ box(100, -704, 160, 651), // capped re-measure → top starved
218
+ box(100, 30, 160, 651), // flipped "bottom" visual box
219
+ ]);
220
+ applyTooltipPosition(popup, trigger, "top");
221
+ expect(popup.style.maxHeight).toBe("651px");
222
+ expect(popup.style.overflow).toBe("hidden");
223
+ expect(popup.style.transform).toBe("translate(-50%, 0)");
224
+ // 30 - 22 = 8 → visual box 8..659, inside the bottom gutter (667-8).
225
+ expect(popup.style.top).toBe("8px");
226
+ });
227
+ });
228
+
229
+ describe("resolveTooltipFlip", () => {
230
+ const vw = 375;
231
+ const vh = 667;
232
+ const gutter = 8;
233
+
234
+ it("keeps the placement when the preferred side fits", () => {
235
+ const trigger = box(100, 100, 40, 40);
236
+ expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, vw, vh, gutter)).toBe("top");
237
+ });
238
+
239
+ it("flips to the opposite side when it has more room", () => {
240
+ const trigger = box(100, 2, 40, 18);
241
+ expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, vw, vh, gutter)).toBe("bottom");
242
+ const floorTrigger = box(100, 640, 40, 20);
243
+ expect(resolveTooltipFlip(floorTrigger, "bottom", { width: 200, height: 32 }, vw, vh, gutter)).toBe("top");
244
+ });
245
+
246
+ it("keeps the placement when the opposite side is no better (the clamp handles it)", () => {
247
+ // Both sides starved, opposite NOT strictly larger → keep the author's
248
+ // placement; the gutter clamp pins the popup on-screen.
249
+ const trigger = box(100, 2, 40, 663); // spaceAbove -6, spaceBelow -6
250
+ expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 400 }, vw, vh, gutter)).toBe("top");
251
+ });
41
252
  });