@celestia-island/hikari 0.55.47 → 0.55.48
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/HkModal.stepflowswap.test.tsx +4 -5
- package/src/components/HkModal.tsx +10 -5
- package/src/components/HkStepFlow.scss +48 -91
- package/src/components/HkStepFlow.slide.contract.test.ts +54 -198
- package/src/components/HkStepFlow.test.tsx +18 -869
- package/src/components/HkStepFlow.tsx +60 -427
package/package.json
CHANGED
|
@@ -2,11 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkModal from "./HkModal";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "./HkStepFlow";
|
|
5
|
+
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
6
|
+
|
|
7
|
+
const SHEET_SWEEP_STAGE_EVENT = "hk-sheet-sweep-stage";
|
|
8
|
+
const SHEET_SWEEP_SETTLE_EVENT = "hk-sheet-sweep-settle";
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* Behavioural contract for the stepflow → sheet morph handoff
|
|
@@ -13,11 +13,16 @@ import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
|
13
13
|
import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
|
|
14
14
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
import { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
17
|
+
|
|
18
|
+
/** The hosting sheet publishes its fold's real span while it stages — the
|
|
19
|
+
* only place where the sheet's max-height cap is already accounted for.
|
|
20
|
+
* Content parks against these numbers instead of guessing the delta. */
|
|
21
|
+
const SHEET_SWEEP_STAGE_EVENT = "hk-sheet-sweep-stage";
|
|
22
|
+
|
|
23
|
+
/** The hosting sheet's fold has landed: the frame is back at its rest
|
|
24
|
+
* geometry, so parked content must release exactly here. */
|
|
25
|
+
const SHEET_SWEEP_SETTLE_EVENT = "hk-sheet-sweep-settle";
|
|
21
26
|
import HButton from "./HkButton";
|
|
22
27
|
import HFab from "./HkFab";
|
|
23
28
|
import HSpinner from "./HkSpinner";
|
|
@@ -1,127 +1,87 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Step-flow bodies: two-phase sequential swap (2026-09-22 user directive).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// the
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// which is what makes the two bodies structurally unable to overlap —
|
|
15
|
-
// the doubled-ghost flash of the simultaneous cross-slide (2026-09-22
|
|
16
|
-
// user directive, round 10).
|
|
17
|
-
//
|
|
18
|
-
// This stylesheet owns ALL of the motion: direction, travel distance and
|
|
19
|
-
// easings are driven by the `data-direction` attribute and those two
|
|
20
|
-
// classes; the component only flips them and schedules the phases. The
|
|
21
|
-
// sheet's own morph is phase-matched by the hosting modal, which reads
|
|
22
|
-
// the swap event's `durationMs`.
|
|
23
|
-
//
|
|
24
|
-
// Duration and travel resolve from local custom properties so consumers
|
|
25
|
-
// can tune every phase at once; the 0.3s default matches the phone
|
|
26
|
-
// sheet's morph window so the sweep and the slide land together.
|
|
3
|
+
// The stage is a GRID with every body in the SAME cell — both the old and
|
|
4
|
+
// the new body overlay each other without any positioning tricks, and the
|
|
5
|
+
// cell sizes itself to whichever body is taller. The swap is therefore
|
|
6
|
+
// JUST opacity: the entering body starts transparent (already painted, so
|
|
7
|
+
// the fade never re-rasters a layer), the leaving body fades and slides
|
|
8
|
+
// out, and the old DOM node is recycled once it is gone (2026-09-22 user
|
|
9
|
+
// directive, round 12: "reduce the property count — only opacity, and
|
|
10
|
+
// recycle the old element at the right time"). No visibility flips, no
|
|
11
|
+
// absolute positioning, no height pins, no measured offsets — every one
|
|
12
|
+
// of those changed the render tree mid-animation and read as a flash on
|
|
13
|
+
// the phone GPU.
|
|
27
14
|
|
|
28
15
|
.hk-step-flow {
|
|
29
16
|
--hk-stepflow-duration: 0.3s;
|
|
30
17
|
--hk-stepflow-travel: 24px;
|
|
31
18
|
}
|
|
32
19
|
|
|
33
|
-
// Breathing room between the step indicator and the step bodies.
|
|
34
|
-
// it the timeline row butts directly against the first body element (modal
|
|
35
|
-
// wizards read as cramped). Tune or remove per host through
|
|
36
|
-
// --hk-stepflow-header-gap; the sticky mode folds the gap into the pinned
|
|
37
|
-
// header's own padding so the surface tint stays continuous while the
|
|
38
|
-
// body scrolls underneath.
|
|
20
|
+
// Breathing room between the step indicator and the step bodies.
|
|
39
21
|
.hk-step-flow > .hk-timeline {
|
|
40
22
|
margin-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
|
|
41
23
|
}
|
|
42
24
|
|
|
43
25
|
// Sticky header mode: positioning, surface and the whitespace contract
|
|
44
|
-
// live on the shared .hk-scroll-pin rules (HkScrollPin.scss)
|
|
45
|
-
// timeline root carries the class plus data-side/data-strategy. This
|
|
46
|
-
// block keeps only what is step-flow-specific: the body gap folded into
|
|
47
|
-
// the pinned header's own padding (so the tint stays continuous while
|
|
48
|
-
// the body scrolls) and the legacy styling knobs mapped onto the pin's
|
|
49
|
-
// custom properties. The doubled class selector must beat
|
|
50
|
-
// .hk-scroll-pin's own declarations regardless of import order.
|
|
26
|
+
// live on the shared .hk-scroll-pin rules (HkScrollPin.scss).
|
|
51
27
|
.hk-step-flow[data-sticky-header] > .hk-timeline.hk-scroll-pin {
|
|
52
28
|
--hk-scroll-pin-z: var(--hk-stepflow-sticky-z, 10);
|
|
53
29
|
--hk-scroll-pin-bg: var(
|
|
54
30
|
--hk-stepflow-sticky-bg,
|
|
55
31
|
color-mix(in srgb, var(--hi-color-surface, #f0f4f8) 95%, transparent)
|
|
56
32
|
);
|
|
57
|
-
// --hk-stepflow-sticky-top lifts the pin below a host chrome edge; this
|
|
58
|
-
// doubled selector outranks .hk-scroll-pin's own `top:` in every
|
|
59
|
-
// strategy, so the knob always wins when set.
|
|
60
33
|
top: var(--hk-stepflow-sticky-top, 0px);
|
|
61
34
|
margin-bottom: 0;
|
|
62
35
|
padding-bottom: var(--hk-stepflow-header-gap, var(--space-16, 1rem));
|
|
63
36
|
}
|
|
64
37
|
|
|
65
|
-
// The swap stage:
|
|
66
|
-
// one overlays it, out of flow and pointer-transparent for the exit.
|
|
38
|
+
// The swap stage: a single grid cell that every body shares.
|
|
67
39
|
.hk-stepflow-bodies {
|
|
68
|
-
|
|
40
|
+
display: grid;
|
|
69
41
|
}
|
|
70
42
|
|
|
43
|
+
// Every body occupies the same cell: the container's height is the taller
|
|
44
|
+
// of the two during a swap (so the hosting sheet sees the NEW geometry as
|
|
45
|
+
// soon as the swap starts for a grow, and keeps the OLD geometry until the
|
|
46
|
+
// old node is recycled for a shrink — exactly the height schedule the
|
|
47
|
+
// directive asks for, with no pin).
|
|
71
48
|
.hk-stepflow-body {
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// read on the device as the new content flashing in — the standard curve
|
|
78
|
-
// starts at ~20% instead (2026-09-22 device report, round 11).
|
|
49
|
+
grid-area: 1 / 1;
|
|
50
|
+
// ENTER: fade in place with a standard ease — no travel, no other
|
|
51
|
+
// property. The element is laid out and PAINTED from the moment it
|
|
52
|
+
// mounts (opacity: 0 is compositor-only), so the first frame of the
|
|
53
|
+
// fade never triggers a re-raster.
|
|
79
54
|
transition:
|
|
80
55
|
opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.2, 1);
|
|
81
56
|
}
|
|
82
57
|
|
|
58
|
+
// The staged new body: transparent, untouchable, and at the CELL BOTTOM
|
|
59
|
+
// until the enter edge. Deliberately NO `visibility: hidden` — that property
|
|
60
|
+
// changes the render tree and the phone GPU re-rasters the layer when it
|
|
61
|
+
// flips, which was the flash the user kept seeing (round 12 directive).
|
|
62
|
+
// `align-self: end` does two things with zero positioning tricks: it stops
|
|
63
|
+
// the grid from STRETCHING the shorter entering body to the old cell height
|
|
64
|
+
// (so its measured height is the true content height, and the shrink's
|
|
65
|
+
// delta sign is correct), and it puts the body where the folded sheet will
|
|
66
|
+
// land it — the cell bottom is anchored to the sheet footer, so the content
|
|
67
|
+
// appears at its final line instead of being wiped by the descending fold
|
|
68
|
+
// edge and jumping at the landing.
|
|
69
|
+
.hk-stepflow-body.hk-stepflow-enter-pending {
|
|
70
|
+
opacity: 0;
|
|
71
|
+
pointer-events: none;
|
|
72
|
+
align-self: end;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The leaving body: fades out and slides in its direction. The slide is
|
|
76
|
+
// the classic accelerating ease; the fade gets its own gentler curve so
|
|
77
|
+
// the old content eases out over several frames.
|
|
83
78
|
.hk-stepflow-body.leaving {
|
|
84
|
-
position: absolute;
|
|
85
|
-
// Default anchor: the stage's TOP. The component measures how far the
|
|
86
|
-
// stage travelled during the swap patch and writes that as an inline
|
|
87
|
-
// `top`, which is the only correct answer across host shapes — a
|
|
88
|
-
// bottom-docked sheet whose box grew, a capped box that could not grow,
|
|
89
|
-
// and an in-flow stage all differ, and a CSS anchor can only encode one
|
|
90
|
-
// of them (a guessed bottom anchor cut 276px off a capped sheet's
|
|
91
|
-
// outgoing step).
|
|
92
|
-
top: 0;
|
|
93
|
-
left: 0;
|
|
94
|
-
right: 0;
|
|
95
79
|
pointer-events: none;
|
|
96
|
-
// LEAVE grammar: the slide keeps its accelerating ease-in; the FADE gets
|
|
97
|
-
// its own gentler curve so the old content eases out over several frames
|
|
98
|
-
// instead of dropping 0.40 -> 0.03 in one (same device report).
|
|
99
80
|
transition:
|
|
100
81
|
opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.6, 1),
|
|
101
82
|
transform calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.5, 0, 0.75, 0);
|
|
102
83
|
}
|
|
103
84
|
|
|
104
|
-
// The parked new body of a shrink whose sheet will fold. Out of flow (the
|
|
105
|
-
// stage keeps the OLD height through the fold) and offset by the span the
|
|
106
|
-
// sheet published, so it already sits on the line the fold lands on; the
|
|
107
|
-
// component releases both when the sheet reports that landing.
|
|
108
|
-
.hk-stepflow-body.hk-stepflow-enter-tail {
|
|
109
|
-
position: absolute;
|
|
110
|
-
top: 0;
|
|
111
|
-
left: 0;
|
|
112
|
-
right: 0;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// The staged new body: LAID OUT (so the flow owns the new height from
|
|
116
|
-
// frame one — the sheet can grow through the exit phase) but invisible,
|
|
117
|
-
// which is what guarantees the old and new bodies are never on screen
|
|
118
|
-
// together. `visibility` (not display/removal) keeps it measurable and
|
|
119
|
-
// keeps a re-swap from ever destroying its reveal.
|
|
120
|
-
.hk-stepflow-body.hk-stepflow-enter-pending {
|
|
121
|
-
opacity: 0;
|
|
122
|
-
visibility: hidden;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
85
|
// Direction-aware travel, on the LEAVING body only: forward exits LEFT,
|
|
126
86
|
// back exits RIGHT (the classic vocabulary). The entering body appears in
|
|
127
87
|
// place, so it carries no direction rule at all.
|
|
@@ -135,8 +95,7 @@
|
|
|
135
95
|
transform: translateX(var(--hk-stepflow-travel, 24px));
|
|
136
96
|
}
|
|
137
97
|
|
|
138
|
-
/* RTL: reading direction flips, so the exit mirrors
|
|
139
|
-
* (house pattern: HkListTransition.scss [dir="rtl"] form). */
|
|
98
|
+
/* RTL: reading direction flips, so the exit mirrors. */
|
|
140
99
|
[dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to {
|
|
141
100
|
transform: translateX(var(--hk-stepflow-travel, 24px));
|
|
142
101
|
}
|
|
@@ -146,9 +105,7 @@
|
|
|
146
105
|
}
|
|
147
106
|
|
|
148
107
|
/* Reduced motion: bodies swap instantly. The component's duration probe
|
|
149
|
-
* reads the zeroed duration and settles without staging
|
|
150
|
-
* is the single switch for the whole choreography (the sheet morph
|
|
151
|
-
* collapses the same way through its own duration tokens). */
|
|
108
|
+
* reads the zeroed duration and settles without staging. */
|
|
152
109
|
@media (prefers-reduced-motion: reduce) {
|
|
153
110
|
.hk-stepflow-body {
|
|
154
111
|
transition: none;
|
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Source contract for the stepflow
|
|
3
|
-
* directive, round
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* size change).
|
|
10
|
-
*
|
|
11
|
-
* The stylesheet owns ALL motion: direction, travel and easings are
|
|
12
|
-
* attribute/class driven, so a refactor cannot silently reintroduce
|
|
13
|
-
* inline-style choreography, a vertical ride, the simultaneous staged
|
|
14
|
-
* grammar, or the retired out-in classes. The component side is pinned to
|
|
15
|
-
* the animation-context bookkeeping (reportTransition, no bare rAF, every
|
|
16
|
-
* phase timer carrying the shared grace) and to deterministic settling
|
|
17
|
-
* where no transition runs (reduced motion / stylesheet-less runtimes).
|
|
2
|
+
* Source contract for the simplified stepflow swap (2026-09-22 user
|
|
3
|
+
* directive, round 12: "reduce the property count — only opacity, and
|
|
4
|
+
* recycle the old element at the right time"). The stage is a grid, the
|
|
5
|
+
* motion is opacity-only (plus the leaving body's direction slide), and
|
|
6
|
+
* the old DOM node is recycled at the phase boundary. No visibility
|
|
7
|
+
* flips, no absolute positioning, no height pins, no measured offsets,
|
|
8
|
+
* no park mechanism.
|
|
18
9
|
*/
|
|
19
10
|
import { describe, expect, it } from "vitest";
|
|
20
11
|
import { readFileSync } from "node:fs";
|
|
@@ -24,16 +15,7 @@ import { fileURLToPath } from "node:url";
|
|
|
24
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
16
|
const src = readFileSync(join(here, "HkStepFlow.scss"), "utf-8");
|
|
26
17
|
const tsx = readFileSync(join(here, "HkStepFlow.tsx"), "utf-8");
|
|
27
|
-
const modal = readFileSync(join(here, "HkModal.tsx"), "utf-8");
|
|
28
|
-
const morphSrc = readFileSync(
|
|
29
|
-
join(here, "..", "composables", "useSizeMorph.ts"),
|
|
30
|
-
"utf-8",
|
|
31
|
-
);
|
|
32
18
|
|
|
33
|
-
/** Top-level stylesheet rules as (selector-list, body) pairs. Nested
|
|
34
|
-
* blocks (e.g. the reduced-motion media query) come out as one rule
|
|
35
|
-
* whose body carries the inner braces — the direction rules this
|
|
36
|
-
* contract binds are all top-level. */
|
|
37
19
|
function extractRules(source: string): { selector: string; body: string }[] {
|
|
38
20
|
const rules: { selector: string; body: string }[] = [];
|
|
39
21
|
let i = 0;
|
|
@@ -60,9 +42,6 @@ function extractRules(source: string): { selector: string; body: string }[] {
|
|
|
60
42
|
|
|
61
43
|
const scssRules = extractRules(src);
|
|
62
44
|
|
|
63
|
-
/** The declaration body of the ONE rule whose selector list carries the
|
|
64
|
-
* exact selector — zero hits means the selector vanished, two means the
|
|
65
|
-
* contract's premise broke; both are red. */
|
|
66
45
|
function blockFor(selector: string): string {
|
|
67
46
|
const hits = scssRules.filter((r) =>
|
|
68
47
|
r.selector.split(",").map((s) => s.trim()).includes(selector),
|
|
@@ -72,233 +51,110 @@ function blockFor(selector: string): string {
|
|
|
72
51
|
}
|
|
73
52
|
|
|
74
53
|
const PHASE = "calc(var(--hk-stepflow-duration, 0.3s) / 2)";
|
|
75
|
-
const TRAVEL_POSITIVE = "translateX(var(--hk-stepflow-travel, 24px))";
|
|
76
|
-
const TRAVEL_NEGATIVE = "translateX(calc(-1 * var(--hk-stepflow-travel, 24px))";
|
|
77
54
|
|
|
78
|
-
describe("HkStepFlow
|
|
55
|
+
describe("HkStepFlow simplified swap contract", () => {
|
|
79
56
|
it("pins the shared duration and travel tokens", () => {
|
|
80
57
|
const root = src.match(/\.hk-step-flow\s*\{[^}]*\}/)![0]!;
|
|
81
58
|
expect(root).toContain("--hk-stepflow-duration: 0.3s;");
|
|
82
59
|
expect(root).toContain("--hk-stepflow-travel: 24px;");
|
|
83
60
|
});
|
|
84
61
|
|
|
62
|
+
it("uses a single grid cell for every body — no absolute positioning", () => {
|
|
63
|
+
const stage = blockFor(".hk-stepflow-bodies");
|
|
64
|
+
expect(stage).toContain("display: grid");
|
|
65
|
+
const body = src.match(/\.hk-stepflow-body\s*\{[^}]*\}/)![0]!;
|
|
66
|
+
expect(body).toContain("grid-area: 1 / 1");
|
|
67
|
+
expect(src).not.toContain("position: absolute");
|
|
68
|
+
expect(src).not.toContain("position:relative");
|
|
69
|
+
});
|
|
70
|
+
|
|
85
71
|
it("declares the in-place enter grammar on the base body rule", () => {
|
|
86
|
-
// The new body FADES IN PLACE for the second phase: opacity only, one
|
|
87
|
-
// phase long, expressive ease-out — and explicitly no travel.
|
|
88
72
|
const rule = src.match(/\.hk-stepflow-body\s*\{[^}]*\}/)![0]!;
|
|
89
73
|
const transitions = rule.match(/transition:\s*[^;}]+/g) ?? [];
|
|
90
74
|
expect(transitions).toHaveLength(1);
|
|
91
75
|
const decl = transitions[0]!;
|
|
92
76
|
expect(decl).toContain("opacity");
|
|
93
77
|
expect(decl).toContain(PHASE);
|
|
94
|
-
// A STANDARD ease: the expressive ease-out put the first painted frame
|
|
95
|
-
// at ~50% opacity, which read as the new content flashing in.
|
|
96
78
|
expect(decl).toContain("cubic-bezier(0.4, 0, 0.2, 1)");
|
|
97
79
|
expect(decl, "the enter phase never travels").not.toContain("transform");
|
|
98
80
|
});
|
|
99
81
|
|
|
100
|
-
it("
|
|
82
|
+
it("stages the new body with opacity only — NO visibility flip", () => {
|
|
83
|
+
const rule = blockFor(".hk-stepflow-body.hk-stepflow-enter-pending");
|
|
84
|
+
expect(rule).toContain("opacity: 0");
|
|
85
|
+
expect(rule).toContain("pointer-events: none");
|
|
86
|
+
expect(rule).toContain("align-self: end");
|
|
87
|
+
expect(rule, "visibility re-rasters the layer — the flash source").not.toContain("visibility");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps the leaving body's fade gentle while the slide accelerates", () => {
|
|
101
91
|
const rule = src.match(/\.hk-stepflow-body\.leaving\s*\{[^}]*\}/)![0]!;
|
|
102
|
-
expect(rule).toContain("position: absolute");
|
|
103
92
|
expect(rule).toContain("pointer-events: none");
|
|
104
|
-
// Default anchor is the TOP: an in-flow stage keeps its top line and
|
|
105
|
-
// grows downward, so that is where the old body holds its position
|
|
106
|
-
// (chest's LoginView renders this flow in a grid-stack crossfade).
|
|
107
|
-
expect(rule).toContain("top: 0");
|
|
108
|
-
expect(rule, "the default anchor is not the bottom").not.toContain("bottom: 0");
|
|
109
|
-
// The slide keeps its classic accelerating ease-in; the fade gets its
|
|
110
|
-
// own gentler curve so the old content eases out over several frames.
|
|
111
|
-
expect(rule).toContain("cubic-bezier(0.5, 0, 0.75, 0)");
|
|
112
93
|
expect(rule).toContain("cubic-bezier(0.4, 0, 0.6, 1)");
|
|
94
|
+
expect(rule).toContain("cubic-bezier(0.5, 0, 0.75, 0)");
|
|
113
95
|
expect(rule).toContain(PHASE);
|
|
114
|
-
const transitions = rule.match(/transition:\s*[^;}]+/g) ?? [];
|
|
115
|
-
expect(transitions).toHaveLength(1);
|
|
116
|
-
expect(transitions[0]).toContain("opacity");
|
|
117
|
-
expect(transitions[0]).toContain("transform");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("measures the outgoing body's shift instead of assuming a host anchor", () => {
|
|
121
|
-
// The host decides where a growing box's lines sit (bottom-docked sheet,
|
|
122
|
-
// capped sheet, centred desktop frame, in-flow stage), so the component
|
|
123
|
-
// measures how far the stage travelled during the swap patch and writes
|
|
124
|
-
// that as an inline `top`. A CSS anchor can only encode one of those
|
|
125
|
-
// shapes: the bottom-anchored attempt cut 276px off a capped sheet's
|
|
126
|
-
// outgoing step (R2 verification finding).
|
|
127
|
-
expect(tsx).toContain("getBoundingClientRect");
|
|
128
|
-
expect(tsx).toMatch(/const shift = Math\.round\(leaveTop0 - goneEl\.getBoundingClientRect\(\)\.top\)/);
|
|
129
|
-
expect(tsx).toMatch(/if \(shift\) goneEl\.style\.top = `\$\{shift\}px`/);
|
|
130
|
-
// …and the stylesheet keeps ONE anchor, the in-flow-safe top.
|
|
131
|
-
expect(src).not.toContain("data-anchor");
|
|
132
|
-
expect(tsx).not.toContain("data-anchor");
|
|
133
|
-
expect(src).not.toContain("--hk-sheet-morph");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("parks the new body on the span the sheet publishes and releases on its landing", () => {
|
|
137
|
-
// Clause 5: the new content must already sit at its final geometry while
|
|
138
|
-
// the sheet's clip edge folds down. The span comes from the SHEET (the
|
|
139
|
-
// only place the max-height cap is accounted for), and the release is
|
|
140
|
-
// tied to the sheet's landing — releasing on the body's own fade left
|
|
141
|
-
// the frame clipped for up to 334ms and parked content 276px off on a
|
|
142
|
-
// capped box.
|
|
143
|
-
const rule = blockFor(".hk-stepflow-body.hk-stepflow-enter-tail");
|
|
144
|
-
expect(rule).toContain("position: absolute");
|
|
145
|
-
const transitions = rule.match(/transition:\s*[^;}]+/g) ?? [];
|
|
146
|
-
expect(transitions, "the parked body does not animate its offset").toHaveLength(0);
|
|
147
|
-
expect(tsx).toContain("SHEET_SWEEP_STAGE_EVENT");
|
|
148
|
-
expect(tsx).toContain("SHEET_SWEEP_SETTLE_EVENT");
|
|
149
|
-
expect(tsx).toMatch(/direction === "conceal"/);
|
|
150
|
-
expect(tsx).toMatch(/span = Math\.max\(0, Math\.round\(info\.from - info\.to\)\)/);
|
|
151
|
-
expect(tsx).toMatch(/span > 0\)[\s\S]{0,200}?parkTail\(/);
|
|
152
|
-
expect(tsx).toMatch(/entering\.style\.top = `\$\{span\}px`/);
|
|
153
|
-
expect(tsx).toMatch(/TAIL_WATCHDOG_GRACE_MS/);
|
|
154
|
-
// The order matters: the stage probe is attached, the pin comes off (so
|
|
155
|
-
// the sheet measures the NEW natural height), the announce stages and
|
|
156
|
-
// publishes the fold, and only then does the park land.
|
|
157
|
-
expect(tsx).toMatch(
|
|
158
|
-
/addEventListener\(SHEET_SWEEP_STAGE_EVENT, onStage\);[\s\S]{0,200}?clearPin\(\);[\s\S]{0,200}?announce\(handle\);/,
|
|
159
|
-
);
|
|
160
|
-
// The host side republishes the morph's own numbers.
|
|
161
|
-
expect(modal).toContain("onSweepStage");
|
|
162
|
-
expect(modal).toContain("onSweepSettle");
|
|
163
|
-
expect(modal).toContain("SHEET_SWEEP_STAGE_EVENT");
|
|
164
|
-
expect(modal).toContain("SHEET_SWEEP_SETTLE_EVENT");
|
|
165
|
-
expect(morphSrc).toContain("options.onSweepStage?.");
|
|
166
|
-
expect(morphSrc).toContain("options.onSweepSettle?.");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("stages the new body laid out but invisible so the bodies cannot overlap", () => {
|
|
170
|
-
// The round-10 ghost fix: the entering body exists from frame one (so
|
|
171
|
-
// the flow owns the new height immediately) but is unpaintable for the
|
|
172
|
-
// whole exit phase.
|
|
173
|
-
const rule = blockFor(".hk-stepflow-body.hk-stepflow-enter-pending");
|
|
174
|
-
expect(rule).toContain("visibility: hidden");
|
|
175
|
-
expect(rule).toContain("opacity: 0");
|
|
176
|
-
expect(rule, "the staged body never travels").not.toContain("transform");
|
|
177
|
-
// …and the component binds it to the EXIT phase only: the class lifts
|
|
178
|
-
// at the phase-2 edge, which is what starts the fade in place.
|
|
179
|
-
expect(tsx).toContain("hk-stepflow-enter-pending");
|
|
180
|
-
expect(tsx).toContain('swapPhase.value === "exit"');
|
|
181
|
-
// The component must announce the enter edge as a distinct phase.
|
|
182
|
-
expect(tsx).toMatch(/phase\s*=\s*"enter"/);
|
|
183
|
-
expect(tsx).toMatch(/swapPhase\.value\s*=\s*"enter"/);
|
|
184
96
|
});
|
|
185
97
|
|
|
186
98
|
it("binds each direction's travel sign to its exact selector", () => {
|
|
187
|
-
// Only the LEAVING body travels (the entering one appears in place), so
|
|
188
|
-
// the direction grammar binds exactly four selectors, sign-bound: a
|
|
189
|
-
// sign inversion must go red. forward exits LEFT (−travel), back exits
|
|
190
|
-
// RIGHT (+travel); RTL mirrors the mapping.
|
|
191
99
|
const body = ".hk-stepflow-body";
|
|
192
100
|
const stage = ".hk-stepflow-bodies";
|
|
193
101
|
const ltr: Array<[string, string]> = [
|
|
194
|
-
[`${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`,
|
|
195
|
-
[`${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`,
|
|
102
|
+
[`${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, "translateX(calc(-1 * var(--hk-stepflow-travel, 24px))"],
|
|
103
|
+
[`${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, "translateX(var(--hk-stepflow-travel, 24px))"],
|
|
196
104
|
];
|
|
197
105
|
const rtl: Array<[string, string]> = [
|
|
198
|
-
[`[dir="rtl"] ${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`,
|
|
199
|
-
[`[dir="rtl"] ${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`,
|
|
106
|
+
[`[dir="rtl"] ${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, "translateX(var(--hk-stepflow-travel, 24px))"],
|
|
107
|
+
[`[dir="rtl"] ${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, "translateX(calc(-1 * var(--hk-stepflow-travel, 24px))"],
|
|
200
108
|
];
|
|
201
|
-
for (const [selector, sign] of ltr) {
|
|
109
|
+
for (const [selector, sign] of [...ltr, ...rtl]) {
|
|
202
110
|
const block = blockFor(selector);
|
|
203
111
|
expect(block, `${selector} must carry ${sign}`).toContain(sign);
|
|
204
|
-
const other = sign === TRAVEL_POSITIVE ? TRAVEL_NEGATIVE : TRAVEL_POSITIVE;
|
|
205
|
-
expect(block, `${selector} must not carry ${other}`).not.toContain(other);
|
|
206
|
-
expect(block).toContain("opacity: 0");
|
|
207
112
|
}
|
|
208
|
-
// RTL blocks are transform-only overrides over the LTR grammar.
|
|
209
|
-
for (const [selector, sign] of rtl) {
|
|
210
|
-
const block = blockFor(selector);
|
|
211
|
-
expect(block, `${selector} must carry ${sign}`).toContain(sign);
|
|
212
|
-
const other = sign === TRAVEL_POSITIVE ? TRAVEL_NEGATIVE : TRAVEL_POSITIVE;
|
|
213
|
-
expect(block, `${selector} must not carry ${other}`).not.toContain(other);
|
|
214
|
-
}
|
|
215
|
-
// No direction rule may hang off the staged or entering class — the
|
|
216
|
-
// new body never travels, in any direction (checked per RULE: the
|
|
217
|
-
// class name also appears in comments, so a raw regex over the file
|
|
218
|
-
// would false-positive across rule boundaries).
|
|
219
|
-
const pendingRules = scssRules.filter((r) =>
|
|
220
|
-
r.selector.includes("hk-stepflow-enter-pending"),
|
|
221
|
-
);
|
|
222
|
-
expect(pendingRules.length).toBeGreaterThan(0);
|
|
223
|
-
for (const r of pendingRules) {
|
|
224
|
-
expect(r.body, r.selector).not.toContain("transform");
|
|
225
|
-
expect(r.selector).not.toContain("data-direction");
|
|
226
|
-
}
|
|
227
|
-
// The stage keeps its relative positioning for the overlay grammar.
|
|
228
|
-
expect(src).toMatch(/\.hk-stepflow-bodies\s*\{[^}]*position:\s*relative/);
|
|
229
|
-
// The component renders the direction attribute the CSS keys off.
|
|
230
113
|
expect(tsx).toContain("data-direction={dir}");
|
|
231
114
|
});
|
|
232
115
|
|
|
233
|
-
it("keeps all motion in the stylesheet —
|
|
234
|
-
// The 2026-09-22 directive: CSS is the base. The component toggles
|
|
235
|
-
// classes and writes only MEASURED geometry (the shrink's height pin and
|
|
236
|
-
// the outgoing/parked bodies' offsets); it must never write motion
|
|
237
|
-
// styles or animate a body itself.
|
|
116
|
+
it("keeps all motion in the stylesheet — the component writes no styles", () => {
|
|
238
117
|
expect(tsx).not.toContain("style.transition");
|
|
239
118
|
expect(tsx).not.toContain("style.transform");
|
|
240
119
|
expect(tsx).not.toContain("style.opacity");
|
|
120
|
+
expect(tsx).not.toContain("style.top");
|
|
121
|
+
expect(tsx).not.toContain("style.minHeight");
|
|
241
122
|
expect(tsx).not.toContain("translateY");
|
|
242
|
-
expect(tsx).toContain("style.minHeight");
|
|
243
|
-
expect(tsx).toMatch(/style\.top = /);
|
|
244
123
|
});
|
|
245
124
|
|
|
246
|
-
it("
|
|
125
|
+
it("has no leftover park/pin/sweep-consumer machinery", () => {
|
|
126
|
+
expect(tsx).not.toContain("pinOldHeight");
|
|
127
|
+
expect(tsx).not.toContain("clearPin");
|
|
128
|
+
expect(tsx).not.toContain("holdLine");
|
|
129
|
+
expect(tsx).not.toContain("parkTail");
|
|
130
|
+
expect(tsx).not.toContain("stageShrinkFold");
|
|
131
|
+
expect(tsx).not.toContain("hk-stepflow-enter-tail");
|
|
132
|
+
expect(tsx).not.toContain("SHEET_SWEEP_STAGE_EVENT");
|
|
133
|
+
expect(tsx).not.toContain("SHEET_SWEEP_SETTLE_EVENT");
|
|
134
|
+
expect(src).not.toContain("hk-stepflow-enter-tail");
|
|
135
|
+
expect(src).not.toContain("min-height");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("books every phase window on the animation context", () => {
|
|
247
139
|
expect(tsx).toContain('from "../runtime/animationBus"');
|
|
248
140
|
expect(tsx).toContain("reportTransition(");
|
|
249
|
-
// The pre-emption fast path stages a start state for one frame: that is
|
|
250
|
-
// exactly what the shared bus one-shot is for.
|
|
251
|
-
expect(tsx).toContain("scheduleFrame(");
|
|
252
141
|
expect(tsx).not.toContain("requestAnimationFrame(");
|
|
253
|
-
|
|
254
|
-
// swap patch itself and the staged state is long-lived, so the old
|
|
255
|
-
// staging frame is gone. Every phase timer must still carry the shared
|
|
256
|
-
// grace so a lost transitionend cannot strand a phase.
|
|
257
|
-
expect(tsx).toMatch(
|
|
258
|
-
/setTimeout\([^,]+,\s*handle\.phaseMs\s*\+\s*SWAP_WATCHDOG_GRACE_MS\)/,
|
|
259
|
-
);
|
|
142
|
+
expect(tsx).not.toContain("scheduleFrame(");
|
|
260
143
|
});
|
|
261
144
|
|
|
262
145
|
it("settles deterministically where no transition runs", () => {
|
|
263
|
-
// The duration probe: zero (reduced motion / stylesheet-less runtime)
|
|
264
|
-
// selects the atomic instant-settle path — the debt pin for chest's
|
|
265
|
-
// transitionend-less wizard tests.
|
|
266
146
|
expect(tsx).toContain("bodyTransitionMs");
|
|
267
147
|
expect(tsx).toContain("transitionDuration");
|
|
268
|
-
// The motion path keeps a watchdog so a lost transitionend cannot
|
|
269
|
-
// freeze a phase (same grammar as the sheet morph).
|
|
270
148
|
expect(tsx).toContain("SWAP_WATCHDOG_GRACE_MS");
|
|
271
149
|
expect(tsx).toContain('addEventListener("transitionend"');
|
|
272
|
-
// transitionend bubbles — only the acting body's own transitions may
|
|
273
|
-
// advance a phase (R3 spot mutation: removing this guard survived the
|
|
274
|
-
// suite, so it is pinned here).
|
|
275
150
|
expect(tsx).toContain("event.target === el");
|
|
276
151
|
});
|
|
277
152
|
|
|
278
|
-
it("
|
|
279
|
-
|
|
280
|
-
// EXIT edge. Shrink: the flow holds its old height → announced at the
|
|
281
|
-
// ENTER edge (the pin lifts in the same step).
|
|
282
|
-
expect(tsx).toMatch(/delta\s*>=\s*0/);
|
|
283
|
-
expect(tsx).toContain("pinOldHeight(");
|
|
284
|
-
// Both edges route through the shared helpers: the ordinary path holds
|
|
285
|
-
// the old height while the old body plays and hands the sheet over at
|
|
286
|
-
// the enter edge; the fast path does the same at its single edge.
|
|
287
|
-
expect(tsx).toContain("stageShrinkFold(");
|
|
288
|
-
expect(tsx).toContain("parkTail(");
|
|
289
|
-
expect(tsx).toMatch(
|
|
290
|
-
/handle\.delta\s*<\s*0\s*\)\s*\{[\s\S]{0,200}?stageShrinkFold\(handle\)/,
|
|
291
|
-
);
|
|
292
|
-
expect(tsx).toMatch(/\} else \{[\s\S]{0,220}?pinOldHeight\(oldH\);/);
|
|
293
|
-
// The event carries the phase length so the sheet can match it.
|
|
294
|
-
expect(tsx).toContain("durationMs: handle.phaseMs");
|
|
153
|
+
it("recycles the old DOM node at the phase boundary", () => {
|
|
154
|
+
expect(tsx).toMatch(/bodies\.value = bodies\.value\.filter\(\(b\) => b\.id !== handle\.leavingId\)/);
|
|
295
155
|
});
|
|
296
156
|
|
|
297
|
-
it("retires the
|
|
298
|
-
// Neither the round-7 vertical ride, the round-9 simultaneous staged
|
|
299
|
-
// class, nor the pre-0.55.38 Vue transition class grammar may sneak
|
|
300
|
-
// back in, and no media block may fork step-body behaviour per
|
|
301
|
-
// viewport.
|
|
157
|
+
it("retires the old vocabulary and media forks", () => {
|
|
302
158
|
expect(tsx).not.toContain("hk-stepflow-fwd");
|
|
303
159
|
expect(tsx).not.toContain("hk-stepflow-back");
|
|
304
160
|
expect(tsx).not.toContain("hk-stepflow-enter-from");
|
|
@@ -308,7 +164,7 @@ describe("HkStepFlow two-phase swap contract", () => {
|
|
|
308
164
|
expect(src).not.toMatch(/@media[^{]*max-width/);
|
|
309
165
|
});
|
|
310
166
|
|
|
311
|
-
it("zeroes the transition under reduced motion
|
|
167
|
+
it("zeroes the transition under reduced motion", () => {
|
|
312
168
|
expect(src).toMatch(
|
|
313
169
|
/prefers-reduced-motion:\s*reduce\)\s*\{[\s\S]{0,160}?\.hk-stepflow-body\s*\{\s*transition:\s*none/,
|
|
314
170
|
);
|