@celestia-island/hikari 0.55.229 → 0.55.231

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 (30) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAdminShell.tsx +28 -0
  3. package/src/components/HkAvatarButton.test.tsx +58 -0
  4. package/src/components/HkFaceShell.rail.test.tsx +370 -0
  5. package/src/components/HkFaceShell.tsx +137 -13
  6. package/src/components/HkMenu.rail.test.tsx +187 -0
  7. package/src/components/HkMenu.scss +55 -0
  8. package/src/components/HkMenu.tsx +77 -0
  9. package/src/components/HkSidebar.rail.test.tsx +141 -0
  10. package/src/components/HkSidebar.tsx +102 -7
  11. package/src/components/HkSidebarRail.scss +116 -0
  12. package/src/components/HkSidebarRail.styles.test.ts +163 -0
  13. package/src/components/HkSidebarRailToggle.test.tsx +94 -0
  14. package/src/components/HkSidebarRailToggle.tsx +67 -0
  15. package/src/composables/useSidebarRail.test.ts +249 -0
  16. package/src/composables/useSidebarRail.ts +194 -0
  17. package/src/i18n/locales/ar/components.json +2 -0
  18. package/src/i18n/locales/de/components.json +2 -0
  19. package/src/i18n/locales/en/components.json +2 -0
  20. package/src/i18n/locales/es/components.json +2 -0
  21. package/src/i18n/locales/fr/components.json +2 -0
  22. package/src/i18n/locales/ja/components.json +2 -0
  23. package/src/i18n/locales/ko/components.json +2 -0
  24. package/src/i18n/locales/pt/components.json +2 -0
  25. package/src/i18n/locales/ru/components.json +2 -0
  26. package/src/i18n/locales/zh-Hans/components.json +2 -0
  27. package/src/i18n/locales/zh-Hant/components.json +2 -0
  28. package/src/index.ts +14 -0
  29. package/src/styles/admin-tokens.scss +10 -0
  30. package/src/styles/sidebar-rail.scss +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.229",
