@celestia-island/hikari 0.55.266 → 0.55.268

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.266",
3
+ "version": "0.55.268",
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",
@@ -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
 
@@ -246,7 +246,7 @@ describe("HkSidebarRail geometry contract", () => {
246
246
  expect(btn).toContain("--hi-icon-button-radius: 9999px");
247
247
  });
248
248
 
249
- it("hosts the rail flip's icon flight: paint-only layer, dead clones, triply-scoped hide", () => {
249
+ it("hosts the rail flip's icon flight: paint-only layer, dead clones, single-glyph hide", () => {
250
250
  // useRailIconFlight clones the flipping icons onto a layer ABOVE the
251
251
  // dissolve. The layer is chrome: it must never intercept a pointer
252
252
  // (the leaving generation is pointer-dead for the dissolve and the
@@ -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",
@@ -84,9 +84,11 @@ import "./HkSidebarRail.scss";
84
84
  * icon slides slightly left as it collapses"). This component hands the
85
85
  * flip to the flight choreographer: icons opted in by HkMenu
86
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
87
+ * animated from the leaving rect to the entering one, while BOTH ends
88
+ * of each matched pair hide (`data-flipping` + the per-cell
89
+ * `data-flip-flying` mark — the single-glyph contract: the clone is the
90
+ * one visible glyph from arm to landing) and the rest of the content
91
+ * keeps the plain dissolve. Skipped under reduced motion / the
90
92
  * animation switch / no WAAPI / no matching pair — every such case
91
93
  * degrades to today's dissolve.
92
94
  *
@@ -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,
@@ -43,7 +43,8 @@ import HkSidebarRailSwap from "../components/HkSidebarRailSwap";
43
43
  * motion, or a dissolved-away leaving generation → no flight.
44
44
  * 4. The lifecycle: the WAAPI `finish` sweeps the layer; the sweep timer
45
45
  * backstops it; a fresh flip replaces (never stacks) the previous
46
- * flight; unmount cleans up.
46
+ * flight; scope disposal tears a live flight down (the afterEach
47
+ * unmount walks the same path).
47
48
  */
48
49
 
49
50
  const mounts: App[] = [];
@@ -54,6 +55,7 @@ interface RecordedAnimation {
54
55
  keyframes: Keyframe[];
55
56
  options: KeyframeAnimationOptions;
56
57
  fire(type: "finish" | "cancel"): void;
58
+ cancelCount(): number;
57
59
  }
58
60
  let animations: RecordedAnimation[] = [];
59
61
 
@@ -182,16 +184,26 @@ beforeEach(() => {
182
184
  options?: KeyframeAnimationOptions,
183
185
  ) {
184
186
  const listeners = new Map<string, EventListener>();
187
+ let cancels = 0;
185
188
  const handle = {
186
189
  addEventListener: (type: string, listener: EventListener) => {
187
190
  listeners.set(type, listener);
188
191
  },
189
- cancel: () => listeners.get("cancel")?.(new Event("cancel")),
192
+ cancel: () => {
193
+ cancels++;
194
+ listeners.get("cancel")?.(new Event("cancel"));
195
+ },
190
196
  fire(type: "finish" | "cancel") {
191
197
  listeners.get(type)?.(new Event(type));
192
198
  },
193
199
  };
194
- animations.push({ el: this, keyframes, options: options ?? ({} as KeyframeAnimationOptions), fire: handle.fire });
200
+ animations.push({
201
+ el: this,
202
+ keyframes,
203
+ options: options ?? ({} as KeyframeAnimationOptions),
204
+ fire: handle.fire,
205
+ cancelCount: () => cancels,
206
+ });
195
207
  return handle as unknown as Animation;
196
208
  } as typeof animatable.animate;
197
209
  });
@@ -212,14 +224,17 @@ afterEach(() => {
212
224
  __teardownMediaQueries();
213
225
  });
214
226
 
215
- describe("enterDelayMs", () => {
216
- it("parses the enter-hold property shapes", () => {
217
- expect(enterDelayMs("")).toBe(0);
218
- expect(enterDelayMs("0ms")).toBe(0);
219
- expect(enterDelayMs("150ms")).toBe(150);
220
- expect(enterDelayMs("0.2s")).toBe(200);
221
- expect(enterDelayMs("garbage")).toBe(0);
222
- expect(enterDelayMs("-5ms")).toBe(0);
227
+ describe("cssDurationMs", () => {
228
+ it("parses the CSS time property shapes", () => {
229
+ expect(cssDurationMs("")).toBe(0);
230
+ expect(cssDurationMs("0ms")).toBe(0);
231
+ expect(cssDurationMs("150ms")).toBe(150);
232
+ expect(cssDurationMs("0.2s")).toBe(200);
233
+ expect(cssDurationMs("300ms")).toBe(300);
234
+ expect(cssDurationMs("0.3S"), "CSS units are case-insensitive").toBe(300);
235
+ expect(cssDurationMs("150MS")).toBe(150);
236
+ expect(cssDurationMs("garbage")).toBe(0);
237
+ expect(cssDurationMs("-5ms")).toBe(0);
223
238
  });
224
239
  });
225
240
 
@@ -248,13 +263,23 @@ describe("useRailIconFlight", () => {
248
263
  // Two MATCHED keys fly; "rail-only"/"full-only" have no partner and
249
264
  // must dissolve instead of being invented a source.
250
265
  expect(layer!.querySelectorAll(".hk-sidebar-rail-flight-clone")).toHaveLength(2);
251
- // Each clone animates by the SIGNED delta (collapse: 12px leftward).
266
+ // Each clone animates by the SIGNED delta (collapse: 12px leftward)
267
+ // at the FALLBACK window (the test DOM resolves no custom
268
+ // properties, so --duration-normal reads empty).
252
269
  const home = animations.find((a) => a.el.textContent === "H");
253
270
  expect(home, "the home clone animates").toBeTruthy();
254
271
  expect(home!.options.duration).toBe(RAIL_FLIGHT_MS);
255
272
  expect(home!.keyframes.at(-1)!.transform).toBe("translate(-12px, 0px)");
256
273
  const inbox = animations.find((a) => a.el.textContent === "I");
257
274
  expect(inbox!.keyframes.at(-1)!.transform).toBe("translate(-12px, 0px)");
275
+ // SINGLE GLYPH: pure travel — no opacity keyframe anywhere, the
276
+ // clone holds full opacity to the landing (the old end-fade is what
277
+ // double-exposed it against the dissolving-in entering icon).
278
+ for (const a of animations) {
279
+ expect(a.keyframes.some((f) => "opacity" in (f as PropertyIndexedKeyframes)), "no fade in the flight").toBe(false);
280
+ }
281
+ expect(home!.keyframes).toHaveLength(2);
282
+ expect(home!.options.fill, "a slipped frame parks the clone at the landing rect").toBe("forwards");
258
283
  // The clone is PAINTED at the leaving rect as a logical inset from
259
284
  // the stage's stable inline-start edge…
260
285
  const homeClone = layer!.querySelector<HTMLElement>(".hk-sidebar-rail-flight-clone")!;
@@ -262,14 +287,59 @@ describe("useRailIconFlight", () => {
262
287
  expect(homeClone.style.insetBlockStart).toBe("100px");
263
288
  // …and carries no flip identity of its own.
264
289
  expect(homeClone.getAttribute(RAIL_FLIP_ICON_ATTR)).toBeNull();
265
- // The pairing contract, visible: matched leaving cells are stamped
266
- // for the hide rule; the partnerless LEAVING cell ("full-only" has
267
- // no rail counterpart) is NOT (it must dissolve, not vanish
268
- // un-substituted).
290
+ expect(homeClone.hasAttribute(RAIL_FLIP_FLYING_ATTR), "the clone never hides itself").toBe(false);
291
+ // The pairing contract, visible: BOTH ends of a matched pair are
292
+ // stamped for the hide rule (the clone is the one glyph from arm to
293
+ // landing — the entering cell hides too); the partnerless cells on
294
+ // either side ("full-only" leaving, "rail-only" entering) are NOT
295
+ // (they must dissolve, not vanish un-substituted).
269
296
  const leavingHome = leaving.querySelector(`[${RAIL_FLIP_ICON_ATTR}="home"]`)!;
270
- expect(leavingHome.hasAttribute(RAIL_FLIP_FLYING_ATTR), "matched cell marked").toBe(true);
297
+ expect(leavingHome.hasAttribute(RAIL_FLIP_FLYING_ATTR), "matched leaving cell marked").toBe(true);
298
+ const enteringHome = entering.querySelector(`[${RAIL_FLIP_ICON_ATTR}="home"]`)!;
299
+ expect(enteringHome.hasAttribute(RAIL_FLIP_FLYING_ATTR), "matched ENTERING cell marked too — single glyph").toBe(true);
271
300
  const solo = leaving.querySelector(`[${RAIL_FLIP_ICON_ATTR}="full-only"]`)!;
272
- expect(solo.hasAttribute(RAIL_FLIP_FLYING_ATTR), "unmatched cell never marked").toBe(false);
301
+ expect(solo.hasAttribute(RAIL_FLIP_FLYING_ATTR), "unmatched leaving cell never marked").toBe(false);
302
+ const soloIn = entering.querySelector(`[${RAIL_FLIP_ICON_ATTR}="rail-only"]`)!;
303
+ expect(soloIn.hasAttribute(RAIL_FLIP_FLYING_ATTR), "unmatched entering cell never marked").toBe(false);
304
+ });
305
+
306
+ it("derives the flight window from the theme's --duration-normal (no hardcoded mirror)", async () => {
307
+ // The window must READ the token the dissolve consumes: a theme
308
+ // retuned to 600ms retunes the flight with it, and the sweep budget
309
+ // stretches past the longer window (arm at 600, sweep at 900).
310
+ const h = mountSwap();
311
+ const realGCS = window.getComputedStyle.bind(window);
312
+ vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
313
+ const declared = realGCS(el as HTMLElement);
314
+ if (el !== h.swap()) return declared;
315
+ return new Proxy(declared, {
316
+ get(target, prop) {
317
+ if (prop === "getPropertyValue") {
318
+ return (name: string) =>
319
+ name === "--duration-normal" ? "600ms" : (target.getPropertyValue(name) ?? "");
320
+ }
321
+ const value = Reflect.get(target, prop, target);
322
+ return typeof value === "function" ? value.bind(target) : value;
323
+ },
324
+ }) as CSSStyleDeclaration;
325
+ });
326
+ await h.set(true);
327
+ const { leaving, entering } = dissolvePair(h, true);
328
+ stubRect(h.swap(), rect(0, 0, 240, 400));
329
+ stubIcons(leaving, (key) => rect(32, key === "home" ? 100 : 140));
330
+ stubIcons(entering, (key) => rect(20, key === "home" ? 100 : 140));
331
+
332
+ await vi.advanceTimersByTimeAsync(0);
333
+ expect(h.swap().getAttribute(RAIL_FLIP_ACTIVE_ATTR)).toBe("");
334
+ const home = animations.find((a) => a.el.textContent === "H")!;
335
+ expect(home.options.duration, "the flight window follows the theme token").toBe(600);
336
+ // The sweep is the DERIVED window plus margin: at 700ms the flight
337
+ // is still on stage; past 900ms the fallback collects it.
338
+ await vi.advanceTimersByTimeAsync(700);
339
+ expect(flightLayer(h), "still flying inside the token-derived window").toBeTruthy();
340
+ await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_SWEEP_MARGIN_MS + 20);
341
+ expect(flightLayer(h), "the sweep follows the stretched window").toBeNull();
342
+ vi.mocked(window.getComputedStyle).mockRestore();
273
343
  });
274
344
 
275
345
  it("divides the written geometry by the ancestor draw scale (root zoom)", async () => {
@@ -346,7 +416,7 @@ describe("useRailIconFlight", () => {
346
416
  await h.set(true);
347
417
  // Let the first flight arm AND settle (sweep budget), so the second
348
418
  // flip starts from a clean host.
349
- await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_SWEEP_MS + 50);
419
+ await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_MS + RAIL_FLIGHT_SWEEP_MARGIN_MS + 50);
350
420
  expect(flightLayer(h)).toBeNull();
351
421
  animations.length = 0;
352
422
 
@@ -386,6 +456,14 @@ describe("useRailIconFlight", () => {
386
456
  expect(h.swap().hasAttribute(RAIL_FLIP_ACTIVE_ATTR)).toBe(false);
387
457
  expect(flightLayer(h)).toBeNull();
388
458
  expect(animations).toHaveLength(0);
459
+ // Skip paths never STAMP either: no cell anywhere may carry the hide
460
+ // mark (the skip tests used to assert only the host attribute and
461
+ // the layer — a future reorder stamping before a veto would have
462
+ // stayed green — 2026-11-02 review F5).
463
+ expect(
464
+ h.swap().querySelectorAll(`[${RAIL_FLIP_FLYING_ATTR}]`),
465
+ "a skipped flight marks nothing",
466
+ ).toHaveLength(0);
389
467
  });
390
468
 
391
469
  it("skips the flight when WAAPI is unavailable", async () => {
@@ -460,9 +538,27 @@ describe("useRailIconFlight", () => {
460
538
  animations[0]!.fire("finish");
461
539
  expect(flightLayer(h), "the finish listener sweeps immediately").toBeNull();
462
540
  expect(h.swap().hasAttribute(RAIL_FLIP_ACTIVE_ATTR)).toBe(false);
463
- // The marks ride along: a swept flight unhides its cells.
541
+ // LEAK PIN: the sweep CANCELS the clone animations, not just drops
542
+ // the layer — under the spec's replace-only auto-removal a finished
543
+ // fill:forwards animation on a never-again-animated element would
544
+ // strand Animation→effect→clone forever (2026-11-02 reviews: one
545
+ // reading says leak, a real-Chromium probe says element removal
546
+ // already clears getAnimations(); the cancel makes BOTH readings
547
+ // true and costs nothing).
548
+ for (const a of animations) {
549
+ expect(a.cancelCount(), "sweep cancels every clone animation").toBeGreaterThan(0);
550
+ }
551
+ // The marks ride along: a swept flight unhides BOTH ends — the
552
+ // leaving cell it substituted AND the entering cell it covered
553
+ // (deleting the unmark loop once stayed green on this side; the pin
554
+ // closes that hole — 2026-11-02 review S3-1).
464
555
  expect(
465
556
  leaving.querySelector(`[${RAIL_FLIP_ICON_ATTR}="home"]`)!.hasAttribute(RAIL_FLIP_FLYING_ATTR),
557
+ "leaving mark cleared",
558
+ ).toBe(false);
559
+ expect(
560
+ entering.querySelector(`[${RAIL_FLIP_ICON_ATTR}="home"]`)!.hasAttribute(RAIL_FLIP_FLYING_ATTR),
561
+ "ENTERING mark cleared",
466
562
  ).toBe(false);
467
563
  });
468
564
 
@@ -476,7 +572,7 @@ describe("useRailIconFlight", () => {
476
572
 
477
573
  await vi.advanceTimersByTimeAsync(0);
478
574
  expect(flightLayer(h)).toBeTruthy();
479
- await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_SWEEP_MS + 10);
575
+ await vi.advanceTimersByTimeAsync(RAIL_FLIGHT_MS + RAIL_FLIGHT_SWEEP_MARGIN_MS + 10);
480
576
  expect(flightLayer(h), "the sweep timer removes the layer").toBeNull();
481
577
  expect(h.swap().hasAttribute(RAIL_FLIP_ACTIVE_ATTR)).toBe(false);
482
578
  });
@@ -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,19 @@ 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.
99
+ *
100
+ * KNOWN CEILING (2026-11-02 review, pre-existing): HkCrossfade's own
101
+ * stall watchdog is a fixed 450ms (CROSSFADE_WATCHDOG_MS). A theme that
102
+ * raises `--duration-normal` past it gets its healthy dissolve swept by
103
+ * the watchdog before the CSS completes — the flight follows the token
104
+ * faithfully, but the dissolve it mirrors is cut short. Tokenizing that
105
+ * watchdog is its own change; until then the practical retune ceiling
106
+ * is 450ms.
84
107
  */
85
108
 
86
109
  /** Row-key attribute on an icon cell that flies across a rail flip. */
@@ -94,24 +117,33 @@ export const RAIL_FLIP_ACTIVE_ATTR = "data-flipping";
94
117
  export const RAIL_FLIP_FLYING_ATTR = "data-flip-flying";
95
118
  /** HkCrossfade's per-item identity — the semantic pairing anchor. */
96
119
  export const RAIL_FLIP_ITEM_ATTR = "data-crossfade-key";
97
- /** Total flight window — parity with the dissolve's --duration-normal. */
120
+ /** Flight-window FALLBACK for when `--duration-normal` yields no
121
+ * parsable time: environments that resolve no custom properties (the
122
+ * test DOM), or a value cssDurationMs cannot read (`calc(…)`, `0s` —
123
+ * a 0s theme means an instant dissolve; the fallback flight covers
124
+ * the swap's settle frames and stays single-glyph). A real browser
125
+ * reads the theme token — see TIMING above. */
98
126
  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;
127
+ /** Sweep margin on top of the (token-derived) flight window: above any
128
+ * healthy completion (HkCrossfade watchdog parity) so a stalled clone
129
+ * can never linger, however long the theme draws the dissolve. */
130
+ export const RAIL_FLIGHT_SWEEP_MARGIN_MS = 300;
105
131
 
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 {
132
+ /** Parse a CSS time custom property (`"120ms"`, `"0.15s"`, `""`) into
133
+ * milliseconds. Anything unparsable or non-positive is 0 — the caller
134
+ * decides what 0 means (no hold, or the fallback window). Units are
135
+ * case-insensitive in CSS (`"0.3S"` is legal): the check lowercases
136
+ * first, or an uppercase second would slip through as 0.3ms — 2026-11-02
137
+ * review F3. */
138
+ export function cssDurationMs(value: string): number {
109
139
  if (!value) return 0;
110
140
  const n = parseFloat(value);
111
141
  if (!Number.isFinite(n) || n <= 0) return 0;
112
- return value.trim().endsWith("s") && !value.trim().endsWith("ms") ? n * 1000 : n;
142
+ const unit = value.trim().toLowerCase();
143
+ return unit.endsWith("s") && !unit.endsWith("ms") ? n * 1000 : n;
113
144
  }
114
145
 
146
+
115
147
  /** One matched icon pair across the swap: live leaving geometry, entering
116
148
  * geometry, the leaving icon's computed color (svg stroke = currentColor),
117
149
  * and the entering cell to clone (its dress is the destination's). */
@@ -149,6 +181,18 @@ export function useRailIconFlight(
149
181
  let flightSeq = 0;
150
182
  let currentFlightId = -1;
151
183
  let layer: HTMLElement | null = null;
184
+ /** The current flight's clone animations. The sweep CANCELS them: a
185
+ * finished `fill: forwards` animation is auto-removed by the engine
186
+ * only when a LATER animation replaces it on the same target+property
187
+ * (the spec's replaceable-animations rule) — and every flight targets
188
+ * a brand-new clone, so nothing ever replaces it. Without the cancel,
189
+ * each flip strands Animation → effect → clone subtree on the document
190
+ * timeline forever (the spec itself warns the pattern accumulates
191
+ * "hundreds, even thousands"; hover-peeks arm flights constantly) —
192
+ * 2026-11-02 review S1. cancel() on an already-idle animation is a
193
+ * no-op, and the cancel listener routes through the idempotent
194
+ * sweepIfCurrent, so re-entrancy is harmless. */
195
+ let flightAnims: Animation[] = [];
152
196
  /** The leaving cells this flight hid — unmarked on every sweep so a
153
197
  * cancelled/dissolve-less path never leaves a stale hide mark. */
154
198
  let hidden: HTMLElement[] = [];
@@ -165,6 +209,12 @@ export function useRailIconFlight(
165
209
  clearTimeout(sweepTimer);
166
210
  sweepTimer = null;
167
211
  }
212
+ // Detach the ledger BEFORE cancelling: a synchronous cancel event
213
+ // (test stubs fire them inline; the browser queues them) re-enters
214
+ // sweep via the listener, and the emptied ledger breaks the cycle.
215
+ const anims = flightAnims;
216
+ flightAnims = [];
217
+ for (const anim of anims) anim.cancel();
168
218
  layer?.remove();
169
219
  layer = null;
170
220
  for (const el of hidden) el.removeAttribute(RAIL_FLIP_FLYING_ATTR);
@@ -247,28 +297,39 @@ export function useRailIconFlight(
247
297
  // column): the dissolve alone is already continuous — no layer.
248
298
  if (!pairs.some(({ from, to }) => from.left !== to.left || from.top !== to.top)) return;
249
299
 
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;
300
+ // Timing is TOKEN-DERIVED: the flight window reads the theme's
301
+ // --duration-normal (the very variable the dissolve consumes), plus
302
+ // any enter hold the host choreographed. Both ends of the window
303
+ // therefore align with the entering fade without a single
304
+ // hard-coded mirror of the theme's number.
305
+ const vars = getComputedStyle(host);
306
+ const delay = cssDurationMs(vars.getPropertyValue("--hk-crossfade-enter-delay"));
307
+ const dissolveMs =
308
+ cssDurationMs(vars.getPropertyValue("--duration-normal")) || RAIL_FLIGHT_MS;
309
+ const flightMs = dissolveMs + delay;
310
+ const sweepMs = flightMs + RAIL_FLIGHT_SWEEP_MARGIN_MS;
256
311
 
257
312
  host.setAttribute(RAIL_FLIP_ACTIVE_ATTR, "");
258
313
  // The fallback sweep arms BEFORE any clone animates: a throwing
259
314
  // `animate` mid-loop must not strand the freshly hidden marks until
260
315
  // 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).
316
+ // Stamp BOTH ends of every matched pair BEFORE any paint with the
317
+ // layer up — the single-glyph contract: the leaving cell hides
318
+ // because the clone substitutes it, and the ENTERING cell hides
319
+ // because the clone IS it until landing; two glyphs of one
320
+ // destination must never share a frame. Unmatched opted-in cells
321
+ // stay unmarked and dissolve (2026-11-02 review S1-1). The clone
322
+ // strips the mark again right after cloneNode, so the layer never
323
+ // hides its own citizens.
264
324
  for (const pair of pairs) {
265
325
  pair.fromEl.setAttribute(RAIL_FLIP_FLYING_ATTR, "");
266
- hidden.push(pair.fromEl);
326
+ pair.template.setAttribute(RAIL_FLIP_FLYING_ATTR, "");
327
+ hidden.push(pair.fromEl, pair.template);
267
328
  }
268
329
  layer = document.createElement("div");
269
330
  layer.className = "hk-sidebar-rail-flight";
270
331
  layer.setAttribute("aria-hidden", "true");
271
- sweepTimer = setTimeout(() => sweepIfCurrent(flightId), RAIL_FLIGHT_SWEEP_MS + delay);
332
+ sweepTimer = setTimeout(() => sweepIfCurrent(flightId), sweepMs);
272
333
  // The host's inline-start edge is the docked edge — a stable origin
273
334
  // in both directions (see the mechanism note above). Its draw scale
274
335
  // converts measured viewport px into the local px the layer's own
@@ -302,23 +363,27 @@ export function useRailIconFlight(
302
363
  clone.style.width = `${pair.from.width / scale.x}px`;
303
364
  clone.style.height = `${pair.from.height / scale.y}px`;
304
365
  clone.style.color = pair.color;
366
+ // Pure travel: full opacity the whole way, `fill: forwards` so a
367
+ // frame that slips past finish still shows the clone AT the
368
+ // landing rect (same-geometry overlap with the real icon reads
369
+ // as one glyph) instead of snapping back to the origin.
305
370
  const anim = clone.animate(
306
371
  [
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" },
372
+ { transform: "translate(0, 0)" },
373
+ { transform: `translate(${dx}px, ${dy}px)` },
314
374
  ],
315
- { duration: flightMs, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
375
+ {
376
+ duration: flightMs,
377
+ easing: "cubic-bezier(0.22, 1, 0.36, 1)",
378
+ fill: "forwards",
379
+ },
316
380
  );
317
381
  // Same duration on every clone: the first settle sweeps the
318
382
  // layer — but only while THIS flight is on stage (a replaced
319
383
  // flight's late finish is a no-op, never its successor's sweeper).
320
384
  anim.addEventListener("finish", () => sweepIfCurrent(flightId));
321
385
  anim.addEventListener("cancel", () => sweepIfCurrent(flightId));
386
+ flightAnims.push(anim);
322
387
  layer.appendChild(clone);
323
388
  }
324
389
  host.appendChild(layer);