@celestia-island/hikari 0.43.4 → 0.43.6

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.4",
3
+ "version": "0.43.6",
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",
@@ -1,6 +1,7 @@
1
1
  import { defineComponent, onBeforeUnmount, ref, shallowRef, watch, type PropType } from "vue";
2
2
  import { onceFrame } from "../runtime/animationBus";
3
3
  import { useReportedTransition } from "../composables/useReportedTransition";
4
+ import { ancestorZoom } from "../runtime/cssZoom";
4
5
  import "./HkDraggableGrid.scss";
5
6
 
6
7
  const MAX_COLS = 3;
@@ -114,8 +115,14 @@ export default defineComponent({
114
115
  const node = document.createElement("div");
115
116
  node.className = "hk-draggable-grid-ghost";
116
117
  node.innerHTML = html;
117
- node.style.width = `${sourceRect.width}px`;
118
- node.style.transform = `translate3d(${sourceRect.left}px, ${sourceRect.top}px, 0)`;
118
+ // The ghost is appended to <body> — inside any root CSS zoom
119
+ // subtree — so its px are LOCAL while sourceRect (gBCR) reports the
120
+ // root VISUAL space (standardized zoom applies ancestor zoom).
121
+ // Divide at the write layer or the ghost paints zoom× too large and
122
+ // zoom× off the pointer (chest's root-level manual DPI scale).
123
+ const z = ancestorZoom(document.body);
124
+ node.style.width = `${sourceRect.width / z}px`;
125
+ node.style.transform = `translate3d(${sourceRect.left / z}px, ${sourceRect.top / z}px, 0)`;
119
126
  node.style.pointerEvents = "none";
120
127
  document.body.appendChild(node);
121
128
  ghostNode = node;
@@ -131,8 +138,12 @@ export default defineComponent({
131
138
  function moveGhost(clientX: number, clientY: number) {
132
139
  const s = drag.value;
133
140
  if (!s || !ghostNode) return;
134
- const gx = clientX - s.grabOffsetX;
135
- const gy = clientY - s.grabOffsetY;
141
+ // gx/gy are the ghost's visual top-left (client coords minus the
142
+ // visual grab offset); the teleported ghost paints local px scaled
143
+ // by the cumulative zoom — divide once at the write layer.
144
+ const z = ancestorZoom(document.body);
145
+ const gx = (clientX - s.grabOffsetX) / z;
146
+ const gy = (clientY - s.grabOffsetY) / z;
136
147
  ghostNode.style.transform = `translate3d(${gx}px, ${gy}px, 0)`;
137
148
  }
138
149
 
@@ -1,6 +1,7 @@
1
1
  import { defineComponent, onBeforeUnmount, ref, shallowRef, type PropType } from "vue";
2
2
  import { onceFrame } from "../runtime/animationBus";
3
3
  import { useReportedTransition } from "../composables/useReportedTransition";
4
+ import { ancestorZoom } from "../runtime/cssZoom";
4
5
  import "./HkDraggableList.scss";
5
6
 
6
7
  export interface DragListItem {
@@ -96,8 +97,14 @@ export default defineComponent({
96
97
  const node = document.createElement("div");
97
98
  node.className = "hk-draggable-list-ghost";
98
99
  node.innerHTML = html;
99
- node.style.width = `${sourceRect.width}px`;
100
- node.style.transform = `translate3d(${sourceRect.left}px, ${sourceRect.top}px, 0)`;
100
+ // The ghost is appended to <body> — inside any root CSS zoom
101
+ // subtree — so its px are LOCAL while sourceRect (gBCR) reports the
102
+ // root VISUAL space (standardized zoom applies ancestor zoom).
103
+ // Divide at the write layer or the ghost paints zoom× too large and
104
+ // zoom× off the pointer (chest's root-level manual DPI scale).
105
+ const z = ancestorZoom(document.body);
106
+ node.style.width = `${sourceRect.width / z}px`;
107
+ node.style.transform = `translate3d(${sourceRect.left / z}px, ${sourceRect.top / z}px, 0)`;
101
108
  node.style.pointerEvents = "none";
102
109
  document.body.appendChild(node);
103
110
  ghostNode = node;
@@ -113,8 +120,12 @@ export default defineComponent({
113
120
  function moveGhost(clientY: number) {
114
121
  const s = drag.value;
115
122
  if (!s || !ghostNode) return;
116
- const gx = s.originX - s.grabOffsetX;
117
- const gy = clientY - s.grabOffsetY;
123
+ // gx/gy are the ghost's visual top-left (client coords minus the
124
+ // visual grab offset); the teleported ghost paints local px scaled
125
+ // by the cumulative zoom — divide once at the write layer.
126
+ const z = ancestorZoom(document.body);
127
+ const gx = (s.originX - s.grabOffsetX) / z;
128
+ const gy = (clientY - s.grabOffsetY) / z;
118
129
  ghostNode.style.transform = `translate3d(${gx}px, ${gy}px, 0)`;
119
130
  }
120
131
 
@@ -13,6 +13,7 @@ import {
13
13
  import { Check, ChevronRight } from "lucide-vue-next";
14
14
 
15
15
  import { useBreakpoint } from "../runtime/useBreakpoint";
16
+ import { ancestorZoom } from "../runtime/cssZoom";
16
17
  import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
17
18
  import "./HkMenu.scss";
18
19
 
@@ -398,7 +399,11 @@ export default defineComponent({
398
399
  /** Synthetic anchor placing a sub-panel beside its parent row:
399
400
  * popout to the right, flipping to the left when the viewport runs
400
401
  * out (classic menubar cascade). The rect stays LIVE — HkSelectPanel
401
- * re-reads it on every reposition (open, resize). */
402
+ * re-reads it on every reposition (open, resize). The fixed panel
403
+ * width is a LOCAL constant while the row rect and the viewport are
404
+ * in the root VISUAL space (standardized zoom applies ancestor zoom
405
+ * to gBCR and innerWidth alike), so the width is scaled by the
406
+ * cumulative zoom before the flip comparison. */
402
407
  function rowAnchor(level: number, key: string): PanelAnchor {
403
408
  const id = `${level}:${key}`;
404
409
  let anchor = anchorCache.get(id);
@@ -409,8 +414,9 @@ export default defineComponent({
409
414
  if (!row) return pointRect(0, 0);
410
415
  const r = row.getBoundingClientRect();
411
416
  if (!r.width && !r.height) return pointRect(0, 0); // detached
412
- const openRight = r.right + CASCADE_PANEL_W <= window.innerWidth - VIEWPORT_PAD;
413
- const left = openRight ? r.right : Math.max(VIEWPORT_PAD, r.left - CASCADE_PANEL_W);
417
+ const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
418
+ const openRight = r.right + cascadeW <= window.innerWidth - VIEWPORT_PAD;
419
+ const left = openRight ? r.right : Math.max(VIEWPORT_PAD, r.left - cascadeW);
414
420
  return pointRect(left, r.top);
415
421
  },
416
422
  contains: (node) => !!rowRefs.value[id]?.contains(node),
@@ -430,12 +436,13 @@ export default defineComponent({
430
436
  if (!real) return pointRect(0, 0);
431
437
  const r = real.getBoundingClientRect();
432
438
  if (!r.width && !r.height) return pointRect(0, 0);
433
- const gap = props.offset;
434
- const openRight = r.right + gap + CASCADE_PANEL_W <= window.innerWidth - VIEWPORT_PAD;
439
+ const gap = props.offset; // visual-space gap, same as the native placements
440
+ const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
441
+ const openRight = r.right + gap + cascadeW <= window.innerWidth - VIEWPORT_PAD;
435
442
  const left =
436
443
  side === "right" && openRight
437
444
  ? r.right + gap
438
- : Math.max(VIEWPORT_PAD, r.left - CASCADE_PANEL_W - gap);
445
+ : Math.max(VIEWPORT_PAD, r.left - cascadeW - gap);
439
446
  return pointRect(left, r.top);
440
447
  },
441
448
  contains: (node) => !!props.anchorRef?.contains(node),
@@ -3,6 +3,7 @@ import { usePopupManager, type PopupEntry } from "../runtime/usePopupManager";
3
3
  import { useI18n } from "../i18n/context";
4
4
  import { useReportedTransition } from "../composables/useReportedTransition";
5
5
  import { scheduleEvery } from "../runtime/animationBus";
6
+ import { ancestorZoom } from "../runtime/cssZoom";
6
7
  import "./HkModalBreadcrumb.scss";
7
8
 
8
9
  /** Window kinds always participate in the stack: modals and drawers block
@@ -59,9 +60,24 @@ export default defineComponent({
59
60
  function resyncTop() {
60
61
  const app = document.getElementById(props.appRootId);
61
62
  if (!app) return;
63
+ // The strip teleports to <body> — inside any root CSS zoom subtree
64
+ // — so its inline top is LOCAL px. Keep every term in that local
65
+ // space: the app root's computed top is already local, while the
66
+ // header's gBCR height reports the root VISUAL space (standardized
67
+ // zoom applies ancestor zoom), so it comes back divided by the
68
+ // cumulative zoom. Without the conversion the strip drifts
69
+ // (zoom-1)·headerHeight/2 down its window (chest's root-level
70
+ // manual DPI scale). The fallback height is a layout constant and
71
+ // stays local.
62
72
  const appTop = parseFloat(getComputedStyle(app).top) || 0;
63
73
  const header = app.querySelector(props.headerSelector) as HTMLElement | null;
64
- const headerH = header ? header.getBoundingClientRect().height : props.headerFallbackHeight;
74
+ // Measure the zoom AT the header (not just the root): the header's
75
+ // gBCR carries the zoom of every ancestor between it and the root,
76
+ // so the conversion factor is read from the same chain.
77
+ const z = ancestorZoom(header ?? document.body);
78
+ const headerH = header
79
+ ? header.getBoundingClientRect().height / z
80
+ : props.headerFallbackHeight;
65
81
  topPx.value = appTop + headerH / 2;
66
82
  }
67
83
 
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Regression rig for popover anchoring under standardized CSS zoom
3
+ * (chest's root-level manual DPI scale): the anchor rect comes back in
4
+ * the root VISUAL space (zoom already applied) while the teleported
5
+ * fixed host lives inside the zoomed subtree and paints its px scaled
6
+ * back up. The host style must carry the VISUAL coords DIVIDED by the
7
+ * cumulative zoom — at a 300% root zoom the theme menu used to open
8
+ * three viewport-widths off screen and a top-anchored sheet floated a
9
+ * zoom× gap above its footer bar.
10
+ */
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
13
+
14
+ import HkPopover from "./HkPopover";
15
+
16
+ const mounts: ReturnType<typeof createApp>[] = [];
17
+ const containers: HTMLElement[] = [];
18
+
19
+ afterEach(() => {
20
+ for (const app of mounts.splice(0)) app.unmount();
21
+ for (const el of containers.splice(0)) el.remove();
22
+ document.body
23
+ .querySelectorAll(".hk-popover-panel, .hk-popover-scrim")
24
+ .forEach((el) => el.remove());
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ function rect(x: number, y: number, w: number, h: number): DOMRect {
29
+ return { x, y, width: w, height: h, top: y, left: x, right: x + w, bottom: y + h, toJSON: () => ({}) } as DOMRect;
30
+ }
31
+
32
+ /** Report `zoom` on the document element, nothing elsewhere. */
33
+ function patchRootZoom(zoom: string) {
34
+ const original = window.getComputedStyle.bind(window);
35
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
36
+ (el: Element, pseudo?: string | null): CSSStyleDeclaration => {
37
+ const decl = original(el, pseudo ?? undefined);
38
+ return new Proxy(decl, {
39
+ get(target, prop, recv) {
40
+ if (prop === "zoom") return el === document.documentElement ? zoom : undefined;
41
+ const v = Reflect.get(target, prop, recv);
42
+ return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
43
+ },
44
+ });
45
+ },
46
+ );
47
+ }
48
+
49
+ /** gBCR rig: the anchor answers with a fixed visual rect, the teleported
50
+ * popover panel with its own, everything else with an empty rect. */
51
+ function patchRects(anchor: HTMLElement, anchorRect: DOMRect, panelRect: DOMRect) {
52
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
53
+ if (this === anchor) return anchorRect;
54
+ if (this.classList.contains("hk-popover-panel")) return panelRect;
55
+ return rect(0, 0, 0, 0);
56
+ });
57
+ }
58
+
59
+ async function flushFrames() {
60
+ await nextTick();
61
+ for (let i = 0; i < 3; i++) {
62
+ await new Promise((r) => setTimeout(r, 0));
63
+ await new Promise((r) => requestAnimationFrame(() => r(null)));
64
+ }
65
+ }
66
+
67
+ function mountPopover(anchor: HTMLElement, placement: "bottom" | "top-start" = "bottom") {
68
+ const container = document.createElement("div");
69
+ document.body.appendChild(container);
70
+ containers.push(container);
71
+ anchor.textContent = "anchor";
72
+ container.appendChild(anchor);
73
+
74
+ const open = ref(false);
75
+ const Wrapper = defineComponent({
76
+ setup() {
77
+ return () =>
78
+ h(HkPopover, {
79
+ modelValue: open.value,
80
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
81
+ anchorRef: anchor,
82
+ placement,
83
+ }, { default: () => h("div", { class: "pop-content" }, "content") });
84
+ },
85
+ });
86
+ const app = createApp(Wrapper);
87
+ mounts.push(app);
88
+ app.mount(container);
89
+ return open;
90
+ }
91
+
92
+ const ANCHOR = { x: 100, y: 20, w: 80, h: 32 };
93
+ const PANEL = { x: 0, y: 0, w: 200, h: 100 };
94
+
95
+ describe("HkPopover anchoring under root CSS zoom", () => {
96
+ it("writes visual px verbatim at zoom 1 (identity)", async () => {
97
+ window.innerWidth = 1200;
98
+ window.innerHeight = 800;
99
+ patchRootZoom("1");
100
+ const anchor = document.createElement("button");
101
+ patchRects(anchor, rect(ANCHOR.x, ANCHOR.y, ANCHOR.w, ANCHOR.h), rect(PANEL.x, PANEL.y, PANEL.w, PANEL.h));
102
+ const open = mountPopover(anchor);
103
+
104
+ open.value = true;
105
+ await flushFrames();
106
+
107
+ const host = document.body.querySelector<HTMLElement>(".hk-popover-panel")!.parentElement!;
108
+ // crossPos = 100 + (80-200)/2 = 40 (clamped ≥8), top = 32+4 = 56 —
109
+ // zoom 1 divides by 1, so the legacy values survive unchanged.
110
+ expect(host.style.top).toBe("56px");
111
+ expect(host.style.left).toBe("40px");
112
+ });
113
+
114
+ it("divides the fixed coords by the cumulative root zoom", async () => {
115
+ window.innerWidth = 1200;
116
+ window.innerHeight = 800;
117
+ patchRootZoom("2");
118
+ const anchor = document.createElement("button");
119
+ patchRects(anchor, rect(ANCHOR.x, ANCHOR.y, ANCHOR.w, ANCHOR.h), rect(PANEL.x, PANEL.y, PANEL.w, PANEL.h));
120
+ const open = mountPopover(anchor);
121
+
122
+ open.value = true;
123
+ await flushFrames();
124
+
125
+ const host = document.body.querySelector<HTMLElement>(".hk-popover-panel")!.parentElement!;
126
+ // Same visual target as above, written in the host's local space so
127
+ // the paint-time zoom multiplication lands the panel back at the
128
+ // anchor: 56/2 = 28, 40/2 = 20.
129
+ expect(host.style.top).toBe("28px");
130
+ expect(host.style.left).toBe("20px");
131
+ });
132
+
133
+ it("divides a top-anchored placement's bottom offset by the zoom", async () => {
134
+ window.innerWidth = 1200;
135
+ window.innerHeight = 800;
136
+ patchRootZoom("3");
137
+ const anchor = document.createElement("button");
138
+ // Footer bar: 60px tall at the viewport bottom edge in VISUAL px.
139
+ patchRects(anchor, rect(20, 740, 120, 60), rect(0, 400, 240, 90));
140
+ const open = mountPopover(anchor, "top-start");
141
+
142
+ open.value = true;
143
+ await flushFrames();
144
+
145
+ const host = document.body.querySelector<HTMLElement>(".hk-popover-panel")!.parentElement!;
146
+ // bottom = vh - anchor.top + off = 800 - 740 + 4 = 64 (clamped ≤ 702)
147
+ // → the panel's bottom edge sits 64 visual px above the viewport
148
+ // bottom (4px gap over the bar); start align → left = anchor.left
149
+ // = 20 (inside the pad clamp). Written divided by 3 to survive the
150
+ // ×3 paint scale.
151
+ expect(parseFloat(host.style.bottom)).toBeCloseTo(64 / 3, 2);
152
+ expect(parseFloat(host.style.left)).toBeCloseTo(20 / 3, 2);
153
+ });
154
+ });
@@ -14,6 +14,7 @@ import {
14
14
 
15
15
  import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
16
16
  import { useBreakpoint } from "../runtime/useBreakpoint";
17
+ import { ancestorZoom } from "../runtime/cssZoom";
17
18
  import { useI18n } from "../i18n/context";
18
19
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
19
20
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
@@ -557,12 +558,27 @@ export default defineComponent({
557
558
  zIndex: panelZ.value,
558
559
  };
559
560
  }
561
+ // Anchored coords are measured in the ROOT VISUAL space (gBCR under
562
+ // standardized CSS zoom reports ancestor zoom already applied), but
563
+ // this fixed host lives inside the zoomed subtree, so the browser
564
+ // scales its px back up at paint. Writing visual px verbatim landed
565
+ // every popup zoom× away from its anchor (chest's root-level DPI
566
+ // scale: a 300% menu opened three viewport-widths off screen; a
567
+ // top-anchored popover floated a zoom× gap above its footer bar).
568
+ // Divide once at the write layer — the measurement, flip and clamp
569
+ // math above stays in the visual space it was computed in. The
570
+ // anchored branch is edge-anchored (0 / custom property) and needs
571
+ // none. Known limit: nothing observes a root-zoom change while the
572
+ // popover is open (resize/RO/reopen all recompute) — transient
573
+ // surfaces reopen correct, so no zoom watcher is wired.
574
+ const z = ancestorZoom(panelHostRef.value ?? document.body);
560
575
  const c = coords.value;
576
+ const localPx = (v: number) => `${v / z}px`;
561
577
  return {
562
- ...(c.top != null ? { top: `${c.top}px` } : {}),
563
- ...(c.left != null ? { left: `${c.left}px` } : {}),
564
- ...(c.bottom != null ? { bottom: `${c.bottom}px` } : {}),
565
- ...(c.right != null ? { right: `${c.right}px` } : {}),
578
+ ...(c.top != null ? { top: localPx(c.top) } : {}),
579
+ ...(c.left != null ? { left: localPx(c.left) } : {}),
580
+ ...(c.bottom != null ? { bottom: localPx(c.bottom) } : {}),
581
+ ...(c.right != null ? { right: localPx(c.right) } : {}),
566
582
  position: "fixed" as const,
567
583
  pointerEvents: "auto" as const,
568
584
  zIndex: panelZ.value,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Regression rig for the select-panel popout under standardized CSS zoom
3
+ * (chest's root-level manual DPI scale): the anchor gBCR and the clamp
4
+ * viewport live in the root VISUAL space, the teleported fixed host
5
+ * paints its local px scaled by the cumulative zoom, and the panel's
6
+ * offsetWidth stays local — positionPanel must multiply the panel size
7
+ * into visual space for the flip/clamp math and divide the written px
8
+ * back to local, or every dropdown lands zoom× away from its trigger.
9
+ */
10
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
12
+ import type { ComponentPublicInstance } from "vue";
13
+
14
+ import HkSelectPanel from "./HkSelectPanel";
15
+
16
+ const mounts: ReturnType<typeof createApp>[] = [];
17
+ const containers: HTMLElement[] = [];
18
+
19
+ afterEach(() => {
20
+ for (const app of mounts.splice(0)) app.unmount();
21
+ for (const el of containers.splice(0)) el.remove();
22
+ document.body
23
+ .querySelectorAll(".hk-select-popout, .hk-select-popout-host, .hk-select-sheet-panel, .hk-select-sheet-scrim")
24
+ .forEach((el) => el.remove());
25
+ vi.restoreAllMocks();
26
+ window.innerWidth = 1200;
27
+ });
28
+
29
+ function rect(x: number, y: number, w: number, h: number): DOMRect {
30
+ return { x, y, width: w, height: h, top: y, left: x, right: x + w, bottom: y + h, toJSON: () => ({}) } as DOMRect;
31
+ }
32
+
33
+ function patchRootZoom(zoom: string) {
34
+ const original = window.getComputedStyle.bind(window);
35
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
36
+ (el: Element, pseudo?: string | null): CSSStyleDeclaration => {
37
+ const decl = original(el, pseudo ?? undefined);
38
+ return new Proxy(decl, {
39
+ get(target, prop, recv) {
40
+ if (prop === "zoom") return el === document.documentElement ? zoom : undefined;
41
+ const v = Reflect.get(target, prop, recv);
42
+ return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
43
+ },
44
+ });
45
+ },
46
+ );
47
+ }
48
+
49
+ function mountPanel() {
50
+ const container = document.createElement("div");
51
+ document.body.appendChild(container);
52
+ containers.push(container);
53
+
54
+ const open = ref(false);
55
+ const anchorEl = ref<HTMLElement | null>(null);
56
+ const Wrapper = defineComponent({
57
+ setup() {
58
+ return () =>
59
+ h("div", [
60
+ h("button", {
61
+ ref: (el: Element | ComponentPublicInstance | null) => {
62
+ anchorEl.value = el as HTMLElement | null;
63
+ },
64
+ type: "button",
65
+ onClick: () => { open.value = !open.value; },
66
+ }, "filter"),
67
+ h(HkSelectPanel, {
68
+ open: open.value,
69
+ "onUpdate:open": (v: boolean) => { open.value = v; },
70
+ anchorRef: anchorEl.value,
71
+ title: "Filters",
72
+ placement: "top-start",
73
+ }, {
74
+ default: () => [h("label", { class: "row" }, "a"), h("label", { class: "row" }, "b")],
75
+ }),
76
+ ]);
77
+ },
78
+ });
79
+ const app = createApp(Wrapper);
80
+ mounts.push(app);
81
+ app.mount(container);
82
+ return { open, button: container.querySelector<HTMLButtonElement>("button")! };
83
+ }
84
+
85
+ async function flush() {
86
+ await nextTick();
87
+ await nextTick();
88
+ await new Promise((r) => setTimeout(r, 0));
89
+ }
90
+
91
+ describe("HkSelectPanel popout under root CSS zoom", () => {
92
+ // Anchor mid-viewport, panel size unmeasurable in happy-dom (offsetWidth
93
+ // 0) → pw falls back to max(anchorWidth,180)=180, ph to 200.
94
+ const ANCHOR = { x: 200, y: 300, w: 100, h: 40 };
95
+
96
+ it("writes visual px verbatim at zoom 1 (identity)", async () => {
97
+ window.innerWidth = 1200;
98
+ patchRootZoom("1");
99
+ const { button } = mountPanel();
100
+ vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(ANCHOR.x, ANCHOR.y, ANCHOR.w, ANCHOR.h));
101
+
102
+ button.click();
103
+ await flush();
104
+
105
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
106
+ // top = 300 - 4 - 200 = 96, left = 200 (start).
107
+ expect(host.style.top).toBe("96px");
108
+ expect(host.style.left).toBe("200px");
109
+ });
110
+
111
+ it("divides the written px by the cumulative root zoom", async () => {
112
+ window.innerWidth = 1200;
113
+ patchRootZoom("2");
114
+ const { button } = mountPanel();
115
+ vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(ANCHOR.x, ANCHOR.y, ANCHOR.w, ANCHOR.h));
116
+
117
+ button.click();
118
+ await flush();
119
+
120
+ const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
121
+ // Same visual target, written in the host's local space: 96/2, 200/2.
122
+ expect(host.style.top).toBe("48px");
123
+ expect(host.style.left).toBe("100px");
124
+ });
125
+ });
@@ -13,6 +13,7 @@ import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
13
13
  import { useOverlay } from "../runtime/useOverlay";
14
14
  import { useBreakpoint } from "../runtime/useBreakpoint";
15
15
  import { createBackGuard } from "../runtime/backStack";
16
+ import { ancestorZoom } from "../runtime/cssZoom";
16
17
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
17
18
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
18
19
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
@@ -525,9 +526,22 @@ export default defineComponent({
525
526
  const anchor = props.anchorRef;
526
527
  if (!anchor) return;
527
528
  const r = anchor.getBoundingClientRect();
528
- // 0 (unstyled/test environments) counts as unmeasured — fall back.
529
- const pw = panelRef.value?.offsetWidth || Math.max(r.width, 180);
530
- const ph = panelRef.value?.offsetHeight || 200;
529
+ // The popout host is teleported INSIDE the root zoom subtree: its
530
+ // inline fixed px are interpreted locally and scaled by the
531
+ // cumulative CSS zoom at paint, while gBCR rects (anchor r, and the
532
+ // clamp viewport below) report the ROOT VISUAL space where that
533
+ // zoom is already applied. Convert both ways at the boundaries —
534
+ // offsetWidth stays local (and transform-agnostic, unlike gBCR
535
+ // which would swallow the enter transition's scale) so panel sizes
536
+ // are multiplied into visual space for the flip/clamp math, and the
537
+ // final px are divided back to local for the write. Without the
538
+ // conversion every dropdown lands zoom× away from its trigger
539
+ // (chest's root-level manual DPI scale).
540
+ const z = ancestorZoom(popoutHostRef.value ?? document.body);
541
+ const pwRaw = panelRef.value?.offsetWidth || 0;
542
+ const phRaw = panelRef.value?.offsetHeight || 0;
543
+ const pw = pwRaw > 0 ? pwRaw * z : Math.max(r.width, 180);
544
+ const ph = phRaw > 0 ? phRaw * z : 200;
531
545
  let side: "top" | "bottom" = props.placement.startsWith("top-") ? "top" : "bottom";
532
546
  let top =
533
547
  side === "top"
@@ -566,9 +580,9 @@ export default defineComponent({
566
580
  const maxLeft = Math.max(VIEWPORT_PAD, window.innerWidth - VIEWPORT_PAD - pw);
567
581
  left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft);
568
582
  coords.value = {
569
- top: `${Math.round(top)}px`,
570
- left: `${Math.round(left)}px`,
571
- ...(props.matchAnchorWidth ? { minWidth: `${Math.round(r.width)}px` } : {}),
583
+ top: `${Math.round(top / z)}px`,
584
+ left: `${Math.round(left / z)}px`,
585
+ ...(props.matchAnchorWidth ? { minWidth: `${Math.round(r.width / z)}px` } : {}),
572
586
  };
573
587
  }
574
588
 
@@ -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,5 +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 { ancestorZoom } from "../runtime/cssZoom";
3
4
  import "./HkTooltip.scss";
4
5
 
5
6
  export default defineComponent({
@@ -33,6 +34,15 @@ export default defineComponent({
33
34
  if (!wrapperRef.value) return;
34
35
  const rect = wrapperRef.value.getBoundingClientRect();
35
36
  const gap = 8;
37
+ // The tooltip teleports to <body> — inside any root CSS zoom
38
+ // subtree — while `rect` is already in the root visual space
39
+ // (standardized zoom reports ancestor zoom applied). The browser
40
+ // scales the tooltip's local px back up at paint, so the visual
41
+ // rect values must be written divided by the cumulative zoom or
42
+ // the tooltip drifts zoom× off its trigger (chest's root-level
43
+ // manual DPI scale).
44
+ const z = ancestorZoom(document.body);
45
+ const local = (v: number) => `${v / z}px`;
36
46
  const style: CSSProperties = {};
37
47
 
38
48
  if (props.maxWidth) {
@@ -41,23 +51,23 @@ export default defineComponent({
41
51
 
42
52
  switch (props.placement) {
43
53
  case "top":
44
- style.top = `${rect.top - gap}px`;
45
- style.left = `${rect.left + rect.width / 2}px`;
54
+ style.top = local(rect.top - gap);
55
+ style.left = local(rect.left + rect.width / 2);
46
56
  style.transform = "translate(-50%, -100%)";
47
57
  break;
48
58
  case "bottom":
49
- style.top = `${rect.bottom + gap}px`;
50
- style.left = `${rect.left + rect.width / 2}px`;
59
+ style.top = local(rect.bottom + gap);
60
+ style.left = local(rect.left + rect.width / 2);
51
61
  style.transform = "translate(-50%, 0)";
52
62
  break;
53
63
  case "left":
54
- style.top = `${rect.top + rect.height / 2}px`;
55
- style.left = `${rect.left - gap}px`;
64
+ style.top = local(rect.top + rect.height / 2);
65
+ style.left = local(rect.left - gap);
56
66
  style.transform = "translate(-100%, -50%)";
57
67
  break;
58
68
  case "right":
59
- style.top = `${rect.top + rect.height / 2}px`;
60
- style.left = `${rect.right + gap}px`;
69
+ style.top = local(rect.top + rect.height / 2);
70
+ style.left = local(rect.right + gap);
61
71
  style.transform = "translate(0, -50%)";
62
72
  break;
63
73
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Contract for the cumulative-zoom helper that keeps teleported overlays
3
+ * anchored under standardized CSS zoom (chest's root-level manual DPI
4
+ * scale): gBCR reports root VISUAL space while fixed px written inside
5
+ * the zoomed subtree are local — the helper is the one conversion factor
6
+ * between the two. It must multiply every explicit zoom along the
7
+ * element → documentElement chain, ignore links without DOM zoom support
8
+ * (old engines, happy-dom) or with `normal`/garbage values, and default
9
+ * to the identity 1 everywhere else.
10
+ */
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import { ancestorZoom } from "./cssZoom";
14
+
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ /** Patch getComputedStyle so the given elements report a `zoom` value. */
20
+ function patchZoom(map: Map<Element, string>) {
21
+ const original = window.getComputedStyle.bind(window);
22
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
23
+ (el: Element, pseudo?: string | null): CSSStyleDeclaration => {
24
+ const decl = original(el, pseudo ?? undefined);
25
+ const zoom = map.get(el);
26
+ return new Proxy(decl, {
27
+ get(target, prop, recv) {
28
+ if (prop === "zoom") return zoom;
29
+ const v = Reflect.get(target, prop, recv);
30
+ return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
31
+ },
32
+ });
33
+ },
34
+ );
35
+ }
36
+
37
+ describe("ancestorZoom", () => {
38
+ it("returns 1 when no element reports a zoom", () => {
39
+ patchZoom(new Map());
40
+ const el = document.createElement("div");
41
+ document.body.appendChild(el);
42
+ expect(ancestorZoom(el)).toBe(1);
43
+ });
44
+
45
+ it("multiplies the explicit zoom values along the ancestor chain", () => {
46
+ const el = document.createElement("div");
47
+ document.body.appendChild(el);
48
+ patchZoom(
49
+ new Map([
50
+ [document.documentElement, "2"],
51
+ [document.body, "1.5"],
52
+ ]),
53
+ );
54
+ expect(ancestorZoom(el)).toBeCloseTo(3);
55
+ });
56
+
57
+ it("counts only the links between the element and the root", () => {
58
+ const outer = document.createElement("div");
59
+ const inner = document.createElement("div");
60
+ outer.appendChild(inner);
61
+ document.body.appendChild(outer);
62
+ patchZoom(new Map([[outer, "2"]]));
63
+ // inner's chain: outer(2) → body → html — the unrelated sibling-zoom
64
+ // absence below outer stays out.
65
+ expect(ancestorZoom(inner)).toBe(2);
66
+ expect(ancestorZoom(outer)).toBe(2);
67
+ });
68
+
69
+ it("ignores normal and unparseable values", () => {
70
+ const el = document.createElement("div");
71
+ document.body.appendChild(el);
72
+ patchZoom(
73
+ new Map([
74
+ [document.documentElement, "normal"],
75
+ [document.body, "zoomed"],
76
+ ]),
77
+ );
78
+ expect(ancestorZoom(el)).toBe(1);
79
+ });
80
+
81
+ it("guards non-positive zoom values", () => {
82
+ const el = document.createElement("div");
83
+ document.body.appendChild(el);
84
+ patchZoom(
85
+ new Map([
86
+ [document.documentElement, "0"],
87
+ [document.body, "-2"],
88
+ ]),
89
+ );
90
+ expect(ancestorZoom(el)).toBe(1);
91
+ });
92
+
93
+ it("returns 1 for a null element", () => {
94
+ expect(ancestorZoom(null)).toBe(1);
95
+ });
96
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Effective CSS `zoom` between a teleported overlay element and the
3
+ * viewport root.
4
+ *
5
+ * Standardized CSS zoom (Chrome 128+, Firefox 126+, Safari 18.2+) made
6
+ * `getBoundingClientRect()` report the ROOT VISUAL space — ancestor zoom
7
+ * is already multiplied into every rect — while `position: fixed` px
8
+ * assigned on an element that LIVES inside the zoomed subtree are
9
+ * interpreted in that element's own local space and scaled back up at
10
+ * paint. Overlay components that measure anchors with gBCR and position
11
+ * themselves with fixed px therefore land zoom× away from their anchor
12
+ * unless the written px are divided by the cumulative zoom (chest's
13
+ * root-level manual DPI scale is the producer of such subtrees; at a
14
+ * 300% scale an anchored menu flew three viewport-widths off screen).
15
+ *
16
+ * `zoom` is not inherited, so the factor is the product of the explicit
17
+ * zoom values along the element → documentElement chain. Links without
18
+ * DOM zoom support (old engines, happy-dom/jsdom), `normal`, garbage or
19
+ * non-positive numbers contribute nothing; the default stays 1 — the
20
+ * identity the fixed-positioning math has always assumed. No DOM at all
21
+ * (SSR guards) also collapses to 1.
22
+ */
23
+ export function ancestorZoom(el: Element | null | undefined): number {
24
+ if (typeof window === "undefined" || !el) return 1;
25
+ let zoom = 1;
26
+ let cur: Element | null = el;
27
+ while (cur) {
28
+ // `zoom` is missing from older DOM style declarations — read it off a
29
+ // widened record instead of assuming the lib carries the property.
30
+ const raw = (window.getComputedStyle(cur) as CSSStyleDeclaration & { zoom?: string | number }).zoom;
31
+ const v = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseFloat(raw) : Number.NaN;
32
+ if (Number.isFinite(v) && v > 0) zoom *= v;
33
+ cur = cur.parentElement;
34
+ }
35
+ return zoom > 0 ? zoom : 1;
36
+ }