@celestia-island/hikari 0.55.220 → 0.55.222
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/HkSettingsDialog.scss +33 -0
- package/src/components/HkSettingsDialog.test.tsx +194 -1
- package/src/components/HkSettingsDialog.tsx +86 -27
- package/src/components/adminHeaderChrome.test.ts +14 -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
|
|
@@ -122,6 +122,39 @@
|
|
|
122
122
|
flex-direction: column;
|
|
123
123
|
gap: var(--space-16);
|
|
124
124
|
}
|
|
125
|
+
|
|
126
|
+
/* ── phone: the rail becomes a top tab strip ────────────────────────
|
|
127
|
+
Under the family's mobile breakpoint the left rail moves to the
|
|
128
|
+
window's top edge as the shared pill strip (HTabs), horizontally
|
|
129
|
+
scrollable with edge fades — every section fits, the window never
|
|
130
|
+
grows a second nav column. The DOM branch and this layout share ONE
|
|
131
|
+
breakpoint through the data-phone attribute (useBreakpoint), so
|
|
132
|
+
neither half can drift. Desktop never sees these rules. */
|
|
133
|
+
&[data-phone] {
|
|
134
|
+
flex-direction: column;
|
|
135
|
+
/* A small breath between the strip and the pane (2026-10-28 user
|
|
136
|
+
direction) — tighter than the desktop rail gutter. */
|
|
137
|
+
gap: var(--space-12);
|
|
138
|
+
|
|
139
|
+
> .hk-settings__topnav {
|
|
140
|
+
flex: none;
|
|
141
|
+
/* Center the strip while it fits (the pill-strip family look);
|
|
142
|
+
once it overflows the scroller caps at 100% and the fades take
|
|
143
|
+
over, so centering never clips the overflowing start. */
|
|
144
|
+
display: flex;
|
|
145
|
+
justify-content: center;
|
|
146
|
+
|
|
147
|
+
/* One notch UP from the regular strips (text-sm, 4/12 padding,
|
|
148
|
+
16px icons): the phone nav is the window's primary switcher
|
|
149
|
+
(2026-10-28 user direction). Icons scale through the strip's
|
|
150
|
+
own trigger icon cell (the shell renders them at 18). */
|
|
151
|
+
.hk-tabs-trigger {
|
|
152
|
+
gap: var(--space-8);
|
|
153
|
+
padding: var(--space-6) var(--space-14);
|
|
154
|
+
font-size: var(--text-base);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
125
158
|
}
|
|
126
159
|
|
|
127
160
|
/* ── Dialog host: stable frame + flush scrollbar edge ────────────────── */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, afterEach } from "vitest";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
4
4
|
import { Languages, Globe } from "lucide-vue-next";
|
|
@@ -322,3 +322,196 @@ describe("settings dialog SCSS host contract (source-level)", () => {
|
|
|
322
322
|
unmount();
|
|
323
323
|
});
|
|
324
324
|
});
|
|
325
|
+
|
|
326
|
+
describe("phone shell (rail → top tab strip, 2026-10-28 user direction)", () => {
|
|
327
|
+
const originalWidth = window.innerWidth;
|
|
328
|
+
|
|
329
|
+
const setWidth = (w: number) => {
|
|
330
|
+
Object.defineProperty(window, "innerWidth", {
|
|
331
|
+
configurable: true,
|
|
332
|
+
writable: true,
|
|
333
|
+
value: w,
|
|
334
|
+
});
|
|
335
|
+
window.dispatchEvent(new Event("resize"));
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
afterEach(() => setWidth(originalWidth));
|
|
339
|
+
|
|
340
|
+
it("renders the top strip instead of the rail under the mobile breakpoint", () => {
|
|
341
|
+
setWidth(390);
|
|
342
|
+
const { container, unmount } = mount(Host);
|
|
343
|
+
// The rail is gone; the strip carries every section as a trigger and
|
|
344
|
+
// the shell marks the column layout for the SCSS.
|
|
345
|
+
expect(container.querySelector(".hk-settings__rail")).toBeNull();
|
|
346
|
+
expect(container.querySelector(".hk-settings")!.hasAttribute("data-phone")).toBe(true);
|
|
347
|
+
const triggers = container.querySelectorAll<HTMLButtonElement>(
|
|
348
|
+
".hk-settings__topnav .hk-tabs-trigger",
|
|
349
|
+
);
|
|
350
|
+
expect(triggers).toHaveLength(3);
|
|
351
|
+
expect(triggers[0].textContent).toContain("Language");
|
|
352
|
+
// Icons ride one notch up with the strip (18 vs the rail's 16).
|
|
353
|
+
expect(triggers[0].querySelector(".hk-tabs-trigger-icon svg")).toBeTruthy();
|
|
354
|
+
expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
|
|
355
|
+
unmount();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("switches sections from the strip and reports selection", async () => {
|
|
359
|
+
setWidth(390);
|
|
360
|
+
const { container, unmount } = mount(Host);
|
|
361
|
+
const triggers = container.querySelectorAll<HTMLButtonElement>(
|
|
362
|
+
".hk-settings__topnav .hk-tabs-trigger",
|
|
363
|
+
);
|
|
364
|
+
triggers[1].click();
|
|
365
|
+
await nextTick();
|
|
366
|
+
expect(container.querySelector('[data-test="pane-network"]')).toBeTruthy();
|
|
367
|
+
expect(triggers[1].getAttribute("aria-selected")).toBe("true");
|
|
368
|
+
// The strip's pill semantics: inactive triggers carry "false", not
|
|
369
|
+
// the attribute's absence.
|
|
370
|
+
expect(triggers[0].getAttribute("aria-selected")).toBe("false");
|
|
371
|
+
unmount();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("never activates a disabled section from the strip", async () => {
|
|
375
|
+
setWidth(390);
|
|
376
|
+
const { container, unmount } = mount(Host);
|
|
377
|
+
const triggers = container.querySelectorAll<HTMLButtonElement>(
|
|
378
|
+
".hk-settings__topnav .hk-tabs-trigger",
|
|
379
|
+
);
|
|
380
|
+
expect(triggers[2].disabled).toBe(true);
|
|
381
|
+
triggers[2].click();
|
|
382
|
+
await nextTick();
|
|
383
|
+
expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
|
|
384
|
+
expect(triggers[2].getAttribute("aria-selected")).toBe("false");
|
|
385
|
+
unmount();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("flips live when the viewport crosses the breakpoint", async () => {
|
|
389
|
+
// Start desktop: the rail is the nav.
|
|
390
|
+
const { container, unmount } = mount(Host);
|
|
391
|
+
expect(container.querySelector(".hk-settings__rail")).toBeTruthy();
|
|
392
|
+
expect(container.querySelector(".hk-settings__topnav")).toBeNull();
|
|
393
|
+
setWidth(390);
|
|
394
|
+
await nextTick();
|
|
395
|
+
expect(container.querySelector(".hk-settings__topnav")).toBeTruthy();
|
|
396
|
+
expect(container.querySelector(".hk-settings__rail")).toBeNull();
|
|
397
|
+
// …and back.
|
|
398
|
+
setWidth(1024);
|
|
399
|
+
await nextTick();
|
|
400
|
+
expect(container.querySelector(".hk-settings__rail")).toBeTruthy();
|
|
401
|
+
expect(container.querySelector(".hk-settings__topnav")).toBeNull();
|
|
402
|
+
unmount();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("lands navLabel on the strip's tablist", () => {
|
|
406
|
+
setWidth(390);
|
|
407
|
+
const Named = defineComponent({
|
|
408
|
+
setup() {
|
|
409
|
+
return () => (
|
|
410
|
+
<HSettingsBody sections={SECTIONS.slice(0, 2)} navLabel="Settings sections">
|
|
411
|
+
{{
|
|
412
|
+
language: () => <div data-test="pane-language" />,
|
|
413
|
+
network: () => <div data-test="pane-network" />,
|
|
414
|
+
}}
|
|
415
|
+
</HSettingsBody>
|
|
416
|
+
);
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
const { container, unmount } = mount(Named);
|
|
420
|
+
const list = container.querySelector(".hk-settings__topnav .hk-tabs-list");
|
|
421
|
+
expect(list).toBeTruthy();
|
|
422
|
+
expect(list!.getAttribute("role")).toBe("tablist");
|
|
423
|
+
expect(list!.getAttribute("aria-label")).toBe("Settings sections");
|
|
424
|
+
unmount();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("never shows a disabled-first section as selected (either nav)", async () => {
|
|
428
|
+
// R2 2026-10-28: the landing default is the first USABLE key — a
|
|
429
|
+
// disabled-first host must not open on the dead entry, and the phone
|
|
430
|
+
// strip must not frame it aria-selected while the desktop rail
|
|
431
|
+
// highlights nothing.
|
|
432
|
+
const LockedFirst = defineComponent({
|
|
433
|
+
setup() {
|
|
434
|
+
return () => (
|
|
435
|
+
<HSettingsBody
|
|
436
|
+
sections={[
|
|
437
|
+
{ key: "locked", label: "Locked", disabled: true },
|
|
438
|
+
{ key: "network", label: "Network", icon: Globe },
|
|
439
|
+
]}
|
|
440
|
+
>
|
|
441
|
+
{{
|
|
442
|
+
locked: () => <div data-test="pane-locked" />,
|
|
443
|
+
network: () => <div data-test="pane-network" />,
|
|
444
|
+
}}
|
|
445
|
+
</HSettingsBody>
|
|
446
|
+
);
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
// Desktop: the rail never marks the dead entry active.
|
|
450
|
+
const desktop = mount(LockedFirst);
|
|
451
|
+
const railItems = desktop.container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
|
|
452
|
+
expect(railItems[0].classList.contains("is-active")).toBe(false);
|
|
453
|
+
expect(railItems[1].classList.contains("is-active")).toBe(true);
|
|
454
|
+
desktop.unmount();
|
|
455
|
+
// Phone: the strip's dead trigger stays unselected; the usable one
|
|
456
|
+
// carries the selection and its pane renders.
|
|
457
|
+
setWidth(390);
|
|
458
|
+
const phone = mount(LockedFirst);
|
|
459
|
+
const triggers = phone.container.querySelectorAll<HTMLButtonElement>(
|
|
460
|
+
".hk-settings__topnav .hk-tabs-trigger",
|
|
461
|
+
);
|
|
462
|
+
expect(triggers[0].getAttribute("aria-selected")).toBe("false");
|
|
463
|
+
expect(triggers[1].getAttribute("aria-selected")).toBe("true");
|
|
464
|
+
expect(phone.container.querySelector('[data-test="pane-network"]')).toBeTruthy();
|
|
465
|
+
phone.unmount();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("round-trips strip picks to a bound section prop (controlled contract)", async () => {
|
|
469
|
+
// The pane follows internal state, so a deleted update:section emit
|
|
470
|
+
// would be invisible to an unbound host — pin the controlled half.
|
|
471
|
+
setWidth(390);
|
|
472
|
+
const section = ref("language");
|
|
473
|
+
const Controlled = defineComponent({
|
|
474
|
+
setup() {
|
|
475
|
+
return () => (
|
|
476
|
+
<HSettingsBody
|
|
477
|
+
sections={SECTIONS.slice(0, 2)}
|
|
478
|
+
section={section.value}
|
|
479
|
+
onUpdate:section={(v: string) => (section.value = v)}
|
|
480
|
+
>
|
|
481
|
+
{{
|
|
482
|
+
language: () => <div data-test="pane-language" />,
|
|
483
|
+
network: () => <div data-test="pane-network" />,
|
|
484
|
+
}}
|
|
485
|
+
</HSettingsBody>
|
|
486
|
+
);
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
const { container, unmount } = mount(Controlled);
|
|
490
|
+
const triggers = container.querySelectorAll<HTMLButtonElement>(
|
|
491
|
+
".hk-settings__topnav .hk-tabs-trigger",
|
|
492
|
+
);
|
|
493
|
+
triggers[1].click();
|
|
494
|
+
await nextTick();
|
|
495
|
+
expect(section.value).toBe("network");
|
|
496
|
+
unmount();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("pins the phone layout contract in the SCSS source", () => {
|
|
500
|
+
// happy-dom cannot exercise the cascade: pin the column flip, the
|
|
501
|
+
// gap, the centering and the one-notch-up sizing at the source. The
|
|
502
|
+
// assertions read INSIDE the &[data-phone] block — several of these
|
|
503
|
+
// declarations also exist elsewhere in the sheet, and a bare
|
|
504
|
+
// toContain would keep passing after the phone rule dies (R2).
|
|
505
|
+
const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8");
|
|
506
|
+
const phoneBlock = scss.slice(
|
|
507
|
+
scss.indexOf("&[data-phone] {"),
|
|
508
|
+
scss.indexOf("Dialog host"),
|
|
509
|
+
);
|
|
510
|
+
expect(phoneBlock).toContain("flex-direction: column;");
|
|
511
|
+
expect(phoneBlock).toContain("gap: var(--space-12);");
|
|
512
|
+
expect(phoneBlock).toContain("> .hk-settings__topnav {");
|
|
513
|
+
expect(phoneBlock).toContain("justify-content: center;");
|
|
514
|
+
expect(phoneBlock).toContain("padding: var(--space-6) var(--space-14);");
|
|
515
|
+
expect(phoneBlock).toContain("font-size: var(--text-base);");
|
|
516
|
+
});
|
|
517
|
+
});
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
|
|
11
11
|
import HkTransitionContainer from "./HkTransitionContainer";
|
|
12
12
|
import HkScrollContainer from "./HkScrollContainer";
|
|
13
|
+
import HTabs from "./HkTabs";
|
|
14
|
+
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
13
15
|
import HModal from "./HkModal";
|
|
14
16
|
import "./HkSettingsDialog.scss";
|
|
15
17
|
|
|
@@ -48,6 +50,15 @@ export interface HkSettingsSection {
|
|
|
48
50
|
* SettingsShell.scss). Inside the dialog the modal body's own scroller is
|
|
49
51
|
* made mathematically idle by the host height rule (see the SCSS); on a
|
|
50
52
|
* page the host manages its own layout.
|
|
53
|
+
*
|
|
54
|
+
* Phone anatomy (2026-10-28 user direction): under the family's mobile
|
|
55
|
+
* breakpoint the left rail moves to the TOP of the window as the shared
|
|
56
|
+
* pill strip (HTabs) — horizontally scrollable with edge fades, so every
|
|
57
|
+
* section fits no matter how many entries a host offers — one notch
|
|
58
|
+
* larger than the regular strips, with a small gap before the pane.
|
|
59
|
+
* Desktop keeps the rail exactly as it was; the flip is live on resize
|
|
60
|
+
* and shared by every consumer of this shell (page-embedded bodies
|
|
61
|
+
* included).
|
|
51
62
|
*/
|
|
52
63
|
export const HkSettingsBody = defineComponent({
|
|
53
64
|
name: "HkSettingsBody",
|
|
@@ -63,7 +74,14 @@ export const HkSettingsBody = defineComponent({
|
|
|
63
74
|
"update:section": (key: string) => typeof key === "string",
|
|
64
75
|
},
|
|
65
76
|
setup(props, { emit, slots }) {
|
|
66
|
-
|
|
77
|
+
// The landing default is the FIRST USABLE section, never a disabled
|
|
78
|
+
// one: a disabled entry "renders but cannot be activated", so it must
|
|
79
|
+
// not win the default (R2 2026-10-28 — the old first-entry fallback
|
|
80
|
+
// let a disabled-first host open on a dead section, and the phone
|
|
81
|
+
// strip went further and FRAMED it as selected).
|
|
82
|
+
const firstUsable = () =>
|
|
83
|
+
props.sections.find((s) => !s.disabled)?.key ?? "";
|
|
84
|
+
const internal = ref(firstUsable());
|
|
67
85
|
// Controlled while a section prop is bound: external changes flow in,
|
|
68
86
|
// clicks flow out. Unbound: the internal ref alone decides. Immediate
|
|
69
87
|
// so an opener that mounts ALREADY on a deep-linked section
|
|
@@ -86,7 +104,7 @@ export const HkSettingsBody = defineComponent({
|
|
|
86
104
|
const active = () =>
|
|
87
105
|
props.sections.some((s) => s.key === internal.value)
|
|
88
106
|
? internal.value
|
|
89
|
-
: (
|
|
107
|
+
: firstUsable();
|
|
90
108
|
|
|
91
109
|
// The pane is an HkScrollContainer; only its consumed ref slice is
|
|
92
110
|
// typed here — the scroll reset needs the live viewport element and
|
|
@@ -103,42 +121,83 @@ export const HkSettingsBody = defineComponent({
|
|
|
103
121
|
});
|
|
104
122
|
|
|
105
123
|
function pick(key: string) {
|
|
124
|
+
// Belt-and-braces: every caller already refuses disabled entries
|
|
125
|
+
// upstream (rail: natively disabled buttons; strip: HTabs' disabled
|
|
126
|
+
// triggers + its keyboard guard; deep links: the section watch), so
|
|
127
|
+
// deleting this guard is provably behavior-neutral today (R3
|
|
128
|
+
// mutation M6, 2026-10-28). It stays so a future caller cannot
|
|
129
|
+
// bypass the contract by accident.
|
|
106
130
|
const entry = props.sections.find((s) => s.key === key);
|
|
107
131
|
if (!entry || entry.disabled) return;
|
|
108
132
|
internal.value = key;
|
|
109
133
|
emit("update:section", key);
|
|
110
134
|
}
|
|
111
135
|
|
|
136
|
+
// Phone flips the nav to the top strip; desktop keeps the rail. One
|
|
137
|
+
// breakpoint source (useBreakpoint) drives BOTH the DOM branch and
|
|
138
|
+
// the data-phone attribute the SCSS keys the column layout on, so
|
|
139
|
+
// the two halves cannot drift (see the SCSS block for the layout).
|
|
140
|
+
const { isMobile } = useBreakpoint();
|
|
141
|
+
|
|
112
142
|
return () => (
|
|
113
|
-
<div class="hk-settings">
|
|
143
|
+
<div class="hk-settings" data-phone={isMobile.value || undefined}>
|
|
114
144
|
{/* Scroll ownership (see the docblock): both scrollers are
|
|
115
145
|
HkScrollContainers — the overlay chrome replaces the native
|
|
116
146
|
bars, and the paired-class rules in the SCSS carry each
|
|
117
147
|
region's padding/gap vocabulary into its viewport. */}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
{isMobile.value ? (
|
|
149
|
+
<div class="hk-settings__topnav">
|
|
150
|
+
{/* The shared pill strip IS the button-group primitive: it
|
|
151
|
+
scrolls horizontally with edge fades, keeps the active
|
|
152
|
+
tab scrolled into view, and carries the tablist
|
|
153
|
+
semantics the rail had as a nav landmark. Panels stay
|
|
154
|
+
with the shell's own pane (renderPanels off). Icons ride
|
|
155
|
+
one notch up with the strip (18px vs the rail's 16). */}
|
|
156
|
+
<HTabs
|
|
157
|
+
modelValue={active()}
|
|
158
|
+
tabs={props.sections.map((s) => {
|
|
159
|
+
const Icon = s.icon;
|
|
160
|
+
return {
|
|
161
|
+
key: s.key,
|
|
162
|
+
label: s.label,
|
|
163
|
+
// Same functional-component guard the rail applies —
|
|
164
|
+
// Component covers object-form components too, which
|
|
165
|
+
// JSX cannot invoke directly.
|
|
166
|
+
icon: typeof Icon === "function" ? <Icon size={18} /> : undefined,
|
|
167
|
+
disabled: s.disabled,
|
|
168
|
+
};
|
|
169
|
+
})}
|
|
170
|
+
renderPanels={false}
|
|
171
|
+
ariaLabel={props.navLabel}
|
|
172
|
+
onUpdate:modelValue={(key: string) => pick(key)}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
) : (
|
|
176
|
+
<HkScrollContainer as="nav" class="hk-settings__rail" aria-label={props.navLabel}>
|
|
177
|
+
{props.sections.map((s) => {
|
|
178
|
+
const Icon = s.icon;
|
|
179
|
+
const on = active() === s.key && !s.disabled;
|
|
180
|
+
return (
|
|
181
|
+
<button
|
|
182
|
+
key={s.key}
|
|
183
|
+
type="button"
|
|
184
|
+
class={["hk-settings__rail-item", on ? "is-active" : ""]}
|
|
185
|
+
aria-current={on ? "true" : undefined}
|
|
186
|
+
aria-disabled={s.disabled || undefined}
|
|
187
|
+
disabled={s.disabled}
|
|
188
|
+
onClick={() => pick(s.key)}
|
|
189
|
+
>
|
|
190
|
+
{typeof Icon === "function" ? (
|
|
191
|
+
<span class="hk-settings__rail-icon" aria-hidden="true">
|
|
192
|
+
<Icon size={16} />
|
|
193
|
+
</span>
|
|
194
|
+
) : null}
|
|
195
|
+
<span class="hk-settings__rail-label">{s.label}</span>
|
|
196
|
+
</button>
|
|
197
|
+
);
|
|
198
|
+
})}
|
|
199
|
+
</HkScrollContainer>
|
|
200
|
+
)}
|
|
142
201
|
<HkScrollContainer class="hk-settings__pane" ref={paneRef}>
|
|
143
202
|
{/* The section swap runs through the family's standard morph
|
|
144
203
|
container (audit 2026-10-23): the pane's frame follows the
|
|
@@ -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
|
|
@@ -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
|