@celestia-island/hikari 0.55.47 → 0.55.49

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.47",
3
+ "version": "0.55.49",
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,127 +1,92 @@
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). A STANDARD ease, not
75
- // the expressive ease-out: the front-loaded curve put the first painted
76
- // frame at ~50% opacity (half the distance in ~11% of the phase), which
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. The NEGATIVE delay starts the fade
54
+ // already partway up its curve, so the new content crosses the eye's
55
+ // perceptual threshold (~0.15 opacity) the moment the old one drops
56
+ // below it — without it, the strictly sequential grammar leaves a
57
+ // ~70-125ms blank body window between the two phases, which was the
58
+ // "flicker" the user's device kept reporting (round 12, second video).
79
59
  transition:
80
- opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.2, 1);
60
+ opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.2, 1) -40ms;
81
61
  }
82
62
 
63
+ // The staged new body: transparent, untouchable, and at the CELL BOTTOM
64
+ // until the enter edge. Deliberately NO `visibility: hidden` — that property
65
+ // changes the render tree and the phone GPU re-rasters the layer when it
66
+ // flips, which was the flash the user kept seeing (round 12 directive).
67
+ // `align-self: end` does two things with zero positioning tricks: it stops
68
+ // the grid from STRETCHING the shorter entering body to the old cell height
69
+ // (so its measured height is the true content height, and the shrink's
70
+ // delta sign is correct), and it puts the body where the folded sheet will
71
+ // land it — the cell bottom is anchored to the sheet footer, so the content
72
+ // appears at its final line instead of being wiped by the descending fold
73
+ // edge and jumping at the landing.
74
+ .hk-stepflow-body.hk-stepflow-enter-pending {
75
+ opacity: 0;
76
+ pointer-events: none;
77
+ align-self: end;
78
+ }
79
+
80
+ // The leaving body: fades out and slides in its direction. The slide is
81
+ // the classic accelerating ease; the fade gets its own gentler curve so
82
+ // the old content eases out over several frames.
83
83
  .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
84
  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
85
  transition:
100
86
  opacity calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.4, 0, 0.6, 1),
101
87
  transform calc(var(--hk-stepflow-duration, 0.3s) / 2) cubic-bezier(0.5, 0, 0.75, 0);
102
88
  }
103
89
 
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
90
  // Direction-aware travel, on the LEAVING body only: forward exits LEFT,
126
91
  // back exits RIGHT (the classic vocabulary). The entering body appears in
127
92
  // place, so it carries no direction rule at all.
@@ -135,8 +100,7 @@
135
100
  transform: translateX(var(--hk-stepflow-travel, 24px));
136
101
  }
137
102
 
138
- /* RTL: reading direction flips, so the exit mirrors — forward exits right
139
- * (house pattern: HkListTransition.scss [dir="rtl"] form). */
103
+ /* RTL: reading direction flips, so the exit mirrors. */
140
104
  [dir="rtl"] .hk-stepflow-bodies[data-direction="forward"] .hk-stepflow-body.hk-stepflow-leave-to {
141
105
  transform: translateX(var(--hk-stepflow-travel, 24px));
142
106
  }
@@ -146,9 +110,7 @@
146
110
  }
147
111
 
148
112
  /* Reduced motion: bodies swap instantly. The component's duration probe
149
- * reads the zeroed duration and settles without staging, so this block
150
- * is the single switch for the whole choreography (the sheet morph
151
- * collapses the same way through its own duration tokens). */
113
+ * reads the zeroed duration and settles without staging. */
152
114
  @media (prefers-reduced-motion: reduce) {
153
115
  .hk-stepflow-body {
154
116
  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,233 +51,113 @@ 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
- // 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)");
79
+ // The negative delay starts the fade already partway up its curve,
80
+ // closing the blank-body window between the two phases.
81
+ expect(decl).toContain("-40ms");
97
82
  expect(decl, "the enter phase never travels").not.toContain("transform");
98
83
  });
99
84
 
