@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 +1 -1
- package/src/components/HkSidebarRail.scss +16 -16
- package/src/components/HkSidebarRail.styles.test.ts +28 -17
- package/src/components/HkSidebarRailSwap.tsx +5 -3
- package/src/composables/useRailIconFlight.test.ts +119 -23
- package/src/composables/useRailIconFlight.ts +114 -49
package/package.json
CHANGED
|
@@ -206,22 +206,22 @@
|
|
|
206
206
|
will-change: transform;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
/*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
.
|
|
223
|
-
|
|
224
|
-
|
|
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,
|
|
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
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// ([data-flip-flying]
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
//
|
|
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
|
|
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
|
*
|
|
@@ -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
|
-
|
|
5
|
+
cssDurationMs,
|
|
6
6
|
RAIL_FLIGHT_MS,
|
|
7
|
-
|
|
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;
|
|
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
|
});
|
|
@@ -212,14 +224,17 @@ afterEach(() => {
|
|
|
212
224
|
__teardownMediaQueries();
|
|
213
225
|
});
|
|
214
226
|
|
|
215
|
-
describe("
|
|
216
|
-
it("parses the
|
|
217
|
-
expect(
|
|
218
|
-
expect(
|
|
219
|
-
expect(
|
|
220
|
-
expect(
|
|
221
|
-
expect(
|
|
222
|
-
expect(
|
|
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
|
-
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
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(
|
|
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.
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* handoff
|
|
66
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
107
|
-
*
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
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
|
|
262
|
-
// layer up
|
|
263
|
-
//
|
|
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
|
-
|
|
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),
|
|
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)"
|
|
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
|
-
{
|
|
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);
|