@celestia-island/hikari 0.55.314 → 0.55.316
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/animation/registerAnimations.test.ts +5 -3
- package/src/animation/registerAnimations.ts +0 -2
- package/src/components/HkListTransition.scss +62 -1
- package/src/components/HkListTransition.tsx +123 -5
- package/src/components/HkModal.leavepin.test.tsx +3 -3
- package/src/components/HkModal.open-close-scale.test.ts +18 -0
- package/src/components/HkModal.scss +8 -9
- package/src/components/HkModal.sheetgap.test.ts +10 -5
- package/src/components/HkModal.sheetmorph.test.ts +14 -7
- package/src/components/HkModalBreadcrumb.motion.contract.test.ts +242 -0
- package/src/components/HkModalBreadcrumb.scss +63 -15
- package/src/components/HkModalBreadcrumb.test.tsx +237 -6
- package/src/components/HkModalBreadcrumb.tsx +225 -138
- package/src/components/HkTable.interactions.test.tsx +111 -2
- package/src/components/HkTable.scss +30 -6
- package/src/components/HkTable.tsx +45 -11
- package/src/components/paneSwap.contract.test.ts +4 -1
- package/src/composables/useSizeMorph.test.ts +10 -7
- package/src/composables/useSizeMorph.ts +23 -9
- package/src/styles/selectionSheet.test.ts +48 -3
package/package.json
CHANGED
|
@@ -15,7 +15,10 @@ const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
15
15
|
|
|
16
16
|
/** Recursively collect SCSS/TS/TSX sources under src/, skipping the
|
|
17
17
|
* animation registrar's own directory (it is the thing under test, not
|
|
18
|
-
* an animation-owning component).
|
|
18
|
+
* an animation-owning component). Test files are skipped too: a keyframe
|
|
19
|
+
* NAME quoted inside a test (a negative assertion, a fixture) is not a
|
|
20
|
+
* declaration — counting it made the parity below agree with itself
|
|
21
|
+
* instead of with the sheets (2026-11-07 review, M7). */
|
|
19
22
|
function collectSourceFiles(dir: string): string[] {
|
|
20
23
|
const out: string[] = [];
|
|
21
24
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -23,7 +26,7 @@ function collectSourceFiles(dir: string): string[] {
|
|
|
23
26
|
if (entry.isDirectory()) {
|
|
24
27
|
if (p === path.resolve(srcDir, "animation")) continue;
|
|
25
28
|
out.push(...collectSourceFiles(p));
|
|
26
|
-
} else if (/\.(scss|ts|tsx)$/.test(entry.name)) {
|
|
29
|
+
} else if (/\.(scss|ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.[tj]sx?$/.test(entry.name)) {
|
|
27
30
|
out.push(p);
|
|
28
31
|
}
|
|
29
32
|
}
|
|
@@ -65,7 +68,6 @@ describe("registerAnimations", () => {
|
|
|
65
68
|
expect(byName.get("hk-range-ribbon-pulse")?.infinite).toBe(true);
|
|
66
69
|
expect(byName.get("s-auth-card-in")?.infinite).toBe(false);
|
|
67
70
|
expect(byName.get("hk-rolling-number-up")?.infinite).toBe(false);
|
|
68
|
-
expect(byName.get("hk-modal-breadcrumb-in")?.infinite).toBe(false);
|
|
69
71
|
expect(byName.get("hk-pwd-flash")?.infinite).toBe(false);
|
|
70
72
|
});
|
|
71
73
|
|
|
@@ -84,8 +84,6 @@ registerCssAnimation("hk-status-pill-pulse", { infinite: true });
|
|
|
84
84
|
registerCssAnimation("hk-rolling-number-up");
|
|
85
85
|
// components/HkSpinner.scss — spinner rotation (loops forever).
|
|
86
86
|
registerCssAnimation("hk-spinner-rotate", { infinite: true });
|
|
87
|
-
// components/HkModalBreadcrumb.scss — breadcrumb entrance (plays once).
|
|
88
|
-
registerCssAnimation("hk-modal-breadcrumb-in");
|
|
89
87
|
// components/HkToast.scss — toast spinner rotation (loops forever).
|
|
90
88
|
registerCssAnimation("hk-toast-spin", { infinite: true });
|
|
91
89
|
// components/HkSkeleton.scss — skeleton shimmer (loops forever).
|
|
@@ -151,6 +151,64 @@
|
|
|
151
151
|
transition: transform var(--duration-fast, 0.15s) ease;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/* surface — the window choreography (HkModal's whole-window entrance,
|
|
155
|
+
* user direction 2026-11-07): a row arrives rising 10px while settling
|
|
156
|
+
* from 97% scale and fading in, and leaves by sinking back to that same
|
|
157
|
+
* pose. A compositor-only signature end to end — opacity + transform
|
|
158
|
+
* only, so the row never reflows per frame (the lesson the modal's own
|
|
159
|
+
* height-ride era taught the hard way). Siblings glide into the freed
|
|
160
|
+
* space with the same ease. Shares the window family's curves: the
|
|
161
|
+
* modal's ease-out (0.33, 1, 0.68, 1) arriving, its ease-in
|
|
162
|
+
* (0.45, 0, 0.55, 1) departing. */
|
|
163
|
+
.hk-list-surface-enter-active {
|
|
164
|
+
transition:
|
|
165
|
+
opacity var(--duration-normal, 0.3s) cubic-bezier(0.33, 1, 0.68, 1),
|
|
166
|
+
transform var(--duration-normal, 0.3s) cubic-bezier(0.33, 1, 0.68, 1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.hk-list-surface-leave-active {
|
|
170
|
+
transition:
|
|
171
|
+
opacity var(--duration-short, 0.15s) cubic-bezier(0.45, 0, 0.55, 1),
|
|
172
|
+
transform var(--duration-short, 0.15s) cubic-bezier(0.45, 0, 0.55, 1);
|
|
173
|
+
position: absolute;
|
|
174
|
+
pointer-events: none;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* A dying ghost's interactive descendants re-enable hit-testing on
|
|
178
|
+
themselves (the modal breadcrumb's trigger/reveal buttons carry auto —
|
|
179
|
+
the strip itself is pointer-transparent), and inheritance does not
|
|
180
|
+
beat an explicit auto. The ghost must not take clicks while it sinks:
|
|
181
|
+
a tap opened a popover anchored to the fading element (2026-11-07
|
|
182
|
+
review). Specificity (0,1,1) outranks the (0,1,0) button faces.
|
|
183
|
+
Belt relationship: pinLeaving also sets `inert` on the ghost (blocks
|
|
184
|
+
clicks AND focus for every descendant where the pin ran); this rule is
|
|
185
|
+
the CSS belt that still dead-ends buttons when the pin skipped (no
|
|
186
|
+
layout — tests — or a host without captured rects). Future surface
|
|
187
|
+
consumers whose rows carry links/inputs, not just buttons: extend this
|
|
188
|
+
selector or rely on the inert. */
|
|
189
|
+
.hk-list-surface-leave-active button {
|
|
190
|
+
pointer-events: none;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.hk-list-surface-enter-from,
|
|
194
|
+
.hk-list-surface-leave-to {
|
|
195
|
+
opacity: 0;
|
|
196
|
+
transform: translateY(10px) scale(0.97);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.hk-list-surface-move {
|
|
200
|
+
/* opacity rides along on purpose: if a multi-row batch ever classifies
|
|
201
|
+
a leaving ghost as moved (its FLIP delta measured before the
|
|
202
|
+
correction pass lands), the stamped move transition would REPLACE
|
|
203
|
+
the leave's transition shorthand — a transform-only move cancels the
|
|
204
|
+
fade mid-flight and snaps the ghost invisible. Carrying the leave's
|
|
205
|
+
own opacity curve keeps the fade alive under that stamp; for true
|
|
206
|
+
moved rows the property goes 1→1 and is inert. */
|
|
207
|
+
transition:
|
|
208
|
+
opacity var(--duration-short, 0.15s) cubic-bezier(0.45, 0, 0.55, 1),
|
|
209
|
+
transform var(--duration-normal, 0.3s) cubic-bezier(0.33, 1, 0.68, 1);
|
|
210
|
+
}
|
|
211
|
+
|
|
154
212
|
.hk-list-none-enter-active,
|
|
155
213
|
.hk-list-none-leave-active,
|
|
156
214
|
.hk-list-none-move {
|
|
@@ -171,11 +229,14 @@
|
|
|
171
229
|
.hk-list-grow-leave-active,
|
|
172
230
|
.hk-list-reveal-enter-active,
|
|
173
231
|
.hk-list-reveal-leave-active,
|
|
232
|
+
.hk-list-surface-enter-active,
|
|
233
|
+
.hk-list-surface-leave-active,
|
|
174
234
|
.hk-list-pop-move,
|
|
175
235
|
.hk-list-slide-move,
|
|
176
236
|
.hk-list-fade-move,
|
|
177
237
|
.hk-list-grow-move,
|
|
178
|
-
.hk-list-reveal-move
|
|
238
|
+
.hk-list-reveal-move,
|
|
239
|
+
.hk-list-surface-move {
|
|
179
240
|
transition: none !important;
|
|
180
241
|
}
|
|
181
242
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { defineComponent, onBeforeUpdate, ref, TransitionGroup, type PropType } from "vue";
|
|
1
|
+
import { defineComponent, onBeforeUpdate, queuePostFlushCb, ref, TransitionGroup, type PropType } from "vue";
|
|
2
2
|
|
|
3
3
|
import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
|
|
4
4
|
import { useReportedTransition } from "../composables/useReportedTransition";
|
|
5
|
+
import { ancestorZoom } from "../runtime/cssZoom";
|
|
5
6
|
|
|
6
7
|
import "./HkListTransition.scss";
|
|
7
8
|
|
|
8
|
-
type ListAnimVariant = "pop" | "slide" | "fade" | "grow" | "reveal";
|
|
9
|
+
type ListAnimVariant = "pop" | "slide" | "fade" | "grow" | "reveal" | "surface";
|
|
9
10
|
|
|
10
|
-
/** Enter/leave report window: the slowest variant (pop/slide/fade)
|
|
11
|
-
* --hi-duration-normal, the fast ones (grow/reveal) run their own 150ms
|
|
11
|
+
/** Enter/leave report window: the slowest variant (pop/slide/fade/surface)
|
|
12
|
+
* runs --hi-duration-normal, the fast ones (grow/reveal) run their own 150ms
|
|
12
13
|
* plus a 150ms stagger delay — both finish inside this budget. */
|
|
13
14
|
const REPORT_MS = 320;
|
|
14
15
|
|
|
@@ -27,6 +28,13 @@ const REPORT_MS = 320;
|
|
|
27
28
|
* their natural height (interpolate-size + calc-size), leaving rows
|
|
28
29
|
* shrink back — the container height itself animates, so wrapping
|
|
29
30
|
* groups and stacked cards both get a real space transition.
|
|
31
|
+
* - `surface` — the window choreography (HkModal's whole-window
|
|
32
|
+
* entrance, user direction 2026-11-07): a row rises 10px while
|
|
33
|
+
* settling from 97% scale and fading in, and leaves by sinking back
|
|
34
|
+
* to the same pose; siblings glide into the freed space (FLIP).
|
|
35
|
+
* The motion signature of the surface family, so a list whose rows
|
|
36
|
+
* ARE windows (HkModalBreadcrumb's stack) can animate them the way
|
|
37
|
+
* the windows themselves arrive.
|
|
30
38
|
*
|
|
31
39
|
* Animation-context participation: every enter/leave batch is REPORTED to
|
|
32
40
|
* the runtime animation bus (`reportTransition`), so JS choreography that
|
|
@@ -69,7 +77,7 @@ export default defineComponent({
|
|
|
69
77
|
// the geometry pin; `reveal` animates the row's own height in place
|
|
70
78
|
// and `none` never leaves the flow, so a pin would freeze the very
|
|
71
79
|
// property they animate.
|
|
72
|
-
const ABSOLUTE_VARIANTS: ReadonlySet<string> = new Set(["pop", "slide", "fade", "grow"]);
|
|
80
|
+
const ABSOLUTE_VARIANTS: ReadonlySet<string> = new Set(["pop", "slide", "fade", "grow", "surface"]);
|
|
73
81
|
|
|
74
82
|
// Pre-patch geometry of every row, refreshed on each update (the DOM
|
|
75
83
|
// is still the pre-patch tree at onBeforeUpdate — same pattern as
|
|
@@ -82,6 +90,18 @@ export default defineComponent({
|
|
|
82
90
|
// position:relative (the full-width leave flash).
|
|
83
91
|
const hostRef = ref<{ $el?: Element } | null>(null);
|
|
84
92
|
const prePatchBoxes = new WeakMap<Element, LeaveBoxSnapshot>();
|
|
93
|
+
/** Pre-patch VIEWPORT position of every row (gBCR). The offset boxes
|
|
94
|
+
* above are relative to the offsetParent's padding box — for a host
|
|
95
|
+
* whose padding box MOVES when its content shrinks (a centered
|
|
96
|
+
* shrink-to-fit host) that origin shift is exactly the ghost-drift
|
|
97
|
+
* the compensation below cancels, so the viewport truth is what the
|
|
98
|
+
* compensation must restore. */
|
|
99
|
+
const prePatchRects = new WeakMap<Element, { x: number; y: number }>();
|
|
100
|
+
// Known limit: the rects include in-flight enter/move transforms — a
|
|
101
|
+
// row removed mid-entrance freezes its transient pose into the
|
|
102
|
+
// one-shot margin. Cosmetic (dying ghost, ≤300ms race window,
|
|
103
|
+
// ≤10px); excluded-transform capture would need offset math per
|
|
104
|
+
// host, not worth it for a generic primitive.
|
|
85
105
|
onBeforeUpdate(() => {
|
|
86
106
|
// Fragment hosts (tag="") have a comment anchor as $el — no
|
|
87
107
|
// element children to measure there.
|
|
@@ -95,20 +115,118 @@ export default defineComponent({
|
|
|
95
115
|
width: e.offsetWidth,
|
|
96
116
|
height: e.offsetHeight,
|
|
97
117
|
});
|
|
118
|
+
const r = e.getBoundingClientRect();
|
|
119
|
+
prePatchRects.set(e, { x: r.left, y: r.top });
|
|
98
120
|
}
|
|
99
121
|
});
|
|
100
122
|
|
|
101
123
|
const usesAbsoluteLeave = () =>
|
|
102
124
|
!props.disabled && ABSOLUTE_VARIANTS.has(props.variant);
|
|
103
125
|
|
|
126
|
+
/** Ghosts of the current batch (inline-absolute, awaiting removal).
|
|
127
|
+
* Iterated by the post-flush correction below; pruned when a leave
|
|
128
|
+
* aborts or an element leaves the DOM. */
|
|
129
|
+
const pinnedGhosts = new Set<HTMLElement>();
|
|
130
|
+
let ghostCorrectionQueued = false;
|
|
131
|
+
|
|
132
|
+
/** The centered-host compensation can only see the refits that have
|
|
133
|
+
* ALREADY happened when a ghost is pinned — in a MULTI-ROW batch
|
|
134
|
+
* (a fold evicting several crumbs in one patch) the later lifts
|
|
135
|
+
* re-shrink a centered host further, dragging every earlier ghost
|
|
136
|
+
* by Σ_w_later/2 before anything paints (2026-11-07 review, round
|
|
137
|
+
* 4). One post-flush pass fixes the whole batch exactly: it runs
|
|
138
|
+
* after the patch (all lifts done, final refit reached) but — being
|
|
139
|
+
* enqueued mid-patch, inside the leave hook, while TransitionGroup
|
|
140
|
+
* only enqueues its own updated hook once its update COMPLETES —
|
|
141
|
+
* BEFORE the FLIP pass measures positions. Exact ghosts mean
|
|
142
|
+
* dx=dy=0, so no ghost is ever mis-classified as moved (a move
|
|
143
|
+
* stamp would swap the leave's opacity transition for the move's
|
|
144
|
+
* transform-only one and snap the fade). Idempotent: the write
|
|
145
|
+
* derives the delta from the live rect including the current
|
|
146
|
+
* margin. */
|
|
147
|
+
function scheduleGhostCorrection(): void {
|
|
148
|
+
if (ghostCorrectionQueued) return;
|
|
149
|
+
ghostCorrectionQueued = true;
|
|
150
|
+
queuePostFlushCb(() => {
|
|
151
|
+
ghostCorrectionQueued = false;
|
|
152
|
+
for (const ghost of pinnedGhosts) {
|
|
153
|
+
if (!ghost.isConnected) {
|
|
154
|
+
pinnedGhosts.delete(ghost);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const pre = prePatchRects.get(ghost);
|
|
158
|
+
if (pre == null) continue;
|
|
159
|
+
// Same visual→local conversion as the first-phase compensation:
|
|
160
|
+
// gBCR deltas are zoom-scaled at paint, margins are local.
|
|
161
|
+
const z = ancestorZoom(ghost);
|
|
162
|
+
const now = ghost.getBoundingClientRect();
|
|
163
|
+
const dx = (pre.x - now.left) / z;
|
|
164
|
+
const dy = (pre.y - now.top) / z;
|
|
165
|
+
if (dx === 0 && dy === 0) continue;
|
|
166
|
+
const currentMx = Number.parseFloat(ghost.style.marginLeft || "0") || 0;
|
|
167
|
+
const currentMy = Number.parseFloat(ghost.style.marginTop || "0") || 0;
|
|
168
|
+
ghost.style.marginLeft = `${currentMx + dx}px`;
|
|
169
|
+
ghost.style.marginTop = `${currentMy + dy}px`;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
104
174
|
function pinLeaving(el: Element) {
|
|
105
175
|
if (!usesAbsoluteLeave()) return;
|
|
176
|
+
const node = el as HTMLElement;
|
|
106
177
|
pinLeaveGeometry(el, { anchorX: "left", box: prePatchBoxes.get(el) });
|
|
178
|
+
// ── Centered-host compensation (first phase) ───────────────────
|
|
179
|
+
// An absolute ghost is positioned against the host's PADDING box —
|
|
180
|
+
// and a shrink-to-fit host REFITS the instant the ghost leaves the
|
|
181
|
+
// flow. A centered host (the modal breadcrumb's nav: fixed +
|
|
182
|
+
// left:50% + translate(-50%,-50%)) then moves its padding origin
|
|
183
|
+
// right by half the freed width, and the pinned `left` lands the
|
|
184
|
+
// ghost ΔW/2 away from where the user last saw it (2026-11-07
|
|
185
|
+
// review, real-Chrome repro: 74px jump on a single removal). Cancel
|
|
186
|
+
// the ALREADY-HAPPENED refits while nobody has painted: lift the
|
|
187
|
+
// ghost out of flow inline — in the SAME patch pass as the pin
|
|
188
|
+
// (Vue adds the leave-active class synchronously here too; its
|
|
189
|
+
// nextFrame only gates the leave-from → leave-to swap) — force the
|
|
190
|
+
// refit with one layout read, and write the visual delta back as a
|
|
191
|
+
// ONE-SHOT margin. Refits that happen AFTER this hook (later ghosts
|
|
192
|
+
// of the same batch) are corrected by scheduleGhostCorrection's
|
|
193
|
+
// post-flush pass above — the pair lands every ghost of any batch
|
|
194
|
+
// on its pre-patch spot before the first leave frame paints, with
|
|
195
|
+
// the ghost's motion itself opacity + transform only (compositor
|
|
196
|
+
// contract intact; margins are static one-time writes).
|
|
197
|
+
const pre = prePatchRects.get(el);
|
|
198
|
+
if (pre == null || node.offsetParent == null) return;
|
|
199
|
+
pinnedGhosts.add(node);
|
|
200
|
+
// Dying ghosts take no clicks AND no Tab focus (pointer-events does
|
|
201
|
+
// not stop focus; a Tab landing mid-leave would focus a doomed
|
|
202
|
+
// button and drop to <body> when it unmounts).
|
|
203
|
+
node.inert = true;
|
|
204
|
+
node.style.position = "absolute";
|
|
205
|
+
// gBCR deltas are VISUAL px (scaled by any ancestor zoom, e.g.
|
|
206
|
+
// chest's manual DPI scale); margins are LOCAL px painted ×z —
|
|
207
|
+
// convert through the zoom read at the ghost, or the correction
|
|
208
|
+
// over-shoots by (z−1)·delta on zoomed hosts.
|
|
209
|
+
const z = ancestorZoom(node);
|
|
210
|
+
const now = node.getBoundingClientRect();
|
|
211
|
+
const dx = (pre.x - now.left) / z;
|
|
212
|
+
const dy = (pre.y - now.top) / z;
|
|
213
|
+
if (dx !== 0) node.style.marginLeft = `${dx}px`;
|
|
214
|
+
if (dy !== 0) node.style.marginTop = `${dy}px`;
|
|
215
|
+
scheduleGhostCorrection();
|
|
107
216
|
}
|
|
108
217
|
|
|
109
218
|
function unpinLeaving(el: Element) {
|
|
110
219
|
if (!usesAbsoluteLeave()) return;
|
|
111
220
|
clearLeaveGeometry(el);
|
|
221
|
+
// The compensation writes of pinLeaving — an aborted leave returns
|
|
222
|
+
// the row to the flow, where a stale position/margin would freeze
|
|
223
|
+
// it off its slot.
|
|
224
|
+
const node = el as HTMLElement;
|
|
225
|
+
pinnedGhosts.delete(node);
|
|
226
|
+
node.inert = false;
|
|
227
|
+
node.style.position = "";
|
|
228
|
+
node.style.marginLeft = "";
|
|
229
|
+
node.style.marginTop = "";
|
|
112
230
|
}
|
|
113
231
|
|
|
114
232
|
return () => {
|
|
@@ -122,7 +122,7 @@ describe("HkModal leave-window height pin", () => {
|
|
|
122
122
|
it("keeps the morph pin through the whole leave and releases at finalize", async () => {
|
|
123
123
|
vi.useFakeTimers();
|
|
124
124
|
freezeRaf();
|
|
125
|
-
|
|
125
|
+
stubPhoneViewport();
|
|
126
126
|
stubDurations();
|
|
127
127
|
stubLayout(500);
|
|
128
128
|
try {
|
|
@@ -160,7 +160,7 @@ describe("HkModal leave-window height pin", () => {
|
|
|
160
160
|
it("leaves the frame unpinned through the enter; the open edge pins the settled height", async () => {
|
|
161
161
|
vi.useFakeTimers();
|
|
162
162
|
freezeRaf();
|
|
163
|
-
|
|
163
|
+
stubPhoneViewport();
|
|
164
164
|
stubDurations();
|
|
165
165
|
stubLayout(400);
|
|
166
166
|
try {
|
|
@@ -197,7 +197,7 @@ describe("HkModal leave-window height pin", () => {
|
|
|
197
197
|
it("drops the held pin at a reopen-interrupt edge; the open edge re-pins", async () => {
|
|
198
198
|
vi.useFakeTimers();
|
|
199
199
|
freezeRaf();
|
|
200
|
-
|
|
200
|
+
stubPhoneViewport();
|
|
201
201
|
stubDurations();
|
|
202
202
|
stubLayout(500);
|
|
203
203
|
try {
|
|
@@ -378,6 +378,24 @@ describe("HkModal desktop open/close contract (compiled cascade, user direction
|
|
|
378
378
|
}
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
it("keeps the desktop rest transition to the height leg only", () => {
|
|
382
|
+
// The size morph's height leg is the ONLY property the desktop rest
|
|
383
|
+
// state ever animates. The clip-path reveal morph belongs to the
|
|
384
|
+
// ≤767px sheet block (which replaces the list wholesale), and
|
|
385
|
+
// nothing changes the frame's max-height after mount (the 70vh cap
|
|
386
|
+
// is the body's, a different element) — retired legs smuggled back
|
|
387
|
+
// into the shorthand must fail this pin (2026-11-07 residue sweep).
|
|
388
|
+
const rest = frameWith();
|
|
389
|
+
expect(winner(rest, "transition", DESKTOP)).toBe(
|
|
390
|
+
"height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1))",
|
|
391
|
+
);
|
|
392
|
+
// The phone sheet keeps its own clip-path-only morph list.
|
|
393
|
+
const mobileRest = frameWith();
|
|
394
|
+
expect(winner(mobileRest, "transition", MOBILE)).toBe(
|
|
395
|
+
"clip-path var(--hk-modal-morph-duration, 0.3s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1))",
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
381
399
|
it("fades the frame and rides its height on one shared token pair per direction", () => {
|
|
382
400
|
// Pinned WHOLE (winner() normalizes whitespace), not by prefix: a
|
|
383
401
|
// fourth time value (transition-delay) or an extra smuggled segment
|
|
@@ -74,14 +74,13 @@
|
|
|
74
74
|
// (capped) height and pins it as px on every content change — the
|
|
75
75
|
// transition below animates old→new px. Both pinned values are
|
|
76
76
|
// explicit px, so this needs no interpolate-size keyword support.
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
77
|
+
// Height is the only leg the desktop rest state ever animates: the
|
|
78
|
+
// clip-path reveal morph belongs to the ≤767px sheet block (which
|
|
79
|
+
// replaces this list wholesale), and on desktop nothing — hikari or
|
|
80
|
+
// host — changes the frame's max-height after mount (the 70vh cap is
|
|
81
|
+
// the body's, a different element; the phone caps move by design and
|
|
82
|
+
// deliberately carry no leg — see the IME cap-chase note below).
|
|
83
|
+
transition: height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
|
|
85
84
|
background: var(--hk-modal-bg, var(--hi-color-surface, rgba(255,255,255,0.95)));
|
|
86
85
|
@include backdrop-filter(var(--hk-modal-blur, none));
|
|
87
86
|
border: 1px solid var(--hk-modal-border-color, var(--hi-color-border, rgba(100,100,100,0.08)));
|
|
@@ -483,7 +482,7 @@
|
|
|
483
482
|
// direction change. Growth keeps its animated reveal via the
|
|
484
483
|
// clip-path sweep (the only transition left on phones).
|
|
485
484
|
//
|
|
486
|
-
// Desktop keeps the
|
|
485
|
+
// Desktop keeps the base list (height only — the size morph's leg).
|
|
487
486
|
// 0.3s = the stepflow crossfade's family standard (2026-09-21 user
|
|
488
487
|
// spec, round 7): the clip sweep, the riding body and the fade must
|
|
489
488
|
// land together. Keyboard-driven height changes never animate
|
|
@@ -114,16 +114,21 @@ describe("HkModal mobile sheet spacing contract", () => {
|
|
|
114
114
|
expect(tr).not.toMatch(/\bmax-height\b/);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
it("keeps the desktop frame transition
|
|
118
|
-
// The guard is phone-only by design: the
|
|
119
|
-
//
|
|
117
|
+
it("keeps the desktop frame transition to the height leg only (base rule)", () => {
|
|
118
|
+
// The guard is phone-only by design: outside the media block the
|
|
119
|
+
// base rule animates height only — the size morph's leg. The
|
|
120
|
+
// clip-path reveal lives in the ≤767px block (which replaces the
|
|
121
|
+
// list wholesale), and nothing changes the frame's max-height
|
|
122
|
+
// after mount (the 70vh cap is the body's, a different element):
|
|
123
|
+
// the 2026-11-07 residue sweep retired the never-animated
|
|
124
|
+
// max-height/clip-path legs, and this pin keeps them retired.
|
|
120
125
|
const base = src.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
|
|
121
126
|
expect(base.length, "base .hk-modal-content rule extracted").toBeGreaterThan(0);
|
|
122
127
|
const tr = base.match(/transition:\s*([^;]+);/)?.[1] ?? "";
|
|
123
128
|
expect(tr.length, "base transition declaration present").toBeGreaterThan(0);
|
|
124
129
|
expect(tr).toMatch(/\bheight\b/);
|
|
125
|
-
expect(tr).toMatch(/\bmax-height\b/);
|
|
126
|
-
expect(tr).toMatch(/\bclip-path\b/);
|
|
130
|
+
expect(tr).not.toMatch(/\bmax-height\b/);
|
|
131
|
+
expect(tr).not.toMatch(/\bclip-path\b/);
|
|
127
132
|
});
|
|
128
133
|
|
|
129
134
|
it("lifts the 70vh body cap on phones so the footer sits on the bottom edge", () => {
|
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* - growth morphs REVEAL through clip-path (--hk-sheet-morph: clip on
|
|
12
12
|
* the docking surfaces, useSizeMorph does the pin-instantly +
|
|
13
13
|
* sweep-up dance) — paint/compositor-level, zero per-frame layout;
|
|
14
|
-
* the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* the ≤767px sheet block replaces the frame's transition list
|
|
15
|
+
* wholesale and carries the clip-path transition retimed to the
|
|
16
|
+
* stepflow family standard, so the stretch look and its
|
|
17
|
+
* reduced-motion / data-css-animations governance are intact (the
|
|
18
|
+
* desktop base list animates the size morph's height leg only)
|
|
17
19
|
* - docked phone surfaces NEVER backdrop-filter (mobile-guard tokens
|
|
18
20
|
* defaulting to none: --hk-modal-blur-mobile, --hk-modal-overlay-
|
|
19
21
|
* blur-mobile, --hk-drawer-blur-mobile, --hk-drawer-overlay-blur-
|
|
@@ -54,12 +56,17 @@ describe("sheet family morph-performance contract", () => {
|
|
|
54
56
|
expect(mobileModal).toMatch(/^[ \t]*--hk-sheet-morph:[ \t]*clip;$/m);
|
|
55
57
|
});
|
|
56
58
|
|
|
57
|
-
it("
|
|
58
|
-
//
|
|
59
|
-
//
|
|
59
|
+
it("keeps the clip sweep on the sheet block, out of the desktop base list", () => {
|
|
60
|
+
// 2026-11-07 residue sweep: the desktop rest state never sweeps a
|
|
61
|
+
// clip-path (no clip-mode surface exists ≥768px), so the base list
|
|
62
|
+
// carries the size morph's height leg only. The reveal grammar lives
|
|
63
|
+
// in the ≤767px block, which replaces the frame's transition list
|
|
64
|
+
// wholesale and retimes the sweep to the stepflow family standard
|
|
65
|
+
// (pinned by the next test).
|
|
60
66
|
const frame = modal.match(/\.hk-modal-content\s*{[^}]*}/)?.[0] ?? "";
|
|
67
|
+
expect(frame).not.toMatch(/^[ \t]*clip-path var\(--duration-fast/m);
|
|
61
68
|
expect(frame).toMatch(
|
|
62
|
-
/^[ \t]*
|
|
69
|
+
/^[ \t]*transition: height var\(--duration-fast, 0\.15s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
|
|
63
70
|
);
|
|
64
71
|
});
|
|
65
72
|
|