@celestia-island/hikari 0.55.264 → 0.55.265

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.264",
3
+ "version": "0.55.265",
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",
@@ -7,6 +7,7 @@ import { createApp, h, nextTick, ref, type Ref } from "vue";
7
7
  import { Home, Settings } from "lucide-vue-next";
8
8
 
9
9
  import HkMenu, { type HkMenuItem } from "./HkMenu";
10
+ import { RAIL_FLIP_ICON_ATTR } from "../composables/useRailIconFlight";
10
11
 
11
12
  /**
12
13
  * HkMenu sidebar-variant ICON-RAIL mode (collapsed prop, 2026-10-30):
@@ -418,6 +419,53 @@ describe("HkMenu sidebar icon rail", () => {
418
419
  expect(glyph.textContent).toBe("D");
419
420
  });
420
421
 
422
+ it("stamps every icon cell with data-flip-icon = the row key, in BOTH densities", () => {
423
+ // The rail flip's icon flight (useRailIconFlight) pairs the leaving
424
+ // and entering copies of ONE destination by this attribute; a cell
425
+ // missing it (or carrying the wrong key) silently drops that row out
426
+ // of the continuity choreography. All four render sites — expanded
427
+ // leaf + group toggle, rail leaf + rail group header — must stamp it.
428
+ // The selector is built from the composable's exported constant — the
429
+ // welding that turns a literal drift in HkMenu's JSX (or the JS
430
+ // pairing vocabulary) into a red test instead of a silently dead
431
+ // flight chain (2026-11-02 review).
432
+ const iconKeys = (root: ParentNode): string[] =>
433
+ [...root.querySelectorAll<HTMLElement>(`.hk-menu-item-icon[${RAIL_FLIP_ICON_ATTR}]`)].map((el) =>
434
+ el.getAttribute(RAIL_FLIP_ICON_ATTR) ?? "",
435
+ );
436
+
437
+ // An icon on the group exercises the group-toggle sites; the
438
+ // icon-less leaf exercises the asymmetry the flight tolerates: the
439
+ // rail renders a fallback cell for it (keyed!) while the expanded
440
+ // sidebar renders no cell at all — pairing needs BOTH ends, so that
441
+ // row dissolves instead of flying.
442
+ const FULL: HkMenuItem[] = [
443
+ { key: "home", label: "Home", icon: Home },
444
+ { key: "plain", label: "Docs" },
445
+ {
446
+ key: "admin",
447
+ label: "Administration",
448
+ icon: Settings,
449
+ children: [{ key: "users", label: "Users", icon: Home }],
450
+ },
451
+ ];
452
+
453
+ const expanded = mountMenu(FULL, { defaultExpandGroups: true });
454
+ expect(iconKeys(expanded), "expanded leaf + group toggle icons").toEqual([
455
+ "home",
456
+ "admin",
457
+ "users",
458
+ ]);
459
+
460
+ const collapsed = mountMenu(FULL, { collapsed: true, defaultExpandGroups: true });
461
+ expect(iconKeys(collapsed), "rail leaves + rail group header icons").toEqual([
462
+ "home",
463
+ "plain",
464
+ "admin",
465
+ "users",
466
+ ]);
467
+ });
468
+
421
469
  it("still selects from the rail (and stays disabled-aware)", () => {
422
470
  const selected: string[] = [];
423
471
  const container = document.createElement("div");
@@ -861,7 +861,12 @@ export default defineComponent({
861
861
  </span>
862
862
  )}
863
863
  {item.icon && (
864
- <span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
864
+ // data-flip-icon: the row key opts this cell into the rail
865
+ // flip's icon flight (useRailIconFlight) — the flight pairs
866
+ // the leaving and entering copies of ONE destination by it.
867
+ <span class="hk-menu-item-icon" data-flip-icon={item.key}>
868
+ {h(item.icon, { size: 16 })}
869
+ </span>
865
870
  )}
866
871
  {item.flag && <span class="hk-menu-flag">{item.flag}</span>}
867
872
  <span class="hk-menu-label">{item.label}</span>
@@ -890,8 +895,14 @@ export default defineComponent({
890
895
  toggleGroup(item.key);
891
896
  }}
892
897
  >
898
+ {/* The group toggle's glyph flies too — same key, same
899
+ pairing (the rail header centers the same group icon).
900
+ Still conditional: an icon-less group renders no cell
901
+ here, exactly as before. */}
893
902
  {item.icon && (
894
- <span class="hk-menu-item-icon">{h(item.icon, { size: 16 })}</span>
903
+ <span class="hk-menu-item-icon" data-flip-icon={item.key}>
904
+ {h(item.icon, { size: 16 })}
905
+ </span>
895
906
  )}
