@celestia-island/hikari 0.55.130 → 0.55.131

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.130",
3
+ "version": "0.55.131",
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",
@@ -0,0 +1,52 @@
1
+ // Unit tests for the context-menu quadrant pick: the pointer's nearest
2
+ // halves split the POPUP viewport (the window minus the app-chrome band
3
+ // the host configured), so a menu opened over a reserved strip grows
4
+ // into the usable space.
5
+ //
6
+ // AI disclosure: drafted by GLM via the ZCode agent, directed and
7
+ // reviewed by langyo — SySL-1.0 §2.3.
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+
10
+ import { configurePopupInsets } from "../runtime/popupBounds";
11
+ import { quadrantPlacement } from "./HkContextMenuProvider";
12
+
13
+ const PREV_INNER = { width: window.innerWidth, height: window.innerHeight };
14
+
15
+ afterEach(() => {
16
+ configurePopupInsets(null);
17
+ window.innerWidth = PREV_INNER.width;
18
+ window.innerHeight = PREV_INNER.height;
19
+ });
20
+
21
+ describe("quadrantPlacement", () => {
22
+ it("splits the raw window in half when no band is configured", () => {
23
+ window.innerWidth = 800;
24
+ window.innerHeight = 600;
25
+ expect(quadrantPlacement(100, 100)).toBe("bottom-start");
26
+ expect(quadrantPlacement(700, 100)).toBe("bottom-end");
27
+ expect(quadrantPlacement(100, 500)).toBe("top-start");
28
+ expect(quadrantPlacement(700, 500)).toBe("top-end");
29
+ // On the midlines the lower/right halves win (<=), unchanged.
30
+ expect(quadrantPlacement(400, 300)).toBe("bottom-start");
31
+ });
32
+
33
+ it("splits the popup viewport when an app-chrome band is configured", () => {
34
+ window.innerWidth = 800;
35
+ window.innerHeight = 600;
36
+ // A 400px title-bar stack: popups live in y 400..600, so the frame
37
+ // midline is y=500 — a pointer at y=550 (raw-window TOP half) now
38
+ // grows UP, staying inside the frame's lower half.
39
+ configurePopupInsets({ top: 400 });
40
+ expect(quadrantPlacement(500, 550)).toBe("top-end");
41
+ expect(quadrantPlacement(100, 450)).toBe("bottom-start");
42
+ expect(quadrantPlacement(700, 450)).toBe("bottom-end");
43
+ });
44
+
45
+ it("honors a side band on the horizontal split", () => {
46
+ window.innerWidth = 800;
47
+ window.innerHeight = 600;
48
+ configurePopupInsets({ left: 600 }); // frame x 600..800, midline 700
49
+ expect(quadrantPlacement(650, 100)).toBe("bottom-start");
50
+ expect(quadrantPlacement(750, 100)).toBe("bottom-end");
51
+ });
52
+ });
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { computed, defineComponent, provide, ref, shallowRef } from "vue";
26
26
 
27
+ import { popupViewportRect } from "../runtime/popupBounds";
27
28
  import HkMenu from "./HkMenu";
