@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
|
@@ -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,
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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;
|
|
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: () =>
|
|
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({
|
|
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
|
-
|
|
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);
|