@celestia-island/hikari 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
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,259 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import { HkAuthCard } from "./HkAuthCard";
5
+
6
+ /** Injectable ResizeObserver: captures the callback so tests can fire
7
+ * content changes deterministically (happy-dom's own RO never fires —
8
+ * there is no layout engine). Same harness as useSizeMorph.test.ts. */
9
+ class FakeResizeObserver {
10
+ static instances: FakeResizeObserver[] = [];
11
+ callback: () => void;
12
+ observed: Element[] = [];
13
+ disconnected = false;
14
+
15
+ constructor(callback: () => void) {
16
+ this.callback = callback;
17
+ FakeResizeObserver.instances.push(this);
18
+ }
19
+ observe(el: Element): void {
20
+ this.observed.push(el);
21
+ }
22
+ disconnect(): void {
23
+ this.disconnected = true;
24
+ }
25
+ }
26
+
27
+ const originalRO = globalThis.ResizeObserver;
28
+
29
+ /** The prototype that actually defines `offsetHeight` in this DOM
30
+ * implementation, so the stub below is portable across environments. */
31
+ function offsetHeightOwner(): object {
32
+ let proto = Object.getPrototypeOf(document.createElement("div")) as object | null;
33
+ while (proto) {
34
+ if (Object.getOwnPropertyDescriptor(proto, "offsetHeight")) return proto;
35
+ proto = Object.getPrototypeOf(proto);
36
+ }
37
+ return HTMLElement.prototype;
38
+ }
39
+
40
+ const originalOffsetHeight = Object.getOwnPropertyDescriptor(
41
+ offsetHeightOwner(),
42
+ "offsetHeight",
43
+ );
44
+
45
+ /** Natural height every element reports for `offsetHeight` in tests. */
46
+ let naturalHeight = 0;
47
+
48
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
49
+
50
+ function mount(node: ReturnType<typeof h>) {
51
+ const container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ const app = createApp({ render: () => node });
54
+ app.mount(container);
55
+ mounts.push({ app, container });
56
+ return container;
57
+ }
58
+
59
+ function cardOf(c: HTMLElement) {
60
+ return c.querySelector<HTMLElement>(".s-auth-card")!;
61
+ }
62
+
63
+ function wrapperOf(c: HTMLElement) {
64
+ return c.querySelector<HTMLElement>(".s-auth-card-height")!;
65
+ }
66
+
67
+ function fireTransitionEnd(el: HTMLElement, propertyName: string) {
68
+ const event = new Event("transitionend", { bubbles: true });
69
+ Object.defineProperty(event, "propertyName", { value: propertyName });
70
+ el.dispatchEvent(event);
71
+ }
72
+
73
+ beforeEach(() => {
74
+ FakeResizeObserver.instances = [];
75
+ naturalHeight = 140;
76
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
77
+ Object.defineProperty(offsetHeightOwner(), "offsetHeight", {
78
+ configurable: true,
79
+ get: () => naturalHeight,
80
+ });
81
+ });
82
+
83
+ afterEach(() => {
84
+ globalThis.ResizeObserver = originalRO;
85
+ if (originalOffsetHeight) {
86
+ Object.defineProperty(offsetHeightOwner(), "offsetHeight", originalOffsetHeight);
87
+ } else {
88
+ delete (offsetHeightOwner() as { offsetHeight?: number }).offsetHeight;
89
+ }
90
+ vi.restoreAllMocks();
91
+ vi.useRealTimers();
92
+ for (const { app, container } of mounts.splice(0)) {
93
+ app.unmount();
94
+ container.remove();
95
+ }
96
+ });
97
+
98
+ describe("HkAuthCard", () => {
99
+ it("renders title, subtitle and every slot inside the unchanged card shell", () => {
100
+ const c = mount(
101
+ h(HkAuthCard, { title: "Sign in", subtitle: "Welcome back" }, {
102
+ default: () => h("input", { name: "probe-field" }),
103
+ methods: () => h("div", { class: "probe-methods" }, "methods"),
104
+ footer: () => h("div", { class: "probe-footer" }, "footer"),
105
+ }),
106
+ );
107
+ // The measuring wrapper is present and the card is still `.s-auth-card`.
108
+ const wrapper = wrapperOf(c);
109
+ const card = cardOf(c);
110
+ expect(wrapper).toBeTruthy();
111
+ expect(card).toBeTruthy();
112
+ expect(c.querySelector(".s-auth-title")?.textContent).toBe("Sign in");
113
+ expect(c.querySelector(".s-auth-subtitle")?.textContent).toBe("Welcome back");
114
+ expect(card.querySelector(".s-auth-form input[name='probe-field']")).toBeTruthy();
115
+ expect(card.querySelector(".s-auth-methods .probe-methods")).toBeTruthy();
116
+ expect(card.querySelector(".s-auth-footer .probe-footer")).toBeTruthy();
117
+ // The wrapper is transparent to descendant styling: the header, form,
118
+ // methods and footer blocks all remain children of `.s-auth-card`.
119
+ expect(wrapper.querySelector(".s-auth-card")).toBe(card);
120
+ });
121
+
122
+ it("seeds the wrapper height from the card on mount without animating", () => {
123
+ naturalHeight = 140;
124
+ const c = mount(h(HkAuthCard, { title: "T" }));
125
+ const wrapper = wrapperOf(c);
126
+ expect(wrapper.style.height).toBe("140px");
127
+ // Seeding must not animate: the measuring class is absent on first paint.
128
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
129
+ // The observer watches the card element itself, not the wrapper.
130
+ expect(FakeResizeObserver.instances).toHaveLength(1);
131
+ expect(FakeResizeObserver.instances[0]!.observed).toEqual([cardOf(c)]);
132
+ });
133
+
134
+ it("animates growth through the measuring class and releases it on the backstop timer", () => {
135
+ vi.useFakeTimers();
136
+ naturalHeight = 140;
137
+ const c = mount(h(HkAuthCard, { title: "T" }));
138
+ const wrapper = wrapperOf(c);
139
+ expect(wrapper.style.height).toBe("140px");
140
+
141
+ naturalHeight = 240;
142
+ FakeResizeObserver.instances[0]!.callback();
143
+ expect(wrapper.style.height).toBe("240px");
144
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
145
+
146
+ vi.advanceTimersByTime(449);
147
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
148
+ vi.advanceTimersByTime(1);
149
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
150
+ });
151
+
152
+ it("releases the measuring class when the height transition finishes", () => {
153
+ naturalHeight = 100;
154
+ const c = mount(h(HkAuthCard, { title: "T" }));
155
+ const wrapper = wrapperOf(c);
156
+
157
+ naturalHeight = 180;
158
+ FakeResizeObserver.instances[0]!.callback();
159
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
160
+
161
+ // An unrelated property finishing its transition must not release.
162
+ fireTransitionEnd(wrapper, "color");
163
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
164
+
165
+ fireTransitionEnd(wrapper, "height");
166
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
167
+ });
168
+
169
+ it("ignores height transitionsend events bubbling up from descendant content", () => {
170
+ naturalHeight = 100;
171
+ const c = mount(
172
+ h(HkAuthCard, { title: "T" }, { default: () => h("div", { class: "probe-anim" }) }),
173
+ );
174
+ const wrapper = wrapperOf(c);
175
+ const child = c.querySelector<HTMLElement>(".probe-anim")!;
176
+
177
+ naturalHeight = 180;
178
+ FakeResizeObserver.instances[0]!.callback();
179
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
180
+
181
+ // A descendant slot element's own height transition bubbling up must
182
+ // NOT release the clip — it did not open this measuring window.
183
+ fireTransitionEnd(child, "height");
184
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
185
+
186
+ // Only the wrapper's own height transition may end it.
187
+ fireTransitionEnd(wrapper, "height");
188
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
189
+ });
190
+
191
+ it("snaps unclipped under prefers-reduced-motion without arming the backstop", () => {
192
+ vi.useFakeTimers();
193
+ vi.spyOn(window, "matchMedia").mockReturnValue({ matches: true } as unknown as MediaQueryList);
194
+ naturalHeight = 140;
195
+ const c = mount(h(HkAuthCard, { title: "T" }));
196
+ const wrapper = wrapperOf(c);
197
+
198
+ naturalHeight = 240;
199
+ FakeResizeObserver.instances[0]!.callback();
200
+ // Height still tracks the content…
201
+ expect(wrapper.style.height).toBe("240px");
202
+ // …but with the transition disabled there is nothing to animate, so
203
+ // no measuring window opens: no clip, no pending backstop timer.
204
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
205
+ expect(vi.getTimerCount()).toBe(0);
206
+ });
207
+
208
+ it("ignores observer firings that move the height by less than a pixel", () => {
209
+ naturalHeight = 140;
210
+ const c = mount(h(HkAuthCard, { title: "T" }));
211
+ const wrapper = wrapperOf(c);
212
+
213
+ naturalHeight = 140.6;
214
+ FakeResizeObserver.instances[0]!.callback();
215
+ expect(wrapper.style.height).toBe("140px");
216
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
217
+ });
218
+
219
+ it("with smoothHeight=false renders the wrapper but installs no measuring", () => {
220
+ naturalHeight = 140;
221
+ const c = mount(h(HkAuthCard, { title: "T", smoothHeight: false }));
222
+ const wrapper = wrapperOf(c);
223
+ // The wrapper still exists (stable DOM shape for consumers) but is
224
+ // completely unmanaged: no explicit height and no observer.
225
+ expect(wrapper).toBeTruthy();
226
+ expect(wrapper.style.height).toBe("");
227
+ expect(FakeResizeObserver.instances).toHaveLength(0);
228
+ });
229
+
230
+ it("degrades to an unmanaged wrapper when ResizeObserver is unavailable", () => {
231
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = undefined;
232
+ naturalHeight = 140;
233
+ const c = mount(h(HkAuthCard, { title: "T" }));
234
+ const wrapper = wrapperOf(c);
235
+ expect(wrapper).toBeTruthy();
236
+ expect(wrapper.style.height).toBe("");
237
+ expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
238
+ });
239
+
240
+ it("disconnects the observer and clears the backstop on unmount", () => {
241
+ vi.useFakeTimers();
242
+ naturalHeight = 140;
243
+ const c = mount(h(HkAuthCard, { title: "T" }));
244
+ const ro = FakeResizeObserver.instances[0]!;
245
+
246
+ naturalHeight = 200;
247
+ ro.callback();
248
+ expect(wrapperOf(c).classList.contains("s-auth-card--measuring")).toBe(true);
249
+
250
+ // Pop the entry so the shared afterEach does not unmount it twice.
251
+ const entry = mounts.pop()!;
252
+ entry.app.unmount();
253
+ expect(ro.disconnected).toBe(true);
254
+ // The pending backstop timer was cleared on unmount: nothing is left
255
+ // scheduled (a leaked timer here would also fire releaseClip on a
256
+ // dead element).
257
+ expect(vi.getTimerCount()).toBe(0);
258
+ });
259
+ });
@@ -1,4 +1,10 @@
1
- import { defineComponent, type PropType } from "vue";
1
+ import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
2
+
3
+ /** Grace window that force-releases the `--measuring` clip if the height
4
+ * `transitionend` never arrives (interrupted transition, backgrounded
5
+ * tab, reduced-motion). Slightly longer than the 0.3s CSS height
6
+ * transition so a normal completion is always released by the event. */
7
+ const MEASURE_BACKSTOP_MS = 450;
2
8
 
