@celestia-island/hikari 0.55.267 → 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.267",
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",
@@ -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
@@ -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
  *
@@ -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
  });
@@ -219,6 +231,8 @@ describe("cssDurationMs", () => {
219
231
  expect(cssDurationMs("150ms")).toBe(150);
220
232
  expect(cssDurationMs("0.2s")).toBe(200);
221
233
  expect(cssDurationMs("300ms")).toBe(300);
234
+ expect(cssDurationMs("0.3S"), "CSS units are case-insensitive").toBe(300);
235
+ expect(cssDurationMs("150MS")).toBe(150);
222
236
  expect(cssDurationMs("garbage")).toBe(0);
223
237
  expect(cssDurationMs("-5ms")).toBe(0);
224
238
  });
@@ -442,6 +456,14 @@ describe("useRailIconFlight", () => {
442
456
  expect(h.swap().hasAttribute(RAIL_FLIP_ACTIVE_ATTR)).toBe(false);
443
457
  expect(flightLayer(h)).toBeNull();
444
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);
445
467
  });
446
468
 
447
469
  it("skips the flight when WAAPI is unavailable", async () => {
@@ -516,6 +538,16 @@ describe("useRailIconFlight", () => {
516
538
  animations[0]!.fire("finish");
517
539
  expect(flightLayer(h), "the finish listener sweeps immediately").toBeNull();
518
540
  expect(h.swap().hasAttribute(RAIL_FLIP_ACTIVE_ATTR)).toBe(false);
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
+ }
519
551
  // The marks ride along: a swept flight unhides BOTH ends — the
520
552
  // leaving cell it substituted AND the entering cell it covered
521
553
  // (deleting the unmark loop once stayed green on this side; the pin
@@ -96,6 +96,14 @@ import { releaseMediaQuery, useMediaQuery } from "../runtime/useMediaQuery";
96
96
  * sweep, and the single-glyph contract makes the alignment exact — the
97
97
  * clone covers the destination until the real icon is fully faded in.
98
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.
99
107
  */
100
108
 
101
109
  /** Row-key attribute on an icon cell that flies across a rail flip. */
@@ -123,16 +131,18 @@ export const RAIL_FLIGHT_SWEEP_MARGIN_MS = 300;
123
131
 
124
132
  /** Parse a CSS time custom property (`"120ms"`, `"0.15s"`, `""`) into
125
133
  * milliseconds. Anything unparsable or non-positive is 0 — the caller
126
- * decides what 0 means (no hold, or the fallback window). */
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. */
127
138
  export function cssDurationMs(value: string): number {
128
139
  if (!value) return 0;
129
140
  const n = parseFloat(value);
130
141
  if (!Number.isFinite(n) || n <= 0) return 0;
131
- 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;
132
144
  }
133
145
 
134
- /** Back-compat alias: HkCrossfade's enter hold parses like any duration. */
135
- export const enterDelayMs = cssDurationMs;
136
146
 
137
147
  /** One matched icon pair across the swap: live leaving geometry, entering
138
148
  * geometry, the leaving icon's computed color (svg stroke = currentColor),
@@ -171,6 +181,18 @@ export function useRailIconFlight(
171
181
  let flightSeq = 0;
172
182
  let currentFlightId = -1;
173
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[] = [];
174
196
  /** The leaving cells this flight hid — unmarked on every sweep so a
175
197
  * cancelled/dissolve-less path never leaves a stale hide mark. */
176
198
  let hidden: HTMLElement[] = [];
@@ -187,6 +209,12 @@ export function useRailIconFlight(
187
209
  clearTimeout(sweepTimer);
188
210
  sweepTimer = null;
189
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();
190
218
  layer?.remove();
191
219
  layer = null;
192
220
  for (const el of hidden) el.removeAttribute(RAIL_FLIP_FLYING_ATTR);
@@ -355,6 +383,7 @@ export function useRailIconFlight(
355
383
  // flight's late finish is a no-op, never its successor's sweeper).
356
384
  anim.addEventListener("finish", () => sweepIfCurrent(flightId));
357
385
  anim.addEventListener("cancel", () => sweepIfCurrent(flightId));
386
+ flightAnims.push(anim);
358
387
  layer.appendChild(clone);
359
388
  }
360
389
  host.appendChild(layer);