@celestia-island/hikari 0.43.13 → 0.43.15

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.
Files changed (96) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAlert.scss +21 -21
  3. package/src/components/HkAltSignIn.scss +3 -3
  4. package/src/components/HkAuthMethodList.scss +2 -2
  5. package/src/components/HkAuthMethodList.test.tsx +24 -0
  6. package/src/components/HkAuthMethodList.tsx +8 -1
  7. package/src/components/HkAvatar.scss +1 -1
  8. package/src/components/HkBlockingToast.scss +6 -6
  9. package/src/components/HkBoard.scale.test.ts +136 -0
  10. package/src/components/HkBoard.tsx +27 -14
  11. package/src/components/HkCheckbox.scss +11 -11
  12. package/src/components/HkDatePicker.scss +12 -12
  13. package/src/components/HkDateTimePicker.scss +16 -16
  14. package/src/components/HkDivider.scss +3 -3
  15. package/src/components/HkDraggableGrid.scss +11 -11
  16. package/src/components/HkDraggableList.scss +6 -6
  17. package/src/components/HkDrawer.scss +24 -24
  18. package/src/components/HkDrawer.sheetfamily.test.ts +1 -1
  19. package/src/components/HkEmptyState.scss +6 -6
  20. package/src/components/HkFab.scss +12 -12
  21. package/src/components/HkFileField.scss +2 -2
  22. package/src/components/HkFilePickerField.scss +2 -2
  23. package/src/components/HkImagePreview.scss +8 -8
  24. package/src/components/HkImageViewer.tsx +37 -16
  25. package/src/components/HkImageViewerTouch.test.tsx +57 -0
  26. package/src/components/HkInput.scss +2 -2
  27. package/src/components/HkJsonTree.scss +2 -2
  28. package/src/components/HkKeywordSearchModal.scss +22 -22
  29. package/src/components/HkLabel.scss +3 -3
  30. package/src/components/HkListTransition.scss +23 -23
  31. package/src/components/HkLogo.scss +3 -3
  32. package/src/components/HkMarkdownRenderer.scss +9 -9
  33. package/src/components/HkMediaSlider.scss +1 -1
  34. package/src/components/HkMinimap.scale.test.ts +78 -0
  35. package/src/components/HkMinimap.scss +2 -2
  36. package/src/components/HkMinimap.tsx +16 -1
  37. package/src/components/HkModal.scss +14 -14
  38. package/src/components/HkModalBreadcrumb.scss +5 -5
  39. package/src/components/HkNavItem.scss +2 -2
  40. package/src/components/HkNumberInput.scss +3 -3
  41. package/src/components/HkPasswordInput.scss +29 -29
  42. package/src/components/HkPasswordInput.test.ts +48 -0
  43. package/src/components/HkPasswordInput.tsx +9 -1
  44. package/src/components/HkPhaseTransition.scss +5 -5
  45. package/src/components/HkPickerPane.scss +4 -4
  46. package/src/components/HkPopover.scss +2 -2
  47. package/src/components/HkProgressRing.scss +4 -4
  48. package/src/components/HkRadio.scss +4 -4
  49. package/src/components/HkRollingNumber.scss +1 -1
  50. package/src/components/HkScrollContainer.scss +5 -5
  51. package/src/components/HkScrollContainer.test.ts +48 -1
  52. package/src/components/HkScrollContainer.tsx +12 -1
  53. package/src/components/HkSearchInput.scss +3 -3
  54. package/src/components/HkSelect.scss +2 -2
  55. package/src/components/HkSelectionGrid.scss +20 -20
  56. package/src/components/HkSelectionWaterfall.scss +15 -15
  57. package/src/components/HkSidebar.scss +7 -7
  58. package/src/components/HkSkeleton.scss +8 -8
  59. package/src/components/HkSlider.scss +1 -1
  60. package/src/components/HkSplash.scss +3 -3
  61. package/src/components/HkStepFlow.scss +1 -1
  62. package/src/components/HkSwitch.scss +1 -1
  63. package/src/components/HkTable.scss +21 -21
  64. package/src/components/HkTag.scss +18 -18
  65. package/src/components/HkTextarea.scss +1 -1
  66. package/src/components/HkTimeline.scss +2 -2
  67. package/src/components/HkTitleBar.scss +2 -2
  68. package/src/components/HkToast.scss +15 -15
  69. package/src/components/HkTree.scss +16 -16
  70. package/src/components/HkWindowedItem.scale.test.ts +107 -0
  71. package/src/components/HkWindowedItem.tsx +9 -1
  72. package/src/components/window-close.scss +1 -1
  73. package/src/composables/layoutGeometry.test.ts +41 -0
  74. package/src/composables/layoutGeometry.ts +27 -0
  75. package/src/composables/useOverlayScrollbar.test.ts +33 -0
  76. package/src/composables/useOverlayScrollbar.ts +15 -1
  77. package/src/composables/useZoomPan.test.ts +90 -0
  78. package/src/composables/useZoomPan.ts +44 -19
  79. package/src/index.ts +16 -0
  80. package/src/scale.scss +76 -0
  81. package/src/styles/admin-tokens.scss +4 -37
  82. package/src/styles/index.scss +25 -11
  83. package/src/styles/theme/_glass.scss +1 -0
  84. package/src/styles/theme/_layout.scss +1 -0
  85. package/src/styles/theme/_scrollbar.scss +1 -0
  86. package/src/styles/theme/_tokens.scss +1 -0
  87. package/src/styles/theme/base.scss +1 -0
  88. package/src/styles/theme/channels.scss +23 -41
  89. package/src/styles/theme/foundation.scss +1 -0
  90. package/src/styles/theme/mixins.scss +1 -0
  91. package/src/styles/theme/scale.scss +78 -0
  92. package/src/styles/theme/themes.scss +1 -0
  93. package/src/styles/theme/variables.scss +1 -0
  94. package/src/styles/tokenInventory.test.ts +290 -0
  95. package/src/styles/vendoredSync.test.ts +91 -0
  96. package/src/tokens.scss +23 -41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.43.13",
