@celestia-island/hikari 0.55.265 → 0.55.267

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.265",
3
+ "version": "0.55.267",
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}
@@ -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);
@@ -102,13 +102,17 @@
102
102
  * THE RAIL ROW SQUARE (2026-11-02 user screenshot: the collapsed
103
103
  * selected pill read as "not quite square — a bit squat"). The body's
104
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
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
112
116
  * custom-property trap), so this ancestor cannot compute from it. */
113
117
  .hk-sidebar[data-collapsed],
114
118
  .hk-sidebar-rail-generation[data-rail] {
@@ -122,7 +126,7 @@
122
126
  }
123
127
 
124
128
  .hk-sidebar-body {
125
- padding-inline: 11px;
129
+ padding-inline: calc((var(--hk-sidebar-rail-w, 56px) - 34px) / 2);
126
130
  }
127
131
  }
128
132
 
@@ -94,7 +94,7 @@ describe("HkSidebar scroll end-cap", () => {
94
94
  expect(
95
95
  bodySquare![1]!.trim(),
96
96
  "the body dress is the row square's inline track ALONE — any block spelling here moves rows",
97
- ).toBe("padding-inline: 11px;");
97
+ ).toBe("padding-inline: calc((var(--hk-sidebar-rail-w, 56px) - 34px) / 2);");
98
98
  const header = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-header");
99
99
  expect(header, "the collapsed header rule exists (grouped density carriers)").not.toBeNull();
100
100
  expect(header).toContain("padding: var(--space-16) var(--space-8)");
@@ -104,7 +104,8 @@ describe("HkSidebar scroll end-cap", () => {
104
104
  // The stronger, spelling-proof form (whole-dress scan): EVERY
105
105
  // padding declaration in ANY dress rule must be EXACTLY one of the
106
106
  // two sanctioned values — the expanded two-track shorthand (header/
107
- // footer bands) or the body's square track — exact, not a prefix: a
107
+ // footer bands) or the body's token-derived square track — exact,
108
+ // not a prefix: a
108
109
  // three-track spelling (`padding: var(--space-16) var(--space-8)
109
110
  // var(--space-12)`) drifts the block END past a block-start-only
110
111
  // check, and a longhand (`padding-block-start:`) hides from a
@@ -146,7 +147,7 @@ describe("HkSidebar scroll end-cap", () => {
146
147
  expect(
147
148
  text,
148
149
  `a dress inline track drifted from the row square: ${text}`,
149
- ).toBe("padding-inline: 11px");
150
+ ).toBe("padding-inline: calc((var(--hk-sidebar-rail-w, 56px) - 34px) / 2)");
150
151
  } else {
151
152
  expect(
152
153
  text,
@@ -29,11 +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.
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. */
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. */
37
36
  collapsedWidth: { type: String, default: "56px" },
38
37
  /** Hover dwell on the rail before the peek drawer opens, ms. */
39
38
  peekDelay: { type: Number, default: 400 },
@@ -268,7 +267,17 @@ export default defineComponent({
268
267
  // panel and the panel's inner widths snap while THIS host's width
269
268
  // transition plays — expanding must not replay the sidebar slide.
270
269
  data-pinning={rail.pinning.value || undefined}
271
- 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
+ }
272
281
  onPointerenter={railChrome.value ? onHostPointerEnter : undefined}
273
282
  onPointerleave={railChrome.value ? rail.onPointerLeave : undefined}
274
283
  onPointermove={railChrome.value ? onHostPointerMove : undefined}
@@ -206,22 +206,22 @@
206
206
  will-change: transform;
207
207
  }
208
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] {
209
+ /* THE SINGLE-GLYPH CONTRACT (2026-11-02 user direction: the flight
210
+ * used to double-expose — the clone crossed paths with the entering
211
+ * icon dissolving in at the destination). While a flight runs, BOTH
212
+ * ends of every matched pair hide ([data-flip-flying], stamped on the
213
+ * leaving AND the entering cell by the choreographer, swept with the
214
+ * layer): the clone is the one visible glyph of that destination from
215
+ * arm to landing, and the entering icon only resurfaces — via the same
216
+ * sweep, in the same task — once the clone has landed on it. Scoped
217
+ * deliberately WITHOUT the items' dissolve classes: those are
218
+ * transient engine state, and the mark alone already names exactly the
219
+ * substituted cells. An opted-in cell without a partner (a rail
220
+ * fallback letter with no expanded counterpart) is never stamped, so
221
+ * it dissolves instead of vanishing without a substitute — the pairing
222
+ * contract. Identity blocks, carets, badges and every other icon keep
223
+ * the plain dissolve. */
224
+ .hk-sidebar-rail-swap[data-flipping] .hk-menu-item-icon[data-flip-flying] {
225
225
  visibility: hidden;
226
226
  }
227
227
 
@@ -264,30 +264,41 @@ describe("HkSidebarRail geometry contract", () => {
264
264
  expect(clone).toContain("position: absolute");
265
265
  expect(clone).toContain("pointer-events: none");
266
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).
267
+ // The hide rule enforces the SINGLE-GLYPH CONTRACT (2026-11-02 user
268
+ // direction: the clone and the dissolving-in entering icon used to
269
+ // double-expose mid-flight): while a flight is armed
270
+ // ([data-flipping] on the swap host), every cell the choreographer
271
+ // marked ([data-flip-flying] — stamped on BOTH ends of a matched
272
+ // pair, swept with the layer) hides, so the clone is the one
273
+ // visible glyph from arm to landing. Deliberately NOT scoped to the
274
+ // items' dissolve classes — those are transient engine state (the
275
+ // happy-dom lesson), and the mark alone already names exactly the
276
+ // substituted cells. The selector is composed from the composable's
277
+ // EXPORTED CONSTANTS — the welding that keeps the JSX/SCSS literals
278
+ // and the JS pairings one vocabulary (a literal drift anywhere turns
279
+ // this red instead of silently double-exposing the glyph).
277
280
  const hideSelector =
278
281
  `.hk-sidebar-rail-swap[${RAIL_FLIP_ACTIVE_ATTR}] ` +
279
- `.hk-crossfade-item.hk-crossfade-leave-active ` +
280
282
  `.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
283
+ // Rule-boundary anchored: ruleBody's plain match is a SUBSTRING
284
+ // lookup, so a longer selector that merely CONTAINS the welded form
285
+ // (a namespaced prefix, a malformed double selector) would pass.
286
+ // Demanding a rule edge (start of stylesheet or a closing brace)
287
+ // before the selector closes that hole.
288
+ const hide = css.match(
289
+ new RegExp(
290
+ "(^|})\\s*" + hideSelector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*\\{([^}]*)\\}",
291
+ ),
292
+ );
293
+ expect(hide, "the single-glyph hide rule exists as ITS OWN rule").not.toBeNull();
294
+ // Group 2 is the rule body (group 1 is the boundary anchor).
295
+ expect(hide![2]).toContain("visibility: hidden");
296
+ // The sheet must not hide on the OPT-IN attribute alone — that
285
297
  // spelling would vanish unmatched fallbacks without a substitute.
286
298
  expect(
287
299
  ruleBody(
288
300
  css,
289
301
  `.hk-sidebar-rail-swap[${RAIL_FLIP_ACTIVE_ATTR}] ` +
290
- `.hk-crossfade-item.hk-crossfade-leave-active ` +
291
302
  `.hk-menu-item-icon[${RAIL_FLIP_ICON_ATTR}]`,
292
303
  ),
293
304
  "hiding keyed to the opt-in attribute alone is the S1-1 regression",
@@ -102,9 +102,10 @@ export default defineComponent({
102
102
  rail: { type: Boolean, default: false },
103
103
  /** Pinned width of the RAIL generation. The host's collapsed-width
104
104
  * token (HkSidebar's `collapsedWidth`, the shell's
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. */
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. */
108
109
  railWidth: { type: String, default: "56px" },
109
110
  /** Pinned width of the EXPANDED generation. The host's full sidebar
110
111
  * width token — a CSS length or a var() reference. */
@@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
3
3
 
4
4
  import {
5
- enterDelayMs,
5
+ cssDurationMs,
6
6
  RAIL_FLIGHT_MS,
7
- RAIL_FLIGHT_SWEEP_MS,
7
+ RAIL_FLIGHT_SWEEP_MARGIN_MS,
8
8
  RAIL_FLIP_ACTIVE_ATTR,
9
9
  RAIL_FLIP_FLYING_ATTR,
10
10
  RAIL_FLIP_ICON_ATTR,
@@ -32,7 +32,7 @@ import HkSidebarRailSwap from "../components/HkSidebarRailSwap";
32
32
  *
33
33
  * The assertions pin the choreography DECISIONS:
34
34
  * 1. A flip with moving keyed icons arms the flight: `data-flipping` on
35
- * the swap host, a flight layer on the ENTERING item with one clone
35
+ * the swap host, a flight layer ON THE HOST with one clone
36
36
  * per PAIRED key, each clone painted at the leaving rect (logical
37
37
  * insets relative to the stage) and animated by the signed delta.
38
38
  * 2. The pairing contract is visible: matched leaving cells carry the
@@ -202,24 +202,30 @@ afterEach(() => {
202
202
  for (const app of mounts.splice(0)) app.unmount();
203
203
  containers.splice(0).forEach((c) => c.remove());
204
204
  document.body.innerHTML = "";
205
+ // Restores every vi.spyOn (getComputedStyle / matchMedia / rect
206
+ // stubs) even when a test failed before its own inline restore —
207
+ // the probes recorded the leak as harmless, but the class is free
208
+ // to close.
209
+ vi.restoreAllMocks();
205
210
  vi.unstubAllGlobals();
206
211
  vi.useRealTimers();
207
212
  __teardownMediaQueries();
208
213
  });
209
214
 
210
- describe("enterDelayMs", () => {
211
- it("parses the enter-hold property shapes", () => {
212
- expect(enterDelayMs("")).toBe(0);
213
- expect(enterDelayMs("0ms")).toBe(0);
214
- expect(enterDelayMs("150ms")).toBe(150);
215
- expect(enterDelayMs("0.2s")).toBe(200);
216
- expect(enterDelayMs("garbage")).toBe(0);
217
- expect(enterDelayMs("-5ms")).toBe(0);
215
+ describe("cssDurationMs", () => {
216
+ it("parses the CSS time property shapes", () => {
217
+ expect(cssDurationMs("")).toBe(0);
218
+ expect(cssDurationMs("0ms")).toBe(0);
219
+ expect(cssDurationMs("150ms")).toBe(150);
220
+ expect(cssDurationMs("0.2s")).toBe(200);
221
+ expect(cssDurationMs("300ms")).toBe(300);
222
+ expect(cssDurationMs("garbage")).toBe(0);
223
+ expect(cssDurationMs("-5ms")).toBe(0);
218
224
  });
219
225
  });
220
226
 
221
227
  describe("useRailIconFlight", () => {
222
- it("arms a flight on collapse: matched cells marked, layer on the entering item, clones at the leaving rects", async () => {
228
+ it("arms a flight on collapse: matched cells marked, layer on the swap host, clones at the leaving rects", async () => {
223
229
  const h = mountSwap();
224
230
  await h.set(true);
225
231
  // Expanded geometry: icon cells at x=32 (the leading padding column).
@@ -243,13 +249,23 @@ describe("useRailIconFlight", () => {
243
249
  // Two MATCHED keys fly; "rail-only"/"full-only" have no partner and
244
250
  // must dissolve instead of being invented a source.
245
251
  expect(layer!.querySelectorAll(".hk-sidebar-rail-flight-clone")).toHaveLength(2);
246
- // Each clone animates by the SIGNED delta (collapse: 12px leftward).
252
+ // Each clone animates by the SIGNED delta (collapse: 12px leftward)
253
+ // at the FALLBACK window (the test DOM resolves no custom
254
+ // properties, so --duration-normal reads empty).
247
255
  const home = animations.find((a) => a.el.textContent === "H");
248
256
  expect(home, "the home clone animates").toBeTruthy();
249
257
  expect(home!.options.duration).toBe(RAIL_FLIGHT_MS);
250
258
  expect(home!.keyframes.at(-1)!.transform).toBe("translate(-12px, 0px)");
251
259
  const inbox = animations.find((a) => a.el.textContent === "I");
252
260
  expect(inbox!.keyframes.at(-1)!.transform).toBe("translate(-12px, 0px)");
261
+ // SINGLE GLYPH: pure travel — no opacity keyframe anywhere, the
262
+ // clone holds full opacity to the landing (the old end-fade is what
263
+ // double-exposed it against the dissolving-in entering icon).
264
+ for (const a of animations) {
265
+ expect(a.keyframes.some((f) => "opacity" in (f as PropertyIndexedKeyframes)), "no fade in the flight").toBe(false);
266
+ }
267
+ expect(home!.keyframes).toHaveLength(2);
268
+ expect(home!.options.fill, "a slipped frame parks the clone at the landing rect").toBe("forwards");
253
269
  // The clone is PAINTED at the leaving rect as a logical inset from
254
270
  // the stage's stable inline-start edge…
255
271
  const homeClone = layer!.querySelector<HTMLElement>(".hk-sidebar-rail-flight-clone")!;
@@ -257,14 +273,59 @@ describe("useRailIconFlight", () => {
257
273
  expect(homeClone.style.insetBlockStart).toBe("100px");
258
274
  // …and carries no flip identity of its own.
259
275
  expect(homeClone.getAttribute(RAIL_FLIP_ICON_ATTR)).toBeNull();
260
- // The pairing contract, visible: matched leaving cells are stamped
261
- // for the hide rule; the partnerless LEAVING cell ("full-only" has
262
- // no rail counterpart) is NOT (it must dissolve, not vanish
263
- // un-substituted).
276
+ expect(homeClone.hasAttribute(RAIL_FLIP_FLYING_ATTR), "the clone never hides itself").toBe(false);
277
+ // The pairing contract, visible: BOTH ends of a matched pair are
278
+ // stamped for the hide rule (the clone is the one glyph from arm to
279
+ // landing — the entering cell hides too); the partnerless cells on
280
+ // either side ("full-only" leaving, "rail-only" entering) are NOT
281
+ // (they must dissolve, not vanish un-substituted).
264
282
  const leavingHome = leaving.querySelector(`[${RAIL_FLIP_ICON_ATTR}="home"]`)!;
265
- expect(leavingHome.hasAttribute(RAIL_FLIP_FLYING_ATTR), "matched cell marked").toBe(true);
283
+ expect(leavingHome.hasAttribute(RAIL_FLIP_FLYING_ATTR), "matched leaving cell marked").toBe(true);
284
+ const enteringHome = entering.querySelector(`[${RAIL_FLIP_ICON_ATTR}="home"]`)!;
285
+ expect(enteringHome.hasAttribute(RAIL_FLIP_FLYING_ATTR), "matched ENTERING cell marked too — single glyph").toBe(true);
266
286
  const solo = leaving.querySelector(`[${RAIL_FLIP_ICON_ATTR}="full-only"]`)!;
267
- expect(solo.hasAttribute(RAIL_FLIP_FLYING_ATTR), "unmatched cell never marked").toBe(false);
287
+ expect(solo.hasAttribute(RAIL_FLIP_FLYING_ATTR), "unmatched leaving cell never marked").toBe(false);
288
+ const soloIn = entering.querySelector(`[${RAIL_FLIP_ICON_ATTR}="rail-only"]`)!;
289
+ expect(soloIn.hasAttribute(RAIL_FLIP_FLYING_ATTR), "unmatched entering cell never marked").toBe(false);
290
+ });
291
+
292
+ it("derives the flight window from the theme's --duration-normal (no hardcoded mirror)", async () => {
293
+ // The window must READ the token the dissolve consumes: a theme
294
+ // retuned to 600ms retunes the flight with it, and the sweep budget
295
+ // stretches past the longer window (arm at 600, sweep at 900).
296
+ const h = mountSwap();
297
+ const realGCS = window.getComputedStyle.bind(window);
298
+ vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
299
+ const declared = realGCS(el as HTMLElement);
300
+ if (el !== h.swap()) return declared;
301
+ return new Proxy(declared, {
302
+ get(target, prop) {
303
+ if (prop === "getPropertyValue") {
304
+ return (name: string) =>
305
+ name === "--duration-normal" ? "600ms" : (target.getPropertyValue(name) ?? "");
306
+ }
307
+ const value = Reflect.get(target, prop, target);
308
+ return typeof value === "function" ? value.bind(target) : value;
309
+ },
310
+ }) as CSSStyleDeclaration;
311
+ });
312
+ await h.set(true);
313
+ const { leaving, entering } = dissolvePair(h, true);
314
+ stubRect(h.swap(), rect(0, 0, 240, 400));
315
+ stubIcons(leaving, (key) => rect(32, key === "home" ? 100 : 140));
316
+ stubIcons(entering, (key) => rect(20, key === "home" ? 100 : 140));
317
+
318
+ await vi.advanceTimersByTimeAsync(0);
319
+ expect(h.swap().getAttribute(RAIL_FLIP_ACTIVE_ATTR)).toBe("");
320
+ const home = animations.find((a) => a.el.textContent === "H")!;
321
+ expect(home.options.duration, "the flight window follows the theme token").toBe(600);
322
+ // The sweep is the DERIVED window plus margin: at 700ms the flight
323
+ // is still on stage; past 900ms the fallback collects it.
324
+ await vi.advanceTimersByTimeAsync(700);
325
+ expect(flightLayer(h), "still flying inside the token-derived window").toBeTruthy();
326
+ await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_SWEEP_MARGIN_MS + 20);
327
+ expect(flightLayer(h), "the sweep follows the stretched window").toBeNull();
328
+ vi.mocked(window.getComputedStyle).mockRestore();
268
329
  });
269
330
 
270
331
  it("divides the written geometry by the ancestor draw scale (root zoom)", async () => {
@@ -341,7 +402,7 @@ describe("useRailIconFlight", () => {
341
402
  await h.set(true);
342
403
  // Let the first flight arm AND settle (sweep budget), so the second
343
404
  // flip starts from a clean host.
344
- await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_SWEEP_MS + 50);
405
+ await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_MS + RAIL_FLIGHT_SWEEP_MARGIN_MS + 50);
345
406
  expect(flightLayer(h)).toBeNull();
346
407
  animations.length = 0;
347
408
 
@@ -455,9 +516,17 @@ describe("useRailIconFlight", () => {
455
516
  animations[0]!.fire("finish");
456
517
  expect(flightLayer(h), "the finish listener sweeps immediately").toBeNull();
457
518
  expect(h.swap().hasAttribute(RAIL_FLIP_ACTIVE_ATTR)).toBe(false);
458
- // The marks ride along: a swept flight unhides its cells.
519
+ // The marks ride along: a swept flight unhides BOTH ends — the
520
+ // leaving cell it substituted AND the entering cell it covered
521
+ // (deleting the unmark loop once stayed green on this side; the pin
522
+ // closes that hole — 2026-11-02 review S3-1).
459
523
  expect(
460
524
  leaving.querySelector(`[${RAIL_FLIP_ICON_ATTR}="home"]`)!.hasAttribute(RAIL_FLIP_FLYING_ATTR),
525
+ "leaving mark cleared",
526
+ ).toBe(false);
527
+ expect(
528
+ entering.querySelector(`[${RAIL_FLIP_ICON_ATTR}="home"]`)!.hasAttribute(RAIL_FLIP_FLYING_ATTR),
529
+ "ENTERING mark cleared",
461
530
  ).toBe(false);
462
531
  });
463
532
 
@@ -471,7 +540,7 @@ describe("useRailIconFlight", () => {
471
540
 
472
541
  await vi.advanceTimersByTimeAsync(0);
473
542
  expect(flightLayer(h)).toBeTruthy();
474
- await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_SWEEP_MS + 10);
543
+ await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_MS + RAIL_FLIGHT_SWEEP_MARGIN_MS + 10);
475
544
  expect(flightLayer(h), "the sweep timer removes the layer").toBeNull();
476
545
  expect(h.swap().hasAttribute(RAIL_FLIP_ACTIVE_ATTR)).toBe(false);
477
546
  });
@@ -51,22 +51,38 @@ import { releaseMediaQuery, useMediaQuery } from "../runtime/useMediaQuery";
51
51
  * (`drawnScale` — a root-level `html { zoom }` multiplies every
52
52
  * measured viewport px by k; values written back in local px would
53
53
  * render k times too far).
54
- * 3. While the flight runs, the leaving generation's MATCHED icons hide
55
- * (CSS keyed on the host's `data-flipping` + the leave item's
56
- * dissolve class + the per-cell `data-flip-flying` mark this
57
- * composable stamps on exactly the cells it cloned): the clone
58
- * substitutes them at the same geometry, full opacity — nothing
59
- * blinks. UNMATCHED keyed cells (a rail fallback letter with no
60
- * expanded counterpart, say) are never marked, never hidden — they
61
- * plain dissolve, exactly as the pairing contract promises. The
62
- * ENTERING icons keep the normal dissolve, so by the time the clone
63
- * lands and fades (last ~20% of the window) the real icon underneath
64
- * is nearly fully dissolved in and occupies the identical rect — the
65
- * handoff tax is a sub-frame dip well under the double-exposure
66
- * artifact this replaces.
54
+ * 3. THE SINGLE-GLYPH CONTRACT (2026-11-02 user direction: the flight
55
+ * used to double-expose — the flying clone crossed paths with the
56
+ * entering icon dissolving in at the destination, two glyphs on
57
+ * screen at once). Both ends of every matched pair are marked
58
+ * `data-flip-flying` and hidden for the flight's whole window (the
59
+ * rule keys on the host's `data-flipping` + the mark — item-local
60
+ * dissolve classes are deliberately NOT part of the selector: they
61
+ * are transient engine state, and the mark itself already names
62
+ * exactly the substituted cells): the clone is therefore the ONE
63
+ * visible glyph of that destination, from arm to landing. The clone
64
+ * holds full opacity the entire way (no end fade — the fade was the
65
+ * old handoff, and it is what showed two glyphs); it lands exactly
66
+ * as the entering dissolve reaches 100%, because both windows read
67
+ * the same theme duration. The sweep then removes the clone and
68
+ * unmarks the entering icon in one task — one paint shows the real
69
+ * icon alone. Even a slipped frame is benign: two identical opaque
70
+ * glyphs at the SAME rect read as one (the old artifact was two
71
+ * glyphs at DIFFERENT rects mid-crossing). UNMATCHED keyed cells (a
72
+ * rail fallback letter with no expanded counterpart) are never
73
+ * marked, never hidden — they plain dissolve, as the pairing
74
+ * contract promises.
67
75
  * 4. Non-participating icons (identity blocks, group carets, badge
68
76
  * dots), the pills, and the labels crossfade exactly as before.
69
77
  *
78
+ * TIMING IS TOKEN-DERIVED, NOT HARDCODED (2026-11-02 user direction):
79
+ * the flight window reads the theme's `--duration-normal` off the swap
80
+ * host's computed style — the very variable HkCrossfade's dissolve
81
+ * consumes — so a retuned theme retunes the flight with it.
82
+ * `RAIL_FLIGHT_MS` survives only as the fallback for environments
83
+ * where custom properties cannot be read (the test DOM); the sweep
84
+ * budget is the derived window plus a fixed margin.
85
+ *
70
86
  * GUARDS: skipped entirely under reduced motion, the global animation
71
87
  * switch (`html[data-css-animations="0"]`), a missing WAAPI, or when no
72
88
  * pair actually moves (identical positions — the dissolve alone is then
@@ -75,12 +91,11 @@ import { releaseMediaQuery, useMediaQuery } from "../runtime/useMediaQuery";
75
91
  * even where `finish` never fires (rAF-starved tabs); a fresh flip
76
92
  * sweeps any previous flight first, so flip-flopping can't stack layers.
77
93
  *
78
- * KNOWN LIMIT (documented, accepted): the window constants assume the
79
- * dissolve runs at its default timing. A host that sets HkCrossfade's
80
- * `--hk-crossfade-enter-delay` holds the entering fade back; the flight
81
- * reads the property off the swap host and widens its window and sweep
82
- * by the same amount, so the handoff lands after the icon has actually
83
- * faded in. No shipped rail host sets it today.
94
+ * The same read covers HkCrossfade's `--hk-crossfade-enter-delay`: a
95
+ * host that holds the entering fade back gets a widened window and
96
+ * sweep, and the single-glyph contract makes the alignment exact — the
97
+ * clone covers the destination until the real icon is fully faded in.
98
+ * No shipped rail host sets it today.
84
99
  */
85
100
 
86
101
  /** Row-key attribute on an icon cell that flies across a rail flip. */
@@ -94,24 +109,31 @@ export const RAIL_FLIP_ACTIVE_ATTR = "data-flipping";
94
109
  export const RAIL_FLIP_FLYING_ATTR = "data-flip-flying";
95
110
  /** HkCrossfade's per-item identity — the semantic pairing anchor. */
96
111
  export const RAIL_FLIP_ITEM_ATTR = "data-crossfade-key";
97
- /** Total flight window — parity with the dissolve's --duration-normal. */
112
+ /** Flight-window FALLBACK for when `--duration-normal` yields no
113
+ * parsable time: environments that resolve no custom properties (the
114
+ * test DOM), or a value cssDurationMs cannot read (`calc(…)`, `0s` —
115
+ * a 0s theme means an instant dissolve; the fallback flight covers
116
+ * the swap's settle frames and stays single-glyph). A real browser
117
+ * reads the theme token — see TIMING above. */
98
118
  export const RAIL_FLIGHT_MS = 300;
99
- /** The clone holds full opacity until this fraction of the window, then
100
- * fades over the rest — landing INTO the entering icon's own fade. */
101
- const FLIGHT_HANDOFF_RATIO = 0.78;
102
- /** Sweep fallback: above any healthy flight (HkCrossfade watchdog
103
- * parity — 450ms budget + margin) so a stalled clone can never linger. */
104
- export const RAIL_FLIGHT_SWEEP_MS = 600;
119
+ /** Sweep margin on top of the (token-derived) flight window: above any
120
+ * healthy completion (HkCrossfade watchdog parity) so a stalled clone
121
+ * can never linger, however long the theme draws the dissolve. */
122
+ export const RAIL_FLIGHT_SWEEP_MARGIN_MS = 300;
105
123
 
106
- /** Parse HkCrossfade's enter-hold custom property (`"120ms"`, `"0.15s"`,
107
- * `""`) into milliseconds. Anything unparsable is no hold at all. */
108
- export function enterDelayMs(value: string): number {
124
+ /** Parse a CSS time custom property (`"120ms"`, `"0.15s"`, `""`) into
125
+ * milliseconds. Anything unparsable or non-positive is 0 — the caller
126
+ * decides what 0 means (no hold, or the fallback window). */
127
+ export function cssDurationMs(value: string): number {
109
128
  if (!value) return 0;
110
129
  const n = parseFloat(value);
111
130
  if (!Number.isFinite(n) || n <= 0) return 0;
112
131
  return value.trim().endsWith("s") && !value.trim().endsWith("ms") ? n * 1000 : n;
113
132
  }
114
133
 
134
+ /** Back-compat alias: HkCrossfade's enter hold parses like any duration. */
135
+ export const enterDelayMs = cssDurationMs;
136
+
115
137
  /** One matched icon pair across the swap: live leaving geometry, entering
116
138
  * geometry, the leaving icon's computed color (svg stroke = currentColor),
117
139
  * and the entering cell to clone (its dress is the destination's). */
@@ -247,28 +269,39 @@ export function useRailIconFlight(
247
269
  // column): the dissolve alone is already continuous — no layer.
248
270
  if (!pairs.some(({ from, to }) => from.left !== to.left || from.top !== to.top)) return;
249
271
 
250
- // Timing: the flight mirrors the dissolve's window, widened by any
251
- // enter hold the host choreographed (see KNOWN LIMIT above).
252
- const delay = enterDelayMs(
253
- getComputedStyle(host).getPropertyValue("--hk-crossfade-enter-delay"),
254
- );
255
- const flightMs = RAIL_FLIGHT_MS + delay;
272
+ // Timing is TOKEN-DERIVED: the flight window reads the theme's
273
+ // --duration-normal (the very variable the dissolve consumes), plus
274
+ // any enter hold the host choreographed. Both ends of the window
275
+ // therefore align with the entering fade without a single
276
+ // hard-coded mirror of the theme's number.
277
+ const vars = getComputedStyle(host);
278
+ const delay = cssDurationMs(vars.getPropertyValue("--hk-crossfade-enter-delay"));
279
+ const dissolveMs =
280
+ cssDurationMs(vars.getPropertyValue("--duration-normal")) || RAIL_FLIGHT_MS;
281
+ const flightMs = dissolveMs + delay;
282
+ const sweepMs = flightMs + RAIL_FLIGHT_SWEEP_MARGIN_MS;
256
283
 
257
284
  host.setAttribute(RAIL_FLIP_ACTIVE_ATTR, "");
258
285
  // The fallback sweep arms BEFORE any clone animates: a throwing
259
286
  // `animate` mid-loop must not strand the freshly hidden marks until
260
287
  // the next flip (2026-11-02 review).
261
- // Stamp exactly the cells being substituted BEFORE any paint with the
262
- // layer up: the hide rule reads this mark, so an opted-in cell
263
- // without a clone partner keeps dissolving (2026-11-02 review S1-1).
288
+ // Stamp BOTH ends of every matched pair BEFORE any paint with the
289
+ // layer up — the single-glyph contract: the leaving cell hides
290
+ // because the clone substitutes it, and the ENTERING cell hides
291
+ // because the clone IS it until landing; two glyphs of one
292
+ // destination must never share a frame. Unmatched opted-in cells
293
+ // stay unmarked and dissolve (2026-11-02 review S1-1). The clone
294
+ // strips the mark again right after cloneNode, so the layer never
295
+ // hides its own citizens.
264
296
  for (const pair of pairs) {
265
297
  pair.fromEl.setAttribute(RAIL_FLIP_FLYING_ATTR, "");
266
- hidden.push(pair.fromEl);
298
+ pair.template.setAttribute(RAIL_FLIP_FLYING_ATTR, "");
299
+ hidden.push(pair.fromEl, pair.template);
267
300
  }
268
301
  layer = document.createElement("div");
269
302
  layer.className = "hk-sidebar-rail-flight";
270
303
  layer.setAttribute("aria-hidden", "true");
271
- sweepTimer = setTimeout(() => sweepIfCurrent(flightId), RAIL_FLIGHT_SWEEP_MS + delay);
304
+ sweepTimer = setTimeout(() => sweepIfCurrent(flightId), sweepMs);
272
305
  // The host's inline-start edge is the docked edge — a stable origin
273
306
  // in both directions (see the mechanism note above). Its draw scale
274
307
  // converts measured viewport px into the local px the layer's own
@@ -302,17 +335,20 @@ export function useRailIconFlight(
302
335
  clone.style.width = `${pair.from.width / scale.x}px`;
303
336
  clone.style.height = `${pair.from.height / scale.y}px`;
304
337
  clone.style.color = pair.color;
338
+ // Pure travel: full opacity the whole way, `fill: forwards` so a
339
+ // frame that slips past finish still shows the clone AT the
340
+ // landing rect (same-geometry overlap with the real icon reads
341
+ // as one glyph) instead of snapping back to the origin.
305
342
  const anim = clone.animate(
306
343
  [
307
- { transform: "translate(0, 0)", opacity: "1" },
308
- {
309
- transform: `translate(${dx}px, ${dy}px)`,
310
- opacity: "1",
311
- offset: FLIGHT_HANDOFF_RATIO,
312
- },
313
- { transform: `translate(${dx}px, ${dy}px)`, opacity: "0" },
344
+ { transform: "translate(0, 0)" },
345
+ { transform: `translate(${dx}px, ${dy}px)` },
314
346
  ],
315
- { duration: flightMs, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
347
+ {
348
+ duration: flightMs,
349
+ easing: "cubic-bezier(0.22, 1, 0.36, 1)",
350
+ fill: "forwards",
351
+ },
316
352
  );
317
353
  // Same duration on every clone: the first settle sweeps the
318
354
  // layer — but only while THIS flight is on stage (a replaced