@celestia-island/hikari 0.55.46 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.46",
3
+ "version": "0.55.48",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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
- SHEET_SWEEP_SETTLE_EVENT,
7
- SHEET_SWEEP_STAGE_EVENT,
8
- STEPFLOW_SWAP_EVENT,
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
- SHEET_SWEEP_SETTLE_EVENT,
18
- SHEET_SWEEP_STAGE_EVENT,
19
- STEPFLOW_SWAP_EVENT,
20
- } from "./HkStepFlow";
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,123 +1,87 @@
1
- // Two-phase step bodies for HkStepFlow.
1
+ // Step-flow bodies: two-phase sequential swap (2026-09-22 user directive).
2
2
  //
3
- // A swap runs TWO phases, each half of `--hk-stepflow-duration` (0.15s of
4
- // the 0.3s family standard):
5
- //
6
- // EXIT — the OLD body alone is visible: `hk-stepflow-leave-to` slides
7
- // it out (forward exits left, back exits right) and fades it,
8
- // with the sharp ease-in;
9
- // ENTER — the old body is gone and the NEW one fades in PLACE (no
10
- // travel) with the expressive ease-out.
11
- //
12
- // The new body is mounted from frame one with `hk-stepflow-enter-pending`
13
- // (invisible, but laid out so the flow owns the new height immediately),
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. Without
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) — the
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: the entering body owns the flow's height; the leaving
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
- position: relative;
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
- // ENTER grammar (what a body computes once its `hk-stepflow-enter-pending`
73
- // class lifts at the phase-2 edge): a plain fade in place — the new
74
- // content never travels (2026-09-22 user directive).
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.
75
54
  transition:
76
- opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.16, 1, 0.3, 1);
55
+ opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.2, 1);
77
56
  }
78
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.
79
78
  .hk-stepflow-body.leaving {
80
- position: absolute;
81
- // Default anchor: the stage's TOP. The component measures how far the
82
- // stage travelled during the swap patch and writes that as an inline
83
- // `top`, which is the only correct answer across host shapes — a
84
- // bottom-docked sheet whose box grew, a capped box that could not grow,
85
- // and an in-flow stage all differ, and a CSS anchor can only encode one
86
- // of them (a guessed bottom anchor cut 276px off a capped sheet's
87
- // outgoing step).
88
- top: 0;
89
- left: 0;
90
- right: 0;
91
79
  pointer-events: none;
92
- // LEAVE grammar: the sharp ease-in counterpart, applied for the whole
93
- // exit phase (the class lands on the same patch that flips the body
94
- // into `leaving`, so the slide starts from the already-painted state).
95
80
  transition:
96
- opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.5, 0, 0.75, 0),
81
+ opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.6, 1),
97
82
  transform calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.5, 0, 0.75, 0);
98
83
  }
99
84
 
100
- // The parked new body of a shrink whose sheet will fold. Out of flow (the
101
- // stage keeps the OLD height through the fold) and offset by the span the
102
- // sheet published, so it already sits on the line the fold lands on; the
103
- // component releases both when the sheet reports that landing.
104
- .hk-stepflow-body.hk-stepflow-enter-tail {
105
- position: absolute;
106
- top: 0;
107
- left: 0;
108
- right: 0;
109
- }
110
-
111
- // The staged new body: LAID OUT (so the flow owns the new height from
112
- // frame one — the sheet can grow through the exit phase) but invisible,
113
- // which is what guarantees the old and new bodies are never on screen
114
- // together. `visibility` (not display/removal) keeps it measurable and
115
- // keeps a re-swap from ever destroying its reveal.
116
- .hk-stepflow-body.hk-stepflow-enter-pending {
117
- opacity: 0;
118
- visibility: hidden;
119
- }
120
-
121
85
  // Direction-aware travel, on the LEAVING body only: forward exits LEFT,
122
86
  // back exits RIGHT (the classic vocabulary). The entering body appears in
123
87
  // place, so it carries no direction rule at all.