3
+ "version": "0.43.15",
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",
@@ -3,10 +3,10 @@
3
3
  align-items: flex-start;
4
4
  gap: var(--space-12, 12px);
5
5
  padding: var(--space-12, 12px) var(--space-16, 16px);
6
- border-radius: var(--hi-radius-md, 8px);
6
+ border-radius: var(--radius-md, 8px);
7
7
  border: 1px solid transparent;
8
8
  border-inline-start-width: 4px;
9
- color: var(--hi-color-text-primary, #333);
9
+ color: var(--hi-color-text-primary, #1e1e1e);
10
10
  }
11
11
 
12
12
  .hk-alert-banner {
@@ -45,27 +45,27 @@
45
45
  }
46
46
 
47
47
  .hk-alert-warning {
48
- background: color-mix(in srgb, var(--hi-color-warning, #d29922) 8%, transparent);
49
- border-color: color-mix(in srgb, var(--hi-color-warning, #d29922) 15%, transparent);
50
- border-inline-start-color: var(--hi-color-warning, #d29922);
48
+ background: color-mix(in srgb, var(--hi-color-warning, #eed677) 8%, transparent);
49
+ border-color: color-mix(in srgb, var(--hi-color-warning, #eed677) 15%, transparent);
50
+ border-inline-start-color: var(--hi-color-warning, #eed677);
51
51
 
52
- .hk-alert-icon { color: var(--hi-color-warning, #d29922); }
52
+ .hk-alert-icon { color: var(--hi-color-warning, #eed677); }
53
53
  }
54
54
 
55
55
  .hk-alert-info {
56
- background: color-mix(in srgb, var(--hi-color-primary, #58a6ff) 8%, transparent);
57
- border-color: color-mix(in srgb, var(--hi-color-primary, #58a6ff) 15%, transparent);
58
- border-inline-start-color: var(--hi-color-primary, #58a6ff);
56
+ background: color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 8%, transparent);
57
+ border-color: color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 15%, transparent);
58
+ border-inline-start-color: var(--hi-color-primary, #7aa2f7);
59
59
 
60
- .hk-alert-icon { color: var(--hi-color-primary, #58a6ff); }
60
+ .hk-alert-icon { color: var(--hi-color-primary, #7aa2f7); }
61
61
  }
62
62
 
63
63
  .hk-alert-success {
64
- background: color-mix(in srgb, var(--hi-color-success, #3fb950) 8%, transparent);
65
- border-color: color-mix(in srgb, var(--hi-color-success, #3fb950) 15%, transparent);
66
- border-inline-start-color: var(--hi-color-success, #3fb950);
64
+ background: color-mix(in srgb, var(--hi-color-success, #0eb840) 8%, transparent);
65
+ border-color: color-mix(in srgb, var(--hi-color-success, #0eb840) 15%, transparent);
66
+ border-inline-start-color: var(--hi-color-success, #0eb840);
67
67
 
68
- .hk-alert-icon { color: var(--hi-color-success, #3fb950); }
68
+ .hk-alert-icon { color: var(--hi-color-success, #0eb840); }
69
69
  }
70
70
 
71
71
  /* ── Icon ──────────────────────────────────────── */
@@ -93,7 +93,7 @@
93
93
  .hk-alert-text {
94
94
  margin: 0;
95
95
  line-height: 1.5;
96
- color: color-mix(in srgb, var(--hi-color-text-secondary, #666) 85%, transparent);
96
+ color: color-mix(in srgb, var(--hi-color-text-secondary, #666666) 85%, transparent);
97
97
  }
98
98
 
99
99
  /* ── Close button ──────────────────────────────── */
@@ -107,19 +107,19 @@
107
107
  height: 22px;
108
108
  border: 0;
109
109
  background: transparent;
110
- border-radius: var(--hi-radius-sm, 4px);
111
- color: var(--hi-color-muted, #666);
110
+ border-radius: var(--radius-sm, 4px);
111
+ color: var(--hi-color-muted, #6c6c6c);
112
112
  cursor: pointer;
113
113
  padding: 0;
114
- transition: background var(--hi-duration-fast, 0.15s) ease, color var(--hi-duration-fast, 0.15s) ease;
114
+ transition: background var(--duration-fast, 0.15s) ease, color var(--duration-fast, 0.15s) ease;
115
115
 
116
116
  &:hover {
117
- background: var(--hi-color-surface, #fff);
118
- color: var(--hi-color-text-primary, #333);
117
+ background: var(--hi-color-surface, #f0f4f8);
118
+ color: var(--hi-color-text-primary, #1e1e1e);
119
119
  }
120
120
 
121
121
  &:focus-visible {
122
- outline: 2px solid var(--hi-color-primary, #58a6ff);
122
+ outline: 2px solid var(--hi-color-primary, #7aa2f7);
123
123
  outline-offset: 2px;
124
124
  }
125
125
  }
@@ -18,7 +18,7 @@
18
18
  background: transparent;
19
19
  cursor: pointer;
20
20
  font-size: var(--text-xs, 0.75rem);
21
- color: var(--hi-color-muted, #666);
21
+ color: var(--hi-color-muted, #6c6c6c);
22
22
  transition:
23
23
  color var(--duration-normal, 0.3s) ease,
24
24
  opacity var(--duration-normal, 0.3s) ease;
@@ -26,12 +26,12 @@
26
26
 
27
27
  .hk-alt-signin__trigger:hover:not(:disabled),
28
28
  .hk-alt-signin__trigger[aria-expanded="true"] {
29
- color: var(--hi-color-text-secondary, #999);
29
+ color: var(--hi-color-text-secondary, #666666);
30
30
  }
31
31
 
32
32
  /* Keyboard position feedback on the full-width disclosure row. */
33
33
  .hk-alt-signin__trigger:focus-visible:not(:disabled) {
34
- outline: 2px solid var(--hi-color-primary, #58a6ff);
34
+ outline: 2px solid var(--hi-color-primary, #7aa2f7);
35
35
  outline-offset: 2px;
36
36
  }
37
37
 
@@ -9,7 +9,7 @@
9
9
  align-items: center;
10
10
  gap: var(--space-10, 0.625rem);
11
11
  margin: 0 0 var(--space-4, 0.25rem);
12
- color: var(--hi-color-muted, #999);
12
+ color: var(--hi-color-muted, #6c6c6c);
13
13
  font-size: var(--text-xs, 0.75rem);
14
14
  text-align: center;
15
15
  }
@@ -19,7 +19,7 @@
19
19
  content: "";
20
20
  flex: 1;
21
21
  height: 1px;
22
- background: var(--hi-color-border, #ddd);
22
+ background: var(--hi-color-border, #646464);
23
23
  }
24
24
 
25
25
  /* The component's own wrapper is `display: contents` (measurement anchor +
@@ -89,6 +89,30 @@ describe("HkAuthMethodList", () => {
89
89
  expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("63px");
90
90
  });
91
91
 
92
+ it("publishes a width in the column's own units inside a scaled root", async () => {
93
+ // The labels are measured where they are DRAWN while the custom property
94
+ // is a CSS length the column is laid out with: in a scaled or zoomed root
95
+ // the published column would come out that many times too wide.
96
+ const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
97
+ await nextTick();
98
+ const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
99
+ const spans = [...wrapper.querySelectorAll<HTMLElement>(".s-auth-methods-label")];
100
+ Object.defineProperty(spans[0]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 58 }) });
101
+ Object.defineProperty(spans[1]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 61.4 }) });
102
+ // The list is drawn twice the size it is laid out at.
103
+ Object.defineProperty(wrapper, "getBoundingClientRect", {
104
+ configurable: true,
105
+ value: () => ({ left: 0, top: 0, right: 400, bottom: 200, width: 400, height: 200, x: 0, y: 0 }) as DOMRect,
106
+ });
107
+ Object.defineProperty(wrapper, "offsetWidth", { configurable: true, get: () => 200 });
108
+ Object.defineProperty(wrapper, "offsetHeight", { configurable: true, get: () => 100 });
109
+ const instance = (wrapper as unknown as { __vueParentComponent?: { exposed?: Record<string, unknown> } })
110
+ .__vueParentComponent?.exposed;
111
+ (instance!.measure as () => void)();
112
+ // 61.4 drawn are 30.7 of the column's own: ceil 31, plus the 1px headroom.
113
+ expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("32px");
114
+ });
115
+
92
116
  it("leaves the column fallback intact when no text metrics are available", async () => {
93
117
  const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
94
118
  await nextTick();
@@ -1,5 +1,6 @@
1
1
  import { defineComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
2
2
  import HkButton from "./HkButton";
3
+ import { drawnScale } from "../composables/layoutGeometry";
3
4
  import "./HkAuthMethodList.scss";
4
5
 
5
6
  /**
@@ -81,10 +82,16 @@ export default defineComponent({
81
82
  const root = listRef.value;
82
83
  if (!root) return;
83
84
  let widest = 0;
85
+ // The labels are measured where they are DRAWN, and the width is
86
+ // published as a CSS length the column is laid out with: in a scaled or
87
+ // zoomed root the two differ by the scale of the space, so the column
88
+ // would come out that many times too wide (see `layoutGeometry`).
89
+ const space = drawnScale(root);
90
+ const factor = root.offsetWidth > 0 ? space.x : root.offsetHeight > 0 ? space.y : 1;
84
91
  for (const label of root.querySelectorAll<HTMLElement>(".s-auth-methods-label")) {
85
92
  const previous = label.style.width;
86
93
  label.style.width = "max-content";
87
- widest = Math.max(widest, label.getBoundingClientRect().width);
94
+ widest = Math.max(widest, label.getBoundingClientRect().width / (factor > 0 ? factor : 1));
88
95
  label.style.width = previous;
89
96
  }
90
97
  // Guard: only publish when real text metrics were observed, so a
@@ -50,5 +50,5 @@
50
50
 
51
51
  [data-theme="dark"] .hk-avatar {
52
52
  background-color: var(--hi-surface);
53
- color: var(--hi-color-text-secondary, rgba(255, 255, 255, 0.6));
53
+ color: var(--hi-color-text-secondary, rgba(102,102,102,0.6));
54
54
  }
@@ -32,7 +32,7 @@
32
32
  align-items: flex-start;
33
33
  gap: var(--space-8, 8px);
34
34
  padding: var(--space-12, 12px) var(--space-16, 16px);
35
- border-radius: var(--hi-radius-sm, 4px);
35
+ border-radius: var(--radius-sm, 4px);
36
36
  font-size: var(--text-base, 0.875rem);
37
37
  line-height: 1.4;
38
38
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
@@ -77,12 +77,12 @@
77
77
  // Variant backgrounds follow the HkToast conventions (including the
78
78
  // dark-on-amber warning text for contrast).
79
79
  .hk-blocking-toast-info {
80
- background: var(--hi-color-info, #58a6ff);
80
+ background: var(--hi-color-info, #6abed6);
81
81
  color: #fff;
82
82
  }
83
83
 
84
84
  .hk-blocking-toast-warning {
85
- background: var(--hi-color-warning, #d29922);
85
+ background: var(--hi-color-warning, #eed677);
86
86
  color: #1c1c1e;
87
87
  }
88
88
 
@@ -95,13 +95,13 @@
95
95
  // cards go absolute so the remaining ones glide up (hk-toast pattern).
96
96
  .hk-blocking-toast-enter-active {
97
97
  transition-property: background-color, border-color, color, box-shadow, opacity, transform;
98
- transition-duration: var(--hi-duration-normal, 0.3s);
98
+ transition-duration: var(--duration-normal, 0.3s);
99
99
  transition-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
100
100
  }
101
101
 
102
102
  .hk-blocking-toast-leave-active {
103
103
  transition-property: background-color, border-color, color, box-shadow, opacity, transform;
104
- transition-duration: var(--hi-duration-normal, 0.3s);
104
+ transition-duration: var(--duration-normal, 0.3s);
105
105
  transition-timing-function: cubic-bezier(0.36, 0, 0.66, 1);
106
106
  /* Only the positioning switch lives here; the leaving slot's box is
107
107
  frozen by the before-leave hook (pinLeaveGeometry), same contract
@@ -126,7 +126,7 @@
126
126
  }
127
127
 
128
128
  .hk-blocking-toast-move {
129
- transition: transform var(--hi-duration-normal, 0.3s) ease;
129
+ transition: transform var(--duration-normal, 0.3s) ease;
130
130
  }
131
131
 
132
132
  // Reduced motion: drop the slide/FLIP choreography, cards snap in and
@@ -0,0 +1,136 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick } from "vue";
3
+
4
+ import HkBoard from "./HkBoard";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+ const restores: Array<() => void> = [];
8
+
9
+ afterEach(() => {
10
+ for (const restore of restores.splice(0)) restore();
11
+ for (const m of mounts.splice(0)) {
12
+ m.app.unmount();
13
+ m.container.remove();
14
+ }
15
+ });
16
+
17
+ /** The board's camera lives in the board's OWN pixels: the world transform is
18
+ * written in them. */
19
+ function worldTransform(container: HTMLElement): { x: number; y: number; k: number } {
20
+ const world = container.querySelector<HTMLElement>(".hk-board-world")!;
21
+ const m = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)\s*scale\(([\d.]+)\)/.exec(
22
+ world.style.transform,
23
+ );
24
+ if (!m) throw new Error(`unexpected world transform: ${world.style.transform}`);
25
+ return { x: parseFloat(m[1]), y: parseFloat(m[2]), k: parseFloat(m[3]) };
26
+ }
27
+
28
+ function pointer(type: string, id: number, x: number, y: number): PointerEvent {
29
+ return new PointerEvent(type, { bubbles: true, pointerId: id, pointerType: "touch", clientX: x, clientY: y });
30
+ }
31
+
32
+ async function mountBoard() {
33
+ // The board reads its viewport size at mount (clientWidth/Height, i.e. the
34
+ // layout box): pin it on the prototype for the duration so the camera's
35
+ // clamp has a real viewport to work with, the way a laid-out page would.
36
+ const proto = HTMLElement.prototype as unknown as {
37
+ clientWidth: number;
38
+ clientHeight: number;
39
+ };
40
+ const beforeW = Object.getOwnPropertyDescriptor(proto, "clientWidth");
41
+ const beforeH = Object.getOwnPropertyDescriptor(proto, "clientHeight");
42
+ Object.defineProperty(proto, "clientWidth", {
43
+ configurable: true,
44
+ get(this: HTMLElement) {
45
+ return this.classList?.contains("hk-board") ? 800 : 0;
46
+ },
47
+ });
48
+ Object.defineProperty(proto, "clientHeight", {
49
+ configurable: true,
50
+ get(this: HTMLElement) {
51
+ return this.classList?.contains("hk-board") ? 600 : 0;
52
+ },
53
+ });
54
+ restores.push(() => {
55
+ if (beforeW) Object.defineProperty(proto, "clientWidth", beforeW);
56
+ else delete (proto as { clientWidth?: number }).clientWidth;
57
+ if (beforeH) Object.defineProperty(proto, "clientHeight", beforeH);
58
+ else delete (proto as { clientHeight?: number }).clientHeight;
59
+ });
60
+ const container = document.createElement("div");
61
+ document.body.appendChild(container);
62
+ const app = createApp(
63
+ defineComponent({
64
+ setup: () => () =>
65
+ h(HkBoard, {
66
+ // Content far larger than the viewport, so the camera's clamp lets
67
+ // it pan instead of re-centring a board that already fits.
68
+ nodes: [
69
+ { id: "a", x: 0, y: 0, w: 200, h: 120, label: "A" },
70
+ { id: "b", x: 3000, y: 3000, w: 200, h: 120, label: "B" },
71
+ ],
72
+ fitOnMount: false,
73
+ // No tween: the assertions read the camera right after the event.
74
+ animated: false,
75
+ grid: false,
76
+ minimap: false,
77
+ }),
78
+ }),
79
+ );
80
+ app.mount(container);
81
+ mounts.push({ app, container });
82
+ await nextTick();
83
+ const viewport = container.querySelector<HTMLElement>(".hk-board")!;
84
+ // The board is drawn twice the size it is laid out at, and its own box
85
+ // reports the layout size — the shape a scaled or zoomed root produces.
86
+ Object.defineProperty(viewport, "getBoundingClientRect", {
87
+ configurable: true,
88
+ value: () => ({ left: 0, top: 0, right: 1600, bottom: 1200, width: 1600, height: 1200, x: 0, y: 0 }) as DOMRect,
89
+ });
90
+ Object.defineProperty(viewport, "offsetWidth", { configurable: true, get: () => 800 });
91
+ Object.defineProperty(viewport, "offsetHeight", { configurable: true, get: () => 600 });
92
+ Object.defineProperty(viewport, "clientWidth", { configurable: true, get: () => 800 });
93
+ Object.defineProperty(viewport, "clientHeight", { configurable: true, get: () => 600 });
94
+ // happy-dom has no pointer capture: the board captures every finger, and a
95
+ // throw there would end the gesture before it starts.
96
+ Object.defineProperty(viewport, "setPointerCapture", { configurable: true, value: () => {} });
97
+ Object.defineProperty(viewport, "releasePointerCapture", { configurable: true, value: () => {} });
98
+ Object.defineProperty(viewport, "hasPointerCapture", { configurable: true, value: () => true });
99
+ return { container, viewport };
100
+ }
101
+
102
+ describe("HkBoard scaled-space gestures", () => {
103
+ it("pans by the finger's own distance inside a scaled root", async () => {
104
+ const { container, viewport } = await mountBoard();
105
+ const before = worldTransform(container);
106
+
107
+ viewport.dispatchEvent(pointer("pointerdown", 1, 1600, 1600));
108
+ viewport.dispatchEvent(pointer("pointermove", 1, 1520, 1540));
109
+ await nextTick();
110
+
111
+ const after = worldTransform(container);
112
+ expect(after.x, "-80 drawn px are -40 of the board's own").toBeCloseTo(before.x - 40, 4);
113
+ expect(after.y, "-60 drawn px are -30 of the board's own").toBeCloseTo(before.y - 30, 4);
114
+ viewport.dispatchEvent(pointer("pointerup", 1, 1520, 1540));
115
+ });
116
+
117
+ it("keeps a zoom anchored under the pointer inside a scaled root", async () => {
118
+ const { container, viewport } = await mountBoard();
119
+ const wheel = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1 });
120
+ Object.defineProperty(wheel, "clientX", { value: 800 });
121
+ Object.defineProperty(wheel, "clientY", { value: 600 });
122
+ // The pointer sits at the board's own (400, 300) — half the layout box.
123
+ const anchor = { x: 400, y: 300 };
124
+ const before = worldTransform(container);
125
+ const world = {
126
+ x: (anchor.x - before.x) / before.k,
127
+ y: (anchor.y - before.y) / before.k,
128
+ };
129
+ viewport.dispatchEvent(wheel);
130
+ await nextTick();
131
+ const after = worldTransform(container);
132
+ expect(after.k).toBeGreaterThan(before.k);
133
+ expect(after.x + world.x * after.k, "the world point stays under the pointer").toBeCloseTo(anchor.x, 3);
134
+ expect(after.y + world.y * after.k).toBeCloseTo(anchor.y, 3);
135
+ });
136
+ });
@@ -59,6 +59,7 @@ import {
59
59
  type BoardEdgeStyle,
60
60
  } from "../utils/boardEdges";
61
61
  import "./HkBoard.scss";
62
+ import { drawnScale } from "../composables/layoutGeometry";
62
63
 
63
64
  /**
64
65
  * A node on the board. `hidden` nodes are junction-only (invisible).
@@ -211,9 +212,23 @@ export default defineComponent({
211
212
  // — SCADA scenes, mind maps — still get `nodeClick` on a short tap.
212
213
  let pressed: { id: string; pointerId: number; x: number; y: number } | null = null;
213
214
 
215
+ /** How many of the board's own pixels one DRAWN pixel is worth: the
216
+ * camera and every anchor live in the board's local CSS pixels while the
217
+ * pointer arrives in drawn ones, and a scaled or zoomed root separates
218
+ * the two (see `layoutGeometry`). */
219
+ const pxScale = (): number => {
220
+ const el = viewportRef.value;
221
+ if (!el) return 1;
222
+ const scale = drawnScale(el);
223
+ const factor = el.offsetWidth > 0 ? scale.x : el.offsetHeight > 0 ? scale.y : 1;
224
+ return factor > 0 ? factor : 1;
225
+ };
226
+
214
227
  const localPoint = (e: PointerEvent | WheelEvent): BoardPoint => {
215
- const rect = viewportRef.value?.getBoundingClientRect();
216
- return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) };
228
+ const el = viewportRef.value;
229
+ const rect = el?.getBoundingClientRect();
230
+ const k = pxScale();
231
+ return { x: (e.clientX - (rect?.left ?? 0)) / k, y: (e.clientY - (rect?.top ?? 0)) / k };
217
232
  };
218
233
 
219
234
  function onWheel(e: WheelEvent): void {
@@ -237,14 +252,10 @@ export default defineComponent({
237
252
  function rearmPinch(): void {
238
253
  const [a, b] = [...pointers.values()];
239
254
  if (!a || !b) return;
240
- const rect = viewportRef.value?.getBoundingClientRect();
241
255
  pinchBase = {
242
256
  cam: { ...camera.value },
243
257
  dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
244
- mid: {
245
- x: (a.x + b.x) / 2 - (rect?.left ?? 0),
246
- y: (a.y + b.y) / 2 - (rect?.top ?? 0),
247
- },
258
+ mid: localPoint({ clientX: (a.x + b.x) / 2, clientY: (a.y + b.y) / 2 } as PointerEvent),
248
259
  };
249
260
  }
250
261
 
@@ -273,17 +284,18 @@ export default defineComponent({
273
284
  if (pinchBase && pointers.size >= 2) {
274
285
  const [a, b] = [...pointers.values()];
275
286
  const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
276
- const rect = viewportRef.value?.getBoundingClientRect();
277
- const mid = {
278
- x: (a.x + b.x) / 2 - (rect?.left ?? 0),
279
- y: (a.y + b.y) / 2 - (rect?.top ?? 0),
280
- };
287
+ const mid = localPoint({
288
+ clientX: (a.x + b.x) / 2,
289
+ clientY: (a.y + b.y) / 2,
290
+ } as PointerEvent);
281
291
  pinchBase.mid = mid;
282
292
  cam.pinchZoom(pinchBase.cam, dist / pinchBase.dist, mid);
283
293
  return;
284
294
  }
285
295
  if (dragState) {
286
- const k = camera.value.k || 1;
296
+ // The node lives in WORLD units: the drawn delta comes down through
297
+ // the ancestor scale and then through the camera's own zoom.
298
+ const k = (camera.value.k || 1) * pxScale();
287
299
  const dx = (e.clientX - dragState.sx) / k;
288
300
  const dy = (e.clientY - dragState.sy) / k;
289
301
  if (Math.abs(e.clientX - dragState.sx) + Math.abs(e.clientY - dragState.sy) > 3) dragState.moved = true;
@@ -293,7 +305,8 @@ export default defineComponent({
293
305
  return;
294
306
  }
295
307
  if (panState) {
296
- cam.panBy(e.clientX - panState.px, e.clientY - panState.py);
308
+ const k = pxScale();
309
+ cam.panBy((e.clientX - panState.px) / k, (e.clientY - panState.py) / k);
297
310
  panState = { px: e.clientX, py: e.clientY };
298
311
  }
299
312
  }
@@ -23,12 +23,12 @@
23
23
  justify-content: center;
24
24
  width: 1.125rem;
25
25
  height: 1.125rem;
26
- border-radius: var(--hi-radius-sm, 4px);
27
- border: 1px solid var(--hi-color-border, rgba(0, 0, 0, 0.08));
28
- background: var(--hi-color-surface, rgba(255, 255, 255, 0.95));
26
+ border-radius: var(--radius-sm, 4px);
27
+ border: 1px solid var(--hi-color-border, rgba(100,100,100,0.08));
28
+ background: var(--hi-color-surface, rgba(240,244,248,0.95));
29
29
  transition:
30
- border-color var(--hi-duration-normal, 0.3s) ease,
31
- background var(--hi-duration-normal, 0.3s) ease;
30
+ border-color var(--duration-normal, 0.3s) ease,
31
+ background var(--duration-normal, 0.3s) ease;
32
32
  flex-shrink: 0;
33
33
  position: relative;
34
34
  }
@@ -38,13 +38,13 @@
38
38
  }
39
39
 
40
40
  .hk-checkbox-box[data-checked] {
41
- background: var(--hi-color-primary, #ff6b9d);
42
- border-color: var(--hi-color-primary, #ff6b9d);
41
+ background: var(--hi-color-primary, #7aa2f7);
42
+ border-color: var(--hi-color-primary, #7aa2f7);
43
43
  }
44
44
 
45
45
  .hk-checkbox-box[data-indeterminate] {
46
- background: var(--hi-color-primary, #ff6b9d);
47
- border-color: var(--hi-color-primary, #ff6b9d);
46
+ background: var(--hi-color-primary, #7aa2f7);
47
+ border-color: var(--hi-color-primary, #7aa2f7);
48
48
  }
49
49
 
50
50
  /* ---------- input ---------- */
@@ -62,8 +62,8 @@
62
62
  :focus-visible keeps mouse toggles ring-free. */
63
63
  .hk-checkbox-box:has(.hk-checkbox-input:focus-visible) {
64
64
  box-shadow:
65
- 0 0 0 2px var(--hi-color-surface, rgba(255, 255, 255, 0.95)),
66
- 0 0 0 4px var(--hi-color-primary, #ff6b9d);
65
+ 0 0 0 2px var(--hi-color-surface, rgba(240,244,248,0.95)),
66
+ 0 0 0 4px var(--hi-color-primary, #7aa2f7);
67
67
  }
68
68
 
69
69
  /* ---------- markers ---------- */
@@ -23,15 +23,15 @@
23
23
  padding: var(--space-8, 8px) var(--space-12, 12px);
24
24
  background: color-mix(in srgb, rgb(var(--color-surface)) 55%, transparent);
25
25
  border: 1px solid color-mix(in srgb, rgb(var(--color-text)) 14%, transparent);
26
- border-radius: var(--hi-radius-sm, 4px);
26
+ border-radius: var(--radius-sm, 4px);
27
27
  color: rgb(var(--color-text));
28
28
  font-family: inherit;
29
29
  font-size: var(--text-sm, 0.875rem);
30
30
  line-height: 1.5;
31
31
  transition:
32
- background-color var(--hi-duration-fast, 0.15s) ease,
33
- border-color var(--hi-duration-fast, 0.15s) ease,
34
- box-shadow var(--hi-duration-fast, 0.15s) ease;
32
+ background-color var(--duration-fast, 0.15s) ease,
33
+ border-color var(--duration-fast, 0.15s) ease,
34
+ box-shadow var(--duration-fast, 0.15s) ease;
35
35
  }
36
36
 
37
37
  .hk-dp-trigger {
@@ -102,11 +102,11 @@
102
102
  padding: 0;
103
103
  border: 0;
104
104
  background: transparent;
105
- border-radius: var(--hi-radius-sm, 4px);
105
+ border-radius: var(--radius-sm, 4px);
106
106
  color: var(--hi-color-muted);
107
107
  cursor: pointer;
108
108
  opacity: 0;
109
- transition: opacity var(--hi-duration-fast, 0.15s) ease, color var(--hi-duration-fast, 0.15s) ease;
109
+ transition: opacity var(--duration-fast, 0.15s) ease, color var(--duration-fast, 0.15s) ease;
110
110
 
111
111
  .hk-dp-trigger:hover &,
112
112
  .hk-dp-trigger:focus &,
@@ -183,13 +183,13 @@
183
183
  border: 0;
184
184
  width: 1.75rem;
185
185
  height: 1.75rem;
186
- border-radius: var(--hi-radius-sm, 4px);
186
+ border-radius: var(--radius-sm, 4px);
187
187
  color: var(--hi-color-muted);
188
188
  cursor: pointer;
189
189
  display: inline-flex;
190
190
  align-items: center;
191
191
  justify-content: center;
192
- transition: background var(--hi-duration-fast, 0.15s) ease, color var(--hi-duration-fast, 0.15s) ease;
192
+ transition: background var(--duration-fast, 0.15s) ease, color var(--duration-fast, 0.15s) ease;
193
193
 
194
194
  &:hover:not(:disabled) {
195
195
  background: var(--hi-color-surface);
@@ -232,8 +232,8 @@
232
232
  color: inherit;
233
233
  cursor: pointer;
234
234
  white-space: nowrap;
235
- border-radius: var(--hi-radius-sm, 4px);
236
- transition: background var(--hi-duration-fast, 0.15s) ease, color var(--hi-duration-fast, 0.15s) ease;
235
+ border-radius: var(--radius-sm, 4px);
236
+ transition: background var(--duration-fast, 0.15s) ease, color var(--duration-fast, 0.15s) ease;
237
237
 
238
238
  &:hover {
239
239
  background: var(--hi-color-surface);
@@ -279,7 +279,7 @@
279
279
  appearance: none;
280
280
  background: transparent;
281
281
  border: 0;
282
- border-radius: var(--hi-radius-sm, 4px);
282
+ border-radius: var(--radius-sm, 4px);
283
283
  /* Fixed height keeps the six-row day grid — and therefore the shared
284
284
  * stage height — independent of the panel width, so drilling between
285
285
  * views never changes the popup's size (enlarged on touch). */
@@ -291,7 +291,7 @@
291
291
  cursor: pointer;
292
292
  font-size: var(--text-xs, 0.75rem);
293
293
  font-variant-numeric: tabular-nums;
294
- transition: background var(--hi-duration-fast, 0.15s) ease, color var(--hi-duration-fast, 0.15s) ease;
294
+ transition: background var(--duration-fast, 0.15s) ease, color var(--duration-fast, 0.15s) ease;
295
295
 
296
296
  &:hover:not(:disabled):not(.is-selected) {
297
297
  background: var(--hi-color-surface);