@celestia-island/hikari 0.49.4 → 0.49.6

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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkBlockingToast.test.tsx +34 -15
  3. package/src/components/HkBlockingToast.tsx +9 -6
  4. package/src/components/HkBoard.tsx +8 -1
  5. package/src/components/HkConfirmDialog.test.tsx +155 -0
  6. package/src/components/HkConfirmDialog.tsx +32 -9
  7. package/src/components/HkDateTimePicker.test.ts +17 -4
  8. package/src/components/HkMenu.scss +7 -0
  9. package/src/components/HkMenu.test.ts +12 -5
  10. package/src/components/HkMenu.tsx +31 -11
  11. package/src/components/HkMessageBox.tsx +9 -7
  12. package/src/components/HkMinimap.scss +47 -1
  13. package/src/components/HkMinimap.test.tsx +189 -0
  14. package/src/components/HkMinimap.tsx +95 -17
  15. package/src/components/HkModal.scss +18 -7
  16. package/src/components/HkModelTag.test.tsx +19 -1
  17. package/src/components/HkModelTag.tsx +22 -15
  18. package/src/components/HkThemeToggle.tsx +6 -1
  19. package/src/components/_menu-item.contract.test.ts +90 -0
  20. package/src/components/_menu-item.scss +38 -1
  21. package/src/i18n/locales/ar/components.json +2 -1
  22. package/src/i18n/locales/de/components.json +2 -1
  23. package/src/i18n/locales/en/components.json +2 -1
  24. package/src/i18n/locales/es/components.json +2 -1
  25. package/src/i18n/locales/fr/components.json +2 -1
  26. package/src/i18n/locales/ja/components.json +2 -1
  27. package/src/i18n/locales/ko/components.json +2 -1
  28. package/src/i18n/locales/pt/components.json +2 -1
  29. package/src/i18n/locales/ru/components.json +2 -1
  30. package/src/i18n/locales/zh-Hans/components.json +2 -1
  31. package/src/i18n/locales/zh-Hant/components.json +2 -1
  32. package/src/styles/admin-tokens.scss +21 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.49.4",
3
+ "version": "0.49.6",
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",
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h, nextTick } from "vue";
3
3
 
4
4
  import HkBlockingToast from "./HkBlockingToast";
@@ -48,6 +48,16 @@ function actionButtons(card: HTMLElement): HTMLButtonElement[] {
48
48
  return Array.from(card.querySelectorAll<HTMLButtonElement>(".hk-blocking-toast-actions button"));
49
49
  }
50
50
 
51
+ /** Answer the gate by semantics, not by position: the row order is pinned
52
+ * separately, so these helpers keep the behaviour cases readable. */
53
+ function confirmButton(card: HTMLElement): HTMLButtonElement {
54
+ return actionButtons(card).find((b) => !b.classList.contains("hk-btn-secondary"))!;
55
+ }
56
+
57
+ function cancelButton(card: HTMLElement): HTMLButtonElement {
58
+ return actionButtons(card).find((b) => b.classList.contains("hk-btn-secondary"))!;
59
+ }
60
+
51
61
  function toastRegistryTitles(): Array<{ kind: string; title?: string }> {
52
62
  return [...usePopupManager().registry.value.values()].map((e) => ({ kind: e.kind, title: e.title }));
53
63
  }
