@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 +1 -1
- package/src/components/HkFaceShell.rail.test.tsx +8 -0
- package/src/components/HkFaceShell.tsx +8 -1
- package/src/components/HkSidebar.rail.test.tsx +6 -0
- package/src/components/HkSidebar.scss +12 -8
- package/src/components/HkSidebar.styles.test.ts +4 -3
- package/src/components/HkSidebar.tsx +15 -6
- package/src/components/HkSidebarRail.scss +16 -16
- package/src/components/HkSidebarRail.styles.test.ts +27 -16
- package/src/components/HkSidebarRailSwap.tsx +4 -3
- package/src/composables/useRailIconFlight.test.ts +91 -22
- package/src/composables/useRailIconFlight.ts +84 -48
package/package.json
CHANGED
|
@@ -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={{
|
|
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
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* the vertical rhythm invariant above is untouched. The
|
|
111
|
-
*
|
|
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:
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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={
|
|
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
|
-
/*
|
|
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
|
|
|
@@ -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",
|
|
@@ -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).
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
|
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("
|
|
211
|
-
it("parses the
|
|
212
|
-
expect(
|
|
213
|
-
expect(
|
|
214
|
-
expect(
|
|
215
|
-
expect(
|
|
216
|
-
expect(
|
|
217
|
-
expect(
|
|
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
|
|
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
|
-
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
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(
|
|
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
|
|
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(
|
|
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.
|
|
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,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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
107
|
-
*
|
|
108
|
-
|
|
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
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
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
|
|
262
|
-
// layer up
|
|
263
|
-
//
|
|
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
|
-
|
|
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),
|
|
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)"
|
|
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
|
-
{
|
|
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
|