3
+ "version": "0.55.231",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -37,6 +37,25 @@ export const HkAdminShell = defineComponent({
37
37
  props: {
38
38
  sidebarCollapsed: { type: Boolean, default: false },
39
39
  sidebarWidth: { type: String, default: "224px" },
40
+ /** Desktop icon-rail capability, forwarded to HkFaceShell (see its
41
+ * prop doc): opt in here and the admin sidebar gains the circular
42
+ * hover-reveal toggle, the one-icon rail and the overlay peek
43
+ * drawer. Declared on the wrapper on purpose — the same
44
+ * honest-forwarding rule as contentMode (a stray attr must not
45
+ * reach the inner shell's declared prop through fallthrough). */
46
+ sidebarCollapsible: { type: Boolean, default: false },
47
+ /** Layout width of the icon rail (default 56px), forwarded. */
48
+ sidebarCollapsedWidth: { type: String, default: "56px" },
49
+ /** Rail state channel, `v-model:sidebar-rail-style` ("expanded" |
50
+ * "rail"), forwarded — session-only unless the host persists it. */
51
+ sidebarRailStyle: {
52
+ type: String as PropType<"expanded" | "rail">,
53
+ default: "expanded",
54
+ },
55
+ /** Hover dwell before the peek opens, ms, forwarded. */
56
+ sidebarPeekDelay: { type: Number, default: 400 },
57
+ /** Grace after pointerleave before the peek closes, ms, forwarded. */
58
+ sidebarPeekGrace: { type: Number, default: 150 },
40
59
  /** Viewport width at which the desktop layout takes over (below it
41
60
  * the nav collapses into the mobile drawer). Defaults to the shared
42
61
  * 1024px "lg" breakpoint; lower it (e.g. 768) for tablet-friendly
@@ -106,6 +125,15 @@ export const HkAdminShell = defineComponent({
106
125
  h(HkFaceShell, {
107
126
  sidebarCollapsed: props.sidebarCollapsed,
108
127
  sidebarWidth: props.sidebarWidth,
128
+ sidebarCollapsible: props.sidebarCollapsible,
129
+ sidebarCollapsedWidth: props.sidebarCollapsedWidth,
130
+ sidebarRailStyle: props.sidebarRailStyle,
131
+ sidebarPeekDelay: props.sidebarPeekDelay,
132
+ sidebarPeekGrace: props.sidebarPeekGrace,
133
+ // The v-model channel rides the inner shell's event straight
134
+ // through this wrapper's listeners (attrs fallthrough carries
135
+ // onUpdate:sidebarRailStyle to HkFaceShell — inheritAttrs stays
136
+ // on, the default), so the host binding is one wire end-to-end.
109
137
  mobileBreakpoint: props.mobileBreakpoint,
110
138
  footerHeight: props.footerHeight,
111
139
  navTitle: props.navTitle,
@@ -1,5 +1,8 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
 
4
7
  import { HkAvatarButton } from "./HkAvatarButton";
5
8
  import { HkAdminHeader } from "./HkAdminHeader";
@@ -107,6 +110,61 @@ describe("HkAvatarButton", () => {
107
110
  });
108
111
  });
109
112
 
113
+ describe("HkAvatarButton sheet contract", () => {
114
+ // happy-dom has no layout engine, so the geometry is pinned at the
115
+ // source level — the same approach the downstream chest tripwire
116
+ // (headerAvatarGeometry.test.ts) takes against the INSTALLED sheet.
117
+ const sheet = readFileSync(
118
+ join(dirname(fileURLToPath(import.meta.url)), "..", "styles", "admin-tokens.scss"),
119
+ "utf8",
120
+ );
121
+
122
+ /** Declarations of the rule whose whole prelude IS `selector`
123
+ * (a compound/descendant spelling is a different rule and does not
124
+ * match), comments stripped; null when no such rule exists — every
125
+ * assertion treats null as a failure, never as "no objection". */
126
+ function ruleDeclarations(selector: string): Array<[string, string]> | null {
127
+ const code = sheet
128
+ .replace(/\/\*[\s\S]*?\*\//g, "")
129
+ .replace(/(^|[^:])\/\/[^\n]*/gm, "$1");
130
+ for (let at = code.indexOf(selector); at >= 0; at = code.indexOf(selector, at + 1)) {
131
+ const open = code.indexOf("{", at + selector.length);
132
+ if (open < 0) continue;
133
+ if (code.slice(at + selector.length, open).trim() !== "") continue;
134
+ let boundary = at - 1;
135
+ while (boundary >= 0 && !"}{;".includes(code[boundary])) boundary -= 1;
136
+ if (code.slice(boundary + 1, at).replace(/\s+/g, " ").trim() !== "") continue;
137
+ const body = code.slice(open + 1, code.indexOf("}", open));
138
+ const out: Array<[string, string]> = [];
139
+ for (const part of body.split(";")) {
140
+ const m = /^\s*([-a-zA-Z]+)\s*:\s*([\s\S]+?)\s*$/.exec(part);
141
+ if (m) out.push([m[1], m[2].replace(/\s+/g, " ")]);
142
+ }
143
+ return out;
144
+ }
145
+ return null;
146
+ }
147
+
148
+ it("the trigger is a BLOCK — hosts may nest it in a plain wrapper div", () => {
149
+ // The chat face wraps HeaderLeft's trigger in a menu-anchor <div>:
150
+ // an inline-block button there joins a line box and the strut's
151
+ // descender space lands BELOW it, riding the avatar ~4px high inside
152
+ // a perfectly centred wrapper (2026-10-30 user report). Removing
153
+ // this declaration is the mutation this test must catch.
154
+ const rule = ruleDeclarations(".s-admin-header-avatar");
155
+ expect(rule, "admin-tokens must declare .s-admin-header-avatar").not.toBeNull();
156
+ // Positive control for the extractor itself: the geometry pair the
157
+ // downstream tripwire also reads. If these stop matching, the parser
158
+ // above is broken — fix it, not the assertion below.
159
+ expect(rule).toContainEqual(["width", "var(--space-28)"]);
160
+ expect(rule).toContainEqual(["height", "var(--space-28)"]);
161
+ expect(rule, "the trigger must not re-enter inline formatting").toContainEqual([
162
+ "display",
163
+ "block",
164
+ ]);
165
+ });
166
+ });
167
+
110
168
  describe("HkAdminHeader × HkAvatarButton / identity slot", () => {
111
169
  it("never renders aria-expanded when no expanded prop is given (drawer-mode contract)", () => {
112
170
  // R2 S3-2: menu engines read this pair; a drawer-mode trigger has no
@@ -0,0 +1,370 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, ref } from "vue";
3
+
4
+ // Same stub shape as HkFaceShell.test.tsx: HDrawer teleports and
5
+ // animates; the desktop rail contract is testable without the overlay
6
+ // runtime.
7
+ vi.mock("@celestia-island/hikari", async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import("@celestia-island/hikari")>();
9
+ const { defineComponent, h } = await import("vue");
10
+ const HDrawerStub = defineComponent({
11
+ name: "HDrawer",
12
+ props: {
13
+ modelValue: { type: Boolean, default: false },
14
+ side: { type: String, default: "left" },
15
+ size: { type: String, default: "280px" },
16
+ title: { type: String, default: undefined },
17
+ panelClass: { type: String, default: undefined },
18
+ },
19
+ setup(props, { slots }) {
20
+ return () =>
21
+ props.modelValue
22
+ ? h("div", { class: ["drawer-stub", props.panelClass] }, [
23
+ h("div", { class: "drawer-stub-body" }, slots.default?.()),
24
+ slots.footer
25
+ ? h("div", { class: "drawer-stub-footer" }, slots.footer())
26
+ : null,
27
+ ])
28
+ : null;
29
+ },
30
+ });
31
+ return { ...actual, HDrawer: HDrawerStub };
32
+ });
33
+
34
+ import { HkFaceShell } from "./HkFaceShell";
35
+ import { HkAdminShell } from "./HkAdminShell";
36
+
37
+ /**
38
+ * HkFaceShell desktop rail + peek integration (2026-10-30). What this
39
+ * file pins:
40
+ *
41
+ * 1. OPT-IN: without sidebarCollapsible the sidebar renders the legacy
42
+ * aside — no rail classes, no toggle, slot collapsed stays false and
43
+ * carries no peeking param.
44
+ * 2. The rail channel: the toggle drives v-model:sidebar-rail-style, the
45
+ * host width collapses to sidebarCollapsedWidth, the slot receives
46
+ * collapsed: true.
47
+ * 3. The PEEK overlay: after the dwell (pointerenter + 400ms) the panel
48
+ * grows to the full width while the HOST width — the only layout
49
+ * lever over the content — STAYS at the rail width (the "content
50
+ * never shifts" core contract), and the slot flips back to full rows
51
+ * (collapsed: false, peeking: true).
52
+ * 4. Grace + flicker: leave closes only after the grace; re-entry inside
53
+ * the grace keeps the peek open.
54
+ * 5. Escape closes; a nav click (the slot's onNavigate) closes.
55
+ * 6. HkAdminShell forwards the whole prop family (source pin, the same
56
+ * honest-forwarding rule as contentMode) and mounts the rail.
57
+ *
58
+ * The harness closes the v-model loop with a stateful wrapper — exactly
59
+ * what a real host's v-model:sidebar-rail-style does.
60
+ */
61
+
62
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
63
+
64
+ interface SlotParams {
65
+ collapsed: boolean;
66
+ onNavigate: () => void;
67
+ peeking?: boolean;
68
+ [key: string]: unknown;
69
+ }
70
+
71
+ interface RailMount {
72
+ container: HTMLElement;
73
+ aside(): HTMLElement;
74
+ panel(): HTMLElement;
75
+ main(): HTMLElement;
76
+ toggle(): HTMLButtonElement;
77
+ slotParams: SlotParams[];
78
+ emitted: string[];
79
+ }
80
+
81
+ function mountShell(props: Record<string, unknown> = {}): RailMount {
82
+ const slotParams: SlotParams[] = [];
83
+ const emitted: string[] = [];
84
+ const container = document.createElement("div");
85
+ document.body.appendChild(container);
86
+
87
+ const Wrapper = defineComponent({
88
+ name: "RailShellHarness",
89
+ setup() {
90
+ const railStyle = ref<"expanded" | "rail">(
91
+ (props.sidebarRailStyle as "expanded" | "rail") ?? "expanded",
92
+ );
93
+ return () =>
94
+ h(HkFaceShell, {
95
+ navTitle: "Navigation",
96
+ ...props,
97
+ sidebarRailStyle: railStyle.value,
98
+ "onUpdate:sidebarRailStyle": (v: string) => {
99
+ emitted.push(v);
100
+ railStyle.value = v as "expanded" | "rail";
101
+ },
102
+ }, {
103
+ header: () => null,
104
+ sidebar: (params: SlotParams) => {
105
+ slotParams.push(params);
106
+ return h("div", { class: "sidebar-mock" }, "NAV");
107
+ },
108
+ content: () => h("div", { class: "content-mock" }, "CONTENT"),
109
+ });
110
+ },
111
+ });
112
+
113
+ const app = createApp(Wrapper);
114
+ app.mount(container);
115
+ mounts.push({ app, container });
116
+ return {
117
+ container,
118
+ aside: () => container.querySelector(".hk-sidebar-rail-host") as HTMLElement,
119
+ panel: () => container.querySelector(".hk-sidebar-rail-panel") as HTMLElement,
120
+ main: () => container.querySelector(".s-admin-shell-main") as HTMLElement,
121
+ toggle: () =>
122
+ container.querySelector(".hk-sidebar-rail-toggle button") as HTMLButtonElement,
123
+ slotParams,
124
+ emitted,
125
+ };
126
+ }
127
+
128
+ afterEach(() => {
129
+ for (const { app, container } of mounts.splice(0)) {
130
+ app.unmount();
131
+ container.remove();
132
+ }
133
+ vi.useRealTimers();
134
+ });
135
+
136
+ const POINTER_ENTER = () => new Event("pointerenter");
137
+ const POINTER_LEAVE = () => new Event("pointerleave");
138
+
139
+ describe("HkFaceShell desktop sidebar rail (opt-in)", () => {
140
+ it("renders the LEGACY aside untouched when not collapsible", () => {
141
+ const c = mountShell();
142
+ expect(c.aside(), "no rail host class").toBeFalsy();
143
+ expect(c.toggle(), "no toggle button").toBeFalsy();
144
+ // The slot keeps its legacy param shape.
145
+ const last = c.slotParams[c.slotParams.length - 1]!;
146
+ expect(last.collapsed).toBe(false);
147
+ expect(typeof last.onNavigate).toBe("function");
148
+ expect("peeking" in last).toBe(false);
149
+ });
150
+
151
+ it("keeps removing the sidebar entirely under the legacy sidebarCollapsed prop", () => {
152
+ const c = mountShell({ sidebarCollapsible: true, sidebarCollapsed: true });
153
+ expect(c.container.querySelector("aside")).toBeFalsy();
154
+ expect(c.container.querySelector(".content-mock")).toBeTruthy();
155
+ });
156
+ });
157
+
158
+ describe("HkFaceShell rail mode", () => {
159
+ beforeEach(() => {
160
+ vi.useFakeTimers();
161
+ });
162
+
163
+ it("collapses through the toggle and the v-model channel", async () => {
164
+ const c = mountShell({ sidebarCollapsible: true });
165
+
166
+ expect(c.aside().style.width).toBe("224px");
167
+ expect(c.aside().getAttribute("data-rail")).toBeNull();
168
+ expect(c.toggle().getAttribute("aria-expanded")).toBe("true");
169
+
170
+ c.toggle().click();
171
+ await vi.advanceTimersByTimeAsync(0);
172
+
173
+ expect(c.emitted, "the model channel emits rail").toEqual(["rail"]);
174
+ expect(c.aside().style.width).toBe("56px");
175
+ expect(c.aside().hasAttribute("data-rail")).toBe(true);
176
+ expect(c.toggle().getAttribute("aria-expanded")).toBe("false");
177
+ expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
178
+ });
179
+
180
+ it("collapses with NO channel bound — the session-only default (R1 defect pin)", async () => {
181
+ // Semi-controlled state: a host that never binds
182
+ // v-model:sidebar-rail-style still gets a working toggle; the emit
183
+ // just lands unheard. (The pure-delegate version left the click
184
+ // inert — found by the R1 verifier.)
185
+ const container = document.createElement("div");
186
+ document.body.appendChild(container);
187
+ const app = createApp({
188
+ render: () =>
189
+ h(HkFaceShell, { navTitle: "Navigation", sidebarCollapsible: true }, {
190
+ header: () => null,
191
+ sidebar: () => h("div", "NAV"),
192
+ content: () => h("div", "CONTENT"),
193
+ }),
194
+ });
195
+ app.mount(container);
196
+ mounts.push({ app, container });
197
+
198
+ const aside = container.querySelector(".hk-sidebar-rail-host") as HTMLElement;
199
+ const toggle = container.querySelector(".hk-sidebar-rail-toggle button") as HTMLButtonElement;
200
+ expect(aside.style.width).toBe("224px");
201
+
202
+ toggle.click();
203
+ await vi.advanceTimersByTimeAsync(0);
204
+ expect(aside.style.width, "the unbound toggle still collapses").toBe("56px");
205
+
206
+ toggle.click();
207
+ await vi.advanceTimersByTimeAsync(0);
208
+ expect(aside.style.width, "and expands again").toBe("224px");
209
+ });
210
+
211
+ it("the bound prop WINS on the way back in — external channel drives both directions", async () => {
212
+ // The other half of the semi-controlled contract (R2 pin): a host
213
+ // that treats sidebarRailStyle as ITS state (store-driven, no echo)
214
+ // must see the sidebar follow. Deleting the prop-sync watch leaves
215
+ // every other test green — this is the one that goes red.
216
+ const railStyle = ref<"expanded" | "rail">("expanded");
217
+ const container = document.createElement("div");
218
+ document.body.appendChild(container);
219
+ const app = createApp({
220
+ setup: () => () =>
221
+ h(HkFaceShell, {
222
+ navTitle: "Navigation",
223
+ sidebarCollapsible: true,
224
+ // Deliberately NO onUpdate:sidebarRailStyle echo — the prop is
225
+ // the external authority here.
226
+ sidebarRailStyle: railStyle.value,
227
+ }, {
228
+ header: () => null,
229
+ sidebar: () => h("div", "NAV"),
230
+ content: () => h("div", "CONTENT"),
231
+ }),
232
+ });
233
+ app.mount(container);
234
+ mounts.push({ app, container });
235
+ const aside = () => container.querySelector(".hk-sidebar-rail-host") as HTMLElement;
236
+
237
+ railStyle.value = "rail";
238
+ await vi.advanceTimersByTimeAsync(0);
239
+ expect(aside().style.width, "an external rail value collapses").toBe("56px");
240
+
241
+ railStyle.value = "expanded";
242
+ await vi.advanceTimersByTimeAsync(0);
243
+ expect(aside().style.width, "and an external expanded value restores").toBe("224px");
244
+ });
245
+
246
+ it("honors a configured rail width", async () => {
247
+ const c = mountShell({ sidebarCollapsible: true, sidebarCollapsedWidth: "72px" });
248
+ c.toggle().click();
249
+ await vi.advanceTimersByTimeAsync(0);
250
+ expect(c.aside().style.width).toBe("72px");
251
+ });
252
+
253
+ it("peeks after the dwell as an OVERLAY — the host width never moves", async () => {
254
+ const c = mountShell({ sidebarCollapsible: true });
255
+ c.toggle().click();
256
+ await vi.advanceTimersByTimeAsync(0);
257
+
258
+ c.aside().dispatchEvent(POINTER_ENTER());
259
+ await vi.advanceTimersByTimeAsync(399);
260
+ expect(c.aside().hasAttribute("data-peeking"), "one tick before the dwell").toBe(false);
261
+
262
+ await vi.advanceTimersByTimeAsync(1);
263
+ expect(c.aside().hasAttribute("data-peeking")).toBe(true);
264
+ // THE core contract: the layout footprint (host width) stays at the
265
+ // rail width while the panel overlays at the full width — the
266
+ // content <main> cannot shift because nothing in flow changed.
267
+ expect(c.aside().style.width).toBe("56px");
268
+ expect(c.panel().style.width).toBe("224px");
269
+ expect(c.main(), "the content host still renders").toBeTruthy();
270
+ // The slot renders FULL rows again inside the peek.
271
+ expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(false);
272
+ expect(c.slotParams[c.slotParams.length - 1]!.peeking).toBe(true);
273
+ });
274
+
275
+ it("closes on leave only after the grace; re-entry cancels the close", async () => {
276
+ const c = mountShell({ sidebarCollapsible: true });
277
+ c.toggle().click();
278
+ await vi.advanceTimersByTimeAsync(0);
279
+ c.aside().dispatchEvent(POINTER_ENTER());
280
+ await vi.advanceTimersByTimeAsync(400);
281
+ expect(c.aside().hasAttribute("data-peeking")).toBe(true);
282
+
283
+ // Diagonal move: out, back inside the grace — no flicker.
284
+ c.aside().dispatchEvent(POINTER_LEAVE());
285
+ await vi.advanceTimersByTimeAsync(100);
286
+ c.aside().dispatchEvent(POINTER_ENTER());
287
+ await vi.advanceTimersByTimeAsync(1000);
288
+ expect(c.aside().hasAttribute("data-peeking"), "re-entry keeps the peek open").toBe(true);
289
+
290
+ // A full grace outside finally closes.
291
+ c.aside().dispatchEvent(POINTER_LEAVE());
292
+ await vi.advanceTimersByTimeAsync(149);
293
+ expect(c.aside().hasAttribute("data-peeking"), "inside the grace it stays open").toBe(true);
294
+ await vi.advanceTimersByTimeAsync(1);
295
+ expect(c.aside().hasAttribute("data-peeking")).toBe(false);
296
+ expect(c.panel().style.width).toBe("56px");
297
+ expect(c.aside().style.width).toBe("56px");
298
+ });
299
+
300
+ it("Escape closes an open peek", async () => {
301
+ const c = mountShell({ sidebarCollapsible: true });
302
+ c.toggle().click();
303
+ await vi.advanceTimersByTimeAsync(0);
304
+ c.aside().dispatchEvent(POINTER_ENTER());
305
+ await vi.advanceTimersByTimeAsync(400);
306
+ expect(c.aside().hasAttribute("data-peeking")).toBe(true);
307
+
308
+ c.aside().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
309
+ await vi.advanceTimersByTimeAsync(0);
310
+ expect(c.aside().hasAttribute("data-peeking")).toBe(false);
311
+ });
312
+
313
+ it("a nav row click inside the peek closes it (onNavigate)", async () => {
314
+ const c = mountShell({ sidebarCollapsible: true });
315
+ c.toggle().click();
316
+ await vi.advanceTimersByTimeAsync(0);
317
+ c.aside().dispatchEvent(POINTER_ENTER());
318
+ await vi.advanceTimersByTimeAsync(400);
319
+ expect(c.aside().hasAttribute("data-peeking")).toBe(true);
320
+
321
+ const nav = c.slotParams[c.slotParams.length - 1]!.onNavigate;
322
+ nav();
323
+ await vi.advanceTimersByTimeAsync(0);
324
+ expect(c.aside().hasAttribute("data-peeking"), "navigate + close, like the drawer").toBe(false);
325
+ });
326
+ });
327
+
328
+ describe("HkAdminShell forwarding (source pin)", () => {
329
+ it("declares and forwards the rail prop family", async () => {
330
+ const { readFileSync } = await import("node:fs");
331
+ const { dirname, join } = await import("node:path");
332
+ const { fileURLToPath } = await import("node:url");
333
+ const src = readFileSync(
334
+ join(dirname(fileURLToPath(import.meta.url)), "HkAdminShell.tsx"),
335
+ "utf8",
336
+ ).replace(/\r\n/g, "\n");
337
+ // Declared on the wrapper (the honest-forwarding rule)…
338
+ for (const decl of [
339
+ "sidebarCollapsible: { type: Boolean, default: false }",
340
+ 'sidebarCollapsedWidth: { type: String, default: "56px" }',
341
+ "sidebarPeekDelay: { type: Number, default: 400 }",
342
+ "sidebarPeekGrace: { type: Number, default: 150 }",
343
+ ]) {
344
+ expect(src, decl).toContain(decl);
345
+ }
346
+ // …and forwarded to the inner shell.
347
+ expect(src).toContain("sidebarCollapsible: props.sidebarCollapsible,");
348
+ expect(src).toContain("sidebarCollapsedWidth: props.sidebarCollapsedWidth,");
349
+ expect(src).toContain("sidebarRailStyle: props.sidebarRailStyle,");
350
+ expect(src).toContain("sidebarPeekDelay: props.sidebarPeekDelay,");
351
+ expect(src).toContain("sidebarPeekGrace: props.sidebarPeekGrace,");
352
+ });
353
+
354
+ it("mounts the rail through the wrapper (behavior spot-check)", () => {
355
+ const container = document.createElement("div");
356
+ document.body.appendChild(container);
357
+ const app = createApp({
358
+ render: () =>
359
+ h(HkAdminShell, { sidebarCollapsible: true }, {
360
+ header: () => null,
361
+ sidebar: () => h("div", "NAV"),
362
+ content: () => h("div", "CONTENT"),
363
+ }),
364
+ });
365
+ app.mount(container);
366
+ mounts.push({ app, container });
367
+ expect(container.querySelector(".hk-sidebar-rail-host")).toBeTruthy();
368
+ expect(container.querySelector(".hk-sidebar-rail-toggle button")).toBeTruthy();
369
+ });
370
+ });
@@ -1,6 +1,8 @@
1
- import { computed, defineComponent, ref, type PropType } from "vue";
1
+ import { computed, defineComponent, ref, watch, type PropType } from "vue";
2
2
  import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
3
3
  import { useI18n } from "../i18n/context";
4
+ import { useSidebarRail } from "../composables/useSidebarRail";
5
+ import HkSidebarRailToggle from "./HkSidebarRailToggle";
4
6
 
5
7
  /** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
6
8
  * padding (`inherit 0 inherit` is rejected by every engine, measured in
@@ -168,6 +170,35 @@ export const HkFaceShell = defineComponent({
168
170
  props: {
169
171
  sidebarCollapsed: { type: Boolean, default: false },
170
172
  sidebarWidth: { type: String, default: "224px" },
173
+ /** OPT-IN icon-rail capability for the desktop sidebar (2026-10-30):
174
+ * mounts the hover-reveal circular chevron toggle on the sidebar's
175
+ * content-facing edge, lets a click collapse the sidebar to a one-
176
+ * icon rail, and — while collapsed — opens a hover-peek drawer that
177
+ * slides the full sidebar OVER the content as an overlay (the
178
+ * layout keeps the rail width; the content area never shifts).
179
+ * Timing lives in useSidebarRail (dwell, grace, Escape); geometry in
180
+ * HkSidebarRail.scss (.hk-sidebar-rail-host/-panel/-toggle).
181
+ * False (default) renders the sidebar exactly as before. Below
182
+ * mobileBreakpoint nothing changes — the mobile drawer stays the
183
+ * only nav surface. */
184
+ sidebarCollapsible: { type: Boolean, default: false },
185
+ /** Layout width of the icon rail (the one-icon column). Default 56px,
186
+ * the rail width HkSidebar's collapsed mode has always used. */
187
+ sidebarCollapsedWidth: { type: String, default: "56px" },
188
+ /** Rail state channel, `v-model:sidebar-rail-style`: "expanded" (the
189
+ * default) or "rail". The library itself NEVER persists this — the
190
+ * collapse is session-only by design; a host that wants persistence
191
+ * (or a shared store) binds the channel and stores the value
192
+ * itself. */
193
+ sidebarRailStyle: {
194
+ type: String as PropType<"expanded" | "rail">,
195
+ default: "expanded",
196
+ },
197
+ /** Hover dwell on the rail before the peek drawer opens, ms. */
198
+ sidebarPeekDelay: { type: Number, default: 400 },
199
+ /** Grace after pointerleave before the peek drawer closes, ms — a
200
+ * diagonal pointer move within the window does not flicker it shut. */
201
+ sidebarPeekGrace: { type: Number, default: 150 },
171
202
  /** Viewport width at which the desktop layout takes over (below it
172
203
  * the nav collapses into the mobile drawer). Defaults to the shared
173
204
  * 1024px "lg" breakpoint; lower it (e.g. 768) for tablet-friendly
@@ -230,12 +261,72 @@ export const HkFaceShell = defineComponent({
230
261
  * Default false: the padding reads the same at every width. */
231
262
  contentBleedOnMobile: { type: Boolean, default: false },
232
263
  },
233
- setup(props, { slots }) {
264
+ emits: {
265
+ "update:sidebarRailStyle": (v: string) => true,
266
+ },
267
+ setup(props, { slots, emit }) {
234
268
  const { t } = useI18n();
235
269
  const { width: viewportWidth } = useBreakpoint();
236
270
  const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
237
271
  const sidebarOpen = ref(false);
238
272
 
273
+ // ── desktop icon rail + hover peek (sidebarCollapsible) ──────────
274
+ // SEMI-CONTROLLED rail state: a local session-only ref is the live
275
+ // state, so the toggle works with NO channel bound at all (the
276
+ // session-only default); a host that binds v-model:sidebar-rail-style
277
+ // hears every flip through update:sidebarRailStyle and its prop then
278
+ // wins on the way back in (the watch below). Same shape as
279
+ // HkSidebar's collapsed handling.
280
+ const railCollapsedLocal = ref(props.sidebarRailStyle === "rail");
281
+ watch(
282
+ () => props.sidebarRailStyle,
283
+ (v) => {
284
+ railCollapsedLocal.value = v === "rail";
285
+ },
286
+ );
287
+ const railCollapsed = computed({
288
+ get: () => railCollapsedLocal.value,
289
+ set: (v: boolean) => {
290
+ railCollapsedLocal.value = v;
291
+ emit("update:sidebarRailStyle", v ? "rail" : "expanded");
292
+ },
293
+ });
294
+ const rail = useSidebarRail({
295
+ collapsed: railCollapsed,
296
+ peekDelay: () => props.sidebarPeekDelay,
297
+ peekGrace: () => props.sidebarPeekGrace,
298
+ });
299
+ const railActive = computed(() => props.sidebarCollapsible && railCollapsed.value);
300
+ // The LAYOUT width — the core contract of the peek: rail width while
301
+ // collapsed OR peeking (the peek overlays; it never reflows content),
302
+ // full width while expanded.
303
+ const hostSidebarWidth = computed(() =>
304
+ railActive.value ? props.sidebarCollapsedWidth : props.sidebarWidth,
305
+ );
306
+ // The PANEL width — full while expanded or peeking (the slide-out
307
+ // lever), rail width otherwise.
308
+ const peekPanelWidth = computed(() =>
309
+ railActive.value && !rail.peeking.value ? props.sidebarCollapsedWidth : props.sidebarWidth,
310
+ );
311
+ // Crossing under the breakpoint unmounts the desktop sidebar — the
312
+ // peek must not survive into the mobile drawer era (or reappear when
313
+ // the viewport grows back).
314
+ watch(isDesktop, (desktop) => {
315
+ if (!desktop) rail.closePeek();
316
+ });
317
+
318
+ /** Nav rows close the mobile drawer (their existing onNavigate
319
+ * contract) AND an open peek — mirroring the drawer's
320
+ * navigate-and-dismiss behavior on desktop hover. */
321
+ const onSidebarNavigate = () => {
322
+ rail.closePeek();
323
+ closeSidebar();
324
+ };
325
+
326
+ const onSidebarHostKeydown = (e: KeyboardEvent) => {
327
+ if (rail.onKeydown(e)) e.stopPropagation();
328
+ };
329
+
239
330
  const contentStyle = computed(() => ({
240
331
  padding: isDesktop.value || !props.contentBleedOnMobile
241
332
  ? props.contentPadding
@@ -299,17 +390,50 @@ export const HkFaceShell = defineComponent({
299
390
 
300
391
  <div class="s-admin-shell-body" style={{ paddingBottom: footerPad() }}>
301
392
  {isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
302
- <aside
303
- style={{
304
- width: props.sidebarWidth,
305
- flexShrink: 0,
306
- borderRight: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
307
- background: "rgb(var(--color-surface))",
308
- overflow: "hidden",
309
- }}
310
- >
311
- {slots.sidebar({ collapsed: false, onNavigate: closeSidebar })}
312
- </aside>
393
+ props.sidebarCollapsible ? (
394
+ <aside
395
+ class="hk-sidebar-rail-host"
396
+ data-rail={railActive.value || undefined}
397
+ data-peeking={rail.peeking.value || undefined}
398
+ style={{ width: hostSidebarWidth.value, flexShrink: 0 }}
399
+ onPointerenter={rail.onPointerEnter}
400
+ onPointerleave={rail.onPointerLeave}
401
+ onKeydown={onSidebarHostKeydown}
402
+ >
403
+ {/* The visual panel — absolutely positioned over the host
404
+ (HkSidebarRail.scss). Its width is the peek's slide-out
405
+ lever; the host width above is the layout footprint and
406
+ NEVER grows during a peek, so the <main> sibling keeps
407
+ its exact width while the drawer covers it. */}
408
+ <div
409
+ class="hk-sidebar-rail-panel"
410
+ style={{
411
+ width: peekPanelWidth.value,
412
+ borderInlineEnd: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
413
+ background: "rgb(var(--color-surface))",
414
+ }}
415
+ >
416
+ {slots.sidebar({
417
+ collapsed: railActive.value && !rail.peeking.value,
418
+ onNavigate: onSidebarNavigate,
419
+ peeking: rail.peeking.value,
420
+ })}
421
+ <HkSidebarRailToggle expanded={!railActive.value} onToggle={rail.toggle} />
422
+ </div>
423
+ </aside>
424
+ ) : (
425
+ <aside
426
+ style={{
427
+ width: props.sidebarWidth,
428
+ flexShrink: 0,
429
+ borderRight: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
430
+ background: "rgb(var(--color-surface))",
431
+ overflow: "hidden",
432
+ }}
433
+ >
434
+ {slots.sidebar({ collapsed: false, onNavigate: closeSidebar })}
435
+ </aside>
436
+ )
313
437
  )}
314
438
  <main class="s-admin-shell-main" data-content-mode={props.contentMode}>
315
439
  {props.contentMode === "stage" ? (