@celestia-island/hikari 0.55.236 → 0.55.237

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.236",
3
+ "version": "0.55.237",
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",
@@ -424,6 +424,41 @@ describe("HkFaceShell rail chrome: toggle placement and the clip wrapper", () =>
424
424
  expect(clip!.querySelector(".sidebar-mock"), "the sidebar slot renders in the clip").toBeTruthy();
425
425
  });
426
426
 
427
+ it("hands the sidebar slot to the rail/full generations through the SWAP", async () => {
428
+ // The density dress rides the swap's GENERATION, never the host root:
429
+ // a root-level data-rail would restyle the frozen leaving generation
430
+ // mid-dissolve and reintroduce the squeeze this wave fixes. The root
431
+ // keeps data-rail as a STATE MARKER only (hosts/tests read it).
432
+ vi.useFakeTimers();
433
+ vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
434
+ vi.stubGlobal("cancelAnimationFrame", () => {});
435
+ const c = mountShell({ sidebarCollapsible: true });
436
+ const clip = () =>
437
+ c.panel().querySelector(":scope > .hk-sidebar-rail-clip") as HTMLElement;
438
+ const gens = () =>
439
+ Array.from(clip().querySelectorAll<HTMLElement>(".hk-sidebar-rail-generation"));
440
+
441
+ expect(clip().querySelector(".hk-sidebar-rail-swap"), "the swap mounts inside the clip").toBeTruthy();
442
+ expect(gens()).toHaveLength(1);
443
+ expect(gens()[0]!.hasAttribute("data-rail"), "expanded carries no rail density").toBe(false);
444
+ expect(gens()[0]!.style.width, "the generation pins the shell's sidebar width").toBe("224px");
445
+ expect(c.aside().hasAttribute("data-rail")).toBe(false);
446
+
447
+ c.toggle().click();
448
+ await vi.advanceTimersByTimeAsync(100); // mid-dissolve
449
+ const mid = gens();
450
+ expect(mid.length, "both generations share the swap's cell").toBe(2);
451
+ expect(mid[0]!.hasAttribute("data-rail"), "the leaving copy keeps the full dress").toBe(false);
452
+ expect(mid[0]!.style.width).toBe("224px");
453
+ expect(mid[1]!.hasAttribute("data-rail"), "the entering copy takes the rail dress").toBe(true);
454
+ expect(mid[1]!.style.width, "the rail generation pins the collapsed width").toBe("56px");
455
+ // The slot ran for the entering generation at its density; the root
456
+ // marker reads rail while the DENSITY lives on the generation.
457
+ expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
458
+ expect(c.aside().hasAttribute("data-rail"), "the root keeps the state marker").toBe(true);
459
+ vi.unstubAllGlobals();
460
+ });
461
+
427
462
  it("does not touch the legacy sidebar path (no clip wrapper, no rail classes)", () => {
428
463
  const c = mountShell();
429
464
  expect(c.container.querySelector(".hk-sidebar-rail-clip")).toBeFalsy();
@@ -2,6 +2,7 @@ import { computed, defineComponent, ref, watch, type PropType } from "vue";
2
2
  import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
3
3
  import { useI18n } from "../i18n/context";
4
4
  import { useSidebarRail } from "../composables/useSidebarRail";
5
+ import HkSidebarRailSwap from "./HkSidebarRailSwap";
5
6
  import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
6
7
 
7
8
  /** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
@@ -435,6 +436,10 @@ export const HkFaceShell = defineComponent({
435
436
  <aside
436
437
  ref={railHostRef}
437
438
  class="hk-sidebar-rail-host"
439
+ // STATE marker only (hosts/tests read "the rail layout is
440
+ // active"): the rail DENSITY dress itself rides the swap's
441
+ // generation inside the clip, so it can freeze on the
442
+ // leaving generation during the dissolve.
438
443
  data-rail={railActive.value || undefined}
439
444
  data-peeking={rail.peeking.value || undefined}
440
445
  // Transient rail -> expanded window (useSidebarRail.pinning):
@@ -466,13 +471,29 @@ export const HkFaceShell = defineComponent({
466
471
  border), so the slot's content clips inside this
467
472
  wrapper — full rows stay inside rail width while the
468
473
  panel width animates. The toggle stays a DIRECT child
469
- of the panel: it must never be clipped. */}
474
+ of the panel: it must never be clipped. Inside the
475
+ clip, the crossfade handoff (HkSidebarRailSwap)
476
+ exchanges the rail and full generations through the
477
+ tab-panel dissolve at width-pinned boxes, so the
478
+ slot's content never re-lays-out at the panel's
479
+ transient width — each generation sees its own
480
+ `collapsed` dress, and the leaving copy stays
481
+ frozen (and unclickable) while it dissolves. */}
470
482
  <div class="hk-sidebar-rail-clip">
471
- {slots.sidebar({
472
- collapsed: railActive.value && !rail.peeking.value,
473
- onNavigate: onSidebarNavigate,
474
- peeking: rail.peeking.value,
475
- })}
483
+ <HkSidebarRailSwap
484
+ rail={railActive.value && !rail.peeking.value}
485
+ railWidth={props.sidebarCollapsedWidth}
486
+ expandedWidth={props.sidebarWidth}
487
+ >
488
+ {/* NOT `rail` — the scoped param would shadow the
489
+ useSidebarRail handle the outer bindings read. */}
490
+ {({ rail: density }: { rail: boolean }) =>
491
+ slots.sidebar?.({
492
+ collapsed: density,
493
+ onNavigate: onSidebarNavigate,
494
+ peeking: rail.peeking.value,
495
+ })}
496
+ </HkSidebarRailSwap>
476
497
  </div>
477
498
  <HkSidebarRailToggle expanded={!railActive.value} onToggle={rail.toggle} />
478
499
  </div>