28
29
  import {
29
30
  CONTEXT_MENU_KEY,
@@ -33,10 +34,15 @@ import {
33
34
 
34
35
  /** Pick the HkMenu placement that grows away from the pointer's nearest
35
36
  * edges: `bottom-*` when there is more room below, `*-start` when there
36
- * is more room to the right. */
37
- function quadrantPlacement(x: number, y: number): "bottom-start" | "bottom-end" | "top-start" | "top-end" {
38
- const below = y <= (typeof window !== "undefined" ? window.innerHeight : 0) / 2;
39
- const right = x <= (typeof window !== "undefined" ? window.innerWidth : 0) / 2;
37
+ * is more room to the right. The halves split the POPUP viewport (the
38
+ * window minus the app-chrome band the host configured — popupBounds),
39
+ * so a menu opened over a desktop shell's reserved strip grows into the
40
+ * usable space like every other popup. */
41
+ export function quadrantPlacement(x: number, y: number): "bottom-start" | "bottom-end" | "top-start" | "top-end" {
42
+ if (typeof window === "undefined") return "bottom-start";
43
+ const frame = popupViewportRect();
44
+ const below = y <= frame.y + frame.height / 2;
45
+ const right = x <= frame.x + frame.width / 2;
40
46
  if (below && right) return "bottom-start";
41
47
  if (below) return "bottom-end";
42
48
  return right ? "top-start" : "top-end";
@@ -1,6 +1,7 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref, type ComponentPublicInstance } from "vue";
3
3
 
4
+ import { configurePopupInsets } from "../runtime/popupBounds";
4
5
  import HkMenu, { type HkMenuItem } from "./HkMenu";
5
6
 
6
7
  /** Regional-indicator flag pair for an ISO 3166-1 alpha-2 code, built
@@ -186,6 +187,7 @@ afterEach(async () => {
186
187
  .querySelectorAll(".hk-select-popout, .hk-select-sheet-panel, .hk-select-sheet-scrim")
187
188
  .forEach((el) => el.remove());
188
189
  while (containers.length) containers.pop()?.remove();
190
+ configurePopupInsets(null);
189
191
  window.innerWidth = 1024;
190
192
  });
191
193
 
@@ -1081,3 +1083,45 @@ describe("HkMenu close linger (leave-transition window)", () => {
1081
1083
  expect(popouts().length).toBe(1);
1082
1084
  });
1083
1085
  });
1086
+
1087
+ describe("HkMenu cascade against an app-chrome band", () => {
1088
+ it("opens a right-start cascade on the side the band leaves room for", async () => {
1089
+ // An 850px right-docked band on a 1200px window leaves the popup
1090
+ // frame 0..350. The anchor's right edge sits at 130: the RAW window
1091
+ // would host the 224px cascade beside it (130 + 6 + 224 = 360 ≤
1092
+ // 1184), but the frame's gutter line is 350 - 16 = 334 — so the
1093
+ // cascade flips to the anchor's left, pinned at the frame's leading
1094
+ // gutter instead of the window's.
1095
+ const rect = (x: number, y: number, w: number, h: number): DOMRect =>
1096
+ ({ x, y, width: w, height: h, top: y, left: x, right: x + w, bottom: y + h, toJSON: () => ({}) }) as DOMRect;
1097
+
1098
+ window.innerWidth = 1200;
1099
+ const openRef = ref(false);
1100
+ const first = mountMenu(openRef, items, {}, { props: { placement: "right-start" } });
1101
+ vi.spyOn(first.anchor, "getBoundingClientRect").mockReturnValue(rect(50, 100, 80, 40));
1102
+ openRef.value = true;
1103
+ await settle();
1104
+ expect(popoutHosts()[0].style.left).toBe("136px"); // r.right + gap
1105
+ first.unmount();
1106
+
1107
+ configurePopupInsets({ right: 850 });
1108
+ const openRef2 = ref(false);
1109
+ const second = mountMenu(openRef2, items, {}, { props: { placement: "right-start" } });
1110
+ vi.spyOn(second.anchor, "getBoundingClientRect").mockReturnValue(rect(50, 100, 80, 40));
1111
+ openRef2.value = true;
1112
+ await settle();
1113
+ expect(popoutHosts()[0]?.style.left).toBe("16px"); // frame.x + gutter
1114
+ second.unmount();
1115
+
1116
+ // A LEFT band pins the flipped cascade at the frame's leading edge —
1117
+ // max(frame.x + pad, …), not max(pad, …): a swapped origin would land
1118
+ // this at 820 (16 would fail the frame's own leading gutter).
1119
+ configurePopupInsets({ left: 1000 });
1120
+ const openRef3 = ref(false);
1121
+ const third = mountMenu(openRef3, items, {}, { props: { placement: "right-start" } });
1122
+ vi.spyOn(third.anchor, "getBoundingClientRect").mockReturnValue(rect(1050, 100, 80, 40));
1123
+ openRef3.value = true;
1124
+ await settle();
1125
+ expect(popoutHosts()[0]?.style.left).toBe("1016px"); // frame.x + gutter
1126
+ });
1127
+ });
@@ -14,6 +14,7 @@ import { Check, ChevronRight } from "lucide-vue-next";
14
14
 
15
15
  import { useBreakpoint } from "../runtime/useBreakpoint";
16
16
  import { ancestorZoom } from "../runtime/cssZoom";
17
+ import { popupViewportRect } from "../runtime/popupBounds";
17
18
  import { viewportGutterPx } from "../runtime/viewportGutter";
18
19
  import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
19
20
  import "./HkMenu.scss";
@@ -426,8 +427,9 @@ export default defineComponent({
426
427
  const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
427
428
  // Shared viewport gutter (--viewport-gutter: 8 mobile / 16 desktop).
428
429
  const pad = viewportGutterPx();
429
- const openRight = r.right + cascadeW <= window.innerWidth - pad;
430
- const left = openRight ? r.right : Math.max(pad, r.left - cascadeW);
430
+ const frame = popupViewportRect();
431
+ const openRight = r.right + cascadeW <= frame.x + frame.width - pad;
432
+ const left = openRight ? r.right : Math.max(frame.x + pad, r.left - cascadeW);
431
433
  return pointRect(left, r.top);
432
434
  },
433
435
  contains: (node) => !!rowRefs.value[id]?.contains(node),
@@ -451,11 +453,12 @@ export default defineComponent({
451
453
  const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
452
454
  // Shared viewport gutter (--viewport-gutter: 8 mobile / 16 desktop).
453
455
  const pad = viewportGutterPx();
454
- const openRight = r.right + gap + cascadeW <= window.innerWidth - pad;
456
+ const frame = popupViewportRect();
457
+ const openRight = r.right + gap + cascadeW <= frame.x + frame.width - pad;
455
458
  const left =
456
459
  side === "right" && openRight
457
460
  ? r.right + gap
458
- : Math.max(pad, r.left - cascadeW - gap);
461
+ : Math.max(frame.x + pad, r.left - cascadeW - gap);
459
462
  return pointRect(left, r.top);
460
463
  },
461
464
  contains: (node) => !!props.anchorRef?.contains(node),
@@ -11,6 +11,7 @@
11
11
  import { afterEach, describe, expect, it, vi } from "vitest";
12
12
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
13
13
 
14
+ import { configurePopupInsets } from "../runtime/popupBounds";
14
15
  import HkPopover from "./HkPopover";
15
16
 
16
17
  const mounts: ReturnType<typeof createApp>[] = [];
@@ -64,7 +65,7 @@ async function flushFrames() {
64
65
  }
65
66
  }
66
67
 
67
- function mountPopover(anchor: HTMLElement, placement: "bottom" | "top-start" = "bottom") {
68
+ function mountPopover(anchor: HTMLElement, placement: "bottom" | "top-start" | "bottom-start" = "bottom") {
68
69
  const container = document.createElement("div");
69
70
  document.body.appendChild(container);
70
71
  containers.push(container);
@@ -152,3 +153,72 @@ describe("HkPopover anchoring under root CSS zoom", () => {
152
153
  expect(parseFloat(host.style.left)).toBeCloseTo(20 / 3, 2);
153
154
  });
154
155
  });
156
+
157
+ describe("HkPopover anchoring against an app-chrome band", () => {
158
+ const PREV_HEIGHT = window.innerHeight;
159
+
160
+ afterEach(() => {
161
+ configurePopupInsets(null);
162
+ window.innerHeight = PREV_HEIGHT;
163
+ });
164
+
165
+ it("clamps the panel inside the band's frame, not the raw window", async () => {
166
+ // The band reserves the BOTTOM 700px of the window — the popup frame
167
+ // is the top 100px strip. The anchor sits inside the band's territory
168
+ // (y=700): both sides are starved inside the frame, the flip picks
169
+ // top (more room), and the main-axis clamp pins the panel bottom at
170
+ // the FRAME's lower gutter (y=84) instead of the window's.
171
+ window.innerWidth = 1200;
172
+ window.innerHeight = 800;
173
+ patchRootZoom("1");
174
+
175
+ // Control, no band: the same anchor clamps against the WINDOW bottom
176
+ // edge — panel bottom 104px above it.
177
+ const anchor = document.createElement("button");
178
+ patchRects(anchor, rect(100, 700, 80, 32), rect(0, 0, 200, 60));
179
+ const open = mountPopover(anchor, "bottom");
180
+ open.value = true;
181
+ await flushFrames();
182
+ const panels = document.body.querySelectorAll<HTMLElement>(".hk-popover-panel");
183
+ const host = panels[panels.length - 1]!.parentElement!;
184
+ expect(host.style.bottom).toBe("104px");
185
+ open.value = false;
186
+ await flushFrames();
187
+
188
+ configurePopupInsets({ bottom: 700 });
189
+ const anchor2 = document.createElement("button");
190
+ patchRects(anchor2, rect(100, 700, 80, 32), rect(0, 0, 200, 60));
191
+ const open2 = mountPopover(anchor2, "bottom");
192
+ open2.value = true;
193
+ await flushFrames();
194
+ // happy-dom never fires transitionend, so the control panel may
195
+ // linger — the freshly positioned one is the LAST in the body.
196
+ const panels2 = document.body.querySelectorAll<HTMLElement>(".hk-popover-panel");
197
+ const host2 = panels2[panels2.length - 1]!.parentElement!;
198
+ // Panel bottom pinned at frame.y + frame.height - gutter = 100 - 16,
199
+ // written as the viewport-bottom distance 800 - 84 = 716.
200
+ expect(host2.style.bottom).toBe("716px");
201
+ // Cross axis stays HORIZONTAL: centered at 40, inside the frame's
202
+ // x-gutter as-is (an axis-swapped frame would pin it to 16).
203
+ expect(host2.style.left).toBe("40px");
204
+ });
205
+
206
+ it("keeps the no-band cross clamp on the horizontal axis of a landscape window", async () => {
207
+ // Regression pin for the cross-axis frame mapping: on a non-square
208
+ // window the cross clamp of a vertically-stacked panel must measure
209
+ // against the WIDTH (1440), not the height (900) — the swapped
210
+ // mapping clamped this start-aligned panel to 634px.
211
+ window.innerWidth = 1440;
212
+ window.innerHeight = 900;
213
+ patchRootZoom("1");
214
+ const anchor = document.createElement("button");
215
+ patchRects(anchor, rect(1300, 500, 40, 40), rect(0, 0, 250, 90));
216
+ const open = mountPopover(anchor, "bottom-start");
217
+ open.value = true;
218
+ await flushFrames();
219
+ const panels = document.body.querySelectorAll<HTMLElement>(".hk-popover-panel");
220
+ const host = panels[panels.length - 1]!.parentElement!;
221
+ expect(host.style.left).toBe("1174px"); // 1440 - 250 - 16
222
+ expect(host.style.top).toBe("544px"); // anchor bottom 540 + offset 4
223
+ });
224
+ });
@@ -15,6 +15,7 @@ import {
15
15
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
16
16
  import { useBreakpoint } from "../runtime/useBreakpoint";
17
17
  import { ancestorZoom } from "../runtime/cssZoom";
18
+ import { popupViewportRect } from "../runtime/popupBounds";
18
19
  import { clampWithGutter, viewportGutterPx } from "../runtime/viewportGutter";
19
20
  import { useI18n } from "../i18n/context";
20
21
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
@@ -342,6 +343,12 @@ export default defineComponent({
342
343
  }
343
344
  const vw = window.innerWidth;
344
345
  const vh = window.innerHeight;
346
+ // The popup viewport: the window minus the app-chrome band the host
347
+ // configured (popupBounds) — full-window when none, in which case
348
+ // every space check and clamp below is the raw-window math. vw/vh
349
+ // stay for the CSS-coordinate writes (`bottom`/`right` are measured
350
+ // from the VIEWPORT edges regardless of the band).
351
+ const frame = popupViewportRect();
345
352
  // The shared viewport gutter (--viewport-gutter: 8px mobile / 16px
346
353
  // desktop) — read per reposition so a viewport crossing the
347
354
  // breakpoint re-clamps with the right value on the next pass.
@@ -352,26 +359,26 @@ export default defineComponent({
352
359
 
353
360
  if (props.autoFlip) {
354
361
  if (side === "bottom") {
355
- const spaceBelow = vh - anchorRect.bottom;
356
- const spaceAbove = anchorRect.top;
362
+ const spaceBelow = frame.y + frame.height - anchorRect.bottom;
363
+ const spaceAbove = anchorRect.top - frame.y;
357
364
  if (spaceBelow < panelRect.height + pad && spaceAbove > spaceBelow) {
358
365
  side = "top";
359
366
  }
360
367
  } else if (side === "top") {
361
- const spaceAbove = anchorRect.top;
362
- const spaceBelow = vh - anchorRect.bottom;
368
+ const spaceAbove = anchorRect.top - frame.y;
369
+ const spaceBelow = frame.y + frame.height - anchorRect.bottom;
363
370
  if (spaceAbove < panelRect.height + pad && spaceBelow > spaceAbove) {
364
371
  side = "bottom";
365
372
  }
366
373
  } else if (side === "right") {
367
- const spaceRight = vw - anchorRect.right;
368
- const spaceLeft = anchorRect.left;
374
+ const spaceRight = frame.x + frame.width - anchorRect.right;
375
+ const spaceLeft = anchorRect.left - frame.x;
369
376
  if (spaceRight < panelRect.width + pad && spaceLeft > spaceRight) {
370
377
  side = "left";
371
378
  }
372
379
  } else if (side === "left") {
373
- const spaceLeft = anchorRect.left;
374
- const spaceRight = vw - anchorRect.right;
380
+ const spaceLeft = anchorRect.left - frame.x;
381
+ const spaceRight = frame.x + frame.width - anchorRect.right;
375
382
  if (spaceLeft < panelRect.width + pad && spaceRight > spaceLeft) {
376
383
  side = "right";
377
384
  }
@@ -387,7 +394,8 @@ export default defineComponent({
387
394
  const anchorStart = isVertical ? anchorRect.left : anchorRect.top;
388
395
  const anchorEnd = isVertical ? anchorRect.right : anchorRect.bottom;
389
396
  const anchorSize = isVertical ? anchorRect.width : anchorRect.height;
390
- const viewportSize = isVertical ? vw : vh;
397
+ const frameSize = isVertical ? frame.width : frame.height;
398
+ const frameOrigin = isVertical ? frame.x : frame.y;
391
399
 
392
400
  let crossPos: number;
393
401
  if (align === "start") {
@@ -397,7 +405,10 @@ export default defineComponent({
397
405
  } else {
398
406
  crossPos = anchorStart + (anchorSize - panelSize) / 2;
399
407
  }
400
- crossPos = clampWithGutter(crossPos, panelSize, viewportSize, pad);
408
+ // Clamps run against the popup viewport, frame-relative and re-based
409
+ // onto the frame origin — with no band configured the origin is 0
410
+ // and every clamp is the plain viewport-gutter clamp.
411
+ crossPos = clampWithGutter(crossPos - frameOrigin, panelSize, frameSize, pad) + frameOrigin;
401
412
 
402
413
  if (side === "bottom") {
403
414
  c.top = anchorRect.bottom + off;
@@ -414,13 +425,21 @@ export default defineComponent({
414
425
  }
415
426
 
416
427
  if (side === "bottom") {
417
- c.top = clampWithGutter(c.top!, panelRect.height, vh, pad);
428
+ c.top = clampWithGutter(c.top! - frame.y, panelRect.height, frame.height, pad) + frame.y;
418
429
  } else if (side === "top") {
419
- c.bottom = clampWithGutter(c.bottom ?? 0, panelRect.height, vh, pad);
430
+ // `bottom` counts from the viewport's bottom edge — shift it into
431
+ // frame-relative space (0 when no band is configured) and back.
432
+ const fromFrameBottom = vh - (frame.y + frame.height);
433
+ c.bottom =
434
+ clampWithGutter((c.bottom ?? 0) - fromFrameBottom, panelRect.height, frame.height, pad) +
435
+ fromFrameBottom;
420
436
  } else if (side === "right") {
421
- c.left = clampWithGutter(c.left!, panelRect.width, vw, pad);
437
+ c.left = clampWithGutter(c.left! - frame.x, panelRect.width, frame.width, pad) + frame.x;
422
438
  } else {
423
- c.right = clampWithGutter(c.right ?? 0, panelRect.width, vw, pad);
439
+ const fromFrameRight = vw - (frame.x + frame.width);
440
+ c.right =
441
+ clampWithGutter((c.right ?? 0) - fromFrameRight, panelRect.width, frame.width, pad) +
442
+ fromFrameRight;
424
443
  }
425
444
 
426
445
  coords.value = c;
@@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
11
11
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
12
12
  import type { ComponentPublicInstance } from "vue";
13
13
 
14
+ import { configurePopupInsets } from "../runtime/popupBounds";
14
15
  import HkSelectPanel from "./HkSelectPanel";
15
16
 
16
17
  const mounts: ReturnType<typeof createApp>[] = [];
@@ -123,3 +124,34 @@ describe("HkSelectPanel popout under root CSS zoom", () => {
123
124
  expect(host.style.left).toBe("100px");
124
125
  });
125
126
  });
127
+
128
+ describe("HkSelectPanel popout against an app-chrome band", () => {
129
+ const PREV_HEIGHT = window.innerHeight;
130
+
131
+ afterEach(() => {
132
+ configurePopupInsets(null);
133
+ window.innerHeight = PREV_HEIGHT;
134
+ });
135
+
136
+ it("flips a top-placed panel below its anchor when the band eats the space", async () => {
137
+ // A 260px band (a docked header stack) leaves frame 260..800. The
138
+ // anchor at y=300 with a top placement: the RAW-viewport top space
139
+ // (300 - 16) holds the 200px panel — the pre-bounds math pinned it
140
+ // at top 96, across the band. Inside the frame only 300 - 260 - 16 =
141
+ // 24 remains → flip below; the clamp keeps the flipped panel inside
142
+ // the frame (344 + 200 = 544 ≤ 800 - 16).
143
+ window.innerWidth = 1200;
144
+ window.innerHeight = 800;
145
+ patchRootZoom("1");
146
+ configurePopupInsets({ top: 260 });
147
+ const { button } = mountPanel();
148
+ vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(200, 300, 100, 40));
149
+
150
+ button.click();
151
+ await flush();
152
+
153
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
154
+ expect(host.style.top).toBe("344px"); // anchor bottom 340 + offset 4
155
+ expect(host.style.left).toBe("200px"); // start, inside the frame as-is
156
+ });
157
+ });
@@ -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 { popupViewportRect } from "../runtime/popupBounds";
17
18
  import { clampWithGutter, viewportGutterPx } from "../runtime/viewportGutter";
18
19
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
19
20
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
@@ -547,16 +548,20 @@ export default defineComponent({
547
548
  // The shared viewport gutter (--viewport-gutter: 8px mobile / 16px
548
549
  // desktop), read per positioning pass.
549
550
  const pad = viewportGutterPx();
551
+ // The popup viewport: the window minus the app-chrome band the host
552
+ // configured (popupBounds) — full-window when none, in which case
553
+ // every flip and clamp below is the raw-window math.
554
+ const frame = popupViewportRect();
550
555
  let side: "top" | "bottom" = props.placement.startsWith("top-") ? "top" : "bottom";
551
556
  let top =
552
557
  side === "top"
553
558
  ? r.top - props.offset - ph
554
559
  : r.bottom + props.offset;
555
560
  // Auto-flip when the chosen side cannot host the panel.
556
- if (side === "bottom" && top + ph > window.innerHeight - pad) {
561
+ if (side === "bottom" && top + ph > frame.y + frame.height - pad) {
557
562
  side = "top";
558
563
  top = r.top - props.offset - ph;
559
- } else if (side === "top" && top < pad) {
564
+ } else if (side === "top" && top < frame.y + pad) {
560
565
  side = "bottom";
561
566
  top = r.bottom + props.offset;
562
567
  }
@@ -571,7 +576,7 @@ export default defineComponent({
571
576
  // bottom→top into a negative top that was applied verbatim. Clamp so
572
577
  // the whole panel stays on-screen; when content exceeds the CSS cap
573
578
  // the panel's own internal scroll takes over.
574
- top = clampWithGutter(top, ph, window.innerHeight, pad);
579
+ top = clampWithGutter(top - frame.y, ph, frame.height, pad) + frame.y;
575
580
  // -center balances the panel on the anchor's horizontal midpoint
576
581
  // (still clamped, so a half-off-screen anchor keeps the panel
577
582
  // readable instead of mirroring the overflow to both edges).
@@ -581,7 +586,7 @@ export default defineComponent({
581
586
  : props.placement.endsWith("-end")
582
587
  ? r.right - pw
583
588
  : r.left;
584
- const clampedLeft = clampWithGutter(left, pw, window.innerWidth, pad);
589
+ const clampedLeft = clampWithGutter(left - frame.x, pw, frame.width, pad) + frame.x;
585
590
  coords.value = {
586
591
  top: `${Math.round(top / z)}px`,
587
592
  left: `${Math.round(clampedLeft / z)}px`,
@@ -19,6 +19,15 @@ export {
19
19
  TOOLTIP_GAP_PX,
20
20
  type TooltipPlacement,
21
21
  } from "./tooltipPosition";
22
+ export {
23
+ configurePopupInsets,
24
+ popupInsets,
25
+ popupViewportRect,
26
+ POPUP_INSET_VARS,
27
+ type PopupInsets,
28
+ type ResolvedPopupInsets,
29
+ type PopupViewportRect,
30
+ } from "./popupBounds";
22
31
  export {
23
32
  viewportGutterPx,
24
33
  clampWithGutter,
@@ -0,0 +1,90 @@
1
+ // Contract tests for the app-chrome popup bounds context.
2
+ //
3
+ // AI disclosure: drafted by GLM via the ZCode agent, directed and
4
+ // reviewed by langyo — SySL-1.0 §2.3.
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import { readHkRuntime, writeHkRuntime } from "./registry";
8
+ import {
9
+ configurePopupInsets,
10
+ popupInsets,
11
+ popupViewportRect,
12
+ POPUP_INSET_VARS,
13
+ } from "./popupBounds";
14
+
15
+ const PREV_INNER = { width: window.innerWidth, height: window.innerHeight };
16
+
17
+ afterEach(() => {
18
+ configurePopupInsets(null);
19
+ window.innerWidth = PREV_INNER.width;
20
+ window.innerHeight = PREV_INNER.height;
21
+ });
22
+
23
+ describe("popupBounds", () => {
24
+ it("defaults to a zero band and the full-window frame", () => {
25
+ window.innerWidth = 1280;
26
+ window.innerHeight = 800;
27
+ expect(popupInsets()).toEqual({ top: 0, right: 0, bottom: 0, left: 0 });
28
+ expect(popupViewportRect()).toEqual({ x: 0, y: 0, width: 1280, height: 800 });
29
+ });
30
+
31
+ it("shrinks the popup frame by the configured band", () => {
32
+ window.innerWidth = 1280;
33
+ window.innerHeight = 800;
34
+ configurePopupInsets({ top: 32, left: 48 });
35
+ expect(popupViewportRect()).toEqual({ x: 48, y: 32, width: 1232, height: 768 });
36
+ });
37
+
38
+ it("replaces the whole band on re-configure and clears on null", () => {
39
+ window.innerWidth = 1280;
40
+ window.innerHeight = 800;
41
+ configurePopupInsets({ top: 32, bottom: 10 });
42
+ configurePopupInsets({ left: 64 });
43
+ expect(popupInsets()).toEqual({ top: 0, right: 0, bottom: 0, left: 64 });
44
+ expect(popupViewportRect()).toEqual({ x: 64, y: 0, width: 1216, height: 800 });
45
+ configurePopupInsets(null);
46
+ expect(popupViewportRect()).toEqual({ x: 0, y: 0, width: 1280, height: 800 });
47
+ });
48
+
49
+ it("sanitizes non-finite and non-positive sides to 0", () => {
50
+ configurePopupInsets({ top: Number.NaN, right: -5, bottom: 12, left: 0 });
51
+ expect(popupInsets()).toEqual({ top: 0, right: 0, bottom: 12, left: 0 });
52
+ });
53
+
54
+ it("mirrors the band onto :root variables, zeroes included", () => {
55
+ configurePopupInsets({ top: 32 });
56
+ expect(document.documentElement.style.getPropertyValue(POPUP_INSET_VARS.top)).toBe("32px");
57
+ expect(document.documentElement.style.getPropertyValue(POPUP_INSET_VARS.left)).toBe("0px");
58
+ // Re-configure rewrites every side, so a dropped side cannot keep a
59
+ // stale larger value.
60
+ configurePopupInsets({ left: 64 });
61
+ expect(document.documentElement.style.getPropertyValue(POPUP_INSET_VARS.top)).toBe("0px");
62
+ expect(document.documentElement.style.getPropertyValue(POPUP_INSET_VARS.left)).toBe("64px");
63
+ });
64
+
65
+ it("collapses the frame when the band overruns the window", () => {
66
+ window.innerWidth = 800;
67
+ window.innerHeight = 600;
68
+ configurePopupInsets({ top: 500, bottom: 500 });
69
+ expect(popupViewportRect()).toEqual({ x: 0, y: 500, width: 800, height: 0 });
70
+ configurePopupInsets({ left: 900 });
71
+ expect(popupViewportRect()).toEqual({ x: 800, y: 0, width: 0, height: 600 });
72
+ });
73
+
74
+ it("reports its live band and frame to the runtime registry", () => {
75
+ window.innerWidth = 1280;
76
+ window.innerHeight = 800;
77
+ configurePopupInsets({ top: 32 });
78
+ expect(readHkRuntime("popupBounds")).toEqual({
79
+ insets: { top: 32, right: 0, bottom: 0, left: 0 },
80
+ viewport: { x: 0, y: 32, width: 1280, height: 768 },
81
+ });
82
+ // The write facet rides the same registry entry — exercise it through
83
+ // writeHkRuntime so the op contract stays honest.
84
+ expect(writeHkRuntime("popupBounds", { type: "configure", insets: { top: 8 } })).toBe(true);
85
+ expect(readHkRuntime("popupBounds")).toMatchObject({
86
+ insets: { top: 8, right: 0, bottom: 0, left: 0 },
87
+ });
88
+ expect(writeHkRuntime("popupBounds", { type: "nonsense" })).toBe(false);
89
+ });
90
+ });
@@ -0,0 +1,146 @@
1
+ // App-chrome popup bounds — the developer-facing inset that shrinks the
2
+ // region every JS-positioned popup may occupy.
3
+ //
4
+ // A desktop shell routinely reserves part of the window for chrome a
5
+ // popup must never cover. The report that motivated this module: a
6
+ // frameless Tauri app draws its own title bar and anchors tooltips on
7
+ // the first row beneath it — every positioner here measured against the
8
+ // raw window plus the gutter alone, so those tooltips pinned up over the
9
+ // caption strip. The host declares the occupied band ONCE and every
10
+ // positioner (both tooltip surfaces, HkPopover, HkSelectPanel / HkMenu
11
+ // cascades, the context-menu quadrant pick) centers, flips and clamps
12
+ // inside the remainder. The configuration is per JS realm — a Tauri
13
+ // tray or overlay window is its own realm and simply configures nothing.
14
+ //
15
+ // The pattern is the one --viewport-gutter established: one magnitude
16
+ // shared by declarative consumers and the JS math. configurePopupInsets
17
+ // mirrors the four numbers onto :root as
18
+ // `--hk-popup-inset-{top,right,bottom,left}` (the useSafeArea variable
19
+ // convention), and popupViewportRect() is the JS side of the same
20
+ // contract — the window rect inset by the band. Values are root visual
21
+ // px (measure chrome with getBoundingClientRect), deliberately NOT
22
+ // zoom-corrected — same story as the gutter.
23
+ //
24
+ // This is developer configuration, not user theming: the shell sets it
25
+ // at bootstrap from its real layout and re-sets it when that layout
26
+ // changes (maximize, a phone-layout bar raising). null clears the band.
27
+ // Scope: the JS-positioned floating surfaces honor the band today
28
+ // (tooltips, popovers, select/menu popouts, context-menu quadrants);
29
+ // CSS-positioned overlays (modals, drawers, bottom sheets) do not yet —
30
+ // a bottom band will underlap them until they read the mirrored vars.
31
+ //
32
+ // AI disclosure: drafted by GLM via the ZCode agent, directed and
33
+ // reviewed by langyo — SySL-1.0 §2.3.
34
+ import { reportHkRuntime } from "./registry";
35
+
36
+ /** The app-chrome band: how much of the window each side reserves. */
37
+ export interface PopupInsets {
38
+ top?: number;
39
+ right?: number;
40
+ bottom?: number;
41
+ left?: number;
42
+ }
43
+
44
+ /** The band with every side resolved (unset sides are 0). */
45
+ export interface ResolvedPopupInsets {
46
+ top: number;
47
+ right: number;
48
+ bottom: number;
49
+ left: number;
50
+ }
51
+
52
+ /** The :root variables the insets mirror onto (useSafeArea convention). */
53
+ export const POPUP_INSET_VARS: Record<keyof ResolvedPopupInsets, string> = {
54
+ top: "--hk-popup-inset-top",
55
+ right: "--hk-popup-inset-right",
56
+ bottom: "--hk-popup-inset-bottom",
57
+ left: "--hk-popup-inset-left",
58
+ };
59
+
60
+ /** The region popups may occupy, in root visual px. */
61
+ export interface PopupViewportRect {
62
+ x: number;
63
+ y: number;
64
+ width: number;
65
+ height: number;
66
+ }
67
+
68
+ let current: Readonly<ResolvedPopupInsets> = { top: 0, right: 0, bottom: 0, left: 0 };
69
+ let runtimeReport: ReturnType<typeof reportHkRuntime> | null = null;
70
+
71
+ function sanitize(value: number | undefined): number {
72
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
73
+ }
74
+
75
+ function reportOnce(): void {
76
+ if (runtimeReport) return;
77
+ runtimeReport = reportHkRuntime("popupBounds", {
78
+ kind: "context",
79
+ description:
80
+ "App-chrome popup bounds — the inset band every popup positions inside",
81
+ read: () => ({ insets: { ...current }, viewport: popupViewportRect() }),
82
+ write: (op) => {
83
+ if (op.type === "configure") {
84
+ configurePopupInsets((op.insets as PopupInsets | null) ?? null);
85
+ return;
86
+ }
87
+ throw new Error(`unknown popupBounds write op: ${op.type}`);
88
+ },
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Declare the app-chrome band popups must stay out of, in root visual px
94
+ * (measure the chrome with getBoundingClientRect). Re-callable — the new
95
+ * insets REPLACE the old ones wholesale, so a shell re-declares on every
96
+ * layout change; null clears the band. Non-finite and non-positive sides
97
+ * sanitize to 0, so a mis-measured bar degrades to "no band on that
98
+ * side" instead of corrupting the frame.
99
+ */
100
+ export function configurePopupInsets(insets: PopupInsets | null): void {
101
+ current = Object.freeze(
102
+ insets
103
+ ? {
104
+ top: sanitize(insets.top),
105
+ right: sanitize(insets.right),
106
+ bottom: sanitize(insets.bottom),
107
+ left: sanitize(insets.left),
108
+ }
109
+ : { top: 0, right: 0, bottom: 0, left: 0 },
110
+ );
111
+ if (typeof document !== "undefined") {
112
+ const rootStyle = document.documentElement.style;
113
+ for (const side of Object.keys(POPUP_INSET_VARS) as Array<keyof ResolvedPopupInsets>) {
114
+ // Zeroes are WRITTEN, not removed, so re-configuring can never
115
+ // leave a stale larger value on a side the new band dropped.
116
+ rootStyle.setProperty(POPUP_INSET_VARS[side], `${current[side]}px`);
117
+ }
118
+ }
119
+ reportOnce();
120
+ runtimeReport?.pulse();
121
+ }
122
+
123
+ /** The currently configured band — zeros when nothing was configured. */
124
+ export function popupInsets(): Readonly<ResolvedPopupInsets> {
125
+ return current;
126
+ }
127
+
128
+ /**
129
+ * The region popups may occupy: the window inset by the configured band.
130
+ * Width and height floor at 0 — an over-wide band collapses the frame,
131
+ * and every clamp then pins to its leading edge, the honest answer to
132
+ * an impossible configuration.
133
+ */
134
+ export function popupViewportRect(win?: Window | null): PopupViewportRect {
135
+ reportOnce();
136
+ if (typeof window === "undefined") return { x: 0, y: 0, width: 0, height: 0 };
137
+ const w = win ?? window;
138
+ const x = Math.min(current.left, w.innerWidth);
139
+ const y = Math.min(current.top, w.innerHeight);
140
+ return {
141
+ x,
142
+ y,
143
+ width: Math.max(0, w.innerWidth - x - Math.min(current.right, w.innerWidth - x)),
144
+ height: Math.max(0, w.innerHeight - y - Math.min(current.bottom, w.innerHeight - y)),
145
+ };
146
+ }
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
 
3
+ import { configurePopupInsets } from "./popupBounds";
3
4
  import {
4
5
  TOOLTIP_GAP_PX,
5
6
  applyTooltipPosition,
@@ -38,6 +39,7 @@ function probePopup(rects: DOMRect[]): HTMLElement {
38
39
  const PREV_INNER = { width: window.innerWidth, height: window.innerHeight };
39
40
 
40
41
  afterEach(() => {
42
+ configurePopupInsets(null);
41
43
  window.innerWidth = PREV_INNER.width;
42
44
  window.innerHeight = PREV_INNER.height;
43
45
  vi.restoreAllMocks();
@@ -227,26 +229,118 @@ describe("applyTooltipPosition viewport clamping", () => {
227
229
  });
228
230
 
229
231
  describe("resolveTooltipFlip", () => {
230
- const vw = 375;
231
- const vh = 667;
232
+ const frame = { x: 0, y: 0, width: 375, height: 667 };
232
233
  const gutter = 8;
233
234
 
234
235
  it("keeps the placement when the preferred side fits", () => {
235
236
  const trigger = box(100, 100, 40, 40);
236
- expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, vw, vh, gutter)).toBe("top");
237
+ expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, frame, gutter)).toBe("top");
237
238
  });
238
239
 
239
240
  it("flips to the opposite side when it has more room", () => {
240
241
  const trigger = box(100, 2, 40, 18);
241
- expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, vw, vh, gutter)).toBe("bottom");
242
+ expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, frame, gutter)).toBe("bottom");
242
243
  const floorTrigger = box(100, 640, 40, 20);
243
- expect(resolveTooltipFlip(floorTrigger, "bottom", { width: 200, height: 32 }, vw, vh, gutter)).toBe("top");
244
+ expect(resolveTooltipFlip(floorTrigger, "bottom", { width: 200, height: 32 }, frame, gutter)).toBe("top");
244
245
  });
245
246
 
246
247
  it("keeps the placement when the opposite side is no better (the clamp handles it)", () => {
247
248
  // Both sides starved, opposite NOT strictly larger → keep the author's
248
249
  // placement; the gutter clamp pins the popup on-screen.
249
250
  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
+ expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 400 }, frame, gutter)).toBe("top");
252
+ });
253
+ });
254
+
255
+ describe("resolveTooltipFlip against an app-chrome band", () => {
256
+ // A 40px desktop title bar configured as the popup bounds.
257
+ const frame = { x: 0, y: 40, width: 375, height: 627 };
258
+ const gutter = 8;
259
+
260
+ it("flips down when the popup would cross the band but fits the raw viewport above", () => {
261
+ // Trigger top 84: the RAW-viewport top space (84 - 8 = 76) would hold
262
+ // a 32px popup, but inside the frame only 84 - 40 - 8 = 36 remains —
263
+ // the caption strip wins, the tooltip pops below the trigger.
264
+ const trigger = box(100, 84, 40, 18);
265
+ expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, frame, gutter)).toBe("bottom");
266
+ });
267
+
268
+ it("keeps top when the space below the band edge still holds the popup", () => {
269
+ const trigger = box(100, 300, 40, 40);
270
+ expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, frame, gutter)).toBe("top");
271
+ });
272
+
273
+ it("measures the horizontal sides against the band, not the window", () => {
274
+ const docked = { x: 200, y: 0, width: 175, height: 667 };
275
+ const trigger = box(210, 300, 40, 40);
276
+ // Right space inside the frame 117 < 120 + 8 → checks the left
277
+ // alternate (2), which is not larger → keeps the author's side.
278
+ expect(resolveTooltipFlip(trigger, "right", { width: 120, height: 32 }, docked, gutter)).toBe("right");
279
+ });
280
+ });
281
+
282
+ describe("applyTooltipPosition against an app-chrome band", () => {
283
+ it("pops a first-row tooltip down instead of over the configured band", () => {
284
+ window.innerWidth = 375;
285
+ window.innerHeight = 667;
286
+ configurePopupInsets({ top: 40 });
287
+ // Raw-viewport top space is 84 - 8 = 76 ≥ 40 — the pre-bounds math
288
+ // kept this popup UP (visual box 44..76, across the 40px band); the
289
+ // band-aware flip sends it below the trigger instead.
290
+ const trigger = box(100, 84, 40, 18);
291
+ // Measures: the base-top box, then the flipped-bottom box the real
292
+ // DOM would report after the base rewrite.
293
+ const popup = probePopup([box(120, 44, 200, 32), box(120, 110, 200, 32)]);
294
+ applyTooltipPosition(popup, trigger, "top");
295
+ expect(popup.style.transform).toBe("translate(-50%, 0)");
296
+ expect(popup.style.top).toBe("110px"); // rect.bottom + 8
297
+ expect(popup.style.left).toBe("120px"); // hcenter, unshifted
298
+ });
299
+
300
+ it("lands inside the band under root zoom (band + zoom combined)", () => {
301
+ // chest's root-level DPI zoom AND a title-bar band together: style
302
+ // px are local (divided by zoom 2), the band and the viewport are
303
+ // visual — the flip must clear the band in VISUAL space and the
304
+ // shift must be divided back to local.
305
+ window.innerWidth = 1200;
306
+ window.innerHeight = 800;
307
+ configurePopupInsets({ top: 32 });
308
+ const original = window.getComputedStyle.bind(window);
309
+ vi.spyOn(window, "getComputedStyle").mockImplementation(((el: Element) => {
310
+ const decl = original(el);
311
+ return new Proxy(decl, {
312
+ get(target, prop, recv) {
313
+ if (prop === "zoom") return el === document.documentElement ? "2" : undefined;
314
+ const v = Reflect.get(target, prop, recv);
315
+ return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
316
+ },
317
+ });
318
+ }) as typeof window.getComputedStyle);
319
+ // Trigger top 100: frame space above is 100 - 32 - 8 = 60 < 48 + 16
320
+ // → flip below. Base-top visual box 136..184 crosses the band; the
321
+ // flipped-bottom box sits at 296..344, already inside the frame.
322
+ const trigger = box(600, 100, 40, 40);
323
+ const popup = probePopup([box(1120, 136, 240, 48), box(1120, 296, 240, 48)]);
324
+ applyTooltipPosition(popup, trigger, "top");
325
+ expect(popup.style.transform).toBe("translate(-50%, 0)");
326
+ expect(popup.style.top).toBe("74px"); // (140 + 8) visual, written /2
327
+ // Center 620 visual, written 310 local; visual box 1120..1360
328
+ // overruns the 1200-16 gutter by 176 visual → 88 local off:
329
+ // 310 - 88 = 222.
330
+ expect(popup.style.left).toBe("222px");
331
+ });
332
+
333
+ it("shifts a centered bubble inside the band's side gutter", () => {
334
+ window.innerWidth = 375;
335
+ window.innerHeight = 667;
336
+ configurePopupInsets({ left: 200 });
337
+ // A docked 200px sidebar: the centered 120px bubble spans 170..290,
338
+ // 38px into the band — the shift re-bases onto the frame origin and
339
+ // pins it at the frame's leading gutter (208).
340
+ const trigger = box(210, 300, 40, 40);
341
+ const popup = probePopup([box(170, 348, 120, 32)]);
342
+ applyTooltipPosition(popup, trigger, "bottom");
343
+ expect(popup.style.left).toBe("268px");
344
+ expect(popup.style.top).toBe("348px");
251
345
  });
252
346
  });
