@celestia-island/hikari 0.55.103 → 0.55.105

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.55.103",
3
+ "version": "0.55.105",
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",
@@ -109,6 +109,20 @@ describe("hikari barrel — 3D board surface", () => {
109
109
  expect(has).toBe(true);
110
110
  });
111
111
 
112
+ it("pins the engine's selection surface", () => {
113
+ // `setSelection` is how a page keeps the 8-corner frame on its
114
+ // selected object (the fleet sky's cube cursor). Type-level probe:
115
+ // vue-tsc covers this file, so dropping the method breaks the gate
116
+ // rather than the consumer's build.
117
+ const engine = null as Engine | null;
118
+ void engine;
119
+ type HasSelection = Engine extends { setSelection(id: string | null): void }
120
+ ? true
121
+ : false;
122
+ const has: HasSelection = true;
123
+ expect(has).toBe(true);
124
+ });
125
+
112
126
  it("exports the minimap governance constants, drag sensitivity included", () => {
113
127
  // A consumer writing its own drag control needs the same sensitivity
114
128
  // the built-in one uses — it must not stay module-private.
@@ -45,6 +45,13 @@
45
45
  }
46
46
 
47
47
  .hk-board3d-fallback {
48
+ // Self-carried contrast chip: in transparent-backdrop mode the root
49
+ // paints nothing, so the note cannot lean on the old surface fill.
50
+ background: rgb(var(--color-surface) / 0.9);
51
+ border-radius: var(--radius-md, 8px);
52
+ padding: 6px 12px;
53
+ margin: auto;
54
+ width: fit-content;
48
55
  position: absolute;
49
56
  inset: 0;
50
57
  display: flex;
@@ -15,20 +15,28 @@ const H = vi.hoisted(() => {
15
15
  renders: Array<{ scene: unknown; camera: unknown }> = [];
16
16
  sizes: Array<[number, number]> = [];
17
17
  clearColor: unknown = null;
18
+ clearAlpha: number | null = null;
19
+ /** The drawing buffer's alpha channel — only present when the board
20
+ * asks for a transparent backdrop (`alpha: true`). */
21
+ alpha: boolean;
18
22
  toneMapping: unknown = null;
19
23
  shadowMap = { enabled: false };
20
24
  disposed = false;
21
- constructor(opts: { canvas: HTMLCanvasElement }) {
25
+ constructor(opts: { canvas: HTMLCanvasElement; alpha?: boolean }) {
22
26
  if (FakeWebGLRenderer.failNext) {
23
27
  FakeWebGLRenderer.failNext = false;
24
28
  throw new Error("no webgl (test)");
25
29
  }
26
30
  this.domElement = opts.canvas;
31
+ this.alpha = opts.alpha === true;
27
32
  FakeWebGLRenderer.instances.push(this);
28
33
  }
29
34
  setPixelRatio(): void {}
30
35
  setSize(w: number, h: number): void { this.sizes.push([w, h]); }
31
- setClearColor(c: unknown): void { this.clearColor = c; }
36
+ setClearColor(c: unknown, alpha = 1): void {
37
+ this.clearColor = c;
38
+ this.clearAlpha = alpha;
39
+ }
32
40
  render(scene: unknown, camera: unknown): void {
33
41
  // A real renderer maintains world matrices every pass; picking and
34
42
  // projection both read them.
@@ -105,6 +113,25 @@ async function tickFrames(): Promise<void> {
105
113
  await new Promise((r) => setTimeout(r, 50));
106
114
  }
107
115
 
116
+ /** Both gizmo frames — hover, then selection — are the only scene
117
+ * children on the main-camera-only layer, and init() creates them in
118
+ * that order, so the index is a stable discriminator. */
119
+ function framesOf(engine: Board3DEngine): THREE.LineSegments[] {
120
+ const found = engine.scene.children.filter((c) => c.layers.isEnabled(BOARD3D_MAIN_LAYER));
121
+ expect(found, "both gizmo frames in the scene").toHaveLength(2);
122
+ return found as THREE.LineSegments[];
123
+ }
124
+
125
+ /** The hover frame (created first in init). */
126
+ function frameOf(engine: Board3DEngine): THREE.LineSegments {
127
+ return framesOf(engine)[0]!;
128
+ }
129
+
130
+ /** The selection frame (created second in init). */
131
+ function selectionOf(engine: Board3DEngine): THREE.LineSegments {
132
+ return framesOf(engine)[1]!;
133
+ }
134
+
108
135
  beforeEach(() => {
109
136
  H.FakeWebGLRenderer.instances = [];
110
137
  H.FakeWebGLRenderer.failNext = false;
@@ -689,13 +716,6 @@ describe("HkBoard3D", () => {
689
716
  });
690
717
 
691
718
  describe("HkBoard3D hover frame", () => {
692
- /** The frame is the only scene child on the main-camera-only layer. */
693
- function frameOf(engine: Board3DEngine): THREE.LineSegments {
694
- const found = engine.scene.children.find((c) => c.layers.isEnabled(BOARD3D_MAIN_LAYER));
695
- expect(found, "hover frame in the scene").toBeDefined();
696
- return found as THREE.LineSegments;
697
- }
698
-
699
719
  function canvasOf(): HTMLCanvasElement {
700
720
  const canvas = mounts[0].container.querySelector("canvas")!;
701
721
  canvas.getBoundingClientRect = () =>
@@ -1063,3 +1083,318 @@ describe("HkBoard3D hover frame", () => {
1063
1083
  expect(frameOf(engine).visible).toBe(false);
1064
1084
  });
1065
1085
  });
1086
+
1087
+ describe("HkBoard3D selection frame", () => {
1088
+ /** A board with one body registered and the id selected — NO pointer
1089
+ * involvement anywhere: the selection frame must not need one. */
1090
+ async function selectedBoard(props: Record<string, unknown> = {}) {
1091
+ const hovers: Array<string | null> = [];
1092
+ let engine: Board3DEngine | null = null;
1093
+ mountBoard({ minimap: false, ...props }, {
1094
+ ready: ((e: Board3DEngine) => { engine = e; }) as never,
1095
+ objectHover: ((id: string | null) => hovers.push(id)) as never,
1096
+ });
1097
+ await nextTick();
1098
+ const body = new THREE.Mesh(
1099
+ new THREE.SphereGeometry(2, 16, 12),
1100
+ new THREE.MeshBasicMaterial(),
1101
+ );
1102
+ body.name = "body";
1103
+ engine!.setObject("star-1", { object: body });
1104
+ engine!.setSelection("star-1");
1105
+ await tickFrames();
1106
+ return { engine: engine!, body, hovers };
1107
+ }
1108
+
1109
+ it("pins the persistent corner frame without any pointer", async () => {
1110
+ const { engine, body } = await selectedBoard();
1111
+ const selection = selectionOf(engine);
1112
+ expect(selection.visible).toBe(true);
1113
+ // Same measurement contract as the hover frame: the body's own
1114
+ // extents plus the padding, composed through its world matrix.
1115
+ expect(selection.matrix.elements[0]).toBeCloseTo(4 * 1.08, 4);
1116
+ expect(selection.matrix.elements[13]).toBeCloseTo(0, 5);
1117
+ // The pointer frame has nothing to say here — nothing is hovered.
1118
+ expect(frameOf(engine).visible).toBe(false);
1119
+ // The body itself is untouched, and the gizmo never joins the
1120
+ // registry (framing and picking never see it).
1121
+ expect(body.scale.x).toBe(1);
1122
+ expect([...engine.objects().keys()]).toEqual(["star-1"]);
1123
+
1124
+ // A selected object re-registered as UN-pickable stays marked: the
1125
+ // frame marks what the page selected, and pointer reachability is a
1126
+ // hover concern (decision pinned — R1).
1127
+ engine.setObject("star-1", { object: body, pickable: false });
1128
+ await tickFrames();
1129
+ expect(selectionOf(engine).visible).toBe(true);
1130
+ });
1131
+
1132
+ it("re-selecting the same id does not re-measure", async () => {
1133
+ const { engine, body } = await selectedBoard();
1134
+ // Pages re-select on data refreshes; each redundant call must be a
1135
+ // no-op, not a forced subtree walk (the hover path guards the same
1136
+ // way at the event level — R2 P2). The measurement walk is the
1137
+ // (updateParents=true, updateChildren=true) call; the per-frame
1138
+ // refresh uses (true, false) and stays.
1139
+ const walk = vi.spyOn(body, "updateWorldMatrix");
1140
+ for (let i = 0; i < 5; i += 1) engine.setSelection("star-1");
1141
+ await tickFrames();
1142
+ const measured = walk.mock.calls.filter(
1143
+ ([parents, children]) => parents === true && children === true,
1144
+ ).length;
1145
+ expect(measured).toBe(0);
1146
+ expect(selectionOf(engine).visible).toBe(true);
1147
+ });
1148
+
1149
+ it("only ever ends through setSelection(null)", async () => {
1150
+ const { engine } = await selectedBoard();
1151
+ expect(selectionOf(engine).visible).toBe(true);
1152
+
1153
+ // Frames pass — the selection is sticky, not a hover that decays.
1154
+ await tickFrames();
1155
+ await tickFrames();
1156
+ expect(selectionOf(engine).visible).toBe(true);
1157
+
1158
+ engine.setSelection(null);
1159
+ await tickFrames();
1160
+ expect(selectionOf(engine).visible).toBe(false);
1161
+ });
1162
+
1163
+ it("survives a rebuild: the removed id hides, re-registration re-measures", async () => {
1164
+ const { engine } = await selectedBoard();
1165
+ expect(selectionOf(engine).visible).toBe(true);
1166
+
1167
+ // A roster rebuild strips every body first — the frame hides while
1168
+ // the id is gone but the SELECTION survives the interim.
1169
+ engine.setObject("star-1", null);
1170
+ await tickFrames();
1171
+ expect(selectionOf(engine).visible).toBe(false);
1172
+
1173
+ // …and the re-registered body (a fresh object at a new spot) gets the
1174
+ // frame measured against IT, not against the dead predecessor.
1175
+ const next = new THREE.Mesh(
1176
+ new THREE.SphereGeometry(1, 12, 8),
1177
+ new THREE.MeshBasicMaterial(),
1178
+ );
1179
+ next.position.set(30, 0, 0);
1180
+ engine.setObject("star-1", { object: next });
1181
+ await tickFrames();
1182
+ expect(selectionOf(engine).visible).toBe(true);
1183
+ expect(selectionOf(engine).matrix.elements[12]).toBeCloseTo(30, 4);
1184
+ expect(selectionOf(engine).matrix.elements[0]).toBeCloseTo(2 * 1.08, 4);
1185
+ });
1186
+
1187
+ it("re-measures when a rebuild hands the selected id a NEW object directly", async () => {
1188
+ const { engine } = await selectedBoard();
1189
+ expect(selectionOf(engine).matrix.elements[12]).toBeCloseTo(0, 5);
1190
+
1191
+ // The ONE-step rebuild (strip + re-register fused into a single
1192
+ // setObject): the latch must release here too, or the frame keeps
1193
+ // riding the dead predecessor's matrix — the two-step test above
1194
+ // cannot see this, the hide path resets the latch for it (R1 P1).
1195
+ const next = new THREE.Mesh(
1196
+ new THREE.SphereGeometry(1, 12, 8),
1197
+ new THREE.MeshBasicMaterial(),
1198
+ );
1199
+ next.position.set(30, 0, 0);
1200
+ engine.setObject("star-1", { object: next });
1201
+ await tickFrames();
1202
+ expect(selectionOf(engine).visible).toBe(true);
1203
+ expect(selectionOf(engine).matrix.elements[12]).toBeCloseTo(30, 4);
1204
+ expect(selectionOf(engine).matrix.elements[0]).toBeCloseTo(2 * 1.08, 4);
1205
+ });
1206
+
1207
+ it("suppresses the hover frame on the selected body only", async () => {
1208
+ const { engine, hovers } = await selectedBoard();
1209
+ const canvas = mounts[0]!.container.querySelector("canvas")!;
1210
+ canvas.getBoundingClientRect = () =>
1211
+ ({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 }) as DOMRect;
1212
+ canvas.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 400, clientY: 300 }));
1213
+ await tickFrames();
1214
+ // Hovering the selected body draws ONE bracket, not two.
1215
+ expect(selectionOf(engine).visible).toBe(true);
1216
+ expect(frameOf(engine).visible).toBe(false);
1217
+ // Suppression is frame-only: the page still HEARS the hover (its
1218
+ // hover card must keep working on the selected body) and the cursor
1219
+ // still promises the click (R1 P2).
1220
+ expect(hovers.at(-1)).toBe("star-1");
1221
+ expect(canvas.style.cursor).toBe("pointer");
1222
+
1223
+ // Moving the selection elsewhere un-suppresses the hover: the pointer
1224
+ // is still over star-1, which is no longer the selected body.
1225
+ const other = new THREE.Mesh(
1226
+ new THREE.SphereGeometry(1, 12, 8),
1227
+ new THREE.MeshBasicMaterial(),
1228
+ );
1229
+ other.position.set(500, 500, 500);
1230
+ engine.setObject("star-2", { object: other });
1231
+ engine.setSelection("star-2");
1232
+ await tickFrames();
1233
+ expect(frameOf(engine).visible).toBe(true);
1234
+ expect(selectionOf(engine).visible).toBe(true);
1235
+ expect(selectionOf(engine).matrix.elements[12]).toBeCloseTo(500, 4);
1236
+ });
1237
+
1238
+ it("picks the selection up when the content arrives after the select", async () => {
1239
+ let engine: Board3DEngine | null = null;
1240
+ mountBoard({ minimap: false }, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
1241
+ await nextTick();
1242
+
1243
+ // Content streams in: the registered group's only mesh is still
1244
+ // hidden when the page selects it.
1245
+ const group = new THREE.Group();
1246
+ const mesh = new THREE.Mesh(
1247
+ new THREE.SphereGeometry(1, 12, 8),
1248
+ new THREE.MeshBasicMaterial(),
1249
+ );
1250
+ mesh.visible = false;
1251
+ group.add(mesh);
1252
+ engine!.setObject("stream", { object: group });
1253
+ engine!.setSelection("stream");
1254
+ await tickFrames();
1255
+ expect(selectionOf(engine!).visible).toBe(false);
1256
+
1257
+ mesh.visible = true;
1258
+ await tickFrames();
1259
+ expect(selectionOf(engine!).visible).toBe(true);
1260
+ });
1261
+
1262
+ it("re-measures an unmeasurable selection on a cadence, not every frame", async () => {
1263
+ let engine: Board3DEngine | null = null;
1264
+ // No minimap: its marker sync calls updateWorldMatrix on every
1265
+ // registered object each frame, which would drown the measurement
1266
+ // cadence this test is about.
1267
+ mountBoard({ minimap: false }, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
1268
+ await nextTick();
1269
+
1270
+ const group = new THREE.Group();
1271
+ const decor = new THREE.Sprite(new THREE.SpriteMaterial());
1272
+ group.add(decor);
1273
+ engine!.setObject("empty", { object: group });
1274
+ engine!.setSelection("empty");
1275
+ await tickFrames();
1276
+ expect(selectionOf(engine!).visible).toBe(false);
1277
+
1278
+ let frames = 0;
1279
+ engine!.addTick(() => {
1280
+ frames += 1;
1281
+ });
1282
+ // The measurement is the walk `frameLocalBounds` makes on the anchor.
1283
+ const walk = vi.spyOn(group, "updateWorldMatrix");
1284
+ await tickFrames();
1285
+ expect(frames).toBeGreaterThan(10);
1286
+ expect(walk.mock.calls.length).toBeGreaterThan(0);
1287
+ expect(walk.mock.calls.length).toBeLessThan(frames / 3);
1288
+ });
1289
+
1290
+ it("hides while the body is invisible, and comes back with it", async () => {
1291
+ const { engine, body } = await selectedBoard();
1292
+ expect(selectionOf(engine).visible).toBe(true);
1293
+
1294
+ // three renders by the CHAIN: the frame must not paint the air.
1295
+ body.visible = false;
1296
+ await tickFrames();
1297
+ expect(selectionOf(engine).visible).toBe(false);
1298
+
1299
+ body.visible = true;
1300
+ await tickFrames();
1301
+ expect(selectionOf(engine).visible).toBe(true);
1302
+ });
1303
+
1304
+ it("tints the selection from the primary channel, apart from the hover", async () => {
1305
+ const previous = ["--color-background", "--color-text", "--color-primary"].map((name) => [
1306
+ name,
1307
+ document.documentElement.style.getPropertyValue(name),
1308
+ ] as const);
1309
+ try {
1310
+ // Deliberately NOT the fallback literals: a variable read that fell
1311
+ // through to its default must fail these assertions.
1312
+ document.documentElement.style.setProperty("--color-background", "10 15 25");
1313
+ document.documentElement.style.setProperty("--color-text", "250 10 10");
1314
+ document.documentElement.style.setProperty("--color-primary", "10 10 250");
1315
+ const { engine } = await selectedBoard();
1316
+ const hoverColor = (frameOf(engine).material as THREE.LineBasicMaterial).color;
1317
+ const selectionColor = (selectionOf(engine).material as THREE.LineBasicMaterial).color;
1318
+ expect(selectionColor.r * 255).toBeCloseTo(10, 0);
1319
+ expect(selectionColor.b * 255).toBeCloseTo(250, 0);
1320
+ // The hover frame keeps its own channel (text) — the two stay
1321
+ // readable apart at a glance.
1322
+ expect(hoverColor.r * 255).toBeCloseTo(250, 0);
1323
+ expect(hoverColor.b * 255).toBeCloseTo(10, 0);
1324
+ } finally {
1325
+ for (const [name, value] of previous) {
1326
+ document.documentElement.style.removeProperty(name);
1327
+ if (value) document.documentElement.style.setProperty(name, value);
1328
+ }
1329
+ }
1330
+ });
1331
+
1332
+ it("disposes the selection frame on unmount", async () => {
1333
+ const { engine } = await selectedBoard();
1334
+ expect(selectionOf(engine).visible).toBe(true);
1335
+ const selection = selectionOf(engine);
1336
+ const geoSpy = vi.spyOn(selection.geometry, "dispose");
1337
+ const matSpy = vi.spyOn(selection.material as THREE.Material, "dispose");
1338
+
1339
+ const m = mounts.splice(0)[0];
1340
+ m.app.unmount();
1341
+ m.container.remove();
1342
+
1343
+ expect(geoSpy).toHaveBeenCalled();
1344
+ expect(matSpy).toHaveBeenCalled();
1345
+ expect(engine.scene.children.some((c) => c.layers.isEnabled(BOARD3D_MAIN_LAYER))).toBe(false);
1346
+ });
1347
+ });
1348
+
1349
+ describe("HkBoard3D transparent background", () => {
1350
+ function rendererOf(): InstanceType<typeof H.FakeWebGLRenderer> {
1351
+ // The board's renderer is the first instance (the minimap spins up
1352
+ // its own, and only when mounted).
1353
+ return H.FakeWebGLRenderer.instances[0]!;
1354
+ }
1355
+
1356
+ it("paints the theme background by default", async () => {
1357
+ let engine: Board3DEngine | null = null;
1358
+ mountBoard({ minimap: false }, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
1359
+ await nextTick();
1360
+ expect(engine!.scene.background).toBeInstanceOf(THREE.Color);
1361
+ expect(rendererOf().alpha).toBe(false);
1362
+ expect(rendererOf().clearAlpha).toBe(1);
1363
+ });
1364
+
1365
+ it("clears to transparent with the transparentBackground prop", async () => {
1366
+ let engine: Board3DEngine | null = null;
1367
+ mountBoard(
1368
+ { minimap: false, transparentBackground: true },
1369
+ { ready: ((e: Board3DEngine) => { engine = e; }) as never },
1370
+ );
1371
+ await nextTick();
1372
+ // No scene backdrop: the page's own backdrop shows through. The
1373
+ // drawing buffer must actually HAVE an alpha channel, and the clear
1374
+ // must be fully transparent — an opaque clear would read as black.
1375
+ expect(engine!.scene.background).toBeNull();
1376
+ expect(rendererOf().alpha).toBe(true);
1377
+ expect(rendererOf().clearAlpha).toBe(0);
1378
+ });
1379
+
1380
+ it("drops the container's own surface paint in transparent mode", async () => {
1381
+ const { container } = mountBoard(
1382
+ { minimap: false, transparentBackground: true },
1383
+ {},
1384
+ );
1385
+ await nextTick();
1386
+ const root = container.querySelector(".hk-board3d") as HTMLElement;
1387
+ // The stylesheet paints --color-surface on .hk-board3d; an inline
1388
+ // transparent style must win, or a page-colored rectangle sits
1389
+ // behind the alpha-0 canvas (user report 2026-10-01: the scene
1390
+ // background differs from the page background).
1391
+ expect(root.style.background).toContain("transparent");
1392
+ });
1393
+
1394
+ it("keeps the stylesheet surface paint when the backdrop is opaque", async () => {
1395
+ const { container } = mountBoard({ minimap: false }, {});
1396
+ await nextTick();
1397
+ const root = container.querySelector(".hk-board3d") as HTMLElement;
1398
+ expect(root.style.background).toBe("");
1399
+ });
1400
+ });
@@ -19,7 +19,16 @@
19
19
  * walk up to the registered root via userData);
20
20
  * - HOVER FRAME: the hovered object is wrapped in an 8-corner selection
21
21
  * frame (default ON, see the `hoverBox` prop) — the pointer marks what
22
- * it is aiming at instead of resizing it;
22
+ * it is aiming at instead of resizing it; hovering the SELECTED body
23
+ * shows nothing extra (the selection frame is already there);
24
+ * - SELECTION FRAME: `engine.setSelection(id)` pins the same 8-corner
25
+ * frame on a registered object for as long as the selection lasts —
26
+ * the persistent counterpart of the hover frame, tinted with the
27
+ * theme's primary instead of the text channel (user direction
28
+ * 2026-10-01: the fleet sky marks its selection with the cube cursor,
29
+ * never with a ring). The frame survives the object being removed and
30
+ * re-registered under the same id (a roster rebuild re-measures), and
31
+ * only `setSelection(null)` clears it;
23
32
  * - LABELS: a CSS2DRenderer overlay — HTML chips that track world
24
33
  * anchors, pointer-events opt-in per chip so drags still orbit;
25
34
  * - MINIMAP: HkMinimap3D pinned bottom-right — a second, stereoscopic
@@ -27,7 +36,9 @@
27
36
  * plane and content markers layered in;
28
37
  * - THEME: scene background follows --color-background, re-read on
29
38
  * data-mode / data-theme / style mutations; `engine.onTheme` lets the
30
- * page re-tone its materials on the same signal;
39
+ * page re-tone its materials on the same signal. With the
40
+ * `transparentBackground` prop the canvas clears to transparent
41
+ * instead, so the page's own backdrop shows through the sky;
31
42
  * - FALLBACK: no WebGL → the `fallback` slot (or a localized note) and
32
43
  * an `error` event; nothing throws out of mount.
33
44
  *
@@ -119,6 +130,16 @@ export interface Board3DEngine {
119
130
  /** Post-render hook (the minimap lives here); returns the disposer. */
120
131
  addPostRender(fn: () => void): () => void;
121
132
  setObject(id: string, def: Board3DObjectDef | null): void;
133
+ /** Pin the persistent 8-corner selection frame on the registered
134
+ * object `id` (`null` clears it). The frame is independent of the
135
+ * pointer: it rides the object through orbits and rebuilds, hides
136
+ * while the object is absent or unmeasurable, and re-measures when
137
+ * the id is re-registered. It marks what the page SELECTED — a
138
+ * `pickable: false` object is marked all the same (pointer
139
+ * reachability is a hover concern, not a selection one). Selecting
140
+ * an object also suppresses the HOVER frame on that same object —
141
+ * the selection frame is already marking it. */
142
+ setSelection(id: string | null): void;
122
143
  /** Snapshot of the registered content (the minimap's marker set).
123
144
  * Mutating it does not touch the board — register through
124
145
  * `setObject` instead. */
@@ -291,6 +312,17 @@ function hoverFrameColor(background: THREE.Color): THREE.Color {
291
312
  );
292
313
  }
293
314
 
315
+ /**
316
+ * The selection frame's tint: the theme's PRIMARY channel on both modes.
317
+ * It has to read apart from the hover frame (text/muted, i.e. near-white
318
+ * over a night sky) at a glance — the selection is a statement about the
319
+ * page's data, and primary is the one channel already reserved for
320
+ * exactly that.
321
+ */
322
+ function selectionFrameColor(): THREE.Color {
323
+ return cssRgbToColor(readCssColor("--color-primary", "21 101 192"));
324
+ }
325
+
294
326
  export default defineComponent({
295
327
  name: "HkBoard3D",
296
328
  props: {
@@ -329,13 +361,20 @@ export default defineComponent({
329
361
  /** Wrap the hovered object in the 8-corner selection frame. Defaults
330
362
  * ON: anything the pointer can select gets one, and `frameObject:
331
363
  * null` opts a single object out. Turn it off for boards whose
332
- * content is not "selected" at all. */
364
+ * content is not "selected" at all. Hovering the object pinned by
365
+ * `engine.setSelection` draws no second frame — the selection
366
+ * frame is already there. */
333
367
  hoverBox: { type: Boolean, default: true },
368
+ /** Clear the canvas to transparent instead of painting the theme
369
+ * background, so the page's own backdrop shows through the sky.
370
+ * STATIC after mount (the drawing buffer's alpha channel exists or
371
+ * it does not — remount to change it). */
372
+ transparentBackground: { type: Boolean, default: false },
334
373
  /** Freeze the frame loop (content keeps its last pose). An animated
335
374
  * flight issued while paused lands in a single jump on resume: the
336
- * tween is evaluated against wall-clock time. Hover is frozen with
337
- * it: the frame keeps whatever it had painted until the loop resumes
338
- * (nothing re-renders while paused). */
375
+ * tween is evaluated against wall-clock time. Both pointer/selection
376
+ * frames are frozen with it: each keeps whatever it had painted
377
+ * until the loop resumes (nothing re-renders while paused). */
339
378
  paused: { type: Boolean, default: false },
340
379
  },
341
380
  emits: {
@@ -357,6 +396,7 @@ export default defineComponent({
357
396
  let camera: THREE.PerspectiveCamera | null = null;
358
397
  let controls: OrbitControls | null = null;
359
398
  let hoverFrame: HoverBoxHandle | null = null;
399
+ let selectionFrame: HoverBoxHandle | null = null;
360
400
  let resizeObs: ResizeObserver | null = null;
361
401
  let themeObs: MutationObserver | null = null;
362
402
  let rafId = 0;
@@ -384,6 +424,8 @@ export default defineComponent({
384
424
  const ndc = new THREE.Vector2();
385
425
  let hoverDirty = false;
386
426
  let lastHover: string | null = null;
427
+ /** The id pinned by `engine.setSelection` (null = no selection). */
428
+ let selectionId: string | null = null;
387
429
  let pressAt: { pointerId: number; x: number; y: number } | null = null;
388
430
 
389
431
  // ── camera tween ──────────────────────────────────────────────────
@@ -400,9 +442,14 @@ export default defineComponent({
400
442
  const bgRaw = readCssColor("--color-background", "10 15 25");
401
443
  const primaryRaw = readCssColor("--color-primary", "21 101 192");
402
444
  const bg = cssRgbToColor(bgRaw);
403
- if (scene) scene.background = bg;
404
- if (renderer) renderer.setClearColor(bg, 1);
445
+ // Transparent mode keeps the TINT for the clear colour but drops
446
+ // the scene backdrop and the clear ALPHA to 0, so the page's own
447
+ // backdrop shows through (the renderer must have been mounted with
448
+ // `alpha: true` — the prop is static for exactly this reason).
449
+ if (scene) scene.background = props.transparentBackground ? null : bg;
450
+ if (renderer) renderer.setClearColor(bg, props.transparentBackground ? 0 : 1);
405
451
  hoverFrame?.setColor(hoverFrameColor(bg));
452
+ selectionFrame?.setColor(selectionFrameColor());
406
453
  for (const cb of themeHooks) cb(bgRaw, primaryRaw);
407
454
  }
408
455
 
@@ -453,6 +500,10 @@ export default defineComponent({
453
500
  * measurement is the expensive half, so it happens on hover change
454
501
  * only — the per-frame half is one matrix composition. */
455
502
  let frameBound = false;
503
+ /** Same latch/cooldown pair for the SELECTION frame (it re-measures
504
+ * when the selected id is re-registered, i.e. after a rebuild). */
505
+ let selectionBound = false;
506
+ let selectionCooldown = 0;
456
507
  /** Frames still to skip before the next measurement attempt while the
457
508
  * hovered target has nothing measurable to wrap (content still
458
509
  * streaming in). A permanently unmeasurable target — a group of
@@ -537,10 +588,16 @@ export default defineComponent({
537
588
  * content still streaming in under a resting pointer — also stays
538
589
  * un-latched, which is what lets the frame appear when the content
539
590
  * finally lands.
591
+ *
592
+ * The SELECTED object is likewise unmarked: its selection frame is
593
+ * already on, and two nested brackets around one body read as noise,
594
+ * not information.
540
595
  */
541
596
  function syncHoverFrame(): void {
542
597
  if (!hoverFrame) return;
543
- const def = props.hoverBox && lastHover !== null ? registry.get(lastHover) : undefined;
598
+ const def = props.hoverBox && lastHover !== null && lastHover !== selectionId
599
+ ? registry.get(lastHover)
600
+ : undefined;
544
601
  const target = def ? (def.frameObject === null ? null : (def.frameObject ?? def.object)) : null;
545
602
  if (!def || def.pickable === false || !target || !isVisibleInHierarchy(target)) {
546
603
  hoverFrame.hide();
@@ -560,6 +617,36 @@ export default defineComponent({
560
617
  hoverFrame.refresh();
561
618
  }
562
619
 
620
+ /**
621
+ * The selection frame: the same measure-once / ride-the-world-matrix
622
+ * contract as the hover frame, driven by `engine.setSelection`
623
+ * instead of the pointer. It is deliberately STICKY — a removed id
624
+ * (a roster rebuild strips and re-registers every body) hides the
625
+ * frame for the interim and re-measures on re-registration, but the
626
+ * selection itself only ever ends through `setSelection(null)`.
627
+ */
628
+ function syncSelectionFrame(): void {
629
+ if (!selectionFrame) return;
630
+ const def = selectionId !== null ? registry.get(selectionId) : undefined;
631
+ const target = def ? (def.frameObject === null ? null : (def.frameObject ?? def.object)) : null;
632
+ if (!def || !target || !isVisibleInHierarchy(target)) {
633
+ selectionFrame.hide();
634
+ selectionBound = false;
635
+ selectionCooldown = 0;
636
+ return;
637
+ }
638
+ if (!selectionBound) {
639
+ if (selectionCooldown > 0) {
640
+ selectionCooldown -= 1;
641
+ return;
642
+ }
643
+ selectionBound = selectionFrame.attach(def.object, target);
644
+ selectionCooldown = selectionBound ? 0 : HOVER_BOX_RETRY_FRAMES;
645
+ return;
646
+ }
647
+ selectionFrame.refresh();
648
+ }
649
+
563
650
  function frame(): void {
564
651
  if (disposed) return;
565
652
  rafId = requestAnimationFrame(frame);
@@ -595,6 +682,7 @@ export default defineComponent({
595
682
  // After the tick hooks have posed the content, so the frame lands on
596
683
  // this frame's pose rather than the previous one.
597
684
  syncHoverFrame();
685
+ syncSelectionFrame();
598
686
 
599
687
  renderer.render(scene, camera);
600
688
  labelRenderer?.render(scene, camera);
@@ -607,12 +695,16 @@ export default defineComponent({
607
695
  if (!canvas || !container) return;
608
696
 
609
697
  scene = new THREE.Scene();
610
- // The hover frame is a pointer affordance, not scenery: it hangs off
611
- // the scene root (so it never joins framing, markers or picking) and
612
- // rides the main-camera-only layer, keeping the minimap render clean.
698
+ // Both frames are pointer/selection affordances, not scenery: they
699
+ // hang off the scene root (so they never join framing, markers or
700
+ // picking) and ride the main-camera-only layer, keeping the minimap
701
+ // render clean.
613
702
  hoverFrame = createHoverBox();
614
703
  hoverFrame.object.layers.set(BOARD3D_MAIN_LAYER);
615
704
  scene.add(hoverFrame.object);
705
+ selectionFrame = createHoverBox();
706
+ selectionFrame.object.layers.set(BOARD3D_MAIN_LAYER);
707
+ scene.add(selectionFrame.object);
616
708
  // A degenerate fov (≤ 0 or ≥ 180) silently bricks the projection —
617
709
  // projectionMatrix[5] becomes Infinity/negative, projection and
618
710
  // picking return NaN, and nothing throws. Fall back to the default.
@@ -627,7 +719,15 @@ export default defineComponent({
627
719
  camera.position.set(initPos[0], initPos[1], initPos[2]);
628
720
  camera.lookAt(initTarget[0], initTarget[1], initTarget[2]);
629
721
 
630
- renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
722
+ renderer = new THREE.WebGLRenderer({
723
+ canvas,
724
+ antialias: true,
725
+ // Transparent backdrops need an alpha channel in the drawing
726
+ // buffer — an `alpha: false` context would paint the cleared
727
+ // pixels opaque black over the page. This is why the prop is
728
+ // documented STATIC after mount.
729
+ alpha: props.transparentBackground,
730
+ });
631
731
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
632
732
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
633
733
  renderer.toneMappingExposure = 1.15;
@@ -706,7 +806,13 @@ export default defineComponent({
706
806
  // their content), and a frame still measured against the old one
707
807
  // would hang where that object used to be — it is out of the
708
808
  // scene now and never moves again. Measure again on the next
709
- // frame instead.
809
+ // frame instead. The selection frame follows the same rule for
810
+ // its own id (the fleet sky strips and re-registers every body
811
+ // on a roster refresh; the selection must survive that).
812
+ if (id === selectionId) {
813
+ selectionBound = false;
814
+ selectionCooldown = 0;
815
+ }
710
816
  if (id === lastHover) {
711
817
  frameBound = false;
712
818
  measureCooldown = 0;
@@ -753,6 +859,24 @@ export default defineComponent({
753
859
  // A snapshot: a consumer that casts it to Map and clears it must
754
860
  // not be able to desynchronise the board from its scene.
755
861
  objects: () => new Map(registry),
862
+ setSelection(id) {
863
+ // Re-selecting the SAME id is a no-op: releasing the latch would
864
+ // force a full re-measure next frame for a frame that is already
865
+ // correct — a page that re-selects on every data refresh would
866
+ // otherwise pay the subtree walk each time (the hover path makes
867
+ // the same guard at the event level).
868
+ if (selectionId === id) return;
869
+ // The frame hides itself while the id is absent from the
870
+ // registry (syncSelectionFrame runs every frame), so an unknown
871
+ // or not-yet-registered id is accepted as-is — a consumer may
872
+ // legitimately select before the content lands. A CHANGED id
873
+ // must also release the measurement latch: without this, the
874
+ // frame keeps riding the PREVIOUS anchor's world matrix and
875
+ // paints a bracket around the body the user just deselected.
876
+ selectionId = id;
877
+ selectionBound = false;
878
+ selectionCooldown = 0;
879
+ },
756
880
  flyTo(position, target, durationMs = 600) {
757
881
  if (!camera || !controls) return;
758
882
  // A non-finite pose would brick the camera silently (every later
@@ -970,7 +1094,13 @@ export default defineComponent({
970
1094
  hoverFrame.dispose();
971
1095
  hoverFrame = null;
972
1096
  }
1097
+ if (selectionFrame) {
1098
+ scene?.remove(selectionFrame.object);
1099
+ selectionFrame.dispose();
1100
+ selectionFrame = null;
1101
+ }
973
1102
  frameBound = false;
1103
+ selectionBound = false;
974
1104
  controls?.dispose();
975
1105
  if (labelRenderer?.domElement.parentNode) {
976
1106
  labelRenderer.domElement.parentNode.removeChild(labelRenderer.domElement);
@@ -1004,7 +1134,16 @@ export default defineComponent({
1004
1134
  });
1005
1135
 
1006
1136
  return () => (
1007
- <div class="hk-board3d" ref={containerRef}>
1137
+ <div
1138
+ class="hk-board3d"
1139
+ // The transparent-backdrop mode clears the CANVAS to alpha 0, but
1140
+ // the container's own surface paint would still sit behind it —
1141
+ // a page-colored rectangle where the page's backdrop should show.
1142
+ // An inline style wins over the stylesheet without a specificity
1143
+ // war with consumer classes.
1144
+ style={props.transparentBackground ? { background: "transparent" } : undefined}
1145
+ ref={containerRef}
1146
+ >
1008
1147
  <canvas ref={canvasRef} class="hk-board3d-canvas" />
1009
1148
  {failed.value ? (
1010
1149
  slots.fallback ? (