@celestia-island/hikari 0.40.18 → 0.40.21
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/HkColorSchemeEditor.test.ts +24 -1
- package/src/components/HkColorSchemeEditor.tsx +19 -7
- package/src/components/HkDrawer.tsx +97 -85
- package/src/components/HkModal.enter-watchdog.test.tsx +42 -19
- package/src/components/HkModal.transition-completion.test.tsx +21 -19
- package/src/components/HkModal.tsx +91 -199
- package/src/components/HkPopover.scss +18 -0
- package/src/components/HkPopover.surfacevars.test.ts +14 -0
- package/src/components/HkPopover.tsx +120 -89
- package/src/components/HkSelectPanel.test.tsx +7 -5
- package/src/components/HkSelectPanel.tsx +163 -231
- package/src/components/HkStatusBar.test.tsx +8 -8
- package/src/components/HkThemeToggle.test.ts +6 -2
- package/src/components/scrim-fade.contract.test.ts +14 -9
- package/src/composables/useSurfaceMachine.test.ts +259 -0
- package/src/composables/useSurfaceMachine.ts +289 -0
- package/src/runtime/surfaceMachine.test.ts +186 -0
- package/src/runtime/surfaceMachine.ts +229 -0
- package/src/runtime/transitionWatchdog.test.ts +0 -79
- package/src/runtime/transitionWatchdog.ts +0 -73
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SURFACE_EVENTS,
|
|
5
|
+
SURFACE_PHASES,
|
|
6
|
+
classPairOf,
|
|
7
|
+
classesForPair,
|
|
8
|
+
isMounted,
|
|
9
|
+
isQuiescent,
|
|
10
|
+
surfaceTransition,
|
|
11
|
+
type SurfaceEventType,
|
|
12
|
+
type SurfacePhase,
|
|
13
|
+
} from "./surfaceMachine";
|
|
14
|
+
|
|
15
|
+
/** The spec table as data — the single reviewable proof artifact. The
|
|
16
|
+
* reducer must agree with it on every cell; this test is the diff. */
|
|
17
|
+
const SPEC: Record<SurfacePhase, Record<SurfaceEventType, SurfacePhase>> = {
|
|
18
|
+
closed: {
|
|
19
|
+
OPEN: "openingFrom",
|
|
20
|
+
CLOSE: "closed",
|
|
21
|
+
FLIP: "closed",
|
|
22
|
+
DEADLINE: "closed",
|
|
23
|
+
TEND: "closed",
|
|
24
|
+
UNMOUNT: "closed",
|
|
25
|
+
},
|
|
26
|
+
openingFrom: {
|
|
27
|
+
OPEN: "openingFrom",
|
|
28
|
+
CLOSE: "closingFrom",
|
|
29
|
+
FLIP: "openingTo",
|
|
30
|
+
DEADLINE: "open",
|
|
31
|
+
TEND: "open",
|
|
32
|
+
UNMOUNT: "closed",
|
|
33
|
+
},
|
|
34
|
+
openingTo: {
|
|
35
|
+
OPEN: "openingTo",
|
|
36
|
+
CLOSE: "closingFrom",
|
|
37
|
+
FLIP: "openingTo",
|
|
38
|
+
DEADLINE: "open",
|
|
39
|
+
TEND: "open",
|
|
40
|
+
UNMOUNT: "closed",
|
|
41
|
+
},
|
|
42
|
+
open: {
|
|
43
|
+
OPEN: "open",
|
|
44
|
+
CLOSE: "closingFrom",
|
|
45
|
+
FLIP: "open",
|
|
46
|
+
DEADLINE: "open",
|
|
47
|
+
TEND: "open",
|
|
48
|
+
UNMOUNT: "closed",
|
|
49
|
+
},
|
|
50
|
+
closingFrom: {
|
|
51
|
+
OPEN: "openingFrom",
|
|
52
|
+
CLOSE: "closingFrom",
|
|
53
|
+
FLIP: "closingTo",
|
|
54
|
+
DEADLINE: "closed",
|
|
55
|
+
TEND: "closed",
|
|
56
|
+
UNMOUNT: "closed",
|
|
57
|
+
},
|
|
58
|
+
closingTo: {
|
|
59
|
+
OPEN: "openingFrom",
|
|
60
|
+
CLOSE: "closingTo",
|
|
61
|
+
FLIP: "closingTo",
|
|
62
|
+
DEADLINE: "closed",
|
|
63
|
+
TEND: "closed",
|
|
64
|
+
UNMOUNT: "closed",
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("surfaceMachine table", () => {
|
|
69
|
+
// T1 (totality + conformance): all 36 cells defined and matching the
|
|
70
|
+
// spec table — the reducer IS the table.
|
|
71
|
+
it("defines every (phase, event) cell exactly as the spec table", () => {
|
|
72
|
+
expect(SURFACE_PHASES).toHaveLength(6);
|
|
73
|
+
expect(SURFACE_EVENTS).toHaveLength(6);
|
|
74
|
+
for (const phase of SURFACE_PHASES) {
|
|
75
|
+
for (const event of SURFACE_EVENTS) {
|
|
76
|
+
const expected = SPEC[phase][event];
|
|
77
|
+
if (surfaceTransition(phase, event) !== expected) {
|
|
78
|
+
throw new Error(`table cell (${phase}, ${event}) diverges from the spec table`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// T2/T3 (outputs are pure functions of state): quiescent states carry
|
|
85
|
+
// no transition classes; only non-closed states mount.
|
|
86
|
+
it("quiescent phases paint no transition classes", () => {
|
|
87
|
+
for (const phase of SURFACE_PHASES) {
|
|
88
|
+
const classes = classesForPair("hk-x", classPairOf(phase));
|
|
89
|
+
if (isQuiescent(phase)) {
|
|
90
|
+
expect(classes).toEqual([]);
|
|
91
|
+
} else {
|
|
92
|
+
expect(classes.length).toBe(2);
|
|
93
|
+
}
|
|
94
|
+
expect(isMounted(phase) === (phase !== "closed")).toBe(true);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// T4 (liveness): one DEADLINE walks any animation phase to a
|
|
99
|
+
// quiescent phase — the A2 (rAF/transitionend starved) column.
|
|
100
|
+
it("a single DEADLINE settles every animation phase (starvation column)", () => {
|
|
101
|
+
for (const phase of SURFACE_PHASES) {
|
|
102
|
+
const next = surfaceTransition(phase, "DEADLINE");
|
|
103
|
+
expect(isQuiescent(next), `${phase} + DEADLINE`).toBe(true);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Idempotence: duplicating any event never moves the machine.
|
|
108
|
+
it("every event is idempotent in its target state", () => {
|
|
109
|
+
for (const phase of SURFACE_PHASES) {
|
|
110
|
+
for (const event of SURFACE_EVENTS) {
|
|
111
|
+
const once = surfaceTransition(phase, event);
|
|
112
|
+
expect(surfaceTransition(once, event)).toBe(once);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── Property-based exploration ─────────────────────────────────────
|
|
119
|
+
// Deterministic seeded RNG (xorshift32) — no PBT dependency, full
|
|
120
|
+
// reproducibility from the printed seed on failure.
|
|
121
|
+
|
|
122
|
+
function rng(seed: number): () => number {
|
|
123
|
+
let s = seed | 0 || 1;
|
|
124
|
+
return () => {
|
|
125
|
+
s ^= s << 13; s ^= s >>> 17; s ^= s << 5;
|
|
126
|
+
return (s >>> 0) / 0x100000000;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("surfaceMachine property-based exploration", () => {
|
|
131
|
+
const RUNS = 4000;
|
|
132
|
+
const MAX_EVENTS = 40;
|
|
133
|
+
|
|
134
|
+
// Invariants checked after EVERY event of every run:
|
|
135
|
+
// I-mounted: mounted(phase) ⟺ phase !== closed
|
|
136
|
+
// I-classes: transition classes only on animation phases (implied by
|
|
137
|
+
// classPairOf being total/pure — asserted via quiescence)
|
|
138
|
+
// I-quiesce: after draining (each request followed by its DEADLINE),
|
|
139
|
+
// the machine rests in {closed, open} — bounded settling
|
|
140
|
+
// without any FLIP/TEND ever arriving (axiom A2).
|
|
141
|
+
it("every random event stream settles quiescently under total frame starvation", () => {
|
|
142
|
+
for (let run = 0; run < RUNS; run++) {
|
|
143
|
+
const rand = rng(run + 1);
|
|
144
|
+
let phase: SurfacePhase = "closed";
|
|
145
|
+
for (let i = 0; i < MAX_EVENTS; i++) {
|
|
146
|
+
const pool: SurfaceEventType[] =
|
|
147
|
+
rand() < 0.5
|
|
148
|
+
? ["OPEN", "CLOSE", "UNMOUNT"] // starvation diet: no FLIP/TEND
|
|
149
|
+
: [...SURFACE_EVENTS];
|
|
150
|
+
const event = pool[Math.floor(rand() * pool.length)]!;
|
|
151
|
+
phase = surfaceTransition(phase, event);
|
|
152
|
+
// I-mounted
|
|
153
|
+
expect(phase === "closed" || isMounted(phase)).toBe(true);
|
|
154
|
+
// UNMOUNT absorbs: once sent, only OPEN can leave closed.
|
|
155
|
+
if (event === "UNMOUNT") {
|
|
156
|
+
expect(phase).toBe("closed");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Drain: one DEADLINE settles any animation phase (T4).
|
|
160
|
+
phase = surfaceTransition(phase, "DEADLINE");
|
|
161
|
+
expect(isQuiescent(phase), `run ${run} drained to ${phase}`).toBe(true);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Reversal safety: OPEN/CLOSE may interleave arbitrarily inside the
|
|
166
|
+
// animation windows; the machine must never enter a phase that paints
|
|
167
|
+
// resting classes while mounted=false (the "invisible with content"
|
|
168
|
+
// inversion) or transition classes while quiescent.
|
|
169
|
+
it("request interleavings never invert the mounted/classes relationship", () => {
|
|
170
|
+
for (let run = 0; run < 2000; run++) {
|
|
171
|
+
const rand = rng(10_000 + run);
|
|
172
|
+
let phase: SurfacePhase = "closed";
|
|
173
|
+
for (let i = 0; i < 30; i++) {
|
|
174
|
+
const event: SurfaceEventType =
|
|
175
|
+
rand() < 0.7 ? (rand() < 0.5 ? "OPEN" : "CLOSE") : "FLIP";
|
|
176
|
+
phase = surfaceTransition(phase, event);
|
|
177
|
+
const mounted = isMounted(phase);
|
|
178
|
+
const paintingTransitionClasses = !isQuiescent(phase);
|
|
179
|
+
// A mounted surface either rests (no classes) or animates
|
|
180
|
+
// (classes) — and classes imply mounted.
|
|
181
|
+
if (paintingTransitionClasses) expect(mounted).toBe(true);
|
|
182
|
+
if (!mounted) expect(phase).toBe("closed");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Surface lifecycle state machine — the provable core of every overlay
|
|
2
|
+
// surface (modal scrim+panel, sheet scrim+panel, popover) in hikari.
|
|
3
|
+
//
|
|
4
|
+
// WHY A MACHINE. The pre-machine design drove two independent Vue
|
|
5
|
+
// <Transition> engines off one boolean, with rAF/transitionend as the
|
|
6
|
+
// class-flip clock. Those events can starve (occluded webviews — the
|
|
7
|
+
// 2026-09 field reports), and two engines can diverge through the same
|
|
8
|
+
// event stream (the mobile "panel visible, scrim gone" state, later the
|
|
9
|
+
// full-opacity close flash — the "black rectangle"). The fixes of that
|
|
10
|
+
// era (#407 leave watchdog, #414 enter watchdog) REPAIRED observed bad
|
|
11
|
+
// states; a repair list is never complete.
|
|
12
|
+
//
|
|
13
|
+
// This module inverts the approach: the lifecycle is ONE explicit
|
|
14
|
+
// finite-state machine whose transition table is total by construction,
|
|
15
|
+
// whose outputs are pure functions of the state, and whose only
|
|
16
|
+
// correctness clock is setTimeout (the one primitive field-verified to
|
|
17
|
+
// fire under the starvation conditions that starve rAF). Bad states —
|
|
18
|
+
// divergent layers, stuck transition classes at rest — are UNREPRESENT-
|
|
19
|
+
// ABLE in the state space rather than detected and repaired.
|
|
20
|
+
//
|
|
21
|
+
// AXIOMS (the proof's ground rules):
|
|
22
|
+
// A1 setTimeout callbacks fire exactly once, within a bounded delay.
|
|
23
|
+
// A2 rAF and transitionend may NEVER fire. (The machine stays correct
|
|
24
|
+
// under A2 alone; both are advisory optimizations in the driver.)
|
|
25
|
+
// A3 Synchronous class/style writes take effect at the next style
|
|
26
|
+
// recalc. (What the compositor PAINTS is outside the axioms.)
|
|
27
|
+
//
|
|
28
|
+
// STATES — six, including the two-phase animation windows (a `.from`
|
|
29
|
+
// phase holds the from-class pair until a frame flip; a `.to` phase
|
|
30
|
+
// runs the CSS transition to the resting pair):
|
|
31
|
+
//
|
|
32
|
+
// closed no DOM, no timers, nothing registered
|
|
33
|
+
// openingFrom mounted, enter-from classes, flip pending
|
|
34
|
+
// openingTo mounted, enter-to classes, animating
|
|
35
|
+
// open mounted, resting classes, morph armed — the only
|
|
36
|
+
// state where size measurements are taken
|
|
37
|
+
// closingFrom mounted, leave-from classes, flip pending
|
|
38
|
+
// closingTo mounted, leave-to classes, animating
|
|
39
|
+
//
|
|
40
|
+
// EVENTS:
|
|
41
|
+
// OPEN/CLOSE surface request (user logic, back gesture, closeAll)
|
|
42
|
+
// FLIP frame flip (double-rAF or the flip timer — same event)
|
|
43
|
+
// DEADLINE phase deadline timer (the correctness clock)
|
|
44
|
+
// TEND transitionend (advisory early completion; the driver
|
|
45
|
+
// does not emit it — the cell exists so the table stays
|
|
46
|
+
// total for future adopters)
|
|
47
|
+
// UNMOUNT component teardown
|
|
48
|
+
//
|
|
49
|
+
// THE TABLE. Completeness is the table itself — every (state, event)
|
|
50
|
+
// cell is defined; table conformance is enforced by a test that holds
|
|
51
|
+
// the spec table as data and diffs the reducer against it cell by cell.
|
|
52
|
+
// Duplicate/inappropriate events are idempotent self-loops (absorbing),
|
|
53
|
+
// so no interleaving — however adversarial — leaves the machine
|
|
54
|
+
// undefined or divergent.
|
|
55
|
+
|
|
56
|
+
export type SurfacePhase =
|
|
57
|
+
| "closed"
|
|
58
|
+
| "openingFrom"
|
|
59
|
+
| "openingTo"
|
|
60
|
+
| "open"
|
|
61
|
+
| "closingFrom"
|
|
62
|
+
| "closingTo";
|
|
63
|
+
|
|
64
|
+
export type SurfaceEventType =
|
|
65
|
+
| "OPEN"
|
|
66
|
+
| "CLOSE"
|
|
67
|
+
| "FLIP"
|
|
68
|
+
| "DEADLINE"
|
|
69
|
+
| "TEND"
|
|
70
|
+
| "UNMOUNT";
|
|
71
|
+
|
|
72
|
+
export type SurfaceEvent = { type: SurfaceEventType };
|
|
73
|
+
|
|
74
|
+
export const SURFACE_PHASES: readonly SurfacePhase[] = [
|
|
75
|
+
"closed",
|
|
76
|
+
"openingFrom",
|
|
77
|
+
"openingTo",
|
|
78
|
+
"open",
|
|
79
|
+
"closingFrom",
|
|
80
|
+
"closingTo",
|
|
81
|
+
] as const;
|
|
82
|
+
|
|
83
|
+
export const SURFACE_EVENTS: readonly SurfaceEventType[] = [
|
|
84
|
+
"OPEN",
|
|
85
|
+
"CLOSE",
|
|
86
|
+
"FLIP",
|
|
87
|
+
"DEADLINE",
|
|
88
|
+
"TEND",
|
|
89
|
+
"UNMOUNT",
|
|
90
|
+
] as const;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The total transition function δ: S × E → S.
|
|
94
|
+
*
|
|
95
|
+
* | state | OPEN | CLOSE | FLIP | DEADLINE | TEND | UNMOUNT |
|
|
96
|
+
* |-------------|--------------|--------------|-------------|-------------|-------------|---------|
|
|
97
|
+
* | closed | openingFrom | closed | closed | closed | closed | closed |
|
|
98
|
+
* | openingFrom | openingFrom | closingFrom | openingTo | open | open | closed |
|
|
99
|
+
* | openingTo | openingTo | closingFrom | openingTo | open | open | closed |
|
|
100
|
+
* | open | open | closingFrom | open | open | open | closed |
|
|
101
|
+
* | closingFrom | openingFrom | closingFrom | closingTo | closed | closed | closed |
|
|
102
|
+
* | closingTo | openingFrom | closingTo | closingTo | closed | closed | closed |
|
|
103
|
+
*
|
|
104
|
+
* Reading guide:
|
|
105
|
+
* - Requests are idempotent (OPEN in any opening/open state is a no-op)
|
|
106
|
+
* — user flapping converges instead of thrashing.
|
|
107
|
+
* - A CLOSE during the opening window reverses from the CURRENT computed
|
|
108
|
+
* style (CSS re-targets the running transition); a OPEN during the
|
|
109
|
+
* closing window likewise reverses. Interruption is a first-class cell,
|
|
110
|
+
* not a corner case — the f108–f112 churn of the 2026-09 recording.
|
|
111
|
+
* - DEADLINE alone walks any state to a quiescent state: openingFrom →
|
|
112
|
+
* open, closingFrom → closed — starving frames skip the animation, not
|
|
113
|
+
* the state). This is the liveness column: under A1+A2 every request
|
|
114
|
+
* settles within flipBudget + maxDuration + slack.
|
|
115
|
+
*/
|
|
116
|
+
export function surfaceTransition(
|
|
117
|
+
phase: SurfacePhase,
|
|
118
|
+
event: SurfaceEventType,
|
|
119
|
+
): SurfacePhase {
|
|
120
|
+
switch (phase) {
|
|
121
|
+
case "closed":
|
|
122
|
+
switch (event) {
|
|
123
|
+
case "OPEN": return "openingFrom";
|
|
124
|
+
case "CLOSE":
|
|
125
|
+
case "FLIP":
|
|
126
|
+
case "DEADLINE":
|
|
127
|
+
case "TEND":
|
|
128
|
+
case "UNMOUNT": return "closed";
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
case "openingFrom":
|
|
132
|
+
switch (event) {
|
|
133
|
+
case "OPEN": return "openingFrom";
|
|
134
|
+
case "CLOSE": return "closingFrom";
|
|
135
|
+
case "FLIP": return "openingTo";
|
|
136
|
+
case "DEADLINE":
|
|
137
|
+
case "TEND": return "open";
|
|
138
|
+
case "UNMOUNT": return "closed";
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
case "openingTo":
|
|
142
|
+
switch (event) {
|
|
143
|
+
case "OPEN": return "openingTo";
|
|
144
|
+
case "CLOSE": return "closingFrom";
|
|
145
|
+
case "FLIP": return "openingTo";
|
|
146
|
+
case "DEADLINE":
|
|
147
|
+
case "TEND": return "open";
|
|
148
|
+
case "UNMOUNT": return "closed";
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case "open":
|
|
152
|
+
switch (event) {
|
|
153
|
+
case "OPEN":
|
|
154
|
+
case "FLIP":
|
|
155
|
+
case "DEADLINE":
|
|
156
|
+
case "TEND": return "open";
|
|
157
|
+
case "CLOSE": return "closingFrom";
|
|
158
|
+
case "UNMOUNT": return "closed";
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
case "closingFrom":
|
|
162
|
+
switch (event) {
|
|
163
|
+
case "OPEN": return "openingFrom";
|
|
164
|
+
case "CLOSE": return "closingFrom";
|
|
165
|
+
case "FLIP": return "closingTo";
|
|
166
|
+
case "DEADLINE":
|
|
167
|
+
case "TEND": return "closed";
|
|
168
|
+
case "UNMOUNT": return "closed";
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
case "closingTo":
|
|
172
|
+
switch (event) {
|
|
173
|
+
case "OPEN": return "openingFrom";
|
|
174
|
+
case "CLOSE":
|
|
175
|
+
case "FLIP": return "closingTo";
|
|
176
|
+
case "DEADLINE":
|
|
177
|
+
case "TEND": return "closed";
|
|
178
|
+
case "UNMOUNT": return "closed";
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
// Exhaustiveness guard: TypeScript proves every phase/event pair
|
|
183
|
+
// returned above; reaching this line is a compile-time impossibility
|
|
184
|
+
// that we still assert at runtime (a built table can never be wrong
|
|
185
|
+
// silently).
|
|
186
|
+
throw new Error(
|
|
187
|
+
`[surfaceMachine] unhandled transition ${phase} x ${event} — the table has a hole (bug: table not total)`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Whether the phase is one of the two quiescent (resting) states. */
|
|
192
|
+
export function isQuiescent(phase: SurfacePhase): boolean {
|
|
193
|
+
return phase === "closed" || phase === "open";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether the surface's DOM is mounted (everything except `closed`). */
|
|
197
|
+
export function isMounted(phase: SurfacePhase): boolean {
|
|
198
|
+
return phase !== "closed";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The class-pair family a phase paints (pure Moore output). */
|
|
202
|
+
export type SurfaceClassPair = "none" | "enter-from" | "enter-to" | "leave-from" | "leave-to";
|
|
203
|
+
|
|
204
|
+
export function classPairOf(phase: SurfacePhase): SurfaceClassPair {
|
|
205
|
+
switch (phase) {
|
|
206
|
+
case "closed": return "none";
|
|
207
|
+
case "openingFrom": return "enter-from";
|
|
208
|
+
case "openingTo": return "enter-to";
|
|
209
|
+
case "open": return "none";
|
|
210
|
+
case "closingFrom": return "leave-from";
|
|
211
|
+
case "closingTo": return "leave-to";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Vue-style transition classes for one layer prefix (Moore output).
|
|
216
|
+
*
|
|
217
|
+
* The names match what the family's SCSS already animates
|
|
218
|
+
* (`${prefix}-enter-from` etc.), so adopting the machine changes WHO
|
|
219
|
+
* flips the classes and WHEN — never WHAT the classes are.
|
|
220
|
+
*/
|
|
221
|
+
export function classesForPair(prefix: string, pair: SurfaceClassPair): string[] {
|
|
222
|
+
switch (pair) {
|
|
223
|
+
case "none": return [];
|
|
224
|
+
case "enter-from": return [`${prefix}-enter-from`, `${prefix}-enter-active`];
|
|
225
|
+
case "enter-to": return [`${prefix}-enter-to`, `${prefix}-enter-active`];
|
|
226
|
+
case "leave-from": return [`${prefix}-leave-from`, `${prefix}-leave-active`];
|
|
227
|
+
case "leave-to": return [`${prefix}-leave-to`, `${prefix}-leave-active`];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { armTransitionClassWatchdog, stripTransitionClasses } from "./transitionWatchdog";
|
|
4
|
-
|
|
5
|
-
afterEach(() => {
|
|
6
|
-
vi.useRealTimers();
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
function makeEl(...classes: string[]): HTMLElement {
|
|
10
|
-
const el = document.createElement("div");
|
|
11
|
-
el.className = classes.join(" ");
|
|
12
|
-
return el;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
describe("stripTransitionClasses", () => {
|
|
16
|
-
it("removes every name-prefixed transition class and keeps the rest", () => {
|
|
17
|
-
const el = makeEl(
|
|
18
|
-
"hk-modal-overlay",
|
|
19
|
-
"hk-modal-overlay-enter-from",
|
|
20
|
-
"hk-modal-overlay-enter-active",
|
|
21
|
-
"other-class",
|
|
22
|
-
);
|
|
23
|
-
stripTransitionClasses(el, "hk-modal-overlay");
|
|
24
|
-
expect(el.className).toBe("hk-modal-overlay other-class");
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("never strips a class merely containing the name mid-word", () => {
|
|
28
|
-
const el = makeEl("hk-modal-overlay-v2");
|
|
29
|
-
stripTransitionClasses(el, "hk-modal-overlay");
|
|
30
|
-
expect(el.className).toBe("hk-modal-overlay-v2");
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("is a no-op on a class-less element", () => {
|
|
34
|
-
const el = makeEl("hk-modal-overlay");
|
|
35
|
-
stripTransitionClasses(el, "hk-modal-overlay");
|
|
36
|
-
expect(el.className).toBe("hk-modal-overlay");
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe("armTransitionClassWatchdog", () => {
|
|
41
|
-
it("strips stuck transition classes once the budget lapses while open", () => {
|
|
42
|
-
vi.useFakeTimers();
|
|
43
|
-
const el = makeEl(
|
|
44
|
-
"hk-modal-overlay",
|
|
45
|
-
"hk-modal-overlay-enter-from",
|
|
46
|
-
"hk-modal-overlay-enter-active",
|
|
47
|
-
);
|
|
48
|
-
armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
|
|
49
|
-
vi.advanceTimersByTime(599);
|
|
50
|
-
expect(el.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
|
|
51
|
-
vi.advanceTimersByTime(2);
|
|
52
|
-
expect(el.className).toBe("hk-modal-overlay");
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("leaves the element alone once the surface closed (leave owns it)", () => {
|
|
56
|
-
vi.useFakeTimers();
|
|
57
|
-
const el = makeEl("hk-modal-overlay", "hk-modal-overlay-enter-from");
|
|
58
|
-
armTransitionClassWatchdog(el, "hk-modal-overlay", () => false);
|
|
59
|
-
vi.advanceTimersByTime(1000);
|
|
60
|
-
expect(el.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("disarming cancels the strip (a normal enter finalized)", () => {
|
|
64
|
-
vi.useFakeTimers();
|
|
65
|
-
const el = makeEl("hk-modal-overlay", "hk-modal-overlay-enter-active");
|
|
66
|
-
const disarm = armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
|
|
67
|
-
disarm();
|
|
68
|
-
vi.advanceTimersByTime(1000);
|
|
69
|
-
expect(el.classList.contains("hk-modal-overlay-enter-active")).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("does nothing when no transition class is stuck", () => {
|
|
73
|
-
vi.useFakeTimers();
|
|
74
|
-
const el = makeEl("hk-modal-overlay");
|
|
75
|
-
armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
|
|
76
|
-
vi.advanceTimersByTime(1000);
|
|
77
|
-
expect(el.className).toBe("hk-modal-overlay");
|
|
78
|
-
});
|
|
79
|
-
});
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
// Transition-class repair kit — the enter-side counterpart of HkModal's
|
|
2
|
-
// leave watchdog.
|
|
3
|
-
//
|
|
4
|
-
// A Vue <Transition> flips enter-from → enter-to through rAF and waits on
|
|
5
|
-
// transitionend. When rAF starves (occluded/backgrounded webview — the
|
|
6
|
-
// same pathology the leave watchdog in HkModal bounds), the enter classes
|
|
7
|
-
// FREEZE on the element: the layer keeps `*-enter-from` (opacity: 0,
|
|
8
|
-
// translateY(100%), …) while the surface is logically open. The modal's
|
|
9
|
-
// scrim then stays invisible with the panel floating above it, and the
|
|
10
|
-
// eventual close flashes the resurrected curtain at full opacity — the
|
|
11
|
-
// 2026-09 mobile "black rectangle" report (the leave pair
|
|
12
|
-
// leave-from → leave-to snaps the scrim to opacity: 1 before fading).
|
|
13
|
-
//
|
|
14
|
-
// Two primitives:
|
|
15
|
-
// stripTransitionClasses — remove every `name-*` transition class so the
|
|
16
|
-
// element snaps to its resting state (no fade — this is a repair
|
|
17
|
-
// path, the animation already failed).
|
|
18
|
-
// armTransitionClassWatchdog — bounded safety net: if the element still
|
|
19
|
-
// carries `name-enter-*` classes once the budget lapses (larger than
|
|
20
|
-
// any themed duration), strip them. Normal enters disarm it; a
|
|
21
|
-
// completed enter is a no-op (the classes are already gone).
|
|
22
|
-
|
|
23
|
-
/** The exact set of classes Vue's <Transition name=…> stamps on an
|
|
24
|
-
* element — matched precisely so a hand-written class that merely
|
|
25
|
-
* starts with the name (e.g. `name-v2`) is never touched. */
|
|
26
|
-
const TRANSITION_CLASS_SUFFIXES = [
|
|
27
|
-
"-enter-from",
|
|
28
|
-
"-enter-active",
|
|
29
|
-
"-enter-to",
|
|
30
|
-
"-leave-from",
|
|
31
|
-
"-leave-active",
|
|
32
|
-
"-leave-to",
|
|
33
|
-
] as const;
|
|
34
|
-
|
|
35
|
-
function transitionClassesOf(el: HTMLElement, name: string): string[] {
|
|
36
|
-
const names = TRANSITION_CLASS_SUFFIXES.map((suffix) => `${name}${suffix}`);
|
|
37
|
-
return Array.from(el.classList).filter((cls) => names.includes(cls));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Strip all `name-*` transition classes off `el`, snapping it to its
|
|
41
|
-
* resting (non-transition) state. Safe on class-less elements. */
|
|
42
|
-
export function stripTransitionClasses(el: HTMLElement, name: string): void {
|
|
43
|
-
for (const cls of transitionClassesOf(el, name)) el.classList.remove(cls);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Bound a stuck transition: if the element still carries ANY `name-*`
|
|
48
|
-
* transition class after `budgetMs` while the surface is open (the enter
|
|
49
|
-
* never finalized — or an interrupted leave left its pair behind — rAF
|
|
50
|
-
* starvation), strip them so the layer renders in its open state instead
|
|
51
|
-
* of staying frozen at a from/to pair. The budget is larger than any
|
|
52
|
-
* themed CSS duration, so a legitimate animation never sees the strip.
|
|
53
|
-
*
|
|
54
|
-
* `isStillOpen` gates the repair: once the surface closed, the leave owns
|
|
55
|
-
* the element and a late strip must not fight it.
|
|
56
|
-
*
|
|
57
|
-
* @returns the disarm function (call on after-enter / enter-cancelled /
|
|
58
|
-
* close / unmount).
|
|
59
|
-
*/
|
|
60
|
-
export function armTransitionClassWatchdog(
|
|
61
|
-
el: HTMLElement,
|
|
62
|
-
name: string,
|
|
63
|
-
isStillOpen: () => boolean,
|
|
64
|
-
budgetMs = 600,
|
|
65
|
-
): () => void {
|
|
66
|
-
const timer = setTimeout(() => {
|
|
67
|
-
if (!isStillOpen()) return;
|
|
68
|
-
if (transitionClassesOf(el, name).length > 0) {
|
|
69
|
-
stripTransitionClasses(el, name);
|
|
70
|
-
}
|
|
71
|
-
}, budgetMs);
|
|
72
|
-
return () => clearTimeout(timer);
|
|
73
|
-
}
|