@celestia-island/hikari 0.55.204 → 0.55.206
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.tsx +9 -0
- package/src/components/HkCrossfade.scss +9 -1
- package/src/components/HkCrossfade.test.tsx +35 -1
- package/src/components/HkCrossfade.tsx +31 -4
- package/src/components/HkSettingsDialog.scss +43 -16
- package/src/components/HkSettingsDialog.test.tsx +77 -2
- package/src/components/HkSettingsDialog.tsx +28 -12
- package/src/components/HkTransitionContainer.test.tsx +117 -2
- package/src/components/HkTransitionContainer.tsx +164 -11
- package/src/components/HkWindowControls.scss +6 -1
- package/src/components/HkWindowControls.tsx +11 -0
- package/src/components/windowControlsEdgeInset.contract.test.ts +125 -0
- package/src/composables/useReportedTransition.ts +9 -3
- package/src/styles/admin-tokens.scss +34 -0
package/package.json
CHANGED
|
@@ -20,6 +20,14 @@ export const HkAdminHeader = defineComponent({
|
|
|
20
20
|
title: { type: String, default: "" },
|
|
21
21
|
showHamburger: { type: Boolean, default: false },
|
|
22
22
|
compact: { type: Boolean, default: false },
|
|
23
|
+
/** Desktop shell face whose header IS the window title bar (Tauri
|
|
24
|
+
* decorations:false): pass true iff the header renders the
|
|
25
|
+
* HkWindowControls cluster, so the right inset follows the
|
|
26
|
+
* window-controls chrome contract (.s-glass-header--chrome /
|
|
27
|
+
* --hk-window-controls-edge-inset) instead of the content inset —
|
|
28
|
+
* a standard-mode cluster glued to the frame corner is the flush
|
|
29
|
+
* variant's look worn by the wrong mode (field report 2026-10-25). */
|
|
30
|
+
chrome: { type: Boolean, default: false },
|
|
23
31
|
actions: { type: Array as PropType<VNode[]>, default: () => [] },
|
|
24
32
|
/** Signed-in identity. Drives the avatar fallback letter, the
|
|
25
33
|
* dropdown identity block and the pending-state branch — it is
|
|
@@ -192,6 +200,7 @@ export const HkAdminHeader = defineComponent({
|
|
|
192
200
|
"s-glass-header",
|
|
193
201
|
"s-balanced-header",
|
|
194
202
|
props.compact ? "s-glass-header--compact" : "",
|
|
203
|
+
props.chrome ? "s-glass-header--chrome" : "",
|
|
195
204
|
]}
|
|
196
205
|
>
|
|
197
206
|
<div ref={userTriggerRef} class="s-admin-header-user s-header-wing-start">
|
|
@@ -39,12 +39,20 @@
|
|
|
39
39
|
* the composite never dips toward the background (the classic
|
|
40
40
|
* double-exposure artifact of symmetric linear fades). The leaving item
|
|
41
41
|
* also drops pointer events: it is dead content and must not intercept
|
|
42
|
-
* clicks while it dissolves under the incoming one.
|
|
42
|
+
* clicks while it dissolves under the incoming one.
|
|
43
|
+
*
|
|
44
|
+
* The enter side additionally honours --hk-crossfade-enter-delay (default
|
|
45
|
+
* 0): the sequencing hook HkTransitionContainer sets per swap (2026-10-27
|
|
46
|
+
* user direction — a height-changing swap holds the new content at
|
|
47
|
+
* opacity 0 until the box is ready, so nothing is clipped by the morph).
|
|
48
|
+
* Declared as a LONGHAND after the shorthand: the `transition` shorthand
|
|
49
|
+
* resets transition-delay to 0, so the order here is load-bearing. */
|
|
43
50
|
.hk-crossfade-enter-active {
|
|
44
51
|
transition:
|
|
45
52
|
opacity
|
|
46
53
|
var(--duration-normal, 0.3s)
|
|
47
54
|
var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1));
|
|
55
|
+
transition-delay: var(--hk-crossfade-enter-delay, 0ms);
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
.hk-crossfade-leave-active {
|
|
@@ -30,6 +30,7 @@ function mountCrossfade(opts: {
|
|
|
30
30
|
initial?: string;
|
|
31
31
|
appear?: boolean;
|
|
32
32
|
disabled?: boolean;
|
|
33
|
+
enterDelay?: number;
|
|
33
34
|
} = {}): Harness {
|
|
34
35
|
const container = document.createElement("div");
|
|
35
36
|
document.body.appendChild(container);
|
|
@@ -45,6 +46,7 @@ function mountCrossfade(opts: {
|
|
|
45
46
|
swapKey: current.value,
|
|
46
47
|
appear: opts.appear ?? false,
|
|
47
48
|
disabled: opts.disabled ?? false,
|
|
49
|
+
enterDelay: opts.enterDelay ?? 0,
|
|
48
50
|
},
|
|
49
51
|
{
|
|
50
52
|
default: () => h("p", { class: "body" }, `body-${current.value}`),
|
|
@@ -181,6 +183,27 @@ describe("HkCrossfade — DOM contract", () => {
|
|
|
181
183
|
assertNoFrozenClasses(settled[0]!);
|
|
182
184
|
});
|
|
183
185
|
|
|
186
|
+
it("enterDelay widens the watchdog budget: a delayed enter mid-flight is not 'stalled'", async () => {
|
|
187
|
+
vi.useFakeTimers();
|
|
188
|
+
freezeRaf();
|
|
189
|
+
// 300ms choreographed hold: the enter fade starts at 300 and runs to
|
|
190
|
+
// 600, so the BASE budget (450) expires mid-enter — the sweep must
|
|
191
|
+
// NOT fire there, or it would strip a live transition's classes.
|
|
192
|
+
const DELAY = 300;
|
|
193
|
+
const { container, set } = mountCrossfade({ enterDelay: DELAY });
|
|
194
|
+
await set("b");
|
|
195
|
+
await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 100); // past the base, inside the extended
|
|
196
|
+
// Both generations still held by the (rAF-frozen) engine: no sweep
|
|
197
|
+
// has stripped anything.
|
|
198
|
+
expect(keys(container)).toEqual(["a", "b"]);
|
|
199
|
+
// Past the extended budget the stall sweep bites as usual.
|
|
200
|
+
await vi.advanceTimersByTimeAsync(DELAY + 200);
|
|
201
|
+
const list = items(container);
|
|
202
|
+
expect(list).toHaveLength(1);
|
|
203
|
+
expect(list[0]!.dataset.crossfadeKey).toBe("b");
|
|
204
|
+
assertNoFrozenClasses(list[0]!);
|
|
205
|
+
});
|
|
206
|
+
|
|
184
207
|
it("appear dissolves the first item in; the watchdog un-freezes it under rAF starvation", async () => {
|
|
185
208
|
vi.useFakeTimers();
|
|
186
209
|
freezeRaf();
|
|
@@ -235,6 +258,15 @@ describe("HkCrossfade — stylesheet contract", () => {
|
|
|
235
258
|
const enter = css.match(/\.hk-crossfade-enter-active[^{]*\{[^}]*\}/)?.[0] ?? "";
|
|
236
259
|
expect(enter).toContain("transition: opacity");
|
|
237
260
|
expect(enter).toContain("var(--duration-normal, 0.3s)");
|
|
261
|
+
// The choreography hook (2026-10-27): the enter side honours the
|
|
262
|
+
// per-swap delay property — as a LONGHAND declared after the
|
|
263
|
+
// shorthand (the `transition` shorthand resets transition-delay, so
|
|
264
|
+
// the order in the rule is load-bearing).
|
|
265
|
+
const enterIdx = css.indexOf(".hk-crossfade-enter-active");
|
|
266
|
+
const delayIdx = css.indexOf("transition-delay: var(--hk-crossfade-enter-delay", enterIdx);
|
|
267
|
+
const shorthandIdx = css.indexOf("transition:", enterIdx);
|
|
268
|
+
expect(delayIdx).toBeGreaterThan(-1);
|
|
269
|
+
expect(delayIdx).toBeGreaterThan(shorthandIdx);
|
|
238
270
|
for (const phase of ["enter-from", "leave-to"]) {
|
|
239
271
|
const block = css.match(new RegExp(`\\.hk-crossfade-${phase}[^{]*\\{[^}]*\\}`))?.[0] ?? "";
|
|
240
272
|
expect(block, `hk-crossfade-${phase} rule missing`).toContain("opacity: 0");
|
|
@@ -267,7 +299,9 @@ describe("HkCrossfade — wiring contract", () => {
|
|
|
267
299
|
expect(src).toContain("export const CROSSFADE_WATCHDOG_MS = 450;");
|
|
268
300
|
expect(src).toContain("suppress.value = true;");
|
|
269
301
|
expect(src).toContain("el.remove();");
|
|
270
|
-
|
|
302
|
+
// The report window is a per-arm provider so a choreographed enter
|
|
303
|
+
// (delayed by the host) re-reads the window wide enough each arm.
|
|
304
|
+
expect(src).toContain("const report = useReportedTransition(reportWindow);");
|
|
271
305
|
expect(src).toContain("armReport(); armWatchdog();");
|
|
272
306
|
});
|
|
273
307
|
});
|
|
@@ -62,7 +62,20 @@ const REPORT_MS = CROSSFADE_WATCHDOG_MS;
|
|
|
62
62
|
* the sweep fires into an already-settled DOM and is a no-op. After a
|
|
63
63
|
* real stall it strips the frozen transition classes, removes the stale
|
|
64
64
|
* item Vue can no longer unmount, and degrades to synchronous rendering
|
|
65
|
-
* until the next `swapKey` change re-arms the Transition fresh.
|
|
65
|
+
* until the next `swapKey` change re-arms the Transition fresh. The
|
|
66
|
+
* budget scales by `enterDelay` (below) so a choreographed swap whose
|
|
67
|
+
* enter starts late never looks stalled.
|
|
68
|
+
* - `enterDelay` (ms, default 0) holds the ENTER side at opacity 0 for a
|
|
69
|
+
* beat before its fade runs — the sequencing hook HkTransitionContainer
|
|
70
|
+
* drives (2026-10-27 user direction: a height-changing swap fades the
|
|
71
|
+
* old content out first; the new content only fades in once the box is
|
|
72
|
+
* ready, so nothing is ever clipped by the animating size). The delay
|
|
73
|
+
* itself travels as the `--hk-crossfade-enter-delay` custom property on
|
|
74
|
+
* an ancestor (consumed by the enter-active rule), so it can be set per
|
|
75
|
+
* swap after the incoming content is measured; the prop exists so the
|
|
76
|
+
* watchdog and animation-bus windows KNOW the worst case and arm wide
|
|
77
|
+
* enough. Direct consumers who never touch the property keep the
|
|
78
|
+
* byte-identical concurrent dissolve.
|
|
66
79
|
*/
|
|
67
80
|
export default defineComponent({
|
|
68
81
|
name: "HkCrossfade",
|
|
@@ -71,6 +84,10 @@ export default defineComponent({
|
|
|
71
84
|
tag: { type: String, default: "div" },
|
|
72
85
|
appear: { type: Boolean, default: false },
|
|
73
86
|
disabled: { type: Boolean, default: false },
|
|
87
|
+
/** Enter-side hold (ms) the watchdog/report budgets must cover; the
|
|
88
|
+
* applied delay is the CSS custom property, this only widens the
|
|
89
|
+
* budgets. Keep it at the worst case the host can set. */
|
|
90
|
+
enterDelay: { type: Number, default: 0 },
|
|
74
91
|
},
|
|
75
92
|
setup(props, { slots }) {
|
|
76
93
|
const hostRef = ref<HTMLElement | null>(null);
|
|
@@ -83,8 +100,11 @@ export default defineComponent({
|
|
|
83
100
|
// ── Animation-bus participation ──────────────────────────────────
|
|
84
101
|
// One swap runs an enter AND a leave concurrently; refcount them so
|
|
85
102
|
// the shared track stays armed until BOTH settle (HkListTransition
|
|
86
|
-
// pattern).
|
|
87
|
-
|
|
103
|
+
// pattern). The window widens by the choreographed enter hold — read
|
|
104
|
+
// per arm, so a host that raises `enterDelay` mid-life is covered
|
|
105
|
+
// without a re-setup.
|
|
106
|
+
const reportWindow = () => REPORT_MS + Math.max(props.enterDelay, 0);
|
|
107
|
+
const report = useReportedTransition(reportWindow);
|
|
88
108
|
let pending = 0;
|
|
89
109
|
const armReport = () => {
|
|
90
110
|
pending++;
|
|
@@ -96,13 +116,20 @@ export default defineComponent({
|
|
|
96
116
|
};
|
|
97
117
|
|
|
98
118
|
// ── Swap-stall watchdog ──────────────────────────────────────────
|
|
119
|
+
/** Budget for one arm: the base sweep window plus the choreographed
|
|
120
|
+
* enter hold — a delayed enter is mid-flight, not stalled, when the
|
|
121
|
+
* base budget expires. */
|
|
122
|
+
function budget(): number {
|
|
123
|
+
return CROSSFADE_WATCHDOG_MS + Math.max(props.enterDelay, 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
99
126
|
let watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
100
127
|
|
|
101
128
|
function armWatchdog(): void {
|
|
102
129
|
// Re-arm per swap event (enter or leave start); a healthy swap lets
|
|
103
130
|
// the timer fire into a settled DOM, where the sweep is a no-op.
|
|
104
131
|
if (watchdog !== null) clearTimeout(watchdog);
|
|
105
|
-
watchdog = setTimeout(onWatchdog,
|
|
132
|
+
watchdog = setTimeout(onWatchdog, budget());
|
|
106
133
|
}
|
|
107
134
|
|
|
108
135
|
function sweepFrozenClasses(el: Element): boolean {
|
|
@@ -4,13 +4,21 @@
|
|
|
4
4
|
// are the L2 scale tokens wowsp already used, so existing surfaces
|
|
5
5
|
// migrate without repainting.
|
|
6
6
|
//
|
|
7
|
-
// Scroll ownership
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
7
|
+
// Scroll ownership (2026-10-27, wowsp contract upstreamed): BOTH shell
|
|
8
|
+
// scrollers are HkScrollContainers — the native thin bars are gone, the
|
|
9
|
+
// shared auto-hiding overlay chrome draws instead. The component
|
|
10
|
+
// templates render the rail and the pane with the container class, and
|
|
11
|
+
// the paired-class rules below re-anchor the shell geometry whose home
|
|
12
|
+
// moved: the regions' own overflow-y is replaced by the container's
|
|
13
|
+
// overflow: hidden, and the paddings/gaps now live on the VIEWPORT
|
|
14
|
+
// (the slot content and rail entries are its children), so each overlay
|
|
15
|
+
// track hugs its region's border edge. Inside the dialog (host class
|
|
16
|
+
// `hk-settings-host`) the shell gets a STABLE height sized under the
|
|
17
|
+
// modal body scroller's inner padding, so HkModal's floating overlay
|
|
18
|
+
// scrollbar stays idle and the pane's track is the only scrollbar; the
|
|
19
|
+
// negative-margin trick puts that track flush with the modal window's
|
|
20
|
+
// right edge and the viewport padding re-applies the content inset
|
|
21
|
+
// between the track and the cards. A page-embedded body (no host class)
|
|
14
22
|
// skips both: plain scroll container, host-managed layout.
|
|
15
23
|
|
|
16
24
|
.hk-settings {
|
|
@@ -22,12 +30,17 @@
|
|
|
22
30
|
&__rail {
|
|
23
31
|
flex: none;
|
|
24
32
|
width: 10.5rem;
|
|
33
|
+
border-right: 1px solid rgb(var(--color-border) / 15%);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* The rail's own vocabulary lives in the scroll container's viewport
|
|
37
|
+
now (scroll ownership above) — the overlay track hugs the rail's
|
|
38
|
+
border edge, flush like the native bar it replaces. */
|
|
39
|
+
&__rail.hk-scroll-container > .hk-scroll-container-viewport {
|
|
25
40
|
display: flex;
|
|
26
41
|
flex-direction: column;
|
|
27
42
|
gap: var(--space-2);
|
|
28
43
|
padding: var(--space-4) var(--space-12) var(--space-4) 0;
|
|
29
|
-
border-right: 1px solid rgb(var(--color-border) / 15%);
|
|
30
|
-
overflow-y: auto;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
&__rail-item {
|
|
@@ -100,10 +113,14 @@
|
|
|
100
113
|
&__pane {
|
|
101
114
|
flex: 1;
|
|
102
115
|
min-width: 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Same re-anchoring for the pane: the sections are viewport children,
|
|
119
|
+
so the column flow and the section gap live there. */
|
|
120
|
+
&__pane.hk-scroll-container > .hk-scroll-container-viewport {
|
|
103
121
|
display: flex;
|
|
104
122
|
flex-direction: column;
|
|
105
123
|
gap: var(--space-16);
|
|
106
|
-
overflow-y: auto;
|
|
107
124
|
}
|
|
108
125
|
}
|
|
109
126
|
|
|
@@ -119,17 +136,27 @@
|
|
|
119
136
|
height: var(--hk-settings-height, calc(70vh - 6.5rem));
|
|
120
137
|
}
|
|
121
138
|
|
|
122
|
-
/* Cancel the modal body's fixed right padding so the pane's
|
|
123
|
-
sits flush at the window edge;
|
|
124
|
-
the cards
|
|
125
|
-
|
|
139
|
+
/* Cancel the modal body's fixed right padding so the pane's overlay
|
|
140
|
+
track sits flush at the window edge; the content inset between the
|
|
141
|
+
track and the cards is re-applied INSIDE the viewport (the scroller).
|
|
142
|
+
HkModal retunes the body padding to 1rem under 768px WITHOUT going
|
|
143
|
+
through the --hk-modal-padding-body var (a hard-coded 1rem there), so
|
|
144
|
+
the var-based margin would over-cancel by 8px and push the whole
|
|
145
|
+
track past the body's overflow clip — follow the hard-coded value or
|
|
146
|
+
the phone sheet loses its scrollbar entirely (R2 2026-10-27). */
|
|
126
147
|
.hk-settings-host .hk-settings__pane {
|
|
127
148
|
margin-right: calc(-1 * var(--hk-modal-padding-body, 1.5rem));
|
|
128
|
-
padding-right: var(--space-28);
|
|
129
149
|
|
|
130
150
|
@media (max-width: 767px) {
|
|
131
151
|
margin-right: -1rem;
|
|
132
|
-
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
& > .hk-scroll-container-viewport {
|
|
155
|
+
padding-right: var(--space-28);
|
|
156
|
+
|
|
157
|
+
@media (max-width: 767px) {
|
|
158
|
+
padding-right: var(--space-20);
|
|
159
|
+
}
|
|
133
160
|
}
|
|
134
161
|
}
|
|
135
162
|
|
|
@@ -97,17 +97,63 @@ describe("HSettingsBody", () => {
|
|
|
97
97
|
},
|
|
98
98
|
});
|
|
99
99
|
const { container, unmount } = mount(Probe);
|
|
100
|
+
// The pane is an HkScrollContainer: the scroller is its viewport
|
|
101
|
+
// (the root only clips), so the reset contract is read there.
|
|
100
102
|
const pane = container.querySelector<HTMLElement>(".hk-settings__pane")!;
|
|
101
|
-
|
|
103
|
+
const viewport = pane.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
104
|
+
expect(viewport).toBeTruthy();
|
|
105
|
+
viewport.scrollTop = 500;
|
|
102
106
|
const items = container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
|
|
103
107
|
items[1].click();
|
|
104
108
|
await nextTick();
|
|
105
109
|
await nextTick();
|
|
106
|
-
scrolled.value =
|
|
110
|
+
scrolled.value = viewport.scrollTop === 0;
|
|
107
111
|
expect(scrolled.value).toBe(true);
|
|
108
112
|
unmount();
|
|
109
113
|
});
|
|
110
114
|
|
|
115
|
+
it("renders the rail and the pane as HkScrollContainers (overlay scrollbar contract)", () => {
|
|
116
|
+
const { container, unmount } = mount(Host);
|
|
117
|
+
// Scroll ownership (2026-10-27): the native thin bars are gone and the
|
|
118
|
+
// shared overlay chrome draws instead — pinned at the source because
|
|
119
|
+
// happy-dom cannot exercise the cascade that hides the native bar.
|
|
120
|
+
const rail = container.querySelector(".hk-settings__rail")!;
|
|
121
|
+
const pane = container.querySelector(".hk-settings__pane")!;
|
|
122
|
+
expect(rail.classList.contains("hk-scroll-container")).toBe(true);
|
|
123
|
+
expect(pane.classList.contains("hk-scroll-container")).toBe(true);
|
|
124
|
+
expect(rail.querySelector(".hk-scroll-container-viewport")).toBeTruthy();
|
|
125
|
+
expect(pane.querySelector(".hk-scroll-container-viewport")).toBeTruthy();
|
|
126
|
+
// The rail stays a navigation landmark through the `as` prop. Without
|
|
127
|
+
// a navLabel the landmark is unnamed (the fixture passes none) — the
|
|
128
|
+
// positive naming contract is its own case below.
|
|
129
|
+
expect(rail.tagName).toBe("NAV");
|
|
130
|
+
expect(rail.getAttribute("aria-label")).toBeNull();
|
|
131
|
+
unmount();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("lands a provided navLabel on the rail landmark (attr fallthrough)", () => {
|
|
135
|
+
// The rail is an HkScrollContainer rendered `as="nav"`: the accessible
|
|
136
|
+
// name travels through the component's attribute fallthrough onto the
|
|
137
|
+
// container root — the machinery this refactor introduced, so pin it.
|
|
138
|
+
const Named = defineComponent({
|
|
139
|
+
setup() {
|
|
140
|
+
return () => (
|
|
141
|
+
<HSettingsBody sections={SECTIONS.slice(0, 2)} navLabel="Settings sections">
|
|
142
|
+
{{
|
|
143
|
+
language: () => <div data-test="pane-language" />,
|
|
144
|
+
network: () => <div data-test="pane-network" />,
|
|
145
|
+
}}
|
|
146
|
+
</HSettingsBody>
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const { container, unmount } = mount(Named);
|
|
151
|
+
const rail = container.querySelector(".hk-settings__rail")!;
|
|
152
|
+
expect(rail.tagName).toBe("NAV");
|
|
153
|
+
expect(rail.getAttribute("aria-label")).toBe("Settings sections");
|
|
154
|
+
unmount();
|
|
155
|
+
});
|
|
156
|
+
|
|
111
157
|
it("honors an externally driven section change (deep-link landing)", async () => {
|
|
112
158
|
const section = ref("language");
|
|
113
159
|
const Deep = defineComponent({
|
|
@@ -222,6 +268,35 @@ describe("settings dialog SCSS host contract (source-level)", () => {
|
|
|
222
268
|
expect(scss).toContain("margin-right: calc(-1 * var(--hk-modal-padding-body, 1.5rem));");
|
|
223
269
|
});
|
|
224
270
|
|
|
271
|
+
it("re-anchors the shell vocabulary into the viewports (scroll ownership)", () => {
|
|
272
|
+
// Scroll ownership (2026-10-27): the paddings/gaps moved into the
|
|
273
|
+
// scroll containers' viewports so the overlay tracks hug the regions'
|
|
274
|
+
// border edges — the paired-class rules are the contract's home.
|
|
275
|
+
expect(scss).toContain("&__rail.hk-scroll-container > .hk-scroll-container-viewport {");
|
|
276
|
+
expect(scss).toContain("&__pane.hk-scroll-container > .hk-scroll-container-viewport {");
|
|
277
|
+
// The regions themselves must NOT scroll natively any more — scoped
|
|
278
|
+
// to their own blocks, not the whole file (a future rule elsewhere
|
|
279
|
+
// may legitimately want overflow-y: auto).
|
|
280
|
+
for (const region of ["&__rail {", "&__pane {"]) {
|
|
281
|
+
const start = scss.indexOf(region);
|
|
282
|
+
expect(start, `${region} block located`).toBeGreaterThan(-1);
|
|
283
|
+
const block = scss.slice(start, scss.indexOf("}", start));
|
|
284
|
+
expect(block, `${region} must not scroll natively`).not.toContain("overflow-y");
|
|
285
|
+
}
|
|
286
|
+
// The dialog inset lives inside the pane's viewport, not on the root.
|
|
287
|
+
expect(scss).toContain("& > .hk-scroll-container-viewport {\n padding-right: var(--space-28);");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("keeps the flush-edge margin width-correct (desktop var, mobile hard-coded)", () => {
|
|
291
|
+
// The desktop margin cancels the var-chained body padding; the mobile
|
|
292
|
+
// arm must exist because HkModal hard-codes 1rem under 768px without
|
|
293
|
+
// the var — dropping it over-cancels by 8px and clips the whole
|
|
294
|
+
// overlay track out of the phone sheet (R2 2026-10-27).
|
|
295
|
+
expect(scss).toContain("margin-right: calc(-1 * var(--hk-modal-padding-body, 1.5rem));");
|
|
296
|
+
const hostBlock = scss.slice(scss.indexOf(".hk-settings-host .hk-settings__pane {"));
|
|
297
|
+
expect(hostBlock).toContain("@media (max-width: 767px) {\n margin-right: -1rem;\n }");
|
|
298
|
+
});
|
|
299
|
+
|
|
225
300
|
it("never activates a disabled section through a deep-linked prop", async () => {
|
|
226
301
|
const Deep = defineComponent({
|
|
227
302
|
setup() {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "vue";
|
|
10
10
|
|
|
11
11
|
import HkTransitionContainer from "./HkTransitionContainer";
|
|
12
|
+
import HkScrollContainer from "./HkScrollContainer";
|
|
12
13
|
import HModal from "./HkModal";
|
|
13
14
|
import "./HkSettingsDialog.scss";
|
|
14
15
|
|
|
@@ -40,9 +41,13 @@ export interface HkSettingsSection {
|
|
|
40
41
|
*
|
|
41
42
|
* Scroll ownership: the pane is the only scroller — switching sections
|
|
42
43
|
* restarts it from the top (arriving on a half-scrolled pane reads as a
|
|
43
|
-
* broken page).
|
|
44
|
-
*
|
|
45
|
-
* the
|
|
44
|
+
* broken page). Both scrollers are HkScrollContainers (2026-10-27: the
|
|
45
|
+
* wowsp-proven contract upstreamed here — the native thin bars are gone
|
|
46
|
+
* and the shared auto-hiding overlay chrome draws instead; wowsp used to
|
|
47
|
+
* re-anchor this from outside through paired-class rules in its own
|
|
48
|
+
* SettingsShell.scss). Inside the dialog the modal body's own scroller is
|
|
49
|
+
* made mathematically idle by the host height rule (see the SCSS); on a
|
|
50
|
+
* page the host manages its own layout.
|
|
46
51
|
*/
|
|
47
52
|
export const HkSettingsBody = defineComponent({
|
|
48
53
|
name: "HkSettingsBody",
|
|
@@ -83,11 +88,17 @@ export const HkSettingsBody = defineComponent({
|
|
|
83
88
|
? internal.value
|
|
84
89
|
: (props.sections[0]?.key ?? "");
|
|
85
90
|
|
|
86
|
-
|
|
87
|
-
//
|
|
91
|
+
// The pane is an HkScrollContainer; only its consumed ref slice is
|
|
92
|
+
// typed here — the scroll reset needs the live viewport element and
|
|
93
|
+
// nothing else.
|
|
94
|
+
const paneRef = ref<{ getScrollElement(): HTMLElement | undefined } | null>(null);
|
|
95
|
+
// Section switches restart the pane from the top. Written through the
|
|
96
|
+
// public ref slice (the viewport element) rather than scrollTo so the
|
|
97
|
+
// reset stays synchronous and testable in no-layout environments.
|
|
88
98
|
watch(active, () => {
|
|
89
99
|
void nextTick(() => {
|
|
90
|
-
|
|
100
|
+
const viewport = paneRef.value?.getScrollElement();
|
|
101
|
+
if (viewport) viewport.scrollTop = 0;
|
|
91
102
|
});
|
|
92
103
|
});
|
|
93
104
|
|
|
@@ -100,7 +111,11 @@ export const HkSettingsBody = defineComponent({
|
|
|
100
111
|
|
|
101
112
|
return () => (
|
|
102
113
|
<div class="hk-settings">
|
|
103
|
-
|
|
114
|
+
{/* Scroll ownership (see the docblock): both scrollers are
|
|
115
|
+
HkScrollContainers — the overlay chrome replaces the native
|
|
116
|
+
bars, and the paired-class rules in the SCSS carry each
|
|
117
|
+
region's padding/gap vocabulary into its viewport. */}
|
|
118
|
+
<HkScrollContainer as="nav" class="hk-settings__rail" aria-label={props.navLabel}>
|
|
104
119
|
{props.sections.map((s) => {
|
|
105
120
|
const Icon = s.icon;
|
|
106
121
|
const on = active() === s.key && !s.disabled;
|
|
@@ -123,8 +138,8 @@ export const HkSettingsBody = defineComponent({
|
|
|
123
138
|
</button>
|
|
124
139
|
);
|
|
125
140
|
})}
|
|
126
|
-
</
|
|
127
|
-
<
|
|
141
|
+
</HkScrollContainer>
|
|
142
|
+
<HkScrollContainer class="hk-settings__pane" ref={paneRef}>
|
|
128
143
|
{/* The section swap runs through the family's standard morph
|
|
129
144
|
container (audit 2026-10-23): the pane's frame follows the
|
|
130
145
|
incoming section's height and the contents dissolve, the
|
|
@@ -133,7 +148,7 @@ export const HkSettingsBody = defineComponent({
|
|
|
133
148
|
<HkTransitionContainer swapKey={active()} axis="height">
|
|
134
149
|
{slots[active()]?.()}
|
|
135
150
|
</HkTransitionContainer>
|
|
136
|
-
</
|
|
151
|
+
</HkScrollContainer>
|
|
137
152
|
</div>
|
|
138
153
|
);
|
|
139
154
|
},
|
|
@@ -142,8 +157,9 @@ export const HkSettingsBody = defineComponent({
|
|
|
142
157
|
/**
|
|
143
158
|
* HkSettingsDialog — the settings window: an HModal carrying the
|
|
144
159
|
* {@link HSettingsBody} shell with a stable frame height, so the modal
|
|
145
|
-
* body's floating scrollbar stays idle and the pane's
|
|
146
|
-
*
|
|
160
|
+
* body's floating scrollbar stays idle and the pane's own overlay
|
|
161
|
+
* scrollbar (an HkScrollContainer) is the only scrollbar (the
|
|
162
|
+
* wowsp-proven contract, upstreamed 2026-10-27). Width defaults to the
|
|
147
163
|
* settings-standard 58rem; `height` styles the shell (the 70vh default
|
|
148
164
|
* fits a modal with title and paddings).
|
|
149
165
|
*
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createApp, defineComponent, h, ref, type App } from "vue";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
|
|
4
|
-
import HkTransitionContainer from "./HkTransitionContainer";
|
|
7
|
+
import HkTransitionContainer, { resolveEnterDelay } from "./HkTransitionContainer";
|
|
5
8
|
import {
|
|
6
9
|
DEFAULT_ANIMATION_DURATION_MS,
|
|
7
10
|
animationLoopArmedCount,
|
|
@@ -95,6 +98,118 @@ describe("HkTransitionContainer", () => {
|
|
|
95
98
|
expect(host?.style.height).not.toContain("NaN");
|
|
96
99
|
t.app.unmount();
|
|
97
100
|
});
|
|
101
|
+
|
|
102
|
+
it("sets the enter-delay custom property on every swap (degraded env reads 'same box')", async () => {
|
|
103
|
+
// No layout here: the incoming measurement returns null, which the
|
|
104
|
+
// choreography decision treats as "same box" — the property must
|
|
105
|
+
// still be SET (the wiring runs on every swap), reading 0ms so the
|
|
106
|
+
// plain dissolve is preserved.
|
|
107
|
+
const t = mountContainer("login");
|
|
108
|
+
t.key.value = "org";
|
|
109
|
+
await nextTick();
|
|
110
|
+
await nextTick();
|
|
111
|
+
const host = t.container.querySelector<HTMLElement>(".hk-tc");
|
|
112
|
+
expect(host?.style.getPropertyValue("--hk-crossfade-enter-delay")).toBe("0ms");
|
|
113
|
+
t.app.unmount();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("survives hostile swapKeys (quotes) — dataset matching, no selector interpolation", async () => {
|
|
117
|
+
// A key is an opaque identity and may contain quotes; interpolating it
|
|
118
|
+
// into an attribute selector used to throw inside the post-flush
|
|
119
|
+
// callback and kill the whole choreography wiring for the swap (stale
|
|
120
|
+
// var, dead observer, unhandled rejection — R2 2026-10-27 S1).
|
|
121
|
+
const t = mountContainer('a"b');
|
|
122
|
+
t.key.value = 'c"d\\e';
|
|
123
|
+
await nextTick();
|
|
124
|
+
await nextTick();
|
|
125
|
+
// No throw above; the property is still written (no-layout → "same
|
|
126
|
+
// box" → 0ms), proving the decision wiring ran to completion.
|
|
127
|
+
const host = t.container.querySelector<HTMLElement>(".hk-tc");
|
|
128
|
+
expect(host?.style.getPropertyValue("--hk-crossfade-enter-delay")).toBe("0ms");
|
|
129
|
+
// And the DOM settled on the hostile key, crossfade untouched.
|
|
130
|
+
expect(t.container.textContent).toContain("panel-c\"d\\e");
|
|
131
|
+
t.app.unmount();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("holds the enter (nonzero delay var) when the incoming box measures larger", async () => {
|
|
135
|
+
// The decision → var wiring on its NON-null path: mock real-layout
|
|
136
|
+
// rects so the entering generation measures 480px tall against a
|
|
137
|
+
// zero outgoing box — a size change, so the enter buys the hold.
|
|
138
|
+
const t = mountContainer("a");
|
|
139
|
+
const spy = vi
|
|
140
|
+
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
|
|
141
|
+
.mockReturnValue({
|
|
142
|
+
x: 0, y: 0, top: 0, left: 0, right: 320, bottom: 480,
|
|
143
|
+
width: 320, height: 480, toJSON: () => ({}),
|
|
144
|
+
} as DOMRect);
|
|
145
|
+
try {
|
|
146
|
+
t.key.value = "b";
|
|
147
|
+
await nextTick();
|
|
148
|
+
await nextTick();
|
|
149
|
+
const host = t.container.querySelector<HTMLElement>(".hk-tc");
|
|
150
|
+
expect(host?.style.getPropertyValue("--hk-crossfade-enter-delay")).toBe(
|
|
151
|
+
`${DEFAULT_ANIMATION_DURATION_MS}ms`,
|
|
152
|
+
);
|
|
153
|
+
} finally {
|
|
154
|
+
spy.mockRestore();
|
|
155
|
+
}
|
|
156
|
+
t.app.unmount();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("resets the hold when the swap settles (source pin)", () => {
|
|
160
|
+
// The settle callback must clear the hold: custom properties inherit
|
|
161
|
+
// down the slot subtree, so a stale delay would make every NESTED
|
|
162
|
+
// crossfade hold a full beat until the next outer swap (R2
|
|
163
|
+
// 2026-10-27 S2). happy-dom cannot drive the observer-driven settle
|
|
164
|
+
// path, so the reset line is pinned at source — the house pattern for
|
|
165
|
+
// cascade-free contracts.
|
|
166
|
+
const src = readFileSync(
|
|
167
|
+
join(dirname(fileURLToPath(import.meta.url)), "HkTransitionContainer.tsx"),
|
|
168
|
+
"utf-8",
|
|
169
|
+
).replace(/\r\n?/g, "\n");
|
|
170
|
+
expect(src).toContain('--hk-crossfade-enter-delay", "0ms"');
|
|
171
|
+
// Inside the settle callback specifically: the reset sits between the
|
|
172
|
+
// timer's arm and the emit it guards (the callback lives in the
|
|
173
|
+
// observer setup, which precedes the decision watch in source order).
|
|
174
|
+
const settleIdx = src.indexOf("settleTimer = setTimeout(");
|
|
175
|
+
const resetIdx = src.indexOf('--hk-crossfade-enter-delay", "0ms"');
|
|
176
|
+
expect(settleIdx).toBeGreaterThan(-1);
|
|
177
|
+
expect(resetIdx).toBeGreaterThan(settleIdx);
|
|
178
|
+
// Exactly one such literal: the per-swap decision setter writes the
|
|
179
|
+
// `${delay}ms` interpolation, never this literal.
|
|
180
|
+
expect(src.split('--hk-crossfade-enter-delay", "0ms"').length - 1).toBe(1);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("swap choreography decision (resolveEnterDelay, 2026-10-27)", () => {
|
|
185
|
+
const D = DEFAULT_ANIMATION_DURATION_MS;
|
|
186
|
+
const box = (w: number, h: number) => ({ w, h });
|
|
187
|
+
|
|
188
|
+
it("holds the enter one beat when the box GROWS (grow ∥ exit, then enter)", () => {
|
|
189
|
+
expect(resolveEnterDelay(box(320, 200), box(320, 480), D)).toBe(D);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("holds the enter one beat when the box SHRINKS (exit, then shrink ∥ enter)", () => {
|
|
193
|
+
expect(resolveEnterDelay(box(320, 480), box(320, 200), D)).toBe(D);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("keeps the simultaneous dissolve when the box is unchanged", () => {
|
|
197
|
+
expect(resolveEnterDelay(box(320, 200), box(320, 200), D)).toBe(0);
|
|
198
|
+
// Sub-pixel noise is not a size change.
|
|
199
|
+
expect(resolveEnterDelay(box(320, 200), box(320, 200.6), D)).toBe(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("reads a null measurement (no layout) as 'same box'", () => {
|
|
203
|
+
expect(resolveEnterDelay(box(320, 200), null, D)).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("collapses to no hold when motion is off (0ms duration)", () => {
|
|
207
|
+
expect(resolveEnterDelay(box(320, 200), box(320, 480), 0)).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("a width-only change also buys the hold (the morph axis is not always height)", () => {
|
|
211
|
+
expect(resolveEnterDelay(box(320, 200), box(480, 200), D)).toBe(D);
|
|
212
|
+
});
|
|
98
213
|
});
|
|
99
214
|
|
|
100
215
|
describe("useAnimationLoop (shared rAF context)", () => {
|
|
@@ -5,14 +5,54 @@ import HkCrossfade from "./HkCrossfade";
|
|
|
5
5
|
|
|
6
6
|
import "./HkTransitionContainer.scss";
|
|
7
7
|
|
|
8
|
+
/** Size delta below which a swap reads as "same box" — sub-pixel
|
|
9
|
+
* measurement noise must not buy a sequencing beat. */
|
|
10
|
+
const SIZE_EPSILON_PX = 1;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The swap choreography decision (2026-10-27 user direction), pure so
|
|
14
|
+
* no-layout test environments can pin it directly. The ENTER side is
|
|
15
|
+
* held back one dissolve beat whenever the incoming content needs a
|
|
16
|
+
* different box than the outgoing one:
|
|
17
|
+
*
|
|
18
|
+
* - growing: the box grows WHILE the old content fades out; the new
|
|
19
|
+
* content fades in only once the box has arrived (the morph can never
|
|
20
|
+
* clip the incoming content from below);
|
|
21
|
+
* - shrinking: the old content fades at the full old height FIRST, then
|
|
22
|
+
* the box shrinks while the new (shorter) content fades in — the box
|
|
23
|
+
* never drops below the incoming height mid-morph, so nothing is
|
|
24
|
+
* clipped there either (the ResizeObserver stream times the shrink
|
|
25
|
+
* naturally: the stage keeps the outgoing height until the leave
|
|
26
|
+
* completes, then collapses, and the observer pins the arrival);
|
|
27
|
+
* - same box: no morph to wait for — the classic simultaneous dissolve
|
|
28
|
+
* stays (a sequencing beat there would only stutter tabs and labels).
|
|
29
|
+
*
|
|
30
|
+
* A null measurement (no layout: every rect reads zero) reads as "same
|
|
31
|
+
* box" so the choreography degrades to the plain dissolve, never to a
|
|
32
|
+
* broken one.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveEnterDelay(
|
|
35
|
+
outgoing: { w: number; h: number },
|
|
36
|
+
incoming: { w: number; h: number } | null,
|
|
37
|
+
durationMs: number,
|
|
38
|
+
): number {
|
|
39
|
+
if (!incoming || durationMs <= 0) return 0;
|
|
40
|
+
const changed =
|
|
41
|
+
Math.abs(incoming.h - outgoing.h) > SIZE_EPSILON_PX ||
|
|
42
|
+
Math.abs(incoming.w - outgoing.w) > SIZE_EPSILON_PX;
|
|
43
|
+
return changed ? durationMs : 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
8
46
|
/**
|
|
9
47
|
* HkTransitionContainer — the state-keyed morph container.
|
|
10
48
|
*
|
|
11
49
|
* HkCrossfade's height-safe dissolve, PLUS the size choreography the
|
|
12
|
-
* user
|
|
13
|
-
* bound state key changes, the OLD content fades out,
|
|
14
|
-
* width/height smoothly animate to the incoming
|
|
15
|
-
* size, and the NEW content fades in —
|
|
50
|
+
* user directions (2026-10-22, refined 2026-10-27) ask of every swap
|
|
51
|
+
* surface: when the bound state key changes, the OLD content fades out,
|
|
52
|
+
* the container's width/height smoothly animate to the incoming
|
|
53
|
+
* content's natural size, and the NEW content fades in — with the
|
|
54
|
+
* sequencing {@link resolveEnterDelay} spells out, so a height-changing
|
|
55
|
+
* swap never clips either generation. One 300ms default, CSS-driven
|
|
16
56
|
* (transition on inline size), with the animation-loop registration
|
|
17
57
|
* kept for the choreography CSS cannot express.
|
|
18
58
|
*
|
|
@@ -68,6 +108,17 @@ export default defineComponent({
|
|
|
68
108
|
let observer: ResizeObserver | null = null;
|
|
69
109
|
let pendingKey: string | number | null = null;
|
|
70
110
|
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
|
+
/** When the current swap's whole choreography (delayed enter
|
|
112
|
+
* included) reads as finished, in `performance.now()` time. The
|
|
113
|
+
* settle timer derives its remaining wait from this, so a swap
|
|
114
|
+
* whose observer fires late still settles no earlier than the
|
|
115
|
+
* enter ends. */
|
|
116
|
+
let choreoEndsAt = 0;
|
|
117
|
+
/** The total choreography length reported to the host through the
|
|
118
|
+
* `swap` event's `durationMs` — a delayed swap runs one beat longer
|
|
119
|
+
* than the bare dissolve, and a scroll-glide handoff should glide
|
|
120
|
+
* across the whole choreography, not just its fade. */
|
|
121
|
+
let choreoMs = props.duration;
|
|
71
122
|
|
|
72
123
|
function readStage(): { w: number; h: number } {
|
|
73
124
|
const el = stageRef.value;
|
|
@@ -75,6 +126,60 @@ export default defineComponent({
|
|
|
75
126
|
return { w: el.offsetWidth, h: el.offsetHeight };
|
|
76
127
|
}
|
|
77
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The entering generation's item. Found by DATASET comparison, never
|
|
131
|
+
* by interpolating the key into a selector: a key is an opaque
|
|
132
|
+
* identity and may contain quotes — a malformed selector would throw
|
|
133
|
+
* inside this post-flush callback and kill the whole choreography
|
|
134
|
+
* wiring for the swap (stale delay var, dead observer, unhandled
|
|
135
|
+
* rejection; R2 2026-10-27 S1).
|
|
136
|
+
*/
|
|
137
|
+
function enteringItem(
|
|
138
|
+
stage: HTMLElement,
|
|
139
|
+
key: string | number,
|
|
140
|
+
): HTMLElement | null {
|
|
141
|
+
const wanted = String(key);
|
|
142
|
+
for (const el of Array.from(
|
|
143
|
+
stage.querySelectorAll<HTMLElement>(".hk-crossfade-item"),
|
|
144
|
+
)) {
|
|
145
|
+
if (el.dataset.crossfadeKey === wanted) return el;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The natural content size of the entering generation. The item
|
|
152
|
+
* itself stretches to the shared grid track (the max of the stacked
|
|
153
|
+
* generations), so during the dissolve its own box masks the
|
|
154
|
+
* incoming content — the CHILDREN carry the natural flow height.
|
|
155
|
+
* Null when nothing measurable exists (no layout: every rect reads
|
|
156
|
+
* zero), which the decision treats as "same box".
|
|
157
|
+
*
|
|
158
|
+
* Known measure blind spots, both degrade to "same box" (the exact
|
|
159
|
+
* pre-choreography dissolve) rather than to a broken swap: text-only
|
|
160
|
+
* slots (no element children to measure) and slots whose only child
|
|
161
|
+
* is height-percentage (it stretches with the track it should be
|
|
162
|
+
* measuring). Real content sections — cards, groups, forms — all
|
|
163
|
+
* measure true.
|
|
164
|
+
*/
|
|
165
|
+
function measureIncomingNaturalSize(
|
|
166
|
+
stage: HTMLElement,
|
|
167
|
+
key: string | number,
|
|
168
|
+
): { w: number; h: number } | null {
|
|
169
|
+
const item = enteringItem(stage, key);
|
|
170
|
+
if (!item) return null;
|
|
171
|
+
const itemTop = item.getBoundingClientRect().top;
|
|
172
|
+
let w = 0;
|
|
173
|
+
let h = 0;
|
|
174
|
+
for (const child of Array.from(item.children)) {
|
|
175
|
+
const r = child.getBoundingClientRect();
|
|
176
|
+
w = Math.max(w, Math.ceil(r.width));
|
|
177
|
+
h = Math.max(h, Math.ceil(r.bottom - itemTop));
|
|
178
|
+
}
|
|
179
|
+
if (w <= 0 && h <= 0) return null;
|
|
180
|
+
return { w, h };
|
|
181
|
+
}
|
|
182
|
+
|
|
78
183
|
function applyPinned(): void {
|
|
79
184
|
const el = hostRef.value;
|
|
80
185
|
if (!el) return;
|
|
@@ -95,19 +200,28 @@ export default defineComponent({
|
|
|
95
200
|
pinned.value = { w: Math.ceil(rect.width), h: Math.ceil(rect.height) };
|
|
96
201
|
applyPinned();
|
|
97
202
|
if (settleTimer !== null) clearTimeout(settleTimer);
|
|
203
|
+
// Settle no earlier than the choreography's true end (a delayed
|
|
204
|
+
// enter runs one beat past the morph), measured from NOW so an
|
|
205
|
+
// observer event that lands mid-swap still waits the right
|
|
206
|
+
// remainder.
|
|
98
207
|
settleTimer = setTimeout(() => {
|
|
99
208
|
pinned.value = null;
|
|
100
209
|
applyPinned();
|
|
210
|
+
// The hold belongs to THIS swap alone: leaving it set would make
|
|
211
|
+
// every NESTED crossfade inside the slot inherit a full beat of
|
|
212
|
+
// delay until the next outer swap happens to rewrite the
|
|
213
|
+
// property (R2 2026-10-27 S2).
|
|
214
|
+
hostRef.value?.style.setProperty("--hk-crossfade-enter-delay", "0ms");
|
|
101
215
|
const key = pendingKey;
|
|
102
216
|
pendingKey = null;
|
|
103
217
|
if (key !== null) {
|
|
104
|
-
emit("swap", { key, previous: previousKey, durationMs:
|
|
218
|
+
emit("swap", { key, previous: previousKey, durationMs: choreoMs });
|
|
105
219
|
// The handoff consumer reads `previous` to scroll the pane back;
|
|
106
220
|
// it must be the key we just left, not the one we mounted with
|
|
107
221
|
// (audit 2026-10-23: it was never reassigned).
|
|
108
222
|
previousKey = key;
|
|
109
223
|
}
|
|
110
|
-
},
|
|
224
|
+
}, Math.max(choreoEndsAt - performance.now(), 0) + 40);
|
|
111
225
|
});
|
|
112
226
|
observer.observe(stageRef.value);
|
|
113
227
|
}
|
|
@@ -120,7 +234,43 @@ export default defineComponent({
|
|
|
120
234
|
pendingKey = next;
|
|
121
235
|
pinned.value = readStage();
|
|
122
236
|
applyPinned();
|
|
123
|
-
|
|
237
|
+
// A settle timer armed by a PREVIOUS swap that never fired (the
|
|
238
|
+
// user flipped the key again mid-choreography) must not discharge
|
|
239
|
+
// into THIS swap: it would release the just-pinned outgoing size
|
|
240
|
+
// and emit this swap as settled before its own choreography ends.
|
|
241
|
+
if (settleTimer !== null) {
|
|
242
|
+
clearTimeout(settleTimer);
|
|
243
|
+
settleTimer = null;
|
|
244
|
+
}
|
|
245
|
+
void nextTick(() => {
|
|
246
|
+
// The choreography decision (2026-10-27): hold the ENTER side one
|
|
247
|
+
// beat whenever the incoming content needs a different box. The
|
|
248
|
+
// hold must be in place before the transition STARTS: Vue adds
|
|
249
|
+
// enter-from + enter-active during the patch, and the double-rAF
|
|
250
|
+
// nextFrame removes enter-from — the style recalc where the
|
|
251
|
+
// opacity transition begins and its delay is read. This post-flush
|
|
252
|
+
// microtask always precedes that frame, so the custom property is
|
|
253
|
+
// in place in time (verified against the installed Vue 3.5
|
|
254
|
+
// runtime, R2 2026-10-27). The leave side and the size morph need
|
|
255
|
+
// no help: the observer stream naturally grows the box during the
|
|
256
|
+
// exit (the stage reads the max of both generations) and shrinks
|
|
257
|
+
// it only after the leave completes.
|
|
258
|
+
const outgoing = pinned.value ?? { w: 0, h: 0 };
|
|
259
|
+
// Without a ResizeObserver there is no morph to wait for — the
|
|
260
|
+
// box snaps to the incoming size, so a hold would only park the
|
|
261
|
+
// new content on a blind beat (R2 2026-10-27): keep master's
|
|
262
|
+
// plain dissolve on that path.
|
|
263
|
+
const measurable =
|
|
264
|
+
typeof ResizeObserver !== "undefined" && stageRef.value != null;
|
|
265
|
+
const incoming = measurable
|
|
266
|
+
? measureIncomingNaturalSize(stageRef.value!, next)
|
|
267
|
+
: null;
|
|
268
|
+
const delay = resolveEnterDelay(outgoing, incoming, motionDuration());
|
|
269
|
+
hostRef.value?.style.setProperty("--hk-crossfade-enter-delay", `${delay}ms`);
|
|
270
|
+
choreoMs = delay + motionDuration();
|
|
271
|
+
choreoEndsAt = performance.now() + choreoMs;
|
|
272
|
+
observe();
|
|
273
|
+
});
|
|
124
274
|
});
|
|
125
275
|
|
|
126
276
|
/**
|
|
@@ -163,11 +313,14 @@ export default defineComponent({
|
|
|
163
313
|
}}
|
|
164
314
|
>
|
|
165
315
|
{/* The stage is HkCrossfade: it owns the opacity dissolve
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
316
|
+
(both generations stacked in one grid cell — no exit blank,
|
|
317
|
+
no collapse flash) and the swap-stall watchdog; THIS
|
|
318
|
+
component owns the measured size morph around it — and the
|
|
319
|
+
enter sequencing: the decision above sets the delay
|
|
320
|
+
property per swap, while this prop widens the watchdog and
|
|
321
|
+
animation-bus budgets to cover the worst-case hold. */}
|
|
169
322
|
<div ref={stageRef} class="hk-tc-stage">
|
|
170
|
-
<HkCrossfade swapKey={props.swapKey}>
|
|
323
|
+
<HkCrossfade swapKey={props.swapKey} enterDelay={motionDuration()}>
|
|
171
324
|
{slots.default?.()}
|
|
172
325
|
</HkCrossfade>
|
|
173
326
|
</div>
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// standard: borderless ghost buttons at the header chrome's own size
|
|
4
4
|
// step (2rem boxes, 16px glyphs, 0.375rem gaps) — no borders, no
|
|
5
5
|
// dividers, no container chrome; the close hover is a small rounded
|
|
6
|
-
// red wash. Reads as one family with gear/search icon buttons.
|
|
6
|
+
// red wash. Reads as one family with gear/search icon buttons. The
|
|
7
|
+
// HOST keeps the canonical right edge inset
|
|
8
|
+
// (--hk-window-controls-edge-inset, declared in styles/admin-tokens)
|
|
9
|
+
// — a standard cluster never rides the frame; gluing it to the corner
|
|
10
|
+
// is the flush mode's look worn by the wrong mode (field report
|
|
11
|
+
// 2026-10-25: the chest login strip had no right padding at all).
|
|
7
12
|
//
|
|
8
13
|
// flush: edge-riding squares for thin captions — the host zeroes its
|
|
9
14
|
// own right padding (the s-header-bar--chrome contract pattern); the
|
|
@@ -15,6 +15,17 @@ import "./HkWindowControls.scss";
|
|
|
15
15
|
* with comfortable gaps. The whole top-right reads as one family of
|
|
16
16
|
* identical buttons; the close hover is a small rounded red wash.
|
|
17
17
|
* THE mode for content-height headers (3rem-class bars).
|
|
18
|
+
* HOST CONTRACT: this mode never rides the window edge (that is the
|
|
19
|
+
* flush variant's job) — the host keeps the canonical inset
|
|
20
|
+
* `var(--hk-window-controls-edge-inset)` (≤1023px:
|
|
21
|
+
* `--hk-window-controls-edge-inset-compact`) between the cluster
|
|
22
|
+
* and the frame — token values live once in styles/admin-tokens,
|
|
23
|
+
* not restated here. A standard cluster glued to the frame corner
|
|
24
|
+
* is the wrong mode's look (field report 2026-10-25: the chest
|
|
25
|
+
* login strip rendered with no end padding at all);
|
|
26
|
+
* `.s-glass-header--chrome` is the in-library implementation of the
|
|
27
|
+
* contract for admin headers, and the chat face sources its
|
|
28
|
+
* `--chrome` padding from the same tokens.
|
|
18
29
|
*
|
|
19
30
|
* - `flush` — edge-riding squares that fill the host's height (the
|
|
20
31
|
* host must zero its own right padding so the close button rides
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Static pins for the window-controls chrome-edge contract (field report
|
|
8
|
+
* 2026-10-25: the chest login strip rendered the standard HkWindowControls
|
|
9
|
+
* cluster glued to the frame corner — zero right padding — while the chat
|
|
10
|
+
* face had ruled the inset to 12px/8px and the console face kept a full
|
|
11
|
+
* 24px of dead air; three faces, three ad-hoc computations of the same
|
|
12
|
+
* number). happy-dom has no layout engine, so — adminHeaderChrome-style —
|
|
13
|
+
* these read the declarations themselves; reverting any pinned value keeps
|
|
14
|
+
* the rest of the suite green, so each pin below is the only red-if-broken
|
|
15
|
+
* evidence for its contract. A deliberate rewrite is expected to update
|
|
16
|
+
* them.
|
|
17
|
+
*
|
|
18
|
+
* Pinned here:
|
|
19
|
+
* 1. the canonical tokens exist at :root (admin-tokens.scss top level —
|
|
20
|
+
* a nested declaration would not reach host headers);
|
|
21
|
+
* 2. .s-glass-header--chrome sources BOTH bands from the tokens (the
|
|
22
|
+
* in-library implementation of the host contract);
|
|
23
|
+
* 3. HkAdminHeader actually hangs the class from the `chrome` prop
|
|
24
|
+
* (the prop without the class, or vice versa, ships the 24px inset);
|
|
25
|
+
* 4. the standard-mode HOST CONTRACT is documented on HkWindowControls
|
|
26
|
+
* (the doc is the contract's prose home — keep it in sync).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
|
|
31
|
+
const adminTokens = readFileSync(join(here, "..", "styles", "admin-tokens.scss"), "utf8");
|
|
32
|
+
const adminHeader = readFileSync(join(here, "HkAdminHeader.tsx"), "utf8");
|
|
33
|
+
const windowControls = readFileSync(join(here, "HkWindowControls.tsx"), "utf8");
|
|
34
|
+
|
|
35
|
+
/** Brace depth of `code` at character index `at` (0 = top level). */
|
|
36
|
+
function braceDepthAt(code: string, at: number): number {
|
|
37
|
+
let depth = 0;
|
|
38
|
+
for (let i = 0; i < at; i += 1) {
|
|
39
|
+
if (code[i] === "{") depth += 1;
|
|
40
|
+
else if (code[i] === "}") depth -= 1;
|
|
41
|
+
}
|
|
42
|
+
return depth;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Declarations of every rule whose prelude IS `selector` (whole-prelude
|
|
46
|
+
* match, comments stripped), with whether it sits inside an at-block. */
|
|
47
|
+
function allRuleDeclarations(
|
|
48
|
+
source: string,
|
|
49
|
+
selector: string,
|
|
50
|
+
): Array<{ nested: boolean; decls: Array<[string, string]> }> {
|
|
51
|
+
const code = source.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
52
|
+
const out: Array<{ nested: boolean; decls: Array<[string, string]> }> = [];
|
|
53
|
+
for (let at = code.indexOf(selector); at >= 0; at = code.indexOf(selector, at + 1)) {
|
|
54
|
+
const open = code.indexOf("{", at + selector.length);
|
|
55
|
+
if (open < 0) continue;
|
|
56
|
+
if (code.slice(at + selector.length, open).trim() !== "") continue;
|
|
57
|
+
let boundary = at - 1;
|
|
58
|
+
while (boundary >= 0 && !"}{;".includes(code[boundary])) boundary -= 1;
|
|
59
|
+
if (code.slice(boundary + 1, at).replace(/\s+/g, " ").trim() !== "") continue;
|
|
60
|
+
const nested = braceDepthAt(code, at) > 0;
|
|
61
|
+
const body = code.slice(open + 1, code.indexOf("}", open));
|
|
62
|
+
const decls: Array<[string, string]> = [];
|
|
63
|
+
for (const part of body.split(";")) {
|
|
64
|
+
const m = /^\s*([-a-zA-Z]+)\s*:\s*([\s\S]+?)\s*$/.exec(part);
|
|
65
|
+
if (m) decls.push([m[1], m[2].replace(/\s+/g, " ")]);
|
|
66
|
+
}
|
|
67
|
+
out.push({ nested, decls });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("window-controls chrome-edge contract (the three faces had drifted)", () => {
|
|
73
|
+
it("declares the canonical edge-inset tokens at :root top level", () => {
|
|
74
|
+
// :root is a top-level rule; a token declared inside an at-block (or
|
|
75
|
+
// renamed) silently falls every consumer back to its var() fallback —
|
|
76
|
+
// the token must exist AND sit where it cascades.
|
|
77
|
+
const rootRules = allRuleDeclarations(adminTokens, ":root").filter((r) => !r.nested);
|
|
78
|
+
expect(rootRules.length, ":root must be declared at top level").toBeGreaterThan(0);
|
|
79
|
+
const joined = rootRules.flatMap((r) => r.decls);
|
|
80
|
+
expect(joined).toContainEqual(["--hk-window-controls-edge-inset", "0.75rem"]);
|
|
81
|
+
expect(joined).toContainEqual(["--hk-window-controls-edge-inset-compact", "0.5rem"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sources the chrome header's desktop inset from the token (logical property)", () => {
|
|
85
|
+
// padding-INLINE-END, not padding-right: in a dir="rtl" host the
|
|
86
|
+
// caption row flips to the visual left and the inset must flip with
|
|
87
|
+
// it (same reasoning as the RTL-mirrored titlebar close button).
|
|
88
|
+
const rules = allRuleDeclarations(adminTokens, ".s-glass-header.s-glass-header--chrome").filter(
|
|
89
|
+
(r) => !r.nested,
|
|
90
|
+
);
|
|
91
|
+
expect(rules.length, ".s-glass-header--chrome must be declared at top level").toBe(1);
|
|
92
|
+
expect(rules[0].decls).toContainEqual([
|
|
93
|
+
"padding-inline-end",
|
|
94
|
+
"var(--hk-window-controls-edge-inset, 0.75rem)",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("sources the chrome header's ≤1023px inset from the compact token", () => {
|
|
99
|
+
const rules = allRuleDeclarations(adminTokens, ".s-glass-header.s-glass-header--chrome").filter(
|
|
100
|
+
(r) => r.nested,
|
|
101
|
+
);
|
|
102
|
+
expect(rules.length, "the compact band must override the chrome inset").toBe(1);
|
|
103
|
+
expect(rules[0].decls).toContainEqual([
|
|
104
|
+
"padding-inline-end",
|
|
105
|
+
"var(--hk-window-controls-edge-inset-compact, 0.5rem)",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("hangs the chrome class from the HkAdminHeader chrome prop", () => {
|
|
110
|
+
// The prop without the class (or the class without a producer) leaves
|
|
111
|
+
// the console face on the base 24px inset — both halves are the pin.
|
|
112
|
+
expect(adminHeader).toMatch(/chrome:\s*\{\s*type:\s*Boolean,\s*default:\s*false\s*\}/);
|
|
113
|
+
expect(adminHeader).toMatch(
|
|
114
|
+
/props\.chrome\s*\?\s*"s-glass-header--chrome"\s*:\s*""/,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("documents the standard-mode host contract on HkWindowControls", () => {
|
|
119
|
+
// The doc comment is the contract's prose home: the token name and the
|
|
120
|
+
// never-flush rule must stay stated where hosts read the component.
|
|
121
|
+
expect(windowControls).toContain("HOST CONTRACT");
|
|
122
|
+
expect(windowControls).toContain("--hk-window-controls-edge-inset");
|
|
123
|
+
expect(windowControls).toContain("never rides the window edge");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -11,7 +11,12 @@ export interface ReportedTransition extends ReportedTransitionTrack {
|
|
|
11
11
|
track(key: string): ReportedTransitionTrack;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
export function useReportedTransition(durationMs: number): ReportedTransition {
|
|
14
|
+
export function useReportedTransition(durationMs: number | (() => number)): ReportedTransition {
|
|
15
|
+
/** The live window: a static value, or a per-run provider — callers
|
|
16
|
+
* whose CSS work can outgrow the setup-time estimate (a choreographed
|
|
17
|
+
* swap whose enter side starts late) re-read the window on every
|
|
18
|
+
* arm, so one composable serves both without a re-setup. */
|
|
19
|
+
const windowMs = () => (typeof durationMs === "function" ? durationMs() : durationMs);
|
|
15
20
|
interface TrackState {
|
|
16
21
|
handle: AnimationHandle | null;
|
|
17
22
|
timer: CronHandle | null;
|
|
@@ -39,15 +44,16 @@ export function useReportedTransition(durationMs: number): ReportedTransition {
|
|
|
39
44
|
|
|
40
45
|
function runTrack(key: string) {
|
|
41
46
|
const t = getTrack(key);
|
|
47
|
+
const ms = windowMs();
|
|
42
48
|
t.timer?.disconnect();
|
|
43
49
|
t.timer = null;
|
|
44
50
|
t.handle?.disconnect();
|
|
45
|
-
t.handle = reportTransition(
|
|
51
|
+
t.handle = reportTransition(ms);
|
|
46
52
|
t.timer = scheduleCronAfter(() => {
|
|
47
53
|
t.timer = null;
|
|
48
54
|
t.handle?.disconnect();
|
|
49
55
|
t.handle = null;
|
|
50
|
-
},
|
|
56
|
+
}, ms);
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
function cancelAll() {
|
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
/* ── Layout tokens ─ */
|
|
10
10
|
--s-footer-height: 3rem;
|
|
11
11
|
--s-header-height: 3rem;
|
|
12
|
+
|
|
13
|
+
/* ── Window-control chrome insets ─
|
|
14
|
+
* The right padding a host title-bar header keeps between the standard
|
|
15
|
+
* HkWindowControls cluster and the window edge (canonical home since
|
|
16
|
+
* the chest login strip shipped the cluster glued to the frame corner —
|
|
17
|
+
* field report 2026-10-25). The standard cluster NEVER rides the edge
|
|
18
|
+
* (that is the flush variant's job), so every chrome face sources its
|
|
19
|
+
* padding from here instead of restating half-of-the-header-inset per
|
|
20
|
+
* face — the faces had drifted three ways (chat 12px/8px, console a
|
|
21
|
+
* full 24px of dead air, auth 0px). Values are the 2026-10-23 ruling's
|
|
22
|
+
* halvings of the base/compact header insets, promoted to tokens. */
|
|
23
|
+
--hk-window-controls-edge-inset: 0.75rem; /* desktop: half of the 24px base header inset */
|
|
24
|
+
--hk-window-controls-edge-inset-compact: 0.5rem; /* ≤1023px: half of the 16px compact header inset */
|
|
12
25
|
/* Layout/type/motion scale tokens (--space-*, --text-*, --blur-*, --z-*,
|
|
13
26
|
* --duration-*, --ease-*) used to be mirrored here ("keep both in sync")
|
|
14
27
|
* — they now live once in styles/theme/scale.scss, which every entry
|
|
@@ -437,6 +450,27 @@
|
|
|
437
450
|
padding: 0 var(--space-16);
|
|
438
451
|
}
|
|
439
452
|
|
|
453
|
+
/* Chrome variant (HkAdminHeader `chrome` prop — the desktop face whose
|
|
454
|
+
* header IS the window title bar): the inline-end inset follows the
|
|
455
|
+
* window-controls contract token, not the content inset — the default
|
|
456
|
+
* 24px reads as dead air next to the standard HkWindowControls cluster
|
|
457
|
+
* (the same 2026-10-23 user direction that halved the chat face's gap).
|
|
458
|
+
* LOGICAL property, like the RTL-mirrored titlebar close button (the
|
|
459
|
+
* [dir="rtl"] .hk-titlebar-btn-close rule in components/HkTitleBar.scss):
|
|
460
|
+
* in a dir="rtl" host the caption row flips to the visual left and the
|
|
461
|
+
* inset must flip with it, not stay on the physical right. (0,2,0)
|
|
462
|
+
* outranks the base rule and the compact band alike, so the chrome
|
|
463
|
+
* inset wins regardless of load order. */
|
|
464
|
+
.s-glass-header.s-glass-header--chrome {
|
|
465
|
+
padding-inline-end: var(--hk-window-controls-edge-inset, 0.75rem);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
@media (max-width: 1023px) {
|
|
469
|
+
.s-glass-header.s-glass-header--chrome {
|
|
470
|
+
padding-inline-end: var(--hk-window-controls-edge-inset-compact, 0.5rem);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
440
474
|
/* ── Balanced header columns (the ONE three-zone header layout) ─────────
|
|
441
475
|
* The shared geometry behind every top bar that owns a header-centre
|
|
442
476
|
* group: the frontend chat header (chest ChatLayout's .s-header-bar-*)
|