@@ -4,6 +4,7 @@
4
4
  // positions it through this helper so the two surfaces can never drift.
5
5
  import type { CSSProperties } from "vue";
6
6
  import { ancestorZoom } from "./cssZoom";
7
+ import { popupViewportRect, type PopupViewportRect } from "./popupBounds";
7
8
  import { clampWithGutter, viewportGutterPx } from "./viewportGutter";
8
9
 
9
10
  export type TooltipPlacement = "top" | "bottom" | "left" | "right";
@@ -70,22 +71,22 @@ export function tooltipPositionStyle(
70
71
  return style;
71
72
  }
72
73
 
73
- /** Space left on each side of the trigger rect, minus the trigger gap. */
74
+ /** Space left on the given side of the trigger rect, inside the popup
75
+ * viewport, minus the trigger gap. */
74
76
  function sideSpace(
75
77
  rect: DOMRect,
76
78
  side: TooltipPlacement,
77
- vw: number,
78
- vh: number,
79
+ frame: PopupViewportRect,
79
80
  ): number {
80
81
  switch (side) {
81
82
  case "top":
82
- return rect.top - TOOLTIP_GAP_PX;
83
+ return rect.top - frame.y - TOOLTIP_GAP_PX;
83
84
  case "bottom":
84
- return vh - rect.bottom - TOOLTIP_GAP_PX;
85
+ return frame.y + frame.height - rect.bottom - TOOLTIP_GAP_PX;
85
86
  case "left":
86
- return rect.left - TOOLTIP_GAP_PX;
87
+ return rect.left - frame.x - TOOLTIP_GAP_PX;
87
88
  case "right":
88
- return vw - rect.right - TOOLTIP_GAP_PX;
89
+ return frame.x + frame.width - rect.right - TOOLTIP_GAP_PX;
89
90
  }
90
91
  }