@@ -73,9 +83,9 @@ describe("HkBlockingToast", () => {
73
83
  expect(card.querySelector(".hk-blocking-toast-title")!.textContent).toContain("Join group?");
74
84
  expect(card.querySelector(".hk-blocking-toast-message")!.textContent).toContain("Admins can view your usage.");
75
85
  const labels = actionButtons(card).map((b) => b.textContent?.trim());
76
- expect(labels).toEqual(["Cancel", "Confirm"]);
86
+ expect(labels).toEqual(["Confirm", "Cancel"]);
77
87
 
78
- actionButtons(card)[1].click();
88
+ confirmButton(card).click();
79
89
  await expect(gate).resolves.toBe(true);
80
90
  await settle();
81
91
  expect(cards().length).toBe(0);
@@ -87,7 +97,7 @@ describe("HkBlockingToast", () => {
87
97
  await settle();
88
98
  const card = cards()[0];
89
99
 
90
- actionButtons(card)[0].click();
100
+ cancelButton(card).click();
91
101
  await expect(gate).resolves.toBe(false);
92
102
  await settle();
93
103
  expect(cards().length).toBe(0);
@@ -103,7 +113,7 @@ describe("HkBlockingToast", () => {
103
113
  expect(cards().length).toBe(1);
104
114
 
105
115
  // Clean up the deliberately unanswered gate.
106
- actionButtons(cards()[0])[1].click();
116
+ confirmButton(cards()[0]).click();
107
117
  await expect(gate).resolves.toBe(true);
108
118
  });
109
119
 
@@ -115,21 +125,29 @@ describe("HkBlockingToast", () => {
115
125
  await settle();
116
126
  expect(toastRegistryTitles().some((e) => e.kind === "toast" && e.title === "Consent")).toBe(true);
117
127
 
118
- actionButtons(cards()[0])[0].click();
128
+ cancelButton(cards()[0]).click();
119
129
  await expect(gate).resolves.toBe(false);
120
130
  await settle();
121
131
  expect(toastRegistryTitles().some((e) => e.kind === "toast")).toBe(false);
122
132
  });
123
133
 
124
134
  it("resolves false when timeoutMs expires", async () => {
135
+ // Virtual clock: a real 40ms timer races suite load on busy runners
136
+ // (the card teardown could lag past the resolution window). settle()'s
137
+ // own sleeps ride the same clock via advanceTimersByTimeAsync.
138
+ vi.useFakeTimers();
125
139
  mountHost();
126
140
  const gate = showBlockingToast("Times out", { timeoutMs: 40 });
127
- await settle();
141
+ await vi.advanceTimersByTimeAsync(30);
142
+ await nextTick();
128
143
  expect(cards().length).toBe(1);
129
144
 
145
+ await vi.advanceTimersByTimeAsync(41);
130
146
  await expect(gate).resolves.toBe(false);
131
- await settle();
147
+ await vi.advanceTimersByTimeAsync(30);
148
+ await nextTick();
132
149
  expect(cards().length).toBe(0);
150
+ vi.useRealTimers();
133
151
  });
134
152
 
135
153
  it("stacks multiple prompts that resolve independently", async () => {
@@ -144,13 +162,13 @@ describe("HkBlockingToast", () => {
144
162
  expect(cardTwo.querySelector(".hk-blocking-toast-title")!.textContent).toContain("Two");
145
163
 
146
164
  // Answer only the second; the first keeps blocking.
147
- actionButtons(cardTwo)[1].click();
165
+ confirmButton(cardTwo).click();
148
166
  await expect(second).resolves.toBe(true);
149
167
  await settle();
150
168
  expect(cards().length).toBe(1);
151
169
  expect(cards()[0].querySelector(".hk-blocking-toast-title")!.textContent).toContain("One");
152
170
 
153
- actionButtons(cards()[0])[0].click();
171
+ cancelButton(cards()[0]).click();
154
172
  await expect(first).resolves.toBe(false);
155
173
  });
156
174
 
@@ -166,9 +184,9 @@ describe("HkBlockingToast", () => {
166
184
  const card = cards()[0];
167
185
  expect(card.classList.contains("hk-blocking-toast-danger")).toBe(true);
168
186
  const labels = actionButtons(card).map((b) => b.textContent?.trim());
169
- expect(labels).toEqual(["Back out", "Join"]);
187
+ expect(labels).toEqual(["Join", "Back out"]);
170
188
 
171
- actionButtons(card)[1].click();
189
+ confirmButton(card).click();
172
190
  await expect(gate).resolves.toBe(true);
173
191
  });
174
192
 
@@ -179,9 +197,9 @@ describe("HkBlockingToast", () => {
179
197
  await settle();
180
198
 
181
199
  const labels = actionButtons(cards()[0]).map((b) => b.textContent?.trim());
182
- expect(labels).toEqual(["取消", "确认"]);
200
+ expect(labels).toEqual(["确认", "取消"]);
183
201
 
184
- actionButtons(cards()[0])[1].click();
202
+ confirmButton(cards()[0]).click();
185
203
  await expect(gate).resolves.toBe(true);
186
204
  await setLocale("en");
187
205
  });
@@ -203,7 +221,8 @@ describe("HkBlockingToast", () => {
203
221
  Object.defineProperty(slot!, "offsetWidth", { value: 384, configurable: true });
204
222
  Object.defineProperty(slot!, "offsetHeight", { value: 120, configurable: true });
205
223
 
206
- actionButtons(cards()[0])[0].click();
224
+ // Dismiss (not confirm): this case only cares about the leave animation.
225
+ cancelButton(cards()[0]).click();
207
226
  await nextTick();
208
227
 
209
228
  const leaving = document.querySelector<HTMLElement>(".hk-blocking-toast-leave-active");
@@ -79,19 +79,22 @@ const HkBlockingToastCard = defineComponent({
79
79
  ) : null}
80
80
  <p class="hk-blocking-toast-message">{item.message}</p>
81
81
  <div class="hk-blocking-toast-actions">
82
+ {/* Confirmation surfaces share one order: primary action first
83
+ (left), dismiss second (right) — matching HkConfirmDialog
84
+ and HkMessageBox. */}
82
85
  <HButton
83
- variant="secondary"
86
+ variant={item.variant === "danger" ? "danger" : "primary"}
84
87
  size="sm"
85
- onClick={() => emit("cancel")}
88
+ onClick={() => emit("confirm")}
86
89
  >
87
- {item.cancelLabel ?? t("hikari::blockingToast.cancel", "Cancel")}
90
+ {item.confirmLabel ?? t("hikari::blockingToast.confirm", "Confirm")}
88
91
  </HButton>
89
92
  <HButton
90
- variant={item.variant === "danger" ? "danger" : "primary"}
93
+ variant="secondary"
91
94
  size="sm"
92
- onClick={() => emit("confirm")}
95
+ onClick={() => emit("cancel")}
93
96
  >
94
- {item.confirmLabel ?? t("hikari::blockingToast.confirm", "Confirm")}
97
+ {item.cancelLabel ?? t("hikari::blockingToast.cancel", "Cancel")}
95
98
  </HButton>
96
99
  </div>
97
100
  </div>
@@ -45,6 +45,7 @@ import HkMinimap, { type MinimapBox } from "./HkMinimap";
45
45
  import {
46
46
  boardBBox,
47
47
  boardStepRung,
48
+ quantizeBoardK,
48
49
  type BoardCamera,
49
50
  type BoardPoint,
50
51
  type BoardRect,
@@ -450,6 +451,9 @@ export default defineComponent({
450
451
  ))}
451
452
  </div>
452
453
  {props.minimap && (
454
+ // Slider drags arrive as ABSOLUTE rung targets — land the camera
455
+ // on the thumb's rung exactly (boardStepRung's directional push
456
+ // is only for the ± buttons' relative steps).
453
457
  <div class="hk-board-minimap">
454
458
  <HkMinimap
455
459
  boxes={minimapBoxes.value}
@@ -466,7 +470,10 @@ export default defineComponent({
466
470
  minZoomPercent={Math.round(props.minK * 100)}
467
471
  maxZoomPercent={Math.round(props.maxK * 100)}
468
472
  showReset
469
- onZoomTo={(percent: number) => cam.zoomToK(boardStepRung(camera.value.k, percent / 100))}
473
+ onZoomTo={(percent: number, source?: "slider" | "step") =>
474
+ cam.zoomToK(source === "slider"
475
+ ? quantizeBoardK(percent / 100)
476
+ : boardStepRung(camera.value.k, percent / 100))}
470
477
  onReset={() => cam.fit()}
471
478
  onPanDelta={(dx: number, dy: number) => cam.panBy(dx, dy)}
472
479
  />
@@ -0,0 +1,155 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkConfirmDialog from "./HkConfirmDialog";
5
+
6
+ const apps: ReturnType<typeof createApp>[] = [];
7
+ const containers: HTMLElement[] = [];
8
+
9
+ afterEach(() => {
10
+ for (const app of apps.splice(0)) app.unmount();
11
+ for (const el of containers.splice(0)) el.remove();
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ async function flush() {
16
+ await nextTick();
17
+ await nextTick();
18
+ await new Promise((resolve) => setTimeout(resolve, 0));
19
+ }
20
+
21
+ /** Mount an open confirm dialog and record every event it emits. */
22
+ function mountDialog(extra: Record<string, unknown> = {}) {
23
+ const container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ containers.push(container);
26
+
27
+ const open = ref(true);
28
+ const events: string[] = [];
29
+ const Host = defineComponent({
30
+ setup() {
31
+ return () =>
32
+ h(HkConfirmDialog, {
33
+ open: open.value,
34
+ title: "Enable emergency lockdown?",
35
+ message: "Every agent tool call is blocked.",
36
+ confirmLabel: "Confirm",
37
+ cancelLabel: "Cancel",
38
+ onConfirm: () => events.push("confirm"),
39
+ onCancel: () => events.push("cancel"),
40
+ "onUpdate:open": (v: boolean) => events.push(`open:${v}`),
41
+ ...extra,
42
+ });
43
+ },
44
+ });
45
+ const app = createApp(Host);
46
+ apps.push(app);
47
+ app.mount(container);
48
+ return { open, events };
49
+ }
50
+
51
+ function actionButtons(): HTMLButtonElement[] {
52
+ return [
53
+ ...document.body.querySelectorAll<HTMLButtonElement>(
54
+ ".hk-confirm-dialog-actions button",
55
+ ),
56
+ ];
57
+ }
58
+
59
+ describe("HkConfirmDialog", () => {
60
+ it("puts the primary action before the dismiss action", async () => {
61
+ mountDialog();
62
+ await flush();
63
+
64
+ const buttons = actionButtons();
65
+ expect(buttons.map((b) => b.textContent)).toEqual(["Confirm", "Cancel"]);
66
+ // The first button is the primary one (danger by default), the second the
67
+ // secondary dismiss — desktop message-box ordering.
68
+ expect(buttons[0]!.classList.contains("hk-btn-danger")).toBe(true);
69
+ expect(buttons[1]!.classList.contains("hk-btn-secondary")).toBe(true);
70
+ });
71
+
72
+ it("resolves through confirm + update:open on the primary action", async () => {
73
+ const { events } = mountDialog();
74
+ await flush();
75
+
76
+ actionButtons()[0]!.click();
77
+ await flush();
78
+
79
+ expect(events).toEqual(["confirm", "open:false"]);
80
+ });
81
+
82
+ it("resolves through cancel + update:open on the dismiss action", async () => {
83
+ const { events } = mountDialog();
84
+ await flush();
85
+
86
+ actionButtons()[1]!.click();
87
+ await flush();
88
+
89
+ expect(events).toEqual(["cancel", "open:false"]);
90
+ });
91
+
92
+ /** `open` is caller-owned, so relaying the modal's close request as
93
+ * `update:open` alone left the window on screen and the caller's promise
94
+ * pending: the operator hit ✕, nothing happened, and an awaiting
95
+ * `useConfirm()` caller waited until unmount (user report 2026-09-11). */
96
+ it("dismisses as a cancel when the title-bar close button is used", async () => {
97
+ const { events } = mountDialog();
98
+ await flush();
99
+
100
+ const close = document.body.querySelector<HTMLButtonElement>(".hk-modal-close");
101
+ expect(close, "title-bar close button renders").toBeTruthy();
102
+ close!.click();
103
+ await flush();
104
+
105
+ expect(events).toContain("cancel");
106
+ expect(events).toContain("open:false");
107
+ expect(events).not.toContain("confirm");
108
+ });
109
+
110
+ it("dismisses as a cancel when the overlay is clicked", async () => {
111
+ const { events } = mountDialog();
112
+ await flush();
113
+
114
+ const overlay = document.body.querySelector<HTMLElement>(".hk-modal-overlay");
115
+ expect(overlay, "overlay renders").toBeTruthy();
116
+ overlay!.click();
117
+ await flush();
118
+
119
+ expect(events).toContain("cancel");
120
+ expect(events).toContain("open:false");
121
+ });
122
+
123
+ it("dismisses as a cancel on Escape", async () => {
124
+ const { events } = mountDialog();
125
+ await flush();
126
+
127
+ const surface = document.body.querySelector<HTMLElement>(".hk-modal-content");
128
+ expect(surface, "dialog surface renders").toBeTruthy();
129
+ surface!.dispatchEvent(
130
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
131
+ );
132
+ await flush();
133
+
134
+ expect(events).toContain("cancel");
135
+ expect(events).toContain("open:false");
136
+ expect(events).not.toContain("confirm");
137
+ });
138
+
139
+ it("cannot be dismissed while the confirmation is loading", async () => {
140
+ const { events } = mountDialog({ loading: true });
141
+ await flush();
142
+
143
+ expect(document.body.querySelector(".hk-modal-close")).toBeNull();
144
+ // Both actions refuse input too, so a half-submitted confirmation cannot
145
+ // be answered twice.
146
+ const buttons = actionButtons();
147
+ expect(buttons.map((b) => b.disabled)).toEqual([true, true]);
148
+ buttons.forEach((b) => b.click());
149
+ const overlay = document.body.querySelector<HTMLElement>(".hk-modal-overlay");
150
+ overlay?.click();
151
+ await flush();
152
+
153
+ expect(events).toEqual([]);
154
+ });
155
+ });
@@ -37,36 +37,59 @@ export default defineComponent({
37
37
  emit("update:open", false);
38
38
  }
39
39
 
40
+ /**
41
+ * Window chrome (✕, overlay click, Escape) is a DISMISS, never a no-op.
42
+ *
43
+ * `open` is caller-owned, so relaying the modal's close request as
44
+ * `update:open` alone left the dialog on screen and the caller's promise
45
+ * unsettled: the operator closed the window, nothing happened, and an
46
+ * awaiting `useConfirm()` caller stayed pending until unmount (user
47
+ * report, 2026-09-11). Routing the close through the cancel path settles
48
+ * the caller and lets it take the window down.
49
+ */
50
+ function onModalUpdate(next: boolean) {
51
+ if (next) {
52
+ emit("update:open", true);
53
+ return;
54
+ }
55
+ onCancel();
56
+ }
57
+
40
58
  return () => (
41
59
  <HModal
42
60
  modelValue={props.open}
43
61
  title={props.title}
44
62
  closable={!props.loading}
45
63
  width="24rem"
46
- onUpdate:modelValue={(v: boolean) => emit("update:open", v)}
64
+ onUpdate:modelValue={onModalUpdate}
47
65
  >
48
66
  {{
49
67
  default: () => (
50
68
  <div class="hk-confirm-dialog">
51
69
  <p class="hk-confirm-dialog-message">{props.message}</p>
70
+ {/* Primary action first, dismiss second — the message-box order
71
+ * the confirmation surfaces share (HkMessageBox,
72
+ * HkBlockingToast); form-dialog footers keep the primary
73
+ * rightmost. It mirrors under `dir="rtl"` because the row only
74
+ * reverses visually. */}
52
75
  <div class="hk-confirm-dialog-actions">
53
76
  <HButton
54
77
  class="hk-confirm-dialog-btn"
55
- variant="secondary"
78
+ variant={props.confirmVariant}
56
79
  size="md"
57
- disabled={props.loading}
58
- onClick={onCancel}
80
+ loading={props.loading}
81
+ onClick={onConfirm}
59
82
  >
60
- {props.cancelLabel || t("hikari::confirmDialog.cancel", "Cancel")}
83
+ {props.confirmLabel || t("hikari::confirmDialog.confirm", "Confirm")}
61
84
  </HButton>
62
85
  <HButton
63
86
  class="hk-confirm-dialog-btn"
64
- variant={props.confirmVariant}
87
+ variant="secondary"
65
88
  size="md"
66
- loading={props.loading}
67
- onClick={onConfirm}
89
+ disabled={props.loading}
90
+ onClick={onCancel}
68
91
  >
69
- {props.confirmLabel || t("hikari::confirmDialog.confirm", "Confirm")}
92
+ {props.cancelLabel || t("hikari::confirmDialog.cancel", "Cancel")}
70
93
  </HButton>
71
94
  </div>
72
95
  </div>
@@ -214,7 +214,13 @@ describe("HkDateTimePicker", () => {
214
214
  const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
215
215
  monthBtn?.click();
216
216
  await settle();
217
- await waitForView("the months grid", () => pickCells().length === 12);
217
+ // Destination state = months pane mounted AND the leaving days pane
218
+ // gone — mid-transition the cell counts already read final while the
219
+ // stage still carries both panes.
220
+ await waitForView("the months grid settled to one pane", () =>
221
+ pickCells().length === 12 &&
222
+ picker()?.querySelectorAll(".hk-dtp-cell").length === 12 &&
223
+ stage?.children.length === 1);
218
224
  expect(stage?.getAttribute("data-dir")).toBe("fwd");
219
225
  expect(picker()?.querySelector<HTMLElement>(".hk-dtp-stage")).toBe(stage);
220
226
  expect(stage?.children.length).toBe(1); // one pane at a time after settle
@@ -249,15 +255,22 @@ describe("HkDateTimePicker", () => {
249
255
  const monthBtn = picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-title-btn")[0];
250
256
  monthBtn?.click();
251
257
  await settle();
252
- await waitForView("the months grid", () => pickCells().length === 12);
258
+ // Destination state = months pane mounted AND the leaving days pane
259
+ // gone — mid-transition the cell counts already read final while the
260
+ // stage still carries both panes.
261
+ await waitForView("the months grid settled to one pane", () =>
262
+ pickCells().length === 12 &&
263
+ picker()?.querySelectorAll(".hk-dtp-cell:not([data-variant])").length === 0);
253
264
  expect(pickCells().length).toBe(12);
254
265
  // The first step button is "Hour +"; bumping it changes only the time
255
266
  // part of the model, which must not yank the view back to days.
256
267
  const upBtn = picker()?.querySelector<HTMLButtonElement>(".hk-dtp-step-btn");
257
268
  upBtn?.click();
258
269
  await nextTick();
259
- expect(picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-cell[data-variant='pick']").length).toBe(12);
260
- expect(picker()?.querySelectorAll<HTMLButtonElement>(".hk-dtp-cell:not([data-variant])").length).toBe(0);
270
+ await waitForView("still the settled months grid after the bump", () =>
271
+ pickCells().length === 12 &&
272
+ picker()?.querySelectorAll(".hk-dtp-cell:not([data-variant])").length === 0);
273
+ expect(picker()?.querySelectorAll(".hk-dtp-cell:not([data-variant])").length).toBe(0);
261
274
  });
262
275
 
263
276
  it("selecting a day emits a new Date preserving the time of day", async () => {
@@ -16,6 +16,13 @@
16
16
  @include mi.item;
17
17
  }
18
18
 
19
+ /* Header/footer/extra slot wrappers: geometry-neutral by contract (the
20
+ * panel's own inset applies), but they are the DOM hook that says rows
21
+ * in here speak the menu-item grammar. */
22
+ .hk-menu-slot {
23
+ display: block;
24
+ }
25
+
19
26
  .hk-menu-flag {
20
27
  flex-shrink: 0;
21
28
  font-size: var(--text-base);
@@ -465,11 +465,16 @@ describe("HkMenu slots", () => {
465
465
  expect(level).toBeTruthy();
466
466
  expect(level.querySelector(".menu-header")!.textContent).toBe("Identity Block");
467
467
  expect(level.querySelector(".menu-footer")!.textContent).toBe("Footer Note");
468
- // Ordering: header → rows → footer.
468
+ // Ordering: header → rows → footer, with the header/footer riding
469
+ // the standard .hk-menu-slot wrappers (their rows inherit the
470
+ // panel's inset; consumers' markup stays untouched inside).
469
471
  const children = Array.from(level.children).map((el) => el.className);
470
- expect(children[0]).toContain("menu-header");
472
+ expect(children[0]).toContain("hk-menu-slot--head");
471
473
  expect(children[1]).toContain("hk-menu-row");
472
- expect(children[children.length - 1]).toContain("menu-footer");
474
+ expect(children[children.length - 1]).toContain("hk-menu-slot--foot");
475
+ // The consumer's own markup lives untouched INSIDE the wrappers.
476
+ expect(level.querySelector(".hk-menu-slot--head")!.querySelector(".menu-header")).not.toBeNull();
477
+ expect(level.querySelector(".hk-menu-slot--foot")!.querySelector(".menu-footer")).not.toBeNull();
473
478
  });
474
479
 
475
480
  it("renders header/footer inside the mobile sheet too", async () => {
@@ -806,11 +811,13 @@ describe("HkMenu sidebar variant", () => {
806
811
  app.mount(container);
807
812
 
808
813
  // The identity block leads the same inline surface (drawer account
809
- // menu grammar) — first child of the nav, before any row.
814
+ // menu grammar) — inside the standard slot wrapper, first child of
815
+ // the nav, before any row.
810
816
  const nav = container.querySelector(".hk-menu-sidebar");
811
817
  const identity = nav?.querySelector(".sidebar-identity");
812
818
  expect(identity).not.toBeNull();
813
- expect(nav!.firstElementChild).toBe(identity);
819
+ expect(nav!.firstElementChild).toBe(nav!.querySelector(".hk-menu-slot--head"));
820
+ expect(nav!.querySelector(".hk-menu-slot--head")!.contains(identity!)).toBe(true);
814
821
  });
815
822
 
816
823
  it("renders the check column on rows that opt in (checked !== undefined)", async () => {
@@ -542,16 +542,35 @@ export default defineComponent({
542
542
  }
543
543
 
544
544
  /** One level's content inside the shared panel surface: header slot →
545
- * rows (or the consumer's default-slot rows) → footer slot. Custom
546
- * slots ride the root level, where an identity block belongs. */
545
+ * rows (or the consumer's default-slot rows) → footer slot. Header
546
+ * and footer ride a standard `.hk-menu-slot` wrapper so their rows
547
+ * inherit the panel's inset and tests/contracts get a stable DOM
548
+ * hook — raw slot vnodes used to splice straight into the level,
549
+ * and every consumer hand-rolled its own spacing (PlatformSwitcher
550
+ * shipped a drifted divider that way). The wrapper renders only
551
+ * when the slot has content: an empty one would cost a phantom
552
+ * flex gap in headerless surfaces. */
553
+ function slotWrapper(kind: "head" | "foot" | "extra", nodes: VNode[]): VNode | null {
554
+ return nodes.length ? (
555
+ <div class={`hk-menu-slot hk-menu-slot--${kind}`}>{nodes}</div>
556
+ ) : null;
557
+ }
558
+
547
559
  function renderLevelBody(level: number, list: HkMenuItem[]): VNode[] {
548
- const head: VNode[] = level === 0 ? (slots.header?.() ?? []) : [];
549
- const tail: VNode[] = level === 0 ? (slots.footer?.() ?? []) : [];
560
+ const out: VNode[] = [];
561
+ const head = level === 0 ? slots.header?.() ?? [] : [];
562
+ const foot = level === 0 ? slots.footer?.() ?? [] : [];
563
+ const headWrap = level === 0 ? slotWrapper("head", head) : null;
564
+ const footWrap = slotWrapper("foot", foot);
565
+ if (headWrap) out.push(headWrap);
550
566
  if (slots.default) {
551
- return [...head, ...slots.default(), ...tail];
567
+ out.push(...slots.default());
568
+ } else {
569
+ const reserved = checkReserved(list);
570
+ out.push(...list.map((it) => renderRow(it, level, reserved)));
552
571
  }
553
- const reserved = checkReserved(list);
554
- return [...head, ...list.map((it) => renderRow(it, level, reserved)), ...tail];
572
+ if (footWrap) out.push(footWrap);
573
+ return out;
555
574
  }
556
575
 
557
576
  function levelSurface(level: number, list: HkMenuItem[]): VNode {
@@ -796,10 +815,11 @@ export default defineComponent({
796
815
  return (
797
816
  <nav class="hk-menu-sidebar" aria-label={props.title || "menu"}>
798
817
  {/* Header slot (identity block, section label, …) above the
799
- rows — mirrors the popup variant's level-0 header, so an
800
- account menu renders the same grammar whether it pops up
801
- on desktop or lives inside a drawer on mobile. */}
802
- {slots.header?.()}
818
+ rows — mirrors the popup variant's level-0 header (same
819
+ .hk-menu-slot wrapper), so an account menu renders the same
820
+ grammar whether it pops up on desktop or lives inside a
821
+ drawer on mobile. */}
822
+ {slotWrapper("head", slots.header?.() ?? [])}
803
823
  {props.items.map((it) => renderSidebarItem(it, 0))}
804
824
  </nav>
805
825
  );
@@ -196,13 +196,10 @@ const HkMessageBoxHost = defineComponent({
196
196
  ),
197
197
  footer: () => (
198
198
  <div class="hk-message-box-actions">
199
- {/* Alerts have no cancel by definition; confirm/prompt
200
- hide it only when the host asks. */}
201
- {props.kind !== "alert" && !props.hideCancel && (
202
- <HkButton variant="secondary" onClick={cancel}>
203
- {cancelLabel}
204
- </HkButton>
205
- )}
199
+ {/* Primary action first, dismiss second — message boxes follow
200
+ the desktop OK-left / Cancel-right convention, matching
201
+ HkConfirmDialog. Alerts have no cancel by definition;
202
+ confirm/prompt hide it only when the host asks. */}
206
203
  <HkButton
207
204
  variant={confirmVariant}
208
205
  class="hk-message-box-confirm"
@@ -210,6 +207,11 @@ const HkMessageBoxHost = defineComponent({
210
207
  >
211
208
  {confirmLabel}
212
209
  </HkButton>
210
+ {props.kind !== "alert" && !props.hideCancel && (
211
+ <HkButton variant="secondary" onClick={cancel}>
212
+ {cancelLabel}
213
+ </HkButton>
214
+ )}
213
215
  </div>
214
216
  ),
215
217
  }}