@celestia-island/hikari 0.40.16 → 0.40.18

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.40.16",
3
+ "version": "0.40.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -145,6 +145,8 @@ export const HkColorSchemeEditor = defineComponent({
145
145
  initialLight: { type: Object as PropType<ThemeSchemeTokens>, default: undefined },
146
146
  /** Prefill extension token groups (per mode); defaults to registry defaults. */
147
147
  initialGroups: { type: Object as PropType<ThemeTokenGroupModes>, default: undefined },
148
+ /** Prefill the scheme name input (edit/fork flows); empty by default. */
149
+ initialName: { type: String, default: "" },
148
150
  },
149
151
  setup(props, { expose }) {
150
152
  const { t } = useI18n();
@@ -153,7 +155,7 @@ export const HkColorSchemeEditor = defineComponent({
153
155
  // through a computed so config-file labels follow the live locale.
154
156
  const activeLocale = computed(() => useI18n().locale);
155
157
  const modeTab = ref<string>("dark");
156
- const themeName = ref("");
158
+ const themeName = ref(props.initialName ?? "");
157
159
 
158
160
  const dark = reactive<ThemeSchemeTokens>({ ...defaultDark });
159
161
  const light = reactive<ThemeSchemeTokens>({ ...defaultLight });
@@ -196,7 +198,7 @@ export const HkColorSchemeEditor = defineComponent({
196
198
 
197
199
  function reset(): void {
198
200
  modeTab.value = useTheme().effectiveMode.value;
199
- themeName.value = t("hikari::theme.customThemeName");
201
+ themeName.value = props.initialName ?? t("hikari::theme.customThemeName");
200
202
  Object.assign(dark, props.initialDark ?? defaultDark);
201
203
  Object.assign(light, props.initialLight ?? defaultLight);
202
204
  // Optional slots: a legacy prefill omitting them must reset to white
@@ -0,0 +1,160 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkModal from "./HkModal";
5
+
6
+ const mounts: ReturnType<typeof createApp>[] = [];
7
+ const containers: HTMLElement[] = [];
8
+
9
+ afterEach(async () => {
10
+ for (const app of mounts.splice(0)) app.unmount();
11
+ for (const el of containers.splice(0)) el.remove();
12
+ vi.unstubAllGlobals();
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ /** Freeze rAF entirely — Vue's <Transition> engine double-raf's the
17
+ * enter-from → enter-to class flip, so a frozen rAF reproduces the
18
+ * occluded-webview pathology exactly: the enter can NEVER complete on
19
+ * its own, the from-pair (opacity: 0 / translateY(100%)) freezes on the
20
+ * layer, and only the enter watchdog can repair it. Mirror of the
21
+ * leave-completion test's freezeRaf. */
22
+ function freezeRaf(): void {
23
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
24
+ vi.stubGlobal("cancelAnimationFrame", () => {});
25
+ }
26
+
27
+ /** Mount an open modal wired to an `open` ref we can flip from the test. */
28
+ async function mountOpenModal() {
29
+ const container = document.createElement("div");
30
+ document.body.appendChild(container);
31
+ containers.push(container);
32
+
33
+ const open = ref(true);
34
+ const Wrapper = defineComponent({
35
+ setup() {
36
+ return () =>
37
+ h(HkModal, {
38
+ modelValue: open.value,
39
+ closable: true,
40
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
41
+ }, { default: () => h("div", "content") });
42
+ },
43
+ });
44
+ const app = createApp(Wrapper);
45
+ mounts.push(app);
46
+ app.mount(container);
47
+ await nextTick();
48
+ return { open };
49
+ }
50
+
51
+ describe("HkModal enter-class watchdog", () => {
52
+ // Regression for the 2026-09 mobile report: a starved enter froze the
53
+ // scrim's enter-from pair (opacity: 0) while the panel stayed open —
54
+ // the dim curtain vanished, and the eventual close flashed it back at
55
+ // full opacity (the "black rectangle"). The watchdog must strip the
56
+ // stuck classes on BOTH layers within its budget while the modal stays
57
+ // open and functional.
58
+ it("strips frozen enter classes on overlay and content within the budget", async () => {
59
+ vi.useFakeTimers();
60
+ freezeRaf();
61
+ await mountOpenModal();
62
+
63
+ const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
64
+ const content = document.querySelector<HTMLElement>(".hk-modal-content");
65
+ expect(overlay).not.toBeNull();
66
+ expect(content).not.toBeNull();
67
+ // The frozen enter left its from-pair on both layers.
68
+ expect(overlay!.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
69
+ expect(content!.classList.contains("hk-modal-content-enter-from")).toBe(true);
70
+
71
+ // Just inside the budget nothing has been repaired yet.
72
+ await vi.advanceTimersByTimeAsync(590);
73
+ expect(overlay!.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
74
+
75
+ await vi.advanceTimersByTimeAsync(100);
76
+ // Both layers snapped to their resting (class-less) state.
77
+ expect(overlay!.className).toBe("hk-modal-overlay");
78
+ expect(content!.className).toBe("hk-modal-content");
79
+ // …and the surface is still open and intact.
80
+ expect(document.querySelector(".hk-modal-content")).not.toBeNull();
81
+ });
82
+
83
+ it("leaves a healthy enter untouched (watchdog disarmed on completion)", async () => {
84
+ // Real rAF: the enter completes on its own, the after-enter disarms
85
+ // the watchdog, and no strip ever fires past the budget.
86
+ vi.useFakeTimers();
87
+ await mountOpenModal();
88
+ await vi.advanceTimersByTimeAsync(50);
89
+
90
+ const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
91
+ expect(overlay).not.toBeNull();
92
+ expect(Array.from(overlay!.classList).some((c) => c.startsWith("hk-modal-overlay-"))).toBe(
93
+ false,
94
+ );
95
+ });
96
+
97
+ it("a repaired modal still closes normally afterwards", async () => {
98
+ vi.useFakeTimers();
99
+ freezeRaf();
100
+ const { open } = await mountOpenModal();
101
+ await vi.advanceTimersByTimeAsync(700);
102
+ expect(document.querySelector(".hk-modal-content")).not.toBeNull();
103
+
104
+ open.value = false;
105
+ await nextTick();
106
+ await vi.advanceTimersByTimeAsync(700);
107
+ await nextTick();
108
+ expect(document.querySelector(".hk-modal-content")).toBeNull();
109
+ expect(document.querySelector(".hk-modal-overlay")).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe("HkModal enter-class watchdog across an interrupted leave", () => {
114
+ // The exact field-report sequence (2026-09-07 recording, f108-f112):
115
+ // the modal started closing (scrim fading out), a reopen patched over
116
+ // the still-live leave on the SAME element, and rAF starvation froze
117
+ // the re-enter's from-pair — the scrim stayed invisible for seconds
118
+ // and the eventual close flashed it back at full opacity. The
119
+ // watchdog must repair both layers of the reopened surface.
120
+ it("repairs a reopen that froze mid-re-enter on the same element", async () => {
121
+ vi.useFakeTimers();
122
+ // Phase 1: open with WORKING rAF so the initial enter completes.
123
+ const { open } = await mountOpenModal();
124
+ await vi.advanceTimersByTimeAsync(50);
125
+ await nextTick();
126
+
127
+ // Phase 2: starve rAF, start a close, and reopen while the leave is
128
+ // still live — the re-enter freezes at its from-pair.
129
+ freezeRaf();
130
+ open.value = false;
131
+ await nextTick();
132
+ await vi.advanceTimersByTimeAsync(120); // leave live, nothing finalized
133
+ open.value = true;
134
+ await nextTick();
135
+
136
+ const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay")!;
137
+ const content = document.querySelector<HTMLElement>(".hk-modal-content")!;
138
+ expect(overlay).not.toBeNull();
139
+ expect(content).not.toBeNull();
140
+ // The frozen re-enter left its from-pair on both layers (the scrim
141
+ // invisible at opacity 0 while the surface is logically open).
142
+ expect(overlay.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
143
+ expect(content.classList.contains("hk-modal-content-enter-from")).toBe(true);
144
+
145
+ await vi.advanceTimersByTimeAsync(590);
146
+ expect(overlay.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
147
+
148
+ await vi.advanceTimersByTimeAsync(100);
149
+ expect(overlay.className).toBe("hk-modal-overlay");
150
+ expect(content.className).toBe("hk-modal-content");
151
+ // The surface is still open, and closing it afterwards runs a NORMAL
152
+ // leave (fade from visible) instead of the full-opacity pop.
153
+ open.value = false;
154
+ await nextTick();
155
+ await vi.advanceTimersByTimeAsync(700);
156
+ await nextTick();
157
+ expect(document.querySelector(".hk-modal-content")).toBeNull();
158
+ expect(document.querySelector(".hk-modal-overlay")).toBeNull();
159
+ });
160
+ });
@@ -19,6 +19,7 @@ import { useOverlay } from "../runtime/useOverlay";
19
19
  import { usePopupManager } from "../runtime/usePopupManager";
20
20
  import { createBackGuard } from "../runtime/backStack";
21
21
  import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
22
+ import { armTransitionClassWatchdog, stripTransitionClasses } from "../runtime/transitionWatchdog";
22
23
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
23
24
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
24
25
  import { useSizeMorph } from "../composables/useSizeMorph";
@@ -180,6 +181,7 @@ export default defineComponent({
180
181
  });
181
182
 
182
183
  const handle = ref<{ id: string; zIndex: number } | null>(null);
184
+ const overlayRef = ref<HTMLElement>();
183
185
  const bodyRef = ref<HTMLElement>();
184
186
  const contentRef = ref<HTMLElement>();
185
187
  /** Natural-height probe inside the scroll container: the body's
@@ -229,6 +231,24 @@ export default defineComponent({
229
231
  }
230
232
  }
231
233
 
234
+ // ── Enter-class watchdog ──────────────────────────────────────────
235
+ // The leave side above bounds rAF starvation for the CLOSE; the enter
236
+ // side has no such guard. A starved enter freezes the from-pair on
237
+ // the layer (scrim at opacity: 0 while the panel floats above it),
238
+ // and the eventual close then flashes the resurrected curtain at full
239
+ // opacity — the mobile "black rectangle" report (2026-09). Each layer
240
+ // (overlay, content) arms on its before-enter and strips stuck
241
+ // enter classes once the same budget lapses; normal enters disarm.
242
+ let overlayEnterGuard: (() => void) | null = null;
243
+ let contentEnterGuard: (() => void) | null = null;
244
+
245
+ function disarmEnterGuards(): void {
246
+ overlayEnterGuard?.();
247
+ overlayEnterGuard = null;
248
+ contentEnterGuard?.();
249
+ contentEnterGuard = null;
250
+ }
251
+
232
252
  const overlayZ = computed(() => handle.value?.zIndex ?? 0);
233
253
  const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
234
254
  const resolvedWidth = computed(() => resolveModalWidth(props.width));
@@ -319,6 +339,7 @@ export default defineComponent({
319
339
  if (unmounted || props.modelValue || leaveFinalized) return;
320
340
  leaveFinalized = true;
321
341
  disarmLeaveWatchdog();
342
+ disarmEnterGuards();
322
343
  if (handle.value) {
323
344
  manager.unregister(handle.value.id);
324
345
  handle.value = null;
@@ -639,6 +660,9 @@ export default defineComponent({
639
660
  // (e.g. immediate), clean up now.
640
661
  overlay.close();
641
662
  backGuard.release();
663
+ // The enter cycles are dead the moment the surface starts
664
+ // closing — their guards must not fire into the leave.
665
+ disarmEnterGuards();
642
666
  // Bound the Transition leave: if rAF starvation froze the
643
667
  // class flip, the watchdog finalizes in the watchdog's place
644
668
  // (see LEAVE_WATCHDOG_MS above). Only an actually-mounted
@@ -678,6 +702,7 @@ export default defineComponent({
678
702
  onBeforeUnmount(() => {
679
703
  unmounted = true;
680
704
  disarmLeaveWatchdog();
705
+ disarmEnterGuards();
681
706
  detachBodyScrollbar();
682
707
  teardownWindowed();
683
708
  teardownAutoFollow();
@@ -731,13 +756,40 @@ export default defineComponent({
731
756
  <Transition
732
757
  name="hk-modal-overlay"
733
758
  appear
734
- onBeforeEnter={overlayHooks.onBeforeEnter}
735
- onAfterEnter={overlayHooks.onAfterEnter}
759
+ onBeforeEnter={(el: Element) => {
760
+ overlayHooks.onBeforeEnter();
761
+ // A prior interrupted cycle can stamp stale classes on a
762
+ // recycled node — never enter from a frozen transition.
763
+ stripTransitionClasses(el as HTMLElement, "hk-modal-overlay");
764
+ overlayEnterGuard?.();
765
+ overlayEnterGuard = armTransitionClassWatchdog(
766
+ el as HTMLElement,
767
+ "hk-modal-overlay",
768
+ () => !unmounted && !!props.modelValue,
769
+ );
770
+ }}
771
+ onAfterEnter={(el: Element) => {
772
+ overlayHooks.onAfterEnter();
773
+ // Stale after-enters from an interrupted cycle (Vue fires
774
+ // them without a cancelled flag) must not disarm the live
775
+ // cycle's guard — only the current element's completion.
776
+ if (el === overlayRef.value) {
777
+ overlayEnterGuard?.();
778
+ overlayEnterGuard = null;
779
+ }
780
+ }}
781
+ onEnterCancelled={() => {
782
+ overlayHooks.onEnterCancelled();
783
+ overlayEnterGuard?.();
784
+ overlayEnterGuard = null;
785
+ }}
736
786
  onBeforeLeave={overlayHooks.onBeforeLeave}
737
787
  onAfterLeave={overlayHooks.onAfterLeave}
788
+ onLeaveCancelled={overlayHooks.onLeaveCancelled}
738
789
  >
739
790
  {props.modelValue && (
740
791
  <div
792
+ ref={overlayRef}
741
793
  class="hk-modal-overlay"
742
794
  onClick={onOverlayClick}
743
795
  />
@@ -746,13 +798,34 @@ export default defineComponent({
746
798
  <Transition
747
799
  name="hk-modal-content"
748
800
  appear
749
- onBeforeEnter={contentHooks.onBeforeEnter}
750
- onAfterEnter={() => {
801
+ onBeforeEnter={(el: Element) => {
802
+ contentHooks.onBeforeEnter();
803
+ stripTransitionClasses(el as HTMLElement, "hk-modal-content");
804
+ contentEnterGuard?.();
805
+ contentEnterGuard = armTransitionClassWatchdog(
806
+ el as HTMLElement,
807
+ "hk-modal-content",
808
+ () => !unmounted && !!props.modelValue,
809
+ );
810
+ }}
811
+ onAfterEnter={(el: Element) => {
751
812
  contentHooks.onAfterEnter();
752
- onAfterEnter();
753
- // Size morphs arm once the open choreography finished —
754
- // pinning during enter would override its height reveal.
755
- morph.start();
813
+ // Identity gate: a stale after-enter from an interrupted
814
+ // cycle must neither disarm the live guard nor re-run the
815
+ // open side effects (focus, morph arming).
816
+ if (el === contentRef.value) {
817
+ contentEnterGuard?.();
818
+ contentEnterGuard = null;
819
+ onAfterEnter();
820
+ // Size morphs arm once the open choreography finished —
821
+ // pinning during enter would override its height reveal.
822
+ morph.start();
823
+ }
824
+ }}
825
+ onEnterCancelled={() => {
826
+ contentHooks.onEnterCancelled();
827
+ contentEnterGuard?.();
828
+ contentEnterGuard = null;
756
829
  }}
757
830
  onBeforeLeave={() => {
758
831
  contentHooks.onBeforeLeave();
@@ -763,6 +836,7 @@ export default defineComponent({
763
836
  contentHooks.onAfterLeave();
764
837
  onAfterLeaveFinalize();
765
838
  }}
839
+ onLeaveCancelled={contentHooks.onLeaveCancelled}
766
840
  >
767
841
  {props.modelValue && (
768
842
  <div
@@ -664,3 +664,59 @@ describe("HkSelectPanel desktop popout motion", () => {
664
664
  expect(hosts.at(-1)!.textContent).toContain("a");
665
665
  });
666
666
  });
667
+
668
+ describe("HkSelectPanel enter-class watchdog (frozen-rAF repair)", () => {
669
+ afterEach(() => {
670
+ vi.unstubAllGlobals();
671
+ vi.useRealTimers();
672
+ });
673
+
674
+ // Mirror of HkModal.enter-watchdog: a starved enter freezes the from
675
+ // pair on the sheet's scrim and panel (2026-09 mobile report — the
676
+ // scrim died invisible and flashed back at full opacity on close).
677
+ // The watchdog must strip the stuck classes on both layers while the
678
+ // panel stays open.
679
+ it("strips frozen enter classes on the sheet scrim and panel within the budget", async () => {
680
+ vi.useFakeTimers();
681
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
682
+ vi.stubGlobal("cancelAnimationFrame", () => {});
683
+ setViewport(375);
684
+ const { open } = mountPanel();
685
+ await nextTick();
686
+ open.value = true;
687
+ await nextTick();
688
+
689
+ const scrim = document.body.querySelector<HTMLElement>(".hk-select-sheet-scrim")!;
690
+ const panel = document.body.querySelector<HTMLElement>(".hk-select-sheet-panel")!;
691
+ expect(scrim).toBeTruthy();
692
+ expect(panel).toBeTruthy();
693
+ // The frozen enter left its from-pair on both layers.
694
+ expect(scrim.classList.contains("hk-select-sheet-scrim-enter-from")).toBe(true);
695
+ expect(panel.classList.contains("hk-select-sheet-enter-from")).toBe(true);
696
+
697
+ await vi.advanceTimersByTimeAsync(590);
698
+ expect(scrim.classList.contains("hk-select-sheet-scrim-enter-from")).toBe(true);
699
+
700
+ await vi.advanceTimersByTimeAsync(100);
701
+ expect(scrim.classList.contains("hk-select-sheet-scrim-enter-from")).toBe(false);
702
+ expect(panel.classList.contains("hk-select-sheet-enter-from")).toBe(false);
703
+ // …and the surface is still open and intact.
704
+ expect(document.body.querySelector(".hk-select-sheet-panel")).not.toBeNull();
705
+ });
706
+
707
+ it("a repaired sheet still closes normally through its scrim click", async () => {
708
+ vi.useFakeTimers();
709
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
710
+ vi.stubGlobal("cancelAnimationFrame", () => {});
711
+ setViewport(375);
712
+ const { open } = mountPanel();
713
+ await nextTick();
714
+ open.value = true;
715
+ await nextTick();
716
+ await vi.advanceTimersByTimeAsync(700);
717
+
718
+ (document.body.querySelector<HTMLElement>(".hk-select-sheet-scrim"))!.click();
719
+ await nextTick();
720
+ expect(open.value).toBe(false);
721
+ });
722
+ });
@@ -14,6 +14,7 @@ import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
14
14
  import { useOverlay } from "../runtime/useOverlay";
15
15
  import { useBreakpoint } from "../runtime/useBreakpoint";
16
16
  import { createBackGuard } from "../runtime/backStack";
17
+ import { armTransitionClassWatchdog, stripTransitionClasses } from "../runtime/transitionWatchdog";
17
18
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
18
19
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
19
20
  import { useSizeMorph } from "../composables/useSizeMorph";
@@ -120,7 +121,11 @@ export default defineComponent({
120
121
  const popoutAnim = useSurfaceTransition(250);
121
122
  const sheetScrimAnim = useSurfaceTransition(320);
122
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");
123
127
  const panelRef = ref<HTMLElement>();
128
+ const sheetScrimRef = ref<HTMLElement>();
124
129
  const sheetListRef = ref<HTMLElement>();
125
130
  /** Natural-height probe inside the sheet list: the content wrapper
126
131
  * (slot children), whose height is the content's intrinsic height
@@ -131,19 +136,57 @@ export default defineComponent({
131
136
  // content growth (a language list gaining rows, filtered options)
132
137
  // with the height transition instead of snapping.
133
138
  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;
155
+ }
134
156
  // Sheet panel transition hooks: the surface transition's own set plus
135
157
  // the size-morph lifecycle (arm after enter — pinning during the
136
- // slide-up would fight it — and release before the leave). The inner
137
- // hooks("panel") call shares the same reported track, so merging is
138
- // safe.
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");
139
161
  const sheetPanelHooks = {
140
- ...sheetPanelAnim.hooks("panel"),
141
- onAfterEnter: () => {
142
- sheetPanelAnim.hooks("panel").onAfterEnter();
143
- morph.start();
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;
185
+ morph.start();
186
+ }
144
187
  },
145
188
  onBeforeLeave: () => {
146
- sheetPanelAnim.hooks("panel").onBeforeLeave();
189
+ sheetPanelBaseHooks.onBeforeLeave();
147
190
  morph.stop();
148
191
  },
149
192
  };
@@ -388,6 +431,9 @@ export default defineComponent({
388
431
  window.removeEventListener("resize", onResize);
389
432
  detachPanelScrollbar();
390
433
  stopDupTitleSync();
434
+ // Enter cycles died with the open state — their guards must
435
+ // not fire into the leave transitions.
436
+ disarmEnterGuards();
391
437
  backGuard.release();
392
438
  if (handle.value) {
393
439
  // Unregister immediately (stacking/breadcrumb must forget the
@@ -412,6 +458,7 @@ export default defineComponent({
412
458
  window.removeEventListener("resize", onResize);
413
459
  detachPanelScrollbar();
414
460
  stopDupTitleSync();
461
+ disarmEnterGuards();
415
462
  overlay.close();
416
463
  backGuard.destroy();
417
464
  if (handle.value) {
@@ -525,9 +572,38 @@ export default defineComponent({
525
572
  panel's `hk-select-sheet` name, so the panel's
526
573
  translateY(100%) enter pair slid the dim curtain up from
527
574
  the bottom edge on phones (2026-09-06 report). */}
528
- <Transition name="hk-select-sheet-scrim" appear {...sheetScrimAnim.hooks("scrim")}>
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
+ >
529
604
  {props.open ? (
530
605
  <div
606
+ ref={sheetScrimRef}
531
607
  class="hk-select-sheet-scrim"
532
608
  style={{ zIndex: popoutZ.value - 1 }}
533
609
  onClick={close}
@@ -595,7 +671,37 @@ export default defineComponent({
595
671
  // after an auto-flip).
596
672
  return (
597
673
  <Teleport to="body">
598
- <Transition name="hk-select-popout" appear {...popoutAnim.hooks()}>
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
+ >
599
705
  {props.open ? (
600
706
  <div
601
707
  ref={popoutHostRef}
@@ -13,13 +13,21 @@
13
13
  height: 1.75rem;
14
14
  min-width: 1.75rem;
15
15
  padding: 0 var(--space-6, 0.375rem);
16
- border: 1px solid var(--border-faint, rgb(var(--color-border) / 12%));
16
+ /* Host/theme-tunable: borderless chrome sets --hk-theme-toggle-btn-border:
17
+ * transparent (e.g. the BA brand look). */
18
+ border: 1px solid var(--hk-theme-toggle-btn-border, var(--border-faint, rgb(var(--color-border) / 12%)));
17
19
  background: rgb(var(--color-surface));
18
20
  color: rgb(var(--color-text-secondary, var(--color-muted)));
19
21
  cursor: pointer;
20
22
  transition: background 0.15s ease, color 0.15s ease;
21
23
  }
22
24
 
25
+ /* Host strip under the mode group (mode-extra slot). */
26
+ .s-theme-mode-extra {
27
+ margin-top: var(--space-8, 0.5rem);
28
+ width: 100%;
29
+ }
30
+
23
31
  .s-theme-toggle-btn:hover {
24
32
  background: rgb(var(--color-primary) / 12%);
25
33
  color: rgb(var(--color-text));
@@ -228,6 +228,41 @@ describe("HkThemeToggle color-mode group", () => {
228
228
  const activeBtn = document.body.querySelector<HTMLButtonElement>(".s-theme-item-btn[data-active]");
229
229
  expect(activeBtn?.querySelector(".s-theme-item-check")).toBeTruthy();
230
230
  });
231
+ it("renders the mode-extra strip under the mode group when provided", async () => {
232
+ const container = document.createElement("div");
233
+ document.body.appendChild(container);
234
+ const app = createApp({
235
+ render: () =>
236
+ h(HkThemeToggle, { externalCustomize: true }, {
237
+ "mode-extra": () => h("div", { class: "dpi-mark", "data-x": "1" }, "dpi"),
238
+ }),
239
+ });
240
+ app.mount(container);
241
+ mounts.push({ app, container });
242
+ await settle();
243
+ openMenu(container);
244
+ await settle();
245
+
246
+ const strip = document.body.querySelector(".s-theme-menu .s-theme-mode-extra");
247
+ expect(strip).toBeTruthy();
248
+ expect(strip!.querySelector(".dpi-mark")).toBeTruthy();
249
+ // It sits inside the menu, after the mode row and before the divider.
250
+ const menu = document.body.querySelector(".s-theme-menu")!;
251
+ const children = [...menu.children].map((c) => c.className);
252
+ const extraIdx = children.indexOf("s-theme-mode-extra");
253
+ expect(extraIdx).toBeGreaterThan(children.indexOf("s-theme-mode-row"));
254
+ expect(children[extraIdx + 1]).toContain("hk-divider");
255
+ });
256
+
257
+ it("omits the mode-extra strip when no slot is provided", async () => {
258
+ let container: HTMLElement;
259
+ const mounted = mountToggle(true);
260
+ container = mounted.container;
261
+ await settle();
262
+ openMenu(container);
263
+ await settle();
264
+ expect(document.body.querySelector(".s-theme-menu .s-theme-mode-extra")).toBeNull();
265
+ });
231
266
  });
232
267
 
233
268
  describe("HkThemeToggle item slots", () => {
@@ -52,6 +52,10 @@ export interface ThemeItemScope {
52
52
  * it entirely; the built-in custom-delete overlay is suppressed, so
53
53
  * the host renders delete/edit itself where it wants them.
54
54
  *
55
+ * `mode-extra` — a host strip rendered directly under the color-mode
56
+ * group (before the divider). Mode-adjacent host chrome that is NOT a
57
+ * theme-editor concern lives here (e.g. display-scale control).
58
+ *
55
59
  * Color-mode group: the unified HTabs strip in segmented (radiogroup)
56
60
  * working mode (Auto | Light | Dark) — same pill chrome as every other
57
61
  * group. In AUTO mode the Light/Dark halves merge into the strip's
@@ -253,9 +257,16 @@ export const HkThemeToggle = defineComponent({
253
257
  </button>
254
258
  ),
255
259
  }}
256
- </HTabs>
260
+ </HTabs>
257
261
  </div>
258
262
 
263
+ {/* Host extension point directly under the mode group (P98: the
264
+ chest DPI control lives here — mode-adjacent chrome, not a theme
265
+ editor tab). Rendered only when the host provides the slot. */}
266
+ {slots["mode-extra"] ? (
267
+ <div class="s-theme-mode-extra">{slots["mode-extra"]()}</div>
268
+ ) : null}
269
+
259
270
  <HDivider spacing="md" />
260
271
 
261
272
  <div class="s-theme-menu-label">{t("hikari::theme.themes")}</div>
@@ -64,7 +64,9 @@ describe("window-layer scrim fade contract", () => {
64
64
 
65
65
  it("the select sheet scrim rides a transition name distinct from the panel's", () => {
66
66
  const src = read("HkSelectPanel.tsx");
67
- const names = Array.from(src.matchAll(/<Transition name="([^"]+)"/g), (m) => m[1]);
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]);
68
70
  expect(names).toContain("hk-select-sheet-scrim");
69
71
  expect(names).toContain("hk-select-sheet");
70
72
  expect(src).toContain('sheetScrimAnim.hooks("scrim")');
@@ -32,17 +32,19 @@ interface Harness {
32
32
  frame: HTMLElement;
33
33
  content: HTMLElement;
34
34
  setNatural(height: number): void;
35
+ setContentNatural(height: number): void;
35
36
  start(): void;
36
37
  stop(): void;
37
38
  remeasure(): void;
38
39
  }
39
40
 
40
- function mountHarness(initialHeight: number): Harness {
41
+ function mountHarness(initialHeight: number, initialContentHeight = 0): Harness {
41
42
  const container = document.createElement("div");
42
43
  document.body.appendChild(container);
43
44
  let frameEl: HTMLElement | null = null;
44
45
  let contentEl: HTMLElement | null = null;
45
46
  let natural = initialHeight;
47
+ let contentNatural = initialContentHeight;
46
48
  let morph: ReturnType<typeof useSizeMorph> | null = null;
47
49
  const app = createApp({
48
50
  setup() {
@@ -68,6 +70,12 @@ function mountHarness(initialHeight: number): Harness {
68
70
  ref: (el: unknown) => {
69
71
  content.value = (el as HTMLElement | null) ?? null;
70
72
  contentEl = content.value;
73
+ if (contentEl) {
74
+ Object.defineProperty(contentEl, "offsetHeight", {
75
+ configurable: true,
76
+ get: () => contentNatural,
77
+ });
78
+ }
71
79
  },
72
80
  class: "content",
73
81
  }, "content"),
@@ -83,6 +91,9 @@ function mountHarness(initialHeight: number): Harness {
83
91
  setNatural: (h: number) => {
84
92
  natural = h;
85
93
  },
94
+ setContentNatural: (h: number) => {
95
+ contentNatural = h;
96
+ },
86
97
  start: () => morph!.start(),
87
98
  stop: () => morph!.stop(),
88
99
  remeasure: () => morph!.remeasure(),
@@ -153,4 +164,92 @@ describe("useSizeMorph", () => {
153
164
  h.remeasure();
154
165
  expect(h.frame.style.height).toBe("");
155
166
  });
167
+
168
+ // ── Contamination guard (2026-09 mobile report) ─────────────────────
169
+ // A remeasure taken under transition-class flex pollution (e.g. a
170
+ // frozen enter's `flex: 0 0 auto` uncapping the scroll body) reads a
171
+ // "natural" height far past the content — pinning it locked a 600px
172
+ // form at 1728px with ~1100px of blank shell. The guard must release
173
+ // to auto instead of pinning.
174
+
175
+ it("releases to auto when the measurement exceeds content plus chrome", async () => {
176
+ // Rest: 600px frame around 560px content → chrome allowance ≈128px.
177
+ const h = mountHarness(600, 560);
178
+ h.start();
179
+ expect(h.frame.style.height).toBe("600px");
180
+
181
+ // Contaminated probe: the frame "naturally" 1728px, content unmoved.
182
+ h.setNatural(1728);
183
+ FakeResizeObserver.instances[0]!.callback();
184
+ await settle();
185
+ expect(h.frame.style.height).toBe("");
186
+ expect(h.frame.style.transition).toBe("");
187
+ });
188
+
189
+ it("recovers and pins again once a clean measurement returns", async () => {
190
+ const h = mountHarness(600, 560);
191
+ h.start();
192
+
193
+ h.setNatural(1728);
194
+ FakeResizeObserver.instances[0]!.callback();
195
+ await settle();
196
+ expect(h.frame.style.height).toBe("");
197
+
198
+ h.setNatural(520);
199
+ FakeResizeObserver.instances[0]!.callback();
200
+ await settle();
201
+ expect(h.frame.style.height).toBe("520px");
202
+ });
203
+
204
+ it("still pins when the content probe reads zero (no layout engine)", async () => {
205
+ // happy-dom-style degenerate probe: the guard cannot validate
206
+ // anything, so it must not block the pin (pre-guard behavior).
207
+ const h = mountHarness(160, 0);
208
+ h.start();
209
+ expect(h.frame.style.height).toBe("160px");
210
+
211
+ h.setNatural(300);
212
+ FakeResizeObserver.instances[0]!.callback();
213
+ await settle();
214
+ expect(h.frame.style.height).toBe("300px");
215
+ });
216
+
217
+ it("pins legitimate overflow growth (frame capped under content height)", async () => {
218
+ // Overflow: content taller than the (capped) frame — the delta goes
219
+ // negative at rest, the allowance floors, and later capped pins
220
+ // must never trip the guard.
221
+ const h = mountHarness(700, 900);
222
+ h.start();
223
+ expect(h.frame.style.height).toBe("700px");
224
+
225
+ h.setNatural(720);
226
+ h.setContentNatural(1200);
227
+ FakeResizeObserver.instances[0]!.callback();
228
+ await settle();
229
+ expect(h.frame.style.height).toBe("720px");
230
+ });
231
+
232
+ it("recalibrates the chrome allowance across stop/start cycles", async () => {
233
+ const h = mountHarness(600, 560);
234
+ h.start();
235
+ h.stop();
236
+
237
+ // A new open cycle with real chrome of ~200px (frame 700, content 500).
238
+ h.setNatural(700);
239
+ h.setContentNatural(500);
240
+ h.start();
241
+ expect(h.frame.style.height).toBe("700px");
242
+
243
+ // 730px is within content+chrome (500+232) → pins.
244
+ h.setNatural(730);
245
+ FakeResizeObserver.instances[0]!.callback();
246
+ await settle();
247
+ expect(h.frame.style.height).toBe("730px");
248
+
249
+ // 900px is past it → releases.
250
+ h.setNatural(900);
251
+ FakeResizeObserver.instances[0]!.callback();
252
+ await settle();
253
+ expect(h.frame.style.height).toBe("");
254
+ });
156
255
  });
@@ -1,5 +1,13 @@
1
1
  import { onBeforeUnmount, type Ref } from "vue";
2
2
 
3
+ /** Chrome-allowance calibration constants (px): the floor covers a
4
+ * standard header+footer+borders stack (and bodies that overflow at
5
+ * arm time, where the resting delta goes negative and says nothing
6
+ * about chrome); the slack absorbs subpixel/border noise so the guard
7
+ * never trips on a legitimate measurement. */
8
+ const CHROME_ALLOWANCE_FLOOR = 96;
9
+ const CHROME_ALLOWANCE_SLACK = 32;
10
+
3
11
  export interface SizeMorph {
4
12
  /** Arm the morph: observe the content and pin the frame's natural
5
13
  * height on every change. Call once the surface finished its open
@@ -54,6 +62,12 @@ export function useSizeMorph(
54
62
  let armed = false;
55
63
  /** Last pinned height (px) — the transition's "from" value. */
56
64
  let pinned = 0;
65
+ /** Contamination allowance (px): how far the frame's natural height may
66
+ * exceed the content probe at rest — its own chrome (header, footer,
67
+ * borders, CSS min-height floors), captured at arm time, floored for
68
+ * bodies that overflow at rest, plus subpixel slack. See the guard in
69
+ * remeasure(). */
70
+ let chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
57
71
 
58
72
  function release(): void {
59
73
  const f = frame.value;
@@ -61,6 +75,22 @@ export function useSizeMorph(
61
75
  pinned = 0;
62
76
  }
63
77
 
78
+ /** Calibrate the chrome allowance from the resting (unpinned, enter
79
+ * finished) frame — the only moment guaranteed free of transition-class
80
+ * flex rules. Never calibrate from a remeasure sample: the first
81
+ * remeasure can itself be the contaminated one (a frozen enter leaves
82
+ * the flex rules behind when the surface is mid-repair). */
83
+ function calibrate(): void {
84
+ const f = frame.value;
85
+ const c = content.value;
86
+ if (!f || !c) return;
87
+ const frameH = f.offsetHeight;
88
+ const contentH = c.offsetHeight;
89
+ chromeAllowance = frameH > 0 && contentH > 0 && frameH >= contentH
90
+ ? Math.max(frameH - contentH, CHROME_ALLOWANCE_FLOOR) + CHROME_ALLOWANCE_SLACK
91
+ : CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
92
+ }
93
+
64
94
  function remeasure(): void {
65
95
  if (!armed) return;
66
96
  const f = frame.value;
@@ -74,7 +104,7 @@ export function useSizeMorph(
74
104
  // 3. Flip to the NEW pin under the live transition — the computed
75
105
  // value changes old→new, so the height transition animates.
76
106
  // No paint happens between the steps: they run in one task and the
77
- // layout flushes are invisible to the screen.
107
+ // layout flushes are invisible to the screen.
78
108
  const inlineTransition = f.style.transition;
79
109
  f.style.transition = "none";
80
110
  f.style.height = "";
@@ -85,12 +115,36 @@ export function useSizeMorph(
85
115
  f.style.transition = inlineTransition;
86
116
  return;
87
117
  }
118
+ // Contamination guard: at rest the frame can only be taller than the
119
+ // content probe by its own chrome. A natural height past that bound
120
+ // describes a mid-transition state (e.g. enter/leave-class
121
+ // `flex: 0 0 auto` rules uncapping the scroll body) that never
122
+ // exists at rest — pinning it would lock a blank shell at the
123
+ // max-height cap (2026-09 mobile report: a 600px form pinned at
124
+ // 1728px with ~1100px of empty body). A zero-height probe (no layout
125
+ // engine, hidden content) validates nothing — fall through and pin.
126
+ // On a trip, release to auto; the observer re-fires on the next
127
+ // genuine content change.
128
+ if (c.offsetHeight > 0 && natural > c.offsetHeight + chromeAllowance) {
129
+ release();
130
+ f.style.transition = inlineTransition;
131
+ return;
132
+ }
88
133
  if (pinned > 0) f.style.height = `${pinned}px`;
89
134
  // Flush the old-pin state before re-enabling the transition.
90
135
  void f.offsetHeight;
91
136
  f.style.transition = inlineTransition;
92
137
  f.style.height = `${Math.round(natural)}px`;
93
138
  pinned = Math.round(natural);
139
+ // Self-heal the allowance on every VALIDATED pin: chrome that grew
140
+ // after calibration (an async footer, a header slot mounting
141
+ // mid-open) updates the baseline instead of tripping the guard on
142
+ // the next change and silently disabling the morph for the cycle.
143
+ const probeH = c.offsetHeight;
144
+ if (probeH > 0 && natural >= probeH) {
145
+ chromeAllowance =
146
+ Math.max(natural - probeH, CHROME_ALLOWANCE_FLOOR) + CHROME_ALLOWANCE_SLACK;
147
+ }
94
148
  }
95
149
 
96
150
  function onResize(): void {
@@ -114,6 +168,10 @@ export function useSizeMorph(
114
168
  function start(): void {
115
169
  if (armed) return;
116
170
  armed = true;
171
+ // Calibrate before the first pin: at this point the surface finished
172
+ // its enter (callers arm in after-enter) and sits at rest, so the
173
+ // frame-vs-content delta is pure chrome.
174
+ calibrate();
117
175
  if (typeof ResizeObserver === "undefined" || !content.value) {
118
176
  remeasure();
119
177
  return;
@@ -136,6 +194,9 @@ export function useSizeMorph(
136
194
  cancelAnimationFrame(raf);
137
195
  raf = 0;
138
196
  }
197
+ // The next start() re-calibrates against whatever chrome that open
198
+ // cycle carries.
199
+ chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
139
200
  release();
140
201
  }
141
202
 
@@ -0,0 +1,79 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { armTransitionClassWatchdog, stripTransitionClasses } from "./transitionWatchdog";
4
+
5
+ afterEach(() => {
6
+ vi.useRealTimers();
7
+ });
8
+
9
+ function makeEl(...classes: string[]): HTMLElement {
10
+ const el = document.createElement("div");
11
+ el.className = classes.join(" ");
12
+ return el;
13
+ }
14
+
15
+ describe("stripTransitionClasses", () => {
16
+ it("removes every name-prefixed transition class and keeps the rest", () => {
17
+ const el = makeEl(
18
+ "hk-modal-overlay",
19
+ "hk-modal-overlay-enter-from",
20
+ "hk-modal-overlay-enter-active",
21
+ "other-class",
22
+ );
23
+ stripTransitionClasses(el, "hk-modal-overlay");
24
+ expect(el.className).toBe("hk-modal-overlay other-class");
25
+ });
26
+
27
+ it("never strips a class merely containing the name mid-word", () => {
28
+ const el = makeEl("hk-modal-overlay-v2");
29
+ stripTransitionClasses(el, "hk-modal-overlay");
30
+ expect(el.className).toBe("hk-modal-overlay-v2");
31
+ });
32
+
33
+ it("is a no-op on a class-less element", () => {
34
+ const el = makeEl("hk-modal-overlay");
35
+ stripTransitionClasses(el, "hk-modal-overlay");
36
+ expect(el.className).toBe("hk-modal-overlay");
37
+ });
38
+ });
39
+
40
+ describe("armTransitionClassWatchdog", () => {
41
+ it("strips stuck transition classes once the budget lapses while open", () => {
42
+ vi.useFakeTimers();
43
+ const el = makeEl(
44
+ "hk-modal-overlay",
45
+ "hk-modal-overlay-enter-from",
46
+ "hk-modal-overlay-enter-active",
47
+ );
48
+ armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
49
+ vi.advanceTimersByTime(599);
50
+ expect(el.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
51
+ vi.advanceTimersByTime(2);
52
+ expect(el.className).toBe("hk-modal-overlay");
53
+ });
54
+
55
+ it("leaves the element alone once the surface closed (leave owns it)", () => {
56
+ vi.useFakeTimers();
57
+ const el = makeEl("hk-modal-overlay", "hk-modal-overlay-enter-from");
58
+ armTransitionClassWatchdog(el, "hk-modal-overlay", () => false);
59
+ vi.advanceTimersByTime(1000);
60
+ expect(el.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
61
+ });
62
+
63
+ it("disarming cancels the strip (a normal enter finalized)", () => {
64
+ vi.useFakeTimers();
65
+ const el = makeEl("hk-modal-overlay", "hk-modal-overlay-enter-active");
66
+ const disarm = armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
67
+ disarm();
68
+ vi.advanceTimersByTime(1000);
69
+ expect(el.classList.contains("hk-modal-overlay-enter-active")).toBe(true);
70
+ });
71
+
72
+ it("does nothing when no transition class is stuck", () => {
73
+ vi.useFakeTimers();
74
+ const el = makeEl("hk-modal-overlay");
75
+ armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
76
+ vi.advanceTimersByTime(1000);
77
+ expect(el.className).toBe("hk-modal-overlay");
78
+ });
79
+ });
@@ -0,0 +1,73 @@
1
+ // Transition-class repair kit — the enter-side counterpart of HkModal's
2
+ // leave watchdog.
3
+ //
4
+ // A Vue <Transition> flips enter-from → enter-to through rAF and waits on
5
+ // transitionend. When rAF starves (occluded/backgrounded webview — the
6
+ // same pathology the leave watchdog in HkModal bounds), the enter classes
7
+ // FREEZE on the element: the layer keeps `*-enter-from` (opacity: 0,
8
+ // translateY(100%), …) while the surface is logically open. The modal's
9
+ // scrim then stays invisible with the panel floating above it, and the
10
+ // eventual close flashes the resurrected curtain at full opacity — the
11
+ // 2026-09 mobile "black rectangle" report (the leave pair
12
+ // leave-from → leave-to snaps the scrim to opacity: 1 before fading).
13
+ //
14
+ // Two primitives:
15
+ // stripTransitionClasses — remove every `name-*` transition class so the
16
+ // element snaps to its resting state (no fade — this is a repair
17
+ // path, the animation already failed).
18
+ // armTransitionClassWatchdog — bounded safety net: if the element still
19
+ // carries `name-enter-*` classes once the budget lapses (larger than
20
+ // any themed duration), strip them. Normal enters disarm it; a
21
+ // completed enter is a no-op (the classes are already gone).
22
+
23
+ /** The exact set of classes Vue's <Transition name=…> stamps on an
24
+ * element — matched precisely so a hand-written class that merely
25
+ * starts with the name (e.g. `name-v2`) is never touched. */
26
+ const TRANSITION_CLASS_SUFFIXES = [
27
+ "-enter-from",
28
+ "-enter-active",
29
+ "-enter-to",
30
+ "-leave-from",
31
+ "-leave-active",
32
+ "-leave-to",
33
+ ] as const;
34
+
35
+ function transitionClassesOf(el: HTMLElement, name: string): string[] {
36
+ const names = TRANSITION_CLASS_SUFFIXES.map((suffix) => `${name}${suffix}`);
37
+ return Array.from(el.classList).filter((cls) => names.includes(cls));
38
+ }
39
+
40
+ /** Strip all `name-*` transition classes off `el`, snapping it to its
41
+ * resting (non-transition) state. Safe on class-less elements. */
42
+ export function stripTransitionClasses(el: HTMLElement, name: string): void {
43
+ for (const cls of transitionClassesOf(el, name)) el.classList.remove(cls);
44
+ }
45
+
46
+ /**
47
+ * Bound a stuck transition: if the element still carries ANY `name-*`
48
+ * transition class after `budgetMs` while the surface is open (the enter
49
+ * never finalized — or an interrupted leave left its pair behind — rAF
50
+ * starvation), strip them so the layer renders in its open state instead
51
+ * of staying frozen at a from/to pair. The budget is larger than any
52
+ * themed CSS duration, so a legitimate animation never sees the strip.
53
+ *
54
+ * `isStillOpen` gates the repair: once the surface closed, the leave owns
55
+ * the element and a late strip must not fight it.
56
+ *
57
+ * @returns the disarm function (call on after-enter / enter-cancelled /
58
+ * close / unmount).
59
+ */
60
+ export function armTransitionClassWatchdog(
61
+ el: HTMLElement,
62
+ name: string,
63
+ isStillOpen: () => boolean,
64
+ budgetMs = 600,
65
+ ): () => void {
66
+ const timer = setTimeout(() => {
67
+ if (!isStillOpen()) return;
68
+ if (transitionClassesOf(el, name).length > 0) {
69
+ stripTransitionClasses(el, name);
70
+ }
71
+ }, budgetMs);
72
+ return () => clearTimeout(timer);
73
+ }