@celestia-island/hikari 0.55.253 → 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.253",
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. */
@@ -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);
@@ -424,4 +427,43 @@ describe("HkMinimap3D collapse affordance (ported from the 2D minimap)", () => {
424
427
  expect(container.querySelector(".hk-mm-collapse-btn")).toBeNull();
425
428
  expect(container.querySelector(".hk-minimap--collapsed")).toBeNull();
426
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
+ });
427
469
  });
@@ -24,7 +24,7 @@
24
24
  * mount.
25
25
  */
26
26
 
27
- import { Map as MapIcon, Maximize2, Minimize2, ZoomIn, ZoomOut } from "lucide-vue-next";
27
+ import { Map as MapIcon, Minimize2, RotateCcw, ZoomIn, ZoomOut } from "lucide-vue-next";
28
28
  import {
29
29
  computed,
30
30
  defineComponent,
@@ -381,11 +381,36 @@ export default defineComponent({
381
381
  }
382
382
  });
383
383
 
384
- /** Idempotent GL-surface init. Called from onMounted (expanded
385
- * mounts) and from the expand transition (a defaultCollapsed map
386
- * has no canvas at mount — the renderer builds on first expand). */
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). */
387
391
  function initRenderer(): void {
388
- if (renderer) return;
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
+ }
389
414
  const canvas = canvasRef.value;
390
415
  const el = rootRef.value;
391
416
  if (!canvas || !el) return;
@@ -519,7 +544,7 @@ export default defineComponent({
519
544
  aria-label={t("hikari::board3d.resetView", "Reset view")}
520
545
  title={t("hikari::board3d.resetView", "Reset view")}
521
546
  >
522
- <Maximize2 size={11} />
547
+ <RotateCcw size={11} />
523
548
  </button>
524
549
  {props.collapsible && (
525
550
  <button
@@ -140,4 +140,13 @@ describe("HkTimeZoneGlobe look contract (source-level)", () => {
140
140
  expect(src).toContain("minimapCollapsible");
141
141
  expect(src).toContain("minimapDefaultCollapsed");
142
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
+ });
143
152
  });
@@ -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
  });