@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 +1 -1
- package/src/components/HkAdminHeader.test.tsx +15 -0
- package/src/components/HkAdminHeader.tsx +14 -0
- package/src/components/HkAdminShell.tsx +74 -256
- package/src/components/HkFaceShell.barrel.test.ts +30 -0
- package/src/components/HkFaceShell.test.tsx +242 -0
- package/src/components/HkFaceShell.tsx +369 -0
- package/src/components/adminHeaderChrome.test.ts +14 -0
- package/src/components/paneSwap.contract.test.ts +5 -1
- package/src/index.ts +1 -0
- package/src/styles/admin-tokens.scss +17 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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`).
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|