@celestia-island/hikari 0.55.201 → 0.55.203
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/animation/registerAnimations.ts +3 -0
- 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 +43 -5
- package/src/components/HkAdminTablePage.tsx +47 -30
- package/src/components/HkPaneHolder.scss +33 -0
- package/src/components/HkPaneHolder.test.tsx +99 -0
- package/src/components/HkPaneHolder.tsx +57 -0
- package/src/components/paneSwap.contract.test.ts +14 -0
- package/src/index.ts +1 -0
- package/src/styles/admin-tokens.scss +6 -1
package/package.json
CHANGED
|
@@ -66,6 +66,9 @@ export function isCssAnimationsEnabled(): boolean {
|
|
|
66
66
|
registerCssAnimation("s-nav-item-badge-pulse", { infinite: true });
|
|
67
67
|
// styles/admin-tokens.scss — auth card entrance (plays once).
|
|
68
68
|
registerCssAnimation("s-auth-card-in");
|
|
69
|
+
// HkPaneHolder's reveal (opacity-only, one shot) — the keep-mounted pane
|
|
70
|
+
// slot's entry, the dissolve-family language for persistent panes.
|
|
71
|
+
registerCssAnimation("hk-pane-holder-in");
|
|
69
72
|
// styles/admin-tokens.scss — status-bar recovery flash (plays once):
|
|
70
73
|
// the traffic light's green background blink when a dropped connection
|
|
71
74
|
// comes back.
|
|
@@ -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
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { computed, defineComponent, ref } from "vue";
|
|
2
|
-
import { HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
|
|
1
|
+
import { computed, defineComponent, ref, type PropType } from "vue";
|
|
2
|
+
import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
|
|
3
3
|
import { provideActionBar } from "../composables/useActionBar";
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
5
|
|
|
@@ -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
|
|
@@ -152,6 +162,13 @@ export const HkAdminShell = defineComponent({
|
|
|
152
162
|
* verbatim at every width unless the page opts into
|
|
153
163
|
* `contentBleedOnMobile`. */
|
|
154
164
|
contentPadding: { type: String, default: "1.5rem" },
|
|
165
|
+
/** Identity of the content page on stage (task-4, 2026-10-24):
|
|
166
|
+
* pass the current view/route key and the content area swaps pages
|
|
167
|
+
* through the family crossfade — the library owns the page-slot
|
|
168
|
+
* mechanism instead of every host hand-wrapping it. Omitted (or
|
|
169
|
+
* null) renders the slot bare: canvas-like hosts that mount one
|
|
170
|
+
* persistent stage opt out. */
|
|
171
|
+
contentKey: { type: [String, Number] as PropType<string | number | null>, default: null },
|
|
155
172
|
/** Whether the content on screen is a CANVAS — a 2D board or the 3D
|
|
156
173
|
* scene — that must span the full phone width. OPT-IN, declared by
|
|
157
174
|
* the page that renders the canvas (2026-10-04 user direction: the
|
|
@@ -186,6 +203,19 @@ export const HkAdminShell = defineComponent({
|
|
|
186
203
|
|
|
187
204
|
const actionBar = provideActionBar();
|
|
188
205
|
|
|
206
|
+
// The status-bar band exists only when the shell renders one (the
|
|
207
|
+
// footer slot) or the host pins its own bar and says so via the prop
|
|
208
|
+
// — an unreserved shell carries no dead clearance (2026-10-24: the
|
|
209
|
+
// unconditional default used to blank 3rem under every console that
|
|
210
|
+
// never mounted a status bar). Computed INSIDE the render (not a
|
|
211
|
+
// `computed()`): slot PRESENCE is what it reads, and a cached
|
|
212
|
+
// computed is not reliably invalidated by a slot appearing or
|
|
213
|
+
// disappearing, while every re-render re-evaluates this line.
|
|
214
|
+
function footerPad(): string {
|
|
215
|
+
if (props.footerHeight !== undefined) return props.footerHeight;
|
|
216
|
+
return slots.footer ? "var(--s-footer-height)" : "0px";
|
|
217
|
+
}
|
|
218
|
+
|
|
189
219
|
const toggleHamburger = () => {
|
|
190
220
|
sidebarOpen.value = !sidebarOpen.value;
|
|
191
221
|
};
|
|
@@ -215,7 +245,7 @@ export const HkAdminShell = defineComponent({
|
|
|
215
245
|
</div>
|
|
216
246
|
)}
|
|
217
247
|
|
|
218
|
-
<div class="s-admin-shell-body" style={{ paddingBottom:
|
|
248
|
+
<div class="s-admin-shell-body" style={{ paddingBottom: footerPad() }}>
|
|
219
249
|
{isDesktop.value && !props.sidebarCollapsed && slots.sidebar && (
|
|
220
250
|
<aside
|
|
221
251
|
style={{
|
|
@@ -234,7 +264,15 @@ export const HkAdminShell = defineComponent({
|
|
|
234
264
|
{/* Padding lives INSIDE the scroll viewport (an inner
|
|
235
265
|
wrapper) so card box-shadows are not clipped at the
|
|
236
266
|
viewport edges. */}
|
|
237
|
-
<div style={contentStyle.value}>
|
|
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>
|
|
238
276
|
</HScrollContainer>
|
|
239
277
|
</main>
|
|
240
278
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineComponent, type Component, type PropType } from "vue";
|
|
2
2
|
import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
|
|
3
|
+
import HkCrossfade from "./HkCrossfade";
|
|
3
4
|
import { HkPageHeader } from "./HkPageHeader";
|
|
4
5
|
import type { HkRowManageGate } from "./HkTable";
|
|
5
6
|
|
|
@@ -98,37 +99,53 @@ export const HkAdminTablePage = defineComponent({
|
|
|
98
99
|
* render alongside the error branch (its precondition — rows on
|
|
99
100
|
* screen — is exactly what the error branch removes). */}
|
|
100
101
|
{props.staleError && !props.error ? <HAlert message={props.staleError} /> : null}
|
|
101
|
-
{
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
emit("manage", row, opener)
|
|
126
|
-
}
|
|
102
|
+
{/* The body is a page slot with three generations — error,
|
|
103
|
+
loading/empty, content (task-4, 2026-10-24): the family
|
|
104
|
+
crossfade owns the exchange, so every admin table page in
|
|
105
|
+
every host dissolves between them instead of popping. The
|
|
106
|
+
key collapses loading-vs-empty into one "placeholder"
|
|
107
|
+
generation: spinner → empty is a data arrival, not a slot
|
|
108
|
+
identity change worth animating. */}
|
|
109
|
+
<HkCrossfade
|
|
110
|
+
swapKey={
|
|
111
|
+
props.error ? "error"
|
|
112
|
+
: props.loading && !props.rows.length ? "placeholder"
|
|
113
|
+
: !props.rows.length ? "placeholder"
|
|
114
|
+
: "content"
|
|
115
|
+
}
|
|
116
|
+
>
|
|
117
|
+
{props.error ? (
|
|
118
|
+
<HAlert message={props.error} />
|
|
119
|
+
) : props.loading && !props.rows.length ? (
|
|
120
|
+
<HSpinner center />
|
|
121
|
+
) : !props.rows.length ? (
|
|
122
|
+
<HEmptyState
|
|
123
|
+
boxed
|
|
124
|
+
title={emptyTitle}
|
|
125
|
+
description={props.emptyDescription}
|
|
127
126
|
>
|
|
128
|
-
{
|
|
129
|
-
</
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
{emptySlots}
|
|
128
|
+
</HEmptyState>
|
|
129
|
+
) : slots.body ? (
|
|
130
|
+
<div class="hk-admin-table-page-body">{slots.body()}</div>
|
|
131
|
+
) : (
|
|
132
|
+
<HCard padded={false}>
|
|
133
|
+
<HTable
|
|
134
|
+
columns={props.columns}
|
|
135
|
+
rows={props.rows}
|
|
136
|
+
rowKey={props.rowKey}
|
|
137
|
+
draggable={props.draggable}
|
|
138
|
+
manageable={props.manageable}
|
|
139
|
+
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
140
|
+
onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
|
|
141
|
+
emit("manage", row, opener)
|
|
142
|
+
}
|
|
143
|
+
>
|
|
144
|
+
{slots}
|
|
145
|
+
</HTable>
|
|
146
|
+
</HCard>
|
|
147
|
+
)}
|
|
148
|
+
</HkCrossfade>
|
|
132
149
|
{slots["create-modal"]?.()}
|
|
133
150
|
{slots["edit-modal"]?.()}
|
|
134
151
|
</div>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// HkPaneHolder — the keep-mounted pane slot (see the TSX doc for when to
|
|
2
|
+
// choose it over the dissolve primitives).
|
|
3
|
+
|
|
4
|
+
.hk-pane-holder {
|
|
5
|
+
display: none;
|
|
6
|
+
|
|
7
|
+
// Active: back in the layout with the family dissolve's own language —
|
|
8
|
+
// a one-shot opacity reveal. No transform: a pane sliding in would
|
|
9
|
+
// disagree with every crossfade around it.
|
|
10
|
+
&[data-active] {
|
|
11
|
+
display: block;
|
|
12
|
+
animation: hk-pane-holder-in var(--duration-normal, 0.3s) ease-out;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@keyframes hk-pane-holder-in {
|
|
17
|
+
from { opacity: 0; }
|
|
18
|
+
to { opacity: 1; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Opt-outs: the global animation switch is the host's, reduced motion is
|
|
22
|
+
// the OS's — the holder obeys both (an inline-less rule; the animation is
|
|
23
|
+
// declared here in the sheet, so unlike HkTransitionContainer's inline
|
|
24
|
+
// duration a media query CAN reach it).
|
|
25
|
+
html[data-css-animations="0"] .hk-pane-holder[data-active] {
|
|
26
|
+
animation: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@media (prefers-reduced-motion: reduce) {
|
|
30
|
+
.hk-pane-holder[data-active] {
|
|
31
|
+
animation-duration: var(--duration-fast, 0.15s);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import sass from "sass";
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
|
8
|
+
|
|
9
|
+
import HkPaneHolder from "./HkPaneHolder";
|
|
10
|
+
|
|
11
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
const containers: HTMLElement[] = [];
|
|
14
|
+
|
|
15
|
+
function mountHolder(active: boolean): {
|
|
16
|
+
app: App;
|
|
17
|
+
root: HTMLElement;
|
|
18
|
+
child: HTMLElement;
|
|
19
|
+
setActive: (v: boolean) => void;
|
|
20
|
+
} {
|
|
21
|
+
const container = document.createElement("div");
|
|
22
|
+
document.body.appendChild(container);
|
|
23
|
+
containers.push(container);
|
|
24
|
+
const isActive = ref(active);
|
|
25
|
+
const Child = defineComponent({
|
|
26
|
+
setup() {
|
|
27
|
+
// The point of the holder: the pane's instance state SURVIVES
|
|
28
|
+
// deactivation. A counter proves the component never unmounted.
|
|
29
|
+
const mounts = ref(0);
|
|
30
|
+
const tick = () => { mounts.value += 1; };
|
|
31
|
+
return () => h("button", { type: "button", onClick: tick, "data-mounts": String(mounts.value) }, "pane");
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const Wrapper = defineComponent({
|
|
35
|
+
render: () =>
|
|
36
|
+
h(HkPaneHolder, { active: isActive.value }, { default: () => h(Child) }),
|
|
37
|
+
});
|
|
38
|
+
const app = createApp(Wrapper);
|
|
39
|
+
app.mount(container);
|
|
40
|
+
const root = container.firstElementChild as HTMLElement;
|
|
41
|
+
const child = root.firstElementChild as HTMLElement;
|
|
42
|
+
return { app, root, child, setActive: (v: boolean) => { isActive.value = v; } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
for (const c of containers.splice(0)) c.remove();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("HkPaneHolder — DOM contract", () => {
|
|
50
|
+
it("renders the holder class and the data-active attribute only when active", async () => {
|
|
51
|
+
const t = mountHolder(false);
|
|
52
|
+
expect(t.root.classList.contains("hk-pane-holder")).toBe(true);
|
|
53
|
+
expect(t.root.hasAttribute("data-active")).toBe(false);
|
|
54
|
+
expect(t.child).toBeDefined(); // mounted, not unmounted
|
|
55
|
+
t.setActive(true);
|
|
56
|
+
await nextTick();
|
|
57
|
+
expect(t.root.hasAttribute("data-active")).toBe(true);
|
|
58
|
+
t.app.unmount();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps component state across deactivate/reactivate (the entire point)", async () => {
|
|
62
|
+
const t = mountHolder(true);
|
|
63
|
+
(t.child as HTMLButtonElement).click();
|
|
64
|
+
(t.child as HTMLButtonElement).click();
|
|
65
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
66
|
+
expect(t.child.getAttribute("data-mounts")).toBe("2");
|
|
67
|
+
t.setActive(false);
|
|
68
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
69
|
+
t.setActive(true);
|
|
70
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
71
|
+
const childAfter = t.root.firstElementChild as HTMLElement;
|
|
72
|
+
// Same instance: the counter did not reset to 0.
|
|
73
|
+
expect(childAfter.getAttribute("data-mounts")).toBe("2");
|
|
74
|
+
t.app.unmount();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("HkPaneHolder — stylesheet contract", () => {
|
|
79
|
+
const css = sass.compile(join(here, "HkPaneHolder.scss")).css;
|
|
80
|
+
const scss = readFileSync(join(here, "HkPaneHolder.scss"), "utf-8");
|
|
81
|
+
|
|
82
|
+
it("hides the base holder and reveals on [data-active]", () => {
|
|
83
|
+
expect(css).toMatch(/\.hk-pane-holder\s*\{[^}]*display:\s*none/s);
|
|
84
|
+
expect(css).toMatch(/\.hk-pane-holder\[data-active\]\s*\{[^}]*display:\s*block/s);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reveals with opacity only — no transform, ever", () => {
|
|
88
|
+
const keyframe = /@keyframes\s+hk-pane-holder-in\s*\{([^}]*)\}/s.exec(css);
|
|
89
|
+
expect(keyframe).not.toBeNull();
|
|
90
|
+
expect(keyframe![1]).toContain("opacity");
|
|
91
|
+
expect(keyframe![1]).not.toContain("transform");
|
|
92
|
+
expect(keyframe![1]).not.toContain("translate");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("obeys the global animation switch and reduced motion", () => {
|
|
96
|
+
expect(css).toMatch(/html\[data-css-animations="0"\][^{]*\{[^}]*animation:\s*none/s);
|
|
97
|
+
expect(css).toMatch(/prefers-reduced-motion[^{]*\{[^}]*animation-duration/s);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineComponent } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkPaneHolder.scss";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HkPaneHolder — the keep-mounted pane slot.
|
|
7
|
+
*
|
|
8
|
+
* The third pane-swap surface, for the class of content the dissolve
|
|
9
|
+
* primitives cannot serve: PERSISTENT panes whose instance must survive
|
|
10
|
+
* while another pane is active — draft stores (an editor whose unsaved
|
|
11
|
+
* state lives in the component), canvases/WebGL stages that are too
|
|
12
|
+
* expensive to rebuild, or sockets that must not unplug.
|
|
13
|
+
*
|
|
14
|
+
* `HkCrossfade` / `HkTransitionContainer` swap by keying: the leaving
|
|
15
|
+
* generation UNMOUNTS when the dissolve completes — exactly wrong for a
|
|
16
|
+
* draft store. This holder instead keeps every pane in the tree and
|
|
17
|
+
* toggles reachability: `display: none` while inactive (out of layout,
|
|
18
|
+
* inert), `display: block` plus a one-shot opacity reveal when it
|
|
19
|
+
* becomes active. The reveal is opacity-ONLY at the family's
|
|
20
|
+
* `--duration-normal`, matching the dissolve language — no movement,
|
|
21
|
+
* ever.
|
|
22
|
+
*
|
|
23
|
+
* Contract:
|
|
24
|
+
* - `active` is a boolean, not a key: the holder IS one pane. A tab
|
|
25
|
+
* surface renders one holder per persistent pane beside the ordinary
|
|
26
|
+
* panel slots it already has.
|
|
27
|
+
* - The inactive pane is `display: none` (inert, out of the layout,
|
|
28
|
+
* unreadable by assistive tech) but its component instances, state
|
|
29
|
+
* and watchers all LIVE — that is the entire point.
|
|
30
|
+
* - The reveal animation is `hk-pane-holder-in` (opacity 0→1, one
|
|
31
|
+
* shot, registered with the animation registry); reduced motion and
|
|
32
|
+
* the global `data-css-animations="0"` switch clamp it to instant,
|
|
33
|
+
* so the holder never animates for users who opted out.
|
|
34
|
+
* - No leave animation: the outgoing pane simply hides (it is usually
|
|
35
|
+
* being covered by an incoming ordinary panel whose own dissolve is
|
|
36
|
+
* the visible exchange).
|
|
37
|
+
*/
|
|
38
|
+
export const HkPaneHolder = defineComponent({
|
|
39
|
+
name: "HkPaneHolder",
|
|
40
|
+
props: {
|
|
41
|
+
/** Whether THIS pane is the active one. */
|
|
42
|
+
active: { type: Boolean, required: true },
|
|
43
|
+
tag: { type: String, default: "div" },
|
|
44
|
+
},
|
|
45
|
+
setup(props, { slots }) {
|
|
46
|
+
return () => {
|
|
47
|
+
const Tag = props.tag as "div" | "section";
|
|
48
|
+
return (
|
|
49
|
+
<Tag class="hk-pane-holder" data-active={props.active || undefined}>
|
|
50
|
+
{slots.default?.()}
|
|
51
|
+
</Tag>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export default HkPaneHolder;
|
|
@@ -51,6 +51,11 @@ const STANDARD_PANE_HOSTS: Array<{ file: string; marker: string; why: string }>
|
|
|
51
51
|
marker: "<HkTransitionContainer",
|
|
52
52
|
why: "settings sections are a page-scale pane slot (migrated 2026-10-23)",
|
|
53
53
|
},
|
|
54
|
+
{
|
|
55
|
+
file: "HkAdminTablePage.tsx",
|
|
56
|
+
marker: "<HkCrossfade",
|
|
57
|
+
why: "the error/placeholder/content body is a page slot every admin table page inherits (2026-10-24)",
|
|
58
|
+
},
|
|
54
59
|
];
|
|
55
60
|
|
|
56
61
|
/** Justified bespoke mechanisms: "<scss file>: <animation identifier>" → reason. */
|
|
@@ -66,6 +71,14 @@ const SANCTIONED_SWAP_KEYFRAMES: Record<string, string> = {
|
|
|
66
71
|
* animation must be argued, decorative pulses and spinners do not. */
|
|
67
72
|
const SWAP_VOCABULARY = /(pane|swap|crossfade|morph|sheet)/;
|
|
68
73
|
|
|
74
|
+
/** The primitives' own stylesheets ARE the sanctioned mechanism — their
|
|
75
|
+
* keyframes are the family language itself, not bespoke swaps. */
|
|
76
|
+
const PRIMITIVE_SHEETS = new Set([
|
|
77
|
+
"HkCrossfade.scss",
|
|
78
|
+
"HkTransitionContainer.scss",
|
|
79
|
+
"HkPaneHolder.scss",
|
|
80
|
+
]);
|
|
81
|
+
|
|
69
82
|
function readComponent(file: string): string {
|
|
70
83
|
return readFileSync(join(componentsDir, file), "utf-8");
|
|
71
84
|
}
|
|
@@ -124,6 +137,7 @@ describe("pane-swap contract", () => {
|
|
|
124
137
|
for (const match of text.matchAll(/@keyframes\s+([\w-]+)/g)) {
|
|
125
138
|
const name = match[1];
|
|
126
139
|
if (!SWAP_VOCABULARY.test(name)) continue;
|
|
140
|
+
if (PRIMITIVE_SHEETS.has(file)) continue;
|
|
127
141
|
const key = `${file}: ${name}`;
|
|
128
142
|
if (!SANCTIONED_SWAP_KEYFRAMES[key]) offenders.push(`@keyframes ${key}`);
|
|
129
143
|
}
|
package/src/index.ts
CHANGED
|
@@ -614,6 +614,7 @@ export { default as HkCopyBadge } from "./components/HkCopyBadge";
|
|
|
614
614
|
export { default as HkConfirmDialog } from "./components/HkConfirmDialog";
|
|
615
615
|
export { default as HkCrossfade } from "./components/HkCrossfade";
|
|
616
616
|
export { default as HkTransitionContainer } from "./components/HkTransitionContainer";
|
|
617
|
+
export { default as HkPaneHolder } from "./components/HkPaneHolder";
|
|
617
618
|
export { default as HkWindowControls, isApplePlatform } from "./components/HkWindowControls";
|
|
618
619
|
export { default as HkStatusRow } from "./components/HkStatusRow";
|
|
619
620
|
export type { HkStatusRowTone } from "./components/HkStatusRow";
|
|
@@ -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) ──────────────────────
|