@@ -53,6 +53,11 @@ function mountSidebar(props: Record<string, unknown> = {}) {
53
53
  aside: () => container.querySelector("aside.hk-sidebar") as HTMLElement,
54
54
  panel: () => container.querySelector("nav.hk-sidebar-panel") as HTMLElement,
55
55
  toggle: () => container.querySelector(".hk-sidebar-rail-toggle button") as HTMLButtonElement,
56
+ // The ACTIVE (entering/settled) generation of the rail swap — the
57
+ // last-mounted one; during a dissolve there are two.
58
+ generation: () =>
59
+ container.querySelectorAll<HTMLElement>(".hk-sidebar-rail-generation"),
60
+ swap: () => container.querySelector(".hk-sidebar-rail-swap") as HTMLElement,
56
61
  };
57
62
  }
58
63
 
@@ -91,7 +96,13 @@ describe("HkSidebar rail capability", () => {
91
96
  await vi.advanceTimersByTimeAsync(0);
92
97
  expect(c.aside().style.width).toBe("64px");
93
98
  expect(c.panel().style.width).toBe("64px");
94
- expect(c.aside().hasAttribute("data-collapsed")).toBe(true);
99
+ // The density moved OFF the root onto the swap's generation: a root
100
+ // attribute flips with the state and would restyle the frozen
101
+ // leaving generation mid-dissolve (the squeeze this wave fixes).
102
+ expect(c.aside().hasAttribute("data-collapsed"), "the rail-mode root never carries the density").toBe(false);
103
+ expect(c.generation().length).toBeGreaterThan(0);
104
+ expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail"), "the generation carries the rail density").toBe(true);
105
+ expect(c.generation()[c.generation().length - 1]!.style.width).toBe("64px");
95
106
  });
96
107
 
