@celestia-island/hikari 0.43.18 → 0.45.0

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.43.18",
3
+ "version": "0.45.0",
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,39 +1,66 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, h } from "vue";
3
3
 
4
- // HPopover teleports to body — stubbing Teleport stringifies its vnode
5
- // children. Replace just HPopover with an inline passthrough that renders
6
- // its default slot while open; the rest of hikari stays real.
7
- vi.mock("@celestia-island/hikari", async (importOriginal) => {
8
- const actual = await importOriginal<typeof import("@celestia-island/hikari")>();
4
+ // HkMenu renders its levels through HkSelectPanel, which teleports to
5
+ // body — replace just HkMenu (imported by the component from
6
+ // ./HkMenu) with an inline passthrough that renders the header slot
7
+ // plus one flattened row per item (children included) while open,
8
+ // wiring row clicks to the select handler; the rest of hikari stays
9
+ // real. The select handler reaches the stub through attrs (it is not
10
+ // re-declared as an emit, which would strip it from props).
11
+ vi.mock("./HkMenu", async () => {
9
12
  const { defineComponent, h } = await import("vue");
10
- const HPopoverStub = defineComponent({
11
- name: "HPopover",
13
+ const HMenuStub = defineComponent({
14
+ name: "HMenu",
12
15
  props: {
13
- modelValue: { type: Boolean, default: false },
14
- placement: { type: String, default: "bottom" },
16
+ open: { type: Boolean, default: false },
17
+ items: { type: Array, default: () => [] },
18
+ title: { type: String, default: "" },
15
19
  },
16
- setup(props, { slots }) {
17
- return () =>
18
- props.modelValue
19
- ? h("div", { class: "popover-stub" }, slots.default?.())
20
- : null;
20
+ setup(props, { slots, attrs }) {
21
+ return () => {
22
+ if (!props.open) return null;
23
+ const rows: ReturnType<typeof h>[] = [];
24
+ const flatten = (items: any[]) => {
25
+ for (const item of items ?? []) {
26
+ rows.push(
27
+ h(
28
+ "button",
29
+ {
30
+ class: "menu-stub-row",
31
+ "data-key": item.key,
32
+ "data-checked": item.checked || undefined,
33
+ "data-danger": item.danger || undefined,
34
+ onClick: () => (attrs as any).onSelect?.(item.key),
35
+ },
36
+ item.label,
37
+ ),
38
+ );
39
+ if (item.children?.length) flatten(item.children);
40
+ }
41
+ };
42
+ flatten(props.items as any[]);
43
+ return h("div", { class: "menu-stub" }, [...(slots.header?.() ?? []), ...rows]);
44
+ };
21
45
  },
22
46
  });
23
- return { ...actual, HPopover: HPopoverStub };
47
+ return { default: HMenuStub };
24
48
  });
25
49
 
26
50
  import { HkAdminHeader } from "./HkAdminHeader";
27
51
 
28
52
  /**
29
- * HkAdminHeader contract tests for the generalized avatar/identity
30
- * behaviors ported from the chest plana-legacy fork:
31
- * - avatarAction "menu" (default) toggles the user dropdown; "drawer"
32
- * emits avatarClick so the shell can open its nav drawer.
33
- * - the identity block (name/email/groups) leads the menu; an empty
34
- * identity renders the signing-in placeholder + force-sign-out escape.
35
- * - an empty title hides the context-title node entirely (pages carry
36
- * their own in-page title in the HPageHeader convention).
53
+ * HkAdminHeader contract tests for the unified user dropdown:
54
+ * - the bar beside the avatar carries the PAGE TITLE, never the
55
+ * nickname;
56
+ * - the avatar trigger toggles an HkMenu dropdown whose header slot is
57
+ * the profile identity block (avatar chip + nickname + email) and
58
+ * whose rows end in a danger logout;
59
+ * - avatarAction "drawer" emits avatarClick instead (bar text hidden);
60
+ * - an empty identity renders the signing-in placeholder + the
61
+ * force-sign-out escape row;
62
+ * - Language children carry checked state and emit localeSelect;
63
+ * - the goToFrontend row sits directly above logout.
37
64
  *
38
65
  * (Repo test convention: raw createApp + container queries, no
39
66
  * @vue/test-utils dependency.)
@@ -66,6 +93,9 @@ function headerNode(props: HeaderProps = {}, slots?: Record<string, unknown>) {
66
93
  const avatarButton = (c: HTMLElement) =>
67
94
  c.querySelector('button[aria-haspopup]') as HTMLButtonElement | null;
68
95
 
96
+ const menuRows = (c: HTMLElement) =>
97
+ [...c.querySelectorAll(".menu-stub-row")] as HTMLButtonElement[];
98
+
69
99
  async function click(el: Element | null) {
70
100
  el?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
71
101
  await Promise.resolve();
@@ -73,27 +103,60 @@ async function click(el: Element | null) {
73
103
  }
74
104
 
75
105
  describe("HkAdminHeader", () => {
76
- it("toggles the user dropdown on avatar click in menu mode", async () => {
77
- const c = mount(headerNode({ logoutLabel: "Log out" }));
106
+ it("shows the page title beside the avatar, never the nickname", () => {
107
+ const c = mount(headerNode({ title: "Providers", logoutLabel: "Log out" }));
108
+ const barText = c.querySelector("header > div")?.textContent ?? "";
109
+ expect(barText).toContain("Providers");
110
+ // The nickname must not render as bar text — identity lives in the
111
+ // dropdown's identity block only.
112
+ expect(barText).not.toContain("alice");
113
+ });
114
+
115
+ it("renders the letter fallback on the shared avatar primitive, no outline ring", () => {
116
+ const c = mount(headerNode({ title: "Providers" }));
117
+ const btn = avatarButton(c) as HTMLButtonElement;
118
+ // The old border-2 ring made the console avatar read as a different
119
+ // control from the chat frontend's (user report 2026-09-12).
120
+ expect(btn.className).not.toContain("border-2");
121
+ expect(btn.querySelector(".s-user-avatar")?.textContent).toBe("A");
122
+ });
123
+
124
+ it("hides the title node entirely when it is empty", () => {
125
+ const withTitle = mount(headerNode({ title: "Dashboard" }));
126
+ expect(withTitle.textContent).toContain("Dashboard");
127
+
128
+ const withoutTitle = mount(headerNode());
129
+ expect(withoutTitle.querySelector("header > div")?.textContent).not.toContain("Dashboard");
130
+ });
131
+
132
+ it("toggles the user dropdown with the profile identity header in menu mode", async () => {
133
+ const c = mount(headerNode({
134
+ userEmail: "alice@example.com",
135
+ logoutLabel: "Log out",
136
+ }));
78
137
  const btn = avatarButton(c);
79
138
  expect(btn?.getAttribute("aria-haspopup")).toBe("menu");
80
139
 
81
140
  await click(btn);
82
- const menu = c.querySelector(".popover-stub");
83
- // The dropdown leads with the identity block and ends with logout.
141
+ const menu = c.querySelector(".menu-stub");
142
+ // The dropdown leads with the identity block — avatar chip,
143
+ // nickname, email — and ends with a danger logout row.
144
+ expect(menu?.querySelector(".s-user-header--profile")).toBeTruthy();
145
+ expect(menu?.querySelector(".s-user-avatar-chip")).toBeTruthy();
84
146
  expect(menu?.textContent).toContain("alice");
85
- expect(c.querySelector(".s-user-header")).toBeTruthy();
86
- expect(menu?.textContent).toContain("Log out");
147
+ expect(menu?.textContent).toContain("alice@example.com");
148
+ expect(c.querySelector(".menu-stub-row[data-key='logout'][data-danger]")).toBeTruthy();
87
149
 
88
150
  await click(btn);
89
- expect(c.querySelector(".s-user-header")).toBeNull();
151
+ expect(c.querySelector(".menu-stub")).toBeNull();
90
152
  });
91
153
 
92
154
  it("emits avatarClick instead of the dropdown in drawer mode", async () => {
93
155
  const clicks: number[] = [];
94
156
  const c = mount(headerNode({
95
157
  avatarAction: "drawer",
96
- logoutLabel: "Log out",
158
+ title: "Providers",
159
+ userEmail: "alice@example.com",
97
160
  onAvatarClick: () => clicks.push(1),
98
161
  }));
99
162
  const btn = avatarButton(c);
@@ -101,9 +164,10 @@ describe("HkAdminHeader", () => {
101
164
 
102
165
  await click(btn);
103
166
  expect(clicks).toHaveLength(1);
104
- // The dropdown must NOT open in drawer mode, and the username stays
105
- // hidden — the identity block lives in the drawer footer.
106
- expect(c.querySelector(".s-user-header")).toBeNull();
167
+ // The dropdown must NOT open in drawer mode, and neither the title
168
+ // nor the nickname renders — both live in the drawer footer.
169
+ expect(c.querySelector(".menu-stub")).toBeNull();
170
+ expect(c.textContent).not.toContain("Providers");
107
171
  expect(c.textContent).not.toContain("alice");
108
172
  });
109
173
 
@@ -123,63 +187,64 @@ describe("HkAdminHeader", () => {
123
187
  // No action items above an absent identity.
124
188
  expect(c.textContent).not.toContain("Avatar");
125
189
 
126
- await click(c.querySelector(".s-popup-menu-item"));
190
+ await click(c.querySelector(".menu-stub-row"));
127
191
  expect(onForceSignOut).toHaveBeenCalledTimes(1);
128
192
  });
129
193
 
130
- it("hides the context title entirely when it is empty", () => {
131
- const withTitle = mount(headerNode({ title: "Dashboard" }));
132
- expect(withTitle.querySelector("h2")?.textContent).toBe("Dashboard");
133
-
134
- const withoutTitle = mount(headerNode());
135
- expect(withoutTitle.querySelector("h2")).toBeNull();
136
- });
137
-
138
- it("renders no emergency-stop control by default", () => {
139
- const c = mount(headerNode({ username: "alice" }));
140
- expect(c.textContent.toLowerCase()).not.toContain("emergency");
141
- });
142
-
143
194
  it("renders the goToFrontend row above logout and emits goToFrontend", async () => {
144
195
  const onGoToFrontend = vi.fn();
145
196
  const c = mount(headerNode({
197
+ userEmail: "alice@example.com",
146
198
  logoutLabel: "Log out",
147
199
  goToFrontendLabel: "Go to Frontend",
148
200
  onGoToFrontend,
149
201
  }));
150
202
  // Opt-in: absent without the label prop…
151
- const bare = mount(headerNode({ logoutLabel: "Log out" }));
203
+ const bare = mount(headerNode({ userEmail: "alice@example.com", logoutLabel: "Log out" }));
152
204
  await click(avatarButton(bare));
153
- expect(bare.textContent).not.toContain("Go to Frontend");
205
+ expect(bare.querySelector(".menu-stub-row[data-key='go-to-frontend']")).toBeNull();
154
206
 
155
207
  // …and present (directly above logout, mirroring the drawer user
156
208
  // panel's row order) with the label prop.
157
209
  await click(avatarButton(c));
158
- const rows = [...c.querySelectorAll(".s-popup-menu-item")];
159
- const labels = rows.map((r) => r.textContent?.trim());
160
- expect(labels.indexOf("Go to Frontend")).toBeGreaterThan(-1);
161
- expect(labels.indexOf("Log out")).toBe(labels.indexOf("Go to Frontend") + 1);
210
+ const keys = menuRows(c).map((r) => r.dataset.key);
211
+ expect(keys.indexOf("go-to-frontend")).toBeGreaterThan(-1);
212
+ expect(keys.indexOf("logout")).toBe(keys.indexOf("go-to-frontend") + 1);
162
213
 
163
- await click(rows[labels.indexOf("Go to Frontend")]);
214
+ await click(menuRows(c)[keys.indexOf("go-to-frontend")]);
164
215
  expect(onGoToFrontend).toHaveBeenCalledTimes(1);
165
216
  });
166
217
 
167
- it("passes the locale trigger ref OBJECT through the slot so the picker anchors to the button", async () => {
168
- let captured: unknown = null;
169
- const c = mount(headerNode(
170
- { username: "alice" },
171
- {
172
- "locale-picker": (scope: { triggerRef: unknown }) => {
173
- captured = scope.triggerRef;
174
- return null;
175
- },
176
- },
177
- ));
218
+ it("offers locales as a checked cascade and emits localeSelect with the code", async () => {
219
+ const onLocaleSelect = vi.fn();
220
+ const c = mount(headerNode({
221
+ userEmail: "alice@example.com",
222
+ localeOptions: [
223
+ { code: "en", label: "English" },
224
+ { code: "zh-Hans", label: "简体中文" },
225
+ ],
226
+ currentLocale: "zh-Hans",
227
+ onLocaleSelect,
228
+ }));
178
229
  await click(avatarButton(c));
179
- // The slot must receive the ref object itself (reactive), not its
180
- // frozen .value snapshot from the first render (null before the
181
- // ref attached).
182
- expect(captured).toBeTruthy();
183
- expect((captured as { value?: unknown }).value).toBeInstanceOf(HTMLElement);
230
+
231
+ // Children flatten after the parent row with checked state on the
232
+ // active locale only.
233
+ const rows = menuRows(c);
234
+ const localeIdx = rows.findIndex((r) => r.dataset.key === "locale");
235
+ const en = rows[localeIdx + 1];
236
+ const zh = rows[localeIdx + 2];
237
+ expect(en.dataset.key).toBe("locale:en");
238
+ expect(zh.dataset.key).toBe("locale:zh-Hans");
239
+ expect(zh.dataset.checked).toBe("true");
240
+ expect(en.dataset.checked).toBeUndefined();
241
+
242
+ await click(en);
243
+ expect(onLocaleSelect).toHaveBeenCalledWith("en");
244
+ });
245
+
246
+ it("renders no emergency-stop control by default", () => {
247
+ const c = mount(headerNode({ username: "alice" }));
248
+ expect(c.textContent.toLowerCase()).not.toContain("emergency");
184
249
  });
185
250
  });
@@ -1,7 +1,8 @@
1
- import { defineComponent, ref, type PropType, type VNode } from "vue";
1
+ import { computed, defineComponent, ref, type PropType, type VNode } from "vue";
2
2
  import { Camera, ExternalLink, Languages, LogOut, Menu } from "lucide-vue-next";
3
- import { HBadge, HButton, HPopover, HSpinner } from "@celestia-island/hikari";
3
+ import { HBadge, HButton, HSpinner } from "@celestia-island/hikari";
4
4
  import { useI18n } from "../i18n/context";
5
+ import HkMenu, { type HkMenuItem } from "./HkMenu";
5
6
 
6
7
  export interface LocaleOption {
7
8
  code: string;
@@ -11,25 +12,30 @@ export interface LocaleOption {
11
12
  export const HkAdminHeader = defineComponent({
12
13
  name: "HkAdminHeader",
13
14
  props: {
14
- /** Optional context title — empty (the default) hides the node
15
- * entirely, for layouts whose pages carry their own in-page title
16
- * (the HPageHeader convention). */
15
+ /** Page title rendered beside the avatar — the view the console is
16
+ * currently ON (dashboard, providers, …). The signed-in nickname
17
+ * deliberately does NOT go here: identity lives in the dropdown's
18
+ * identity block, the bar labels WHERE you are. Empty hides the
19
+ * node; long titles truncate. */
17
20
  title: { type: String, default: "" },
18
21
  showHamburger: { type: Boolean, default: false },
19
22
  compact: { type: Boolean, default: false },
20
23
  actions: { type: Array as PropType<VNode[]>, default: () => [] },
24
+ /** Signed-in identity. Drives the avatar fallback letter, the
25
+ * dropdown identity block and the pending-state branch — it is
26
+ * never rendered as bar text (the title slot above owns that). */
21
27
  username: { type: String, default: "" },
22
28
  avatarUrl: { type: String, default: "" },
23
29
  userEmail: { type: String, default: "" },
24
30
  userGroups: { type: Array as PropType<{ id: string; name: string }[]>, default: () => [] },
25
31
  /** What the avatar trigger does:
26
- * - "menu" (default, desktop): toggle the user dropdown popover
32
+ * - "menu" (default, desktop): toggle the user dropdown
27
33
  * (identity, avatar edit, language, logout).
28
34
  * - "drawer" (mobile): emit `avatarClick` so the shell opens its nav
29
35
  * drawer, whose `userPanel` footer carries the same user content.
30
- * The username stays hidden in this mode — the identity block
31
- * lives in the drawer, so a header username would read as a
32
- * duplicated stray control. */
36
+ * Both the page title and the identity stay hidden in this mode —
37
+ * they live in the drawer, so header copies read as duplicated
38
+ * stray controls. */
33
39
  avatarAction: { type: String as PropType<"menu" | "drawer">, default: "menu" },
34
40
  /** Placeholder row shown while the identity is still loading (a
35
41
  * fetchUser race on hard refresh) — the action items stay hidden
@@ -51,6 +57,13 @@ export const HkAdminHeader = defineComponent({
51
57
  /** Accessible label for the avatar trigger button. */
52
58
  avatarTriggerLabel: { type: String, default: undefined },
53
59
  localeMenuLabel: { type: String, default: undefined },
60
+ /** Offered locales for the Language cascade (the same shape the chat
61
+ * frontend feeds its user menu). An empty list hides the row —
62
+ * hosts without a locale concept simply omit it. Selecting a child
63
+ * emits `localeSelect` with the code. */
64
+ localeOptions: { type: Array as PropType<LocaleOption[]>, default: () => [] },
65
+ /** Currently active locale code (the Language cascade's check). */
66
+ currentLocale: { type: String, default: undefined },
54
67
  logoutLabel: { type: String, default: undefined },
55
68
  adminGroupLabel: { type: String, default: undefined },
56
69
  /** "Go to frontend" menu row (external-face link, rendered directly
@@ -65,6 +78,7 @@ export const HkAdminHeader = defineComponent({
65
78
  hamburger: () => true,
66
79
  avatarClick: () => true,
67
80
  emergencyStop: () => true,
81
+ localeSelect: (_code: string) => true,
68
82
  },
69
83
  setup(props, { emit, slots }) {
70
84
  const { t } = useI18n();
@@ -72,8 +86,6 @@ export const HkAdminHeader = defineComponent({
72
86
  const userTriggerRef = ref<HTMLElement>();
73
87
  const avatarModalOpen = ref(false);
74
88
  const avatarFailed = ref(false);
75
- const localeMenuOpen = ref(false);
76
- const localeTriggerRef = ref<HTMLElement | null>(null);
77
89
 
78
90
  function onAvatarClick(e: MouseEvent) {
79
91
  e.stopPropagation();
@@ -84,6 +96,72 @@ export const HkAdminHeader = defineComponent({
84
96
  userMenuOpen.value = !userMenuOpen.value;
85
97
  }
86
98
 
99
+ /** The dropdown is the SAME engine as the chat frontend's user menu
100
+ * (HkMenu): identity header via the header slot, cascading Language
101
+ * children, danger logout row — every account surface (chat header,
102
+ * console header, mobile drawer) reads identically. */
103
+ const userMenuItems = computed<HkMenuItem[]>(() => {
104
+ // Empty identity (fetchUser race on a hard refresh): a single
105
+ // force-sign-out escape row — action items floating above no
106
+ // identity read as a broken menu.
107
+ if (!props.username && !props.userEmail) {
108
+ return [
109
+ {
110
+ key: "force-signout",
111
+ label: props.forceSignOutLabel ?? t("hikari::adminHeader.forceSignOut", "Sign out"),
112
+ },
113
+ ];
114
+ }
115
+ const items: HkMenuItem[] = [
116
+ {
117
+ key: "avatar",
118
+ label: props.avatarMenuLabel ?? t("hikari::adminHeader.avatar", "Avatar"),
119
+ icon: Camera,
120
+ },
121
+ ];
122
+ if (props.localeOptions.length > 0) {
123
+ items.push({
124
+ key: "locale",
125
+ label: props.localeMenuLabel ?? t("hikari::adminHeader.language", "Language"),
126
+ icon: Languages,
127
+ children: props.localeOptions.map((o) => ({
128
+ key: `locale:${o.code}`,
129
+ label: o.label,
130
+ checked: o.code === props.currentLocale,
131
+ })),
132
+ });
133
+ }
134
+ // Frontend link — mirrors the drawer user panel's "go to frontend"
135
+ // row (same position: directly above logout). Opt-in via the label
136
+ // prop so admin-only panels keep the menu at avatar/language/logout.
137
+ if (props.goToFrontendLabel) {
138
+ items.push({ key: "go-to-frontend", label: props.goToFrontendLabel, icon: ExternalLink });
139
+ }
140
+ items.push({
141
+ key: "logout",
142
+ label: props.logoutLabel ?? t("hikari::adminHeader.logout", "Logout"),
143
+ icon: LogOut,
144
+ danger: true,
145
+ });
146
+ return items;
147
+ });
148
+
149
+ /** Leaf selection IS the action; HkMenu closes the menu around the
150
+ * emit, so each branch only fires its own side effect. */
151
+ function onUserMenuSelect(key: string) {
152
+ if (key === "avatar") {
153
+ avatarModalOpen.value = true;
154
+ } else if (key.startsWith("locale:")) {
155
+ emit("localeSelect", key.slice("locale:".length));
156
+ } else if (key === "go-to-frontend") {
157
+ emit("goToFrontend");
158
+ } else if (key === "logout") {
159
+ emit("logout");
160
+ } else if (key === "force-signout") {
161
+ props.onForceSignOut?.();
162
+ }
163
+ }
164
+
87
165
  return () => (
88
166
  <header
89
167
  class={[
@@ -103,12 +181,7 @@ export const HkAdminHeader = defineComponent({
103
181
 
104
182
  <div ref={userTriggerRef} class="flex items-center gap-2 min-w-0">
105
183
  <button
106
- class={[
107
- "w-7 h-7 rounded-full overflow-hidden shrink-0 cursor-pointer transition-opacity relative group p-0 border-0",
108
- props.avatarUrl
109
- ? ""
110
- : "bg-primary/10 border-2 border-primary/15 hover:border-primary/30",
111
- ]}
184
+ class="w-7 h-7 rounded-full overflow-hidden shrink-0 cursor-pointer transition-opacity relative group p-0 border-0"
112
185
  aria-label={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
113
186
  aria-haspopup={props.avatarAction === "drawer" ? "dialog" : "menu"}
114
187
  aria-expanded={props.avatarAction === "menu" ? userMenuOpen.value : undefined}
@@ -122,7 +195,12 @@ export const HkAdminHeader = defineComponent({
122
195
  onError={() => { avatarFailed.value = true; }}
123
196
  />
124
197
  ) : (
125
- <span class="text-xs font-bold text-primary/70">
198
+ // Letter fallback on the shared avatar primitive — the
199
+ // same filled-circle grammar as the chat frontend's
200
+ // trigger. The old border-2 ring made the console's
201
+ // avatar read as a different control (user report
202
+ // 2026-09-12: 前后台头像不一致).
203
+ <span class="s-user-avatar">
126
204
  {props.username?.charAt(0).toUpperCase() || "?"}
127
205
  </span>
128
206
  )}
@@ -132,142 +210,85 @@ export const HkAdminHeader = defineComponent({
132
210
  </div>
133
211
  )}
134
212
  </button>
135
- {props.avatarAction === "menu" && (
136
- <span class="text-sm font-semibold text-text truncate max-w-[8rem]">
137
- {props.username}
213
+ {/* WHERE am I — the open view's title, not the nickname. The
214
+ explicit 1.5 line-height keeps descenders (g, y, p) inside
215
+ the truncate clip box: the default text-sm box is exactly
216
+ the em advance, so zoom/subpixel rounding in a scaled root
217
+ shaves the ink off at the bottom (user report 2026-09-12:
218
+ the "g" tail of the nickname was visibly cut). */}
219
+ {props.avatarAction === "menu" && props.title && (
220
+ <span
221
+ class="text-sm font-semibold text-text truncate max-w-[8rem]"
222
+ style={{ lineHeight: "1.5" }}
223
+ >
224
+ {props.title}
138
225
  </span>
139
226
  )}
140
227
  </div>
141
228
 
142
- <HPopover
143
- modelValue={userMenuOpen.value}
144
- onUpdate:modelValue={(v: boolean) => {
145
- userMenuOpen.value = v;
146
- // Closing the outer menu must also close the locale submenu,
147
- // or it stays orphaned for the next open.
148
- if (!v) localeMenuOpen.value = false;
149
- }}
150
- placement="bottom-start"
229
+ <HkMenu
230
+ open={userMenuOpen.value}
231
+ onUpdate:open={(v: boolean) => { userMenuOpen.value = v; }}
151
232
  anchorRef={userTriggerRef.value ?? null}
152
- class="w-56"
153
- sheetOnMobile
154
- title={t("hikari::adminHeader.avatarTrigger")}
233
+ placement="bottom-start"
234
+ title={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
235
+ items={userMenuItems.value}
236
+ onSelect={(key: string) => onUserMenuSelect(key)}
155
237
  >
156
- {/* Empty identity (fetchUser race on a hard refresh): render a
157
- single subtle placeholder row instead of the action items —
158
- "Change Avatar"/"Logout" floating above no identity read as a
159
- broken menu. The force-sign-out escape hatch lets the user
160
- break out of a wedged session restore. */}
161
- {!props.username && !props.userEmail ? (
162
- <div>
163
- <div class="s-user-header s-user-header--pending">
164
- <HSpinner size="sm" />
165
- <div class="s-user-header-email">{props.signingInLabel ?? t("hikari::adminHeader.signingIn", "Signing in…")}</div>
166
- </div>
167
- <button
168
- class="s-popup-menu-item"
169
- onClick={() => props.onForceSignOut?.()}
170
- >
171
- <span class="hk-menu-item-icon"><LogOut size={14} /></span>
172
- {props.forceSignOutLabel ?? t("hikari::adminHeader.forceSignOut", "Sign out")}
173
- </button>
174
- </div>
175
- ) : (
176
- <div>
177
- {/* Identity header FIRST — nickname, login email, permission
178
- badges — so every account surface reads identically. */}
179
- <div class="s-user-header">
180
- {props.username && <div class="s-user-header-name">{props.username}</div>}
181
- {props.userEmail && <div class="s-user-header-email">{props.userEmail}</div>}
182
- {props.userGroups && props.userGroups.length > 0 && (
183
- <div class="s-user-header-groups">
184
- {props.userGroups.map((g: { id: string; name: string }) => (
185
- <HBadge
186
- key={g.id}
187
- variant={g.name === "Administrators" ? "error" : "primary"}
188
- size="sm"
189
- >
190
- {g.name === "Administrators"
191
- ? (props.adminGroupLabel ?? t("hikari::adminHeader.adminGroup", "Administrators"))
192
- : g.name}
193
- </HBadge>
194
- ))}
238
+ {{
239
+ header: () => (
240
+ !props.username && !props.userEmail ? (
241
+ // Pending identity: a spinner beside the label reads as
242
+ // "actively working", not a frozen panel.
243
+ <div class="s-user-header s-user-header--pending">
244
+ <HSpinner size="sm" />
245
+ <div class="s-user-header-email">{props.signingInLabel ?? t("hikari::adminHeader.signingIn", "Signing in…")}</div>
246
+ </div>
247
+ ) : (
248
+ // Identity block FIRST — avatar, nickname, login email,
249
+ // permission badges — the same grammar the chat
250
+ // frontend's user-menu header uses (the shared
251
+ // s-user-header profile variant).
252
+ <div class="s-user-header s-user-header--profile">
253
+ <span class="s-user-avatar-chip" aria-hidden="true">
254
+ {props.avatarUrl && !avatarFailed.value ? (
255
+ <img
256
+ src={props.avatarUrl}
257
+ alt=""
258
+ class="s-user-avatar-img"
259
+ onError={() => { avatarFailed.value = true; }}
260
+ />
261
+ ) : (
262
+ <span class="s-user-avatar">
263
+ {props.username?.charAt(0).toUpperCase() || "?"}
264
+ </span>
265
+ )}
266
+ </span>
267
+ <div class="s-user-header-body">
268
+ {props.username && <div class="s-user-header-name">{props.username}</div>}
269
+ {props.userEmail && <div class="s-user-header-email">{props.userEmail}</div>}
270
+ {props.userGroups && props.userGroups.length > 0 && (
271
+ <div class="s-user-header-groups">
272
+ {props.userGroups.map((g: { id: string; name: string }) => (
273
+ <HBadge
274
+ key={g.id}
275
+ variant={g.name === "Administrators" ? "error" : "primary"}
276
+ size="sm"
277
+ >
278
+ {g.name === "Administrators"
279
+ ? (props.adminGroupLabel ?? t("hikari::adminHeader.adminGroup", "Administrators"))
280
+ : g.name}
281
+ </HBadge>
282
+ ))}
283
+ </div>
284
+ )}
195
285
  </div>
196
- )}
197
- </div>
198
- <button
199
- class="s-popup-menu-item"
200
- onClick={() => {
201
- userMenuOpen.value = false;
202
- avatarModalOpen.value = true;
203
- }}
204
- >
205
- <span class="hk-menu-item-icon"><Camera size={14} /></span>
206
- {props.avatarMenuLabel ?? t("hikari::adminHeader.avatar", "Avatar")}
207
- </button>
208
- {/* The language trigger owns the locale anchor: the ref sits
209
- on the BUTTON itself (not a wrapper div) so the picker
210
- popup anchors to it instead of falling back to inline
211
- rendering. The ref OBJECT is passed through the slot —
212
- reading .value here would freeze null from the first
213
- render before the ref attached. */}
214
- <button
215
- ref={localeTriggerRef}
216
- class="s-popup-menu-item"
217
- onClick={() => (localeMenuOpen.value = !localeMenuOpen.value)}
218
- >
219
- <span class="hk-menu-item-icon"><Languages size={14} /></span>
220
- {props.localeMenuLabel ?? t("hikari::adminHeader.language", "Language")}
221
- </button>
222
- {slots["locale-picker"]?.({
223
- open: localeMenuOpen.value,
224
- onUpdateOpen: (v: boolean) => {
225
- localeMenuOpen.value = v;
226
- if (!v) userMenuOpen.value = false;
227
- },
228
- triggerRef: localeTriggerRef,
229
- })}
230
- {slots["user-menu-extra"]?.()}
231
- {/* Frontend link — mirrors the drawer user panel's
232
- "go to frontend" row (same icon/position: directly
233
- above logout). Opt-in via the label prop so admin-only
234
- panels keep the menu at avatar/language/logout. */}
235
- {props.goToFrontendLabel ? (
236
- <button
237
- class="s-popup-menu-item"
238
- onClick={() => {
239
- userMenuOpen.value = false;
240
- emit("goToFrontend");
241
- }}
242
- >
243
- <span class="hk-menu-item-icon"><ExternalLink size={14} /></span>
244
- {props.goToFrontendLabel}
245
- </button>
246
- ) : null}
247
- <button
248
- class="s-popup-menu-item"
249
- onClick={() => {
250
- userMenuOpen.value = false;
251
- emit("logout");
252
- }}
253
- >
254
- <span class="hk-menu-item-icon"><LogOut size={14} /></span>
255
- {props.logoutLabel ?? t("hikari::adminHeader.logout", "Logout")}
256
- </button>
257
- </div>
258
- )}
259
- </HPopover>
286
+ </div>
287
+ )
288
+ ),
289
+ }}
290
+ </HkMenu>
260
291
 
261
- {/* The page title lives INSIDE each page in the HPageHeader
262
- convention (big title left, tool buttons right). The bar
263
- renders an optional context title only when one is given —
264
- empty hides the node, keeping the bar to identity + theme
265
- controls. */}
266
- {props.title && (
267
- <h2 class="text-sm font-semibold text-text truncate min-w-0">
268
- {props.title}
269
- </h2>
270
- )}
271
292
  <div class="ml-auto flex items-center gap-1.5 shrink-0">
272
293
  {props.showEmergencyStop && (
273
294
  <button
@@ -789,6 +789,85 @@ describe("HkMenu sidebar variant", () => {
789
789
  expect(container.textContent).toContain("Products");
790
790
  expect(container.textContent).not.toContain("General");
791
791
  });
792
+
793
+ it("renders the header slot above the rows", () => {
794
+ const container = document.createElement("div");
795
+ document.body.appendChild(container);
796
+ containers.push(container);
797
+ const app = createApp({
798
+ render: () =>
799
+ h(
800
+ HkMenu,
801
+ { variant: "sidebar", open: true, items: navItems },
802
+ { header: () => h("div", { class: "sidebar-identity" }, "me@example.com") },
803
+ ),
804
+ });
805
+ mounts.push(app);
806
+ app.mount(container);
807
+
808
+ // The identity block leads the same inline surface (drawer account
809
+ // menu grammar) — first child of the nav, before any row.
810
+ const nav = container.querySelector(".hk-menu-sidebar");
811
+ const identity = nav?.querySelector(".sidebar-identity");
812
+ expect(identity).not.toBeNull();
813
+ expect(nav!.firstElementChild).toBe(identity);
814
+ });
815
+
816
+ it("renders the check column on rows that opt in (checked !== undefined)", async () => {
817
+ const selected: string[] = [];
818
+ const container = document.createElement("div");
819
+ document.body.appendChild(container);
820
+ containers.push(container);
821
+ const app = createApp({
822
+ render: () =>
823
+ h(HkMenu, {
824
+ variant: "sidebar",
825
+ open: true,
826
+ items: [
827
+ {
828
+ key: "locale",
829
+ label: "Language",
830
+ children: [
831
+ { key: "en", label: "English", checked: false },
832
+ { key: "zh", label: "中文", checked: true },
833
+ ],
834
+ },
835
+ { key: "logout", label: "Log out", danger: true },
836
+ ],
837
+ onSelect: (key: string) => selected.push(key),
838
+ }),
839
+ });
840
+ mounts.push(app);
841
+ app.mount(container);
842
+ await settle();
843
+
844
+ // Groups default collapsed — expand the locale group.
845
+ const toggle = container.querySelector(
846
+ ".hk-menu-sidebar-group-toggle",
847
+ ) as HTMLButtonElement;
848
+ toggle.click();
849
+ await settle();
850
+
851
+ const rowOf = (label: string) =>
852
+ [...container.querySelectorAll(".hk-menu-sidebar-row")].find(
853
+ (r) => r.textContent?.includes(label),
854
+ ) as HTMLButtonElement;
855
+ const en = rowOf("English");
856
+ const zh = rowOf("中文");
857
+ const logout = rowOf("Log out");
858
+
859
+ // Checked rows carry the cell; true renders the mark, false keeps
860
+ // the empty placeholder so labels stay aligned. Rows without the
861
+ // opt-in get no cell at all.
862
+ expect(en.querySelector(".hk-menu-check")).not.toBeNull();
863
+ expect(en.querySelector(".hk-menu-check")?.hasAttribute("data-on")).toBe(false);
864
+ expect(zh.querySelector(".hk-menu-check")?.hasAttribute("data-on")).toBe(true);
865
+ expect(logout.querySelector(".hk-menu-check")).toBeNull();
866
+
867
+ en.click();
868
+ await settle();
869
+ expect(selected).toEqual(["en"]);
870
+ });
792
871
  });
793
872
 
794
873
  describe("HkMenu close linger (leave-transition window)", () => {
@@ -723,6 +723,7 @@ export default defineComponent({
723
723
  class="hk-menu-sidebar-row"
724
724
  data-depth={depth || undefined}
725
725
  data-active={active || undefined}
726
+ data-checked={item.checked || undefined}
726
727
  data-danger={item.danger || undefined}
727
728
  data-disabled={item.disabled || undefined}
728
729
  style={
@@ -735,6 +736,15 @@ export default defineComponent({
735
736
  emit("select", item.key, item);
736
737
  }}
737
738
  >
739
+ {/* Check column on opt-in rows (checked !== undefined): the
740
+ same fixed cell grammar the popup rows use, so a sidebar
741
+ locale/account picker shows its active entry in place.
742
+ false renders the empty placeholder to keep labels aligned. */}
743
+ {item.checked !== undefined && (
744
+ <span class="hk-menu-check" data-on={item.checked || undefined} aria-hidden="true">
745
+ {item.checked ? <Check size={12} /> : null}
746
+ </span>
747
+ )}
738
748
  {item.icon && (
739
749
  <span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
740
750
  )}
@@ -782,6 +792,11 @@ export default defineComponent({
782
792
  function renderSidebar() {
783
793
  return (
784
794
  <nav class="hk-menu-sidebar" aria-label={props.title || "menu"}>
795
+ {/* Header slot (identity block, section label, …) above the
796
+ rows — mirrors the popup variant's level-0 header, so an
797
+ account menu renders the same grammar whether it pops up
798
+ on desktop or lives inside a drawer on mobile. */}
799
+ {slots.header?.()}
785
800
  {props.items.map((it) => renderSidebarItem(it, 0))}
786
801
  </nav>
787
802
  );
@@ -364,6 +364,55 @@
364
364
  margin-bottom: var(--space-4);
365
365
  }
366
366
 
367
+ /* Profile variant — avatar chip beside the identity column, the ONE
368
+ identity grammar the chat frontend's user menu and the admin
369
+ header's dropdown share (moved up from chest's ChatLayout so both
370
+ surfaces render from the same sheet). */
371
+ .s-user-header--profile {
372
+ flex-direction: row;
373
+ align-items: center;
374
+ gap: var(--space-10);
375
+ }
376
+
377
+ /* Avatar primitives shared by the trigger button and the identity
378
+ chip: the letter fallback fills its box, images cover. */
379
+ .s-user-avatar {
380
+ display: flex;
381
+ align-items: center;
382
+ justify-content: center;
383
+ width: 100%;
384
+ height: 100%;
385
+ border-radius: 50%;
386
+ background: var(--c-primary-medium);
387
+ color: rgb(var(--color-primary));
388
+ font-size: var(--text-xs);
389
+ font-weight: 700;
390
+ letter-spacing: 0.02em;
391
+ }
392
+
393
+ .s-user-avatar-img {
394
+ width: 100%;
395
+ height: 100%;
396
+ border-radius: 50%;
397
+ object-fit: cover;
398
+ display: block;
399
+ }
400
+
401
+ .s-user-avatar-chip {
402
+ flex-shrink: 0;
403
+ width: 32px;
404
+ height: 32px;
405
+ border-radius: 50%;
406
+ overflow: hidden;
407
+ }
408
+
409
+ .s-user-header-body {
410
+ min-width: 0;
411
+ display: flex;
412
+ flex-direction: column;
413
+ gap: 2px;
414
+ }
415
+
367
416
  /* Pending variant (identity not yet hydrated): spinner beside the
368
417
  label reads as "actively working", not a frozen panel. */
369
418
  .s-user-header--pending {