@celestia-island/hikari 0.35.0 → 0.35.1

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.35.1",
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",
@@ -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
  }
@@ -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;