100
- it("keeps the leaving body out of the flow with the leave grammar", () => {
85
+ it("stages the new body with opacity only — NO visibility flip", () => {
86
+ const rule = blockFor(".hk-stepflow-body.hk-stepflow-enter-pending");
87
+ expect(rule).toContain("opacity: 0");
88
+ expect(rule).toContain("pointer-events: none");
89
+ expect(rule).toContain("align-self: end");
90
+ expect(rule, "visibility re-rasters the layer — the flash source").not.toContain("visibility");
91
+ });
92
+
93
+ it("keeps the leaving body's fade gentle while the slide accelerates", () => {
101
94
  const rule = src.match(/\.hk-stepflow-body\.leaving\s*\{[^}]*\}/)![0]!;
102
- expect(rule).toContain("position: absolute");
103
95
  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
96
  expect(rule).toContain("cubic-bezier(0.4, 0, 0.6, 1)");
97
+ expect(rule).toContain("cubic-bezier(0.5, 0, 0.75, 0)");
113
98
  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
99
  });
185
100
 
186
101
  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
102
  const body = ".hk-stepflow-body";
192
103
  const stage = ".hk-stepflow-bodies";
193
104
  const ltr: Array<[string, string]> = [
194
- [`${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, TRAVEL_NEGATIVE],
195
- [`${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, TRAVEL_POSITIVE],
105
+ [`${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, "translateX(calc(-1 * var(--hk-stepflow-travel, 24px))"],
106
+ [`${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, "translateX(var(--hk-stepflow-travel, 24px))"],
196
107
  ];
197
108
  const rtl: Array<[string, string]> = [
198
- [`[dir="rtl"] ${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, TRAVEL_POSITIVE],
199
- [`[dir="rtl"] ${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, TRAVEL_NEGATIVE],
109
+ [`[dir="rtl"] ${stage}[data-direction="forward"] ${body}.hk-stepflow-leave-to`, "translateX(var(--hk-stepflow-travel, 24px))"],
110
+ [`[dir="rtl"] ${stage}[data-direction="back"] ${body}.hk-stepflow-leave-to`, "translateX(calc(-1 * var(--hk-stepflow-travel, 24px))"],
200
111
  ];
201
- for (const [selector, sign] of ltr) {
112
+ for (const [selector, sign] of [...ltr, ...rtl]) {
202
113
  const block = blockFor(selector);
203
114
  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
115
  }
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
116
  expect(tsx).toContain("data-direction={dir}");
231
117
  });
232
118
 
233
- it("keeps all motion in the stylesheet — inline writes are geometry staging only", () => {
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.
119
+ it("keeps all motion in the stylesheet — the component writes no styles", () => {
238
120
  expect(tsx).not.toContain("style.transition");
239
121
  expect(tsx).not.toContain("style.transform");
240
122
  expect(tsx).not.toContain("style.opacity");
123
+ expect(tsx).not.toContain("style.top");
124
+ expect(tsx).not.toContain("style.minHeight");
241
125
  expect(tsx).not.toContain("translateY");
242
- expect(tsx).toContain("style.minHeight");
243
- expect(tsx).toMatch(/style\.top = /);
244
126
  });
245
127
 
246
- it("books every phase window on the animation context, never a bare rAF", () => {
128
+ it("has no leftover park/pin/sweep-consumer machinery", () => {
129
+ expect(tsx).not.toContain("pinOldHeight");
130
+ expect(tsx).not.toContain("clearPin");
131
+ expect(tsx).not.toContain("holdLine");
132
+ expect(tsx).not.toContain("parkTail");
133
+ expect(tsx).not.toContain("stageShrinkFold");
134
+ expect(tsx).not.toContain("hk-stepflow-enter-tail");
135
+ expect(tsx).not.toContain("SHEET_SWEEP_STAGE_EVENT");
136
+ expect(tsx).not.toContain("SHEET_SWEEP_SETTLE_EVENT");
137
+ expect(src).not.toContain("hk-stepflow-enter-tail");
138
+ expect(src).not.toContain("min-height");
139
+ });
140
+
141
+ it("books every phase window on the animation context", () => {
247
142
  expect(tsx).toContain('from "../runtime/animationBus"');
248
143
  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
144
  expect(tsx).not.toContain("requestAnimationFrame(");
253
- // No frame-loop state control survives: the exit class flips in the
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
- );
145
+ expect(tsx).not.toContain("scheduleFrame(");
260
146
  });
261
147
 
262
148
  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
149
  expect(tsx).toContain("bodyTransitionMs");
267
150
  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
151
  expect(tsx).toContain("SWAP_WATCHDOG_GRACE_MS");
271
152
  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
153
  expect(tsx).toContain("event.target === el");
276
154
  });
277
155
 
278
- it("schedules the sheet morph onto the phase that owns the height change", () => {
279
- // Grow: the flow owns the new height from frame one → announced at the
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");
156
+ it("recycles the old DOM node at the phase boundary", () => {
157
+ expect(tsx).toMatch(/bodies\.value = bodies\.value\.filter\(\(b\) => b\.id !== handle\.leavingId\)/);
295
158
  });
296
159
 
297
- it("retires the crossfade ride, the simultaneous staged class and out-in", () => {
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.
160
+ it("retires the old vocabulary and media forks", () => {
302
161
  expect(tsx).not.toContain("hk-stepflow-fwd");
303
162
  expect(tsx).not.toContain("hk-stepflow-back");
304
163
  expect(tsx).not.toContain("hk-stepflow-enter-from");
@@ -308,7 +167,7 @@ describe("HkStepFlow two-phase swap contract", () => {
308
167
  expect(src).not.toMatch(/@media[^{]*max-width/);
309
168
  });
310
169
 
311
- it("zeroes the transition under reduced motion so the probe settles", () => {
170
+ it("zeroes the transition under reduced motion", () => {
312
171
  expect(src).toMatch(
313
172
  /prefers-reduced-motion:\s*reduce\)\s*\{[\s\S]{0,160}?\.hk-stepflow-body\s*\{\s*transition:\s*none/,
314
173
  );