91
92
 
@@ -105,23 +106,26 @@ function oppositeSide(placement: TooltipPlacement): TooltipPlacement {
105
106
 
106
107
  /**
107
108
  * Flip a top↔bottom / left↔right preference when the requested side
108
- * cannot hold the measured popup inside the viewport gutter but the
109
- * opposite side can — same criterion as HkPopover's autoFlip, so the
110
- * surfaces rule their geometry identically. Returns the placement to use.
109
+ * cannot hold the measured popup inside the popup viewport's gutter but
110
+ * the opposite side can — same criterion as HkPopover's autoFlip, so the
111
+ * surfaces rule their geometry identically. The popup viewport is the
112
+ * full window minus the app-chrome band the host configured
113
+ * (popupBounds), so a tooltip anchored on the first row under a custom
114
+ * title bar flips below its trigger instead of pinning over the strip.
115
+ * Returns the placement to use.
111
116
  */
112
117
  export function resolveTooltipFlip(
113
118
  rect: DOMRect,
114
119
  placement: TooltipPlacement,
115
120
  popup: { width: number; height: number },
116
- vw: number,
117
- vh: number,
121
+ frame: PopupViewportRect,
118
122
  gutter: number,
119
123
  ): TooltipPlacement {
120
- const preferred = sideSpace(rect, placement, vw, vh);
124
+ const preferred = sideSpace(rect, placement, frame);
121
125
  const need = (placement === "top" || placement === "bottom" ? popup.height : popup.width) + gutter;
122
126
  if (preferred >= need) return placement;
123
127
  const alternate = oppositeSide(placement);
124
- const alternateSpace = sideSpace(rect, alternate, vw, vh);
128
+ const alternateSpace = sideSpace(rect, alternate, frame);
125
129
  return alternateSpace > preferred ? alternate : placement;
126
130
  }
127
131
 
@@ -156,8 +160,10 @@ export function applyTooltipPosition(
156
160
  const box = popup.getBoundingClientRect();
157
161
  if (!(box.width > 0 && box.height > 0)) return; // no layout — pure base stands
158
162
 
159
- const vw = window.innerWidth;
160
- const vh = window.innerHeight;
163
+ // The popup viewport: the window minus the app-chrome band the host
164
+ // configured (a frameless shell's title bar), full-window when none —
165
+ // in which case every number below is exactly the raw-window math.
166
+ const frame = popupViewportRect();
161
167
  const gutter = viewportGutterPx();
162
168
  const z = ancestorZoom(popup);
163
169
 
@@ -171,8 +177,11 @@ export function applyTooltipPosition(
171
177
  let capW = "";
172
178
  let capH = "";
173
179
  let clip = false;
174
- const maxW = vw - 2 * gutter;
175
- const maxH = vh - 2 * gutter;
180
+ // Floor at 0: a band so tight the gutters overrun the frame must not
181
+ // write negative (invalid) inline lengths — the cap degrades to 0 and
182
+ // collapses the bubble, the honest reading of an impossible frame.
183
+ const maxW = Math.max(0, frame.width - 2 * gutter);
184
+ const maxH = Math.max(0, frame.height - 2 * gutter);
176
185
  if (box.width > maxW) {
177
186
  capW = `${maxW / z}px`;
178
187
  }
@@ -194,7 +203,7 @@ export function applyTooltipPosition(
194
203
  measured = popup.getBoundingClientRect();
195
204
  }
196
205
 
197
- const side = resolveTooltipFlip(rect, placement, measured, vw, vh, gutter);
206
+ const side = resolveTooltipFlip(rect, placement, measured, frame, gutter);
198
207
  let final = measured;
199
208
  if (side !== placement) {
200
209
  applyBase(side);
@@ -203,14 +212,20 @@ export function applyTooltipPosition(
203
212
  }
204
213
 
205
214
  // Shift whatever is left over (centered under a near-edge trigger, the
206
- // flipped side, a cap) so both edges sit inside the gutter. The delta
207
- // is measured in root visual px but the written coordinates are local
208
- // (divided by the cumulative zoom) AND offset from the box edge by the
209
- // placement transform (translate(-50%) makes style.left the box
210
- // CENTER) — so the shift adds onto the current written value instead
211
- // of the measured edge.
212
- const dx = clampWithGutter(final.left, final.width, vw, gutter) - final.left;
213
- const dy = clampWithGutter(final.top, final.height, vh, gutter) - final.top;
215
+ // flipped side, a cap) so both edges sit inside the gutter — measured
216
+ // against the popup viewport, so the gutter rides just inside the
217
+ // app-chrome band rather than inside the raw window edge. The clamp is
218
+ // computed frame-relative and re-based onto the frame origin (with no
219
+ // band configured the origin is 0 and this is the plain gutter clamp).
220
+ // The delta is measured in root visual px but the written coordinates
221
+ // are local (divided by the cumulative zoom) AND offset from the box
222
+ // edge by the placement transform (translate(-50%) makes style.left
223
+ // the box CENTER) — so the shift adds onto the current written value
224
+ // instead of the measured edge.
225
+ const dx =
226
+ clampWithGutter(final.left - frame.x, final.width, frame.width, gutter) - (final.left - frame.x);
227
+ const dy =
228
+ clampWithGutter(final.top - frame.y, final.height, frame.height, gutter) - (final.top - frame.y);
214
229
  if (dx !== 0 || dy !== 0) {
215
230
  const curLeft = Number.parseFloat(popup.style.left) || 0;
216
231
  const curTop = Number.parseFloat(popup.style.top) || 0;