@@ -131,8 +95,7 @@
131
95
  transform: translateX(var(--hk-stepflow-travel, 24px));
132
96
  }
133
97
 
134
- /* RTL: reading direction flips, so the exit mirrors — forward exits right
135
- * (house pattern: HkListTransition.scss [dir="rtl"] form). */
98
+ /* RTL: reading direction flips, so the exit mirrors. */
136
99
  [dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to {
137
100
  transform: translateX(var(--hk-stepflow-travel, 24px));
138
101
  }
@@ -142,9 +105,7 @@
142
105
  }
143
106
 
144
107
  /* Reduced motion: bodies swap instantly. The component's duration probe
145
- * reads the zeroed duration and settles without staging, so this block
146
- * is the single switch for the whole choreography (the sheet morph
147
- * collapses the same way through its own duration tokens). */
108
+ * reads the zeroed duration and settles without staging. */
148
109
  @media (prefers-reduced-motion: reduce) {
149
110
  .hk-stepflow-body {
150
111
  transition: none;
@@ -1,20 +1,11 @@
1
1
  /**
2
- * Source contract for the stepflow two-phase swap (2026-09-22 user
3
- * directive, round 10: the old and the new body must NEVER be on screen
4
- * together — the simultaneous cross-slide read as a doubled ghost of
5
- * overlapping text — so the swap runs an exit phase (old body alone,
6
- * sliding out) and then an enter phase (new body alone, fading in
7
- * place), each half of the family duration; the sheet's height morph is
8
- * scheduled onto one of those phases depending on the direction of the
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,229 +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 two-phase swap contract", () => {
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
- expect(decl).toContain("cubic-bezier(0.16, 1, 0.3, 1)");
78
+ expect(decl).toContain("cubic-bezier(0.4, 0, 0.2, 1)");
95
79
  expect(decl, "the enter phase never travels").not.toContain("transform");
96
80
  });
97
81
 
98
- it("keeps the leaving body out of the flow with the leave grammar", () => {
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", () => {
99
91
  const rule = src.match(/\.hk-stepflow-body\.leaving\s*\{[^}]*\}/)![0]!;
100
- expect(rule).toContain("position: absolute");
101
92
  expect(rule).toContain("pointer-events: none");
102
- // Default anchor is the TOP: an in-flow stage keeps its top line and
103
- // grows downward, so that is where the old body holds its position
104
- // (chest's LoginView renders this flow in a grid-stack crossfade).
105
- expect(rule).toContain("top: 0");
106
- expect(rule, "the default anchor is not the bottom").not.toContain("bottom: 0");
107
- // The classic sharp ease-in on leave, for BOTH opacity and travel.
93
+ expect(rule).toContain("cubic-bezier(0.4, 0, 0.6, 1)");
108
94
  expect(rule).toContain("cubic-bezier(0.5, 0, 0.75, 0)");
109
95
  expect(rule).toContain(PHASE);
110
- const transitions = rule.match(/transition:\s*[^;}]+/g) ?? [];
111
- expect(transitions).toHaveLength(1);
112
- expect(transitions[0]).toContain("opacity");
113
- expect(transitions[0]).toContain("transform");
114
- });
115
-
116
- it("measures the outgoing body's shift instead of assuming a host anchor", () => {
117
- // The host decides where a growing box's lines sit (bottom-docked sheet,
118
- // capped sheet, centred desktop frame, in-flow stage), so the component
119
- // measures how far the stage travelled during the swap patch and writes
120
- // that as an inline `top`. A CSS anchor can only encode one of those
121
- // shapes: the bottom-anchored attempt cut 276px off a capped sheet's
122
- // outgoing step (R2 verification finding).
123
- expect(tsx).toContain("getBoundingClientRect");
124
- expect(tsx).toMatch(/const shift = Math\.round\(leaveTop0 - goneEl\.getBoundingClientRect\(\)\.top\)/);
125
- expect(tsx).toMatch(/if \(shift\) goneEl\.style\.top = `\$\{shift\}px`/);
126
- // …and the stylesheet keeps ONE anchor, the in-flow-safe top.
127
- expect(src).not.toContain("data-anchor");
128
- expect(tsx).not.toContain("data-anchor");
129
- expect(src).not.toContain("--hk-sheet-morph");
130
- });
131
-
132
- it("parks the new body on the span the sheet publishes and releases on its landing", () => {
133
- // Clause 5: the new content must already sit at its final geometry while
134
- // the sheet's clip edge folds down. The span comes from the SHEET (the
135
- // only place the max-height cap is accounted for), and the release is
136
- // tied to the sheet's landing — releasing on the body's own fade left
137
- // the frame clipped for up to 334ms and parked content 276px off on a
138
- // capped box.
139
- const rule = blockFor(".hk-stepflow-body.hk-stepflow-enter-tail");
140
- expect(rule).toContain("position: absolute");
141
- const transitions = rule.match(/transition:\s*[^;}]+/g) ?? [];
142
- expect(transitions, "the parked body does not animate its offset").toHaveLength(0);
143
- expect(tsx).toContain("SHEET_SWEEP_STAGE_EVENT");
144
- expect(tsx).toContain("SHEET_SWEEP_SETTLE_EVENT");
145
- expect(tsx).toMatch(/direction === "conceal"/);
146
- expect(tsx).toMatch(/span = Math\.max\(0, Math\.round\(info\.from - info\.to\)\)/);
147
- expect(tsx).toMatch(/span > 0\)[\s\S]{0,200}?parkTail\(/);
148
- expect(tsx).toMatch(/entering\.style\.top = `\$\{span\}px`/);
149
- expect(tsx).toMatch(/TAIL_WATCHDOG_GRACE_MS/);
150
- // The order matters: the stage probe is attached, the pin comes off (so
151
- // the sheet measures the NEW natural height), the announce stages and
152
- // publishes the fold, and only then does the park land.
153
- expect(tsx).toMatch(
154
- /addEventListener\(SHEET_SWEEP_STAGE_EVENT, onStage\);[\s\S]{0,200}?clearPin\(\);[\s\S]{0,200}?announce\(handle\);/,
155
- );
156
- // The host side republishes the morph's own numbers.
157
- expect(modal).toContain("onSweepStage");
158
- expect(modal).toContain("onSweepSettle");
159
- expect(modal).toContain("SHEET_SWEEP_STAGE_EVENT");
160
- expect(modal).toContain("SHEET_SWEEP_SETTLE_EVENT");
161
- expect(morphSrc).toContain("options.onSweepStage?.");
162
- expect(morphSrc).toContain("options.onSweepSettle?.");
163
- });
164
-
165
- it("stages the new body laid out but invisible so the bodies cannot overlap", () => {
166
- // The round-10 ghost fix: the entering body exists from frame one (so
167
- // the flow owns the new height immediately) but is unpaintable for the
168
- // whole exit phase.
169
- const rule = blockFor(".hk-stepflow-body.hk-stepflow-enter-pending");
170
- expect(rule).toContain("visibility: hidden");
171
- expect(rule).toContain("opacity: 0");
172
- expect(rule, "the staged body never travels").not.toContain("transform");
173
- // …and the component binds it to the EXIT phase only: the class lifts
174
- // at the phase-2 edge, which is what starts the fade in place.
175
- expect(tsx).toContain("hk-stepflow-enter-pending");
176
- expect(tsx).toContain('swapPhase.value === "exit"');
177
- // The component must announce the enter edge as a distinct phase.
178
- expect(tsx).toMatch(/phase\s*=\s*"enter"/);
179
- expect(tsx).toMatch(/swapPhase\.value\s*=\s*"enter"/);
180
96
  });
181
97
 
182
98
  it("binds each direction's travel sign to its exact selector", () => {
183
- // Only the LEAVING body travels (the entering one appears in place), so
184
- // the direction grammar binds exactly four selectors, sign-bound: a
185
- // sign inversion must go red. forward exits LEFT (−travel), back exits
186
- // RIGHT (+travel); RTL mirrors the mapping.
187
99
  const body = ".hk-stepflow-body";
188
100
  const stage = ".hk-stepflow-bodies";
189
101
  const ltr: Array<[string, string]> = [
190
- [`${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, TRAVEL_NEGATIVE],
191
- [`${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, TRAVEL_POSITIVE],
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))"],
192
104
  ];
193
105
  const rtl: Array<[string, string]> = [
194
- [`[dir="rtl"] ${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, TRAVEL_POSITIVE],
195
- [`[dir="rtl"] ${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, TRAVEL_NEGATIVE],
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))"],
196
108
  ];
197
- for (const [selector, sign] of ltr) {
198
- const block = blockFor(selector);
199
- expect(block, `${selector} must carry ${sign}`).toContain(sign);
200
- const other = sign === TRAVEL_POSITIVE ? TRAVEL_NEGATIVE : TRAVEL_POSITIVE;
201
- expect(block, `${selector} must not carry ${other}`).not.toContain(other);
202
- expect(block).toContain("opacity: 0");
203
- }
204
- // RTL blocks are transform-only overrides over the LTR grammar.
205
- for (const [selector, sign] of rtl) {
109
+ for (const [selector, sign] of [...ltr, ...rtl]) {
206
110
  const block = blockFor(selector);
207
111
  expect(block, `${selector} must carry ${sign}`).toContain(sign);
208
- const other = sign === TRAVEL_POSITIVE ? TRAVEL_NEGATIVE : TRAVEL_POSITIVE;
209
- expect(block, `${selector} must not carry ${other}`).not.toContain(other);
210
- }
211
- // No direction rule may hang off the staged or entering class — the
212
- // new body never travels, in any direction (checked per RULE: the
213
- // class name also appears in comments, so a raw regex over the file
214
- // would false-positive across rule boundaries).
215
- const pendingRules = scssRules.filter((r) =>
216
- r.selector.includes("hk-stepflow-enter-pending"),
217
- );
218
- expect(pendingRules.length).toBeGreaterThan(0);
219
- for (const r of pendingRules) {
220
- expect(r.body, r.selector).not.toContain("transform");
221
- expect(r.selector).not.toContain("data-direction");
222
112
  }
223
- // The stage keeps its relative positioning for the overlay grammar.
224
- expect(src).toMatch(/\.hk-stepflow-bodies\s*\{[^}]*position:\s*relative/);
225
- // The component renders the direction attribute the CSS keys off.
226
113
  expect(tsx).toContain("data-direction={dir}");
227
114
  });
228
115
 
229
- it("keeps all motion in the stylesheet — inline writes are geometry staging only", () => {
230
- // The 2026-09-22 directive: CSS is the base. The component toggles
231
- // classes and writes only MEASURED geometry (the shrink's height pin and
232
- // the outgoing/parked bodies' offsets); it must never write motion
233
- // styles or animate a body itself.
116
+ it("keeps all motion in the stylesheet — the component writes no styles", () => {
234
117
  expect(tsx).not.toContain("style.transition");
235
118
  expect(tsx).not.toContain("style.transform");
236
119
  expect(tsx).not.toContain("style.opacity");
120
+ expect(tsx).not.toContain("style.top");
121
+ expect(tsx).not.toContain("style.minHeight");
237
122
  expect(tsx).not.toContain("translateY");
238
- expect(tsx).toContain("style.minHeight");
239
- expect(tsx).toMatch(/style\.top = /);
240
123
  });
241
124
 
242
- it("books every phase window on the animation context, never a bare rAF", () => {
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", () => {
243
139
  expect(tsx).toContain('from "../runtime/animationBus"');
244
140
  expect(tsx).toContain("reportTransition(");
245
- // The pre-emption fast path stages a start state for one frame: that is
246
- // exactly what the shared bus one-shot is for.
247
- expect(tsx).toContain("scheduleFrame(");
248
141
  expect(tsx).not.toContain("requestAnimationFrame(");
249
- // No frame-loop state control survives: the exit class flips in the
250
- // swap patch itself and the staged state is long-lived, so the old
251
- // staging frame is gone. Every phase timer must still carry the shared
252
- // grace so a lost transitionend cannot strand a phase.
253
- expect(tsx).toMatch(
254
- /setTimeout\([^,]+,\s*handle\.phaseMs\s*\+\s*SWAP_WATCHDOG_GRACE_MS\)/,
255
- );
142
+ expect(tsx).not.toContain("scheduleFrame(");
256
143
  });
257
144
 
258
145
  it("settles deterministically where no transition runs", () => {
259
- // The duration probe: zero (reduced motion / stylesheet-less runtime)
260
- // selects the atomic instant-settle path — the debt pin for chest's
261
- // transitionend-less wizard tests.
262
146
  expect(tsx).toContain("bodyTransitionMs");
263
147
  expect(tsx).toContain("transitionDuration");
264
- // The motion path keeps a watchdog so a lost transitionend cannot
265
- // freeze a phase (same grammar as the sheet morph).
266
148
  expect(tsx).toContain("SWAP_WATCHDOG_GRACE_MS");
267
149
  expect(tsx).toContain('addEventListener("transitionend"');
268
- // transitionend bubbles — only the acting body's own transitions may
269
- // advance a phase (R3 spot mutation: removing this guard survived the
270
- // suite, so it is pinned here).
271
150
  expect(tsx).toContain("event.target === el");
272
151
  });
273
152
 
274
- it("schedules the sheet morph onto the phase that owns the height change", () => {
275
- // Grow: the flow owns the new height from frame one → announced at the
276
- // EXIT edge. Shrink: the flow holds its old height → announced at the
277
- // ENTER edge (the pin lifts in the same step).
278
- expect(tsx).toMatch(/delta\s*>=\s*0/);
279
- expect(tsx).toContain("pinOldHeight(");
280
- // Both edges route through the shared helpers: the ordinary path holds
281
- // the old height while the old body plays and hands the sheet over at
282
- // the enter edge; the fast path does the same at its single edge.
283
- expect(tsx).toContain("stageShrinkFold(");
284
- expect(tsx).toContain("parkTail(");
285
- expect(tsx).toMatch(
286
- /handle\.delta\s*<\s*0\s*\)\s*\{[\s\S]{0,200}?stageShrinkFold\(handle\)/,
287
- );
288
- expect(tsx).toMatch(/\} else \{[\s\S]{0,220}?pinOldHeight\(oldH\);/);
289
- // The event carries the phase length so the sheet can match it.
290
- 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\)/);
291
155
  });
292
156
 
293
- it("retires the crossfade ride, the simultaneous staged class and out-in", () => {
294
- // Neither the round-7 vertical ride, the round-9 simultaneous staged
295
- // class, nor the pre-0.55.38 Vue transition class grammar may sneak
296
- // back in, and no media block may fork step-body behaviour per
297
- // viewport.
157
+ it("retires the old vocabulary and media forks", () => {
298
158
  expect(tsx).not.toContain("hk-stepflow-fwd");
299
159
  expect(tsx).not.toContain("hk-stepflow-back");
300
160
  expect(tsx).not.toContain("hk-stepflow-enter-from");
@@ -304,7 +164,7 @@ describe("HkStepFlow two-phase swap contract", () => {
304
164
  expect(src).not.toMatch(/@media[^{]*max-width/);
305
165
  });
306
166
 
307
- it("zeroes the transition under reduced motion so the probe settles", () => {
167
+ it("zeroes the transition under reduced motion", () => {
308
168
  expect(src).toMatch(
309
169
  /prefers-reduced-motion:\s*reduce\)\s*\{[\s\S]{0,160}?\.hk-stepflow-body\s*\{\s*transition:\s*none/,
310
170
  );