@celestia-island/hikari 0.55.251 → 0.55.254

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.251",
3
+ "version": "0.55.254",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -251,9 +251,10 @@
251
251
  * comes from the registration list, not from here. */
252
252
  @include mi.item;
253
253
 
254
- &[data-disabled] {
255
- cursor: default;
256
- }
254
+ /* data-disabled rides the mixin's face whole (dim + not-allowed +
255
+ guarded hover/focus): the sibling tag-input catalog rows already
256
+ did, and a family-local cursor:default split the two choosers'
257
+ disabled language. */
257
258
 
258
259
  /* The "Use <query>" free-text row reads as an add affordance. Truncation
259
260
  * comes from the base .hk-affix-row-label — only the emphasis is new. */
@@ -33,7 +33,9 @@
33
33
  * anchors, pointer-events opt-in per chip so drags still orbit;
34
34
  * - MINIMAP: HkMinimap3D pinned bottom-right — a second, stereoscopic
35
35
  * renderer onto the SAME scene with the main camera glyph, its view
36
- * plane and content markers layered in;
36
+ * plane and content markers layered in; minimapCollapsible lets the
37
+ * user fold it into the small map-icon button and
38
+ * minimapDefaultCollapsed mounts it folded;
37
39
  * - THEME: scene background follows --color-background, re-read on
38
40
  * data-mode / data-theme / style mutations; `engine.onTheme` lets the
39
41
  * page re-tone its materials on the same signal. With the
@@ -331,6 +333,12 @@ export default defineComponent({
331
333
  interactive: { type: Boolean, default: true },
332
334
  /** Pin the stereoscopic minimap bottom-right. */
333
335
  minimap: { type: Boolean, default: true },
336
+ /** Give the minimap the collapse affordance (minimize button in its
337
+ * zoom bar; folded state = the small map-icon button). */
338
+ minimapCollapsible: { type: Boolean, default: false },
339
+ /** Mount the minimap already folded (with minimapCollapsible the
340
+ * user can bring it back; the renderer then inits on first expand). */
341
+ minimapDefaultCollapsed: { type: Boolean, default: false },
334
342
  /** Vertical field of view in degrees. STATIC after mount (the
335
343
  * camera is built in init; remount to change it). */
336
344
  fov: { type: Number, default: 45 },
@@ -1156,7 +1164,11 @@ export default defineComponent({
1156
1164
  )
1157
1165
  ) : null}
1158
1166
  {props.minimap && engineRef.value && !failed.value ? (
1159
- <HkMinimap3D engine={engineRef.value} />
1167
+ <HkMinimap3D
1168
+ engine={engineRef.value}
1169
+ collapsible={props.minimapCollapsible}
1170
+ defaultCollapsed={props.minimapDefaultCollapsed}
1171
+ />
1160
1172
  ) : null}
1161
1173
  {slots.default ? <div class="hk-board3d-overlay">{slots.default()}</div> : null}
1162
1174
  </div>
@@ -1,37 +1,34 @@
1
- /* HkLocalePicker — the inline hand-rolled dropdown joins the pop motion
2
- * family (--hk-pop-* tokens in theme/theme.scss): it grows out of its
3
- * trigger (below-right) and retracts into it, matching the popover /
4
- * select-panel surfaces instead of appearing bare. */
5
- .hk-locale-picker-dropdown {
6
- transform-origin: top right;
7
- }
8
-
9
- .hk-locale-picker-enter-active {
10
- transition:
11
- opacity var(--hk-pop-in-duration, 0.2s)
12
- var(--hk-pop-in-opacity-ease, cubic-bezier(0.16, 1, 0.3, 1)),
13
- transform var(--hk-pop-in-duration, 0.2s)
14
- var(--hk-pop-in-transform-ease, cubic-bezier(0.34, 1.56, 0.64, 1));
15
- }
1
+ /* HkLocalePicker — only the TRIGGER is component-owned now; the dropdown
2
+ * is HkLocalePickerPopup (HkMenu) and inherits the menu grammar + pop
3
+ * motion from there. The hand-rolled dropdown sheet (and its pop-motion
4
+ * classes) was removed with the 2026-10-31 sweep rewrite. */
5
+ .hk-locale-picker-trigger {
6
+ display: inline-flex;
7
+ align-items: center;
8
+ gap: 0.25rem;
9
+ padding: 0.25rem 0.5rem;
10
+ border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
11
+ border-radius: var(--radius-sm, 4px);
12
+ background: transparent;
13
+ color: rgb(var(--color-muted));
14
+ font-size: 0.75rem;
15
+ font-weight: 500;
16
+ font-family: inherit;
17
+ cursor: pointer;
18
+ transition: background 0.15s;
16
19
 
17
- .hk-locale-picker-leave-active {
18
- transition:
19
- opacity var(--hk-pop-out-duration, 0.15s)
20
- var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1)),
21
- transform var(--hk-pop-out-duration, 0.15s)
22
- var(--hk-pop-out-ease, cubic-bezier(0.4, 0, 1, 1));
23
- }
20
+ &:hover {
21
+ background: rgb(var(--color-primary) / 8%);
22
+ }
24
23
 
25
- .hk-locale-picker-enter-from,
26
- .hk-locale-picker-leave-to {
27
- opacity: 0;
28
- transform: translateY(calc(-1 * var(--hk-pop-offset, 4px)))
29
- scale(var(--hk-pop-scale, 0.97));
30
- }
24
+ &:focus-visible {
25
+ outline: 2px solid rgb(var(--color-primary) / 70%);
26
+ outline-offset: 1px;
27
+ }
31
28
 
