@celestia-island/hikari 0.55.314 → 0.55.315
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 +56 -1
- package/src/components/HkListTransition.tsx +123 -5
- 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/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) && !/\btest\.[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,58 @@
|
|
|
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
|
+
.hk-list-surface-leave-active button {
|
|
184
|
+
pointer-events: none;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.hk-list-surface-enter-from,
|
|
188
|
+
.hk-list-surface-leave-to {
|
|
189
|
+
opacity: 0;
|
|
190
|
+
transform: translateY(10px) scale(0.97);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.hk-list-surface-move {
|
|
194
|
+
/* opacity rides along on purpose: if a multi-row batch ever classifies
|
|
195
|
+
a leaving ghost as moved (its FLIP delta measured before the
|
|
196
|
+
correction pass lands), the stamped move transition would REPLACE
|
|
197
|
+
the leave's transition shorthand — a transform-only move cancels the
|
|
198
|
+
fade mid-flight and snaps the ghost invisible. Carrying the leave's
|
|
199
|
+
own opacity curve keeps the fade alive under that stamp; for true
|
|
200
|
+
moved rows the property goes 1→1 and is inert. */
|
|
201
|
+
transition:
|
|
202
|
+
opacity var(--duration-short, 0.15s) cubic-bezier(0.45, 0, 0.55, 1),
|
|
203
|
+
transform var(--duration-normal, 0.3s) cubic-bezier(0.33, 1, 0.68, 1);
|
|
204
|
+
}
|
|
205
|
+
|
|
154
206
|
.hk-list-none-enter-active,
|
|
155
207
|
.hk-list-none-leave-active,
|
|
156
208
|
.hk-list-none-move {
|
|
@@ -171,11 +223,14 @@
|
|
|
171
223
|
.hk-list-grow-leave-active,
|
|
172
224
|
.hk-list-reveal-enter-active,
|
|
173
225
|
.hk-list-reveal-leave-active,
|
|
226
|
+
.hk-list-surface-enter-active,
|
|
227
|
+
.hk-list-surface-leave-active,
|
|
174
228
|
.hk-list-pop-move,
|
|
175
229
|
.hk-list-slide-move,
|
|
176
230
|
.hk-list-fade-move,
|
|
177
231
|
.hk-list-grow-move,
|
|
178
|
-
.hk-list-reveal-move
|
|
232
|
+
.hk-list-reveal-move,
|
|
233
|
+
.hk-list-surface-move {
|
|
179
234
|
transition: none !important;
|
|
180
235
|
}
|
|
181
236
|
}
|
|
@@ -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 () => {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled-CSS contract for the strip's MOTION — run against the COMPILED
|
|
3
|
+
* stylesheets, not the source text (the behavioural suite runs in
|
|
4
|
+
* happy-dom, which resolves no theme custom properties, so a wrong
|
|
5
|
+
* literal there paints green and breaks only in a real theme).
|
|
6
|
+
*
|
|
7
|
+
* User direction 2026-11-07: window crumbs must animate the way the
|
|
8
|
+
* windows themselves do (the B3 whole-window choreography — rise 10px,
|
|
9
|
+
* settle from 97% scale, fade; leave is the true reverse). Three layers
|
|
10
|
+
* carry that signature and this file pins all three:
|
|
11
|
+
*
|
|
12
|
+
* 1. the strip's own surface entrance/exit (hk-modal-breadcrumb-enter/leave-*)
|
|
13
|
+
* 2. each crumb's enter/leave via the list container (hk-list-surface-*)
|
|
14
|
+
* 3. the shared pose constants with HkModal itself
|
|
15
|
+
*
|
|
16
|
+
* Compositor-only is the law here (the modal height-ride lesson): every
|
|
17
|
+
* animated rule below touches ONLY opacity/transform. A layout property
|
|
18
|
+
* (top/left/width/height) in any of them re-rasters the backdrop-filtered
|
|
19
|
+
* strip per frame — the exact jank class that retired the height ride.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, expect, it } from "vitest";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { compile } from "sass";
|
|
25
|
+
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const compiled = (sheet: string): string =>
|
|
28
|
+
compile(join(here, sheet), {
|
|
29
|
+
loadPaths: [here, join(here, "..", "styles")],
|
|
30
|
+
style: "expanded",
|
|
31
|
+
}).css;
|
|
32
|
+
|
|
33
|
+
const breadcrumb = compiled("HkModalBreadcrumb.scss");
|
|
34
|
+
const list = compiled("HkListTransition.scss");
|
|
35
|
+
const modal = compiled("HkModal.scss");
|
|
36
|
+
|
|
37
|
+
/** Brace-matched body of the first top-level block whose head starts with
|
|
38
|
+
* the given prefix (`@media …` or a selector). */
|
|
39
|
+
function topBlock(cssText: string, headPrefix: string): string {
|
|
40
|
+
const start = cssText.indexOf(headPrefix);
|
|
41
|
+
if (start === -1) return "";
|
|
42
|
+
const open = cssText.indexOf("{", start);
|
|
43
|
+
let depth = 0;
|
|
44
|
+
for (let i = open; i < cssText.length; i++) {
|
|
45
|
+
if (cssText[i] === "{") depth += 1;
|
|
46
|
+
else if (cssText[i] === "}") {
|
|
47
|
+
depth -= 1;
|
|
48
|
+
if (depth === 0) return cssText.slice(open + 1, i);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** All `selector { body }` frames anywhere inside a block (one nesting
|
|
55
|
+
* level deep is enough for these sheets). A selector part that still
|
|
56
|
+
* carries the preceding comment's tail (ellipsis + comment close before
|
|
57
|
+
* the selector) is trimmed to the bare selector — sass expanded output
|
|
58
|
+
* keeps comments, and their commas would otherwise glue the comment
|
|
59
|
+
* onto the selector string. */
|
|
60
|
+
function frames(block: string): { selectors: string[]; body: string }[] {
|
|
61
|
+
const out: { selectors: string[]; body: string }[] = [];
|
|
62
|
+
const re = /([^{}]+)\{([^{}]*)\}/g;
|
|
63
|
+
let m: RegExpExecArray | null;
|
|
64
|
+
while ((m = re.exec(block))) {
|
|
65
|
+
out.push({
|
|
66
|
+
selectors: m[1]
|
|
67
|
+
.split(",")
|
|
68
|
+
.map((s) => {
|
|
69
|
+
let t = s;
|
|
70
|
+
const comment = t.lastIndexOf("*/");
|
|
71
|
+
if (comment !== -1) t = t.slice(comment + 2);
|
|
72
|
+
const semi = t.lastIndexOf(";");
|
|
73
|
+
if (semi !== -1) t = t.slice(semi + 1);
|
|
74
|
+
return t.trim();
|
|
75
|
+
})
|
|
76
|
+
.filter((s) => s.startsWith(".") || s.startsWith("[") || s.includes("@")),
|
|
77
|
+
body: m[2].trim(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The rule whose selector LIST contains exactly the given selector. */
|
|
84
|
+
function rule(sheet: string, selector: string): string {
|
|
85
|
+
const all = frames(sheet);
|
|
86
|
+
const hit = all.find((f) => f.selectors.includes(selector));
|
|
87
|
+
expect(hit, `rule ${selector} must exist`).toBeTruthy();
|
|
88
|
+
return hit!.body;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Rule scoped to the body of a media block. */
|
|
92
|
+
function ruleIn(block: string, selector: string): string {
|
|
93
|
+
const hit = frames(block).find((f) => f.selectors.includes(selector));
|
|
94
|
+
expect(hit, `rule ${selector} must exist in the block`).toBeTruthy();
|
|
95
|
+
return hit!.body;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const reducedBreadcrumb = topBlock(breadcrumb, "@media (prefers-reduced-motion: reduce)");
|
|
99
|
+
const reducedList = topBlock(list, "@media (prefers-reduced-motion: reduce)");
|
|
100
|
+
|
|
101
|
+
const MODAL_POSE = "translateY(10px) scale(0.97)";
|
|
102
|
+
const EASE_OUT = "cubic-bezier(0.33, 1, 0.68, 1)";
|
|
103
|
+
const EASE_IN = "cubic-bezier(0.45, 0, 0.55, 1)";
|
|
104
|
+
|
|
105
|
+
describe("strip surface enter/leave (window choreography)", () => {
|
|
106
|
+
it("rides opacity + transform only — never a layout property", () => {
|
|
107
|
+
for (const sel of [
|
|
108
|
+
".hk-modal-breadcrumb-enter-active",
|
|
109
|
+
".hk-modal-breadcrumb-leave-active",
|
|
110
|
+
".hk-modal-breadcrumb-enter-from",
|
|
111
|
+
".hk-modal-breadcrumb-leave-to",
|
|
112
|
+
]) {
|
|
113
|
+
const block = rule(breadcrumb, sel);
|
|
114
|
+
expect(block).not.toMatch(/\b(top|left|width|height|margin|padding)\s*:/);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("arrives with the window's rise + settle + fade", () => {
|
|
119
|
+
const enter = rule(breadcrumb, ".hk-modal-breadcrumb-enter-active");
|
|
120
|
+
expect(enter).toContain(`opacity var(--duration-normal, 0.3s) ${EASE_OUT}`);
|
|
121
|
+
expect(enter).toContain(`transform var(--duration-normal, 0.3s) ${EASE_OUT}`);
|
|
122
|
+
const from = rule(breadcrumb, ".hk-modal-breadcrumb-enter-from");
|
|
123
|
+
expect(from).toContain("opacity: 0");
|
|
124
|
+
expect(from).toContain("translate(-50%, calc(-50% - 10px)) scale(0.97)");
|
|
125
|
+
const to = rule(breadcrumb, ".hk-modal-breadcrumb-enter-to");
|
|
126
|
+
expect(to).toContain("opacity: 1");
|
|
127
|
+
expect(to).toContain("translate(-50%, -50%) scale(1)");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("leaves as the true reverse, quicker than the windows it names", () => {
|
|
131
|
+
const leave = rule(breadcrumb, ".hk-modal-breadcrumb-leave-active");
|
|
132
|
+
expect(leave).toContain(`opacity var(--duration-short, 0.15s) ${EASE_IN}`);
|
|
133
|
+
expect(leave).toContain(`transform var(--duration-short, 0.15s) ${EASE_IN}`);
|
|
134
|
+
const to = rule(breadcrumb, ".hk-modal-breadcrumb-leave-to");
|
|
135
|
+
expect(to).toContain("opacity: 0");
|
|
136
|
+
expect(to).toContain("translate(-50%, calc(-50% - 10px)) scale(0.97)");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("keeps the centering prefix in every animated state", () => {
|
|
140
|
+
// The resting rule's transform is byte-identical to the enter-to /
|
|
141
|
+
// leave-from state — a re-seated mid-flight frame would drift the
|
|
142
|
+
// strip off its measured `top` line (the modal frame keeps the same
|
|
143
|
+
// contract for its dragged-shift vars).
|
|
144
|
+
const resting = rule(breadcrumb, ".hk-modal-breadcrumb")
|
|
145
|
+
.match(/transform:\s*([^;]+);/)?.[1]
|
|
146
|
+
?.trim();
|
|
147
|
+
expect(resting).toBe("translate(-50%, -50%)");
|
|
148
|
+
expect(rule(breadcrumb, ".hk-modal-breadcrumb-enter-to")).toContain(
|
|
149
|
+
`transform: ${resting} scale(1)`,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("has no keyframe animation on the base class (no double entrance)", () => {
|
|
154
|
+
// The Transition enter classes replaced the old mount keyframe; a
|
|
155
|
+
// reintroduced base `animation:` would stack on top of them. A regex
|
|
156
|
+
// literal on purpose: a plain string would read as a `@keyframes`
|
|
157
|
+
// DECLARATION to naive scanners of the source tree (see
|
|
158
|
+
// registerAnimations.test.ts's parity) — the `\s` escape keeps this
|
|
159
|
+
// assertion from being mistaken for one.
|
|
160
|
+
expect(rule(breadcrumb, ".hk-modal-breadcrumb")).not.toMatch(/animation\s*:/);
|
|
161
|
+
expect(breadcrumb).not.toMatch(/@keyframes\s+hk-modal-breadcrumb-in/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("drops the motion under prefers-reduced-motion", () => {
|
|
165
|
+
expect(reducedBreadcrumb).toContain(".hk-modal-breadcrumb-enter-active");
|
|
166
|
+
expect(reducedBreadcrumb).toContain(".hk-modal-breadcrumb-leave-active");
|
|
167
|
+
expect(ruleIn(reducedBreadcrumb, ".hk-modal-breadcrumb-enter-active")).toContain(
|
|
168
|
+
"transition: none",
|
|
169
|
+
);
|
|
170
|
+
expect(ruleIn(reducedBreadcrumb, ".hk-modal-breadcrumb-leave-active")).toContain(
|
|
171
|
+
"transition: none",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("crumb list surface variant (HkListTransition)", () => {
|
|
177
|
+
it("animates each crumb with the same window pose", () => {
|
|
178
|
+
const from = rule(list, ".hk-list-surface-enter-from");
|
|
179
|
+
expect(from).toContain("opacity: 0");
|
|
180
|
+
expect(from).toContain(`transform: ${MODAL_POSE}`);
|
|
181
|
+
const leaveTo = rule(list, ".hk-list-surface-leave-to");
|
|
182
|
+
expect(leaveTo).toContain("opacity: 0");
|
|
183
|
+
expect(leaveTo).toContain(`transform: ${MODAL_POSE}`);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("uses the window family's curves at crumb pace", () => {
|
|
187
|
+
const enter = rule(list, ".hk-list-surface-enter-active");
|
|
188
|
+
expect(enter).toContain(`opacity var(--duration-normal, 0.3s) ${EASE_OUT}`);
|
|
189
|
+
expect(enter).toContain(`transform var(--duration-normal, 0.3s) ${EASE_OUT}`);
|
|
190
|
+
const leave = rule(list, ".hk-list-surface-leave-active");
|
|
191
|
+
expect(leave).toContain(`opacity var(--duration-short, 0.15s) ${EASE_IN}`);
|
|
192
|
+
expect(leave).toContain(`transform var(--duration-short, 0.15s) ${EASE_IN}`);
|
|
193
|
+
// The leaving crumb is lifted out of flow so the space collapses and
|
|
194
|
+
// the siblings glide (FLIP move) into it.
|
|
195
|
+
expect(leave).toContain("position: absolute");
|
|
196
|
+
// A sinking ghost takes no clicks — neither the row nor the buttons
|
|
197
|
+
// inside it, whose own pointer-events:auto would otherwise beat
|
|
198
|
+
// inheritance (the strip re-enables only its buttons).
|
|
199
|
+
expect(leave).toContain("pointer-events: none");
|
|
200
|
+
expect(rule(list, ".hk-list-surface-leave-active button")).toContain(
|
|
201
|
+
"pointer-events: none",
|
|
202
|
+
);
|
|
203
|
+
const move = rule(list, ".hk-list-surface-move");
|
|
204
|
+
expect(move).toContain("transform");
|
|
205
|
+
expect(move).toContain(EASE_OUT);
|
|
206
|
+
// Insurance: if a mis-classified ghost is ever stamped with the move
|
|
207
|
+
// class, its shorthand must not REPLACE the leave's opacity
|
|
208
|
+
// transition (a transform-only move would cancel the fade).
|
|
209
|
+
expect(move).toContain("opacity");
|
|
210
|
+
expect(move).toContain(EASE_IN);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("shares the pose constants with HkModal itself", () => {
|
|
214
|
+
// Single source of truth by equality: retune the window's pose in the
|
|
215
|
+
// modal sheet and this fails until the crumbs follow (or the reviewer
|
|
216
|
+
// rules the divergence in — deliberately loud either way).
|
|
217
|
+
expect(rule(modal, ".hk-modal-content-enter-from")).toContain(MODAL_POSE);
|
|
218
|
+
expect(list).toContain(MODAL_POSE);
|
|
219
|
+
expect(list).toContain(EASE_OUT);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("drops the motion under prefers-reduced-motion", () => {
|
|
223
|
+
expect(reducedList).toContain(".hk-list-surface-enter-active");
|
|
224
|
+
expect(reducedList).toContain(".hk-list-surface-leave-active");
|
|
225
|
+
expect(reducedList).toContain(".hk-list-surface-move");
|
|
226
|
+
// The shared guard rule's BODY matters, not just the selector list —
|
|
227
|
+
// pin it (mirrors the strip-side ruleIn assertions above).
|
|
228
|
+
expect(ruleIn(reducedList, ".hk-list-surface-enter-active")).toContain(
|
|
229
|
+
"transition: none !important",
|
|
230
|
+
);
|
|
231
|
+
expect(ruleIn(reducedList, ".hk-list-surface-leave-active")).toContain(
|
|
232
|
+
"transition: none !important",
|
|
233
|
+
);
|
|
234
|
+
expect(ruleIn(reducedList, ".hk-list-surface-move")).toContain("transition: none !important");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("crumb list host (HkModalBreadcrumb → HkListTransition)", () => {
|
|
239
|
+
it("renders the group as display:contents — the nav stays the sole layout owner", () => {
|
|
240
|
+
expect(rule(breadcrumb, ".hk-modal-breadcrumb-levels")).toContain("display: contents");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
@include backdrop-filter(var(--hk-modal-breadcrumb-blur, blur(var(--blur-md))));
|
|
29
29
|
pointer-events: none;
|
|
30
30
|
@include user-select(none);
|
|
31
|
-
animation: hk-modal-breadcrumb-in var(--duration-fast, 0.15s) cubic-bezier(0.16, 1, 0.3, 1);
|
|
32
31
|
/* Never wider than the viewport (minus the shared gutter): the strip is
|
|
33
32
|
centred on the app header, so a stack whose labels outgrow the screen
|
|
34
33
|
used to spill past BOTH edges at once and read as a clipped bar with
|
|
@@ -40,6 +39,62 @@
|
|
|
40
39
|
overflow: hidden;
|
|
41
40
|
}
|
|
42
41
|
|
|
42
|
+
/* ── Whole-surface enter/leave (window choreography) ─────────────────
|
|
43
|
+
The strip IS a window artefact — it materializes when a second window
|
|
44
|
+
opens and dissolves when the last-but-one closes — so its own entrance
|
|
45
|
+
and exit ride the same motion signature as the windows themselves
|
|
46
|
+
(B3, user direction 2026-11-07): rise 10px while settling from 97%
|
|
47
|
+
scale and fading in; leave is the true reverse. A compositor-only pair
|
|
48
|
+
(opacity + transform only; nothing layout ever animates — the
|
|
49
|
+
backdrop-filtered layer never re-rasters mid-motion).
|
|
50
|
+
|
|
51
|
+
The centering translate is the PERMANENT transform prefix in every
|
|
52
|
+
state (byte-identical between resting and animated), exactly the
|
|
53
|
+
contract the modal frame keeps for its dragged-shift vars.
|
|
54
|
+
|
|
55
|
+
Vue drives these classes (<Transition name="hk-modal-breadcrumb"
|
|
56
|
+
appear> in the tsx); there is no keyframe animation on the base class —
|
|
57
|
+
a mount replay would stack on top of the enter transition. */
|
|
58
|
+
.hk-modal-breadcrumb-enter-active {
|
|
59
|
+
transition:
|
|
60
|
+
opacity var(--duration-normal, 0.3s) cubic-bezier(0.33, 1, 0.68, 1),
|
|
61
|
+
transform var(--duration-normal, 0.3s) cubic-bezier(0.33, 1, 0.68, 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Chrome departs quicker than the windows it names (the modal's own
|
|
65
|
+
leave runs 0.32s): the strip has nothing left to say once its last
|
|
66
|
+
level is gone. */
|
|
67
|
+
.hk-modal-breadcrumb-leave-active {
|
|
68
|
+
transition:
|
|
69
|
+
opacity var(--duration-short, 0.15s) cubic-bezier(0.45, 0, 0.55, 1),
|
|
70
|
+
transform var(--duration-short, 0.15s) cubic-bezier(0.45, 0, 0.55, 1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.hk-modal-breadcrumb-enter-from,
|
|
74
|
+
.hk-modal-breadcrumb-leave-to {
|
|
75
|
+
opacity: 0;
|
|
76
|
+
transform: translate(-50%, calc(-50% - 10px)) scale(0.97);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.hk-modal-breadcrumb-enter-to,
|
|
80
|
+
.hk-modal-breadcrumb-leave-from {
|
|
81
|
+
opacity: 1;
|
|
82
|
+
transform: translate(-50%, -50%) scale(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* ── The keyed crumb list ────────────────────────────────────────────
|
|
86
|
+
The trigger and the visible tail ride HkListTransition (surface
|
|
87
|
+
variant — the window choreography per crumb). The group renders a real
|
|
88
|
+
wrapper span, and it is `display: contents`: the strip's own flex row
|
|
89
|
+
stays the SOLE layout owner (gap, alignment, the fence's clipping) and
|
|
90
|
+
the wrapper generates no box of its own, while still giving the
|
|
91
|
+
TransitionGroup a DOM host whose children can be measured pre-patch
|
|
92
|
+
(the multi-removal geometry pins need it) and absolutely positioned
|
|
93
|
+
against the strip when a crumb leaves. */
|
|
94
|
+
.hk-modal-breadcrumb-levels {
|
|
95
|
+
display: contents;
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
.hk-modal-breadcrumb-crumb {
|
|
44
99
|
display: inline-flex;
|
|
45
100
|
align-items: center;
|
|
@@ -239,22 +294,15 @@
|
|
|
239
294
|
transform: scaleX(-1);
|
|
240
295
|
}
|
|
241
296
|
|
|
242
|
-
@keyframes hk-modal-breadcrumb-in {
|
|
243
|
-
from {
|
|
244
|
-
opacity: 0;
|
|
245
|
-
transform: translate(-50%, calc(-50% - 8px));
|
|
246
|
-
}
|
|
247
|
-
to {
|
|
248
|
-
opacity: 1;
|
|
249
|
-
transform: translate(-50%, -50%);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
297
|
// House reduced-motion guard (HkExpansionPanel / HkTooltip pattern): the
|
|
254
|
-
// entrance
|
|
298
|
+
// entrance and exit are transform rides — drop both, keep the strip's
|
|
299
|
+
// state changes instant (Vue's transition classes with no transition
|
|
300
|
+
// resolve to an immediate swap; the crumb group's guard lives in
|
|
301
|
+
// HkListTransition.scss).
|
|
255
302
|
@media (prefers-reduced-motion: reduce) {
|
|
256
|
-
.hk-modal-breadcrumb
|
|
257
|
-
|
|
303
|
+
.hk-modal-breadcrumb-enter-active,
|
|
304
|
+
.hk-modal-breadcrumb-leave-active {
|
|
305
|
+
transition: none;
|
|
258
306
|
}
|
|
259
307
|
}
|
|
260
308
|
|