@celestia-island/hikari 0.43.18 → 0.44.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.44.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,51 @@ 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("hides the title node entirely when it is empty", () => {
116
+ const withTitle = mount(headerNode({ title: "Dashboard" }));
117
+ expect(withTitle.textContent).toContain("Dashboard");
118
+
119
+ const withoutTitle = mount(headerNode());
120
+ expect(withoutTitle.querySelector("header > div")?.textContent).not.toContain("Dashboard");
121
+ });
122
+
123
+ it("toggles the user dropdown with the profile identity header in menu mode", async () => {
124
+ const c = mount(headerNode({
125
+ userEmail: "alice@example.com",
126
+ logoutLabel: "Log out",
127
+ }));
78
128
  const btn = avatarButton(c);
79
129
  expect(btn?.getAttribute("aria-haspopup")).toBe("menu");
80
130
 
81
131
  await click(btn);
82
- const menu = c.querySelector(".popover-stub");
83
- // The dropdown leads with the identity block and ends with logout.
132
+ const menu = c.querySelector(".menu-stub");
133
+ // The dropdown leads with the identity block — avatar chip,
134
+ // nickname, email — and ends with a danger logout row.
135
+ expect(menu?.querySelector(".s-user-header--profile")).toBeTruthy();
136
+ expect(menu?.querySelector(".s-user-avatar-chip")).toBeTruthy();
84
137
  expect(menu?.textContent).toContain("alice");
85
- expect(c.querySelector(".s-user-header")).toBeTruthy();
86
- expect(menu?.textContent).toContain("Log out");
138
+ expect(menu?.textContent).toContain("alice@example.com");
139
+ expect(c.querySelector(".menu-stub-row[data-key='logout'][data-danger]")).toBeTruthy();
87
140
 
88
141
  await click(btn);
89
- expect(c.querySelector(".s-user-header")).toBeNull();
142
+ expect(c.querySelector(".menu-stub")).toBeNull();
90
143
  });
91
144
 
92
145
  it("emits avatarClick instead of the dropdown in drawer mode", async () => {
93
146
  const clicks: number[] = [];
94
147
  const c = mount(headerNode({
95
148
  avatarAction: "drawer",
96
- logoutLabel: "Log out",
149
+ title: "Providers",
150
+ userEmail: "alice@example.com",
97
151
  onAvatarClick: () => clicks.push(1),
98
152
  }));
99
153
  const btn = avatarButton(c);
@@ -101,9 +155,10 @@ describe("HkAdminHeader", () => {
101
155
 
102
156
  await click(btn);
103
157
  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();
158
+ // The dropdown must NOT open in drawer mode, and neither the title
159
+ // nor the nickname renders — both live in the drawer footer.
160
+ expect(c.querySelector(".menu-stub")).toBeNull();
161
+ expect(c.textContent).not.toContain("Providers");
107
162
  expect(c.textContent).not.toContain("alice");
108
163
  });
109
164
 
@@ -123,63 +178,64 @@ describe("HkAdminHeader", () => {
123
178
  // No action items above an absent identity.
124
179
  expect(c.textContent).not.toContain("Avatar");
125
180
 
126
- await click(c.querySelector(".s-popup-menu-item"));
181
+ await click(c.querySelector(".menu-stub-row"));
127
182
  expect(onForceSignOut).toHaveBeenCalledTimes(1);
128
183
  });
129
184
 
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
185
  it("renders the goToFrontend row above logout and emits goToFrontend", async () => {
144
186
  const onGoToFrontend = vi.fn();
145
187
  const c = mount(headerNode({
188
+ userEmail: "alice@example.com",
146
189
  logoutLabel: "Log out",
147
190
  goToFrontendLabel: "Go to Frontend",
148
191
  onGoToFrontend,
149
192
  }));
150
193
  // Opt-in: absent without the label prop…
151
- const bare = mount(headerNode({ logoutLabel: "Log out" }));
194
+ const bare = mount(headerNode({ userEmail: "alice@example.com", logoutLabel: "Log out" }));
152
195
  await click(avatarButton(bare));
153
- expect(bare.textContent).not.toContain("Go to Frontend");
196
+ expect(bare.querySelector(".menu-stub-row[data-key='go-to-frontend']")).toBeNull();
154
197
 
155
198
  // …and present (directly above logout, mirroring the drawer user
156
199
  // panel's row order) with the label prop.
157
200
  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);
201
+ const keys = menuRows(c).map((r) => r.dataset.key);
202
+ expect(keys.indexOf("go-to-frontend")).toBeGreaterThan(-1);
203
+ expect(keys.indexOf("logout")).toBe(keys.indexOf("go-to-frontend") + 1);
162
204
 
163
- await click(rows[labels.indexOf("Go to Frontend")]);
205
+ await click(menuRows(c)[keys.indexOf("go-to-frontend")]);
164
206
  expect(onGoToFrontend).toHaveBeenCalledTimes(1);
165
207
  });
166
208
 
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
- ));
209
+ it("offers locales as a checked cascade and emits localeSelect with the code", async () => {
210
+ const onLocaleSelect = vi.fn();
211
+ const c = mount(headerNode({
212
+ userEmail: "alice@example.com",
213
+ localeOptions: [
214
+ { code: "en", label: "English" },
215
+ { code: "zh-Hans", label: "简体中文" },
216
+ ],
217
+ currentLocale: "zh-Hans",
218
+ onLocaleSelect,
219
+ }));
178
220
  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);
221
+
222
+ // Children flatten after the parent row with checked state on the
223
+ // active locale only.
224
+ const rows = menuRows(c);
225
+ const localeIdx = rows.findIndex((r) => r.dataset.key === "locale");
226
+ const en = rows[localeIdx + 1];
227
+ const zh = rows[localeIdx + 2];
228
+ expect(en.dataset.key).toBe("locale:en");
229
+ expect(zh.dataset.key).toBe("locale:zh-Hans");
230
+ expect(zh.dataset.checked).toBe("true");
231
+ expect(en.dataset.checked).toBeUndefined();
232
+
233
+ await click(en);
234
+ expect(onLocaleSelect).toHaveBeenCalledWith("en");
235
+ });
236
+
237
+ it("renders no emergency-stop control by default", () => {
238
+ const c = mount(headerNode({ username: "alice" }));
239
+ expect(c.textContent.toLowerCase()).not.toContain("emergency");
184
240
  });
185
241
  });
@@ -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={[
@@ -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
@@ -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 {