3
9
  /**
4
10
  * HkAuthCard — the shared shell of every Celestia auth screen (login,
@@ -19,42 +25,162 @@ import { defineComponent, type PropType } from "vue";
19
25
  * per-row centering; content that must sit on one row belongs inside
20
26
  * one child element.
21
27
  * - `logo` swaps the header's logo slot; `default` is the form body.
28
+ *
29
+ * Smooth height contract:
30
+ * The card's content is consumer-driven and changes at runtime — channel
31
+ * tabs swap the OAuth `methods` list (3 buttons ↔ 1), validation banners
32
+ * appear, locale switches reflow labels — and the card's height would
33
+ * otherwise snap between the two layouts. The root card is therefore
34
+ * wrapped in a `.s-auth-card-height` measuring wrapper that owns an
35
+ * explicit `height` and animates toward whatever natural height the card
36
+ * grows or shrinks to:
37
+ *
38
+ * - On mount the wrapper seeds its `height` from the card's current
39
+ * `offsetHeight` while the animating state is impossible, so the first
40
+ * paint never animates.
41
+ * - A `ResizeObserver` on the card element (not the wrapper) fires when
42
+ * the content-driven natural height changes. Changes smaller than 1px
43
+ * are ignored to keep rounding noise from starting a cycle. A real
44
+ * change adds the `s-auth-card--measuring` class — the ONLY place
45
+ * `overflow: hidden` is applied — then rewrites the height, and the CSS
46
+ * `height` transition animates the wrapper between the two sizes.
47
+ * - The clipping class is released by the wrapper's `transitionend`
48
+ * (propertyName === "height") and, if that event never fires, by a
49
+ * 450ms backstop timer. Between animations the card carries no clip,
50
+ * so its shadow, focus rings and popovers are never cut off at rest.
51
+ * - `smoothHeight: false` opts out entirely: the wrapper still renders
52
+ * (the DOM shape stays stable for consumers) but no height style, no
53
+ * observer and no listeners are installed, and height changes snap as
54
+ * before. The prop is read ONCE at mount time — runtime toggles are
55
+ * ignored, making it a static opt-out rather than a reactive switch
56
+ * (remount with a changed `:key` to apply a new value).
57
+ * - Environments without `ResizeObserver` degrade the same way: instant
58
+ * snap, no measurement, no clipping.
59
+ * - `prefers-reduced-motion: reduce` disables the transition in CSS, so
60
+ * the height still tracks the content, just without animation. While
61
+ * it matches, the observer updates the height directly WITHOUT opening
62
+ * a measuring window — no clip and no backstop timer — because a clip
63
+ * held for the backstop would cut off the card's shadow with nothing
64
+ * animating to mask it.
65
+ *
66
+ * The wrapper is transparent to descendant styling: nothing inside
67
+ * `.s-auth-card` changes, and no hikari stylesheet targets the card as a
68
+ * direct child. Only consumer styles that select `.s-auth-card` as a
69
+ * DIRECT child of a specific parent must account for the extra wrapper
70
+ * (e.g. `.us-main > .s-auth-card` becomes
71
+ * `.us-main > .s-auth-card-height > .s-auth-card`).
22
72
  */
