@celestia-island/hikari 0.40.18 → 0.40.21

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.
@@ -5,7 +5,6 @@ import {
5
5
  onBeforeUnmount,
6
6
  ref,
7
7
  Teleport,
8
- Transition,
9
8
  watch,
10
9
  type PropType,
11
10
  } from "vue";
@@ -14,9 +13,9 @@ import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
14
13
  import { useOverlay } from "../runtime/useOverlay";
15
14
  import { useBreakpoint } from "../runtime/useBreakpoint";
16
15
  import { createBackGuard } from "../runtime/backStack";
17
- import { armTransitionClassWatchdog, stripTransitionClasses } from "../runtime/transitionWatchdog";
18
16
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
19
17
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
18
+ import { useSurfaceMachine } from "../composables/useSurfaceMachine";
20
19
  import { useSizeMorph } from "../composables/useSizeMorph";
21
20
  import { useI18n } from "../i18n/context";
22
21
  import HIconButton from "./HkIconButton";
@@ -114,16 +113,14 @@ export default defineComponent({
114
113
 
115
114
  const { isMobile } = useBreakpoint();
116
115
  const sheetMode = computed(() => props.sheetOnMobile && isMobile.value);
116
+ /** The form factor this surface OPENED in. The render branches on
117
+ * it (not the live sheetMode) so a viewport crossing the mobile
118
+ * breakpoint mid-flight closes in the shape the surface was born
119
+ * in — the popout branch must not flash in for the closing window
120
+ * of a dying sheet (or vice versa). Set on the machine's opening
121
+ * edge; the next open adopts whatever the viewport calls for. */
122
+ const activeBranch = ref<"sheet" | "popout">("sheet");
117
123
 
118
- // Open/close motion reports into the unified animation context
119
- // (animationBus) for both surface shapes: the desktop popout and the
120
- // mobile bottom sheet — scrim and panel on separate tracks.
121
- const popoutAnim = useSurfaceTransition(250);
122
- const sheetScrimAnim = useSurfaceTransition(320);
123
- const sheetPanelAnim = useSurfaceTransition(320);
124
- // Stable hook sets (one object per track, hoisted out of handlers).
125
- const popoutHooks = popoutAnim.hooks();
126
- const sheetScrimHooks = sheetScrimAnim.hooks("scrim");
127
124
  const panelRef = ref<HTMLElement>();
128
125
  const sheetScrimRef = ref<HTMLElement>();
129
126
  const sheetListRef = ref<HTMLElement>();
@@ -136,60 +133,135 @@ export default defineComponent({
136
133
  // content growth (a language list gaining rows, filtered options)
137
134
  // with the height transition instead of snapping.
138
135
  const morph = useSizeMorph(panelRef, sheetContentRef);
139
- // Enter-class watchdogs (runtime/transitionWatchdog): a starved enter
140
- // freezes the from-pair on a layer — a stuck scrim at opacity: 0 with
141
- // the panel floating above it, resurrected as a full-opacity flash on
142
- // close (the 2026-09 mobile report). One guard per transition track;
143
- // normal enters disarm them.
144
- let scrimEnterGuard: (() => void) | null = null;
145
- let sheetPanelEnterGuard: (() => void) | null = null;
146
- let popoutEnterGuard: (() => void) | null = null;
147
-
148
- function disarmEnterGuards(): void {
149
- scrimEnterGuard?.();
150
- scrimEnterGuard = null;
151
- sheetPanelEnterGuard?.();
152
- sheetPanelEnterGuard = null;
153
- popoutEnterGuard?.();
154
- popoutEnterGuard = null;
136
+
137
+ // Open/close motion reported into the unified animation context
138
+ // (animationBus) — one track for the whole surface, armed on the
139
+ // machine's animation-phase edges.
140
+ const surfTrack = useSurfaceTransition(320).track("surface");
141
+
142
+ /** Open-request bookkeeping (was the props.open watcher's open
143
+ * arm): popup registration (blocking follows the sheet decision),
144
+ * overlay registry, release-then-push back guard, and the
145
+ * next-tick mounts (scrollbar, duplicate-title filter). */
146
+ function handleOpenRequest(): void {
147
+ // Register with the panel title so the modal breadcrumb labels
148
+ // this layer by its i18n name. Blocking follows the sheet
149
+ // decision: the desktop popout is a hidden level, the mobile
150
+ // bottom sheet is a window layer that must be listed.
151
+ handle.value = manager.register(
152
+ "dropdown",
153
+ false,
154
+ props.title || undefined,
155
+ sheetMode.value,
156
+ );
157
+ overlay.open();
158
+ // Release-then-push (the HkMenu normalizer form): a same-tick
159
+ // close→reopen must not leave the reopened panel unguarded —
160
+ // release() keeps its rewind claim (desired snaps to 0) and the
161
+ // following push() re-advances desired by one, so the pending
162
+ // flush rewinds exactly to the fresh entry instead of past it.
163
+ if (backGuard.entries > 0) backGuard.release();
164
+ backGuard.push();
165
+ // The scrolling surface mounts on this very render (popout
166
+ // subtree or sheet body) — attach the overlay scrollbar once
167
+ // the DOM has landed. A same-tick open→close must not arm it
168
+ // on the leaving panel.
169
+ void nextTick(() => {
170
+ if (!props.open) return;
171
+ attachPanelScrollbar();
172
+ });
173
+ if (!sheetMode.value) {
174
+ document.addEventListener("click", onDocumentClick, true);
175
+ } else {
176
+ // The sheet body mounts on this very render — run the
177
+ // duplicate-title filter once it has landed, then keep
178
+ // re-syncing while open (async slot content swaps).
179
+ void nextTick(() => {
180
+ // A same-tick open→close must not arm the observer on the
181
+ // leaving panel (the close edge already ran).
182
+ if (!props.open || !sheetMode.value) return;
183
+ syncDupTitle();
184
+ if (sheetListRef.value && !dupTitleObserver) {
185
+ dupTitleObserver = new MutationObserver(syncDupTitle);
186
+ dupTitleObserver.observe(sheetListRef.value, {
187
+ childList: true,
188
+ characterData: true,
189
+ subtree: true,
190
+ // A consumer re-render patching className can wipe
191
+ // hk-sheet-dup-title mid-open — class mutations re-sync
192
+ // it (adding an existing class mutates nothing, so the
193
+ // loop converges).
194
+ attributes: true,
195
+ attributeFilter: ["class"],
196
+ });
197
+ }
198
+ });
199
+ }
200
+ window.addEventListener("resize", onResize);
155
201
  }
156
- // Sheet panel transition hooks: the surface transition's own set plus
157
- // the size-morph lifecycle (arm after enter — pinning during the
158
- // slide-up would fight it — and release before the leave). The base
159
- // hook set shares the same reported track, so merging is safe.
160
- const sheetPanelBaseHooks = sheetPanelAnim.hooks("panel");
161
- const sheetPanelHooks = {
162
- ...sheetPanelBaseHooks,
163
- onBeforeEnter: (el: Element) => {
164
- sheetPanelBaseHooks.onBeforeEnter();
165
- stripTransitionClasses(el as HTMLElement, "hk-select-sheet");
166
- sheetPanelEnterGuard?.();
167
- sheetPanelEnterGuard = armTransitionClassWatchdog(
168
- el as HTMLElement,
169
- "hk-select-sheet",
170
- () => props.open,
171
- );
172
- },
173
- onEnterCancelled: () => {
174
- sheetPanelBaseHooks.onEnterCancelled();
175
- sheetPanelEnterGuard?.();
176
- sheetPanelEnterGuard = null;
177
- },
178
- onAfterEnter: (el: Element) => {
179
- sheetPanelBaseHooks.onAfterEnter();
180
- // Identity gate: a stale after-enter from an interrupted cycle
181
- // must neither disarm the live guard nor re-arm the morph.
182
- if (el === panelRef.value) {
183
- sheetPanelEnterGuard?.();
184
- sheetPanelEnterGuard = null;
202
+
203
+ /** Close-request bookkeeping (was the watcher's close arm): the
204
+ * registries forget the surface at request time; the machine keeps
205
+ * the DOM mounted through its closing window (leave transition),
206
+ * painting at the remembered z band. */
207
+ function handleCloseRequest(): void {
208
+ document.removeEventListener("click", onDocumentClick, true);
209
+ window.removeEventListener("resize", onResize);
210
+ detachPanelScrollbar();
211
+ stopDupTitleSync();
212
+ // Release the pinned height so the leave owns the frame.
213
+ morph.stop();
214
+ backGuard.release();
215
+ if (handle.value) {
216
+ // Unregister immediately (stacking/breadcrumb must forget the
217
+ // dying panel at once) but remember its z — the closing window
218
+ // below keeps rendering at that band for its short lifetime
219
+ // instead of sinking under the page.
220
+ lastZ.value = handle.value.zIndex;
221
+ manager.unregister(handle.value.id);
222
+ handle.value = null;
223
+ }
224
+ overlay.close();
225
+ }
226
+
227
+ // ── Surface lifecycle machine ─────────────────────────────────────
228
+ // One machine drives every layer of BOTH form factors — the mobile
229
+ // sheet (scrim + panel) and the desktop popout. The branches render
230
+ // disjoint elements, so the inactive layers' probes read null and
231
+ // only the live branch's CSS timing tightens the deadlines. The
232
+ // two-<Transition> era could freeze one layer's enter pair while the
233
+ // other stayed visible (2026-09 mobile report); with one phase the
234
+ // divergent states are unrepresentable. See runtime/surfaceMachine.ts
235
+ // for the axioms, the total table, and the invariants.
236
+ const machine = useSurfaceMachine({
237
+ layers: [
238
+ // Budgets are the starvation-era bounds (flip 120 + slowest
239
+ // layer + slack); the driver probes the live CSS durations and
240
+ // tightens them. SCSS truths: scrim 0.25s/0.2s, sheet panel
241
+ // 0.3s/0.25s, popout 0.2s/0.15s.
242
+ { prefix: "hk-select-sheet-scrim", el: () => sheetScrimRef.value, enterMs: () => 270, leaveMs: () => 240 },
243
+ { prefix: "hk-select-sheet", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 280 },
244
+ { prefix: "hk-select-popout", el: () => popoutHostRef.value, enterMs: () => 240, leaveMs: () => 190 },
245
+ ],
246
+ onPhase: (from, to) => {
247
+ if (to === "openingFrom") {
248
+ surfTrack.run();
249
+ activeBranch.value = sheetMode.value ? "sheet" : "popout";
250
+ handleOpenRequest();
251
+ } else if (to === "open") {
252
+ surfTrack.cancel();
253
+ // Size morphs arm once the open choreography finished —
254
+ // pinning during the slide-up would fight it. Harmless on the
255
+ // desktop popout (the sheet content probe is absent).
185
256
  morph.start();
257
+ } else if (to === "closingFrom") {
258
+ surfTrack.run();
259
+ handleCloseRequest();
260
+ } else if (to === "closed" && (from === "closingFrom" || from === "closingTo")) {
261
+ surfTrack.cancel();
186
262
  }
187
263
  },
188
- onBeforeLeave: () => {
189
- sheetPanelBaseHooks.onBeforeLeave();
190
- morph.stop();
191
- },
192
- };
264
+ });
193
265
 
194
266
  // Window-first back priority (HkModal convention): while this panel is
195
267
  // the topmost window, the back gesture closes it instead of navigating
@@ -221,12 +293,14 @@ export default defineComponent({
221
293
  if (props.open && sheetMode.value) syncDupTitle();
222
294
  },
223
295
  );
224
- // Flipping OUT of sheet mode while open (consumer prop, not the
225
- // breakpoint flip — that closes the panel) swaps the render branch
226
- // and detaches the sheet list; drop the observer promptly instead
227
- // of letting it guard a detached subtree. Blocking follows so the
228
- // breadcrumb level tracks the surface's current form even when only
229
- // the consumer prop (not the viewport) moved.
296
+ // A consumer-prop flip out of sheet mode while open (the breakpoint
297
+ // flip closes the panel instead) keeps rendering the BORN branch —
298
+ // only the registry tracks the new form. The dup-title observer
299
+ // drops promptly so it stops guarding a soon-irrelevant subtree
300
+ // (the sheet list stays mounted through the rest of that open
301
+ // cycle, but re-syncing its duplicate headings no longer matters
302
+ // once the form is no longer the sheet). Blocking follows so the
303
+ // breadcrumb level tracks the surface's declared form.
230
304
  watch(sheetMode, (mode) => {
231
305
  if (!mode) stopDupTitleSync();
232
306
  if (handle.value) manager.setBlocking(handle.value.id, mode);
@@ -371,85 +445,13 @@ export default defineComponent({
371
445
  watch(
372
446
  () => props.open,
373
447
  (open) => {
374
- if (open) {
375
- // Register with the panel title so the modal breadcrumb labels
376
- // this layer by its i18n name. Blocking follows the sheet
377
- // decision: the desktop popout is a hidden level, the mobile
378
- // bottom sheet is a window layer that must be listed.
379
- handle.value = manager.register(
380
- "dropdown",
381
- false,
382
- props.title || undefined,
383
- sheetMode.value,
384
- );
385
- overlay.open();
386
- // Release-then-push (the HkMenu normalizer form): a same-tick
387
- // close→reopen must not leave the reopened panel unguarded —
388
- // release() keeps its rewind claim (desired snaps to 0) and the
389
- // following push() re-advances desired by one, so the pending
390
- // flush rewinds exactly to the fresh entry instead of past it.
391
- if (backGuard.entries > 0) backGuard.release();
392
- backGuard.push();
393
- // The scrolling surface mounts on this very render (popout
394
- // subtree or sheet body) — attach the overlay scrollbar once
395
- // the DOM has landed. A same-tick open→close must not arm it
396
- // on the leaving panel.
397
- void nextTick(() => {
398
- if (!props.open) return;
399
- attachPanelScrollbar();
400
- });
401
- if (!sheetMode.value) {
402
- document.addEventListener("click", onDocumentClick, true);
403
- } else {
404
- // The sheet body mounts on this very render — run the
405
- // duplicate-title filter once it has landed, then keep
406
- // re-syncing while open (async slot content swaps).
407
- void nextTick(() => {
408
- // A same-tick open→close must not arm the observer on the
409
- // leaving panel (the close branch already ran).
410
- if (!props.open || !sheetMode.value) return;
411
- syncDupTitle();
412
- if (sheetListRef.value && !dupTitleObserver) {
413
- dupTitleObserver = new MutationObserver(syncDupTitle);
414
- dupTitleObserver.observe(sheetListRef.value, {
415
- childList: true,
416
- characterData: true,
417
- subtree: true,
418
- // A consumer re-render patching className can wipe
419
- // hk-sheet-dup-title mid-open — class mutations
420
- // re-sync it (adding an existing class mutates
421
- // nothing, so the loop converges).
422
- attributes: true,
423
- attributeFilter: ["class"],
424
- });
425
- }
426
- });
427
- }
428
- window.addEventListener("resize", onResize);
429
- } else {
430
- document.removeEventListener("click", onDocumentClick, true);
431
- window.removeEventListener("resize", onResize);
432
- detachPanelScrollbar();
433
- stopDupTitleSync();
434
- // Enter cycles died with the open state — their guards must
435
- // not fire into the leave transitions.
436
- disarmEnterGuards();
437
- backGuard.release();
438
- if (handle.value) {
439
- // Unregister immediately (stacking/breadcrumb must forget the
440
- // dying panel at once) but remember its z — the leave
441
- // transition below keeps rendering at that band for its
442
- // short lifetime instead of sinking under the page.
443
- lastZ.value = handle.value.zIndex;
444
- manager.unregister(handle.value.id);
445
- handle.value = null;
446
- }
447
- overlay.close();
448
- }
448
+ // The machine owns every visual/registry consequence on its
449
+ // phase edges; this watcher is purely the event feed.
450
+ machine.send(open ? "OPEN" : "CLOSE");
449
451
  },
450
- // immediate: mounting with open=true must register with the popup
451
- // manager (z band) and overlay registry right away — the HkPopover /
452
- // HkModal convention.
452
+ // immediate: mounting with open=true must walk the machine into
453
+ // its opening edge right away — the HkPopover / HkModal
454
+ // convention.
453
455
  { immediate: true },
454
456
  );
455
457
 
@@ -458,7 +460,6 @@ export default defineComponent({
458
460
  window.removeEventListener("resize", onResize);
459
461
  detachPanelScrollbar();
460
462
  stopDupTitleSync();
461
- disarmEnterGuards();
462
463
  overlay.close();
463
464
  backGuard.destroy();
464
465
  if (handle.value) {
@@ -560,11 +561,12 @@ export default defineComponent({
560
561
  });
561
562
 
562
563
  return () => {
563
- // Sheet mode keeps its Teleport mounted across the close so the
564
- // leave transition (slide-down) actually runs — unmounting the
565
- // subtree on close would snap the sheet shut instead (the same
566
- // reason HkPopover keeps its Teleport alive through the leave).
567
- if (sheetMode.value) {
564
+ // The machine keeps the Teleport mounted across the whole closing
565
+ // window so the leave transition (slide-down / pop-out) actually
566
+ // runs — `closed` unmounts it (the same reason the pre-machine
567
+ // code kept its Transitions alive through the leave).
568
+ if (!machine.mounted.value) return null;
569
+ if (activeBranch.value === "sheet") {
568
570
  return (
569
571
  <Teleport to="body">
570
572
  {/* Window-layer contract (./_scrim-fade.scss): the scrim fades
@@ -572,49 +574,15 @@ export default defineComponent({
572
574
  panel's `hk-select-sheet` name, so the panel's
573
575
  translateY(100%) enter pair slid the dim curtain up from
574
576
  the bottom edge on phones (2026-09-06 report). */}
575
- <Transition
576
- name="hk-select-sheet-scrim"
577
- appear
578
- onBeforeEnter={(el: Element) => {
579
- sheetScrimHooks.onBeforeEnter();
580
- stripTransitionClasses(el as HTMLElement, "hk-select-sheet-scrim");
581
- scrimEnterGuard?.();
582
- scrimEnterGuard = armTransitionClassWatchdog(
583
- el as HTMLElement,
584
- "hk-select-sheet-scrim",
585
- () => props.open,
586
- );
587
- }}
588
- onAfterEnter={(el: Element) => {
589
- sheetScrimHooks.onAfterEnter();
590
- if (el === sheetScrimRef.value) {
591
- scrimEnterGuard?.();
592
- scrimEnterGuard = null;
593
- }
594
- }}
595
- onEnterCancelled={() => {
596
- sheetScrimHooks.onEnterCancelled();
597
- scrimEnterGuard?.();
598
- scrimEnterGuard = null;
599
- }}
600
- onBeforeLeave={sheetScrimHooks.onBeforeLeave}
601
- onAfterLeave={sheetScrimHooks.onAfterLeave}
602
- onLeaveCancelled={sheetScrimHooks.onLeaveCancelled}
603
- >
604
- {props.open ? (
605
- <div
606
- ref={sheetScrimRef}
607
- class="hk-select-sheet-scrim"
608
- style={{ zIndex: popoutZ.value - 1 }}
609
- onClick={close}
610
- />
611
- ) : null}
612
- </Transition>
613
- <Transition name="hk-select-sheet" appear {...sheetPanelHooks}>
614
- {props.open ? (
615
- <div
577
+ <div
578
+ ref={sheetScrimRef}
579
+ class={["hk-select-sheet-scrim", ...machine.classesFor("hk-select-sheet-scrim")]}
580
+ style={{ zIndex: popoutZ.value - 1 }}
581
+ onClick={close}
582
+ />
583
+ <div
616
584
  ref={panelRef}
617
- class="hk-select-sheet-panel"
585
+ class={["hk-select-sheet-panel", ...machine.classesFor("hk-select-sheet")]}
618
586
  style={{ zIndex: popoutZ.value }}
619
587
  role="dialog"
620
588
  aria-modal="true"
@@ -655,8 +623,6 @@ export default defineComponent({
655
623
  </div>
656
624
  </div>
657
625
  </div>
658
- ) : null}
659
- </Transition>
660
626
  </Teleport>
661
627
  );
662
628
  }
@@ -671,41 +637,9 @@ export default defineComponent({
671
637
  // after an auto-flip).
672
638
  return (
673
639
  <Teleport to="body">
674
- <Transition
675
- name="hk-select-popout"
676
- appear
677
- onBeforeEnter={(el: Element) => {
678
- popoutHooks.onBeforeEnter();
679
- stripTransitionClasses(el as HTMLElement, "hk-select-popout");
680
- popoutEnterGuard?.();
681
- popoutEnterGuard = armTransitionClassWatchdog(
682
- el as HTMLElement,
683
- "hk-select-popout",
684
- () => props.open,
685
- );
686
- }}
687
- onAfterEnter={(el: Element) => {
688
- popoutHooks.onAfterEnter();
689
- // Identity-gated disarm: a stale after-enter from an
690
- // interrupted cycle must not disarm the live cycle's guard.
691
- if (el === popoutHostRef.value) {
692
- popoutEnterGuard?.();
693
- popoutEnterGuard = null;
694
- }
695
- }}
696
- onEnterCancelled={() => {
697
- popoutHooks.onEnterCancelled();
698
- popoutEnterGuard?.();
699
- popoutEnterGuard = null;
700
- }}
701
- onBeforeLeave={popoutHooks.onBeforeLeave}
702
- onAfterLeave={popoutHooks.onAfterLeave}
703
- onLeaveCancelled={popoutHooks.onLeaveCancelled}
704
- >
705
- {props.open ? (
706
- <div
640
+ <div
707
641
  ref={popoutHostRef}
708
- class="hk-select-popout-host"
642
+ class={["hk-select-popout-host", ...machine.classesFor("hk-select-popout")]}
709
643
  data-side={resolved.value.side}
710
644
  data-align={resolved.value.align}
711
645
  style={{ ...coords.value, zIndex: popoutZ.value }}
@@ -719,8 +653,6 @@ export default defineComponent({
719
653
  {slots.default?.()}
720
654
  </div>
721
655
  </div>
722
- ) : null}
723
- </Transition>
724
656
  </Teleport>
725
657
  );
726
658
  };
@@ -142,14 +142,14 @@ describe("HkStatusBar", () => {
142
142
  tag.dispatchEvent(tap());
143
143
  await nextTick();
144
144
  expect(onRetry).toHaveBeenCalledTimes(1);
145
- // happy-dom never fires transitionend, so the panel is guaranteed to
146
- // still be mid leave-animation here (the leave class can only be
147
- // removed by the transition's after-leave hook). The Transition's
148
- // direct child is the positioning wrapper around the panel, so the
149
- // leave class lands on it and the real panel must sit INSIDE it.
150
- const tail = document.body.querySelector<HTMLElement>('[class*="hk-popover-leave"]');
151
- expect(tail).not.toBeNull();
152
- expect(tail?.querySelector(".hk-popover-panel")).not.toBeNull();
145
+ // Machine era: in no-transition environments (happy-dom's computed
146
+ // durations read 0) the close settles on the microtask queue — the
147
+ // rAF-era lingering leave window is gone. Assert the dismissal
148
+ // completed: the panel unmounted, exactly one retry ever fired.
149
+ await new Promise((resolve) => setTimeout(resolve, 0));
150
+ await nextTick();
151
+ expect(document.body.querySelector(".hk-popover-panel")).toBeNull();
152
+ expect(onRetry).toHaveBeenCalledTimes(1);
153
153
  });
154
154
 
155
155
  it("a scroll-like touch ending over the tag does not retry", async () => {
@@ -299,12 +299,16 @@ describe("HkThemeToggle item slots", () => {
299
299
  const rows = [...document.body.querySelectorAll<HTMLElement>(".s-theme-menu .s-theme-item-row")];
300
300
  expect(rows.length).toBeGreaterThanOrEqual(2);
301
301
  // Every row — active or not — carries the slot cell; no check cell is
302
- // rendered alongside (the slot owns the leading zone).
302
+ // rendered alongside (the slot owns the leading zone). Exactly ONE
303
+ // slot cell per row in the DOM — the machine-era popover re-renders
304
+ // per phase (class outputs are reactive), so slot invocation counts
305
+ // are no longer 1:1 with rows; DOM uniqueness is the invariant.
303
306
  for (const row of rows) {
304
307
  expect(row.querySelector(".s-theme-item-lead .lead-mark")).toBeTruthy();
308
+ expect(row.querySelectorAll(".lead-mark")).toHaveLength(1);
305
309
  expect(row.querySelector(".s-theme-item-check")).toBeNull();
306
310
  }
307
- expect(seen.length).toBe(rows.length);
311
+ expect(seen.length).toBeGreaterThanOrEqual(rows.length);
308
312
  });
309
313
 
310
314
  it("reserves the trailing column and suppresses the built-in delete overlay", async () => {
@@ -63,20 +63,25 @@ describe("window-layer scrim fade contract", () => {
63
63
  });
64
64
 
65
65
  it("the select sheet scrim rides a transition name distinct from the panel's", () => {
66
+ // Machine era: the surface lifecycle is driven by useSurfaceMachine
67
+ // whose layer prefixes ARE the transition names — the contract
68
+ // survives as the distinct-prefix wiring (scrim never shares the
69
+ // panel's slide name).
66
70
  const src = read("HkSelectPanel.tsx");
67
- // \s+ across the attribute list: the tag may be formatted with name
68
- // on its own line.
69
- const names = Array.from(src.matchAll(/<Transition\s+name="([^"]+)"/g), (m) => m[1]);
70
- expect(names).toContain("hk-select-sheet-scrim");
71
- expect(names).toContain("hk-select-sheet");
72
- expect(src).toContain('sheetScrimAnim.hooks("scrim")');
71
+ expect(src).toContain('prefix: "hk-select-sheet-scrim"');
72
+ expect(src).toContain('prefix: "hk-select-sheet"');
73
+ expect(src).toContain('prefix: "hk-select-popout"');
74
+ expect(src).toContain("useSurfaceMachine({");
73
75
  });
74
76
 
75
77
  it("the popover sheet scrim fades instead of mounting bare", () => {
78
+ // Machine era: the scrim's fade classes are machine outputs under a
79
+ // prefix DISTINCT from the panel's slide names — the contract
80
+ // survives as the layer wiring.
76
81
  const src = read("HkPopover.tsx");
77
- expect(src).toContain('<Transition name="hk-popover-scrim"');
78
- expect(src).toContain('surfaceAnim.hooks("scrim")');
82
+ expect(src).toContain('prefix: "hk-popover-scrim"');
83
+ expect(src).toContain("useSurfaceMachine({");
79
84
  // The panel's sheet name stays the slide family — distinct from the scrim.
80
- expect(src).toContain('"hk-popover-sheet"');
85
+ expect(src).toContain('prefix: "hk-popover-sheet"');
81
86
  });
82
87
  });