97
108
  it("peeks as an overlay: the host width never moves", async () => {
@@ -117,13 +128,13 @@ describe("HkSidebar rail capability", () => {
117
128
  await vi.advanceTimersByTimeAsync(0);
118
129
  expect(c.slotParams[c.slotParams.length - 1]!.collapsed).toBe(true);
119
130
  expect(c.slotParams[c.slotParams.length - 1]!.peeking).toBe(false);
120
- expect(c.aside().hasAttribute("data-collapsed"), "rail density while collapsed").toBe(true);
131
+ expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail"), "rail density while collapsed").toBe(true);
121
132
 
122
133
  c.aside().dispatchEvent(new Event("pointerenter"));
123
134
  await vi.advanceTimersByTimeAsync(400);
124
135
  expect(c.slotParams[c.slotParams.length - 1]!.collapsed, "full rows during the peek").toBe(false);
125
136
  expect(c.slotParams[c.slotParams.length - 1]!.peeking).toBe(true);
126
- expect(c.aside().hasAttribute("data-collapsed"), "no rail density on the peek panel").toBe(false);
137
+ expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail"), "no rail density on the peek generation").toBe(false);
127
138
 
128
139
  // closePeek (the nav-row hook) closes the drawer.
129
140
  c.slotParams[c.slotParams.length - 1]!.closePeek();
@@ -198,11 +209,38 @@ describe("HkSidebar rail chrome: toggle placement and the clip wrapper", () => {
198
209
  expect(clip, "the content clip lives inside the panel").toBeTruthy();
199
210
  expect(toggle, "the toggle is a direct child of the panel").toBeTruthy();
200
211
  expect(clip!.contains(toggle!), "the toggle is NOT clipped by the wrapper").toBe(false);
201
- // The bands (header/body/footer) are inside the clip.
212
+ // The bands (header/body/footer) are inside the clip — now inside the
213
+ // swap's generation, one per density.
202
214
  expect(clip!.querySelector(".hk-sidebar-body")).toBeTruthy();
215
+ expect(clip!.querySelector(".hk-sidebar-rail-swap")).toBeTruthy();
203
216
  expect(clip!.contains(c.aside())).toBe(false);
204
217
  });
205
218
 
219
+ it("collapses through the SWAP: both densities coexist, each pinned at its own width", async () => {
220
+ // Freeze rAF to hold the dissolve mid-flight (HkCrossfade.test's
221
+ // timing model): the leaving FULL generation stays frozen at its
222
+ // dress and width while the entering RAIL generation takes over —
223
+ // no generation ever renders at the panel's transient width.
224
+ vi.useFakeTimers();
225
+ vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
226
+ vi.stubGlobal("cancelAnimationFrame", () => {});
227
+ const c = mountSidebar({ width: "240px", collapsible: true, collapsedWidth: "64px" });
228
+
229
+ c.toggle().click();
230
+ await vi.advanceTimersByTimeAsync(100);
231
+
232
+ const gens = c.generation();
233
+ expect(gens.length, "both generations share the swap's cell mid-dissolve").toBe(2);
234
+ const [leaving, entering] = gens;
235
+ expect(leaving!.hasAttribute("data-rail"), "the leaving copy keeps the full dress").toBe(false);
236
+ expect(leaving!.style.width).toBe("240px");
237
+ expect(entering!.hasAttribute("data-rail"), "the entering copy takes the rail dress").toBe(true);
238
+ expect(entering!.style.width).toBe("64px");
239
+ // The band content lives inside the entering generation.
240
+ expect(entering!.querySelector(".hk-sidebar-body")).toBeTruthy();
241
+ vi.unstubAllGlobals();
242
+ });
243
+
206
244
  it("renders the legacy panel with NO clip wrapper (byte-identical non-rail path)", () => {
207
245
  const legacy = mountSidebar({ width: "240px" });
208
246
  expect(legacy.panel().querySelector(".hk-sidebar-rail-clip")).toBeFalsy();
@@ -293,7 +331,7 @@ describe("HkSidebar hover suppression: a pointer on the toggle never peeks", ()
293
331
  c.aside().dispatchEvent(pointerMove(110, 210));
294
332
  await vi.advanceTimersByTimeAsync(400 * 4);
295
333
  expect(c.aside().hasAttribute("data-peeking"), "parked on the toggle: no peek").toBe(false);
296
- expect(c.aside().hasAttribute("data-collapsed")).toBe(true);
334
+ expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail")).toBe(true);
297
335
 
298
336
  // Moving off the toggle (still on the rail) re-arms the normal dwell.
299
337
  c.aside().dispatchEvent(pointerMove(400, 500));
@@ -314,7 +352,7 @@ describe("HkSidebar hover suppression: a pointer on the toggle never peeks", ()
314
352
  c.aside().dispatchEvent(pointerEnterAt(110, 210));
315
353
  await vi.advanceTimersByTimeAsync(400 * 4);
316
354
  expect(c.aside().hasAttribute("data-peeking"), "enter on the toggle arms nothing").toBe(false);
317
- expect(c.aside().hasAttribute("data-collapsed")).toBe(true);
355
+ expect(c.generation()[c.generation().length - 1]!.hasAttribute("data-rail")).toBe(true);
318
356
  });
319
357
 
320
358
  it("evaluates suppression on ENTER: entering elsewhere on the rail still peeks", async () => {
@@ -77,7 +77,15 @@
77
77
  flex-shrink: 0;
78
78
  }
79
79
 
80
- .hk-sidebar[data-collapsed] {
80
+ /* The rail density dress, keyed off EITHER density carrier: the legacy
81
+ * presentational rail (no collapsible prop — the attr rides the root,
82
+ * instant, no animation to mask) or the swapped GENERATION of the rail
83
+ * kit (HkSidebarRailSwap) — the generation's [data-rail] flips with the
84
+ * swap key, so the leaving generation keeps its dress for the dissolve
85
+ * while the entering one takes the new one. A root-level attr cannot do
86
+ * that: it would restyle the frozen leaving copy mid-dissolve. */
87
+ .hk-sidebar[data-collapsed],
88
+ .hk-sidebar-rail-generation[data-rail] {
81
89
  .hk-sidebar-header {
82
90
  justify-content: center;
83
91
  padding: var(--space-12) var(--space-8);
@@ -75,8 +75,14 @@ describe("HkSidebar scroll end-cap", () => {
75
75
  });
76
76
 
77
77
  it("keeps the same tail on the collapsed body", () => {
78
- const body = ruleBody(css, ".hk-sidebar[data-collapsed] .hk-sidebar-body");
79
- expect(body, "the collapsed body rule exists").not.toBeNull();
78
+ // The density dress is keyed off EITHER carrier — the legacy root's
79
+ // [data-collapsed] or the rail swap generation's [data-rail] (the
80
+ // generation attr freezes on the leaving copy for the dissolve, a
81
+ // root attr cannot). ruleBody needs the arm its brace follows, so the
82
+ // legacy arm is asserted as text and the body read from the group.
83
+ expect(css).toContain(".hk-sidebar[data-collapsed] .hk-sidebar-body");
84
+ const body = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-body");
85
+ expect(body, "the collapsed body rule exists (grouped density carriers)").not.toBeNull();
80
86
  expect(body).toContain("padding-block: var(--space-12) var(--hk-sidebar-scroll-tail)");
81
87
  });
82
88
 
@@ -3,6 +3,7 @@ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } fro
3
3
  import "./HkSidebar.scss";
4
4
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
5
5
  import { useSidebarRail } from "../composables/useSidebarRail";
6
+ import HkSidebarRailSwap from "./HkSidebarRailSwap";
6
7
  import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
7
8
 
8
9
  export default defineComponent({
@@ -76,6 +77,11 @@ export default defineComponent({
76
77
  : props.collapsed,
77
78
  );
78
79
  const peeking = computed(() => props.collapsible && !props.mobile && rail.peeking.value);
80
+ // The rail DENSITY (icon-only dress): collapsed layout, not peeking.
81
+ // In rail mode this is the swap key — the density rides the swapped
82
+ // GENERATION (frozen on the leaving copy for the dissolve), never the
83
+ // host root, so nothing re-lays-out at the panel's transient width.
84
+ const railDensity = computed(() => layoutCollapsed.value && !peeking.value);
79
85
  // The host's inline width IS the layout footprint — rail width while
80
86
  // collapsed OR peeking (the peek is an overlay; the layout must not
81
87
  // shift), full width while expanded.
@@ -88,21 +94,39 @@ export default defineComponent({
88
94
  props.mobile ? undefined : layoutCollapsed.value && !peeking.value ? props.collapsedWidth : props.width,
89
95
  );
90
96
 
91
- // Persistent layout surface — attach the overlay scrollbar (shared
92
- // chrome) once and detach on unmount.
97
+ // Persistent layout surface — the overlay scrollbar (shared chrome)
98
+ // attaches to the scrolling body band. The rail swap mounts each
99
+ // generation's body FRESH (the leaving copy is frozen and unmounts
100
+ // with the dissolve), so the attach is a re-attach. Mechanism
101
+ // notes (probed against Vue 3.5): a flip patches the refs
102
+ // old -> null -> new in sequence — the unmount of the leaving copy
103
+ // nulls the shared ref before the entering copy's mount re-points
104
+ // it — so the guard below swallows the transient null and the FINAL
105
+ // state always lands on the entering body. The element tracker keeps
106
+ // a same-element watcher fire a no-op (no track churn on plain
107
+ // re-renders); a flip hands the single handle to the new body, which
108
+ // leaves the fading copy without a track for the dissolve — it is
109
+ // pointer-dead and dissolving, so that is cosmetic.
93
110
  const bodyRef = ref<HTMLElement>();
94
111
  // Positioned wrapper containing ONLY the body — the rail host (the
95
112
  // panel also contains the header/footer bands).
96
113
  const bodyWrapRef = ref<HTMLElement>();
97
114
  let bodyScrollbar: OverlayScrollbarHandle | null = null;
115
+ let bodyScrollbarEl: HTMLElement | null = null;
98
116
 
99
- onMounted(() => {
117
+ const attachBodyScrollbar = () => {
100
118
  if (!bodyRef.value) return;
119
+ if (bodyScrollbar && bodyScrollbarEl === bodyRef.value) return;
120
+ bodyScrollbar?.detach();
121
+ bodyScrollbarEl = bodyRef.value;
101
122
  bodyScrollbar = attachOverlayScrollbars(bodyRef.value, {
102
123
  axis: "vertical",
103
124
  host: bodyWrapRef.value,
104
125
  });
105
- });
126
+ };
127
+
128
+ onMounted(attachBodyScrollbar);
129
+ watch([bodyRef, bodyWrapRef], attachBodyScrollbar);
106
130
 
107
131
  onBeforeUnmount(() => {
108
132
  bodyScrollbar?.detach();
@@ -142,27 +166,32 @@ export default defineComponent({
142
166
  rail.endPinning();
143
167
  };
144
168
 
145
- /** The panel's inner bands. In rail mode they live inside
146
- * .hk-sidebar-rail-clip — the rail panel no longer clips (the toggle
147
- * straddles its content-facing border), so the wrapper is what keeps
148
- * full-width rows inside the 56px footprint while widths animate.
149
- * Without rail chrome they stay direct children of the panel, exactly
150
- * as before (and the toggle is a direct child of the panel in both
151
- * cases where it exists, so it can never be clipped). */
152
- const renderBands = () => (
169
+ /** The panel's inner bands, parameterized by the density so the
170
+ * rail swap can render each generation at its OWN dress: the swap
171
+ * invokes this with the entering generation's flag while the leaving
172
+ * copy stays frozen at its last render (per-generation density by
173
+ * construction). In rail mode the bands live inside the swap, which
174
+ * lives inside .hk-sidebar-rail-clip — the rail panel no longer
175
+ * clips (the toggle straddles its content-facing border), so the
176
+ * wrapper is what keeps full-width rows inside the 56px footprint
177
+ * while widths animate. Without rail chrome they stay direct
178
+ * children of the panel, exactly as before (and the toggle is a
179
+ * direct child of the panel in both cases where it exists, so it
180
+ * can never be clipped). */
181
+ const renderBands = (collapsed: boolean) => (
153
182
  <>
154
183
  {slots.header ? (
155
184
  <header class="hk-sidebar-header">{slots.header()}</header>
156
185
  ) : null}
157
186
  <div ref={bodyWrapRef} class="hk-sidebar-body-wrap">
158
187
  <div ref={bodyRef} class="hk-sidebar-body">
159
- {/* Scoped params (ignored by plain slots): the LIVE rail
160
- state so a host renders icon-only rows while collapsed
161
- and full rows during a peek, plus the peek closer — a
162
- nav row click should dismiss the peek like the shell's
163
- onNavigate does. */}
188
+ {/* Scoped params (ignored by plain slots): the density that
189
+ owns THIS generation so a host renders icon-only rows
190
+ while collapsed and full rows during a peek, plus the
191
+ peek closer — a nav row click should dismiss the peek
192
+ like the shell's onNavigate does. */}
164
193
  {slots.default?.({
165
- collapsed: layoutCollapsed.value && !peeking.value,
194
+ collapsed,
166
195
  peeking: peeking.value,
167
196
  closePeek: rail.closePeek,
168
197
  })}
@@ -179,10 +208,13 @@ export default defineComponent({
179
208
  ref={hostRef}
180
209
  class={railChrome.value ? "hk-sidebar hk-sidebar-rail-host" : "hk-sidebar"}
181
210
  data-mobile={props.mobile || undefined}
182
- // NOT during a peek: [data-collapsed] carries the rail density
183
- // (centered header, tight paddings) and the peek renders FULL
184
- // rows at full width — the collapsed dress would mis-style it.
185
- data-collapsed={layoutCollapsed.value && !peeking.value || undefined}
211
+ // LEGACY density carrier only (no rail chrome): [data-collapsed]
212
+ // styles the presentational 56px rail exactly as before. In rail
213
+ // mode the density moves onto the swap's GENERATION — a root-level
214
+ // attribute flips with the state and would restyle the frozen
215
+ // leaving generation mid-dissolve (the squeeze this wave fixes),
216
+ // and a peek renders full rows at full width anyway.
217
+ data-collapsed={!railChrome.value && railDensity.value || undefined}
186
218
  data-peeking={peeking.value || undefined}
187
219
  // Transient rail -> expanded window (useSidebarRail.pinning): the
188
220
  // panel and the panel's inner widths snap while THIS host's width
@@ -211,9 +243,23 @@ export default defineComponent({
211
243
  style={{ width: props.mobile ? undefined : panelWidth.value }}
212
244
  >
213
245
  {railChrome.value ? (
214
- <div class="hk-sidebar-rail-clip">{renderBands()}</div>
246
+ /* The crossfade handoff (HkSidebarRailSwap): each generation
247
+ * renders the bands at its OWN dress and width while the clip
248
+ * windows the panel's animating width — no reflow, no wrap,
249
+ * a pure opacity exchange (the tab-panel dissolve). */
250
+ <div class="hk-sidebar-rail-clip">
251
+ <HkSidebarRailSwap
252
+ rail={railDensity.value}
253
+ railWidth={props.collapsedWidth}
254
+ expandedWidth={props.width}
255
+ >
256
+ {/* NOT `rail` — the scoped param would shadow the
257
+ useSidebarRail handle the outer bindings read. */}
258
+ {({ rail: density }: { rail: boolean }) => renderBands(density)}
259
+ </HkSidebarRailSwap>
260
+ </div>
215
261
  ) : (
216
- renderBands()
262
+ renderBands(railDensity.value)
217
263
  )}
218
264
  {railChrome.value && (
219
265
  <HkSidebarRailToggle
@@ -20,6 +20,16 @@
20
20
  * itself cannot clip any more (see below), so
21
21
  * this wrapper preserves the rail-width clip
22
22
  * during the width transitions.
23
+ * .hk-sidebar-rail-swap the rail/full handoff inside the clip
24
+ * (HkSidebarRailSwap): two width-pinned
25
+ * generations exchanged through HkCrossfade's
26
+ * opacity dissolve, so no content ever reflows
27
+ * at the panel's transient width.
28
+ * .hk-sidebar-rail-generation ONE density's content (rail or full),
29
+ * width-pinned inline by the component; the
30
+ * active density carries [data-rail] HERE (never
31
+ * on the host root — a root attr would restyle
32
+ * the frozen leaving generation mid-dissolve).
23
33
  * .hk-sidebar-rail-toggle the hover-reveal circular chevron button
24
34
  * (HkSidebarRailToggle) STRADDLING the panel's
25
35
  * content-facing border — its center ON the
@@ -129,6 +139,40 @@
129
139
  overflow: hidden;
130
140
  }
131
141
 
142
+ /* ── the swap: crossfade handoff between rail and full generations ────
143
+ * (HkSidebarRailSwap, user direction 2026-10-30.) The clip windows; the
144
+ * swap keeps every generation at its ORIGINAL width so nothing reflows
145
+ * while the panel's width animates; HkCrossfade (the tab-panel dissolve)
146
+ * exchanges the two generations through pure opacity. The leaving
147
+ * generation is frozen at its last render and drops pointer events for
148
+ * the dissolve (HkCrossfade's leave rule).
149
+ *
150
+ * The no-reflow pin, precisely: the generations carry their widths
151
+ * INLINE (the component), and the grid inside (HkCrossfade's one-cell
152
+ * stack) sizes its auto track to max-content — a track that NEVER
153
+ * shrinks below the widest mounted generation when the container runs
154
+ * narrower — so both generations lay out at their OWN widths however
155
+ * wide the clip currently is. (The swap host itself DOES track the
156
+ * animating clip width — a non-stretched flex child's fit-content is
157
+ * clamped by the available space — which is fine: the host box is not
158
+ * the layout reference for anything inside it. align-self simply keeps
159
+ * the host from STRETCHING, which would clip nothing but would invite
160
+ * percentage-width content to follow the animation.) The generations
161
+ * fill the swap's height so the bands' flex column (spacer, footer at
162
+ * the bottom) behaves exactly as it did as a direct child of the clip. */
163
+ .hk-sidebar-rail-clip > .hk-sidebar-rail-swap {
164
+ flex: 1;
165
+ min-height: 0;
166
+ align-self: flex-start;
167
+ }
168
+
169
+ .hk-sidebar-rail-swap .hk-sidebar-rail-generation {
170
+ height: 100%;
171
+ min-height: 0;
172
+ display: flex;
173
+ flex-direction: column;
174
+ }
175
+
132
176
  /* The peek's only shadow: cast toward the content side. Offsets cannot
133
177
  * flip via logical properties — mirror the cast in RTL (the house
134
178
  * [dir="rtl"] override HkSidebar's mobile panel uses). */
@@ -131,6 +131,31 @@ describe("HkSidebarRail geometry contract", () => {
131
131
  expect(clip).toContain("flex-direction: column");
132
132
  });
133
133
 
134
+ it("the swap holds every generation at its OWN width (the no-reflow pin)", () => {
135
+ // The crossfade handoff (HkSidebarRailSwap): the generations carry
136
+ // their widths INLINE and HkCrossfade's auto track never shrinks
137
+ // below the widest mounted generation, so neither generation is ever
138
+ // laid out at the panel's transient width while it animates — the
139
+ // structural guarantee behind "content is never squeezed". The swap
140
+ // host itself only needs to not STRETCH with the animating clip
141
+ // (align-self): the host box is not the layout reference for the
142
+ // generations inside it.
143
+ const swap = ruleBody(css, ".hk-sidebar-rail-clip > .hk-sidebar-rail-swap");
144
+ expect(swap, "the swap sizing rule exists").not.toBeNull();
145
+ expect(swap).toContain("flex: 1");
146
+ expect(swap).toContain("min-height: 0");
147
+ expect(swap).toContain("align-self: flex-start");
148
+
149
+ // Each generation is the flex column the bands expect, filling the
150
+ // swap's height (spacer + footer-at-bottom behave as they did as
151
+ // direct children of the clip).
152
+ const gen = ruleBody(css, ".hk-sidebar-rail-swap .hk-sidebar-rail-generation");
153
+ expect(gen, "the generation rule exists").not.toBeNull();
154
+ expect(gen).toContain("height: 100%");
155
+ expect(gen).toContain("display: flex");
156
+ expect(gen).toContain("flex-direction: column");
157
+ });
158
+
134
159
  it("straddles the panel border: half the toggle outside, center on the border line", () => {
135
160
  const toggle = ruleBody(css, ".hk-sidebar-rail-toggle");
136
161
  expect(toggle, "the toggle rule exists").not.toBeNull();
@@ -0,0 +1,254 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
3
+
4
+ import HkSidebarRailSwap from "./HkSidebarRailSwap";
5
+ import { CROSSFADE_WATCHDOG_MS } from "./HkCrossfade";
6
+
7
+ /**
8
+ * HkSidebarRailSwap contract (2026-10-30) — the rail/full content handoff.
9
+ *
10
+ * What this file pins:
11
+ *
12
+ * 1. WIDTH PINS: each generation renders at ITS density's width (the
13
+ * rail/expanded props) with the density attribute (`data-rail`) on the
14
+ * GENERATION — never the host root — so the leaving generation keeps
15
+ * its dress while the entering one takes the new one.
16
+ * 2. THE DISSOLVE is HkCrossfade's simultaneous exchange (the tab-panel
17
+ * mechanism): mid-swap both generations share the swap's single grid
18
+ * cell, the leaving copy frozen at its last render and pointer-dead.
19
+ * 3. The scoped `{ rail }` slot param: the entering generation is invoked
20
+ * with the new value; the leaving one keeps what it last rendered.
21
+ * 4. A healthy swap settles on exactly one generation.
22
+ *
23
+ * Timing model: identical to HkCrossfade.test — happy-dom resolves no CSS
24
+ * transition durations, so a working rAF completes the swap inside
25
+ * fake-timer advances, and freezing rAF holds both generations mounted.
26
+ */
27
+
28
+ const mounts: App[] = [];
29
+ const containers: HTMLElement[] = [];
30
+
31
+ interface SwapSlotParams {
32
+ rail: boolean;
33
+ }
34
+
35
+ interface Harness {
36
+ container: HTMLElement;
37
+ swap(): HTMLElement;
38
+ generations(): HTMLElement[];
39
+ params: SwapSlotParams[];
40
+ set(rail: boolean): Promise<void>;
41
+ }
42
+
43
+ function mountSwap(opts: { rail?: boolean; railWidth?: string; expandedWidth?: string } = {}): Harness {
44
+ const params: SwapSlotParams[] = [];
45
+ const container = document.createElement("div");
46
+ document.body.appendChild(container);
47
+ containers.push(container);
48
+
49
+ const rail = ref(opts.rail ?? false);
50
+ const Wrapper = defineComponent({
51
+ name: "RailSwapHarness",
52
+ setup() {
53
+ return () =>
54
+ h(
55
+ HkSidebarRailSwap,
56
+ {
57
+ rail: rail.value,
58
+ railWidth: opts.railWidth ?? "56px",
59
+ expandedWidth: opts.expandedWidth ?? "240px",
60
+ },
61
+ {
62
+ default: (p: SwapSlotParams) => {
63
+ params.push(p);
64
+ return h("p", { class: "body" }, p.rail ? "RAIL" : "FULL");
65
+ },
66
+ },
67
+ );
68
+ },
69
+ });
70
+ const app = createApp(Wrapper);
71
+ mounts.push(app);
72
+ app.mount(container);
73
+ return {
74
+ container,
75
+ swap: () => container.querySelector(".hk-sidebar-rail-swap") as HTMLElement,
76
+ generations: () =>
77
+ Array.from(container.querySelectorAll<HTMLElement>(".hk-sidebar-rail-generation")),
78
+ params,
79
+ set: async (v: boolean) => {
80
+ rail.value = v;
81
+ await nextTick();
82
+ },
83
+ };
84
+ }
85
+
86
+ function freezeRaf(): void {
87
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
88
+ vi.stubGlobal("cancelAnimationFrame", () => {});
89
+ }
90
+
91
+ afterEach(() => {
92
+ for (const app of mounts) app.unmount();
93
+ mounts.length = 0;
94
+ document.body.innerHTML = "";
95
+ containers.length = 0;
96
+ vi.unstubAllGlobals();
97
+ vi.useRealTimers();
98
+ });
99
+
100
+ describe("HkSidebarRailSwap — generation contract", () => {
101
+ beforeEach(() => {
102
+ vi.useFakeTimers();
103
+ });
104
+
105
+ it("renders the active generation silently on first paint, pinned at its width", () => {
106
+ const c = mountSwap({ railWidth: "56px", expandedWidth: "240px" });
107
+ // The swap host IS HkCrossfade's host — class fallthrough merges them.
108
+ expect(c.swap().classList.contains("hk-crossfade")).toBe(true);
109
+
110
+ const gens = c.generations();
111
+ expect(gens).toHaveLength(1);
112
+ expect(gens[0]!.style.width).toBe("240px");
113
+ expect(gens[0]!.hasAttribute("data-rail"), "expanded carries no rail density").toBe(false);
114
+ expect(c.params[c.params.length - 1]!.rail).toBe(false);
115
+ expect(gens[0]!.textContent).toContain("FULL");
116
+ });
117
+
118
+ it("the rail generation pins the rail width and carries the density", async () => {
119
+ const c = mountSwap({ railWidth: "64px" });
120
+ await c.set(true);
121
+ await vi.advanceTimersByTimeAsync(600);
122
+ const gens = c.generations();
123
+ expect(gens).toHaveLength(1);
124
+ expect(gens[0]!.style.width).toBe("64px");
125
+ expect(gens[0]!.hasAttribute("data-rail")).toBe(true);
126
+ expect(c.params[c.params.length - 1]!.rail).toBe(true);
127
+ expect(gens[0]!.textContent).toContain("RAIL");
128
+ });
129
+
130
+ it("applies the width props VERBATIM (a var() token must survive the passthrough)", async () => {
131
+ // happy-dom's CSSOM silently drops a var() width from the parsed
132
+ // inline style, so the token passthrough is pinned in SOURCE (the
133
+ // house source-pin style): the generation's width is the raw prop —
134
+ // no re-resolution, no fallback substitution — which is what lets a
135
+ // bespoke host pass its own var(--app-sidebar-w, 240px) token.
136
+ const { readFileSync } = await import("node:fs");
137
+ const { dirname, join } = await import("node:path");
138
+ const { fileURLToPath } = await import("node:url");
139
+ const src = readFileSync(
140
+ join(dirname(fileURLToPath(import.meta.url)), "HkSidebarRailSwap.tsx"),
141
+ "utf8",
142
+ );
143
+ expect(src).toContain("width: props.rail ? props.railWidth : props.expandedWidth");
144
+ });
145
+ });
146
+
147
+ describe("HkSidebarRailSwap — the dissolve (HkCrossfade's mechanism)", () => {
148
+ beforeEach(() => {
149
+ vi.useFakeTimers();
150
+ });
151
+
152
+ it("holds BOTH generations mid-swap: leaving FROZEN at its dress, entering at the new one", async () => {
153
+ freezeRaf();
154
+ const c = mountSwap({ railWidth: "56px", expandedWidth: "240px" });
155
+ await c.set(true);
156
+ await vi.advanceTimersByTimeAsync(200); // inside the dissolve window
157
+
158
+ const gens = c.generations();
159
+ expect(gens, "both generations share the cell mid-swap").toHaveLength(2);
160
+ // DOM order: leaving first, entering last.
161
+ const [leaving, entering] = gens;
162
+ expect(leaving!.hasAttribute("data-rail"), "the leaving copy keeps the EXPANDED dress").toBe(false);
163
+ expect(leaving!.style.width).toBe("240px");
164
+ expect(leaving!.textContent).toContain("FULL");
165
+ expect(entering!.hasAttribute("data-rail"), "the entering copy takes the rail dress").toBe(true);
166
+ expect(entering!.style.width).toBe("56px");
167
+ expect(entering!.textContent).toContain("RAIL");
168
+ // The leaving ITEM is pointer-dead for the dissolve (HkCrossfade's
169
+ // leave rule) — the frozen generation cannot intercept clicks.
170
+ const leavingItem = leaving!.parentElement!;
171
+ expect(leavingItem.classList.contains("hk-crossfade-leave-active")).toBe(true);
172
+ });
173
+
174
+ it("a healthy swap settles on the entering generation alone", async () => {
175
+ const c = mountSwap({ railWidth: "56px" });
176
+ await c.set(true);
177
+ await vi.advanceTimersByTimeAsync(600);
178
+ const gens = c.generations();
179
+ expect(gens).toHaveLength(1);
180
+ expect(gens[0]!.hasAttribute("data-rail")).toBe(true);
181
+ });
182
+
183
+ it("reversing mid-dissolve still lands on the live density — and settles", async () => {
184
+ freezeRaf();
185
+ const c = mountSwap({});
186
+ await c.set(true);
187
+ await vi.advanceTimersByTimeAsync(100);
188
+ await c.set(false); // flipped back before the first dissolve settled
189
+ await vi.advanceTimersByTimeAsync(100);
190
+ const last = c.generations()[c.generations().length - 1]!;
191
+ expect(last.hasAttribute("data-rail")).toBe(false);
192
+ expect(last.style.width).toBe("240px");
193
+
194
+ // Release rAF and let the choreography finish: whatever stale items
195
+ // the reversal stranded must sweep, settling on exactly one
196
+ // generation at the live density (the watchdog's degraded path
197
+ // would otherwise leave wedged copies mounted).
198
+ vi.unstubAllGlobals();
199
+ await vi.advanceTimersByTimeAsync(CROSSFADE_WATCHDOG_MS + 200);
200
+ const settled = c.generations();
201
+ expect(settled).toHaveLength(1);
202
+ expect(settled[0]!.hasAttribute("data-rail")).toBe(false);
203
+ expect(settled[0]!.style.width).toBe("240px");
204
+ });
205
+
206
+ it("FREEZES the leaving generation under a host re-render mid-dissolve", async () => {
207
+ // THE central guarantee: the leaving copy is not re-rendered while
208
+ // it dissolves — a host re-render (peek state, live store data) must
209
+ // not restyle or rewrite it, or the density handoff would reintroduce
210
+ // the squeeze this component exists to fix. The harness re-renders
211
+ // the whole slot with NEW live text mid-dissolve; the leaving copy
212
+ // must keep the OLD text while the entering copy shows the new one.
213
+ freezeRaf();
214
+ const container = document.createElement("div");
215
+ document.body.appendChild(container);
216
+ containers.push(container);
217
+
218
+ const rail = ref(false);
219
+ const live = ref("v1");
220
+ const Wrapper = defineComponent({
221
+ name: "RailSwapFreezeHarness",
222
+ setup() {
223
+ return () =>
224
+ h(
225
+ HkSidebarRailSwap,
226
+ { rail: rail.value, railWidth: "56px", expandedWidth: "240px" },
227
+ {
228
+ default: ({ rail: r }: { rail: boolean }) =>
229
+ h("p", { class: "body" }, (r ? "RAIL:" : "FULL:") + live.value),
230
+ },
231
+ );
232
+ },
233
+ });
234
+ const app = createApp(Wrapper);
235
+ mounts.push(app);
236
+ app.mount(container);
237
+
238
+ rail.value = true; // start the FULL -> RAIL dissolve
239
+ await nextTick();
240
+ await vi.advanceTimersByTimeAsync(120);
241
+ live.value = "v2"; // the host re-renders mid-dissolve
242
+ await nextTick();
243
+ await vi.advanceTimersByTimeAsync(50);
244
+
245
+ const gens = Array.from(
246
+ container.querySelectorAll<HTMLElement>(".hk-sidebar-rail-generation"),
247
+ );
248
+ expect(gens).toHaveLength(2);
249
+ // The frozen leaving copy keeps its last-rendered text (FULL:v1);
250
+ // the entering copy re-renders live (RAIL:v2).
251
+ expect(gens[0]!.textContent).toBe("FULL:v1");
252
+ expect(gens[1]!.textContent).toBe("RAIL:v2");
253
+ });
254
+ });
@@ -0,0 +1,102 @@
1
+ import { defineComponent } from "vue";
2
+
3
+ import HkCrossfade from "./HkCrossfade";
4
+
5
+ import "./HkSidebarRail.scss";
6
+
7
+ /**
8
+ * HkSidebarRailSwap — the rail/full CONTENT handoff (2026-10-30).
9
+ *
10
+ * THE DEFECT this closes: a rail <-> expanded flip animates the panel's
11
+ * `width` (the slide lever) while the density dress (`[data-rail]` /
12
+ * the slot's scoped state) flips INSTANTLY. The single mounted body is
13
+ * therefore re-laid-out at the ANIMATING panel width — expanding mounts
14
+ * full-width rows inside a still-56px panel (labels wrap and ellipsize,
15
+ * then relax as the panel grows: the reported "squeezed" content) and
16
+ * collapsing snaps rows to icon tiles that glide through the still-wide
17
+ * panel. Both read as the content being crushed for the transition's
18
+ * ~300ms, even though nothing is wrong before or after it.
19
+ *
20
+ * THE CONTRACT (user direction 2026-10-30): during the handoff the
21
+ * content keeps its ORIGINAL width and the clip does the windowing
22
+ * (`overflow: hidden` — `.hk-sidebar-rail-clip`, already in the kit),
23
+ * and the old and new generations exchange through an OPACITY dissolve —
24
+ * the SAME primitive tab panels swap through (HkCrossfade via
25
+ * HkTransitionContainer; the paneSwap contract's standard mechanism, so
26
+ * this component composes it rather than inventing a bespoke fade).
27
+ *
28
+ * Per generation:
29
+ * - the generation wrapper is PINNED at its own width (`railWidth` /
30
+ * `expandedWidth` inline) and anchored at the docked inline-start
31
+ * edge, so neither generation ever reflows while the panel animates;
32
+ * - the swap host itself NEVER stretches to the animating clip width
33
+ * (HkSidebarRail.scss: shrink-to-fit at the widest generation) — the
34
+ * "original content width" guarantee is structural, not per-frame;
35
+ * - the generation carries `data-rail` while the rail density is
36
+ * active, so hosts scope their density dress to the GENERATION, never
37
+ * to the host root (a root-level attribute flips with the state and
38
+ * would restyle the frozen leaving generation mid-dissolve);
39
+ * - the leaving generation is FROZEN at its last render (Vue keeps the
40
+ * keyed leaving item unpatched) and drops pointer events for the
41
+ * dissolve (HkCrossfade's leave rule), so the dead copy can neither
42
+ * update nor intercept clicks.
43
+ *
44
+ * Slot: the default slot receives `{ rail }` — render the body as a
45
+ * function of it. The ENTERING generation is invoked with the new value;
46
+ * the LEAVING one keeps whatever it last rendered (per-generation density
47
+ * by construction). Hosts that ignore the param and close over their own
48
+ * state get the same freeze for free — but the param is the sanctioned
49
+ * channel because it also drives per-generation branching (icon hints,
50
+ * aria labels) that CSS cannot express.
51
+ *
52
+ * Trade-offs (documented, accepted): each flip MOUNTS the entering
53
+ * generation fresh, exactly like a tab-panel swap —
54
+ * - a scrolled sidebar body starts the entering copy at the top, and
55
+ * uncontrolled state inside the slot (an expanded nav group, hover)
56
+ * does not carry over. Same-family precedent (HkTabs panels, admin
57
+ * views); rail flips are rare, and preserving scroll across a width
58
+ * change whose row heights differ would map positions, not restore
59
+ * them. A peek OPEN also hands hover to the full row under the
60
+ * resting pointer without user intent — inherent to any dissolve.
61
+ * - FOCUS: when the leaving generation unmounts at the end of the
62
+ * dissolve, focus inside it (a nav row focused in an open peek)
63
+ * drops to <body> — the sharpest edge is Escape-closing a peek from
64
+ * a focused row, which also drops the host's :focus-within (the
65
+ * toggle's keyboard reveal) until the next Tab. Hosts that need a
66
+ * softer landing can refocus the toggle from the peek-closer path.
67
+ *
68
+ * Reduced motion: HkCrossfade keeps a short opacity exchange (a fade
69
+ * carries no movement) while the kit's sheets kill the width slides —
70
+ * the handoff degrades to a quick dip, never a squeeze.
71
+ */
72
+ export default defineComponent({
73
+ name: "HkSidebarRailSwap",
74
+ props: {
75
+ /** Which generation is active: true = the icon rail (the collapsed
76
+ * density), false = the full sidebar (including a peek). */
77
+ rail: { type: Boolean, default: false },
78
+ /** Pinned width of the RAIL generation. The host's collapsed-width
79
+ * token (HkSidebar's `collapsedWidth`, the shell's
80
+ * `sidebarCollapsedWidth`, wowsp's 56px). */
81
+ railWidth: { type: String, default: "56px" },
82
+ /** Pinned width of the EXPANDED generation. The host's full sidebar
83
+ * width token — a CSS length or a var() reference. */
84
+ expandedWidth: { type: String, default: "280px" },
85
+ },
86
+ setup(props, { slots }) {
87
+ return () => (
88
+ // The class rides HkCrossfade's host (Vue class fallthrough), so
89
+ // HkSidebarRail.scss can size the swap INSIDE the clip without a
90
+ // second wrapper element.
91
+ <HkCrossfade class="hk-sidebar-rail-swap" swapKey={props.rail ? "rail" : "full"} tag="div">
92
+ <div
93
+ class="hk-sidebar-rail-generation"
94
+ data-rail={props.rail || undefined}
95
+ style={{ width: props.rail ? props.railWidth : props.expandedWidth }}
96
+ >
97
+ {slots.default?.({ rail: props.rail })}
98
+ </div>
99
+ </HkCrossfade>
100
+ );
101
+ },
102
+ });
package/src/index.ts CHANGED
@@ -104,6 +104,12 @@ export {
104
104
  RAIL_TOGGLE_SELECTOR,
105
105
  RAIL_TOGGLE_SUPPRESS_PAD,
106
106
  } from "./components/HkSidebarRailToggle";
107
+ // The rail/full content handoff: two width-pinned generations exchanged
108
+ // through HkCrossfade's opacity dissolve (the tab-panel mechanism), so
109
+ // sidebar content never re-lays-out at the panel's transient width while
110
+ // it collapses or expands. Bespoke hosts (wowsp's own Sidebar) mount it
111
+ // inside their .hk-sidebar-rail-clip exactly like HkSidebar/HkFaceShell.
112
+ export { default as HkSidebarRailSwap } from "./components/HkSidebarRailSwap";
107
113
  export { default as HSkeleton } from "./components/HkSkeleton";
108
114
  export { default as HSkeletonList } from "./components/HkSkeletonList";
109
115
  export { default as HSlider } from "./components/HkSlider";