@celestia-island/hikari 0.55.205 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.205",
3
+ "version": "0.55.206",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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
- expect(src).toContain("useReportedTransition(REPORT_MS)");
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
- const report = useReportedTransition(REPORT_MS);
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, CROSSFADE_WATCHDOG_MS);
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: inside the dialog (host class `hk-settings-host`)
8
- // the shell gets a STABLE height sized under the modal body scroller's
9
- // inner padding, so HkModal's floating overlay scrollbar stays idle and
10
- // the pane's own edge bar is the only scrollbar. The negative-margin
11
- // trick puts that edge — and its scrollbar — flush with the modal
12
- // window's right edge; the padding re-applies the content inset between
13
- // the scrollbar and the cards. A page-embedded body (no host class)
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 scrollbar
123
- sits flush at the window edge; re-apply the inset between the bar and
124
- the cards. HkModal retunes the body padding to 1rem under 768px —
125
- follow it or the pane overshoots the edge. */
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
- padding-right: var(--space-20);
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
- pane.scrollTop = 500;
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 = pane.scrollTop === 0;
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). Inside the dialog the modal body's own scroller is made
44
- * mathematically idle by the host height rule (see the SCSS); on a page
45
- * the host manages its own layout.
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
- const paneRef = ref<HTMLElement | null>(null);
87
- // Section switches restart the pane from the top.
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
- if (paneRef.value) paneRef.value.scrollTop = 0;
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
- <nav class="hk-settings__rail" aria-label={props.navLabel}>
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
- </nav>
127
- <div class="hk-settings__pane" ref={paneRef}>
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
- </div>
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 edge bar is the
146
- * only scrollbar (the wowsp-proven contract). Width defaults to the
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 direction (2026-10-22) asks of every swap surface: when the
13
- * bound state key changes, the OLD content fades out, the container's
14
- * width/height smoothly animate to the incoming content's natural
15
- * size, and the NEW content fades in — one 300ms default, CSS-driven
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: props.duration });
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
- }, motionDuration() + 40);
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
- void nextTick(() => observe());
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
- (both generations stacked in one grid cell — no exit blank,
167
- no collapse flash) and the swap-stall watchdog; THIS
168
- component owns the measured size morph around it. */}
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>
@@ -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(durationMs);
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
- }, durationMs);
56
+ }, ms);
51
57
  }
52
58
 
53
59
  function cancelAll() {