@celestia-island/hikari 0.55.264 → 0.55.266

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.266",
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",
@@ -185,6 +185,10 @@ describe("HkFaceShell rail mode", () => {
185
185
 
186
186
  expect(c.aside().style.width).toBe("224px");
187
187
  expect(c.aside().getAttribute("data-rail")).toBeNull();
188
+ // The shell declares the rail width token for the rail row square's
189
+ // inline track (same contract as HkSidebar's collapsedWidth); the
190
+ // default rides the 56px fallback.
191
+ expect(c.aside().style.getPropertyValue("--hk-sidebar-rail-w")).toBe("56px");
188
192
  expect(c.toggle().getAttribute("aria-expanded")).toBe("true");
189
193
 
190
194
  c.toggle().click();
@@ -265,6 +269,10 @@ describe("HkFaceShell rail mode", () => {
265
269
 
266
270
  it("honors a configured rail width", async () => {
267
271
  const c = mountShell({ sidebarCollapsible: true, sidebarCollapsedWidth: "72px" });
272
+ expect(
273
+ c.aside().style.getPropertyValue("--hk-sidebar-rail-w"),
274
+ "a non-default collapsed width retunes the square track via the token",
275
+ ).toBe("72px");
268
276
  c.toggle().click();
269
277
  await vi.advanceTimersByTimeAsync(0);
270
278
  expect(c.aside().style.width).toBe("72px");
@@ -461,7 +461,14 @@ export const HkFaceShell = defineComponent({
461
461
  // while THIS host's width transition plays — expanding must
462
462
  // not replay the sidebar slide.
463
463
  data-pinning={rail.pinning.value || undefined}
464
- style={{ width: hostSidebarWidth.value, flexShrink: 0 }}
464
+ style={{
465
+ width: hostSidebarWidth.value,
466
+ flexShrink: 0,
467
+ // The rail row square's inline track (HkSidebar.scss)
468
+ // derives from this token, so a non-default
469
+ // sidebarCollapsedWidth keeps producing exact squares.
470
+ "--hk-sidebar-rail-w": props.sidebarCollapsedWidth,
471
+ }}
465
472
  onPointerenter={onSidebarHostPointerEnter}
466
473
  onPointerleave={rail.onPointerLeave}
467
474
  onPointermove={onSidebarHostPointerMove}
@@ -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 && (
@@ -95,6 +95,12 @@ describe("HkSidebar rail capability", () => {
95
95
  const c = mountSidebar({ width: "240px", collapsible: true, collapsedWidth: "64px" });
96
96
  expect(c.aside().className).toContain("hk-sidebar-rail-host");
97
97
  expect(c.aside().style.width).toBe("240px");
98
+ // The rail width travels to the sheet as a token: the rail row
99
+ // square's inline track (HkSidebar.scss) computes from it, so a
100
+ // non-default collapsedWidth keeps exact squares without a second
101
+ // edit. Deleting the declaration regresses non-56 hosts silently
102
+ // (the 56px fallback renders identically at the default).
103
+ expect(c.aside().style.getPropertyValue("--hk-sidebar-rail-w")).toBe("64px");
98
104
 
99
105
  c.toggle().click();
100
106
  await vi.advanceTimersByTimeAsync(0);
@@ -97,7 +97,23 @@
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 rail width minus the menu-item grammar's 34px row, half
106
+ * a side each. The row spans its track (width: 100% in the shared
107
+ * spec), so the hover/active pill fills 34×34 instead of the 32×34
108
+ * oblong a 12px track produced. INLINE ONLY: the block paddings — the
109
+ * 20px leading air and the scroll tail — stay with the base rule, so
110
+ * the vertical rhythm invariant above is untouched. The rail width
111
+ * travels as `--hk-sidebar-rail-w`, declared inline by both rail
112
+ * holders (HkSidebar's `collapsedWidth`, HkFaceShell's
113
+ * `sidebarCollapsedWidth`) — a host retuning the width keeps exact
114
+ * squares with no second edit. The 34px minuend stays a literal
115
+ * because --hk-menu-item-min-h is declared ON each row (the
116
+ * custom-property trap), so this ancestor cannot compute from it. */
101
117
  .hk-sidebar[data-collapsed],
102
118
  .hk-sidebar-rail-generation[data-rail] {
103
119
  .hk-sidebar-header {
@@ -108,6 +124,10 @@
108
124
  .hk-sidebar-footer {
109
125
  padding: var(--space-16) var(--space-8);
110
126
  }
127
+
128
+ .hk-sidebar-body {
129
+ padding-inline: calc((var(--hk-sidebar-rail-w, 56px) - 34px) / 2);
130
+ }
111
131
  }
112
132
 
113
133
  .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: calc((var(--hk-sidebar-rail-w, 56px) - 34px) / 2);");
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,84 @@ 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 token-derived square track — exact,
108
+ // not a prefix: a
109
+ // three-track spelling (`padding: var(--space-16) var(--space-8)
110
+ // var(--space-12)`) drifts the block END past a block-start-only
111
+ // check, and a longhand (`padding-block-start:`) hides from a
112
+ // shorthand-only one; both green-holed earlier rounds. Physical
113
+ // `bottom` longhands are banned beside the logical ones; an
114
+ // `padding-inline` on any NON-body rule is rejected the same way (a
115
+ // band that truly needs different block metrics must move the BASE
116
+ // rule for BOTH densities). Margins are banned outright — the bands
109
117
  // separate by their paddings alone — and so are the block-FLOW
110
118
  // metrics that move rows just the same (gap/row-gap/height/
111
119
  // min-height/max-height).
120
+ // Keyed on the carrier SUBSTRINGS, not their bracket spelling: a
121
+ // `[data-collapsed="true"]`-style variant of the same carrier must
122
+ // not slip the scan (2026-11-02 review: the exact-literal filter was
123
+ // a green hole). Boundary (restored): a brand-new carrier MECHANISM
124
+ // — a class-based hook or a third density attribute under a new
125
+ // name — is a new dress and must re-run this contract; not
126
+ // mechanically foreseeable here.
112
127
  const dressRules = css
113
128
  .split("}")
114
- .filter((rule) => rule.includes("[data-collapsed]") || rule.includes("[data-rail]"));
129
+ .filter((rule) => rule.includes("data-collapsed") || rule.includes("data-rail"));
115
130
  expect(dressRules.length, "the density dress rules exist").toBeGreaterThan(1);
116
131
  for (const rule of dressRules) {
117
132
  for (const decl of rule.matchAll(
118
- /\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:[^;}]*/g,
133
+ /\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?|inline(?:-(?:start|end))?))?\s*:[^;}]*/g,
119
134
  )) {
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)");
135
+ const text = decl[0].trim();
136
+ if (/^padding-inline/.test(text)) {
137
+ // Chunk-anchored, not chunk-containment: the chunk carries the
138
+ // rule's own selector before its "{" — requiring THE SELECTOR
139
+ // to be the body's is what actually confines the inline track
140
+ // to the body rule (a raw chunk toContain would pass on a
141
+ // neighbor's selector too — 2026-11-02 review).
142
+ const selector = rule.slice(0, rule.indexOf("{"));
143
+ expect(
144
+ selector,
145
+ `a dress inline track outside the body rule: ${rule}`,
146
+ ).toContain(".hk-sidebar-body");
147
+ expect(
148
+ text,
149
+ `a dress inline track drifted from the row square: ${text}`,
150
+ ).toBe("padding-inline: calc((var(--hk-sidebar-rail-w, 56px) - 34px) / 2)");
151
+ } else {
152
+ expect(
153
+ text,
154
+ `a dress padding is not the expanded two-track shorthand: ${text}`,
155
+ ).toBe("padding: var(--space-16) var(--space-8)");
156
+ }
124
157
  }
125
158
  expect(rule, `a margin crept into the dress: ${rule}`).not.toMatch(
126
159
  /\bmargin(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:/,
127
160
  );
161
+ // block-FLOW metrics that move rows just the same (line-height
162
+ // already falls under the `height` alternative via the word
163
+ // boundary; the logical `block-size` family spelled out
164
+ // separately — "min-block-size" carries no "height" substring,
165
+ // 2026-11-02 review), plus the block-EDGE spellings a
166
+ // padding/margin scan cannot see (border-block-start-width and
167
+ // inset-block drifted rows while every padding check stayed
168
+ // green).
128
169
  expect(rule, `block-flow metric in the dress: ${rule}`).not.toMatch(
129
- /\b(?:gap|row-gap|height|min-height|max-height)\s*:/,
170
+ /\b(?:gap|row-gap|height|min-height|max-height|(?:min-|max-)?block-size)\s*:/,
171
+ );
172
+ expect(rule, `block-edge border in the dress: ${rule}`).not.toMatch(/\bborder-block/);
173
+ expect(rule, `block-edge inset in the dress: ${rule}`).not.toMatch(/\binset-block/);
174
+ // The PHYSICAL block-edge borders: the lookbehind above deliberately
175
+ // spares "-top"/"-bottom" INSIDE longer words, which let
176
+ // `border-top-width: 6px` push every row below it down while every
177
+ // logical check stayed green (2026-11-02 review).
178
+ expect(rule, `physical block-edge border in the dress: ${rule}`).not.toMatch(
179
+ /\bborder-(?:top|bottom)\b/,
180
+ );
181
+ expect(rule, `physical block offset in the dress: ${rule}`).not.toMatch(
182
+ /(?<![-\w])(?:top|bottom)\s*:/,
130
183
  );
131
184
  }
132
185
  });
@@ -29,7 +29,10 @@ 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. Declared
33
+ * to the sheet as `--hk-sidebar-rail-w` on this aside, so the rail
34
+ * row square (HkSidebar.scss's body inline track) retunes with it —
35
+ * any width yields exact 34px squares. */
33
36
  collapsedWidth: { type: String, default: "56px" },
34
37
  /** Hover dwell on the rail before the peek drawer opens, ms. */
35
38
  peekDelay: { type: Number, default: 400 },
@@ -264,7 +267,17 @@ export default defineComponent({
264
267
  // panel and the panel's inner widths snap while THIS host's width
265
268
  // transition plays — expanding must not replay the sidebar slide.
266
269
  data-pinning={rail.pinning.value || undefined}
267
- style={props.mobile ? undefined : { width: hostWidth.value }}
270
+ style={
271
+ props.mobile
272
+ ? undefined
273
+ : {
274
+ width: hostWidth.value,
275
+ // The rail row square's inline track (HkSidebar.scss)
276
+ // derives from this token, so a non-default
277
+ // collapsedWidth keeps producing exact squares.
278
+ "--hk-sidebar-rail-w": props.collapsedWidth,
279
+ }
280
+ }
268
281
  onPointerenter={railChrome.value ? onHostPointerEnter : undefined}
269
282
  onPointerleave={railChrome.value ? rail.onPointerLeave : undefined}
270
283
  onPointermove={railChrome.value ? onHostPointerMove : undefined}
@@ -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,28 @@ 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). The rail row square's
106
+ * body inline track reads the same width as
107
+ * `--hk-sidebar-rail-w` — declared by the rail holder (HkSidebar /
108
+ * HkFaceShell), so a non-default width keeps exact squares. */
93
109
  railWidth: { type: String, default: "56px" },
94
110
  /** Pinned width of the EXPANDED generation. The host's full sidebar
95
111
  * width token — a CSS length or a var() reference. */
96
112
  expandedWidth: { type: String, default: "280px" },
97
113
  },
98
114
  setup(props, { slots }) {
115
+ // The swap host IS this component's root element — HkCrossfade's
116
+ // host div (class fallthrough). The component ref's $el reaches it
117
+ // without adding a wrapper (the clip's child combinator styles
118
+ // `.hk-sidebar-rail-clip > .hk-sidebar-rail-swap` directly).
119
+ const swapRef = ref<ComponentPublicInstance | null>(null);
120
+ const swapEl = (): HTMLElement | null => (swapRef.value?.$el as HTMLElement | null) ?? null;
121
+ useRailIconFlight(swapEl, () => props.rail, () => (props.rail ? "rail" : "full"));
99
122
  return () => (
100
123
  // The class rides HkCrossfade's host (Vue class fallthrough), so
101
124
  // HkSidebarRail.scss can size the swap INSIDE the clip without a
102
125
  // second wrapper element.
103
- <HkCrossfade class="hk-sidebar-rail-swap" swapKey={props.rail ? "rail" : "full"} tag="div">
126
+ <HkCrossfade ref={swapRef} class="hk-sidebar-rail-swap" swapKey={props.rail ? "rail" : "full"} tag="div">
104
127
  <div
105
128
  class="hk-sidebar-rail-generation"
106
129
  data-rail={props.rail || undefined}