32
- @media (prefers-reduced-motion: reduce) {
33
- .hk-locale-picker-enter-active,
34
- .hk-locale-picker-leave-active {
35
- transition: none;
29
+ /* The caret is furniture, not copy — half-strength ink, as the
30
+ pre-rewrite trigger drew it. */
31
+ svg {
32
+ opacity: 0.5;
36
33
  }
37
34
  }
@@ -1,6 +1,6 @@
1
- import { defineComponent, ref, Transition, type PropType } from "vue";
1
+ import { defineComponent, ref, type PropType } from "vue";
2
2
 
3
- import { useSurfaceTransition } from "../composables/useSurfaceTransition";
3
+ import HkLocalePickerPopup from "./HkLocalePickerPopup";
4
4
  import "./HkLocalePicker.scss";
5
5
 
6
6
  export interface LocaleOption {
@@ -8,6 +8,15 @@ export interface LocaleOption {
8
8
  label: string;
9
9
  }
10
10
 
11
+ /**
12
+ * HkLocalePicker — the compact trigger + dropdown locale picker. The
13
+ * dropdown hand-rolled its own absolutely-positioned stock (inline
14
+ * styles, JS-painted hover, no sheet, no roles — the 2026-10-31 sweep's
15
+ * finding #8); it now rides HkLocalePickerPopup (HkMenu), so the rows
16
+ * speak the menu-item grammar, the panel anchors/cascades/flips like
17
+ * every menu, and phones get the sheet family. The public surface is
18
+ * unchanged: locales / currentLocale props, `select` event.
19
+ */
11
20
  export const HkLocalePicker = defineComponent({
12
21
  name: "HkLocalePicker",
13
22
  props: {
@@ -23,110 +32,42 @@ export const HkLocalePicker = defineComponent({
23
32
  emits: ["select"],
24
33
  setup(props, { emit }) {
25
34
  const open = ref(false);
26
- // Open/close motion reported into the unified animation context —
27
- // this little dropdown moves like the rest of the pop family
28
- // instead of appearing bare.
29
- const anim = useSurfaceTransition(250).hooks();
35
+ const trigger = ref<HTMLElement | null>(null);
30
36
 
31
- function handleSelect(code: string) {
32
- emit("select", code);
37
+ function handleSelect(code: string | string[]) {
38
+ const code0 = Array.isArray(code) ? code[0] : code;
39
+ if (code0) emit("select", code0);
33
40
  open.value = false;
34
41
  }
35
42
 
36
43
  return () => (
37
- <div style={{ position: "relative" }}>
44
+ <div style={{ position: "relative" }} ref={trigger}>
38
45
  <button
39
46
  type="button"
40
- style={{
41
- display: "inline-flex",
42
- alignItems: "center",
43
- gap: "0.25rem",
44
- padding: "0.25rem 0.5rem",
45
- border: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
46
- borderRadius: "var(--radius-sm, 4px)",
47
- background: "transparent",
48
- color: "rgb(var(--color-muted))",
49
- fontSize: "0.75rem",
50
- fontWeight: 500,
51
- cursor: "pointer",
52
- transition: "background 0.15s",
53
- }}
47
+ class="hk-locale-picker-trigger"
48
+ aria-haspopup="menu"
49
+ aria-expanded={open.value}
54
50
  onClick={() => (open.value = !open.value)}
55
- onBlur={() => setTimeout(() => (open.value = false), 150)}
56
51
  >
57
52
  {props.currentLocale.toUpperCase()}
58
- <svg width="10" height="10" viewBox="0 0 10 10" style={{ opacity: 0.5 }}>
59
- <path d="M2 3.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.2" />
53
+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
54
+ <path
55
+ d="M2 3.5l3 3 3-3"
56
+ fill="none"
57
+ stroke="currentColor"
58
+ stroke-width="1.2"
59
+ />
60
60
  </svg>
61
61
  </button>
62
- <Transition
63
- name="hk-locale-picker"
64
- appear
65
- onBeforeEnter={anim.onBeforeEnter}
66
- onAfterEnter={anim.onAfterEnter}
67
- onBeforeLeave={anim.onBeforeLeave}
68
- onAfterLeave={anim.onAfterLeave}
69
- >
70
- {open.value && (
71
- <div
72
- class="hk-locale-picker-dropdown"
73
- style={{
74
- position: "absolute",
75
- top: "calc(100% + 4px)",
76
- right: 0,
77
- minWidth: "120px",
78
- background: "rgb(var(--color-surface))",
79
- border: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
80
- borderRadius: "var(--radius-md, 6px)",
81
- boxShadow: "0 4px 16px rgb(0 0 0 / 12%)",
82
- // Local stacking inside the header's own context — the
83
- // app-chrome header-popup band documents the intent (this
84
- // value is scoped, it never races the popup stack).
85
- zIndex: "var(--hk-z-float)",
86
- overflow: "hidden",
87
- padding: "4px",
88
- }}
89
- >
90
- {props.locales.map((loc) => (
91
- <button
92
- key={loc.code}
93
- type="button"
94
- style={{
95
- display: "flex",
96
- alignItems: "center",
97
- width: "100%",
98
- padding: "6px 10px",
99
- border: "none",
100
- borderRadius: "4px",
101
- background: loc.code === props.currentLocale
102
- ? "var(--c-primary-subtle, rgb(var(--color-primary) / 8%))"
103
- : "transparent",
104
- color: loc.code === props.currentLocale
105
- ? "var(--c-primary, rgb(var(--color-primary)))"
106
- : "rgb(var(--color-text))",
107
- fontSize: "0.75rem",
108
- cursor: "pointer",
109
- textAlign: "left",
110
- transition: "background 0.1s",
111
- }}
112
- onMouseenter={(e: MouseEvent) => {
113
- if (loc.code !== props.currentLocale) {
114
- (e.currentTarget as HTMLElement).style.background = "var(--c-primary-overlay, rgb(var(--color-primary) / 15%))";
115
- }
116
- }}
117
- onMouseleave={(e: MouseEvent) => {
118
- if (loc.code !== props.currentLocale) {
119
- (e.currentTarget as HTMLElement).style.background = "transparent";
120
- }
121
- }}
122
- onMousedown={() => handleSelect(loc.code)}
123
- >
124
- {loc.label}
125
- </button>
126
- ))}
127
- </div>
128
- )}
129
- </Transition>
62
+ <HkLocalePickerPopup
63
+ open={open.value}
64
+ triggerRef={trigger.value}
65
+ placement="bottom-end"
66
+ locales={props.locales.map((loc) => ({ code: loc.code, label: loc.label }))}
67
+ currentLocale={props.currentLocale}
68
+ onUpdate:open={(v: boolean) => (open.value = v)}
69
+ onSelect={handleSelect}
70
+ />
130
71
  </div>
131
72
  );
132
73
  },
@@ -38,7 +38,9 @@ const HkLocalePickerPopup = defineComponent({
38
38
  open={props.open}
39
39
  anchorRef={anchor}
40
40
  placement={
41
- props.placement === "left-start" || props.placement === "right-start"
41
+ props.placement === "left-start" ||
42
+ props.placement === "right-start" ||
43
+ props.placement === "bottom-end"
42
44
  ? props.placement
43
45
  : "right-start"
44
46
  }
@@ -1,4 +1,4 @@
1
- import { Map as MapIcon, Maximize2, Minimize2, ZoomIn, ZoomOut } from "lucide-vue-next";
1
+ import { Map as MapIcon, Minimize2, RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
2
2
  import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
3
3
 
4
4
  import HSlider from "./HkSlider";
@@ -407,7 +407,7 @@ export default defineComponent({
407
407
  aria-label={t("hikari::zoomToolbar.reset", "Reset zoom")}
408
408
  title={t("hikari::zoomToolbar.reset", "Reset zoom")}
409
409
  >
410
- <Maximize2 size={11} />
410
+ <RotateCcw size={11} />
411
411
  </button>
412
412
  )}
413
413
  {props.collapsible && (
@@ -12,12 +12,15 @@ const H = vi.hoisted(() => {
12
12
  static instances: FakeWebGLRenderer[] = [];
13
13
  renders: Array<{ scene: unknown; camera: THREE.PerspectiveCamera }> = [];
14
14
  disposed = false;
15
- constructor(_opts: { canvas: HTMLCanvasElement }) {
15
+ readonly domElement: HTMLCanvasElement;
16
+ constructor(opts: { canvas: HTMLCanvasElement }) {
17
+ this.domElement = opts.canvas;
16
18
  FakeWebGLRenderer.instances.push(this);
17
19
  }
18
20
  setPixelRatio(): void {}
19
21
  setSize(): void {}
20
22
  setClearColor(): void {}
23
+ forceContextLoss(): void { this.disposed = true; }
21
24
  render(scene: unknown, camera: THREE.PerspectiveCamera): void {
22
25
  // A real renderer maintains world matrices every pass.
23
26
  (scene as THREE.Scene).updateMatrixWorld(true);
@@ -82,13 +85,13 @@ function fakeEngine(opts: FakeEngineOpts = {}): {
82
85
  };
83
86
  }
84
87
 
85
- function mountMap(engine: Board3DEngine) {
88
+ function mountMap(engine: Board3DEngine, extra: Record<string, unknown> = {}) {
86
89
  const container = document.createElement("div");
87
90
  document.body.appendChild(container);
88
91
  const app = createApp(
89
92
  defineComponent({
90
93
  setup() {
91
- return () => h(HkMinimap3D, { engine });
94
+ return () => h(HkMinimap3D, { engine, ...extra });
92
95
  },
93
96
  }),
94
97
  );
@@ -360,3 +363,107 @@ describe("HkMinimap3D", () => {
360
363
  for (const spy of spies) expect(spy).toHaveBeenCalled();
361
364
  });
362
365
  });
366
+
367
+ describe("HkMinimap3D collapse affordance (ported from the 2D minimap)", () => {
368
+ it("mounts folded with defaultCollapsed: map-icon button, no GL surface", async () => {
369
+ const { engine, firePostRender } = fakeEngine();
370
+ const { container } = mountMap(engine, { collapsible: true, defaultCollapsed: true });
371
+ await nextTick();
372
+ firePostRender();
373
+
374
+ expect(container.querySelector(".hk-minimap--collapsed"), "folded button renders").toBeTruthy();
375
+ expect(container.querySelector(".hk-minimap3d-canvas"), "no canvas while folded").toBeNull();
376
+ // No GL surface was ever built for a folded-at-mount map.
377
+ expect(H.FakeWebGLRenderer.instances).toHaveLength(0);
378
+ });
379
+
380
+ it("expands on the folded button and lazily inits the renderer", async () => {
381
+ const { engine, firePostRender } = fakeEngine();
382
+ const { container } = mountMap(engine, { collapsible: true, defaultCollapsed: true });
383
+ await nextTick();
384
+
385
+ const fold = container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed");
386
+ expect(fold).toBeTruthy();
387
+ fold!.click();
388
+ await nextTick();
389
+ await nextTick();
390
+
391
+ expect(container.querySelector(".hk-minimap3d-canvas"), "canvas mounts on expand").toBeTruthy();
392
+ expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
393
+ // The lazy init built the renderer; frames flow again.
394
+ expect(H.FakeWebGLRenderer.instances).toHaveLength(1);
395
+ firePostRender();
396
+ expect(H.FakeWebGLRenderer.instances[0].renders.length).toBe(1);
397
+ // The zoom bar carries the minimize affordance once expanded.
398
+ expect(container.querySelector(".hk-mm-collapse-btn")).toBeTruthy();
399
+ });
400
+
401
+ it("collapsible adds the minimize button; clicking folds the card", async () => {
402
+ const { engine, firePostRender } = fakeEngine();
403
+ const { container } = mountMap(engine, { collapsible: true });
404
+ await nextTick();
405
+
406
+ expect(container.querySelector(".hk-minimap3d-canvas")).toBeTruthy();
407
+ const minimize = container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn");
408
+ expect(minimize, "minimize button rides the zoom bar").toBeTruthy();
409
+ minimize!.click();
410
+ await nextTick();
411
+
412
+ expect(container.querySelector(".hk-minimap--collapsed")).toBeTruthy();
413
+ expect(container.querySelector(".hk-minimap3d-canvas")).toBeNull();
414
+ // A folded map renders no frames even though the board keeps ticking.
415
+ const renderer = H.FakeWebGLRenderer.instances[0];
416
+ const before = renderer.renders.length;
417
+ firePostRender();
418
+ firePostRender();
419
+ expect(renderer.renders.length).toBe(before);
420
+ });
421
+
422
+ it("stays expanded by default (board pages keep the standing card)", async () => {
423
+ const { engine } = fakeEngine();
424
+ const { container } = mountMap(engine);
425
+ await nextTick();
426
+ expect(container.querySelector(".hk-minimap3d-canvas")).toBeTruthy();
427
+ expect(container.querySelector(".hk-mm-collapse-btn")).toBeNull();
428
+ expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
429
+ });
430
+ });
431
+ describe("HkMinimap3D reopen cycle (the canvas element is rebuilt by v-if)", () => {
432
+ it("re-binds the renderer to the LIVE canvas; helpers never duplicate", async () => {
433
+ const { engine, scene, firePostRender } = fakeEngine();
434
+ const { container } = mountMap(engine, { collapsible: true });
435
+ await nextTick();
436
+ firePostRender();
437
+ expect(H.FakeWebGLRenderer.instances).toHaveLength(1);
438
+
439
+ const helpersOnLayer = () =>
440
+ scene.children.filter((c) => (c.layers.mask & (1 << BOARD3D_HELPERS_LAYER)) !== 0).length;
441
+ expect(helpersOnLayer()).toBe(2); // glyph group + view-plane frame
442
+
443
+ // fold -> unfold: Vue rebuilds the canvas element; the renderer must
444
+ // follow it or the reopened map paints into the detached element.
445
+ container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn")!.click();
446
+ await nextTick();
447
+ container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed")!.click();
448
+ await nextTick();
449
+ await nextTick();
450
+
451
+ const canvasNow = container.querySelector<HTMLCanvasElement>(".hk-minimap3d-canvas");
452
+ expect(canvasNow, "canvas is back").toBeTruthy();
453
+ const latest = H.FakeWebGLRenderer.instances.at(-1)!;
454
+ expect(latest.domElement).toBe(canvasNow);
455
+ expect(helpersOnLayer()).toBe(2); // rebuilt, NOT duplicated
456
+ firePostRender();
457
+ expect(latest.renders.length).toBe(1);
458
+
459
+ // a second cycle stays correct
460
+ container.querySelector<HTMLButtonElement>(".hk-mm-collapse-btn")!.click();
461
+ await nextTick();
462
+ container.querySelector<HTMLButtonElement>(".hk-minimap--collapsed")!.click();
463
+ await nextTick();
464
+ await nextTick();
465
+ const canvas2 = container.querySelector<HTMLCanvasElement>(".hk-minimap3d-canvas");
466
+ expect(H.FakeWebGLRenderer.instances.at(-1)!.domElement).toBe(canvas2);
467
+ expect(helpersOnLayer()).toBe(2);
468
+ });
469
+ });
@@ -15,13 +15,20 @@
15
15
  * - ZOOM is an auto-fit radius (content ∪ view plane) with a ± ladder
16
16
  * on top: 100% = everything fits, higher = closer;
17
17
  * - the helper layer never leaks into the main render (helpers sit on
18
- * BOARD3D_HELPERS_LAYER, which only the minimap camera enables).
18
+ * BOARD3D_HELPERS_LAYER, which only the minimap camera enables);
19
+ * - COLLAPSE (the 2D HkMinimap's 2026-10-27 affordance, ported): with
20
+ * a "collapsible" prop the zoom bar grows a minimize button and the
21
+ * card folds into the small semi-transparent map-icon button; the
22
+ * render hook skips frames while collapsed and the renderer re-inits
23
+ * lazily on first expand, so "defaultCollapsed" costs nothing at
24
+ * mount.
19
25
  */
20
26
 
21
- import { Maximize2, ZoomIn, ZoomOut } from "lucide-vue-next";
27
+ import { Map as MapIcon, Minimize2, RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
22
28
  import {
23
29
  computed,
24
30
  defineComponent,
31
+ nextTick,
25
32
  onBeforeUnmount,
26
33
  onMounted,
27
34
  ref,
@@ -62,6 +69,13 @@ export default defineComponent({
62
69
  name: "HkMinimap3D",
63
70
  props: {
64
71
  engine: { type: Object as PropType<Board3DEngine>, required: true },
72
+ /** Grow the minimize button in the zoom bar (2026-10-31: ported
73
+ * from the 2D HkMinimap's collapse affordance). */
74
+ collapsible: { type: Boolean, default: false },
75
+ /** Mount straight into the collapsed map-icon state — the renderer
76
+ * then inits lazily on first expand, so a collapsed-at-mount map
77
+ * never builds a GL surface at all. */
78
+ defaultCollapsed: { type: Boolean, default: false },
65
79
  },
66
80
  setup(props) {
67
81
  const { t } = useI18n();
@@ -70,6 +84,7 @@ export default defineComponent({
70
84
  const dragging = ref(false);
71
85
  const sliderOpen = ref(false);
72
86
  const zoomPercent = ref(100);
87
+ const collapsed = ref(props.defaultCollapsed);
73
88
 
74
89
  let renderer: THREE.WebGLRenderer | null = null;
75
90
  let mapCamera: THREE.PerspectiveCamera | null = null;
@@ -287,6 +302,7 @@ export default defineComponent({
287
302
  }
288
303
 
289
304
  function onFrame(): void {
305
+ if (collapsed.value) return; // folded away: no map frames at all
290
306
  if (!renderer || !mapCamera || !scene.value) return;
291
307
  const now = performance.now();
292
308
  if (now - lastRefit >= REFIT_MS) {
@@ -343,6 +359,10 @@ export default defineComponent({
343
359
  zoomPercent.value = 100;
344
360
  sliderOpen.value = false;
345
361
  }
362
+ function toggleCollapsed(next: boolean): void {
363
+ collapsed.value = next;
364
+ if (next) sliderOpen.value = false;
365
+ }
346
366
 
347
367
  // Slider pop dismissal (same shield as the 2D minimap).
348
368
  function onDocPointerDown(e: PointerEvent): void {
@@ -361,7 +381,36 @@ export default defineComponent({
361
381
  }
362
382
  });
363
383
 
364
- onMounted(() => {
384
+ /** GL-surface init, safe to call repeatedly. Called from onMounted
385
+ * (expanded mounts) and from the expand transition (a
386
+ * defaultCollapsed map has no canvas at mount; a fold/unfold cycle
387
+ * REBUILDS the canvas element, so a surviving renderer would keep
388
+ * painting into the detached element and the reopened map would
389
+ * stay empty — detect the swap and rebuild against the live canvas,
390
+ * helpers and post-render hook included). */
391
+ function initRenderer(): void {
392
+ if (renderer && renderer.domElement === canvasRef.value) return;
393
+ removePostRender?.();
394
+ removePostRender = null;
395
+ if (renderer) {
396
+ renderer.dispose();
397
+ renderer.forceContextLoss?.();
398
+ renderer = null;
399
+ }
400
+ const s = scene.value;
401
+ if (s) {
402
+ if (glyph) {
403
+ s.remove(glyph);
404
+ disposeNode(glyph);
405
+ glyph = null;
406
+ }
407
+ if (planeFrame) {
408
+ s.remove(planeFrame);
409
+ planeFrame.geometry.dispose();
410
+ (planeFrame.material as THREE.Material).dispose();
411
+ planeFrame = null;
412
+ }
413
+ }
365
414
  const canvas = canvasRef.value;
366
415
  const el = rootRef.value;
367
416
  if (!canvas || !el) return;
@@ -387,6 +436,12 @@ export default defineComponent({
387
436
  el.addEventListener("pointerup", onUp);
388
437
  el.addEventListener("pointercancel", onUp);
389
438
  el.addEventListener("wheel", onWheel, { passive: false });
439
+ }
440
+ onMounted(() => {
441
+ if (!collapsed.value) initRenderer();
442
+ });
443
+ watch(collapsed, (folded) => {
444
+ if (!folded) void nextTick(() => initRenderer());
390
445
  });
391
446
 
392
447
  onBeforeUnmount(() => {
@@ -425,7 +480,24 @@ export default defineComponent({
425
480
  renderer?.forceContextLoss?.();
426
481
  });
427
482
 
428
- return () => (
483
+ return () => {
484
+ if (collapsed.value) {
485
+ // The folded affordance, mirroring the 2D HkMinimap: a small
486
+ // semi-transparent map-icon button pinned where the card sat.
487
+ return (
488
+ <button
489
+ ref={rootRef}
490
+ type="button"
491
+ class="hk-minimap hk-minimap3d hk-minimap--collapsed"
492
+ aria-label={t("hikari::zoomToolbar.expandMap", "Show minimap")}
493
+ title={t("hikari::zoomToolbar.expandMap", "Show minimap")}
494
+ onClick={() => toggleCollapsed(false)}
495
+ >
496
+ <MapIcon size={16} />
497
+ </button>
498
+ );
499
+ }
500
+ return (
429
501
  <div
430
502
  ref={rootRef}
431
503
  class="hk-minimap hk-minimap3d"
@@ -472,8 +544,19 @@ export default defineComponent({
472
544
  aria-label={t("hikari::board3d.resetView", "Reset view")}
473
545
  title={t("hikari::board3d.resetView", "Reset view")}
474
546
  >
475
- <Maximize2 size={11} />
547
+ <RotateCcw size={11} />
476
548
  </button>
549
+ {props.collapsible && (
550
+ <button
551
+ class="hk-mm-zoom-btn hk-mm-collapse-btn"
552
+ type="button"
553
+ onClick={() => toggleCollapsed(true)}
554
+ aria-label={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
555
+ title={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
556
+ >
557
+ <Minimize2 size={11} />
558
+ </button>
559
+ )}
477
560
  </div>
478
561
  {sliderOpen.value && (
479
562
  <div class="hk-mm-zoom-pop">
@@ -497,5 +580,5 @@ export default defineComponent({
497
580
  )}
498
581
  </div>
499
582
  );
500
- },
583
+ }; },
501
584
  });
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
3
  import { createApp, h, nextTick } from "vue";
4
4
 
5
5
  import HkTimeZoneGlobe from "./HkTimeZoneGlobe";
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
6
8
  import { setLocale } from "../i18n/context";
7
9
 
8
10
  /**
@@ -126,3 +128,25 @@ describe("HkTimeZoneGlobe fallback list", () => {
126
128
  expect(p8.textContent).toContain("上海、台北、香港、新加坡");
127
129
  });
128
130
  });
131
+
132
+ describe("HkTimeZoneGlobe look contract (source-level)", () => {
133
+ it("renders the base sphere TRANSPARENT (the white-alpha map must read as holo, not a solid ball)", () => {
134
+ const src = readFileSync(join(import.meta.dirname, "HkTimeZoneGlobe.tsx"), "utf8");
135
+ expect(src).toMatch(/MeshBasicMaterial\(\{ color: 0x8899aa, transparent: true \}\)/);
136
+ });
137
+
138
+ it("mounts its minimap collapsible AND folded by default (user direction 2026-10-31)", () => {
139
+ const src = readFileSync(join(import.meta.dirname, "HkTimeZoneGlobe.tsx"), "utf8");
140
+ expect(src).toContain("minimapCollapsible");
141
+ expect(src).toContain("minimapDefaultCollapsed");
142
+ });
143
+ });
144
+ describe("minimap reset icon contract (user direction 2026-10-31)", () => {
145
+ it("reset buttons use RotateCcw, never the expand-looking Maximize2", () => {
146
+ for (const name of ["HkMinimap.tsx", "HkMinimap3D.tsx", "HkZoomToolbar.tsx"]) {
147
+ const src = readFileSync(join(import.meta.dirname, name), "utf8");
148
+ expect(src, name + " imports RotateCcw").toContain("RotateCcw");
149
+ expect(src, name + " dropped Maximize2").not.toContain("Maximize2");
150
+ }
151
+ });
152
+ });
@@ -169,7 +169,12 @@ export default defineComponent({
169
169
  function buildScene(eng: Board3DEngine): void {
170
170
  // Base sphere — the board-tinted borderless world map.
171
171
  const geometry = new THREE.SphereGeometry(GLOBE_RADIUS, 96, 64);
172
- baseMaterial = new THREE.MeshBasicMaterial({ color: 0x8899aa });
172
+ // transparent is the WHOLE LOOK: the base map is white-alpha (the
173
+ // ocean carries alpha 0), so without the flag three ignores the
174
+ // texture alpha and renders the sphere as an opaque dark ball.
175
+ // With it the globe reads as a holo: continents and plate lines
176
+ // floating over the theme surface, nothing solid behind them.
177
+ baseMaterial = new THREE.MeshBasicMaterial({ color: 0x8899aa, transparent: true });
173
178
  const sphere = new THREE.Mesh(geometry, baseMaterial);
174
179
  eng.setObject("globe-base", {
175
180
  object: sphere, pickable: false, content: true, marker: "none",
@@ -346,6 +351,8 @@ export default defineComponent({
346
351
  <HkBoard3D
347
352
  interactive
348
353
  minimap
354
+ minimapCollapsible
355
+ minimapDefaultCollapsed
349
356
  hoverBox={false}
350
357
  transparentBackground
351
358
  enablePan={false}
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Source contract for the bond CELLS of the horizontal stepper (2026-10-31
3
+ * user rule).
4
+ *
5
+ * The row is read as one cell per step plus one cell per bond: the steps
6
+ * keep their content width, the leftover splits into EQUAL bond cells, and
7
+ * each bond paints 80 % of its own cell CENTERED, so both ends keep a 10 %
8
+ * air gap. Before that rule the bond filled whatever the step had left after
9
+ * its label, minus two fixed 4px margins — measured on a real engine at the
10
+ * provider wizard's 590px row that put the bond 12px off its own label but
11
+ * only 4px off the next node, i.e. the completed bond read as glued to the
12
+ * following circle instead of sitting in the gap (the user's 圈出来的地方
13
+ * 有点太长 report). The four bonds were already equal in length; what was
14
+ * not equal was the air around them.
15
+ *
16
+ * Every assertion below is anchored to the rule that must carry the
17
+ * declaration, and the colour assertions run against a comment-stripped copy
18
+ * of the source: a declaration that survives only as a comment (while a
19
+ * literal paint stays live) measured as an invisible bond on a real engine,
20
+ * and a whole-file `toContain` cannot tell the two apart.
21
+ */
22
+ import { describe, expect, it } from "vitest";
23
+ import { readFileSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const raw = readFileSync(join(here, "HkTimeline.scss"), "utf-8");
29
+
30
+ /** The source with comments removed — declarations only. */
31
+ const src = raw
32
+ .replace(/\/\*[\s\S]*?\*\//g, "")
33
+ .split("\n")
34
+ .map((line) => line.replace(/\/\/.*$/, ""))
35
+ .join("\n");
36
+
37
+ function extractRules(source: string): { selector: string; body: string }[] {
38
+ const rules: { selector: string; body: string }[] = [];
39
+ let i = 0;
40
+ while (i < source.length) {
41
+ const open = source.indexOf("{", i);
42
+ if (open === -1) break;
43
+ const selector = source.slice(i, open).trim();
44
+ let depth = 1;
45
+ let j = open + 1;
46
+ while (j < source.length && depth > 0) {
47
+ if (source[j] === "{") depth += 1;
48
+ else if (source[j] === "}") depth -= 1;
49
+ j += 1;
50
+ }
51
+ rules.push({ selector, body: source.slice(open + 1, j - 1) });
52
+ i = j;
53
+ }
54
+ return rules;
55
+ }
56
+
57
+ const rules = extractRules(src);
58
+
59
+ function blockFor(selector: string): string {
60
+ const hits = rules.filter((r) =>
61
+ r.selector.split(",").map((s) => s.trim()).includes(selector),
62
+ );
63
+ expect(hits, `exactly one rule carries ${selector}`).toHaveLength(1);
64
+ return hits[0]!.body;
65
+ }
66
+
67
+ /** The shared spacing expression: the bond's box must read the SAME token
68
+ * the step's gap does, or the cell arithmetic halves itself. */
69
+ const GAP = "var(--hk-timeline-step-gap, var(--space-8, 8px))";
70
+
71
+ const BOND_CELL =
72
+ '.hk-timeline[data-mode="full"][data-orientation="horizontal"] .hk-timeline-step [data-el="connector"]';
73
+ const BOND_PAINT = `${BOND_CELL}::before`;
74
+
75
+ /** The declaration block of one status, comment-stripped. */
76
+ function statusBlock(status: string): string {
77
+ const at = src.indexOf(`.hk-timeline-step[data-status="${status}"]`);
78
+ expect(at, `${status} status rule must exist`).toBeGreaterThan(-1);
79
+ return src.slice(at, src.indexOf("\n}", at));
80
+ }
81
+
82
+ /** Only the bond's own declarations inside a status rule (the indicator may
83
+ * legitimately keep reading the border token for its ring). */
84
+ function bondBlockOf(status: string): string {
85
+ const block = statusBlock(status);
86
+ const at = block.indexOf('[data-el="connector"]');
87
+ expect(at, `${status} must style the bond`).toBeGreaterThan(-1);
88
+ return block.slice(at, block.indexOf("}", at));
89
+ }
90
+
91
+ describe("horizontal bond cell contract", () => {
92
+ it("spans the whole cell by cancelling the step's trailing gap", () => {
93
+ const cell = blockFor(BOND_CELL);
94
+ // Without the negated start margin the label→bond air is gap+margin
95
+ // while the bond→node air is margin alone — the 12/4 asymmetry that
96
+ // made the bond look glued to the next circle.
97
+ expect(cell).toContain(`margin-inline: calc(-1 * ${GAP}) 0;`);
98
+ // The box must not paint itself: the visible 80 % is the ::before.
99
+ expect(cell).toContain("background: none;");
100
+ // The ::before resolves percentage insets against this box.
101
+ expect(cell).toContain("position: relative;");
102
+ expect(cell).toContain("&::before");
103
+ // The negated margin is only correct against the step's OWN gap
104
+ // declaration, so pin the partner: drifting `gap` alone silently
105
+ // restores the asymmetry (measured 7.84 / 3.84 on a 12px drift).
106
+ expect(blockFor(".hk-timeline-step")).toContain(`gap: ${GAP};`);
107
+ });
108
+
109
+ it("paints 80 % of the cell centred with a 10 % inset on each end", () => {
110
+ // The parser above only yields top-level rules; the paint lives in the
111
+ // nested block, so lift it out of the cell rule's body.
112
+ const paint = /&::before\s*\{([^}]*)\}/.exec(blockFor(BOND_CELL))?.[1] ?? "";
113
+ expect(paint, "the bond's ::before must exist").not.toBe("");
114
+ expect(paint).toContain('content: "";');
115
+ expect(paint).toContain("position: absolute;");
116
+ expect(paint).toContain("inset-block: 0;");
117
+ expect(paint).toContain("inset-inline: 10%;");
118
+ expect(paint).toContain("background: var(--_bond-color);");
119
+ // Percentage PADDING would resolve against the step, not the bond.
120
+ expect(src).not.toMatch(/padding-inline:\s*10%/);
121
+ });
122
+
123
+ it("keeps a visible bond floor the collapse measurement can see", () => {
124
+ const cell = blockFor(BOND_CELL);
125
+ // A clamped row must still paint a bond, and the row's max-content
126
+ // width must keep reserving it — otherwise collapse="auto" measures the
127
+ // row narrower than it renders and swaps the window away too early.
128
+ expect(cell).toContain(`min-width: calc(0.5rem + ${GAP});`);
129
+ });
130
+
131
+ it("publishes one bond colour token for every status", () => {
132
+ for (const status of ["completed", "active", "pending"]) {
133
+ const bond = bondBlockOf(status);
134
+ expect(bond, `${status} must publish --_bond-color`).toContain("--_bond-color:");
135
+ expect(bond).toContain("background: var(--_bond-color);");
136
+ // No status may paint a literal colour straight onto the box again:
137
+ // in horizontal mode the box itself stays unpainted, and a stray
138
+ // longhand would tint the vertical rail red instead.
139
+ expect(bond, `${status} must not paint the box directly`).not.toMatch(
140
+ /background(-color)?:\s*(?!\s*var\(--_bond-color\))/,
141
+ );
142
+ }
143
+ // The completed bond is the primary tone; the faint ones derive from an
144
+ // OPAQUE tone. `--hi-color-border` already carries the theme's alpha, so
145
+ // 30 % of it computed to about 4.5 % — measured as ~7/255 off a white
146
+ // background (a 248 grey) in full mode, while the window-mode link had
147
+ // already been fixed.
148
+ expect(bondBlockOf("completed")).toContain(
149
+ "--_bond-color: var(--hi-color-primary);",
150
+ );
151
+ for (const status of ["active", "pending"]) {
152
+ const bond = bondBlockOf(status);
153
+ expect(bond, `${status} must not re-multiply the border token's alpha`)
154
+ .toContain("--_bond-color: color-mix(in srgb, var(--hi-color-muted");
155
+ expect(bond).not.toContain("var(--hi-color-border)");
156
+ }
157
+ });
158
+
159
+ it("leaves the vertical rail painting its own box", () => {
160
+ // The cell rule is mode+orientation scoped, so the vertical rail (a rail
161
+ // hanging under the node, not a cell-wide bond) keeps the plain
162
+ // `background: var(--_bond-color)` chain and its absolute geometry.
163
+ const vertical = blockFor(
164
+ '.hk-timeline[data-orientation="vertical"][data-mode="full"] .hk-timeline-step',
165
+ );
166
+ expect(vertical).toContain("width: var(--hk-timeline-link-thickness);");
167
+ expect(vertical).toContain("min-width: 0;");
168
+ expect(vertical).not.toContain("background: none");
169
+ // A bare `.hk-timeline-step [data-el="connector"]` override would hit the
170
+ // vertical rail too and blank it.
171
+ const unscoped = rules.filter(
172
+ (r) =>
173
+ r.selector.trim() === '.hk-timeline-step [data-el="connector"]' &&
174
+ /background:\s*none/.test(r.body),
175
+ );
176
+ expect(unscoped, "no unscoped connector background reset").toHaveLength(0);
177
+ });
178
+
179
+ it("rides the status crossfade on the painted box, not just the frame", () => {
180
+ // The paint moved to the ::before, so the crossfade must cover THAT
181
+ // selector. Asserting the bare selector name proves nothing (the
182
+ // reduced-motion block lists it too), so pin the transition BODY.
183
+ const crossfade = blockFor(BOND_PAINT);
184
+ expect(crossfade).toContain("background-color var(--hk-timeline-motion)");
185
+ // ...and the reduced-motion switch must cover the same box.
186
+ const reducedAt = src.lastIndexOf("@media (prefers-reduced-motion: reduce)");
187
+ expect(reducedAt).toBeGreaterThan(-1);
188
+ expect(src.slice(reducedAt)).toContain(BOND_PAINT);
189
+ });
190
+ });
@@ -312,6 +312,56 @@
312
312
  }
313
313
  }
314
314
 
315
+ /* ── Bond cells (2026-10-31 user rule) ──────────────────────────────────
316
+ The horizontal row reads as one cell per step plus one cell per bond: the
317
+ steps keep their content width, the leftover splits into EQUAL bond cells,
318
+ and each bond paints 80 % of its cell CENTERED, so both ends keep a 10 %
319
+ air gap. Two mechanics make that exact:
320
+
321
+ 1. The bond's box must span the WHOLE cell. Its cell begins where its own
322
+ label ends, but the step's flex gap already sits in front of it (that
323
+ gap is part of the cell, not extra air), so the box starts one gap
324
+ earlier — hence the negated start margin, which must keep reading the
325
+ SAME `--hk-timeline-step-gap` token the step's `gap` uses. Before this
326
+ the gap counted twice and the bond ran 12px off its label but only 4px
327
+ off the next node: the completed bond read as glued to the next circle.
328
+ 2. The painted 80 % cannot be `padding-inline` — a percentage padding
329
+ resolves against the CONTAINING block (the step), not the bond's own
330
+ box, which would inset it by a slice of the step instead of 10 % of the
331
+ cell. So the colour moves to a ::before pinned `inset-inline: 10%`,
332
+ which does resolve against the bond. The status rules below therefore
333
+ publish `--_bond-color`; the vertical rail keeps painting its own box
334
+ (it is a rail hanging under the node, not a cell-wide bond).
335
+
336
+ The floor keeps a squeezed row honest: the cell absorbs the gap on top of
337
+ a half-rem of bond, so the narrowest full row still paints a visible line.
338
+ Its outer size is that floor MINUS the cancelled gap, i.e. half a rem per
339
+ bond — eight of the sixteen pixels the old two 4px margins reserved, so
340
+ `collapse="auto"` (HkTimeline.tsx measures this max-content width) fires
341
+ about 32px earlier than before across a five-step row, never later.
342
+ Bounded cost, accepted: in a row squeezed past the floor (reachable through
343
+ `collapse="never"`, or the full-mode frame that a label change and a
344
+ hysteresis un-collapse render before re-measuring) the box keeps its floor
345
+ and overhangs the next step by a QUARTER of the row's shortfall — ~6px at a
346
+ 430px host, ~14px at 400px — so the bond paints under that step's circle
347
+ until the overhang outgrows the 24px circle itself (host ≲ 362px), where a
348
+ visible stub sits beside it. Shrinking the floor to nothing would trade
349
+ this for a bond that vanishes on the narrowest hosts. */
350
+ .hk-timeline[data-mode="full"][data-orientation="horizontal"] .hk-timeline-step [data-el="connector"] {
351
+ position: relative;
352
+ margin-inline: calc(-1 * var(--hk-timeline-step-gap, var(--space-8, 8px))) 0;
353
+ min-width: calc(0.5rem + var(--hk-timeline-step-gap, var(--space-8, 8px)));
354
+ background: none;
355
+
356
+ &::before {
357
+ content: "";
358
+ position: absolute;
359
+ inset-block: 0;
360
+ inset-inline: 10%;
361
+ background: var(--_bond-color);
362
+ }
363
+ }
364
+
315
365
  .hk-timeline-step[data-last] {
316
366
  flex: 0 0 auto;
317
367
  }
@@ -376,7 +426,11 @@
376
426
  }
377
427
 
378
428
  [data-el="connector"] {
379
- background: var(--hi-color-primary);
429
+ // The bonds paint 80 % of their cell through a ::before (see the
430
+ // full-mode cell rule); the token is the single source for both that
431
+ // paint and the vertical rail's own box.
432
+ --_bond-color: var(--hi-color-primary);
433
+ background: var(--_bond-color);
380
434
  }
381
435
  }
382
436
 
@@ -392,7 +446,12 @@
392
446
  }
393
447
 
394
448
  [data-el="connector"] {
395
- background: color-mix(in srgb, var(--hi-color-border) 30%, transparent);
449
+ // Opaque-tone derivation, same fix the window-mode link carries:
450
+ // `--hi-color-border` already carries the theme's own alpha, so 30 % of
451
+ // IT computed to about 4.5 % and the bond was not a line on any real
452
+ // surface (measured: ~7/255 off a white background — a 248 grey).
453
+ --_bond-color: color-mix(in srgb, var(--hi-color-muted, #6c6c6c) 30%, transparent);
454
+ background: var(--_bond-color);
396
455
  }
397
456
  }
398
457
 
@@ -408,7 +467,12 @@
408
467
  }
409
468
 
410
469
  [data-el="connector"] {
411
- background: color-mix(in srgb, var(--hi-color-border) 30%, transparent);
470
+ // Opaque-tone derivation, same fix the window-mode link carries:
471
+ // `--hi-color-border` already carries the theme's own alpha, so 30 % of
472
+ // IT computed to about 4.5 % and the bond was not a line on any real
473
+ // surface (measured: ~7/255 off a white background — a 248 grey).
474
+ --_bond-color: color-mix(in srgb, var(--hi-color-muted, #6c6c6c) 30%, transparent);
475
+ background: var(--_bond-color);
412
476
  }
413
477
  }
414
478
 
@@ -451,10 +515,14 @@
451
515
  }
452
516
 
453
517
  // Status crossfade: the data-status driven colors (indicator fill/border,
454
- // label color, connector fill) transition instead of flipping.
518
+ // label color, connector fill) transition instead of flipping. The
519
+ // horizontal bond's paint lives on its ::before (the 80 % cell rule), so
520
+ // that box rides the crossfade too — without it a status flip snaps there
521
+ // while the vertical rail keeps easing.
455
522
  .hk-timeline-step [data-el="indicator"],
456
523
  .hk-timeline-step [data-el="label"],
457
- .hk-timeline-step [data-el="connector"] {
524
+ .hk-timeline-step [data-el="connector"],
525
+ .hk-timeline[data-mode="full"][data-orientation="horizontal"] .hk-timeline-step [data-el="connector"]::before {
458
526
  transition:
459
527
  background-color var(--hk-timeline-motion) var(--hk-timeline-motion-ease),
460
528
  color var(--hk-timeline-motion) var(--hk-timeline-motion-ease),
@@ -530,7 +598,8 @@ html[data-css-animations="0"] .hk-timeline[data-mode="window"][data-dir] .hk-tim
530
598
  @media (prefers-reduced-motion: reduce) {
531
599
  .hk-timeline-step [data-el="indicator"],
532
600
  .hk-timeline-step [data-el="label"],
533
- .hk-timeline-step [data-el="connector"] {
601
+ .hk-timeline-step [data-el="connector"],
602
+ .hk-timeline[data-mode="full"][data-orientation="horizontal"] .hk-timeline-step [data-el="connector"]::before {
534
603
  transition: none;
535
604
  }
536
605
 
@@ -18,7 +18,16 @@
18
18
  --hk-tb-focus: rgb(var(--color-primary, 122 162 247) / 0.6);
19
19
  --hk-tb-close-hover: #e81123;
20
20
  --hk-tb-close-active: #f1707a;
21
+ /* The close fill is a FIXED danger red, so its ink is fixed too. Deriving
22
+ it from the theme's on-solid choice (the default dark preset picks
23
+ polar-night ink for its pale primary fills) painted a dark X on dark
24
+ red at roughly 1:1 - the host-visible bug both wowsp and momoi filed.
25
+ Hosts may override the token as a pair with --hk-tb-close-hover. */
26
+ --hk-tb-close-ink: #ffffff;
21
27
  --hk-tb-radius-close: 0 8px 0 0;
28
+ /* Caption glyph size; hosts that want a larger brand mark set the token
29
+ instead of overriding the icon rule. */
30
+ --hk-tb-icon-size: 16px;
22
31
 
23
32
  position: fixed;
24
33
  top: 0;
@@ -47,8 +56,8 @@
47
56
  }
48
57
 
49
58
  .hk-titlebar-icon {
50
- width: 16px;
51
- height: 16px;
59
+ width: var(--hk-tb-icon-size);
60
+ height: var(--hk-tb-icon-size);
52
61
  display: block;
53
62
  pointer-events: none;
54
63
  }
@@ -80,7 +89,13 @@
80
89
  height: 100%;
81
90
  }
82
91
 
83
- .hk-titlebar-btn {
92
+ /* Caption buttons are scoped under the bar on purpose: a host's element
93
+ rules (`button:hover:not(:disabled)` scores (0,2,1)) would otherwise
94
+ out-rank the component's class-level hover and repaint the caption -
95
+ the momoi field report. Scoped selectors sit at (0,3,0), one level
96
+ above any plausible element rule, while the close-specific rules stay
97
+ later in the sheet so they keep winning among themselves. */
98
+ .hk-titlebar .hk-titlebar-btn {
84
99
  width: 46px;
85
100
  height: var(--hk-tb-height);
86
101
  border: none;
@@ -95,24 +110,24 @@
95
110
  padding: 0;
96
111
  }
97
112
 
98
- .hk-titlebar-btn:hover {
113
+ .hk-titlebar .hk-titlebar-btn:hover {
99
114
  background: var(--hk-tb-hover);
100
115
  color: var(--hk-tb-fg-strong);
101
116
  }
102
117
 
103
- .hk-titlebar-btn:active {
118
+ .hk-titlebar .hk-titlebar-btn:active {
104
119
  background: var(--hk-tb-active);
105
120
  }
106
121
 
107
122
  /* Only the rightmost (close) button carries the window's top-right
108
123
  corner radius; the other captions highlight as plain rectangles. */
109
- .hk-titlebar-btn-close {
124
+ .hk-titlebar .hk-titlebar-btn-close {
110
125
  border-radius: var(--hk-tb-radius-close);
111
126
  }
112
127
 
113
128
  /* RTL mirrors the caption row, so the close button lands top-left and
114
129
  the window corner radius flips with it. */
115
- [dir="rtl"] .hk-titlebar-btn-close {
130
+ [dir="rtl"] .hk-titlebar .hk-titlebar-btn-close {
116
131
  border-radius: 8px 0 0 0;
117
132
  }
118
133
 
@@ -125,17 +140,17 @@
125
140
  border-radius: 0;
126
141
  }
127
142
 
128
- .hk-titlebar-btn-close:hover {
143
+ .hk-titlebar .hk-titlebar-btn-close:hover {
129
144
  background: var(--hk-tb-close-hover);
130
- color: var(--hi-color-text-on-danger, #ffffff);
145
+ color: var(--hk-tb-close-ink);
131
146
  }
132
147
 
133
- .hk-titlebar-btn-close:active {
148
+ .hk-titlebar .hk-titlebar-btn-close:active {
134
149
  background: var(--hk-tb-close-active);
135
- color: var(--hi-color-text-on-danger, #ffffff);
150
+ color: var(--hk-tb-close-ink);
136
151
  }
137
152
 
138
- .hk-titlebar-btn:focus-visible {
153
+ .hk-titlebar .hk-titlebar-btn:focus-visible {
139
154
  outline: 2px solid var(--hk-tb-focus);
140
155
  outline-offset: -2px;
141
156
  }
@@ -213,7 +213,38 @@ describe("HkTitleBar stylesheet contract", () => {
213
213
  it("keeps the windowed radius var wired to the close button", () => {
214
214
  const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
215
215
  expect(scss).toContain("--hk-tb-radius-close:");
216
- const body = ruleBody(css, ".hk-titlebar-btn-close");
216
+ const body = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close");
217
217
  expect(body).toContain("border-radius: var(--hk-tb-radius-close)");
218
218
  });
219
+
220
+ it("inks the close fill from its own token, never the theme's on-solid", () => {
221
+ // The fill is a fixed danger red; the ink must be fixed with it. The
222
+ // theme-derived --hi-color-text-on-danger painted a dark X on dark red
223
+ // under the default dark preset (the wowsp/momoi field report).
224
+ const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
225
+ expect(scss).toContain("--hk-tb-close-ink:");
226
+ const hover = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close:hover");
227
+ expect(hover).toContain("color: var(--hk-tb-close-ink)");
228
+ expect(hover).not.toContain("--hi-color-text-on-danger");
229
+ const active = ruleBody(css, ".hk-titlebar .hk-titlebar-btn-close:active");
230
+ expect(active).toContain("color: var(--hk-tb-close-ink)");
231
+ });
232
+
233
+ it("outranks host element rules with caption-scoped buttons", () => {
234
+ // (0,3,0) beats a host's `button:hover:not(:disabled)` at (0,2,1);
235
+ // the close-specific rules stay later in the sheet, so they still win.
236
+ const hover = ruleBody(css, ".hk-titlebar .hk-titlebar-btn:hover");
237
+ expect(hover).toContain("background: var(--hk-tb-hover)");
238
+ const closeHoverAt = css.indexOf(".hk-titlebar .hk-titlebar-btn-close:hover");
239
+ const hoverAt = css.indexOf(".hk-titlebar .hk-titlebar-btn:hover");
240
+ expect(closeHoverAt).toBeGreaterThan(hoverAt);
241
+ });
242
+
243
+ it("sizes the caption icon through its token", () => {
244
+ const scss = readFileSync(resolve(componentDir, "HkTitleBar.scss"), "utf8");
245
+ expect(scss).toContain("--hk-tb-icon-size:");
246
+ const body = ruleBody(css, ".hk-titlebar-icon");
247
+ expect(body).toContain("width: var(--hk-tb-icon-size)");
248
+ expect(body).toContain("height: var(--hk-tb-icon-size)");
249
+ });
219
250
  });
@@ -1,4 +1,4 @@
1
- import { Maximize2, ZoomIn, ZoomOut } from "lucide-vue-next";
1
+ import { RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
2
2
  import { defineComponent } from "vue";
3
3
 
4
4
 
@@ -62,7 +62,7 @@ export default defineComponent({
62
62
  ariaLabel={t("hikari::zoomToolbar.reset", "Reset zoom")}
63
63
  onClick={() => emit("reset")}
64
64
  >
65
- <Maximize2 size={14} />
65
+ <RotateCcw size={14} />
66
66
  </HButton>
67
67
  )}
68
68
  </div>
@@ -35,6 +35,9 @@ const menuScss = readFileSync(join(here, "HkMenu.scss"), "utf-8");
35
35
  const affixScss = readFileSync(join(here, "HkAffixPicker.scss"), "utf-8");
36
36
  const tagInputScss = readFileSync(join(here, "HkTagInput.scss"), "utf-8");
37
37
  const tplScss = readFileSync(join(here, "HkTemplateField.scss"), "utf-8");
38
+ const userPickerScss = readFileSync(join(here, "HkUserPicker.scss"), "utf-8");
39
+ const tagInputTsx = readFileSync(join(here, "HkTagInput.tsx"), "utf-8");
40
+ const userPickerTsx = readFileSync(join(here, "HkUserPicker.tsx"), "utf-8");
38
41
  const themeTsx = readFileSync(join(here, "HkThemePanelContent.tsx"), "utf8");
39
42
  const adminTokens = readFileSync(join(here, "../styles/admin-tokens.scss"), "utf-8");
40
43
  const panelScss = readFileSync(join(here, "HkMenuPanel.scss"), "utf-8");
@@ -242,6 +245,13 @@ describe("menu-item grammar contract", () => {
242
245
  expect(adminTokens).toMatch(
243
246
  /\.s-user-header \{[^}]*padding: var\(--space-10\) var\(--space-14\) var\(--space-12\)/,
244
247
  );
248
+ // The drawer is the third hosting surface (.hk-drawer-panel is one of
249
+ // the grammar's sheet contexts) — its twin carries the SHEET pad-x.
250
+ // Without it a drawer identity block kept the bare 14px while the
251
+ // drawer rows around it rode 1px + 16px (2026-10-31 audit finding 9).
252
+ expect(adminTokens).toMatch(
253
+ /\.hk-drawer-panel \.s-user-header \{[^}]*padding-inline: calc\(1px \+ var\(--hk-menu-item-pad-x, 16px\)\)/,
254
+ );
245
255
  });
246
256
 
247
257
  it("spends the italic overhang cushion in the label mixin only", () => {
@@ -777,4 +787,74 @@ describe("menu-item grammar contract", () => {
777
787
  ).toEqual([]);
778
788
  }
779
789
  });
790
+
791
+ it("keeps the adopted row families on the mixin's geometry (no local padding/min-height)", () => {
792
+ // The 2026-10-31 adoption gave four hand-rolled row families
793
+ // @include mi.item — the mixin OWNS their padding and min-height
794
+ // (6×12 desktop / 10×16 sheet via the registration lists). A local
795
+ // padding or min-height declaration in the family's own rule
796
+ // overrides the mixin at the same specificity and silently forks
797
+ // that family off the grammar again (the pre-adoption stock was
798
+ // exactly such a fork). Layout-axis restatements (flex direction,
799
+ // justify, text-align, color) are content decisions and stay legal.
800
+ const sources: Array<[string, string]> = [
801
+ [".hk-affix-row", affixScss],
802
+ [".hk-tag-input-row", tagInputScss],
803
+ [".hk-tpl-row", tplScss],
804
+ [".hk-user-picker-row", userPickerScss],
805
+ ];
806
+ for (const [selector, sheet] of sources) {
807
+ const block = flattenBlocks(parseScss(stripScssComments(sheet))).find(
808
+ (candidate) =>
809
+ candidate.prelude
810
+ .trim()
811
+ .split(",")
812
+ .map((sel) => sel.trim())
813
+ .includes(selector),
814
+ );
815
+ expect(block, `${selector} rule exists`).toBeTruthy();
816
+ const offenders = block!.statements
817
+ .map((stmt) => stmt.replace(/\s+/g, " ").trim())
818
+ .filter((stmt) => /^(padding|min-height)\s*[:]/.test(stmt));
819
+ expect(
820
+ offenders,
821
+ `${selector} declares NO padding/min-height of its own — @mixin item owns the row box (a local declaration forks the family off the grammar)`,
822
+ ).toEqual([]);
823
+ }
824
+ });
825
+
826
+ it("keeps the grammar's data-checked emission beside the legacy state attrs", () => {
827
+ // The sharpest honest gap of the adoption R3: TagInput/UserPicker
828
+ // rows carry BOTH the legacy state (data-selected / .is-selected —
829
+ // tests and hosts key on those) and the grammar's data-checked (the
830
+ // face + the hover guard). A tsx-only revert of the emission keeps
831
+ // every test green while the checked face silently dies to the
832
+ // hover rule — so the emission itself is pinned at source level.
833
+ expect(
834
+ tagInputTsx,
835
+ "HkTagInput must emit data-checked beside its legacy data-selected (the grammar's checked face and hover guard key on it)",
836
+ ).toMatch(/data-checked=\{selected \|\| undefined\}/);
837
+ expect(
838
+ userPickerTsx,
839
+ "HkUserPicker must emit data-checked for the multi-mode draft pick beside the legacy .is-selected class",
840
+ ).toMatch(/data-checked=\{props\.mode === "multi" && inDraft\(user\.uid\) \|\| undefined\}/);
841
+ });
842
+
843
+ it("sizes the tag-input grip to the adopted row's content box", () => {
844
+ // The grip is fixed-size inside a flex-auto row: taller than the
845
+ // content box and every selected+reorderable row grows past the
846
+ // grammar min-height while siblings stay on it (the 2026-10-31 R2
847
+ // S1 — 28px grip in a 22px box). Pinned at source level: the grip
848
+ // height must equal 34px − 2×6px.
849
+ const grip = flattenBlocks(parseScss(stripScssComments(tagInputScss))).find(
850
+ (block) => block.prelude.trim() === ".hk-tag-input-grip",
851
+ );
852
+ expect(grip, ".hk-tag-input-grip rule exists").toBeTruthy();
853
+ const height = grip!.statements.find((stmt) => stmt.includes("height:"));
854
+ expect(height, "the grip declares a height").toBeTruthy();
855
+ expect(
856
+ height!.replace(/\s+/g, " ").trim(),
857
+ "grip height = 22px (the 34px row's content box: 34 − 2×6px)",
858
+ ).toBe("height: 1.375rem");
859
+ });
780
860
  });