896
907
  <span class="hk-menu-label">{item.label}</span>
897
908
  {item.badge && <span class="hk-menu-sidebar-badge">{item.badge}</span>}
@@ -956,8 +967,14 @@ export default defineComponent({
956
967
  }}
957
968
  >
958
969
  {/* Icon-less rows fall back to the label's initial — the rail
959
- must never strand a destination behind a missing glyph. */}
960
- <span class="hk-menu-item-icon" data-fallback={item.icon ? undefined : true}>
970
+ must never strand a destination behind a missing glyph.
971
+ data-flip-icon pairs this cell with the expanded row's
972
+ icon across the density flip (useRailIconFlight). */}
973
+ <span
974
+ class="hk-menu-item-icon"
975
+ data-flip-icon={item.key}
976
+ data-fallback={item.icon ? undefined : true}
977
+ >
961
978
  {item.icon ? h(item.icon, { size: 16 }) : item.label.trim().charAt(0).toUpperCase()}
962
979
  </span>
963
980
  {item.badge && (
@@ -1002,7 +1019,16 @@ export default defineComponent({
1002
1019
  toggleGroup(item.key);
1003
1020
  }}
1004
1021
  >
1005
- <span class="hk-menu-item-icon" data-fallback={item.icon ? undefined : true}>
1022
+ {/* The flight pairs the rail header's glyph with the
1023
+ expanded group toggle's icon by the group key. An
1024
+ icon-less group has no expanded cell to pair with, so
1025
+ its placeholder caret simply dissolves — pairing needs
1026
+ both ends, and the flight never invents one. */}
1027
+ <span
1028
+ class="hk-menu-item-icon"
1029
+ data-flip-icon={item.key}
1030
+ data-fallback={item.icon ? undefined : true}
1031
+ >
1006
1032
  {item.icon ? h(item.icon, { size: 16 }) : railCaret(expanded, 16)}
1007
1033
  </span>
1008
1034
  {item.icon && (
@@ -97,7 +97,19 @@
97
97
  * collapsed paddings used to lift the whole nav by up to two paddings'
98
98
  * worth on every flip; a band that needs a different vertical dress in
99
99
  * rail mode would have to move the base values for BOTH densities, never
100
- * add a per-density block padding. */
100
+ * add a per-density block padding.
101
+ *
102
+ * THE RAIL ROW SQUARE (2026-11-02 user screenshot: the collapsed
103
+ * selected pill read as "not quite square — a bit squat"). The body's
104
+ * inline track narrows so a rail row is EXACTLY its own min-height
105
+ * square: the default 56px rail minus the menu-item grammar's 34px row
106
+ * leaves 22px, 11px a side. The row spans its track (width: 100% in the
107
+ * shared spec), so the hover/active pill fills 34×34 instead of the
108
+ * 32×34 oblong a 12px track produced. INLINE ONLY: the block paddings —
109
+ * the 20px leading air and the scroll tail — stay with the base rule, so
110
+ * the vertical rhythm invariant above is untouched. The literal is
111
+ * derived, not tunable: --hk-menu-item-min-h is declared ON each row (the
112
+ * custom-property trap), so this ancestor cannot compute from it. */
101
113
  .hk-sidebar[data-collapsed],
102
114
  .hk-sidebar-rail-generation[data-rail] {
103
115
  .hk-sidebar-header {
@@ -108,6 +120,10 @@
108
120
  .hk-sidebar-footer {
109
121
  padding: var(--space-16) var(--space-8);
110
122
  }
123
+
124
+ .hk-sidebar-body {
125
+ padding-inline: 11px;
126
+ }
111
127
  }
112
128
 
113
129
  .hk-sidebar[data-mobile] {
@@ -76,19 +76,25 @@ describe("HkSidebar scroll end-cap", () => {
76
76
 
77
77
  it("the density dress keeps every vertical metric at the expanded value", () => {
78
78
  // Vertical rhythm is density-invariant (2026-10-31 user direction:
79
- // the collapse toggle must not move rows). The body therefore has no
80
- // density rule AT ALL — the leading air AND the scroll tail ride the
81
- // single base rule in both densities — and the header/footer dress
79
+ // the collapse toggle must not move rows). The body's ONLY density
80
+ // declaration is the rail row square's inline track (2026-11-02 user
81
+ // screenshot: the collapsed selected pill was a 32×34 oblong; the
82
+ // track narrows to 11px a side so the 100%-wide row fills exactly its
83
+ // 34px min-height square) — the leading air AND the scroll tail ride
84
+ // the single base rule in both densities, and the header/footer dress
82
85
  // rules keep the expanded BLOCK padding, narrowing only the inline
83
86
  // track for the rail column.
84
- expect(css).not.toContain(".hk-sidebar[data-collapsed] .hk-sidebar-body");
85
- expect(css).not.toContain(".hk-sidebar-rail-generation[data-rail] .hk-sidebar-body");
86
- // The two carrier literals above are the compiled spellings; a brand
87
- // NEW carrier (a third density hook) is a new dress and must re-run
88
- // this contract — not mechanically foreseeable here.
89
87
  // ruleBody needs the arm its brace follows; the dress compiles to one
90
88
  // comma-grouped rule per band, so the generation arm is the one the
91
89
  // helper can read (the legacy arm shares its body).
90
+ const bodySquare = css.match(
91
+ /\.hk-sidebar\[data-collapsed\]\s*\.hk-sidebar-body[^{]*\{([^}]*)\}/,
92
+ );
93
+ expect(bodySquare, "the rail body inline-track rule exists").not.toBeNull();
94
+ expect(
95
+ bodySquare![1]!.trim(),
96
+ "the body dress is the row square's inline track ALONE — any block spelling here moves rows",
97
+ ).toBe("padding-inline: 11px;");
92
98
  const header = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-header");
93
99
  expect(header, "the collapsed header rule exists (grouped density carriers)").not.toBeNull();
94
100
  expect(header).toContain("padding: var(--space-16) var(--space-8)");
@@ -96,37 +102,83 @@ describe("HkSidebar scroll end-cap", () => {
96
102
  expect(footer, "the collapsed footer rule exists (grouped density carriers)").not.toBeNull();
97
103
  expect(footer).toContain("padding: var(--space-16) var(--space-8)");
98
104
  // The stronger, spelling-proof form (whole-dress scan): EVERY
99
- // padding declaration in ANY dress rule must be EXACTLY the expanded
100
- // two-track shorthand — exact, not a prefix: a three-track spelling
101
- // (`padding: var(--space-16) var(--space-8) var(--space-12)`) drifts
102
- // the block END past a block-start-only check, and a longhand
103
- // (`padding-block-start:`) hides from a shorthand-only one; both
104
- // green-holed earlier rounds. Physical `bottom` longhands are banned
105
- // beside the logical ones. Horizontal-only tracks (padding-inline*)
106
- // cannot match the value pin and are rejected the same way; a band
107
- // that truly needs different block metrics must move the BASE rule
108
- // for BOTH densities. Margins are banned outright — the bands
105
+ // padding declaration in ANY dress rule must be EXACTLY one of the
106
+ // two sanctioned values — the expanded two-track shorthand (header/
107
+ // footer bands) or the body's square track — exact, not a prefix: a
108
+ // three-track spelling (`padding: var(--space-16) var(--space-8)
109
+ // var(--space-12)`) drifts the block END past a block-start-only
110
+ // check, and a longhand (`padding-block-start:`) hides from a
111
+ // shorthand-only one; both green-holed earlier rounds. Physical
112
+ // `bottom` longhands are banned beside the logical ones; an
113
+ // `padding-inline` on any NON-body rule is rejected the same way (a
114
+ // band that truly needs different block metrics must move the BASE
115
+ // rule for BOTH densities). Margins are banned outright — the bands
109
116
  // separate by their paddings alone — and so are the block-FLOW
110
117
  // metrics that move rows just the same (gap/row-gap/height/
111
118
  // min-height/max-height).
119
+ // Keyed on the carrier SUBSTRINGS, not their bracket spelling: a
120
+ // `[data-collapsed="true"]`-style variant of the same carrier must
121
+ // not slip the scan (2026-11-02 review: the exact-literal filter was
122
+ // a green hole). Boundary (restored): a brand-new carrier MECHANISM
123
+ // — a class-based hook or a third density attribute under a new
124
+ // name — is a new dress and must re-run this contract; not
125
+ // mechanically foreseeable here.
112
126
  const dressRules = css
113
127
  .split("}")
114
- .filter((rule) => rule.includes("[data-collapsed]") || rule.includes("[data-rail]"));
128
+ .filter((rule) => rule.includes("data-collapsed") || rule.includes("data-rail"));
115
129
  expect(dressRules.length, "the density dress rules exist").toBeGreaterThan(1);
116
130
  for (const rule of dressRules) {
117
131
  for (const decl of rule.matchAll(
118
- /\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:[^;}]*/g,
132
+ /\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?|inline(?:-(?:start|end))?))?\s*:[^;}]*/g,
119
133
  )) {
120
- expect(
121
- decl[0].trim(),
122
- `a dress padding is not the expanded two-track shorthand: ${decl[0]}`,
123
- ).toBe("padding: var(--space-16) var(--space-8)");
134
+ const text = decl[0].trim();
135
+ if (/^padding-inline/.test(text)) {
136
+ // Chunk-anchored, not chunk-containment: the chunk carries the
137
+ // rule's own selector before its "{" — requiring THE SELECTOR
138
+ // to be the body's is what actually confines the inline track
139
+ // to the body rule (a raw chunk toContain would pass on a
140
+ // neighbor's selector too — 2026-11-02 review).
141
+ const selector = rule.slice(0, rule.indexOf("{"));
142
+ expect(
143
+ selector,
144
+ `a dress inline track outside the body rule: ${rule}`,
145
+ ).toContain(".hk-sidebar-body");
146
+ expect(
147
+ text,
148
+ `a dress inline track drifted from the row square: ${text}`,
149
+ ).toBe("padding-inline: 11px");
150
+ } else {
151
+ expect(
152
+ text,
153
+ `a dress padding is not the expanded two-track shorthand: ${text}`,
154
+ ).toBe("padding: var(--space-16) var(--space-8)");
155
+ }
124
156
  }
125
157
  expect(rule, `a margin crept into the dress: ${rule}`).not.toMatch(
126
158
  /\bmargin(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:/,
127
159
  );
160
+ // block-FLOW metrics that move rows just the same (line-height
161
+ // already falls under the `height` alternative via the word
162
+ // boundary; the logical `block-size` family spelled out
163
+ // separately — "min-block-size" carries no "height" substring,
164
+ // 2026-11-02 review), plus the block-EDGE spellings a
165
+ // padding/margin scan cannot see (border-block-start-width and
166
+ // inset-block drifted rows while every padding check stayed
167
+ // green).
128
168
  expect(rule, `block-flow metric in the dress: ${rule}`).not.toMatch(
129
- /\b(?:gap|row-gap|height|min-height|max-height)\s*:/,
169
+ /\b(?:gap|row-gap|height|min-height|max-height|(?:min-|max-)?block-size)\s*:/,
170
+ );
171
+ expect(rule, `block-edge border in the dress: ${rule}`).not.toMatch(/\bborder-block/);
172
+ expect(rule, `block-edge inset in the dress: ${rule}`).not.toMatch(/\binset-block/);
173
+ // The PHYSICAL block-edge borders: the lookbehind above deliberately
174
+ // spares "-top"/"-bottom" INSIDE longer words, which let
175
+ // `border-top-width: 6px` push every row below it down while every
176
+ // logical check stayed green (2026-11-02 review).
177
+ expect(rule, `physical block-edge border in the dress: ${rule}`).not.toMatch(
178
+ /\bborder-(?:top|bottom)\b/,
179
+ );
180
+ expect(rule, `physical block offset in the dress: ${rule}`).not.toMatch(
181
+ /(?<![-\w])(?:top|bottom)\s*:/,
130
182
  );
131
183
  }
132
184
  });
@@ -29,7 +29,11 @@ export default defineComponent({
29
29
  * no peek, `collapsed` stays the plain presentational 56px rail. */
30
30
  collapsible: { type: Boolean, default: false },
31
31
  /** Layout width of the icon rail (the one-icon column). Mirrors the
32
- * 56px this component always used for its collapsed mode. */
32
+ * 56px this component always used for its collapsed mode.
33
+ * COUPLING (2026-11-02): the rail row square in HkSidebar.scss
34
+ * derives its body inline track from the DEFAULT — 56px rail − 34px
35
+ * menu row = 11px a side. A non-56 rail re-flattens the selected
36
+ * pill unless that track is retuned alongside. */
33
37
  collapsedWidth: { type: String, default: "56px" },
34
38
  /** Hover dwell on the rail before the peek drawer opens, ms. */
35
39
  peekDelay: { type: Number, default: 400 },
@@ -159,11 +159,70 @@
159
159
  * the host from STRETCHING, which would clip nothing but would invite
160
160
  * percentage-width content to follow the animation.) The generations
161
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. */
162
+ * the bottom) behaves exactly as it did as a direct child of the clip.
163
+ *
164
+ * position: relative makes the host the containing block for the rail
165
+ * flip's FLIGHT LAYER (useRailIconFlight). The host is the right stage
166
+ * for two reasons, each the inverse of the other's failure mode
167
+ * (2026-11-02 review): its inline-START edge is the docked edge
168
+ * `align-self: flex-start` pins — stable while only the inline-END edge
169
+ * tracks the animating clip width, in LTR and RTL alike (a physical
170
+ * left anchor would drift by the whole width increment in RTL); and it
171
+ * sits OUTSIDE both dissolving items, so the clones never inherit an
172
+ * item's enter fade (enter-from holds opacity 0 for the flight's first
173
+ * frames — an item-anchored clone blinks out exactly when it should
174
+ * substitute). Clone insets are written as LOGICAL offsets from the
175
+ * stable edge; see the composable. */
163
176
  .hk-sidebar-rail-clip > .hk-sidebar-rail-swap {
164
177
  flex: 1;
165
178
  min-height: 0;
166
179
  align-self: flex-start;
180
+ position: relative;
181
+ }
182
+
183
+ /* The flight layer + clones (see useRailIconFlight). The layer covers
184
+ * the host — the widest generation's footprint — so a clone never
185
+ * repositions relative to the animating panel; its own geometry is
186
+ * inline (the measured rects, written as LOGICAL insets from the
187
+ * stable inline-start edge). pointer-events stay dead: the layer is
188
+ * paint-only chrome above a pointer-dead dissolve. Clones keep
189
+ * inline-flex centering so the svg sits in the cell exactly as the real
190
+ * icon cell centers it. */
191
+ .hk-sidebar-rail-flight {
192
+ position: absolute;
193
+ inset: 0;
194
+ z-index: 2;
195
+ display: block;
196
+ pointer-events: none;
197
+ }
198
+
199
+ .hk-sidebar-rail-flight-clone {
200
+ position: absolute;
201
+ display: inline-flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ margin: 0;
205
+ pointer-events: none;
206
+ will-change: transform;
207
+ }
208
+
209
+ /* During a flight the leaving generation's MATCHED icons hide: the clone
210
+ * paints in their place at the same geometry, full opacity — a
211
+ * substitution, not a fade, so the icon reads as ONE object that
212
+ * travels. Scoped four ways on purpose: only while a flight runs
213
+ * ([data-flipping] on the swap host), only on the dissolving copy (the
214
+ * leave item class), only on opted-in icons ([data-flip-icon]), and
215
+ * only on the cells the choreographer actually PAIRED AND CLONED
216
+ * ([data-flip-flying] — stamped per flight, swept after). That last
217
+ * scope is the pairing contract made visible: an opted-in cell without
218
+ * a partner (a rail fallback letter with no expanded counterpart) is
219
+ * never stamped, so it dissolves instead of vanishing without a
220
+ * substitute (2026-11-02 review). Identity blocks, carets, badges
221
+ * and every other icon keep the plain dissolve. */
222
+ .hk-sidebar-rail-swap[data-flipping]
223
+ .hk-crossfade-item.hk-crossfade-leave-active
224
+ .hk-menu-item-icon[data-flip-flying] {
225
+ visibility: hidden;
167
226
  }
168
227
 
169
228
  .hk-sidebar-rail-swap .hk-sidebar-rail-generation {
@@ -5,6 +5,12 @@ import { fileURLToPath } from "node:url";
5
5
  import { compile } from "sass";
6
6
  import { describe, expect, it } from "vitest";
7
7
 
8
+ import {
9
+ RAIL_FLIP_ACTIVE_ATTR,
10
+ RAIL_FLIP_FLYING_ATTR,
11
+ RAIL_FLIP_ICON_ATTR,
12
+ } from "../composables/useRailIconFlight";
13
+
8
14
  /**
9
15
  * Sidebar rail + peek stylesheet contract (2026-10-30) — the shared
10
16
  * geometry behind HkFaceShell's sidebar, HkSidebar and bespoke hosts
@@ -145,6 +151,20 @@ describe("HkSidebarRail geometry contract", () => {
145
151
  expect(swap).toContain("flex: 1");
146
152
  expect(swap).toContain("min-height: 0");
147
153
  expect(swap).toContain("align-self: flex-start");
154
+ // The swap host IS the flight layer's containing block: its
155
+ // inline-start edge is the docked edge (stable in LTR and RTL while
156
+ // only the inline-END edge tracks the animating clip width), and it
157
+ // sits OUTSIDE both dissolving items — clones must not inherit an
158
+ // item's enter fade (enter-from holds opacity 0 for the flight's
159
+ // first frames; an item-anchored clone blinks out at arm time).
160
+ expect(swap).toContain("position: relative");
161
+ // The dissolving items must NOT become containing blocks: a
162
+ // relative item would put the clones inside the very opacity group
163
+ // the flight exists to bypass.
164
+ expect(
165
+ ruleBody(css, ".hk-sidebar-rail-swap .hk-crossfade-item"),
166
+ "the dissolve item must not become the clone's containing block",
167
+ ).toBeNull();
148
168
 
149
169
  // Each generation is the flex column the bands expect, filling the
150
170
  // swap's height (spacer + footer-at-bottom behave as they did as
@@ -226,6 +246,65 @@ describe("HkSidebarRail geometry contract", () => {
226
246
  expect(btn).toContain("--hi-icon-button-radius: 9999px");
227
247
  });
228
248
 
249
+ it("hosts the rail flip's icon flight: paint-only layer, dead clones, triply-scoped hide", () => {
250
+ // useRailIconFlight clones the flipping icons onto a layer ABOVE the
251
+ // dissolve. The layer is chrome: it must never intercept a pointer
252
+ // (the leaving generation is pointer-dead for the dissolve and the
253
+ // layer rides above it) and it lives INSIDE the swap host, so the
254
+ // nobody-clips contract above is untouched.
255
+ const layer = ruleBody(css, ".hk-sidebar-rail-flight");
256
+ expect(layer, "the flight layer rule exists").not.toBeNull();
257
+ expect(layer).toContain("position: absolute");
258
+ expect(layer).toContain("inset: 0");
259
+ expect(layer).toContain("z-index: 2");
260
+ expect(layer).toContain("pointer-events: none");
261
+
262
+ const clone = ruleBody(css, ".hk-sidebar-rail-flight-clone");
263
+ expect(clone, "the clone rule exists").not.toBeNull();
264
+ expect(clone).toContain("position: absolute");
265
+ expect(clone).toContain("pointer-events: none");
266
+
267
+ // The hide rule is scoped FOUR ways — only while a flight is armed
268
+ // ([data-flipping] on the swap host), only on the DISSOLVING copy
269
+ // (the leave item class), only on opted-in icons ([data-flip-icon]),
270
+ // and only on the cells the choreographer actually PAIRED AND CLONED
271
+ // ([data-flip-flying]): identity blocks, carets, badges, unmatched
272
+ // fallbacks and every other icon keep the plain dissolve. The
273
+ // selector is composed from the composable's EXPORTED CONSTANTS —
274
+ // the welding that keeps the JSX/SCSS literals and the JS pairings
275
+ // one vocabulary (a literal drift anywhere turns this red instead of
276
+ // silently double-exposing the glyph).
277
+ const hideSelector =
278
+ `.hk-sidebar-rail-swap[${RAIL_FLIP_ACTIVE_ATTR}] ` +
279
+ `.hk-crossfade-item.hk-crossfade-leave-active ` +
280
+ `.hk-menu-item-icon[${RAIL_FLIP_FLYING_ATTR}]`;
281
+ const hide = ruleBody(css, hideSelector);
282
+ expect(hide, "the flight hide rule exists (all four scopes)").not.toBeNull();
283
+ expect(hide).toContain("visibility: hidden");
284
+ // …and the sheet must not hide on the OPT-IN attribute alone — that
285
+ // spelling would vanish unmatched fallbacks without a substitute.
286
+ expect(
287
+ ruleBody(
288
+ css,
289
+ `.hk-sidebar-rail-swap[${RAIL_FLIP_ACTIVE_ATTR}] ` +
290
+ `.hk-crossfade-item.hk-crossfade-leave-active ` +
291
+ `.hk-menu-item-icon[${RAIL_FLIP_ICON_ATTR}]`,
292
+ ),
293
+ "hiding keyed to the opt-in attribute alone is the S1-1 regression",
294
+ ).toBeNull();
295
+ });
296
+
297
+ it("leaves no flight transition for reduced motion to kill (the flight is JS-driven)", () => {
298
+ // useRailIconFlight skips itself under prefers-reduced-motion, so the
299
+ // sheet must not grow CSS transitions for the layer/clone that the
300
+ // kit's reduced-motion block would then have to enumerate — a
301
+ // transition here would mean the choreography drifted into CSS.
302
+ const layer = ruleBody(css, ".hk-sidebar-rail-flight");
303
+ expect(layer).not.toContain("transition");
304
+ const clone = ruleBody(css, ".hk-sidebar-rail-flight-clone");
305
+ expect(clone).not.toContain("transition");
306
+ });
307
+
229
308
  it("kills every transition under reduced motion", () => {
230
309
  const reduceAt = css.indexOf("@media (prefers-reduced-motion: reduce)");
231
310
  expect(reduceAt, "the reduced-motion block exists").toBeGreaterThanOrEqual(0);
@@ -1,5 +1,6 @@
1
- import { defineComponent } from "vue";
1
+ import { defineComponent, ref, type ComponentPublicInstance } from "vue";
2
2
 
3
+ import { useRailIconFlight } from "../composables/useRailIconFlight";
3
4
  import HkCrossfade from "./HkCrossfade";
4
5
 
5
6
  import "./HkSidebarRail.scss";
@@ -77,6 +78,18 @@ import "./HkSidebarRail.scss";
77
78
  * dissolve (HkCrossfade), so the frozen copy is neither tabbable nor
78
79
  * announced while the live one fades in.
79
80
  *
81
+ * THE ICON FLIGHT (2026-11-02, useRailIconFlight): a pure dissolve
82
+ * cannot show the icons' small horizontal journey into the rail column —
83
+ * both copies fade, nothing moves (user direction: "I can't tell the
84
+ * icon slides slightly left as it collapses"). This component hands the
85
+ * flip to the flight choreographer: icons opted in by HkMenu
86
+ * (`data-flip-icon`) are cloned into a flight layer on THIS host and
87
+ * animated from the leaving rect to the entering one, while the leaving
88
+ * copies hide (`data-flipping` + the leave class) and the rest of the
89
+ * content keeps the plain dissolve. Skipped under reduced motion / the
90
+ * animation switch / no WAAPI / no matching pair — every such case
91
+ * degrades to today's dissolve.
92
+ *
80
93
  * Reduced motion: HkCrossfade keeps a short opacity exchange (a fade
81
94
  * carries no movement) while the kit's sheets kill the width slides —
82
95
  * the handoff degrades to a quick dip, never a squeeze.
@@ -89,18 +102,27 @@ export default defineComponent({
89
102
  rail: { type: Boolean, default: false },
90
103
  /** Pinned width of the RAIL generation. The host's collapsed-width
91
104
  * token (HkSidebar's `collapsedWidth`, the shell's
92
- * `sidebarCollapsedWidth`, wowsp's 56px). */
105
+ * `sidebarCollapsedWidth`, wowsp's 56px). COUPLING (2026-11-02): the
106
+ * rail row square in HkSidebar.scss derives from the 56px default —
107
+ * a non-56 rail needs its body inline track retuned alongside. */
93
108
  railWidth: { type: String, default: "56px" },
94
109
  /** Pinned width of the EXPANDED generation. The host's full sidebar
95
110
  * width token — a CSS length or a var() reference. */
96
111
  expandedWidth: { type: String, default: "280px" },
97
112
  },
98
113
  setup(props, { slots }) {
114
+ // The swap host IS this component's root element — HkCrossfade's
115
+ // host div (class fallthrough). The component ref's $el reaches it
116
+ // without adding a wrapper (the clip's child combinator styles
117
+ // `.hk-sidebar-rail-clip > .hk-sidebar-rail-swap` directly).
118
+ const swapRef = ref<ComponentPublicInstance | null>(null);
119
+ const swapEl = (): HTMLElement | null => (swapRef.value?.$el as HTMLElement | null) ?? null;
120
+ useRailIconFlight(swapEl, () => props.rail, () => (props.rail ? "rail" : "full"));
99
121
  return () => (
100
122
  // The class rides HkCrossfade's host (Vue class fallthrough), so
101
123
  // HkSidebarRail.scss can size the swap INSIDE the clip without a
102
124
  // second wrapper element.
103
- <HkCrossfade class="hk-sidebar-rail-swap" swapKey={props.rail ? "rail" : "full"} tag="div">
125
+ <HkCrossfade ref={swapRef} class="hk-sidebar-rail-swap" swapKey={props.rail ? "rail" : "full"} tag="div">
104
126
  <div
105
127
  class="hk-sidebar-rail-generation"
106
128
  data-rail={props.rail || undefined}