@celestia-island/hikari 0.34.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.34.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",
@@ -0,0 +1,83 @@
1
+ // HkBoard — the shared node-and-edge board surface.
2
+ // Geometry semantics live in utils/boardCamera.ts + utils/boardEdges.ts;
3
+ // this file only styles the layers (grid, world, edges, node shells,
4
+ // minimap dock).
5
+
6
+ .hk-board {
7
+ position: relative;
8
+ width: 100%;
9
+ height: 100%;
10
+ min-height: 0;
11
+ overflow: hidden;
12
+ background: rgb(var(--color-surface));
13
+ // gestures own the surface: no native scroll/selection interference
14
+ touch-action: none;
15
+ user-select: none;
16
+ }
17
+
18
+ .hk-board-grid {
19
+ position: absolute;
20
+ inset: 0;
21
+ pointer-events: none;
22
+ }
23
+
24
+ .hk-board-world {
25
+ position: absolute;
26
+ top: 0;
27
+ left: 0;
28
+ transform-origin: 0 0;
29
+ will-change: transform;
30
+ }
31
+
32
+ .hk-board-edges {
33
+ position: absolute;
34
+ top: 0;
35
+ left: 0;
36
+ overflow: visible;
37
+ pointer-events: none;
38
+ }
39
+
40
+ .hk-board-edge {
41
+ fill: none;
42
+ stroke-linecap: round;
43
+ }
44
+
45
+ .hk-board-node {
46
+ position: absolute;
47
+ box-sizing: border-box;
48
+
49
+ &--draggable {
50
+ cursor: grab;
51
+
52
+ &:active {
53
+ cursor: grabbing;
54
+ }
55
+ }
56
+ }
57
+
58
+ .hk-board-node-shell {
59
+ width: 100%;
60
+ height: 100%;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ padding: 0 var(--space-6);
65
+ box-sizing: border-box;
66
+ overflow: hidden;
67
+ text-overflow: ellipsis;
68
+ white-space: nowrap;
69
+ font-size: var(--text-2xs);
70
+ color: rgb(var(--color-text));
71
+ background: rgb(var(--color-surface));
72
+ border: 1px solid rgb(var(--color-primary) / 45%);
73
+ border-radius: var(--radius-sm);
74
+ box-shadow: var(--shadow-elevated);
75
+ }
76
+
77
+ .hk-board-minimap {
78
+ position: absolute;
79
+ right: var(--space-10);
80
+ bottom: var(--space-10);
81
+ z-index: 6;
82
+ pointer-events: auto;
83
+ }
@@ -0,0 +1,465 @@
1
+ /**
2
+ * HkBoard — the shared node-and-edge board surface.
3
+ *
4
+ * ONE world-camera canvas that every graph-like page builds on (history
5
+ * topic maps, SCADA scenes, PLC panels, and whatever comes next). The
6
+ * board owns the motion feel and the chrome; the page owns the layout:
7
+ *
8
+ * - CAMERA: 5% geometric zoom ladder with an ANIMATED settle (no snapping),
9
+ * wheel zoom anchored at the pointer (desktop), two-finger pinch
10
+ * (touch, continuous while pinching → animated rung on release),
11
+ * clamped background pan, fit/reset;
12
+ * - GRID: the engineering dot grid is always on and world-anchored —
13
+ * it scales and translates with the camera so it doubles as an
14
+ * alignment aid;
15
+ * - NODES: DOM shells positioned in world coords, rendered through the
16
+ * `node` scoped slot (a labeled shell is provided when the slot is
17
+ * omitted). `draggable` nodes move in world coords and emit
18
+ * `node-move`; `hidden` nodes paint NOTHING but still route edges —
19
+ * that is how a page bakes a right-angle corner into its graph;
20
+ * - EDGES: SVG paths with independently configurable anchor modes
21
+ * (center / nearest / top / top-left / fan-天女散花) and route styles
22
+ * (straight / bezier / orthogonal / spine), optionally routed through
23
+ * `via` junction nodes (see utils/boardEdges.ts);
24
+ * - MINIMAP: HkMinimap pinned bottom-right, wired to the same camera.
25
+ *
26
+ * The page supplies `nodes` (world rects, reactive) and `edges`; node
27
+ * LAYOUT stays the page's business — the board never forces an
28
+ * arrangement, it only standardizes how things connect and move.
29
+ */
30
+
31
+ import {
32
+ computed,
33
+ defineComponent,
34
+ onBeforeUnmount,
35
+ onMounted,
36
+ ref,
37
+ toRef,
38
+ watch,
39
+ type PropType,
40
+ type Ref,
41
+ } from "vue";
42
+
43
+ import { useBoardCamera } from "../composables/useBoardCamera";
44
+ import HkMinimap, { type MinimapBox } from "./HkMinimap";
45
+ import {
46
+ boardBBox,
47
+ boardStepRung,
48
+ type BoardCamera,
49
+ type BoardPoint,
50
+ type BoardRect,
51
+ type BoardViewport,
52
+ } from "../utils/boardCamera";
53
+ import {
54
+ boardAnchor,
55
+ boardEdgePath,
56
+ boardFanOrdinals,
57
+ boardViaPath,
58
+ type BoardAnchorMode,
59
+ type BoardEdgeStyle,
60
+ } from "../utils/boardEdges";
61
+ import "./HkBoard.scss";
62
+
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
+ */
69
+ export interface BoardNodeInput {
70
+ id: string;
71
+ x: number;
72
+ y: number;
73
+ w: number;
74
+ h: number;
75
+ label?: string;
76
+ kind?: string;
77
+ /** Invisible junction node — routes edges, paints nothing. */
78
+ hidden?: boolean;
79
+ /** Opt in to pointer dragging (world-coord moves + `node-move` events). */
80
+ draggable?: boolean;
81
+ /** Page payload, handed back through the scoped slot. */
82
+ data?: Record<string, unknown>;
83
+ }
84
+
85
+ export interface BoardEdgeInput {
86
+ id: string;
87
+ from: string;
88
+ to: string;
89
+ /** Junction node ids the edge routes through (their centers). */
90
+ via?: string[];
91
+ style?: BoardEdgeStyle;
92
+ anchorFrom?: BoardAnchorMode;
93
+ anchorTo?: BoardAnchorMode;
94
+ ink?: string;
95
+ dashed?: boolean;
96
+ width?: number;
97
+ }
98
+
99
+ const DEFAULT_INK = "rgb(var(--color-primary) / 38%)";
100
+
101
+ export default defineComponent({
102
+ name: "HkBoard",
103
+ props: {
104
+ nodes: { type: Array as PropType<BoardNodeInput[]>, default: () => [] },
105
+ edges: { type: Array as PropType<BoardEdgeInput[]>, default: () => [] },
106
+ /** Wire wheel / pinch / pan gestures (node clicks always work). */
107
+ interactive: { type: Boolean, default: true },
108
+ grid: { type: Boolean, default: true },
109
+ gridSize: { type: Number, default: 24 },
110
+ minimap: { type: Boolean, default: true },
111
+ minK: { type: Number, default: 0.2 },
112
+ maxK: { type: Number, default: 4 },
113
+ animated: { type: Boolean, default: true },
114
+ fitOnMount: { type: Boolean, default: true },
115
+ },
116
+ emits: {
117
+ nodeClick: (_node: BoardNodeInput) => true,
118
+ nodeMove: (_node: BoardNodeInput, _x: number, _y: number) => true,
119
+ viewportChange: (_cam: BoardCamera) => true,
120
+ },
121
+ setup(props, { emit, expose, slots }) {
122
+ const viewportRef = ref<HTMLElement | null>(null);
123
+ const viewportSize = ref<BoardViewport>({ w: 800, h: 600 });
124
+
125
+ /** Content bbox (world) — nodes plus a small margin. */
126
+ const content = computed<BoardRect>(() => {
127
+ const box = boardBBox(props.nodes.map((n) => ({ x: n.x, y: n.y, w: n.w, h: n.h })));
128
+ return { x: box.x - 20, y: box.y - 20, w: box.w + 40, h: box.h + 40 };
129
+ });
130
+
131
+ const cam: ReturnType<typeof useBoardCamera> = useBoardCamera({
132
+ viewportSize,
133
+ content,
134
+ minK: props.minK,
135
+ maxK: props.maxK,
136
+ animated: props.animated,
137
+ });
138
+ const camera = cam.camera as Ref<BoardCamera>;
139
+
140
+ // ── geometry ────────────────────────────────────────────────────────
141
+ const rectOf = computed(() => {
142
+ const map = new Map<string, BoardRect>();
143
+ for (const n of props.nodes) map.set(n.id, { x: n.x, y: n.y, w: n.w, h: n.h });
144
+ return map;
145
+ });
146
+
147
+ /** Edges sharing a `from` node fan across its border (天女散花). */
148
+ const fanOrdinal = computed(() => boardFanOrdinals(props.edges));
149
+
150
+ const edgePaths = computed(() => {
151
+ const rects = rectOf.value;
152
+ const out: { id: string; d: string; ink: string; dashed: boolean; width: number }[] = [];
153
+ for (const e of props.edges) {
154
+ const from = rects.get(e.from);
155
+ const to = rects.get(e.to);
156
+ if (!from || !to) continue;
157
+ const toCenter = { x: to.x + to.w / 2, y: to.y + to.h / 2 };
158
+ const fromCenter = { x: from.x + from.w / 2, y: from.y + from.h / 2 };
159
+ const fan = fanOrdinal.value.get(e.id) ?? { index: 0, count: 1 };
160
+ const a = boardAnchor(from, e.anchorFrom ?? "center", toCenter, fan.index, fan.count);
161
+ const b = boardAnchor(to, e.anchorTo ?? "center", fromCenter, fan.index, fan.count);
162
+ const viaCenters = (e.via ?? [])
163
+ .map((id) => rects.get(id))
164
+ .filter((r): r is BoardRect => Boolean(r))
165
+ .map((r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }));
166
+ out.push({
167
+ id: e.id,
168
+ d: boardViaPath(a, viaCenters, b, e.style ?? "orthogonal"),
169
+ ink: e.ink ?? DEFAULT_INK,
170
+ dashed: Boolean(e.dashed),
171
+ width: e.width ?? 1.5,
172
+ });
173
+ }
174
+ return out;
175
+ });
176
+
177
+ const contentBounds = computed(() => content.value);
178
+
179
+ const worldStyle = computed(() => ({
180
+ transform: `translate(${camera.value.x}px, ${camera.value.y}px) scale(${camera.value.k})`,
181
+ width: `${contentBounds.value.w}px`,
182
+ height: `${contentBounds.value.h}px`,
183
+ }));
184
+
185
+ const gridStyle = computed(() => {
186
+ if (!props.grid) return { display: "none" };
187
+ const s = props.gridSize * camera.value.k;
188
+ return {
189
+ backgroundImage: "radial-gradient(rgb(var(--color-text) / 12%) 1px, transparent 1px)",
190
+ backgroundSize: `${s}px ${s}px`,
191
+ backgroundPosition: `${camera.value.x}px ${camera.value.y}px`,
192
+ };
193
+ });
194
+
195
+ const minimapBoxes = computed<MinimapBox[]>(() =>
196
+ props.nodes
197
+ .filter((n) => !n.hidden)
198
+ .map((n) => ({
199
+ id: n.id,
200
+ bounds: { x: n.x, y: n.y, w: n.w, h: n.h },
201
+ color: "rgb(var(--color-primary) / 40%)",
202
+ })),
203
+ );
204
+
205
+ // ── gestures ────────────────────────────────────────────────────────
206
+ const pointers = new Map<number, { x: number; y: number }>();
207
+ let panState: { px: number; py: number } | null = null;
208
+ let pinchBase: { cam: BoardCamera; dist: number; mid: BoardPoint } | null = null;
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;
213
+
214
+ 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) };
217
+ };
218
+
219
+ function onWheel(e: WheelEvent): void {
220
+ if (!props.interactive) return;
221
+ e.preventDefault();
222
+ const factor = e.deltaY < 0 ? 1.05 : 1 / 1.05;
223
+ cam.zoomByFactor(factor, localPoint(e));
224
+ }
225
+
226
+ function refreshSize(): void {
227
+ const el = viewportRef.value;
228
+ if (!el) return;
229
+ viewportSize.value = { w: el.clientWidth, h: el.clientHeight };
230
+ }
231
+
232
+ let resizeObs: ResizeObserver | null = null;
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
+
251
+ function onPointerDown(e: PointerEvent): void {
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();
257
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
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);
261
+ if (pointers.size === 1) {
262
+ panState = { px: e.clientX, py: e.clientY };
263
+ } else if (pointers.size === 2) {
264
+ // pinch begins: snapshot camera + finger geometry, suspend panning
265
+ rearmPinch();
266
+ panState = null;
267
+ }
268
+ }
269
+
270
+ function onPointerMove(e: PointerEvent): void {
271
+ if (!props.interactive) return;
272
+ if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
273
+ if (pinchBase && pointers.size >= 2) {
274
+ const [a, b] = [...pointers.values()];
275
+ 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
+ };
281
+ pinchBase.mid = mid;
282
+ cam.pinchZoom(pinchBase.cam, dist / pinchBase.dist, mid);
283
+ return;
284
+ }
285
+ if (dragState) {
286
+ const k = camera.value.k || 1;
287
+ const dx = (e.clientX - dragState.sx) / k;
288
+ const dy = (e.clientY - dragState.sy) / k;
289
+ if (Math.abs(e.clientX - dragState.sx) + Math.abs(e.clientY - dragState.sy) > 3) dragState.moved = true;
290
+ dragState.node.x = Math.round(dragState.ox + dx);
291
+ dragState.node.y = Math.round(dragState.oy + dy);
292
+ emit("nodeMove", dragState.node, dragState.node.x, dragState.node.y);
293
+ return;
294
+ }
295
+ if (panState) {
296
+ cam.panBy(e.clientX - panState.px, e.clientY - panState.py);
297
+ panState = { px: e.clientX, py: e.clientY };
298
+ }
299
+ }
300
+
301
+ function onPointerUp(e: PointerEvent): void {
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;
335
+ pinchBase = null;
336
+ cam.settlePinch(mid);
337
+ }
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;
350
+ dragState = null;
351
+ retirePointer(e.pointerId);
352
+ }
353
+
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.
358
+ if (!props.interactive || !node.draggable) return;
359
+ e.stopPropagation();
360
+ dragState = { node, sx: e.clientX, sy: e.clientY, ox: node.x, oy: node.y, moved: false };
361
+ viewportRef.value?.setPointerCapture(e.pointerId);
362
+ }
363
+
364
+ watch(() => camera.value, (c) => emit("viewportChange", { ...c }), { deep: true });
365
+ watch(toRef(props, "nodes"), () => {
366
+ // content growth invalidates clamping — re-clamp against the new bbox
367
+ cam.setCamera({ ...camera.value });
368
+ });
369
+
370
+ onMounted(() => {
371
+ refreshSize();
372
+ resizeObs = new ResizeObserver(refreshSize);
373
+ if (viewportRef.value) resizeObs.observe(viewportRef.value);
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);
378
+ if (props.fitOnMount) cam.fit();
379
+ });
380
+
381
+ onBeforeUnmount(() => {
382
+ cam.stop(); // kill any in-flight tween rAF
383
+ resizeObs?.disconnect();
384
+ viewportRef.value?.removeEventListener("wheel", onWheel);
385
+ viewportRef.value?.removeEventListener("lostpointercapture", onLostPointerCapture, true);
386
+ });
387
+
388
+ expose({
389
+ fit: () => cam.fit(),
390
+ zoomIn: () => cam.zoomIn(),
391
+ zoomOut: () => cam.zoomOut(),
392
+ zoomToPercent: (p: number, anchor?: BoardPoint) => cam.zoomToPercent(p, anchor),
393
+ reset: () => cam.fit(),
394
+ camera,
395
+ });
396
+
397
+ return () => (
398
+ <div
399
+ class="hk-board"
400
+ ref={viewportRef}
401
+ onPointerdown={onPointerDown}
402
+ onPointermove={onPointerMove}
403
+ onPointerup={onPointerUp}
404
+ onPointercancel={onPointerCancel}
405
+ >
406
+ <div class="hk-board-grid" style={gridStyle.value} />
407
+ <div class="hk-board-world" style={worldStyle.value}>
408
+ <svg class="hk-board-edges" width={contentBounds.value.w} height={contentBounds.value.h}>
409
+ {edgePaths.value.map((e) => (
410
+ <path
411
+ key={e.id}
412
+ class="hk-board-edge"
413
+ d={e.d}
414
+ stroke={e.ink}
415
+ stroke-width={e.width}
416
+ stroke-dasharray={e.dashed ? "6 5" : undefined}
417
+ fill="none"
418
+ stroke-linecap="round"
419
+ />
420
+ ))}
421
+ </svg>
422
+ {props.nodes.filter((n) => !n.hidden).map((n) => (
423
+ <div
424
+ key={n.id}
425
+ class={[
426
+ "hk-board-node",
427
+ { "hk-board-node--draggable": Boolean(n.draggable) },
428
+ n.kind ? `hk-board-node--${n.kind}` : undefined,
429
+ ]}
430
+ style={{ left: `${n.x}px`, top: `${n.y}px`, width: `${n.w}px`, height: `${n.h}px` }}
431
+ onPointerdown={(e: PointerEvent) => onNodePointerDown(e, n)}
432
+ >
433
+ {slots.node
434
+ ? slots.node({ node: n })
435
+ : <div class="hk-board-node-shell">{n.label ?? ""}</div>}
436
+ </div>
437
+ ))}
438
+ </div>
439
+ {props.minimap && (
440
+ <div class="hk-board-minimap">
441
+ <HkMinimap
442
+ boxes={minimapBoxes.value}
443
+ zoom={camera.value.k}
444
+ panX={camera.value.x}
445
+ panY={camera.value.y}
446
+ viewportWidth={viewportSize.value.w}
447
+ viewportHeight={viewportSize.value.h}
448
+ contentBounds={contentBounds.value}
449
+ zoomPercent={Math.round(camera.value.k * 100)}
450
+ canZoomIn={camera.value.k < props.maxK}
451
+ canZoomOut={camera.value.k > props.minK}
452
+ zoomStepPercent={5}
453
+ minZoomPercent={Math.round(props.minK * 100)}
454
+ maxZoomPercent={Math.round(props.maxK * 100)}
455
+ showReset
456
+ onZoomTo={(percent: number) => cam.zoomToK(boardStepRung(camera.value.k, percent / 100))}
457
+ onReset={() => cam.fit()}
458
+ onPanDelta={(dx: number, dy: number) => cam.panBy(dx, dy)}
459
+ />
460
+ </div>
461
+ )}
462
+ </div>
463
+ );
464
+ },
465
+ });
@@ -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);