@celestia-island/hikari 0.55.200 → 0.55.202
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 +22 -0
- package/src/components/HkAdminHeader.tsx +48 -29
- package/src/components/HkAdminShell.test.tsx +70 -0
- package/src/components/HkAdminShell.tsx +25 -2
- package/src/components/HkSettingsDialog.tsx +9 -1
- package/src/components/HkTransitionContainer.tsx +26 -2
- package/src/components/paneSwap.contract.test.ts +148 -0
- package/src/styles/admin-tokens.scss +6 -1
package/package.json
CHANGED
|
@@ -129,6 +129,28 @@ describe("HkAdminHeader", () => {
|
|
|
129
129
|
expect(withoutTitle.querySelector("header > div")?.textContent).not.toContain("Dashboard");
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
it("renders no avatar trigger when no identity is injected", () => {
|
|
133
|
+
// 2026-10-24 regression: the trigger rendered unconditionally, so a
|
|
134
|
+
// host with no identity props got a stray "?" button opening an
|
|
135
|
+
// empty "Signing in…" menu with a lone sign-out row. The identity
|
|
136
|
+
// cluster is injection-driven now — no identity, no trigger.
|
|
137
|
+
const anonymous = mount(headerNode({ username: "", title: "Anywhere" }));
|
|
138
|
+
expect(avatarButton(anonymous)).toBeNull();
|
|
139
|
+
// The page title still rides the left wing without the avatar.
|
|
140
|
+
expect(anonymous.querySelector("header > div")?.textContent).toContain("Anywhere");
|
|
141
|
+
|
|
142
|
+
// The pending identity flow is EXPLICIT injection: signingInLabel /
|
|
143
|
+
// onForceSignOut keep the "?" trigger alive so the force-sign-out
|
|
144
|
+
// escape stays reachable during a wedged session restore.
|
|
145
|
+
const pending = mount(headerNode({
|
|
146
|
+
username: "",
|
|
147
|
+
signingInLabel: "Signing in…",
|
|
148
|
+
onForceSignOut: () => {},
|
|
149
|
+
}));
|
|
150
|
+
const btn = avatarButton(pending) as HTMLButtonElement;
|
|
151
|
+
expect(btn.querySelector(".s-user-avatar")?.textContent).toBe("?");
|
|
152
|
+
});
|
|
153
|
+
|
|
132
154
|
it("toggles the user dropdown with the profile identity header in menu mode", async () => {
|
|
133
155
|
const c = mount(headerNode({
|
|
134
156
|
userEmail: "alice@example.com",
|
|
@@ -87,6 +87,23 @@ export const HkAdminHeader = defineComponent({
|
|
|
87
87
|
const avatarModalOpen = ref(false);
|
|
88
88
|
const avatarFailed = ref(false);
|
|
89
89
|
|
|
90
|
+
// The identity cluster is INJECTED, not assumed (2026-10-24): the
|
|
91
|
+
// trigger renders only when the host actually supplied an identity
|
|
92
|
+
// (username / email / avatar) or explicitly opted into the pending
|
|
93
|
+
// identity flow (signingInLabel / onForceSignOut — the fetchUser
|
|
94
|
+
// race machinery whose "?" trigger opens the force-sign-out escape).
|
|
95
|
+
// A host that passes none of those has no account surface, and the
|
|
96
|
+
// old unconditional render left it a stray "?" button opening an
|
|
97
|
+
// empty "Signing in…" menu.
|
|
98
|
+
const identityMounted = computed(
|
|
99
|
+
() =>
|
|
100
|
+
!!props.username ||
|
|
101
|
+
!!props.userEmail ||
|
|
102
|
+
!!props.avatarUrl ||
|
|
103
|
+
props.signingInLabel !== undefined ||
|
|
104
|
+
props.onForceSignOut !== undefined,
|
|
105
|
+
);
|
|
106
|
+
|
|
90
107
|
function onAvatarClick(e: MouseEvent) {
|
|
91
108
|
e.stopPropagation();
|
|
92
109
|
if (props.avatarAction === "drawer") {
|
|
@@ -192,35 +209,37 @@ export const HkAdminHeader = defineComponent({
|
|
|
192
209
|
<Menu size={20} class="s-admin-header-hamburger-icon" />
|
|
193
210
|
</HButton>
|
|
194
211
|
)}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
212
|
+
{identityMounted.value && (
|
|
213
|
+
<button
|
|
214
|
+
class="s-admin-header-avatar"
|
|
215
|
+
aria-label={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
|
|
216
|
+
aria-haspopup={props.avatarAction === "drawer" ? "dialog" : "menu"}
|
|
217
|
+
aria-expanded={props.avatarAction === "menu" ? userMenuOpen.value : undefined}
|
|
218
|
+
onClick={onAvatarClick}
|
|
219
|
+
>
|
|
220
|
+
{/* Deliberately NO hover veil here: the trigger opens the user
|
|
221
|
+
* MENU (the avatar edit lives one row down as the Camera
|
|
222
|
+
* item), so a camera overlay would promise click-to-change
|
|
223
|
+
* and lie (user report 2026-09-16). */}
|
|
224
|
+
{props.avatarUrl && !avatarFailed.value ? (
|
|
225
|
+
<img
|
|
226
|
+
src={props.avatarUrl}
|
|
227
|
+
alt={props.username}
|
|
228
|
+
class="s-admin-header-avatar-img"
|
|
229
|
+
onError={() => { avatarFailed.value = true; }}
|
|
230
|
+
/>
|
|
231
|
+
) : (
|
|
232
|
+
// Letter fallback on the shared avatar primitive — the
|
|
233
|
+
// same filled-circle grammar as the chat frontend's
|
|
234
|
+
// trigger. The old border-2 ring made the console's
|
|
235
|
+
// avatar read as a different control (user report
|
|
236
|
+
// 2026-09-12: 前后台头像不一致).
|
|
237
|
+
<span class="s-user-avatar">
|
|
238
|
+
{props.username?.charAt(0).toUpperCase() || "?"}
|
|
239
|
+
</span>
|
|
240
|
+
)}
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
224
243
|
{/* WHERE am I — the open view's title, not the nickname. The
|
|
225
244
|
1.5 line-height (in the class) keeps descenders (g, y, p)
|
|
226
245
|
inside the truncate clip box: the default text-sm box is
|
|
@@ -538,4 +538,74 @@ describe("HkAdminShell", () => {
|
|
|
538
538
|
expect(tripleInner?.style.paddingLeft).toBe("2rem");
|
|
539
539
|
expect(tripleInner?.style.paddingRight).toBe("2rem");
|
|
540
540
|
});
|
|
541
|
+
|
|
542
|
+
it("reserves the footer band only when a status bar is actually injected", () => {
|
|
543
|
+
// 2026-10-24 regression: the body carried
|
|
544
|
+
// paddingBottom: var(--s-footer-height) unconditionally — a dead 3rem
|
|
545
|
+
// blank under every console that never mounts a status bar. The
|
|
546
|
+
// reservation is injection-driven now: the shell's own footer slot
|
|
547
|
+
// brings it, an explicit prop overrides it, nothing else reserves.
|
|
548
|
+
setWidth(1024);
|
|
549
|
+
|
|
550
|
+
// No footer slot, no prop: NO band.
|
|
551
|
+
const plain = mount(shellNode(
|
|
552
|
+
{ navTitle: "Navigation" },
|
|
553
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
554
|
+
));
|
|
555
|
+
const plainBody = plain.querySelector(".s-admin-shell-body") as HTMLElement;
|
|
556
|
+
expect(plainBody.style.paddingBottom).toBe("0px");
|
|
557
|
+
expect(plain.querySelector("footer.s-status-bar")).toBeNull();
|
|
558
|
+
|
|
559
|
+
// A filled footer slot renders the fixed .s-status-bar and reserves
|
|
560
|
+
// its band by default.
|
|
561
|
+
const withBar = mount(shellNode(
|
|
562
|
+
{ navTitle: "Navigation" },
|
|
563
|
+
{
|
|
564
|
+
header: () => null,
|
|
565
|
+
sidebar: NAV,
|
|
566
|
+
content: CONTENT,
|
|
567
|
+
footer: () => h("div", { class: "bar-mock" }, "STATUS"),
|
|
568
|
+
},
|
|
569
|
+
));
|
|
570
|
+
const barBody = withBar.querySelector(".s-admin-shell-body") as HTMLElement;
|
|
571
|
+
// A fallback-less var() survives as the direct longhand here — the
|
|
572
|
+
// shorthand round-trip failures happy-dom shows elsewhere in this
|
|
573
|
+
// file do not apply to a single-track longhand read.
|
|
574
|
+
expect(barBody.style.paddingBottom).toBe("var(--s-footer-height)");
|
|
575
|
+
expect(withBar.querySelector("footer.s-status-bar .bar-mock")).toBeTruthy();
|
|
576
|
+
|
|
577
|
+
// An explicit prop overrides BOTH branches: the opt-in for hosts that
|
|
578
|
+
// pin their own fixed bar outside the shell…
|
|
579
|
+
const external = mount(shellNode(
|
|
580
|
+
{ navTitle: "Navigation", footerHeight: "3.5rem" },
|
|
581
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
582
|
+
));
|
|
583
|
+
expect((external.querySelector(".s-admin-shell-body") as HTMLElement).style.paddingBottom)
|
|
584
|
+
.toBe("3.5rem");
|
|
585
|
+
// …and a custom band for hosts that fill the slot anyway. "0px"
|
|
586
|
+
// explicitly re-kills it.
|
|
587
|
+
const explicitZero = mount(shellNode(
|
|
588
|
+
{ navTitle: "Navigation", footerHeight: "0px" },
|
|
589
|
+
{
|
|
590
|
+
header: () => null,
|
|
591
|
+
sidebar: NAV,
|
|
592
|
+
content: CONTENT,
|
|
593
|
+
footer: () => h("div", { class: "bar-mock" }, "STATUS"),
|
|
594
|
+
},
|
|
595
|
+
));
|
|
596
|
+
expect((explicitZero.querySelector(".s-admin-shell-body") as HTMLElement).style.paddingBottom)
|
|
597
|
+
.toBe("0px");
|
|
598
|
+
|
|
599
|
+
// The empty string is an EXPLICIT value, not an absent one: `!==`
|
|
600
|
+
// undefined (not truthiness) is the gate, so "" passes through
|
|
601
|
+
// verbatim exactly as the old code passed it — same contract the
|
|
602
|
+
// contentPadding longhand pins below. A truthiness mutant would
|
|
603
|
+
// reroute "" into the slot branch; this mount catches it.
|
|
604
|
+
const explicitEmpty = mount(shellNode(
|
|
605
|
+
{ navTitle: "Navigation", footerHeight: "" },
|
|
606
|
+
{ header: () => null, sidebar: NAV, content: CONTENT },
|
|
607
|
+
));
|
|
608
|
+
expect((explicitEmpty.querySelector(".s-admin-shell-body") as HTMLElement).style.paddingBottom)
|
|
609
|
+
.toBe("");
|
|
610
|
+
});
|
|
541
611
|
});
|
|
@@ -140,7 +140,17 @@ export const HkAdminShell = defineComponent({
|
|
|
140
140
|
* 1024px "lg" breakpoint; lower it (e.g. 768) for tablet-friendly
|
|
141
141
|
* admins that keep the sidebar at md widths. */
|
|
142
142
|
mobileBreakpoint: { type: Number, default: 1024 },
|
|
143
|
-
|
|
143
|
+
/** Height reserved under the scroll body for a fixed status-bar
|
|
144
|
+
* band. INJECTION-DRIVEN (2026-10-24): the reservation tracks
|
|
145
|
+
* whether the shell actually renders a status bar — a filled
|
|
146
|
+
* `footer` slot (the shell's own fixed `.s-status-bar`) reserves
|
|
147
|
+
* `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)"`). */
|
|
153
|
+
footerHeight: { type: String, default: undefined },
|
|
144
154
|
navTitle: { type: String, default: undefined },
|
|
145
155
|
/** Extra class for the mobile nav drawer's panel — lets consumers zero
|
|
146
156
|
* nested paddings so drawer nav rows and a `userPanel` footer share
|
|
@@ -186,6 +196,19 @@ export const HkAdminShell = defineComponent({
|
|
|
186
196
|
|
|
187
197
|
const actionBar = provideActionBar();
|
|
188
198
|
|
|
199
|
+
// The status-bar band exists only when the shell renders one (the
|
|
200
|
+
// footer slot) or the host pins its own bar and says so via the prop
|
|
201
|
+
// — an unreserved shell carries no dead clearance (2026-10-24: the
|
|
202
|
+
// unconditional default used to blank 3rem under every console that
|
|
203
|
+
// never mounted a status bar). Computed INSIDE the render (not a
|
|
204
|
+
// `computed()`): slot PRESENCE is what it reads, and a cached
|
|
205
|
+
// computed is not reliably invalidated by a slot appearing or
|
|
206
|
+
// disappearing, while every re-render re-evaluates this line.
|
|
207
|
+
function footerPad(): string {
|
|
208
|
+
if (props.footerHeight !== undefined) return props.footerHeight;
|
|
209
|
+
return slots.footer ? "var(--s-footer-height)" : "0px";
|
|
210
|
+
}
|
|
211
|
+
|
|
189
212
|
const toggleHamburger = () => {
|
|
190
213
|
sidebarOpen.value = !sidebarOpen.value;
|
|
191
214
|
};
|
|
@@ -215,7 +238,7 @@ export const HkAdminShell = defineComponent({
|
|
|
215
238
|
</div>
|
|
216
239
|
)}
|
|
217
240
|
|
|
218
|
-
<div class="s-admin-shell-body" style={{ paddingBottom:
|
|
241
|
+
<div class="s-admin-shell-body" style={{ paddingBottom: footerPad() }}>
|
|
219
242
|
{isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
|
|
220
243
|
<aside
|
|
221
244
|
style={{
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type PropType,
|
|
9
9
|
} from "vue";
|
|
10
10
|
|
|
11
|
+
import HkTransitionContainer from "./HkTransitionContainer";
|
|
11
12
|
import HModal from "./HkModal";
|
|
12
13
|
import "./HkSettingsDialog.scss";
|
|
13
14
|
|
|
@@ -124,7 +125,14 @@ export const HkSettingsBody = defineComponent({
|
|
|
124
125
|
})}
|
|
125
126
|
</nav>
|
|
126
127
|
<div class="hk-settings__pane" ref={paneRef}>
|
|
127
|
-
{
|
|
128
|
+
{/* The section swap runs through the family's standard morph
|
|
129
|
+
container (audit 2026-10-23): the pane's frame follows the
|
|
130
|
+
incoming section's height and the contents dissolve, the
|
|
131
|
+
same mechanism HkTabs panels use. A settings pane is a page
|
|
132
|
+
slot — it must not hard-cut. */}
|
|
133
|
+
<HkTransitionContainer swapKey={active()} axis="height">
|
|
134
|
+
{slots[active()]?.()}
|
|
135
|
+
</HkTransitionContainer>
|
|
128
136
|
</div>
|
|
129
137
|
</div>
|
|
130
138
|
);
|
|
@@ -102,8 +102,12 @@ export default defineComponent({
|
|
|
102
102
|
pendingKey = null;
|
|
103
103
|
if (key !== null) {
|
|
104
104
|
emit("swap", { key, previous: previousKey, durationMs: props.duration });
|
|
105
|
+
// The handoff consumer reads `previous` to scroll the pane back;
|
|
106
|
+
// it must be the key we just left, not the one we mounted with
|
|
107
|
+
// (audit 2026-10-23: it was never reassigned).
|
|
108
|
+
previousKey = key;
|
|
105
109
|
}
|
|
106
|
-
},
|
|
110
|
+
}, motionDuration() + 40);
|
|
107
111
|
});
|
|
108
112
|
observer.observe(stageRef.value);
|
|
109
113
|
}
|
|
@@ -119,6 +123,26 @@ export default defineComponent({
|
|
|
119
123
|
void nextTick(() => observe());
|
|
120
124
|
});
|
|
121
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Reduced motion: the morph runs on an INLINE `transitionDuration`,
|
|
128
|
+
* which no stylesheet `@media (prefers-reduced-motion)` can override
|
|
129
|
+
* (inline beats non-important rules) and which the host's
|
|
130
|
+
* `html[data-css-animations="0"]` pin only covers if the host wired
|
|
131
|
+
* useReducedMotion() at boot. So the gate lives HERE (audit
|
|
132
|
+
* 2026-10-23): zero the morph when the OS asks for reduced motion or
|
|
133
|
+
* the host turned the animation switch off. The dissolve side is
|
|
134
|
+
* already handled by HkCrossfade's own media query.
|
|
135
|
+
*/
|
|
136
|
+
function motionDuration(): number {
|
|
137
|
+
if (typeof window === "undefined") return props.duration;
|
|
138
|
+
const reduced =
|
|
139
|
+
typeof window.matchMedia === "function" &&
|
|
140
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
141
|
+
const switchedOff =
|
|
142
|
+
document.documentElement.dataset.cssAnimations === "0";
|
|
143
|
+
return reduced || switchedOff ? 0 : props.duration;
|
|
144
|
+
}
|
|
145
|
+
|
|
122
146
|
onBeforeUnmount(() => {
|
|
123
147
|
observer?.disconnect();
|
|
124
148
|
observer = null;
|
|
@@ -127,7 +151,7 @@ export default defineComponent({
|
|
|
127
151
|
|
|
128
152
|
return () => {
|
|
129
153
|
const Tag = props.tag as "div" | "section" | "main" | "aside";
|
|
130
|
-
const d = `${
|
|
154
|
+
const d = `${motionDuration()}ms`;
|
|
131
155
|
return (
|
|
132
156
|
<Tag
|
|
133
157
|
ref={hostRef}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const componentsDir = here;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pane-swap contract — the enforcement half of the family's transition
|
|
12
|
+
* primitives (user audit 2026-10-23).
|
|
13
|
+
*
|
|
14
|
+
* THE RULE. A "pane slot" is a container whose visible content is selected
|
|
15
|
+
* by a state key while the container stays put: tabs, settings sections,
|
|
16
|
+
* step bodies, view areas. Every such slot must swap through the standard
|
|
17
|
+
* mechanism — `HkTransitionContainer` (frame morph + dissolve) or
|
|
18
|
+
* `HkCrossfade` (same-box dissolve) — or a component that composes them
|
|
19
|
+
* (`HkTabs` panels). The library shipped both primitives and then used
|
|
20
|
+
* them in exactly ONE place, so the rule was advisory: each surface
|
|
21
|
+
* re-decided, and two page-scale slots hard-cut while a third
|
|
22
|
+
* re-implemented the primitives by hand.
|
|
23
|
+
*
|
|
24
|
+
* HOW IT IS ENFORCED. Two source scans, because the violation is only
|
|
25
|
+
* visible in source (the primitives are leaf components; the call site
|
|
26
|
+
* cannot tell you a div is a page slot):
|
|
27
|
+
*
|
|
28
|
+
* 1. every `slots[<state>]?.()` pane render must be either routed through
|
|
29
|
+
* HkTransitionContainer/HkCrossfade, or listed below with a reason —
|
|
30
|
+
* an explicit-exception list, not a ban;
|
|
31
|
+
* 2. every pane-vocabulary `@keyframes` (names carrying pane/swap/
|
|
32
|
+
* crossfade/morph/sheet vocabulary) must be allow-listed with a
|
|
33
|
+
* reason, so new bespoke swap animations cannot slip in.
|
|
34
|
+
*
|
|
35
|
+
* ALLOW-LIST DISCIPLINE. Each entry states WHY the standard cannot carry
|
|
36
|
+
* it. Nothing is grandfathered silently; a new entry needs a reason that
|
|
37
|
+
* would survive review.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Components whose slot render is a pane swap and MUST stay on the
|
|
41
|
+
* standard mechanism — pinned positively so a refactor cannot quietly
|
|
42
|
+
* drop back to a hard cut. */
|
|
43
|
+
const STANDARD_PANE_HOSTS: Array<{ file: string; marker: string; why: string }> = [
|
|
44
|
+
{
|
|
45
|
+
file: "HkTabs.tsx",
|
|
46
|
+
marker: "<HkTransitionContainer",
|
|
47
|
+
why: "tab panels are the canonical pane slot",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
file: "HkSettingsDialog.tsx",
|
|
51
|
+
marker: "<HkTransitionContainer",
|
|
52
|
+
why: "settings sections are a page-scale pane slot (migrated 2026-10-23)",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/** Justified bespoke mechanisms: "<scss file>: <animation identifier>" → reason. */
|
|
57
|
+
const SANCTIONED_SWAP_KEYFRAMES: Record<string, string> = {
|
|
58
|
+
"HkPickerPane.scss: hk-picker-pane-fwd-in": "fixed-stage picker with a direction axis; the stage size is known, so the measured morph container does not apply",
|
|
59
|
+
"HkPickerPane.scss: hk-picker-pane-fwd-out": "fixed-stage picker with a direction axis (see above)",
|
|
60
|
+
"HkPickerPane.scss: hk-picker-pane-back-in": "fixed-stage picker with a direction axis (see above)",
|
|
61
|
+
"HkPickerPane.scss: hk-picker-pane-back-out": "fixed-stage picker with a direction axis (see above)",
|
|
62
|
+
"HkModalBreadcrumb.scss: hk-modal-breadcrumb-in": "breadcrumb level change — a text strip, not a pane",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Swap-vocabulary keyframe names: anything that reads as a pane-swap
|
|
66
|
+
* animation must be argued, decorative pulses and spinners do not. */
|
|
67
|
+
const SWAP_VOCABULARY = /(pane|swap|crossfade|morph|sheet)/;
|
|
68
|
+
|
|
69
|
+
function readComponent(file: string): string {
|
|
70
|
+
return readFileSync(join(componentsDir, file), "utf-8");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scssFiles(): string[] {
|
|
74
|
+
return readdirSync(componentsDir).filter((f) => f.endsWith(".scss"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("pane-swap contract", () => {
|
|
78
|
+
it("keeps the known pane slots on the standard mechanism", () => {
|
|
79
|
+
for (const host of STANDARD_PANE_HOSTS) {
|
|
80
|
+
const src = readComponent(host.file);
|
|
81
|
+
expect(
|
|
82
|
+
src,
|
|
83
|
+
`${host.file} must swap through the standard pane mechanism (${host.why})`,
|
|
84
|
+
).toContain(host.marker);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("every keyed slot render is either standard or explicitly excepted", () => {
|
|
89
|
+
// `slots[active()]?.()`-style renders: the pattern a pane slot uses.
|
|
90
|
+
const slotRenders: Array<{ file: string; line: string }> = [];
|
|
91
|
+
for (const file of readdirSync(componentsDir).filter((f) => f.endsWith(".tsx"))) {
|
|
92
|
+
const text = readComponent(file);
|
|
93
|
+
for (const line of text.split("\n")) {
|
|
94
|
+
if (/slots\[[^\]]+\]\?\.\(\)/.test(line)) slotRenders.push({ file, line: line.trim() });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const EXCEPTIONS = new Set([
|
|
98
|
+
// These are hosts that MUST render their slot keyed by state — they
|
|
99
|
+
// are exactly the pane slots the rule is about, and each is either
|
|
100
|
+
// wrapped by the standard above or justified here.
|
|
101
|
+
"HkTabs.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
102
|
+
"HkSettingsDialog.tsx", // wrapped by HkTransitionContainer (pinned above)
|
|
103
|
+
"HkStepFlow.tsx", // justified: frozen-sheet two-window protocol + direction axis
|
|
104
|
+
"HkAdminShell.tsx", // content slot is host-owned; hosts key it (chest wraps HkCrossfade)
|
|
105
|
+
"HkThemePanelContent.tsx", // forwards to HTabs (HkTabs), inherits the mechanism
|
|
106
|
+
"HkMenuPanel.tsx", // menu items, not a pane swap
|
|
107
|
+
// Named slots that are not pane swaps at all: a header action strip
|
|
108
|
+
// (always-on actions, not a keyed slot) and a table page's modal /
|
|
109
|
+
// empty-action hooks (modals own their own enter/leave motion).
|
|
110
|
+
"HkAdminHeader.tsx",
|
|
111
|
+
"HkAdminTablePage.tsx",
|
|
112
|
+
]);
|
|
113
|
+
const unexplained = slotRenders.filter((r) => !EXCEPTIONS.has(r.file));
|
|
114
|
+
expect(
|
|
115
|
+
unexplained.map((r) => `${r.file}: ${r.line}`),
|
|
116
|
+
"A keyed slot render appeared outside the standard mechanism. Route it through HkTransitionContainer/HkCrossfade, or add a reasoned exception.",
|
|
117
|
+
).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("has no unsanctioned swap-vocabulary keyframes", () => {
|
|
121
|
+
const offenders: string[] = [];
|
|
122
|
+
for (const file of scssFiles()) {
|
|
123
|
+
const text = readFileSync(join(componentsDir, file), "utf-8");
|
|
124
|
+
for (const match of text.matchAll(/@keyframes\s+([\w-]+)/g)) {
|
|
125
|
+
const name = match[1];
|
|
126
|
+
if (!SWAP_VOCABULARY.test(name)) continue;
|
|
127
|
+
const key = `${file}: ${name}`;
|
|
128
|
+
if (!SANCTIONED_SWAP_KEYFRAMES[key]) offenders.push(`@keyframes ${key}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
expect(
|
|
132
|
+
offenders,
|
|
133
|
+
"A bespoke pane-swap animation appeared. Use the standard primitives, or add a reasoned allow-list entry.",
|
|
134
|
+
).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("allow-list entries reference real keyframes (no stale exceptions)", () => {
|
|
138
|
+
const present = new Set<string>();
|
|
139
|
+
for (const file of scssFiles()) {
|
|
140
|
+
const text = readFileSync(join(componentsDir, file), "utf-8");
|
|
141
|
+
for (const match of text.matchAll(/@keyframes\s+([\w-]+)/g)) {
|
|
142
|
+
present.add(`${file}: ${match[1]}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const stale = Object.keys(SANCTIONED_SWAP_KEYFRAMES).filter((k) => !present.has(k));
|
|
146
|
+
expect(stale, "Remove allow-list entries whose keyframe is gone.").toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -1228,8 +1228,13 @@
|
|
|
1228
1228
|
font-variant-numeric: tabular-nums;
|
|
1229
1229
|
}
|
|
1230
1230
|
|
|
1231
|
+
// Full-viewport by default: the page does NOT assume a fixed status bar
|
|
1232
|
+
// is mounted under it (2026-10-24 — the old calc() subtracted
|
|
1233
|
+
// --s-footer-height unconditionally, stopping the background 3rem short
|
|
1234
|
+
// of the bottom on pages with no status bar). Hosts that DO pair the
|
|
1235
|
+
// auth page with a status bar subtract it themselves.
|
|
1231
1236
|
.a-auth-page {
|
|
1232
|
-
min-height:
|
|
1237
|
+
min-height: 100vh;
|
|
1233
1238
|
}
|
|
1234
1239
|
|
|
1235
1240
|
// ── adjacent-button spacing (the button-row grammar) ──────────────────────
|