23
73
  export const HkAuthCard = defineComponent({
24
74
  name: "HkAuthCard",
25
75
  props: {
26
76
  title: { type: String, required: true },
27
77
  subtitle: { type: String, default: "" },
78
+ smoothHeight: { type: Boolean, default: true },
28
79
  },
29
80
  setup(props, { slots }) {
81
+ const heightEl = ref<HTMLElement | null>(null);
82
+ const cardEl = ref<HTMLElement | null>(null);
83
+
84
+ let observer: ResizeObserver | null = null;
85
+ let backstopTimer: ReturnType<typeof setTimeout> | null = null;
86
+
87
+ /** End the current measuring window: drop the clip and the backstop. */
88
+ const releaseClip = () => {
89
+ if (backstopTimer !== null) {
90
+ clearTimeout(backstopTimer);
91
+ backstopTimer = null;
92
+ }
93
+ heightEl.value?.classList.remove("s-auth-card--measuring");
94
+ };
95
+
96
+ const onTransitionEnd = (event: TransitionEvent) => {
97
+ // Only the wrapper's OWN height transition may release the clip —
98
+ // a descendant slot element's height transition bubbling up must
99
+ // not end a measuring window it did not open.
100
+ if (event.target !== heightEl.value) return;
101
+ if (event.propertyName !== "height") return;
102
+ releaseClip();
103
+ };
104
+
105
+ onMounted(() => {
106
+ if (!props.smoothHeight) return;
107
+ const wrapper = heightEl.value;
108
+ const card = cardEl.value;
109
+ if (!wrapper || !card || typeof ResizeObserver === "undefined") return;
110
+
111
+ // Seed the explicit height BEFORE any animating class exists, so
112
+ // the first paint (wrapper already at the natural size) never
113
+ // animates.
114
+ wrapper.style.height = `${card.offsetHeight}px`;
115
+ wrapper.addEventListener("transitionend", onTransitionEnd);
116
+
117
+ observer = new ResizeObserver(() => {
118
+ const next = card.offsetHeight;
119
+ const current = parseFloat(wrapper.style.height) || 0;
120
+ // Ignore sub-pixel churn — rounding noise must not start a
121
+ // clip-and-animate cycle.
122
+ if (Math.abs(next - current) < 1) return;
123
+
124
+ // Reduced motion: the CSS transition is `none`, so transitionend
125
+ // will never fire and the clip would just sit there for the
126
+ // backstop window, cutting off the shadow with nothing to mask.
127
+ // Snap unclipped instead — no class, no timer.
128
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
129
+ wrapper.style.height = `${next}px`;
130
+ return;
131
+ }
132
+
133
+ // Clip ONLY while a height transition is actually running; the
134
+ // class is released by transitionend or the backstop timer.
135
+ wrapper.classList.add("s-auth-card--measuring");
136
+ wrapper.style.height = `${next}px`;
137
+
138
+ if (backstopTimer !== null) clearTimeout(backstopTimer);
139
+ backstopTimer = setTimeout(releaseClip, MEASURE_BACKSTOP_MS);
140
+ });
141
+ observer.observe(card);
142
+ });
143
+
144
+ onBeforeUnmount(() => {
145
+ observer?.disconnect();
146
+ observer = null;
147
+ heightEl.value?.removeEventListener("transitionend", onTransitionEnd);
148
+ if (backstopTimer !== null) {
149
+ clearTimeout(backstopTimer);
150
+ backstopTimer = null;
151
+ }
152
+ });
153
+
30
154
  return () => (
31
- <div class="s-auth-card">
32
- {slots.logo && (
33
- <div class="s-auth-header">
34
- {slots.logo()}
35
- <h1 class="s-auth-title">{props.title}</h1>
36
- {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
37
- </div>
38
- )}
39
- {!slots.logo && (
40
- <div class="s-auth-header">
41
- <h1 class="s-auth-title">{props.title}</h1>
42
- {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
155
+ <div class="s-auth-card-height" ref={heightEl}>
156
+ <div class="s-auth-card" ref={cardEl}>
157
+ {slots.logo && (
158
+ <div class="s-auth-header">
159
+ {slots.logo()}
160
+ <h1 class="s-auth-title">{props.title}</h1>
161
+ {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
162
+ </div>
163
+ )}
164
+ {!slots.logo && (
165
+ <div class="s-auth-header">
166
+ <h1 class="s-auth-title">{props.title}</h1>
167
+ {props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
168
+ </div>
169
+ )}
170
+ <div class="s-auth-form">
171
+ {slots.default?.()}
43
172
  </div>
44
- )}
45
- <div class="s-auth-form">
46
- {slots.default?.()}
173
+ {slots.methods && (
174
+ <div class="s-auth-methods">
175
+ {slots.methods()}
176
+ </div>
177
+ )}
178
+ {slots.footer && (
179
+ <div class="s-auth-footer">
180
+ {slots.footer()}
181
+ </div>
182
+ )}
47
183
  </div>
48
- {slots.methods && (
49
- <div class="s-auth-methods">
50
- {slots.methods()}
51
- </div>
52
- )}
53
- {slots.footer && (
54
- <div class="s-auth-footer">
55
- {slots.footer()}
56
- </div>
57
- )}
58
184
  </div>
59
185
  );
60
186
  },
@@ -44,6 +44,7 @@ import { useBoardCamera } from "../composables/useBoardCamera";
44
44
  import HkMinimap, { type MinimapBox } from "./HkMinimap";
45
45
  import {
46
46
  boardBBox,
47
+ boardStepRung,
47
48
  type BoardCamera,
48
49
  type BoardPoint,
49
50
  type BoardRect,
@@ -52,13 +53,19 @@ import {
52
53
  import {
53
54
  boardAnchor,
54
55
  boardEdgePath,
56
+ boardFanOrdinals,
55
57
  boardViaPath,
56
58
  type BoardAnchorMode,
57
59
  type BoardEdgeStyle,
58
60
  } from "../utils/boardEdges";
59
61
  import "./HkBoard.scss";
60
62
 
61
- /** A node on the board. `hidden` nodes are junction-only (invisible). */
63
+ /**
64
+ * A node on the board. `hidden` nodes are junction-only (invisible).
65
+ * During a node drag the board mutates the node objects IN PLACE and
66
+ * emits the live reference through `node-move` / `nodeClick` — pass
67
+ * deep-reactive, mutable node objects, not frozen copies.
68
+ */
62
69
  export interface BoardNodeInput {
63
70
  id: string;
64
71
  x: number;
@@ -138,16 +145,7 @@ export default defineComponent({
138
145
  });
139
146
 
140
147
  /** Edges sharing a `from` node fan across its border (天女散花). */
141
- const fanOrdinal = computed(() => {
142
- const counters = new Map<string, number>();
143
- const ordinals = new Map<string, { index: number; count: number }>();
144
- for (const e of props.edges) {
145
- const next = (counters.get(e.from) ?? 0) + 1;
146
- counters.set(e.from, next);
147
- ordinals.set(e.id, { index: next - 1, count: counters.get(e.from)! });
148
- }
149
- return ordinals;
150
- });
148
+ const fanOrdinal = computed(() => boardFanOrdinals(props.edges));
151
149
 
152
150
  const edgePaths = computed(() => {
153
151
  const rects = rectOf.value;
@@ -209,6 +207,9 @@ export default defineComponent({
209
207
  let panState: { px: number; py: number } | null = null;
210
208
  let pinchBase: { cam: BoardCamera; dist: number; mid: BoardPoint } | null = null;
211
209
  let dragState: { node: BoardNodeInput; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
210
+ // Every node press is tracked (draggable or not) so read-only boards
211
+ // — SCADA scenes, mind maps — still get `nodeClick` on a short tap.
212
+ let pressed: { id: string; pointerId: number; x: number; y: number } | null = null;
212
213
 
213
214
  const localPoint = (e: PointerEvent | WheelEvent): BoardPoint => {
214
215
  const rect = viewportRef.value?.getBoundingClientRect();
@@ -230,23 +231,39 @@ export default defineComponent({
230
231
 
231
232
  let resizeObs: ResizeObserver | null = null;
232
233
 
234
+ /** Snapshot the camera + current finger pair as the pinch baseline —
235
+ * at gesture start, or whenever the pair's composition changes — so
236
+ * the scale keeps following the fingers 1:1 without a jump. */
237
+ function rearmPinch(): void {
238
+ const [a, b] = [...pointers.values()];
239
+ if (!a || !b) return;
240
+ const rect = viewportRef.value?.getBoundingClientRect();
241
+ pinchBase = {
242
+ cam: { ...camera.value },
243
+ 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
+ },
248
+ };
249
+ }
250
+
233
251
  function onPointerDown(e: PointerEvent): void {
234
252
  if (!props.interactive) return;
253
+ // Defensive prune: tracked pointers no gesture is using are stale
254
+ // (their up/cancel AND capture loss were both lost) — drop them so
255
+ // the next single-finger touch cannot become a phantom pinch.
256
+ if (pointers.size > 0 && !pinchBase && !panState && !dragState) pointers.clear();
235
257
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
236
- if (pointers.size === 2) {
237
- // pinch begins: snapshot camera + finger geometry
238
- const [a, b] = [...pointers.values()];
239
- pinchBase = {
240
- cam: { ...camera.value },
241
- dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
242
- mid: localPoint(e),
243
- };
244
- panState = null;
245
- return;
246
- }
258
+ // Capture EVERY finger: up/cancel are only guaranteed while capture
259
+ // holds, and an uncaptured pinch finger would never leave `pointers`.
260
+ viewportRef.value?.setPointerCapture(e.pointerId);
247
261
  if (pointers.size === 1) {
248
262
  panState = { px: e.clientX, py: e.clientY };
249
- viewportRef.value?.setPointerCapture(e.pointerId);
263
+ } else if (pointers.size === 2) {
264
+ // pinch begins: snapshot camera + finger geometry, suspend panning
265
+ rearmPinch();
266
+ panState = null;
250
267
  }
251
268
  }
252
269
 
@@ -256,10 +273,12 @@ export default defineComponent({
256
273
  if (pinchBase && pointers.size >= 2) {
257
274
  const [a, b] = [...pointers.values()];
258
275
  const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
276
+ const rect = viewportRef.value?.getBoundingClientRect();
259
277
  const mid = {
260
- x: (a.x + b.x) / 2 - (viewportRef.value?.getBoundingClientRect().left ?? 0),
261
- y: (a.y + b.y) / 2 - (viewportRef.value?.getBoundingClientRect().top ?? 0),
278
+ x: (a.x + b.x) / 2 - (rect?.left ?? 0),
279
+ y: (a.y + b.y) / 2 - (rect?.top ?? 0),
262
280
  };
281
+ pinchBase.mid = mid;
263
282
  cam.pinchZoom(pinchBase.cam, dist / pinchBase.dist, mid);
264
283
  return;
265
284
  }
@@ -280,17 +299,62 @@ export default defineComponent({
280
299
  }
281
300
 
282
301
  function onPointerUp(e: PointerEvent): void {
283
- pointers.delete(e.pointerId);
284
- if (pointers.size < 2 && pinchBase) {
302
+ const press = pressed;
303
+ pressed = null;
304
+ dragState = null;
305
+ retirePointer(e.pointerId);
306
+ // nodeClick: a press that belongs to THIS pointer and stayed within
307
+ // the drag slop clicks — regardless of `draggable`.
308
+ if (press && press.pointerId === e.pointerId
309
+ && Math.abs(e.clientX - press.x) + Math.abs(e.clientY - press.y) < 3) {
310
+ const node = props.nodes.find((n) => n.id === press.id);
311
+ if (node) emit("nodeClick", node);
312
+ }
313
+ }
314
+
315
+ function onPointerCancel(e: PointerEvent): void {
316
+ // An aborted gesture must never synthesize a click: prune state only.
317
+ pressed = null;
318
+ dragState = null;
319
+ retirePointer(e.pointerId);
320
+ }
321
+
322
+ /** A pointer left the gesture (up, cancel, or capture loss): prune it,
323
+ * re-baseline or settle the pinch, and hand panning to any surviving
324
+ * pointer so a pinch that ends with one finger down keeps panning. */
325
+ function retirePointer(pointerId: number): void {
326
+ if (!pointers.delete(pointerId)) return;
327
+ if (pinchBase && pointers.size >= 2) {
328
+ // The pinch pair composition changed (a finger lifted while two
329
+ // or more remain): re-baseline onto the surviving pair.
330
+ rearmPinch();
331
+ return;
332
+ }
333
+ if (pinchBase) {
334
+ const mid = pinchBase.mid;
285
335
  pinchBase = null;
286
- cam.settlePinch();
336
+ cam.settlePinch(mid);
287
337
  }
288
- if (pointers.size === 0) panState = null;
289
- if (dragState && !dragState.moved) emit("nodeClick", dragState.node);
338
+ const rest = pointers.values().next().value;
339
+ panState = rest ? { px: rest.x, py: rest.y } : null;
340
+ }
341
+
342
+ /** Capture-loss backstop: up/cancel are only guaranteed while pointer
343
+ * capture holds; a pointer that was released without an up is pruned
344
+ * here — otherwise the next single-finger touch would silently become
345
+ * a phantom pinch. The event fires on the capture target and does not
346
+ * bubble, hence the capture-phase listener; after a normal up it is a
347
+ * no-op (the pointer was already pruned there). */
348
+ function onLostPointerCapture(e: PointerEvent): void {
349
+ pressed = null;
290
350
  dragState = null;
351
+ retirePointer(e.pointerId);
291
352
  }
292
353
 
293
354
  function onNodePointerDown(e: PointerEvent, node: BoardNodeInput): void {
355
+ pressed = { id: node.id, pointerId: e.pointerId, x: e.clientX, y: e.clientY };
356
+ // Draggable nodes own the gesture (no board pan); every other press
357
+ // bubbles on so the board can pan — and still click on a short tap.
294
358
  if (!props.interactive || !node.draggable) return;
295
359
  e.stopPropagation();
296
360
  dragState = { node, sx: e.clientX, sy: e.clientY, ox: node.x, oy: node.y, moved: false };
@@ -308,12 +372,17 @@ export default defineComponent({
308
372
  resizeObs = new ResizeObserver(refreshSize);
309
373
  if (viewportRef.value) resizeObs.observe(viewportRef.value);
310
374
  viewportRef.value?.addEventListener("wheel", onWheel, { passive: false });
375
+ // Capture phase: lostpointercapture does not bubble and fires on the
376
+ // element that held capture.
377
+ viewportRef.value?.addEventListener("lostpointercapture", onLostPointerCapture, true);
311
378
  if (props.fitOnMount) cam.fit();
312
379
  });
313
380
 
314
381
  onBeforeUnmount(() => {
382
+ cam.stop(); // kill any in-flight tween rAF
315
383
  resizeObs?.disconnect();
316
384
  viewportRef.value?.removeEventListener("wheel", onWheel);
385
+ viewportRef.value?.removeEventListener("lostpointercapture", onLostPointerCapture, true);
317
386
  });
318
387
 
319
388
  expose({
@@ -332,7 +401,7 @@ export default defineComponent({
332
401
  onPointerdown={onPointerDown}
333
402
  onPointermove={onPointerMove}
334
403
  onPointerup={onPointerUp}
335
- onPointercancel={onPointerUp}
404
+ onPointercancel={onPointerCancel}
336
405
  >
337
406
  <div class="hk-board-grid" style={gridStyle.value} />
338
407
  <div class="hk-board-world" style={worldStyle.value}>
@@ -384,7 +453,7 @@ export default defineComponent({
384
453
  minZoomPercent={Math.round(props.minK * 100)}
385
454
  maxZoomPercent={Math.round(props.maxK * 100)}
386
455
  showReset
387
- onZoomTo={(percent: number) => cam.zoomToPercent(percent)}
456
+ onZoomTo={(percent: number) => cam.zoomToK(boardStepRung(camera.value.k, percent / 100))}
388
457
  onReset={() => cam.fit()}
389
458
  onPanDelta={(dx: number, dy: number) => cam.panBy(dx, dy)}
390
459
  />
@@ -150,7 +150,6 @@
150
150
  width: var(--hi-icon-button-icon-size);
151
151
  height: var(--hi-icon-button-icon-size);
152
152
  display: block;
153
- fill: currentColor;
154
153
  }
155
154
  }
156
155
 
@@ -11,6 +11,10 @@
11
11
  cursor: pointer;
12
12
  overflow: hidden;
13
13
  pointer-events: auto;
14
+ /* The minimap owns its own drag gestures — the surrounding pannable
15
+ surface must never claim them (mobile browsers would otherwise fire
16
+ pointercancel and kill the drag). */
17
+ touch-action: none;
14
18
 
15
19
  &:hover {
16
20
  border-color: rgb(var(--color-primary) / 0.3);
@@ -63,12 +63,15 @@ export interface UseBoardCameraReturn {
63
63
  zoomOut: () => void;
64
64
  /** Raw (unquantized, unanimated) pinch zoom — call per pointermove. */
65
65
  pinchZoom: (base: BoardCamera, ratio: number, anchor: BoardPoint) => void;
66
- /** Snap the post-pinch camera onto the nearest rung (animated). */
67
- settlePinch: () => void;
66
+ /** Snap the post-pinch camera onto the nearest rung (animated), keeping
67
+ * the world point under `anchor` (the pinch midpoint) fixed. */
68
+ settlePinch: (anchor?: BoardPoint) => void;
68
69
  panBy: (dx: number, dy: number) => void;
69
70
  setCamera: (cam: BoardCamera, opts?: { animate?: boolean }) => void;
70
71
  fit: () => void;
71
72
  reset: () => void;
73
+ /** Cancel any in-flight tween and its pending rAF (call on unmount). */
74
+ stop: () => void;
72
75
  }
73
76
 
74
77
  export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraReturn {
@@ -83,6 +86,12 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
83
86
  const camera = ref<BoardCamera>({ x: 0, y: 0, k: 1 });
84
87
  const isAnimating = ref(false);
85
88
 
89
+ // fit() may park the camera BELOW the interaction minimum (show the
90
+ // whole board wins over the zoom ladder, as in HkImageViewer) — the
91
+ // clamp floor follows it down so the fitted framing survives; any
92
+ // quantized gesture lands back on the ladder at ≥ minK.
93
+ let kFloor = minK;
94
+
86
95
  let raf = 0;
87
96
  let tweenFrom: BoardCamera = { x: 0, y: 0, k: 1 };
88
97
  let tweenTo: BoardCamera = { x: 0, y: 0, k: 1 };
@@ -100,7 +109,7 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
100
109
  const contentRect = (): BoardRect => toValue(content);
101
110
 
102
111
  const clampCam = (cam: BoardCamera): BoardCamera => {
103
- const k = Math.min(maxK, Math.max(minK, cam.k));
112
+ const k = Math.min(maxK, Math.max(kFloor, cam.k));
104
113
  return boardClampPan({ ...cam, k }, contentRect(), viewport());
105
114
  };
106
115
 
@@ -135,13 +144,16 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
135
144
  };
136
145
 
137
146
  function zoomByFactor(factor: number, anchor?: BoardPoint): void {
138
- const next = boardZoomAt(camera.value, camera.value.k * factor, anchorPoint(anchor));
139
- animateTo({ ...next, k: quantizeBoardK(next.k) });
147
+ // Quantize FIRST, then solve the anchored pan for the QUANTIZED k —
148
+ // anchoring on the raw k and swapping in the quantized one afterwards
149
+ // lets the anchored world point jump by the raw/quantized ratio.
150
+ const k = quantizeBoardK(camera.value.k * factor);
151
+ animateTo(boardZoomAt(camera.value, k, anchorPoint(anchor)));
140
152
  }
141
153
 
142
154
  function zoomToK(targetK: number, anchor?: BoardPoint): void {
143
- const next = boardZoomAt(camera.value, targetK, anchorPoint(anchor));
144
- animateTo({ ...next, k: quantizeBoardK(next.k) });
155
+ const k = quantizeBoardK(targetK);
156
+ animateTo(boardZoomAt(camera.value, k, anchorPoint(anchor)));
145
157
  }
146
158
 
147
159
  function zoomToPercent(percent: number, anchor?: BoardPoint): void {
@@ -162,9 +174,11 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
162
174
  camera.value = clampCam(boardZoomAt(base, base.k * ratio, anchor));
163
175
  }
164
176
 
165
- function settlePinch(): void {
166
- const settled = { ...camera.value, k: quantizeBoardK(camera.value.k) };
167
- animateTo(settled);
177
+ function settlePinch(anchor?: BoardPoint): void {
178
+ // Same quantize-then-anchor discipline as the wheel path: the pinch
179
+ // midpoint stays pinned to its world point while k snaps onto the rung.
180
+ const k = quantizeBoardK(camera.value.k);
181
+ animateTo(boardZoomAt(camera.value, k, anchorPoint(anchor)));
168
182
  }
169
183
 
170
184
  function panBy(dx: number, dy: number): void {
@@ -181,7 +195,9 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
181
195
  }
182
196
 
183
197
  function fit(): void {
184
- animateTo(boardFit(contentRect(), viewport()));
198
+ const target = boardFit(contentRect(), viewport());
199
+ kFloor = Math.min(minK, target.k);
200
+ animateTo(target);
185
201
  }
186
202
 
187
203
  function reset(): void {
@@ -214,5 +230,6 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
214
230
  setCamera,
215
231
  fit,
216
232
  reset,
233
+ stop: stopTween,
217
234
  };
218
235
  }
@@ -527,6 +527,30 @@
527
527
  to { opacity: 1; transform: translateY(0) scale(1); }
528
528
  }
529
529
 
530
+ /* Measuring wrapper (HkAuthCard): owns an explicit height so content-
531
+ driven height changes (channel tabs swapping the methods list, error
532
+ banners, …) animate instead of snapping. The transition lives here,
533
+ always on; the `--measuring` clip below is applied by HkAuthCard only
534
+ for the duration of a height transition, so an idle card never clips
535
+ its shadow, focus rings or popovers. */
536
+ .s-auth-card-height {
537
+ /* The wrapper is sized from the card's offsetHeight (border-box); an
538
+ explicit box-sizing keeps that mapping honest on hosts without a
539
+ global border-box reset. */
540
+ box-sizing: border-box;
541
+ transition: height 0.3s var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1));
542
+
543
+ &.s-auth-card--measuring {
544
+ overflow: hidden;
545
+ }
546
+ }
547
+
548
+ @media (prefers-reduced-motion: reduce) {
549
+ .s-auth-card-height {
550
+ transition: none;
551
+ }
552
+ }
553
+
530
554
  .s-auth-card input {
531
555
  transition: color calc(infinity * 1s) step-end, background-color calc(infinity * 1s) step-end;
532
556
  appearance: none;
@@ -343,14 +343,19 @@ describe("late registration", () => {
343
343
  setMode("dark");
344
344
  initTheme();
345
345
  const el = document.documentElement;
346
+ // Theme vars live in the managed :root block (lean injection), so the
347
+ // html inline style stays clean until the late group lands.
348
+ const block = () => document.head.querySelector("style[data-hikari-theme-vars]")?.textContent ?? "";
346
349
  expect(el.style.getPropertyValue("--late-wires-a")).toBe("");
350
+ expect(block()).not.toContain("--late-wires-a");
347
351
 
348
352
  registerTokenGroup(LATE_GROUP);
349
353
  await flushMicrotasks();
350
354
 
351
355
  // The injected hook re-applied the current dark theme, so the late
352
- // group's registry defaults are already on the document element.
353
- expect(el.style.getPropertyValue("--late-wires-a")).toBe("1 2 3");
356
+ // group's registry defaults are already in the managed block.
357
+ expect(block()).toContain("--late-wires-a:1 2 3");
358
+ expect(el.style.getPropertyValue("--late-wires-a")).toBe("");
354
359
  });
355
360
 
356
361
  it("coalesces same-tick registrations into a single re-apply", async () => {
@@ -70,3 +70,70 @@ describe("useTheme theme clock", () => {
70
70
  expect(theme.useTheme().geo.value).toEqual({ lat: 1, lng: 2 });
71
71
  });
72
72
  });
73
+
74
+ describe("useTheme lean cssvar injection", () => {
75
+ let theme: ThemeModule;
76
+
77
+ beforeEach(async () => {
78
+ vi.resetModules();
79
+ vi.unstubAllGlobals();
80
+ localStorage.clear();
81
+ document.documentElement.style.cssText = "";
82
+ document.documentElement.removeAttribute("data-theme");
83
+ document.documentElement.removeAttribute("data-mode");
84
+ document.head.querySelectorAll("style[data-hikari-theme-vars]").forEach((el) => el.remove());
85
+ vi.stubGlobal("fetch", vi.fn(async () => {
86
+ throw new Error("offline");
87
+ }));
88
+ theme = await import("./useTheme");
89
+ });
90
+
91
+ afterEach(() => {
92
+ theme.stopThemeClock();
93
+ vi.unstubAllGlobals();
94
+ vi.restoreAllMocks();
95
+ });
96
+
97
+ it("pickThemeVarDeltas keeps only values differing from the static cascade", () => {
98
+ const vars = {
99
+ "--a": "1 2 3",
100
+ "--b": "4 5 6",
101
+ "--c": "7 8 9",
102
+ "--d": " 10 11 12 ",
103
+ };
104
+ const baseline = {
105
+ "--a": "1 2 3",
106
+ "--b": "4 5 6",
107
+ "--c": "",
108
+ "--d": "10 11 12",
109
+ };
110
+ // Whitespace-only differences collapse (normalization), so only the
111
+ // genuinely absent/divergent values are injected.
112
+ expect(theme.pickThemeVarDeltas(vars, baseline)).toEqual({
113
+ "--c": "7 8 9",
114
+ });
115
+ });
116
+
117
+ it("writes theme vars into a managed :root style block, never inline", () => {
118
+ theme.initTheme();
119
+ const styleEl = document.head.querySelector("style[data-hikari-theme-vars]");
120
+ expect(styleEl).not.toBeNull();
121
+ // Real browsers compute the static defaults, so the block holds only the
122
+ // true deltas; either way it is a :root block, not an inline attribute.
123
+ expect(styleEl!.textContent).toMatch(/^:root\{/);
124
+ expect(styleEl!.textContent).toContain("--color-primary");
125
+ // The html inline style attribute stays clean — no token vars on it.
126
+ expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("");
127
+ // The epoch attributes that consumers watch are still written.
128
+ expect(document.documentElement.getAttribute("data-theme")).toBeTruthy();
129
+ expect(document.documentElement.getAttribute("data-mode")).toBeTruthy();
130
+ // Swapping theme keeps ONE managed block (no duplicates per apply) and
131
+ // actually re-applies: the block content changes with the preset.
132
+ const before = document.head.querySelector("style[data-hikari-theme-vars]")!.textContent;
133
+ theme.useTheme().setTheme("nord");
134
+ const blocks = document.head.querySelectorAll("style[data-hikari-theme-vars]");
135
+ expect(blocks).toHaveLength(1);
136
+ expect(blocks[0].textContent).toMatch(/^:root\{/);
137
+ expect(blocks[0].textContent).not.toBe(before);
138
+ });
139
+ });
@@ -148,6 +148,76 @@ function resolveEffectiveMode(mode: ThemeMode): "dark" | "light" {
148
148
  const THEME_TRANSITION_DURATION = 300;
149
149
  let transitionTimer: CronHandle | null = null;
150
150
 
151
+ /**
152
+ * Theme vars live in ONE managed stylesheet block (`:root` overrides)
153
+ * instead of a hundred inline declarations on `<html style="...">`.
154
+ * - Inline style attributes bloat the DOM, show up as giant devtools
155
+ * noise on the root element, defeat caching of the token set and make
156
+ * every style recalc parse the whole attribute again.
157
+ * - A stylesheet block is a single atomic replacement (one recalc for
158
+ * the whole theme apply) and inspectable/serializable.
159
+ * - Only the DELTAS are written: hikari's static stylesheet already
160
+ * seeds :root with the full default token set (including pure
161
+ * derivations like `--hi-color-primary: rgb(var(--color-primary))`),
162
+ * so a token whose value already resolves identically needs no
163
+ * override at all — a default-ish theme injects a handful of vars
164
+ * instead of ~80.
165
+ */
166
+ const THEME_VARS_STYLE_ATTR = "data-hikari-theme-vars";
167
+
168
+ function normalizeVarValue(value: string): string {
169
+ return value.replace(/\s+/g, " ").trim();
170
+ }
171
+
172
+ /** Keep only the vars whose value differs from the static cascade. */
173
+ export function pickThemeVarDeltas(
174
+ vars: Record<string, string>,
175
+ baseline: Record<string, string>,
176
+ ): Record<string, string> {
177
+ const deltas: Record<string, string> = {};
178
+ for (const [key, value] of Object.entries(vars)) {
179
+ const normalized = normalizeVarValue(value);
180
+ if (normalizeVarValue(baseline[key] ?? "") !== normalized) {
181
+ deltas[key] = normalized;
182
+ }
183
+ }
184
+ return deltas;
185
+ }
186
+
187
+ function themeVarsStyleElement(): HTMLStyleElement {
188
+ let styleEl = document.head.querySelector<HTMLStyleElement>(
189
+ `style[${THEME_VARS_STYLE_ATTR}]`,
190
+ );
191
+ if (!styleEl) {
192
+ styleEl = document.createElement("style");
193
+ styleEl.setAttribute(THEME_VARS_STYLE_ATTR, "");
194
+ document.head.appendChild(styleEl);
195
+ }
196
+ return styleEl;
197
+ }
198
+
199
+ function injectThemeVars(el: HTMLElement, vars: Record<string, string>): void {
200
+ const styleEl = themeVarsStyleElement();
201
+ // Measure the cascade WITHOUT our own previous overrides: disable the
202
+ // managed block, read every candidate, re-enable. One recalc serves
203
+ // all reads, and applyTheme is a rare switch-time action.
204
+ const baseline: Record<string, string> = {};
205
+ try {
206
+ styleEl.disabled = true;
207
+ const computed = getComputedStyle(el);
208
+ for (const key of Object.keys(vars)) {
209
+ baseline[key] = computed.getPropertyValue(key);
210
+ }
211
+ } finally {
212
+ styleEl.disabled = false;
213
+ }
214
+
215
+ const deltas = pickThemeVarDeltas(vars, baseline);
216
+ styleEl.textContent = Object.keys(deltas).length > 0
217
+ ? `:root{${Object.entries(deltas).map(([key, value]) => `${key}:${value}`).join(";")}}`
218
+ : "";
219
+ }
220
+
151
221
  function applyTheme() {
152
222
  const el = document.documentElement;
153
223
  const all = getAllThemePresets();
@@ -168,9 +238,7 @@ function applyTheme() {
168
238
 
169
239
  invalidateLuminanceCache();
170
240
 
171
- for (const [key, value] of Object.entries(vars)) {
172
- el.style.setProperty(key, value);
173
- }
241
+ injectThemeVars(el, vars);
174
242
 
175
243
  el.setAttribute("data-theme", currentTheme.value);
176
244
  el.setAttribute("data-mode", effectiveMode);
@@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
4
  BOARD_FIT_CAP,
5
+ BOARD_K_MAX,
5
6
  BOARD_ZOOM_FACTOR,
6
7
  boardBBox,
7
8
  boardClampPan,
8
9
  boardEaseOutCubic,
9
10
  boardFit,
10
11
  boardLevelOf,
12
+ boardStepRung,
11
13
  boardToScreen,
12
14
  boardTweenCam,
13
15
  boardToWorld,
@@ -51,6 +53,20 @@ describe("boardCamera — anchor zoom", () => {
51
53
  expect(zoomed.k).toBe(1.5);
52
54
  });
53
55
 
56
+ it("keeps the anchor world point fixed when zooming to a QUANTIZED rung", () => {
57
+ // The settle path (wheel notch / pinch release) must quantize k FIRST
58
+ // and then solve the anchored pan for the quantized k — anchoring on
59
+ // the raw k and swapping the rung in afterwards jumps the world point.
60
+ const cam = { x: 40, y: -20, k: 2.3 };
61
+ const anchor = { x: 300, y: 200 };
62
+ const worldBefore = boardToWorld(cam, anchor.x, anchor.y);
63
+ const settled = boardZoomAt(cam, quantizeBoardK(cam.k), anchor);
64
+ const worldAfter = boardToWorld(settled, anchor.x, anchor.y);
65
+ expect(worldAfter.x).toBeCloseTo(worldBefore.x, 6);
66
+ expect(worldAfter.y).toBeCloseTo(worldBefore.y, 6);
67
+ expect(settled.k).toBe(quantizeBoardK(cam.k));
68
+ });
69
+
54
70
  it("round-trips screen→world→screen", () => {
55
71
  const cam = { x: -130, y: 88, k: 1.3 };
56
72
  const p = boardToScreen(cam, 42, -7);
@@ -83,6 +99,45 @@ describe("boardCamera — pan clamp + fit", () => {
83
99
  const tiny = boardFit({ x: 0, y: 0, w: 60, h: 40 }, viewport);
84
100
  expect(tiny.k).toBeLessThanOrEqual(BOARD_FIT_CAP);
85
101
  });
102
+
103
+ it("shrinks below the interaction minimum to fit very wide boards", () => {
104
+ // 8000 world px in an 800 px viewport: fit k = 720/8000 = 0.09, far
105
+ // below the old 0.2 floor that cropped such boards on open.
106
+ const wide = { x: 0, y: 0, w: 8000, h: 600 };
107
+ const cam = boardFit(wide, viewport);
108
+ expect(cam.k).toBeCloseTo((800 - 80) / 8000, 6);
109
+ expect(cam.k).toBeLessThan(0.2);
110
+ // the whole board lands inside the viewport
111
+ const tl = boardToScreen(cam, 0, 0);
112
+ const br = boardToScreen(cam, 8000, 600);
113
+ expect(tl.x).toBeGreaterThanOrEqual(0);
114
+ expect(br.x).toBeLessThanOrEqual(viewport.w);
115
+ });
116
+ });
117
+
118
+ describe("boardCamera — minimap step rungs", () => {
119
+ const lastRung = BOARD_ZOOM_FACTOR ** 28; // ≈3.92 — last rung below the cap
120
+
121
+ it("pushes a linear +5% step that nearest-rounds onto the current rung", () => {
122
+ // from 392%, the + button targets 397% — nearest rounding falls back
123
+ // onto rung 28 (the dead band), so the step resolves one rung further
124
+ // and clamps to the cap.
125
+ expect(quantizeBoardK(3.97)).toBeCloseTo(lastRung, 5);
126
+ expect(boardStepRung(lastRung, 3.97)).toBe(BOARD_K_MAX);
127
+ });
128
+
129
+ it("pushes a linear −5% step onto the rung below, not the current one", () => {
130
+ expect(boardStepRung(lastRung, 3.87)).toBeCloseTo(BOARD_ZOOM_FACTOR ** 27, 5);
131
+ });
132
+
133
+ it("leaves steps that already resolve to a different rung untouched", () => {
134
+ expect(boardStepRung(1, 1.05)).toBeCloseTo(BOARD_ZOOM_FACTOR, 5);
135
+ expect(boardStepRung(1, 0.95)).toBeCloseTo(BOARD_ZOOM_FACTOR ** -1, 5);
136
+ });
137
+
138
+ it("steps from the ladder floor up onto the nearest live rung", () => {
139
+ expect(boardStepRung(0.2, 0.25)).toBeCloseTo(BOARD_ZOOM_FACTOR ** -28, 5);
140
+ });
86
141
  });
87
142
 
88
143
  describe("boardCamera — animated tween", () => {
@@ -14,7 +14,9 @@
14
14
  * - pan is CLAMPED so the content bbox can never be pushed fully out of
15
15
  * view (half a viewport of slack on each side); content smaller than
16
16
  * the viewport re-centers;
17
- * - `boardFit` fits the whole bbox (pad + floor + cap);
17
+ * - `boardFit` fits the whole bbox (pad + cap) and may shrink as far as
18
+ * needed to show the whole board — fit sits below the interaction
19
+ * ladder freely;
18
20
  * - `boardTweenCam` eases between two cameras with an ease-out curve and
19
21
  * GEOMETRIC k interpolation (log-space lerp) so animated zooms still
20
22
  * land exactly on ladder rungs.
@@ -51,8 +53,6 @@ export interface BoardViewport {
51
53
  export const BOARD_ZOOM_FACTOR = 1.05;
52
54
  export const BOARD_K_MIN = 0.2;
53
55
  export const BOARD_K_MAX = 4;
54
- /** fit() may shrink as far as needed to show the whole board. */
55
- export const BOARD_FIT_FLOOR = 0.2;
56
56
  /** fit() never blows above this (nearly-empty boards). */
57
57
  export const BOARD_FIT_CAP = 1.25;
58
58
  /** Padding around the bbox used by fit(), world px. */
@@ -87,6 +87,26 @@ export function quantizeBoardStep(base: number, ratio: number): number {
87
87
  return boardClampK(base * boardKOf(level));
88
88
  }
89
89
 
90
+ /**
91
+ * Quantize a fixed-step zoom target (the minimap ± button: linear ±5% on
92
+ * a geometric ladder) into a rung, in the step's DIRECTION. Nearest-rung
93
+ * rounding alone can collapse onto the rung the camera already sits on —
94
+ * from the last reachable rung below the cap, a +5% percent target rounds
95
+ * straight back to it and the press does nothing. A target that resolves
96
+ * to the current rung is therefore pushed ONE rung further in the press
97
+ * direction (clamped to the global bounds); targets that already resolve
98
+ * elsewhere are untouched.
99
+ */
100
+ export function boardStepRung(currentK: number, targetK: number): number {
101
+ const clamped = boardClampK(targetK);
102
+ const rung = quantizeBoardK(clamped);
103
+ const currentLevel = Math.round(boardLevelOf(boardClampK(currentK)));
104
+ const rungLevel = Math.round(boardLevelOf(rung));
105
+ if (rungLevel !== currentLevel) return rung;
106
+ const dir = clamped >= currentK ? 1 : -1;
107
+ return boardClampK(boardKOf(currentLevel + dir));
108
+ }
109
+
90
110
  export function boardToScreen(cam: BoardCamera, wx: number, wy: number): BoardPoint {
91
111
  return { x: wx * cam.k + cam.x, y: wy * cam.k + cam.y };
92
112
  }
@@ -136,19 +156,20 @@ export function boardClampPan(
136
156
 
137
157
  /**
138
158
  * Fit the whole content bbox into the viewport (centered, padded). Wide
139
- * boards shrink fully into view; tiny boards never blow up past the cap.
159
+ * boards shrink as far as needed to show everything fit is allowed
160
+ * below the interaction minimum (the ladder is for gestures, not for
161
+ * the initial framing); tiny boards never blow up past the cap.
140
162
  */
141
163
  export function boardFit(
142
164
  content: BoardRect,
143
165
  viewport: BoardViewport,
144
- opts: { pad?: number; floor?: number; cap?: number } = {},
166
+ opts: { pad?: number; cap?: number } = {},
145
167
  ): BoardCamera {
146
168
  const pad = opts.pad ?? BOARD_FIT_PAD;
147
- const floor = opts.floor ?? BOARD_FIT_FLOOR;
148
169
  const cap = opts.cap ?? BOARD_FIT_CAP;
149
170
  const kw = content.w > 0 ? (viewport.w - pad * 2) / content.w : cap;
150
171
  const kh = content.h > 0 ? (viewport.h - pad * 2) / content.h : cap;
151
- const k = Math.min(cap, Math.max(floor, Math.min(kw, kh)));
172
+ const k = Math.min(cap, Math.min(kw, kh));
152
173
  return {
153
174
  k,
154
175
  x: (viewport.w - content.w * k) / 2 - content.x * k,
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  boardAnchor,
5
5
  boardEdgePath,
6
+ boardFanOrdinals,
6
7
  boardViaPath,
7
8
  type BoardNodeRect,
8
9
  } from "./boardEdges";
@@ -39,6 +40,39 @@ describe("boardEdges — anchor modes", () => {
39
40
  });
40
41
  });
41
42
 
43
+ describe("boardEdges — fan ordinals", () => {
44
+ it("gives a single edge the full fan (count = 1, no spread)", () => {
45
+ const ord = boardFanOrdinals([{ id: "e1", from: "a" }]);
46
+ expect(ord.get("e1")).toEqual({ index: 0, count: 1 });
47
+ });
48
+
49
+ it("spreads three edges from one node as {0,3} {1,3} {2,3}", () => {
50
+ const ord = boardFanOrdinals([
51
+ { id: "e1", from: "a" },
52
+ { id: "e2", from: "a" },
53
+ { id: "e3", from: "a" },
54
+ ]);
55
+ expect(ord.get("e1")).toEqual({ index: 0, count: 3 });
56
+ expect(ord.get("e2")).toEqual({ index: 1, count: 3 });
57
+ expect(ord.get("e3")).toEqual({ index: 2, count: 3 });
58
+ });
59
+
60
+ it("counts each source node independently", () => {
61
+ const ord = boardFanOrdinals([
62
+ { id: "e1", from: "a" },
63
+ { id: "e2", from: "b" },
64
+ { id: "e3", from: "a" },
65
+ ]);
66
+ expect(ord.get("e1")).toEqual({ index: 0, count: 2 });
67
+ expect(ord.get("e2")).toEqual({ index: 0, count: 1 });
68
+ expect(ord.get("e3")).toEqual({ index: 1, count: 2 });
69
+ });
70
+
71
+ it("returns an empty map for empty input", () => {
72
+ expect(boardFanOrdinals([]).size).toBe(0);
73
+ });
74
+ });
75
+
42
76
  describe("boardEdges — route styles", () => {
43
77
  const a = { x: 220, y: 130 };
44
78
  const b = { x: 400, y: 260 };
@@ -35,6 +35,29 @@ export interface BoardPoint {
35
35
  y: number;
36
36
  }
37
37
 
38
+ /**
39
+ * Fan ordinals for a set of edges: every edge sharing a `from` node gets
40
+ * its `{ index, count }` slot in the 天女散花 spread across that node's
41
+ * exit border. Two passes — totals first, then positions — so `count` is
42
+ * the FINAL total per node (a running count would feed the first edges
43
+ * count = 1 and collapse the whole fan onto one anchor point). Edges from
44
+ * different nodes are counted independently.
45
+ */
46
+ export function boardFanOrdinals(
47
+ edges: ReadonlyArray<{ id: string; from: string }>,
48
+ ): Map<string, { index: number; count: number }> {
49
+ const totals = new Map<string, number>();
50
+ for (const e of edges) totals.set(e.from, (totals.get(e.from) ?? 0) + 1);
51
+ const seen = new Map<string, number>();
52
+ const ordinals = new Map<string, { index: number; count: number }>();
53
+ for (const e of edges) {
54
+ const index = seen.get(e.from) ?? 0;
55
+ seen.set(e.from, index + 1);
56
+ ordinals.set(e.id, { index, count: totals.get(e.from)! });
57
+ }
58
+ return ordinals;
59
+ }
60
+
38
61
  export interface BoardNodeRect {
39
62
  x: number;
40
63
  y: number;