@celestia-island/hikari 0.55.315 → 0.55.318

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.315",
3
+ "version": "0.55.318",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -26,7 +26,7 @@ function collectSourceFiles(dir: string): string[] {
26
26
  if (entry.isDirectory()) {
27
27
  if (p === path.resolve(srcDir, "animation")) continue;
28
28
  out.push(...collectSourceFiles(p));
29
- } else if (/\.(scss|ts|tsx)$/.test(entry.name) && !/\btest\.[tj]sx?$/.test(entry.name)) {
29
+ } else if (/\.(scss|ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.[tj]sx?$/.test(entry.name)) {
30
30
  out.push(p);
31
31
  }
32
32
  }
@@ -179,7 +179,13 @@
179
179
  the strip itself is pointer-transparent), and inheritance does not
180
180
  beat an explicit auto. The ghost must not take clicks while it sinks:
181
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. */
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. */
183
189
  .hk-list-surface-leave-active button {
184
190
  pointer-events: none;
185
191
  }
@@ -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
- stubPhoneViewport();
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
- stubPhoneViewport();
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
- stubPhoneViewport();
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
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Compiled-CSS contract for the modal BLUR PLATE — the open-stutter fix.
3
+ *
4
+ * Measured root cause (2026-11-07 open-jank spike, headless rAF matrix):
5
+ * a backdrop-filter re-filters its full-viewport backdrop on EVERY
6
+ * composited frame while any animation is live anywhere — the enter
7
+ * storm (frame + dim + strip + crumbs mounting together) ran at
8
+ * 26-55% dropped frames with live plates, while the steady state
9
+ * (nothing animating) is cached and runs 60fps. The architecture:
10
+ *
11
+ * 1. the blur lives on a STATIC plate that never animates and never
12
+ * carries a transition;
13
+ * 2. the eye-tracked transition is the solid-color dim curtain
14
+ * (opacity-only composite);
15
+ * 3. the plate is REMOVED on stack-covered windows (a present
16
+ * backdrop-filter element still forces the per-frame backdrop
17
+ * surface even computed to none — measured ~44pp worse than
18
+ * hidden);
19
+ * 4. the overlay carries the default blur only for plate-less markup
20
+ * (the Rust-rendered modals share this compiled sheet without a
21
+ * plate element) and opts down to dim-only document-globally when
22
+ * a Vue plate exists.
23
+ *
24
+ * Regression risk this file pins: a future edit re-adding a
25
+ * backdrop-filter to `.hk-modal-overlay` would double-filter the Vue
26
+ * path and reintroduce the jank; re-adding an enter/leave-scoped
27
+ * suppression leg would reintroduce the settle hitch; relaxing the
28
+ * covered drop would reintroduce the stacked-blur storm.
29
+ */
30
+ import { describe, expect, it } from "vitest";
31
+ import { dirname, join } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+ import { compile } from "sass";
34
+
35
+ const here = dirname(fileURLToPath(import.meta.url));
36
+ const css = compile(join(here, "HkModal.scss"), {
37
+ loadPaths: [here, join(here, "..", "styles")],
38
+ style: "expanded",
39
+ }).css;
40
+
41
+ /** Brace-matched body of the first top-level block whose head starts
42
+ * with the given prefix (`@media …` or a selector). */
43
+ function topBlock(cssText: string, headPrefix: string): string {
44
+ const start = cssText.indexOf(headPrefix);
45
+ if (start === -1) return "";
46
+ const open = cssText.indexOf("{", start);
47
+ let depth = 0;
48
+ for (let i = open; i < cssText.length; i++) {
49
+ if (cssText[i] === "{") depth += 1;
50
+ else if (cssText[i] === "}") {
51
+ depth -= 1;
52
+ if (depth === 0) return cssText.slice(open + 1, i);
53
+ }
54
+ }
55
+ return "";
56
+ }
57
+
58
+ /** All `selector { body }` frames inside a block (one nesting level deep
59
+ * is enough). Selector parts carry preceding block-glue in expanded
60
+ * output (media heads, comment remains) — trimmed to the bare
61
+ * selector. */
62
+ function frames(block: string): { selectors: string[]; body: string }[] {
63
+ const out: { selectors: string[]; body: string }[] = [];
64
+ const re = /([^{}]+)\{([^{}]*)\}/g;
65
+ let m: RegExpExecArray | null;
66
+ while ((m = re.exec(block))) {
67
+ out.push({
68
+ selectors: m[1]
69
+ .split(",")
70
+ .map((part) => {
71
+ let t = part;
72
+ const comment = t.lastIndexOf("*/");
73
+ if (comment !== -1) t = t.slice(comment + 2);
74
+ const brace = t.lastIndexOf("{");
75
+ if (brace !== -1) t = t.slice(brace + 1);
76
+ const semi = t.lastIndexOf(";");
77
+ if (semi !== -1) t = t.slice(semi + 1);
78
+ return t.trim();
79
+ })
80
+ .filter((s) => s.startsWith(".") || s.startsWith("[") || s.startsWith(":")),
81
+ body: m[2].trim(),
82
+ });
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** Bodies of every rule whose selector LIST includes exactly `selector`. */
88
+ function ruleBodies(sheet: string, selector: string): string[] {
89
+ return frames(sheet)
90
+ .filter((f) => f.selectors.includes(selector))
91
+ .map((f) => f.body);
92
+ }
93
+ const oneRule = (sheet: string, selector: string): string => {
94
+ const bodies = ruleBodies(sheet, selector);
95
+ expect(bodies.length, `rule ${selector} must exist`).toBeGreaterThan(0);
96
+ return bodies[0];
97
+ };
98
+
99
+ const mobileBlock = topBlock(css, "@media (max-width: 767px)");
100
+
101
+ describe("modal blur plate (static filter, stack-covered removal)", () => {
102
+ it("carries the blur; the fading overlay must NOT (vue path)", () => {
103
+ // The core invariant: the overlay is the ANIMATED layer — a
104
+ // backdrop-filter on it re-filters the whole viewport per frame
105
+ // (the reported stutter). The blur lives on the never-animated
106
+ // plate only.
107
+ const plate = oneRule(css, ".hk-modal-blur-plate");
108
+ expect(plate).toMatch(/backdrop-filter:\s*var\(--hk-modal-overlay-blur/);
109
+ expect(plate).toContain("pointer-events: none");
110
+ expect(plate).toContain("z-index: -1");
111
+ });
112
+
113
+ it("gives plate-less markup (rust-rendered modals) the overlay blur", () => {
114
+ // The Rust-rendered modals share this compiled sheet but their
115
+ // markup has no plate element: the overlay keeps the default blur
116
+ // there.
117
+ const overlayDefault = ruleBodies(css, ".hk-modal-overlay").find((b) =>
118
+ b.includes("backdrop-filter: var(--hk-modal-overlay-blur"),
119
+ );
120
+ expect(overlayDefault, "default overlay blur must exist").toBeTruthy();
121
+ });
122
+
123
+ it("opts the overlay down to dim-only wherever a plate exists", () => {
124
+ // :root:has(plate) is document-global: one Vue modal anywhere means
125
+ // every overlay in the document is plate-backed and dim-only.
126
+ expect(css).toMatch(
127
+ /:root:has\(\.hk-modal-blur-plate\)\s*\.hk-modal-overlay\s*\{[^}]*backdrop-filter:\s*none/,
128
+ );
129
+ });
130
+
131
+ it("removes the plate on stack-covered windows", () => {
132
+ // A present backdrop-filter element still forces the per-frame
133
+ // backdrop surface even computed to none (~44pp worse than hidden).
134
+ const covered = ruleBodies(css, ".hk-modal-root[data-stack-covered] .hk-modal-blur-plate");
135
+ expect(covered.length).toBeGreaterThanOrEqual(1);
136
+ for (const body of covered) expect(body).toContain("display: none");
137
+ });
138
+
139
+ it("keeps no enter/leave-scoped suppression legs (the top plate animates with the filter)", () => {
140
+ // The top plate's own enter measured 2% dropped WITH the filter
141
+ // live (single live blur) — suppression legs were removed with the
142
+ // settle hitch they created. Re-adding any leg must be a deliberate
143
+ // re-trade.
144
+ expect(css).not.toMatch(
145
+ /hk-modal-content-(?:enter|leave)-(?:from|to|active) ~ \.hk-modal-blur-plate/,
146
+ );
147
+ });
148
+
149
+ it("keeps the mobile stand-down on both faces", () => {
150
+ expect(mobileBlock).toContain(".hk-modal-blur-plate");
151
+ expect(frames(mobileBlock).some((f) => f.selectors.includes(".hk-modal-blur-plate"))).toBe(true);
152
+ // The Vue-path opt-out must hold on phones too.
153
+ expect(mobileBlock).toMatch(
154
+ /:root:has\(\.hk-modal-blur-plate\)\s*\.hk-modal-overlay\s*\{[^}]*backdrop-filter:\s*none/,
155
+ );
156
+ });
157
+ });
@@ -23,6 +23,44 @@
23
23
  // Overlay (backdrop)
24
24
  // ------
25
25
 
26
+ // The BLUR is a separate static plate, NOT on the fading overlay: a
27
+ // backdrop-filter re-filters the whole viewport on every composited frame
28
+ // while ANY animation is live (the steady state is cached and clean —
29
+ // measured separately), and the enter storm (frame + dim + strip + crumbs
30
+ // mounting together) measured 36% dropped frames with the filter live vs
31
+ // 0% with it suppressed (2026-11-07 open-jank spike, headless rAF matrix;
32
+ // one modal, blur 3px). The plate holds the filter and never animates;
33
+ // the overlay below is a solid-color dim curtain whose opacity ramp is
34
+ // the cheapest possible layer to animate — and the ramp is what the eye
35
+ // tracks, so the blur cutting in with the first frame (instead of
36
+ // ramping over 0.42s) reads as focus pull, not as a missing layer.
37
+ .hk-modal-blur-plate {
38
+ position: fixed;
39
+ inset: 0;
40
+ // Below the overlay (0) and the content frame (1) inside the root's own
41
+ // stacking context — the plate is DOM-last but paints first.
42
+ z-index: -1;
43
+ @include backdrop-filter(var(--hk-modal-overlay-blur, blur(3px)));
44
+ // The root is pointer-events: none; the plate must never take clicks.
45
+ pointer-events: none;
46
+ }
47
+
48
+ // STACK-COVERED: a covered (non-topmost) window's plate is REMOVED
49
+ // outright (display:none, not merely filter:none — a present
50
+ // backdrop-filter element still forces the per-frame backdrop surface
51
+ // during any animation even with the filter computed to none, measured
52
+ // at ~44pp dropped frames vs 2% hidden; 2026-11-07 open-jank spike).
53
+ // Blur reads as a property of the TOP surface anyway: lower windows sit
54
+ // under the top window's own dim + blur, so their own filter is
55
+ // visually redundant (blur-of-blur). The dim curtains are untouched:
56
+ // depth still darkens, only the redundant blur legs drop. (The
57
+ // breadcrumb strip — z band 250 — paints ABOVE these roots: the strip
58
+ // band is not part of any plate's backdrop, which is why removing the
59
+ // covered plates, not the strip, was the fix that measured.)
60
+ .hk-modal-root[data-stack-covered] .hk-modal-blur-plate {
61
+ display: none;
62
+ }
63
+
26
64
  .hk-modal-overlay {
27
65
  position: fixed;
28
66
  inset: 0;
@@ -32,10 +70,20 @@
32
70
  // handle hasn't resolved yet — zIndex briefly starts at 0).
33
71
  z-index: 0;
34
72
  background: var(--hk-modal-overlay-bg, rgba(0, 0, 0, 0.45));
73
+ // The overlay itself carries the blur ONLY where no plate exists (the
74
+ // Rust-rendered modals share this compiled sheet but their markup has
75
+ // no plate element — dropping the property from the overlay would
76
+ // silently strip their desktop blur). The Vue path always renders a
77
+ // plate, so :root:has(...) opts the overlay down to dim-only there and
78
+ // the plate owns the blur (including its stack-covered removal).
35
79
  @include backdrop-filter(var(--hk-modal-overlay-blur, blur(3px)));
36
80
  pointer-events: auto;
37
81
  }
38
82
 
83
+ :root:has(.hk-modal-blur-plate) .hk-modal-overlay {
84
+ @include backdrop-filter(none);
85
+ }
86
+
39
87
  // Overlay enter/leave — fade-only classes emitted from the shared
40
88
  // window-layer contract (./_scrim-fade.scss): the curtain dims the whole
41
89
  // viewport in place, never sliding or scaling with the content frame.
@@ -74,14 +122,13 @@
74
122
  // (capped) height and pins it as px on every content change — the
75
123
  // transition below animates old→new px. Both pinned values are
76
124
  // explicit px, so this needs no interpolate-size keyword support.
77
- // clip-path rides the same token pair for the mobile sheet's reveal
78
- // morph (--hk-sheet-morph: clip below): the pin lands instantly and
79
- // the top edge sweeps up paint-only — no per-frame layout, no
80
- // per-frame backdrop re-raster over the resizing fixed layer.
81
- transition:
82
- height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
83
- max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
84
- clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
125
+ // Height is the only leg the desktop rest state ever animates: the
126
+ // clip-path reveal morph belongs to the ≤767px sheet block (which
127
+ // replaces this list wholesale), and on desktop nothing — hikari or
128
+ // host — changes the frame's max-height after mount (the 70vh cap is
129
+ // the body's, a different element; the phone caps move by design and
130
+ // deliberately carry no leg — see the IME cap-chase note below).
131
+ transition: height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
85
132
  background: var(--hk-modal-bg, var(--hi-color-surface, rgba(255,255,255,0.95)));
86
133
  @include backdrop-filter(var(--hk-modal-blur, none));
87
134
  border: 1px solid var(--hk-modal-border-color, var(--hi-color-border, rgba(100,100,100,0.08)));
@@ -154,6 +201,11 @@
154
201
  .hk-modal-content-enter-from,
155
202
  .hk-modal-content-leave-to {
156
203
  opacity: 0;
204
+ // Pre-promote at insert: enter-from lands in the same frame the
205
+ // content mounts — carrying will-change HERE (shared with the leave's
206
+ // end pose) means the layer is composited before the transition's
207
+ // first measured frame, not one hitch later.
208
+ will-change: opacity, transform;
157
209
  transform: translate(
158
210
  calc(-50% + var(--hk-modal-shift-x, 0px)),
159
211
  calc(-50% + var(--hk-modal-shift-y, 0px))
@@ -444,10 +496,18 @@
444
496
  // the page, so an opaque-ish sheet reads identically. Hosts restore
445
497
  // the finish per surface with --hk-modal-blur-mobile /
446
498
  // --hk-modal-bg-mobile.
499
+ .hk-modal-blur-plate {
500
+ @include backdrop-filter(var(--hk-modal-overlay-blur-mobile, none));
501
+ }
502
+
447
503
  .hk-modal-overlay {
448
504
  @include backdrop-filter(var(--hk-modal-overlay-blur-mobile, none));
449
505
  }
450
506
 
507
+ :root:has(.hk-modal-blur-plate) .hk-modal-overlay {
508
+ @include backdrop-filter(none);
509
+ }
510
+
451
511
  // Height-morph window (round 20): while the sheet's height animates
452
512
  // every frame, a backdrop-filter anywhere in the resizing subtree has
453
513
  // to re-render its backdrop surface every frame — the same per-frame
@@ -483,7 +543,7 @@
483
543
  // direction change. Growth keeps its animated reveal via the
484
544
  // clip-path sweep (the only transition left on phones).
485
545
  //
486
- // Desktop keeps the full base list (height + max-height + clip-path).
546
+ // Desktop keeps the base list (height only — the size morph's leg).
487
547
  // 0.3s = the stepflow crossfade's family standard (2026-09-21 user
488
548
  // spec, round 7): the clip sweep, the riding body and the fade must
489
549
  // land together. Keyboard-driven height changes never animate
@@ -566,6 +626,7 @@
566
626
  .hk-modal-content-leave-to {
567
627
  opacity: 0;
568
628
  height: auto;
629
+ will-change: opacity, transform;
569
630
  transform: translateY(100%);
570
631
  }
571
632
 
@@ -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 list intact (base rule)", () => {
118
- // The guard is phone-only by design: the base rule outside the
119
- // media block keeps height+max-height+clip-path animated.
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 frame stylesheet carries the clip-path transition on the SAME
15
- * duration/ease tokens as the height transition, so the stretch look
16
- * and its reduced-motion / data-css-animations governance are intact
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("carries a clip-path transition on the morph timing tokens", () => {
58
- // Same duration/ease pair as the height morph — the reveal must keep
59
- // the stretch look, not invent new timing.
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]*clip-path var\(--duration-fast, 0\.15s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
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
 
@@ -92,9 +99,14 @@ describe("sheet family morph-performance contract", () => {
92
99
  const panel = select.match(/\.hk-select-sheet-panel\s*{[\s\S]*?^}/m)?.[0] ?? "";
93
100
  expect(panel).toMatch(/^[ \t]*--hk-sheet-morph:[ \t]*clip;$/m);
94
101
  expect(panel).toMatch(/^[ \t]*contain:[ \t]*layout style;$/m);
102
+ // 2026-11-07 residue sweep: the panel rides the modal's ≤767px
103
+ // grammar — clip-path is the ONLY leg (clip mode pins instantly and
104
+ // folds transition-off, and a max-height leg on a dvh-capped sheet
105
+ // is the IME cap-chase the modal block fixed on 2026-09-21).
95
106
  expect(panel).toMatch(
96
- /^[ \t]*clip-path var\(--duration-fast, 0\.15s\) var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
107
+ /^[ \t]*transition: clip-path var\(--duration-fast, 0\.15s\)[\s]*var\(--ease-standard, cubic-bezier\(0\.4, 0, 0\.2, 1\)\);$/m,
97
108
  );
109
+ expect(panel).not.toMatch(/\bmax-height var\(--duration-fast/m);
98
110
  // Opaque already — the panel must not regress to a translucent
99
111
  // finish that re-composites against the streaming page.
100
112
  expect(panel).toMatch(/^[ \t]*background:[ \t]*rgb\(var\(--color-surface\)\);$/m);
@@ -815,3 +815,81 @@ describe("HkModal Escape precedence", () => {
815
815
  expect(document.activeElement, "focus wraps back to the first focusable").toBe(first);
816
816
  });
817
817
  });
818
+
819
+ describe("HkModal blur plate", () => {
820
+ it("renders the plate as the aria-hidden last child of the root", async () => {
821
+ // The plate is DOM-last so the sheet's stack-covered drop can be keyed
822
+ // by attribute scoping without depending on paint order (z-indexes
823
+ // carry that). Structural pin: last child + hidden from a11y.
824
+ const container = document.createElement("div");
825
+ document.body.appendChild(container);
826
+ containers.push(container);
827
+ const open = ref(true);
828
+ const Wrapper = defineComponent({
829
+ setup() {
830
+ return () =>
831
+ h(HkModal, { modelValue: open.value }, { default: () => h("div", "content") });
832
+ },
833
+ });
834
+ const app = createApp(Wrapper);
835
+ mounts.push(app);
836
+ app.mount(container);
837
+ await nextTick();
838
+ await nextTick();
839
+ const root = document.querySelector(".hk-modal-root")!;
840
+ const last = root.lastElementChild as HTMLElement | null;
841
+ expect(last?.classList.contains("hk-modal-blur-plate")).toBe(true);
842
+ expect(last?.getAttribute("aria-hidden")).toBe("true");
843
+ });
844
+
845
+ it("marks covered roots while a higher window is open", async () => {
846
+ // TOP-ONLY BLUR wiring: the covered (lower) root carries
847
+ // data-stack-covered; the top root does not. Flips live with the
848
+ // registry: closing the top window un-covers the lower one.
849
+ const mounts2: ReturnType<typeof createApp>[] = [];
850
+ const containers2: HTMLElement[] = [];
851
+ try {
852
+ const openA = ref(true);
853
+ const openB = ref(true);
854
+ const mk = (mv: { value: boolean }, title: string) => {
855
+ const container = document.createElement("div");
856
+ document.body.appendChild(container);
857
+ containers2.push(container);
858
+ const Wrapper = defineComponent({
859
+ setup() {
860
+ return () =>
861
+ h(HkModal, { modelValue: mv.value, title }, { default: () => h("div", "content") });
862
+ },
863
+ });
864
+ const app = createApp(Wrapper);
865
+ mounts2.push(app);
866
+ app.mount(container);
867
+ };
868
+ mk(openA, "下层窗");
869
+ mk(openB, "上层窗");
870
+ await nextTick();
871
+ await nextTick();
872
+ const roots = () => [...document.querySelectorAll(".hk-modal-root")];
873
+ expect(roots()).toHaveLength(2);
874
+ // The later-registered window is the top: the FIRST root is covered.
875
+ const attrs = roots().map((r) => r.hasAttribute("data-stack-covered"));
876
+ expect(attrs).toEqual([true, false]);
877
+ openB.value = false;
878
+ await nextTick();
879
+ // The leave settles on the surface machine's own clock.
880
+ const deadline = Date.now() + 1500;
881
+ while (roots().some((r) => r.classList.contains("hk-modal-content-leave-active")) && Date.now() < deadline) {
882
+ await nextTick();
883
+ }
884
+ const deadline2 = Date.now() + 1500;
885
+ while (roots().some((r) => r.hasAttribute("data-stack-covered")) && Date.now() < deadline2) {
886
+ await new Promise((resolve) => setTimeout(resolve, 20));
887
+ await nextTick();
888
+ }
889
+ expect(roots().every((r) => !r.hasAttribute("data-stack-covered"))).toBe(true);
890
+ } finally {
891
+ for (const a of mounts2.splice(0)) a.unmount();
892
+ for (const c of containers2.splice(0)) c.remove();
893
+ }
894
+ });
895
+ });
@@ -566,6 +566,20 @@ export default defineComponent({
566
566
  // optimization for the class flip. See runtime/surfaceMachine.ts
567
567
  // for the axioms, the total transition table, and the invariants.
568
568
  const overlayEl = ref<HTMLElement>();
569
+ // TOP-ONLY BLUR: this window's blur plate carries the filter only
570
+ // while it is the topmost WINDOW-kind layer of the stack. The covered
571
+ // window's plate drops to dim-only (see the sheet) — with N stacked
572
+ // full-viewport backdrop-filters every composited frame re-runs all
573
+ // of them, and the open storm measured 36-55% dropped frames vs 2%
574
+ // with a single live filter. Reacts live: covered flips with every
575
+ // register/unregister above this window.
576
+ const stackCovered = computed(() => {
577
+ // Reuses the shared topWindowZ authority (the same windowing filter
578
+ // the dial consumes below) instead of a private registry scan.
579
+ const top = topWindowZ.value;
580
+ if (!handle.value || top === null) return false;
581
+ return handle.value.zIndex < top;
582
+ });
569
583
  const machine = useSurfaceMachine({
570
584
  layers: [
571
585
  // Budgets are the starvation-era upper bound; the driver probes
@@ -1071,6 +1085,7 @@ export default defineComponent({
1071
1085
  <Teleport to={ensureOverlayRoot()}>
1072
1086
  <div
1073
1087
  class="hk-modal-root"
1088
+ data-stack-covered={stackCovered.value || undefined}
1074
1089
  style={{ zIndex: overlayZ.value, ...hkPadStyle(props.padding) }}
1075
1090
  onKeydown={onKeydown}
1076
1091
  >
@@ -1195,6 +1210,11 @@ export default defineComponent({
1195
1210
  renderFooter(),
1196
1211
  ])}
1197
1212
  </div>
1213
+ {/* Static blur plate — the LAST child of hk-modal-root so the
1214
+ sheet's stack-covered drop ([data-stack-covered] on this
1215
+ root) reaches it; paint order rides the explicit z-indexes
1216
+ (plate -1 < overlay 0 < content 1), not DOM order. */}
1217
+ <div class="hk-modal-blur-plate" aria-hidden="true" />
1198
1218
  </div>
1199
1219
  </Teleport>
1200
1220
  );
@@ -311,24 +311,29 @@
311
311
  display: flex;
312
312
  flex-direction: column;
313
313
  /* The panel height hugs its content (up to the cap below); the sheet
314
- * morphs smoothly when content grows/shrinks — a language list gaining
315
- * or losing rows, a select whose options filter. `height: auto` growth
314
+ * morphs when content grows/shrinks — a language list gaining or
315
+ * losing rows, a select whose options filter. `height: auto` growth
316
316
  * cannot fire a CSS transition, so HkSelectPanel's useSizeMorph pins
317
- * the measured natural height as px (see useSizeMorph) and the
318
- * transition below animates old→new px; `interpolate-size: allow-keywords`
319
- * stays harmless for the enter/leave slide. Growth reveals through
320
- * clip-path instead (--hk-sheet-morph: clip, see useSizeMorph): the
321
- * pin lands instantly and the top edge sweeps up paint-only — the
322
- * sheet is a fixed full-width layer, and an animated height relaid
323
- * out + repainted it every frame (mobile patchy-flicker source,
324
- * 2026-09-15). Same duration/ease tokens, same stretch look. */
317
+ * the measured natural height as px (see useSizeMorph) and growth
318
+ * reveals through clip-path (--hk-sheet-morph: clip, see
319
+ * useSizeMorph): the pin lands instantly and the top edge sweeps up
320
+ * paint-only — the sheet is a fixed full-width layer, and an animated
321
+ * height relaid out + repainted it every frame (mobile
322
+ * patchy-flicker source, 2026-09-15). Shrink folds the same way and
323
+ * the atomic re-pin lands transition-off, so — exactly like the
324
+ * modal's ≤767px block — the clip-path leg is the ONLY animated
325
+ * property here. The max-height leg in particular must stay OUT: the
326
+ * cap below carries a live dvh/inset term, and with max-height in the
327
+ * list an adjustResize keyboard RETARGETS a 150ms transition every
328
+ * frame — cap-bound sheets chase their own viewport permanently (the
329
+ * IME cap-chase the modal block fixed on 2026-09-21; aligning this
330
+ * panel to that grammar on 2026-11-07 — inference-level evidence,
331
+ * same engine class, no same-day field report). */
325
332
  interpolate-size: allow-keywords;
326
333
  --hk-sheet-morph: clip;
327
334
  contain: layout style;
328
- transition:
329
- height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
330
- max-height var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
331
- clip-path var(--duration-fast, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
335
+ transition: clip-path var(--duration-fast, 0.15s)
336
+ var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
332
337
  max-height: min(
333
338
  72vh,
334
339
  calc(
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Source contract tying the sheet family's morph flag to its transition
3
+ * grammar (2026-11-07 residue sweep, the mechanical context the flag never
4
+ * had): `--hk-sheet-morph: clip` is read by useSizeMorph through
5
+ * getComputedStyle, while the transition LIST that actually animates the
6
+ * sweep is owned by these stylesheets — no other mechanism ties the two
7
+ * together, which is how dead legs survived five choreography generations.
8
+ * Pinned here:
9
+ * - a rule that sets the flag MUST carry a clip-path transition leg
10
+ * (otherwise the sweep composites unanimated / watchdog-landed);
11
+ * - a clip-mode rule must NOT carry height/max-height legs (in clip
12
+ * mode the morph pins instantly and folds transition-off, so those
13
+ * legs are dead — and a live max-height leg on a dvh-capped sheet is
14
+ * the IME cap-chase the modal block fixed on 2026-09-21);
15
+ * - the flagged-rule set is pinned exactly, so a new flagged surface
16
+ * opts in here consciously and inherits the grammar.
17
+ * Provenance: HkModal's base list trimmed in #1158; HkSelect's panel
18
+ * aligned in the same wave (inference-level evidence for the IME fix —
19
+ * same engine class as the modal's 2026-09-21 Xiaomi reports, no field
20
+ * report yet).
21
+ */
22
+ import { describe, expect, it } from "vitest";
23
+ import { readFileSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const read = (name: string): string => readFileSync(join(here, name), "utf-8");
29
+
30
+ const FAMILY_SHEETS = ["HkModal.scss", "HkSelect.scss"] as const;
31
+
32
+ /** The exact set of rules allowed to flag clip mode: selector text as it
33
+ * appears in the sheet, keyed per file. A new flagged surface must add
34
+ * itself here (and inherit the clip-only grammar consciously). */
35
+ const EXPECTED_FLAGGED_RULES: Record<string, readonly string[]> = {
36
+ "HkModal.scss": [".hk-modal-content"],
37
+ "HkSelect.scss": [".hk-select-sheet-panel"],
38
+ };
39
+
40
+ /** Walk the source and return every rule block that sets
41
+ * `--hk-sheet-morph: clip`, as {selector, body} — brace-matched so the
42
+ * modal's media-nested block extracts whole. */
43
+ function flaggedRules(source: string): Array<{ selector: string; body: string }> {
44
+ const out: Array<{ selector: string; body: string }> = [];
45
+ const flag = /--hk-sheet-morph:[ \t]*clip[ \t]*;/g;
46
+ for (const match of source.matchAll(flag)) {
47
+ const open = source.lastIndexOf("{", match.index);
48
+ if (open < 0) continue;
49
+ // Selector text: everything after the previous structural close
50
+ // (}; or }) up to the opening brace.
51
+ const prevClose = Math.max(
52
+ source.lastIndexOf("}", open),
53
+ source.lastIndexOf(";", open),
54
+ );
55
+ const selector = source
56
+ .slice(prevClose + 1, open)
57
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
58
+ .replace(/\s+/g, " ")
59
+ .trim();
60
+ // Forward-match braces to the block's own close.
61
+ let depth = 0;
62
+ let end = -1;
63
+ for (let i = open; i < source.length; i += 1) {
64
+ if (source[i] === "{") depth += 1;
65
+ else if (source[i] === "}") {
66
+ depth -= 1;
67
+ if (depth === 0) {
68
+ end = i;
69
+ break;
70
+ }
71
+ }
72
+ }
73
+ expect(end, `unbalanced braces after flag at offset ${match.index}`).toBeGreaterThan(0);
74
+ out.push({ selector, body: source.slice(open + 1, end) });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** The block's own transition declaration (multi-line shorthands joined). */
80
+ function transitionOf(block: string): string {
81
+ const match = block.match(/transition:[^;]+;/);
82
+ return match?.[0]?.replace(/\s+/g, " ").trim() ?? "";
83
+ }
84
+
85
+ describe("sheet family morph-flag ↔ transition-grammar contract", () => {
86
+ it("flags exactly the expected rules across the family", () => {
87
+ for (const sheet of FAMILY_SHEETS) {
88
+ const found = flaggedRules(read(sheet)).map((r) => r.selector).sort();
89
+ const expected = [...EXPECTED_FLAGGED_RULES[sheet]].sort();
90
+ expect(found, `${sheet} flagged rules`).toEqual(expected);
91
+ }
92
+ });
93
+
94
+ it("every flagged rule carries a clip-path leg and no dead height/max-height legs", () => {
95
+ for (const sheet of FAMILY_SHEETS) {
96
+ for (const rule of flaggedRules(read(sheet))) {
97
+ const tr = transitionOf(rule.body);
98
+ expect(tr, `${sheet} ${rule.selector} has a transition`).toContain("clip-path");
99
+ expect(tr, `${sheet} ${rule.selector} height leg is dead in clip mode`).not.toMatch(
100
+ /\bheight\b/,
101
+ );
102
+ expect(
103
+ tr,
104
+ `${sheet} ${rule.selector} max-height leg is dead in clip mode (IME cap-chase)`,
105
+ ).not.toMatch(/\bmax-height\b/);
106
+ }
107
+ }
108
+ });
109
+
110
+ it("no un-flagged family rule animates a clip-path sweep", () => {
111
+ // The dead-leg shape this sweep retired: a clip-path transition leg on
112
+ // a surface the flag never arms. Scoped to the family sheets; other
113
+ // components may animate clip-path for their own reasons.
114
+ for (const sheet of FAMILY_SHEETS) {
115
+ const source = read(sheet);
116
+ const flagged = flaggedRules(source);
117
+ for (const match of source.matchAll(/transition:[^;]*\bclip-path\b[^;]*;/g)) {
118
+ const open = source.lastIndexOf("{", match.index);
119
+ const prevClose = Math.max(
120
+ source.lastIndexOf("}", open),
121
+ source.lastIndexOf(";", open),
122
+ );
123
+ const selector = source
124
+ .slice(prevClose + 1, open)
125
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
126
+ .replace(/\s+/g, " ")
127
+ .trim();
128
+ const isFlaggedRule = flagged.some((r) => r.selector === selector);
129
+ expect(
130
+ isFlaggedRule,
131
+ `${sheet} ${selector} animates clip-path without --hk-sheet-morph: clip`,
132
+ ).toBe(true);
133
+ }
134
+ }
135
+ });
136
+ });
@@ -743,10 +743,12 @@ describe("useSizeMorph clip reveal", () => {
743
743
  // ── Leave-window hold + enter-window defer (2026-09-16 modal report) ──
744
744
  // The modal's close fold owns the frame's geometry for the whole leave:
745
745
  // hold() keeps the pin (a mid-leave content change must not resize the
746
- // folding frame) and the reopen-interrupt path animates FROM that pin.
747
- // The enter unfold is height-relative geometry (translateY 5% + bottom
748
- // 10% clip of the frame height), so deferRemeasure freezes resize-driven
749
- // re-pins through the enter and the open edge flushes them.
746
+ // folding frame); a reopen that goes hold → start without releasing
747
+ // animates from that pin (no current HkModal path does this — its reopen
748
+ // edge releases first). deferRemeasure is the generic freeze gate: while
749
+ // it returns true, resize-driven re-pins are deferred and an explicit
750
+ // remeasure() flushes them — HkModal's only remaining freezer is the
751
+ // stepflow slide window.
750
752
 
751
753
  describe("useSizeMorph hold + deferRemeasure", () => {
752
754
  it("hold keeps the pin and stops observing (leave-window stability)", () => {
@@ -790,14 +792,15 @@ describe("useSizeMorph hold + deferRemeasure", () => {
790
792
  // The initial pin is NOT gated (arming must pin immediately).
791
793
  expect(h.frame.style.height).toBe("120px");
792
794
 
793
- // Content streams in mid-enter: the RO fires but the pin must not
794
- // move — the unfold's height-relative geometry stays put.
795
+ // Content changes while the gate is closed: the RO fires but the pin
796
+ // must not move — the caller froze the frame on purpose (the
797
+ // stepflow slide window at the consumer).
795
798
  h.setNatural(160);
796
799
  FakeResizeObserver.instances[0]!.callback();
797
800
  await settle();
798
801
  expect(h.frame.style.height).toBe("120px");
799
802
 
800
- // The open edge flushes the deferred growth.
803
+ // An explicit remeasure bypasses the gate and flushes the growth.
801
804
  h.remeasure();
802
805
  expect(h.frame.style.height).toBe("160px");
803
806
 
@@ -35,12 +35,16 @@ export interface SizeMorph {
35
35
  /** Arm the morph: observe the content and pin the frame's natural
36
36
  * height on every change. Call once the surface finished its open
37
37
  * enter — the enter must own the frame's geometry (HkModal and
38
- * HkSelectPanel both arm at the machine's `open` edge; the enter
39
- * classes are off the frame by then, so calibration reads the rest
40
- * state). There is no early-arm protocol anymore: the modal's enter
41
- * is opacity+transform and never reads the frame's height, so an
42
- * enter-time pin would only freeze a stale height under
43
- * `overflow: hidden`. */
38
+ * HkSelectPanel both arm at the machine's `open` edge). The call may
39
+ * land one render flush before the enter classes actually leave the
40
+ * frame (the machine flips the phase synchronously, Vue re-renders on
41
+ * the next flush); that is safe because no enter class carries layout
42
+ * rules that differ from rest — the choreographies are
43
+ * opacity+transform, and the ≤767px from-pair's `height: auto`
44
+ * coincides with the unpinned rest height — so calibration still
45
+ * reads the rest geometry. There is no early-arm protocol anymore:
46
+ * the modal's enter never reads the frame's height, so an enter-time
47
+ * pin would only freeze a stale height under `overflow: hidden`. */
44
48
  start(): void;
45
49
  /** Disarm the morph and release the frame to `height: auto` — call
46
50
  * before a surface's leave/close so the exit animation owns the
@@ -56,7 +60,13 @@ export interface SizeMorph {
56
60
  * does this, its reopen edge releases the pin first; held content is unchanged, delta
57
61
  * zero; a re-pin to a CHANGED natural while an enter's transition
58
62
  * classes own the frame lands without a height animation, i.e. snaps
59
- * — acceptable, the enter's own choreography owns that moment). */
63
+ * — acceptable, the enter's own choreography owns that moment).
64
+ * Recorded design note: a close during an ENTER that never reached
65
+ * `open` finds the morph disarmed (hold() early-returns) and the
66
+ * leave folds on the live `height: auto` — the same unpinned grammar
67
+ * the enter plays, and preferable to folding on a stale mount-time
68
+ * pin. contentHold freezes the rendered leaf state at closingFrom, so
69
+ * only a self-updating child could resize such a fold. */
60
70
  hold(): void;
61
71
  /** Drop the height pin and hand the frame back to `height: auto`
62
72
  * without disarming anything (a hold()ed morph stays disarmed; an
@@ -177,7 +187,9 @@ export interface SizeMorphOptions {
177
187
  * shrink animation the clip-path-only transition list had lost
178
188
  * (2026-09-21 chest report, round 5) without reintroducing a frame of
179
189
  * per-frame layout. The layer promotion is RESIDENT for the whole arm
180
- * cycle (applied at start(), cleared on hold/release): promoting at the
190
+ * cycle (applied at start(), cleared by hold() at the close request; a
191
+ * mid-cycle release() — the contamination guard's re-pin —
192
+ * deliberately keeps it, see release()): promoting at the
181
193
  * step-change moment cost a one-frame see-through as the old layer died
182
194
  * before the new one rastered (same report).
183
195
  * Height-mode surfaces (desktop) keep the height morph unchanged.
@@ -233,7 +245,9 @@ export function useSizeMorph(
233
245
  * normal close. */
234
246
  let revealWatchdog: ReturnType<typeof setTimeout> | null = null;
235
247
  /** Resident layer promotion on clip-mode surfaces: applied at arm time
236
- * and cleared on hold/release. Promoting at the STEP-change moment
248
+ * and cleared by hold() at the close request (a mid-cycle release() —
249
+ * the contamination guard's re-pin — deliberately leaves it for the
250
+ * cycle to unwind, see clearResidentWill's callers). Promoting at the STEP-change moment
237
251
  * (0.55.38's per-sweep will-change) destroyed the old layer one frame
238
252
  * before the new one had rastered — on the phone GPU the sheet read
239
253
  * as a one-frame see-through flash exactly when the raster race had