@celestia-island/hikari 0.55.221 → 0.55.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.221",
3
+ "version": "0.55.223",
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",
@@ -193,6 +193,21 @@ describe("HkAdminHeader", () => {
193
193
  expect(c.textContent).not.toContain("alice");
194
194
  });
195
195
 
196
+ it("reserves the workspace-switcher slot in drawer mode only", () => {
197
+ // 2026-10-29 user direction: the frontend header's left wing is
198
+ // avatar + ws-trigger; the console drawer face keeps the SLOT as a
199
+ // deliberate blank so the centre strip can never slide into the
200
+ // space — menu mode carries the page title in that position instead.
201
+ const drawer = mount(headerNode({ avatarAction: "drawer", userEmail: "alice@example.com" }));
202
+ const slot = drawer.querySelector(".s-admin-header-ws-slot");
203
+ expect(slot, "drawer face must render the reserved ws slot").toBeTruthy();
204
+ expect(slot?.getAttribute("aria-hidden")).toBe("true");
205
+
206
+ const menu = mount(headerNode({ title: "Providers", logoutLabel: "Log out" }));
207
+ expect(menu.querySelector(".s-admin-header-ws-slot")).toBeNull();
208
+ expect(menu.querySelector(".s-admin-header-title")).toBeTruthy();
209
+ });
210
+
196
211
  it("renders the signing-in placeholder with the force-sign-out escape when the identity is empty", async () => {
197
212
  const onForceSignOut = vi.fn();
198
213
  const c = mount(headerNode({
@@ -249,6 +249,20 @@ export const HkAdminHeader = defineComponent({
249
249
  )}
250
250
  </button>
251
251
  )}
252
+ {/* The reserved workspace-switcher slot (drawer face only).
253
+ The chat header's left wing is avatar + ws-trigger button,
254
+ and that wing's min-content floor is what keeps the
255
+ header-centre strip off the space on a phone. The console
256
+ has no workspace concept, but the drawer face hides the
257
+ title too — without a stand-in the wing's floor is just
258
+ the 28px avatar and a wide centre strip slides left into
259
+ the slot and reads as hugging the avatar (2026-10-29 user
260
+ direction: 头像右侧留白,前后台布局统一). An aria-hidden
261
+ blank of the trigger's own footprint keeps both faces'
262
+ left wings at the same floor. */}
263
+ {props.avatarAction === "drawer" && (
264
+ <span class="s-admin-header-ws-slot" aria-hidden="true" />
265
+ )}
252
266
  {/* WHERE am I — the open view's title, not the nickname. The
253
267
  1.5 line-height (in the class) keeps descenders (g, y, p)
254
268
  inside the truncate clip box: the default text-sm box is
@@ -1,135 +1,37 @@
1
- import { computed, defineComponent, ref, type PropType } from "vue";
2
- import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
1
+ import { defineComponent, h, type PropType } from "vue";
2
+ import { HkFaceShell } from "./HkFaceShell";
3
3
  import { provideActionBar } from "../composables/useActionBar";
4
- import { useI18n } from "../i18n/context";
5
4
 
6
- /** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
7
- * padding (`inherit 0 inherit` is rejected by every engine, measured in
8
- * Chromium), so the declaration would be dropped WHOLE and the page
9
- * would lose the vertical clearance too — worse than not bleeding. */
10
- const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
5
+ // The padding-scan utilities moved INTO HkFaceShell with the content
6
+ // host that applies them (2026-10-29 extraction). Re-exported here
7
+ // because HkAdminShell.padding.test.ts and any external importer know
8
+ // this module as their home — the extraction must not move the public
9
+ // surface, only the implementation.
10
+ export {
11
+ scanPaddingSides,
12
+ splitPaddingSides,
13
+ mobileSideGutterFree,
14
+ } from "./HkFaceShell";
15
+ export type { PaddingSidesScan } from "./HkFaceShell";
11
16
 
12
17
  /**
13
- * A scanned padding shorthand: the top-level `sides` plus whether the
14
- * value ENDED with every function closed and every quote terminated.
15
- * `balanced: false` means the scan cannot know where the value ends —
16
- * CSS closes an open function at EOF, so the authored declaration can
17
- * still be valid while a rewrite of it cannot be.
18
- */
19
- export interface PaddingSidesScan {
20
- sides: string[];
21
- balanced: boolean;
22
- }
23
-
24
- /**
25
- * Scan a CSS padding shorthand into its top-level tracks: split on
26
- * TOP-LEVEL whitespace, quote- and function-aware, so whitespace inside
27
- * a function belongs to that value — `calc(1rem + 2px)`,
28
- * `var(--pad, 1rem)` and a quoted argument are ONE track. A plain
29
- * `split(/\s+/)` shreds them (`["calc(1rem", "+", "2px)"]`), and the
30
- * rebuilt shorthand is invalid CSS — a browser drops the whole
31
- * declaration, taking with it the vertical clearance
32
- * `contentBleedOnMobile` promises to keep (rounds 2 and 3 of this change
33
- * hit exactly that; rounds 4 and 5 the escape and EOF variants). The
34
- * depth counter balances parentheses, so nested functions
35
- * (`clamp(1rem, min(2vw, 3px), 4rem)`) survive; a stray `)` never drives
36
- * the depth negative. Tabs and newlines are valid CSS separators and
37
- * split like spaces, and the whitespace around a shorthand yields no
38
- * empty tracks.
18
+ * HkAdminShell — the ADMIN face of the generalized shell
19
+ * (2026-10-29 extraction): HkFaceShell in document mode, plus the
20
+ * legacy action-bar channel. Everything structural — the frame, the
21
+ * optional sidebar and its mobile drawer, the scroll-viewport content
22
+ * host with `contentPadding` / `contentKey` / `contentBleedOnMobile`,
23
+ * the fixed footer band and its height reservation, the overlays slot —
24
+ * lives in HkFaceShell now; this component is a thin, behavior-identical
25
+ * wrapper kept for every existing consumer (props, slots and rendered
26
+ * classes are unchanged).
39
27
  *
40
- * Exported for its own unit test: happy-dom cannot round-trip
41
- * `var()`/`clamp()` values or the hostile inputs below, so the
42
- * component-level suite cannot observe them.
28
+ * The one thing the wrapper ADDS is its own action-bar provide: hosts
29
+ * of the pre-extraction contract read the registered actions through
30
+ * the header slot's `actions` param (the bar this shell provides
31
+ * itself). Faces that own their action bar — or their whole content
32
+ * lifecycle, like a canvas stage — consume HkFaceShell directly and
33
+ * provide whatever they want, closest to their pages.
43
34
  */
44
- export function scanPaddingSides(value: string): PaddingSidesScan {
45
- const sides: string[] = [];
46
- let current = "";
47
- let depth = 0;
48
- let quote = "";
49
- for (const ch of value) {
50
- if (quote) {
51
- current += ch;
52
- if (ch === quote) quote = "";
53
- continue;
54
- }
55
- if (ch === "\"" || ch === "'") {
56
- quote = ch;
57
- current += ch;
58
- continue;
59
- }
60
- if (ch === "(") depth += 1;
61
- else if (ch === ")") depth = Math.max(0, depth - 1);
62
- if (depth === 0 && /\s/.test(ch)) {
63
- if (current) sides.push(current);
64
- current = "";
65
- continue;
66
- }
67
- current += ch;
68
- }
69
- if (current) sides.push(current);
70
- return { sides, balanced: depth === 0 && quote === "" };
71
- }
72
-
73
- /** The tracks of {@link scanPaddingSides} — for callers that only need
74
- * the split, and for the unit test that pins the splitting rules. */
75
- export function splitPaddingSides(value: string): string[] {
76
- return scanPaddingSides(value).sides;
77
- }
78
-
79
- /**
80
- * The side-gutter-free form of a padding shorthand: the vertical tracks
81
- * survive, the horizontal ones become zero (`"1rem 2rem"` -> `"1rem 0
82
- * 1rem"`). The value is returned VERBATIM — the page keeps its padding
83
- * and simply does not bleed — whenever the shorthand cannot be rewritten
84
- * safely. That fallback IS the contract: emitting a declaration a browser
85
- * drops costs the page ALL FOUR sides, including the vertical clearance
86
- * this prop exists to keep.
87
- *
88
- * Verbatim cases:
89
- *
90
- * - an empty / whitespace-only value has nothing to strip;
91
- * - a LONE CSS-wide keyword cannot be combined per side (`inherit 0
92
- * inherit` is not a padding — every engine rejects it);
93
- * - the value contains a CSS comment, whose text would land in the
94
- * rebuilt declaration as a garbage track (`1rem 0 x`, measured in
95
- * Chromium: dropped, all four sides lost);
96
- * - the value contains a backslash: this scanner does not decode CSS
97
- * escapes, and an escape can hide a quote, a parenthesis or a whole
98
- * keyword (`\69 nherit` IS `inherit` to an engine) — the literal text
99
- * is not the value, so no rewrite of it is trustworthy (round 5);
100
- * - the value ends with a `!important` annotation: the annotation
101
- * belongs to the DECLARATION, so a per-side rewrite would either
102
- * silently lose the priority (`2rem !important`) or rebuild the
103
- * `inherit 0 inherit` every engine rejects (`inherit !important`,
104
- * measured in Chromium, round 5);
105
- * - the scan ends inside an open function or an unterminated quote: CSS
106
- * auto-closes a function at EOF, so `"calc(1rem + 2px"` IS a real
107
- * padding (measured: all four sides) while its rewrite is not; an
108
- * unterminated quote is invalid at both ends, and bailing keeps the
109
- * two paths identical (round 5).
110
- *
111
- * Known residual (documented, not fixable at string level): a `var()`
112
- * whose custom property resolves to MORE than one track cannot keep its
113
- * vertical half — `padding: var(--multi) 0 var(--multi)` is valid at
114
- * parse time and invalid at computed-value time, and all four sides
115
- * collapse. No consumer passes a multi-track custom property today.
116
- */
117
- export function mobileSideGutterFree(value: string): string {
118
- if (value.includes("/*") || value.includes("\\")) return value;
119
- // `!important` is an annotation on the DECLARATION, not a track — it
120
- // cannot ride a per-side rewrite: `2rem !important` would silently lose
121
- // its priority, and `inherit !important` would rebuild into the
122
- // `inherit 0 inherit` every engine drops (measured in Chromium, round
123
- // 5). Bail out and let the authored padding stand.
124
- if (/!\s*important\s*$/i.test(value)) return value;
125
- const { sides, balanced } = scanPaddingSides(value);
126
- if (!balanced) return value;
127
- if (sides.length === 0) return value;
128
- if (sides.length === 1 && CSS_WIDE_KEYWORDS.has(sides[0].toLowerCase())) return value;
129
- const [top, , bottom] = sides;
130
- return `${top} 0 ${bottom ?? top}`;
131
- }
132
-
133
35
  export const HkAdminShell = defineComponent({
134
36
  name: "HkAdminShell",
135
37
  props: {
@@ -145,11 +47,9 @@ export const HkAdminShell = defineComponent({
145
47
  * whether the shell actually renders a status bar — a filled
146
48
  * `footer` slot (the shell's own fixed `.s-status-bar`) reserves
147
49
  * `var(--s-footer-height)` by default, and with NO footer slot
148
- * nothing is reserved (`0px`). The old unconditional default
149
- * reserved the band on every host, leaving a dead 3rem blank under
150
- * consoles that never mount a status bar. Hosts that pin their OWN
151
- * fixed bar outside the shell opt back in by passing an explicit
152
- * value (e.g. `"var(--s-footer-height)"`). */
50
+ * nothing is reserved (`0px`). Hosts that pin their OWN fixed bar
51
+ * outside the shell opt back in by passing an explicit value
52
+ * (e.g. `"var(--s-footer-height)"`). */
153
53
  footerHeight: { type: String, default: undefined },
154
54
  navTitle: { type: String, default: undefined },
155
55
  /** Extra class for the mobile nav drawer's panel — lets consumers zero
@@ -188,134 +88,52 @@ export const HkAdminShell = defineComponent({
188
88
  * of risking a declaration a browser would drop whole.
189
89
  * Default false: the padding reads the same at every width. */
190
90
  contentBleedOnMobile: { type: Boolean, default: false },
91
+ /** The content host mode, forwarded to HkFaceShell. DECLARED on
92
+ * this wrapper on purpose (R2 S2, 2026-10-29): an undeclared input
93
+ * would fall through `$attrs` onto the inner shell, where it IS a
94
+ * declared prop — and fallthrough attrs override explicit props,
95
+ * so a stray `content-mode="stage"` from a template or a
96
+ * `v-bind="$attrs"` relay would silently flip the admin shell into
97
+ * the stage host. Declared here, the input is a real prop of this
98
+ * component and forwards honestly; the default stays `document`,
99
+ * which is what the admin face is. */
100
+ contentMode: { type: String as PropType<"document" | "stage">, default: "document" },
191
101
  },
192
102
  setup(props, { slots }) {
193
- const { t } = useI18n();
194
- const { width: viewportWidth } = useBreakpoint();
195
- const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
196
- const sidebarOpen = ref(false);
197
-
198
- const contentStyle = computed(() => ({
199
- padding: isDesktop.value || !props.contentBleedOnMobile
200
- ? props.contentPadding
201
- : mobileSideGutterFree(props.contentPadding),
202
- }));
203
-
204
103
  const actionBar = provideActionBar();
205
104
 
206
- // The status-bar band exists only when the shell renders one (the
207
- // footer slot) or the host pins its own bar and says so via the prop
208
- // — an unreserved shell carries no dead clearance (2026-10-24: the
209
- // unconditional default used to blank 3rem under every console that
210
- // never mounted a status bar). Computed INSIDE the render (not a
211
- // `computed()`): slot PRESENCE is what it reads, and a cached
212
- // computed is not reliably invalidated by a slot appearing or
213
- // disappearing, while every re-render re-evaluates this line.
214
- function footerPad(): string {
215
- if (props.footerHeight !== undefined) return props.footerHeight;
216
- return slots.footer ? "var(--s-footer-height)" : "0px";
217
- }
218
-
219
- const toggleHamburger = () => {
220
- sidebarOpen.value = !sidebarOpen.value;
221
- };
222
-
223
- const openSidebar = () => {
224
- sidebarOpen.value = true;
225
- };
226
-
227
- const closeSidebar = () => {
228
- sidebarOpen.value = false;
229
- };
230
-
231
- return () => (
232
- <div class="s-admin-shell">
233
- {slots.header && (
234
- <div style={{ flexShrink: 0 }}>
235
- {slots.header({
236
- isDesktop: isDesktop.value,
237
- showHamburger: !isDesktop.value,
238
- compact: !isDesktop.value,
239
- actions: actionBar.actions.value ? actionBar.actions.value() : [],
240
- onHamburger: toggleHamburger,
241
- // Lets a header trigger (e.g. the avatar in "drawer" action
242
- // mode) open the mobile nav drawer directly.
243
- onOpenDrawer: openSidebar,
244
- })}
245
- </div>
246
- )}
247
-
248
- <div class="s-admin-shell-body" style={{ paddingBottom: footerPad() }}>
249
- {isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
250
- <aside
251
- style={{
252
- width: props.sidebarWidth,
253
- flexShrink: 0,
254
- borderRight: "1px solid var(--border-faint, rgb(var(--color-border) / 10%))",
255
- background: "rgb(var(--color-surface))",
256
- overflow: "hidden",
257
- }}
258
- >
259
- {slots.sidebar({ collapsed: false, onNavigate: closeSidebar })}
260
- </aside>
261
- )}
262
- <main class="s-admin-shell-main">
263
- <HScrollContainer class="s-admin-shell-scroll">
264
- {/* Padding lives INSIDE the scroll viewport (an inner
265
- wrapper) so card box-shadows are not clipped at the
266
- viewport edges. */}
267
- <div style={contentStyle.value}>
268
- {props.contentKey != null ? (
269
- <HkCrossfade swapKey={props.contentKey} class="s-admin-shell-swap">
270
- {slots.content?.()}
271
- </HkCrossfade>
272
- ) : (
273
- slots.content?.()
274
- )}
275
- </div>
276
- </HScrollContainer>
277
- </main>
278
-
279
- {!isDesktop.value && (
280
- <HDrawer
281
- modelValue={sidebarOpen.value}
282
- onUpdate:modelValue={(v: boolean) => (sidebarOpen.value = v)}
283
- side="left"
284
- size="280px"
285
- title={props.navTitle ?? t("hikari::adminShell.navTitle", "Navigation")}
286
- panelClass={props.drawerPanelClass}
287
- >
288
- {/* The drawer body carries the nav; a `userPanel` slot rides
289
- the drawer footer (identity + account actions) so mobile
290
- gets the same content the desktop user menu exposes.
291
- `inDrawer` lets the sidebar slot fill the drawer width.
292
- The userPanel receives `onNavigate` (closes the drawer)
293
- so its action rows — e.g. "go to frontend", which swaps
294
- the whole layout underneath — can dismiss the drawer
295
- instead of leaving it hovering over the new page. */}
296
- {{
297
- default: () =>
298
- slots.sidebar?.({ collapsed: false, onNavigate: closeSidebar, inDrawer: true }),
299
- footer: slots.userPanel
300
- ? () => slots.userPanel!({ onNavigate: closeSidebar })
301
- : undefined,
302
- }}
303
- </HDrawer>
304
- )}
305
- </div>
306
-
307
- {slots.footer && (
308
- <footer class="s-status-bar" style={{ position: "fixed", bottom: 0, left: 0, right: 0, // Footer band (was a bare 40 — the pre-band chrome value).
309
- zIndex: "var(--hk-z-toolbar)" }}>
310
- {slots.footer()}
311
- </footer>
312
- )}
313
-
314
- {/* Scoped slots are functions — rendering the slot itself instead of
315
- calling it would stringify the compiled withCtx source into a text
316
- node (normalizeVNode String()s non-vnode children). */}
317
- {slots.overlays?.()}
318
- </div>
319
- );
105
+ return () =>
106
+ h(HkFaceShell, {
107
+ sidebarCollapsed: props.sidebarCollapsed,
108
+ sidebarWidth: props.sidebarWidth,
109
+ mobileBreakpoint: props.mobileBreakpoint,
110
+ footerHeight: props.footerHeight,
111
+ navTitle: props.navTitle,
112
+ drawerPanelClass: props.drawerPanelClass,
113
+ // The admin face IS the document mode by default — stage faces
114
+ // normally consume HkFaceShell directly; the forward keeps an
115
+ // explicit opt-in honest (and the declared prop above keeps a
116
+ // stray attr from ever reaching the inner shell on its own).
117
+ contentMode: props.contentMode,
118
+ contentPadding: props.contentPadding,
119
+ contentKey: props.contentKey,
120
+ contentBleedOnMobile: props.contentBleedOnMobile,
121
+ }, {
122
+ header: slots.header
123
+ ? (shellParams: Record<string, unknown>) =>
124
+ slots.header!({
125
+ ...shellParams,
126
+ // The legacy channel this wrapper exists to keep: the
127
+ // actions of the bar provided above, handed to the header
128
+ // slot exactly as before the extraction.
129
+ actions: actionBar.actions.value ? actionBar.actions.value() : [],
130
+ })
131
+ : undefined,
132
+ sidebar: slots.sidebar,
133
+ userPanel: slots.userPanel,
134
+ content: slots.content,
135
+ footer: slots.footer,
136
+ overlays: slots.overlays,
137
+ });
320
138
  },
321
139
  });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as barrel from "../index";
3
+
4
+ /**
5
+ * The public barrel guard for HkFaceShell (R3 M6 on PR #900): deleting
6
+ * the export line from index.ts kept the ENTIRE suite green before this
7
+ * pin — every other test reaches the component through the direct
8
+ * "./HkFaceShell" path, so only an external consumer would have noticed.
9
+ * Same shape as theme/themeDecorSurface.test.ts.
10
+ *
11
+ * This file deliberately does NOT mock "@celestia-island/hikari": the
12
+ * package's self-reference resolves to the very same src/index.ts module
13
+ * as "../index", so a vi.mock here would replace the barrel under test
14
+ * with the mock's own namespace (measured: HkFaceShell read as undefined
15
+ * through `...actual` under the re-entrant factory).
16
+ */
17
+ describe("the public barrel", () => {
18
+ it("exports HkFaceShell (deleting the line kept the suite green before this pin)", () => {
19
+ // defineComponent's object syntax yields a plain object, so the
20
+ // identity check is the component's own `name`, not typeof.
21
+ const face = barrel.HkFaceShell as unknown as { name?: string };
22
+ expect(face, "HkFaceShell must be re-exported").toBeTruthy();
23
+ expect(face.name).toBe("HkFaceShell");
24
+ // The admin wrapper stays reachable too — the extraction must not
25
+ // move the public surface.
26
+ const admin = barrel.HkAdminShell as unknown as { name?: string };
27
+ expect(admin, "HkAdminShell must stay exported").toBeTruthy();
28
+ expect(admin.name).toBe("HkAdminShell");
29
+ });
30
+ });
@@ -0,0 +1,242 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createApp, h } from "vue";
6
+
7
+ // HDrawer teleports and animates; replace it with an inline passthrough
8
+ // (same stub shape as HkAdminShell.test.tsx) so the shell contract is
9
+ // testable without the overlay runtime.
10
+ vi.mock("@celestia-island/hikari", async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import("@celestia-island/hikari")>();
12
+ const { defineComponent, h } = await import("vue");
13
+ const HDrawerStub = defineComponent({
14
+ name: "HDrawer",
15
+ props: {
16
+ modelValue: { type: Boolean, default: false },
17
+ side: { type: String, default: "left" },
18
+ size: { type: String, default: "280px" },
19
+ title: { type: String, default: undefined },
20
+ panelClass: { type: String, default: undefined },
21
+ },
22
+ setup(props, { slots }) {
23
+ return () =>
24
+ props.modelValue
25
+ ? h("div", { class: ["drawer-stub", props.panelClass] }, [
26
+ h("div", { class: "drawer-stub-body" }, slots.default?.()),
27
+ slots.footer
28
+ ? h("div", { class: "drawer-stub-footer" }, slots.footer())
29
+ : null,
30
+ ])
31
+ : null;
32
+ },
33
+ });
34
+ return { ...actual, HDrawer: HDrawerStub };
35
+ });
36
+
37
+ import { HkFaceShell } from "./HkFaceShell";
38
+ import { HkAdminShell } from "./HkAdminShell";
39
+
40
+ /**
41
+ * HkFaceShell — the generalized face shell extracted from HkAdminShell
42
+ * (2026-10-29). What this file pins:
43
+ *
44
+ * 1. The STAGE content mode: no scroll viewport, no padding wrapper, the
45
+ * content bare under the main region — a canvas face must never be
46
+ * wrapped in the document viewport (that is the mode's reason for
47
+ * existing). `contentPadding` is ignored there.
48
+ * 2. The DOCUMENT mode (default) keeps the admin shell's exact host:
49
+ * scroll viewport + inner padding wrapper + `data-content-mode`.
50
+ * 3. `contentKey` crossfade stays available in BOTH modes.
51
+ * 4. The footer band + its injection-driven height reservation behave
52
+ * identically in both modes.
53
+ * 5. The extraction boundary: HkFaceShell's header slot carries NO
54
+ * `actions` param (the shell provides no action bar); HkAdminShell —
55
+ * the admin wrapper — still injects it (the legacy channel that
56
+ * wrapper exists to keep).
57
+ *
58
+ * (Repo test convention: raw createApp + container queries, no
59
+ * @vue/test-utils dependency.)
60
+ */
61
+
62
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
63
+
64
+ function mount(node: ReturnType<typeof h>) {
65
+ const container = document.createElement("div");
66
+ document.body.appendChild(container);
67
+ const app = createApp({ render: () => node });
68
+ app.mount(container);
69
+ mounts.push({ app, container });
70
+ return container;
71
+ }
72
+
73
+ afterEach(() => {
74
+ for (const { app, container } of mounts.splice(0)) {
75
+ app.unmount();
76
+ container.remove();
77
+ }
78
+ });
79
+
80
+ const CONTENT = () => h("main", null, "CONTENT");
81
+ const FOOTER = () => h("div", { class: "footer-mock" }, "FOOTER");
82
+
83
+ describe("HkFaceShell stage mode", () => {
84
+ it("renders the content bare — no scroll viewport, no padding wrapper", () => {
85
+ const c = mount(h(HkFaceShell, {
86
+ contentMode: "stage",
87
+ contentPadding: "3rem",
88
+ navTitle: "Navigation",
89
+ }, {
90
+ header: () => null,
91
+ content: CONTENT,
92
+ }));
93
+ const main = c.querySelector(".s-admin-shell-main");
94
+ expect(main, "the main region exists").toBeTruthy();
95
+ expect(main!.getAttribute("data-content-mode")).toBe("stage");
96
+ // The document host is GONE — that is the stage contract.
97
+ expect(c.querySelector(".hk-scroll-container")).toBeNull();
98
+ expect(c.querySelector(".hk-scroll-container-viewport")).toBeNull();
99
+ // No padding wrapper anywhere: the content slot's first element IS
100
+ // the main region's child (contentPadding is a document-mode prop).
101
+ expect(main!.firstElementChild?.textContent).toBe("CONTENT");
102
+ expect([...main!.querySelectorAll("*")].every((el) => (el as HTMLElement).style.padding === "")).toBe(true);
103
+ });
104
+
105
+ it("still honors the contentKey crossfade in stage mode", () => {
106
+ const c = mount(h(HkFaceShell, {
107
+ contentMode: "stage",
108
+ contentKey: "scene-a",
109
+ navTitle: "Navigation",
110
+ }, {
111
+ header: () => null,
112
+ content: CONTENT,
113
+ }));
114
+ const swap = c.querySelector(".s-admin-shell-swap");
115
+ expect(swap, "the library crossfade wraps the stage content").toBeTruthy();
116
+ expect(swap!.className).toContain("hk-crossfade");
117
+ // …and the crossfade is the main region's DIRECT child (no host
118
+ // wrapper between them).
119
+ expect(swap!.parentElement!.classList.contains("s-admin-shell-main")).toBe(true);
120
+ });
121
+
122
+ it("keeps the footer band and its reservation in stage mode", () => {
123
+ const withFooter = mount(h(HkFaceShell, {
124
+ contentMode: "stage",
125
+ navTitle: "Navigation",
126
+ }, {
127
+ header: () => null,
128
+ content: CONTENT,
129
+ footer: FOOTER,
130
+ }));
131
+ expect(withFooter.querySelector("footer.s-status-bar .footer-mock")).toBeTruthy();
132
+ expect((withFooter.querySelector(".s-admin-shell-body") as HTMLElement)!.style.paddingBottom)
133
+ .toBe("var(--s-footer-height)");
134
+
135
+ const withoutFooter = mount(h(HkFaceShell, {
136
+ contentMode: "stage",
137
+ navTitle: "Navigation",
138
+ }, {
139
+ header: () => null,
140
+ content: CONTENT,
141
+ }));
142
+ expect(withoutFooter.querySelector("footer.s-status-bar")).toBeNull();
143
+ expect((withoutFooter.querySelector(".s-admin-shell-body") as HTMLElement)!.style.paddingBottom).toBe("0px");
144
+ });
145
+ });
146
+
147
+ describe("HkFaceShell document mode (the default)", () => {
148
+ it("keeps the admin shell's exact content host", () => {
149
+ const c = mount(h(HkFaceShell, {
150
+ contentPadding: "2rem",
151
+ navTitle: "Navigation",
152
+ }, {
153
+ header: () => null,
154
+ content: CONTENT,
155
+ }));
156
+ const main = c.querySelector(".s-admin-shell-main");
157
+ expect(main!.getAttribute("data-content-mode")).toBe("document");
158
+ expect(c.querySelector(".hk-scroll-container")).toBeTruthy();
159
+ const viewport = c.querySelector(".hk-scroll-container-viewport")
160
+ ?? c.querySelector(".hk-scroll-container");
161
+ const inner = viewport!.firstElementChild as HTMLElement;
162
+ expect(inner.style.padding).toBe("2rem");
163
+ expect(inner.textContent).toContain("CONTENT");
164
+ });
165
+ });
166
+
167
+ describe("the extraction boundary: the action-bar channel", () => {
168
+ it("HkFaceShell's header slot carries no actions param (no bar provided)", () => {
169
+ const seen: Array<Record<string, unknown>> = [];
170
+ mount(h(HkFaceShell, { navTitle: "Navigation" }, {
171
+ header: (params: Record<string, unknown>) => {
172
+ seen.push(params);
173
+ return null;
174
+ },
175
+ content: CONTENT,
176
+ }));
177
+ expect(seen.length).toBeGreaterThan(0);
178
+ for (const params of seen) {
179
+ expect("actions" in params).toBe(false);
180
+ }
181
+ // The shell-scoped params the face header relies on are all there.
182
+ expect(seen[0]).toMatchObject({
183
+ isDesktop: true,
184
+ showHamburger: false,
185
+ compact: false,
186
+ });
187
+ expect(typeof seen[0].onOpenDrawer).toBe("function");
188
+ });
189
+
190
+ it("HkAdminShell still injects actions into its header slot (the legacy channel)", () => {
191
+ const seen: Array<Record<string, unknown>> = [];
192
+ mount(h(HkAdminShell, { navTitle: "Navigation" }, {
193
+ header: (params: Record<string, unknown>) => {
194
+ seen.push(params);
195
+ return null;
196
+ },
197
+ content: CONTENT,
198
+ }));
199
+ expect(seen.length).toBeGreaterThan(0);
200
+ for (const params of seen) {
201
+ expect("actions" in params).toBe(true);
202
+ expect(Array.isArray(params.actions)).toBe(true);
203
+ }
204
+ });
205
+ });
206
+
207
+ describe("the contentMode attrs leak (R2 S2)", () => {
208
+ it("HkAdminShell declares and forwards contentMode — a passed stage value reaches the host honestly", () => {
209
+ const staged = mount(h(HkAdminShell, {
210
+ contentMode: "stage",
211
+ navTitle: "Navigation",
212
+ }, {
213
+ header: () => null,
214
+ content: CONTENT,
215
+ }));
216
+ expect(staged.querySelector(".s-admin-shell-main")!.getAttribute("data-content-mode"))
217
+ .toBe("stage");
218
+ expect(staged.querySelector(".hk-scroll-container")).toBeNull();
219
+
220
+ const defaulted = mount(h(HkAdminShell, { navTitle: "Navigation" }, {
221
+ header: () => null,
222
+ content: CONTENT,
223
+ }));
224
+ expect(defaulted.querySelector(".s-admin-shell-main")!.getAttribute("data-content-mode"))
225
+ .toBe("document");
226
+ expect(defaulted.querySelector(".hk-scroll-container")).toBeTruthy();
227
+ });
228
+
229
+ it("the wrapper keeps the DECLARATION (the half the behavior test cannot see)", () => {
230
+ // Without the declared prop, a stray `content-mode` from a template
231
+ // or a v-bind="$attrs" relay falls through to the inner shell, where
232
+ // fallthrough attrs OVERRIDE explicit props — the leak R2 proved
233
+ // with a repro. The behavior test above cannot distinguish honest
234
+ // forwarding from the leak (both render stage for a passed value),
235
+ // so the declaration itself is pinned at source.
236
+ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "HkAdminShell.tsx"), "utf-8");
237
+ expect(src, "the prop must be declared on the wrapper").toContain(
238
+ 'contentMode: { type: String as PropType<"document" | "stage">, default: "document" }',
239
+ );
240
+ expect(src, "and forwarded honestly").toContain("contentMode: props.contentMode,");
241
+ });
242
+ });
@@ -0,0 +1,369 @@
1
+ import { computed, defineComponent, ref, type PropType } from "vue";
2
+ import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
3
+ import { useI18n } from "../i18n/context";
4
+
5
+ /** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
6
+ * padding (`inherit 0 inherit` is rejected by every engine, measured in
7
+ * Chromium), so the declaration would be dropped WHOLE and the page
8
+ * would lose the vertical clearance too — worse than not bleeding. */
9
+ const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
10
+
11
+ /**
12
+ * A scanned padding shorthand: the top-level `sides` plus whether the
13
+ * value ENDED with every function closed and every quote terminated.
14
+ * `balanced: false` means the scan cannot know where the value ends —
15
+ * CSS closes an open function at EOF, so the authored declaration can
16
+ * still be valid while a rewrite of it cannot be.
17
+ */
18
+ export interface PaddingSidesScan {
19
+ sides: string[];
20
+ balanced: boolean;
21
+ }
22
+
23
+ /**
24
+ * Scan a CSS padding shorthand into its top-level tracks: split on
25
+ * TOP-LEVEL whitespace, quote- and function-aware, so whitespace inside
26
+ * a function belongs to that value — `calc(1rem + 2px)`,
27
+ * `var(--pad, 1rem)` and a quoted argument are ONE track. A plain
28
+ * `split(/\s+/)` shreds them (`["calc(1rem", "+", "2px)"]`), and the
29
+ * rebuilt shorthand is invalid CSS — a browser drops the whole
30
+ * declaration, taking with it the vertical clearance
31
+ * `contentBleedOnMobile` promises to keep (rounds 2 and 3 of this change
32
+ * hit exactly that; rounds 4 and 5 the escape and EOF variants). The
33
+ * depth counter balances parentheses, so nested functions
34
+ * (`clamp(1rem, min(2vw, 3px), 3px)`) survive; a stray `)` never drives
35
+ * the depth negative. Tabs and newlines are valid CSS separators and
36
+ * split like spaces, and the whitespace around a shorthand yields no
37
+ * empty tracks.
38
+ *
39
+ * Exported for its own unit test: happy-dom cannot round-trip
40
+ * `var()`/`clamp()` values or the hostile inputs below, so the
41
+ * component-level suite cannot observe them.
42
+ */
43
+ export function scanPaddingSides(value: string): PaddingSidesScan {
44
+ const sides: string[] = [];
45
+ let current = "";
46
+ let depth = 0;
47
+ let quote = "";
48
+ for (const ch of value) {
49
+ if (quote) {
50
+ current += ch;
51
+ if (ch === quote) quote = "";
52
+ continue;
53
+ }
54
+ if (ch === "\"" || ch === "'") {
55
+ quote = ch;
56
+ current += ch;
57
+ continue;
58
+ }
59
+ if (ch === "(") depth += 1;
60
+ else if (ch === ")") depth = Math.max(0, depth - 1);
61
+ if (depth === 0 && /\s/.test(ch)) {
62
+ if (current) sides.push(current);
63
+ current = "";
64
+ continue;
65
+ }
66
+ current += ch;
67
+ }
68
+ if (current) sides.push(current);
69
+ return { sides, balanced: depth === 0 && quote === "" };
70
+ }
71
+
72
+ /** The tracks of {@link scanPaddingSides} — for callers that only need
73
+ * the split, and for the unit test that pins the splitting rules. */
74
+ export function splitPaddingSides(value: string): string[] {
75
+ return scanPaddingSides(value).sides;
76
+ }
77
+
78
+ /**
79
+ * The side-gutter-free form of a padding shorthand: the vertical tracks
80
+ * survive, the horizontal ones become zero (`"1rem 2rem"` -> `"1rem 0
81
+ * 1rem"`). The value is returned VERBATIM — the page keeps its padding
82
+ * and simply does not bleed — whenever the shorthand cannot be rewritten
83
+ * safely. That fallback IS the contract: emitting a declaration a browser
84
+ * drops costs the page ALL FOUR sides, including the vertical clearance
85
+ * this prop exists to keep.
86
+ *
87
+ * Verbatim cases:
88
+ *
89
+ * - an empty / whitespace-only value has nothing to strip;
90
+ * - a LONE CSS-wide keyword cannot be combined per side (`inherit 0
91
+ * inherit` is not a padding — every engine rejects it);
92
+ * - the value contains a CSS comment, whose text would land in the
93
+ * rebuilt declaration as a garbage track (`1rem 0 x`, measured in
94
+ * Chromium: dropped, all four sides lost);
95
+ * - the value contains a backslash: this scanner does not decode CSS
96
+ * escapes, and an escape can hide a quote, a parenthesis or a whole
97
+ * keyword (`\69 nherit` IS `inherit` to an engine) — the literal text
98
+ * is not the value, so no rewrite of it is trustworthy (round 5);
99
+ * - the value ends with a `!important` annotation: the annotation
100
+ * belongs to the DECLARATION, so a per-side rewrite would either
101
+ * silently lose the priority (`2rem !important`) or rebuild the
102
+ * `inherit 0 inherit` every engine rejects (`inherit !important`,
103
+ * measured in Chromium, round 5);
104
+ * - the scan ends inside an open function or an unterminated quote: CSS
105
+ * auto-closes a function at EOF, so `"calc(1rem + 2px"` IS a real
106
+ * padding (measured: all four sides) while its rewrite is not; an
107
+ * unterminated quote is invalid at both ends, and bailing keeps the
108
+ * two paths identical (round 5).
109
+ *
110
+ * Known residual (documented, not fixable at string level): a `var()`
111
+ * whose custom property resolves to MORE than one track cannot keep its
112
+ * vertical half — `padding: var(--multi) 0 var(--multi)` is valid at
113
+ * parse time and invalid at computed-value time, and all four sides
114
+ * collapse. No consumer passes a multi-track custom property today.
115
+ */
116
+ export function mobileSideGutterFree(value: string): string {
117
+ if (value.includes("/*") || value.includes("\\")) return value;
118
+ // `!important` is an annotation on the DECLARATION, not a track — it
119
+ // cannot ride a per-side rewrite: `2rem !important` would silently lose
120
+ // its priority, and `inherit !important` would rebuild into the
121
+ // `inherit 0 inherit` every engine drops (measured in Chromium, round
122
+ // 5). Bail out and let the authored padding stand.
123
+ if (/!\s*important\s*$/i.test(value)) return value;
124
+ const { sides, balanced } = scanPaddingSides(value);
125
+ if (!balanced) return value;
126
+ if (sides.length === 0) return value;
127
+ if (sides.length === 1 && CSS_WIDE_KEYWORDS.has(sides[0].toLowerCase())) return value;
128
+ const [top, , bottom] = sides;
129
+ return `${top} 0 ${bottom ?? top}`;
130
+ }
131
+
132
+ /**
133
+ * HkFaceShell — the generalized face shell (2026-10-29, extracted from
134
+ * HkAdminShell): the ONE frame every app face hangs its chrome on —
135
+ * an optional header row, an optional sidebar with its mobile drawer,
136
+ * the content host, the fixed status-bar band, and the overlays slot.
137
+ *
138
+ * HkAdminShell is this shell's ADMIN face: it composes HkFaceShell in
139
+ * document mode and adds its own action-bar provide (the legacy channel
140
+ * some hosts still read through the header slot's `actions` param).
141
+ * Hosts that own their action bar — or their whole content lifecycle —
142
+ * consume HkFaceShell directly, so the shell carries NO action-bar
143
+ * provider of its own: whoever provides, provides closest to the pages.
144
+ *
145
+ * The content host has TWO modes (`contentMode`):
146
+ *
147
+ * - `document` (default) — the console shape HkAdminShell has always
148
+ * rendered: one persistent `HScrollContainer` viewport, the
149
+ * `contentPadding` inner wrapper (box-shadows never clipped), the
150
+ * `contentKey` crossfade, and the `contentBleedOnMobile` phone-gutter
151
+ * drop. `contentPadding` / `contentBleedOnMobile` apply ONLY here.
152
+ *
153
+ * - `stage` — the canvas shape: NO scroll container and NO padding
154
+ * wrapper; the content slot renders directly under the shell's main
155
+ * region and owns its scrolling, its canvases and its floating
156
+ * layers (the chat face's desktop surfaces, industrial boards, 3D
157
+ * scenes). A stage face must not be wrapped in the document viewport
158
+ * — that is the whole reason the mode exists. `contentKey` remains
159
+ * honored for faces that want the library's keyed crossfade anyway.
160
+ *
161
+ * Everything else — the header slot's scoped params (minus the admin
162
+ * `actions` param), the sidebar + drawer split, the `userPanel` drawer
163
+ * footer, the fixed footer band and its injection-driven height
164
+ * reservation, the overlays slot — behaves identically in both modes.
165
+ */
166
+ export const HkFaceShell = defineComponent({
167
+ name: "HkFaceShell",
168
+ props: {
169
+ sidebarCollapsed: { type: Boolean, default: false },
170
+ sidebarWidth: { type: String, default: "224px" },
171
+ /** Viewport width at which the desktop layout takes over (below it
172
+ * the nav collapses into the mobile drawer). Defaults to the shared
173
+ * 1024px "lg" breakpoint; lower it (e.g. 768) for tablet-friendly
174
+ * faces that keep the sidebar at md widths. */
175
+ mobileBreakpoint: { type: Number, default: 1024 },
176
+ /** Height reserved under the scroll body for a fixed status-bar
177
+ * band. INJECTION-DRIVEN (2026-10-24): the reservation tracks
178
+ * whether the shell actually renders a status bar — a filled
179
+ * `footer` slot (the shell's own fixed `.s-status-bar`) reserves
180
+ * `var(--s-footer-height)` by default, and with NO footer slot
181
+ * nothing is reserved (`0px`). The old unconditional default
182
+ * reserved the band on every host, leaving a dead 3rem blank under
183
+ * faces that never mount a status bar. Hosts that pin their OWN
184
+ * fixed bar outside the shell opt back in by passing an explicit
185
+ * value (e.g. `"var(--s-footer-height)"`). */
186
+ footerHeight: { type: String, default: undefined },
187
+ navTitle: { type: String, default: undefined },
188
+ /** Extra class for the mobile nav drawer's panel — lets consumers zero
189
+ * nested paddings so drawer nav rows and a `userPanel` footer share
190
+ * one left edge. */
191
+ drawerPanelClass: { type: String, default: undefined },
192
+ /** Content host mode (see the component doc): `document` wraps the
193
+ * content in the shell's scroll viewport + padding wrapper;
194
+ * `stage` renders the content bare — the face owns scrolling,
195
+ * canvases and floating layers. */
196
+ contentMode: { type: String as PropType<"document" | "stage">, default: "document" },
197
+ /** Content-area padding (inside the scroll viewport). Document mode
198
+ * only — a stage face owns its own geometry. Padding is applied to
199
+ * an inner wrapper rather than the scroll container so card
200
+ * box-shadows are never clipped at the viewport edges. Applied
201
+ * verbatim at every width unless the page opts into
202
+ * `contentBleedOnMobile`. */
203
+ contentPadding: { type: String, default: "1.5rem" },
204
+ /** Identity of the content page on stage (task-4, 2026-10-24):
205
+ * pass the current view/route key and the content area swaps pages
206
+ * through the family crossfade — the library owns the page-slot
207
+ * mechanism instead of every host hand-wrapping it. Omitted (or
208
+ * null) renders the slot bare: canvas-like hosts that mount one
209
+ * persistent stage opt out. Honored in BOTH content modes. */
210
+ contentKey: { type: [String, Number] as PropType<string | number | null>, default: null },
211
+ /** Whether the content on screen is a CANVAS — a 2D board or the 3D
212
+ * scene — that must span the full phone width. Document mode only
213
+ * (a stage face IS the canvas; there is no gutter to drop).
214
+ * OPT-IN, declared by the page that renders the canvas
215
+ * (2026-10-04 user direction: the side-gutter drop is a PER-PAGE
216
+ * declaration, never a shell-wide rule — an ordinary page keeps
217
+ * its side gutters on phones).
218
+ *
219
+ * When true, below `mobileBreakpoint` the horizontal half of
220
+ * `contentPadding` is dropped while the vertical clearance
221
+ * survives; desktop is never affected and the verbatim padding is
222
+ * restored when the viewport crosses back above the breakpoint.
223
+ * A `contentPadding` that cannot be rewritten per side (empty, a
224
+ * lone CSS-wide keyword, or a value this scanner cannot trust: one
225
+ * carrying a CSS comment or a backslash escape, one ending in a
226
+ * `!important` annotation, or one whose functions/quotes never
227
+ * close) falls back to the verbatim value — see
228
+ * `mobileSideGutterFree`; the page then keeps its gutters instead
229
+ * of risking a declaration a browser would drop whole.
230
+ * Default false: the padding reads the same at every width. */
231
+ contentBleedOnMobile: { type: Boolean, default: false },
232
+ },
233
+ setup(props, { slots }) {
234
+ const { t } = useI18n();
235
+ const { width: viewportWidth } = useBreakpoint();
236
+ const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
237
+ const sidebarOpen = ref(false);
238
+
239
+ const contentStyle = computed(() => ({
240
+ padding: isDesktop.value || !props.contentBleedOnMobile
241
+ ? props.contentPadding
242
+ : mobileSideGutterFree(props.contentPadding),
243
+ }));
244
+
245
+ // The status-bar band exists only when the shell renders one (the
246
+ // footer slot) or the host pins its own bar and says so via the prop
247
+ // — an unreserved shell carries no dead clearance (2026-10-24: the
248
+ // unconditional default used to blank 3rem under every console that
249
+ // never mounted a status bar). Computed INSIDE the render (not a
250
+ // `computed()`): slot PRESENCE is what it reads, and a cached
251
+ // computed is not reliably invalidated by a slot appearing or
252
+ // disappearing, while every re-render re-evaluates this line.
253
+ function footerPad(): string {
254
+ if (props.footerHeight !== undefined) return props.footerHeight;
255
+ return slots.footer ? "var(--s-footer-height)" : "0px";
256
+ }
257
+
258
+ const toggleHamburger = () => {
259
+ sidebarOpen.value = !sidebarOpen.value;
260
+ };
261
+
262
+ const openSidebar = () => {
263
+ sidebarOpen.value = true;
264
+ };
265
+
266
+ const closeSidebar = () => {
267
+ sidebarOpen.value = false;
268
+ };
269
+
270
+ /** The content stage: the keyed crossfade when a face opts in, the
271
+ * bare slot otherwise — identical in both content modes; what the
272
+ * mode selects is the HOST around it (scroll viewport + padding in
273
+ * document mode, nothing in stage mode). */
274
+ function renderContentStage() {
275
+ return props.contentKey != null ? (
276
+ <HkCrossfade swapKey={props.contentKey} class="s-admin-shell-swap">
277
+ {slots.content?.()}
278
+ </HkCrossfade>
279
+ ) : (
280
+ slots.content?.()
281
+ );
282
+ }
283
+
284
+ return () => (
285
+ <div class="s-admin-shell">
286
+ {slots.header && (
287
+ <div style={{ flexShrink: 0 }}>
288
+ {slots.header({
289
+ isDesktop: isDesktop.value,
290
+ showHamburger: !isDesktop.value,
291
+ compact: !isDesktop.value,
292
+ onHamburger: toggleHamburger,
293
+ // Lets a header trigger (e.g. the avatar in "drawer" action
294
+ // mode) open the mobile nav drawer directly.
295
+ onOpenDrawer: openSidebar,
296
+ })}
297
+ </div>
298
+ )}
299
+
300
+ <div class="s-admin-shell-body" style={{ paddingBottom: footerPad() }}>
301
+ {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>
313
+ )}
314
+ <main class="s-admin-shell-main" data-content-mode={props.contentMode}>
315
+ {props.contentMode === "stage" ? (
316
+ renderContentStage()
317
+ ) : (
318
+ <HScrollContainer class="s-admin-shell-scroll">
319
+ {/* Padding lives INSIDE the scroll viewport (an inner
320
+ wrapper) so card box-shadows are not clipped at the
321
+ viewport edges. */}
322
+ <div style={contentStyle.value}>{renderContentStage()}</div>
323
+ </HScrollContainer>
324
+ )}
325
+ </main>
326
+
327
+ {!isDesktop.value && (
328
+ <HDrawer
329
+ modelValue={sidebarOpen.value}
330
+ onUpdate:modelValue={(v: boolean) => (sidebarOpen.value = v)}
331
+ side="left"
332
+ size="280px"
333
+ title={props.navTitle ?? t("hikari::adminShell.navTitle", "Navigation")}
334
+ panelClass={props.drawerPanelClass}
335
+ >
336
+ {/* The drawer body carries the nav; a `userPanel` slot rides
337
+ the drawer footer (identity + account actions) so mobile
338
+ gets the same content the desktop user menu exposes.
339
+ `inDrawer` lets the sidebar slot fill the drawer width.
340
+ The userPanel receives `onNavigate` (closes the drawer)
341
+ so its action rows — e.g. "go to frontend", which swaps
342
+ the whole layout underneath — can dismiss the drawer
343
+ instead of leaving it hovering over the new page. */}
344
+ {{
345
+ default: () =>
346
+ slots.sidebar?.({ collapsed: false, onNavigate: closeSidebar, inDrawer: true }),
347
+ footer: slots.userPanel
348
+ ? () => slots.userPanel!({ onNavigate: closeSidebar })
349
+ : undefined,
350
+ }}
351
+ </HDrawer>
352
+ )}
353
+ </div>
354
+
355
+ {slots.footer && (
356
+ <footer class="s-status-bar" style={{ position: "fixed", bottom: 0, left: 0, right: 0, // Footer band (was a bare 40 — the pre-band chrome value).
357
+ zIndex: "var(--hk-z-toolbar)" }}>
358
+ {slots.footer()}
359
+ </footer>
360
+ )}
361
+
362
+ {/* Scoped slots are functions — rendering the slot itself instead of
363
+ calling it would stringify the compiled withCtx source into a text
364
+ node (normalizeVNode String()s non-vnode children). */}
365
+ {slots.overlays?.()}
366
+ </div>
367
+ );
368
+ },
369
+ });
@@ -95,6 +95,20 @@ describe("admin header chrome geometry (the two user-requested fixes)", () => {
95
95
  expect(rule).toContainEqual(["gap", "var(--space-16)"]);
96
96
  });
97
97
 
98
+ it("reserves the drawer face's workspace-switcher slot at the trigger's own footprint", () => {
99
+ // 2026-10-29 user direction: the chat header's left wing is avatar +
100
+ // ws-trigger, and that wing's min-content floor is what stops the
101
+ // balanced grid's centre strip from sliding into the space on a
102
+ // phone. The console drawer face (no title, no workspace concept)
103
+ // must keep the slot as a blank of the trigger's own 28px box —
104
+ // dropping the rule would drop the floor and the strip would hug
105
+ // the avatar again.
106
+ const rule = ruleDeclarations(adminTokens, ".s-admin-header-ws-slot");
107
+ expect(rule, ".s-admin-header-ws-slot must be declared").not.toBeNull();
108
+ expect(rule).toContainEqual(["width", "var(--space-28)"]);
109
+ expect(rule).toContainEqual(["flex-shrink", "0"]);
110
+ });
111
+
98
112
  it("hangs the shared balanced-header grid contract (the ONE header layout)", () => {
99
113
  // User report 2026-10-15: the console's header-centre tab strip sat
100
114
  // LEFT of centre on phones (drawer mode hides the title, so the left
@@ -157,7 +157,11 @@ describe("pane-swap contract", () => {
157
157
  // Merging any of this into the generic container would change its
158
158
  // semantics for every existing call site. Permanent exception.
159
159
  "HkStepFlow.tsx",
160
- "HkAdminShell.tsx", // content slot is host-owned; hosts key it (chest wraps HkCrossfade)
160
+ // The content host lives in HkFaceShell since the 2026-10-29
161
+ // extraction (HkAdminShell composes it); both render the slot in
162
+ // dot form — host-owned, hosts key it (chest wraps HkCrossfade).
163
+ "HkAdminShell.tsx",
164
+ "HkFaceShell.tsx",
161
165
  "HkThemePanelContent.tsx", // forwards to HTabs (HkTabs), inherits the mechanism
162
166
  "HkMenuPanel.tsx", // menu items, not a pane swap
163
167
  // Named slots that are not pane swaps at all: a header action strip
package/src/index.ts CHANGED
@@ -757,6 +757,7 @@ export {
757
757
  export { HkErrorLanding as HkErrorLanding } from "./components/HkErrorLanding";
758
758
  export { HkErrorReportingOverlay as HkErrorReportingOverlay } from "./errorReporting";
759
759
  export { HkAdminShell as HkAdminShell } from "./components/HkAdminShell";
760
+ export { HkFaceShell as HkFaceShell } from "./components/HkFaceShell";
760
761
  export { HkAdminHeader as HkAdminHeader } from "./components/HkAdminHeader";
761
762
  export { HkNavSidebar as HkNavSidebar } from "./components/HkNavSidebar";
762
763
  export { HkThemeToggle as HkThemeToggle } from "./components/HkThemeToggle";
@@ -597,6 +597,23 @@
597
597
  object-fit: cover;
598
598
  }
599
599
 
600
+ /* The reserved workspace-switcher slot (HkAdminHeader, drawer face).
601
+ * The chat header's left wing is avatar + ws-trigger (8px flex gap +
602
+ * 28px button box), and that wing's min-content floor is what keeps the
603
+ * balanced grid's centre strip off the space on a phone: the strip can
604
+ * only shrink into its own column, never into a wing. The console's
605
+ * drawer face hides both the title and the workspace concept, so
606
+ * without a stand-in the wing's floor is just the 28px avatar — a wide
607
+ * strip (the firewall page's three sections on a 412px phone) slides
608
+ * left until it hugs the avatar (2026-10-29 user report). The blank
609
+ * span carries the trigger's 28px box; the wing's 16px gap already
610
+ * covers the button's 8px inline padding (the 2026-09-28 title-distance
611
+ * pin), so the floor lands within one padding-step of the frontend's. */
612
+ .s-admin-header-ws-slot {
613
+ width: var(--space-28);
614
+ flex-shrink: 0;
615
+ }
616
+
600
617
  /* The old hover camera veil ("change avatar" affordance) is gone: the
601
618
  * avatar trigger opens the account MENU — the avatar edit is the Camera
602
619
  * item inside it — so the veil promised an action the click never