@celestia-island/hikari 0.40.18 → 0.40.19

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.18",
3
+ "version": "0.40.19",
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",
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
- import { createApp, h, nextTick, ref, type Ref } from "vue";
2
+ import { createApp, h, nextTick, onMounted, ref, type Ref } from "vue";
3
3
 
4
4
  import { registerTokenGroup, useTheme, type TokenGroupDefinition } from "../theme";
5
5
  import { HkColorSchemeEditor, type HCustomTheme } from "./HkColorSchemeEditor";
@@ -267,4 +267,27 @@ describe("HkColorSchemeEditor", () => {
267
267
  expect(ref.value!.getDraft().dark.onSolidText).toEqual({ r: 255, g: 200, b: 0 });
268
268
  expect(ref.value!.getDraft().dark.onSolidIcon).toEqual({ r: 0, g: 200, b: 255 });
269
269
  });
270
+
271
+ it("hides the built-in name input under showName=false and takes setThemeName", async () => {
272
+ const container = document.createElement("div");
273
+ document.body.appendChild(container);
274
+ let exposed: { getDraft(): { name: string }; setThemeName(n: string): void } | null = null;
275
+ const app = createApp({
276
+ setup() {
277
+ const ed = ref<{ getDraft(): { name: string }; setThemeName(n: string): void } | null>(null);
278
+ onMounted(() => { exposed = ed.value; });
279
+ return () => h(HkColorSchemeEditor, { ref: ed, showName: false, initialName: "" });
280
+ },
281
+ });
282
+ app.mount(container);
283
+ mounts.push({ app, container });
284
+ await settle();
285
+
286
+ const label = [...container.querySelectorAll("label")].find((l) =>
287
+ l.textContent?.includes("Theme name") || l.textContent?.includes("主题名称"),
288
+ );
289
+ expect(label).toBeUndefined();
290
+ exposed!.setThemeName("Host owned name");
291
+ expect(exposed!.getDraft().name).toBe("Host owned name");
292
+ });
270
293
  });
@@ -147,6 +147,12 @@ export const HkColorSchemeEditor = defineComponent({
147
147
  initialGroups: { type: Object as PropType<ThemeTokenGroupModes>, default: undefined },
148
148
  /** Prefill the scheme name input (edit/fork flows); empty by default. */
149
149
  initialName: { type: String, default: "" },
150
+ /**
151
+ * Render the built-in name input. Hosts that own the name field
152
+ * elsewhere (e.g. a "basic" tab above the editor) hide it and feed the
153
+ * value through `setThemeName()` — getDraft() still carries it.
154
+ */
155
+ showName: { type: Boolean, default: true },
150
156
  },
151
157
  setup(props, { expose }) {
152
158
  const { t } = useI18n();
@@ -214,7 +220,11 @@ export const HkColorSchemeEditor = defineComponent({
214
220
 
215
221
  onMounted(() => reset());
216
222
 
217
- expose({ reset, getDraft });
223
+ function setThemeName(name: string): void {
224
+ themeName.value = name;
225
+ }
226
+
227
+ expose({ reset, getDraft, setThemeName });
218
228
 
219
229
  watch(
220
230
  () => [
@@ -357,12 +367,14 @@ export const HkColorSchemeEditor = defineComponent({
357
367
 
358
368
  return () => (
359
369
  <div class="s-scheme-dialog">
360
- <HInput
361
- modelValue={themeName.value}
362
- onUpdate:modelValue={(v: string) => { themeName.value = v; }}
363
- label={t("hikari::theme.themeName")}
364
- placeholder={t("hikari::theme.customThemeName")}
365
- />
370
+ {props.showName && (
371
+ <HInput
372
+ modelValue={themeName.value}
373
+ onUpdate:modelValue={(v: string) => { themeName.value = v; }}
374
+ label={t("hikari::theme.themeName")}
375
+ placeholder={t("hikari::theme.customThemeName")}
376
+ />
377
+ )}
366
378
  <HTabs
367
379
  variant="segmented"
368
380
  class="s-scheme-mode-switch"
@@ -6,7 +6,6 @@ import {
6
6
  onMounted,
7
7
  ref,
8
8
  Teleport,
9
- Transition,
10
9
  watch,
11
10
  type PropType,
12
11
  } from "vue";
@@ -19,6 +18,7 @@ import { usePopupManager } from "../runtime/usePopupManager";
19
18
  import { createBackGuard } from "../runtime/backStack";
20
19
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
21
20
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
21
+ import { useSurfaceMachine } from "../composables/useSurfaceMachine";
22
22
  import HIconButton from "./HkIconButton";
23
23
  import HIcon from "./HkIcon";
24
24
  import "./window-close.scss";
@@ -57,10 +57,9 @@ export default defineComponent({
57
57
  const { t } = useI18n();
58
58
  const manager = usePopupManager();
59
59
  // Open/close motion reported into the unified animation context
60
- // (animationBus) — scrim and sliding panel on separate tracks.
61
- const surf = useSurfaceTransition(320);
62
- const scrimHooks = surf.hooks("scrim");
63
- const panelHooks = surf.hooks("panel");
60
+ // (animationBus) — one track for the whole surface, armed on the
61
+ // machine's animation-phase edges.
62
+ const surfTrack = useSurfaceTransition(320).track("surface");
64
63
  const overlayHook = useOverlay({
65
64
  name: "hk-drawer",
66
65
  // A global closeAll() must be able to actually close this drawer
@@ -89,25 +88,6 @@ export default defineComponent({
89
88
  bodyScrollbar = null;
90
89
  }
91
90
 
92
- watch(() => props.modelValue, (open) => {
93
- if (unmounted) return;
94
- if (open) {
95
- void nextTick(() => {
96
- if (!props.modelValue || !bodyRef.value) return;
97
- detachBodyScrollbar();
98
- // The wrapper (not the panel) is the track host: the panel also
99
- // contains the header/footer bands, and rails spanning those
100
- // would light up in the wrong place.
101
- bodyScrollbar = attachOverlayScrollbars(bodyRef.value, {
102
- axis: "vertical",
103
- host: bodyWrapRef.value,
104
- });
105
- });
106
- } else {
107
- detachBodyScrollbar();
108
- }
109
- });
110
-
111
91
  /**
112
92
  * Window-first back priority: while this drawer is the topmost open
113
93
  * window, the back gesture closes it instead of navigating the
@@ -121,6 +101,80 @@ export default defineComponent({
121
101
  },
122
102
  });
123
103
 
104
+ // ── Surface lifecycle machine ─────────────────────────────────────
105
+ // One machine drives the overlay scrim and the sliding panel — the
106
+ // layers are outputs of the shared phase, so the divergent-layer
107
+ // states of the two-<Transition> era are unrepresentable (see
108
+ // runtime/surfaceMachine.ts). The panel prefix follows the side
109
+ // prop; all four sides share the same 0.3s CSS timing.
110
+ const overlayEl = ref<HTMLElement>();
111
+ const machine = useSurfaceMachine({
112
+ layers: [
113
+ // Budgets are the starvation-era bounds over the SCSS truths
114
+ // (overlay 0.3s/0.3s, panel slides 0.3s/0.3s); the driver probes
115
+ // the live CSS durations and tightens them.
116
+ { prefix: "hk-drawer-overlay", el: () => overlayEl.value, enterMs: () => 320, leaveMs: () => 320 },
117
+ { prefix: "hk-drawer-left", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 320 },
118
+ { prefix: "hk-drawer-right", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 320 },
119
+ { prefix: "hk-drawer-top", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 320 },
120
+ { prefix: "hk-drawer-bottom", el: () => panelRef.value, enterMs: () => 320, leaveMs: () => 320 },
121
+ ],
122
+ onPhase: (from, to, event) => {
123
+ if (to === "openingFrom") {
124
+ surfTrack.run();
125
+ cleanup();
126
+ // Register with the drawer title so the modal-stack breadcrumb
127
+ // labels this layer by name — a drawer is a window on every
128
+ // form factor and must never fall back to a generic label.
129
+ handle.value = manager.register("drawer", true, props.title);
130
+ overlayHook.open();
131
+ previouslyFocused = document.activeElement as HTMLElement | null;
132
+ if (backGuardEnabled() && backGuard.entries === 0) {
133
+ backGuard.push();
134
+ }
135
+ // Scrollbar chrome mounts with the panel (was the modelValue
136
+ // watcher's open arm, nextTick after the DOM lands).
137
+ void nextTick(() => {
138
+ // props.modelValue (not machine.mounted): a same-tick
139
+ // open→close flap is already in closingFrom — still mounted —
140
+ // and must not attach a scrollbar onto the dying surface.
141
+ if (!props.modelValue || !bodyRef.value) return;
142
+ detachBodyScrollbar();
143
+ // The wrapper (not the panel) is the track host: the panel
144
+ // also contains the header/footer bands, and rails spanning
145
+ // those would light up in the wrong place.
146
+ bodyScrollbar = attachOverlayScrollbars(bodyRef.value, {
147
+ axis: "vertical",
148
+ host: bodyWrapRef.value,
149
+ });
150
+ });
151
+ } else if (to === "open") {
152
+ surfTrack.cancel();
153
+ const el = panelRef.value;
154
+ if (el) focusFirst(el);
155
+ } else if (to === "closingFrom") {
156
+ surfTrack.run();
157
+ // Register/unregister WITH the open state so a closed-but-
158
+ // mounted drawer does not linger in the overlay registry
159
+ // (isOverlayOpen must reflect reality). The popup manager
160
+ // handle is torn down by the finalize edge below or by
161
+ // cleanup() on unmount.
162
+ overlayHook.close();
163
+ backGuard.release();
164
+ detachBodyScrollbar();
165
+ } else if (
166
+ to === "closed" &&
167
+ (from === "closingFrom" || from === "closingTo") &&
168
+ // UNMOUNT mid-close is a teardown, not a finalized leave —
169
+ // afterLeave/focus-restore belong to the close lifecycle only.
170
+ event !== "UNMOUNT"
171
+ ) {
172
+ surfTrack.cancel();
173
+ onDrawerAfterLeave();
174
+ }
175
+ },
176
+ });
177
+
124
178
  const isVertical = computed(
125
179
  () => props.side === "left" || props.side === "right",
126
180
  );
@@ -149,11 +203,6 @@ export default defineComponent({
149
203
  if (props.closable) close();
150
204
  }
151
205
 
152
- function onDrawerAfterEnter() {
153
- const el = panelRef.value;
154
- if (el) focusFirst(el);
155
- }
156
-
157
206
  function onDrawerAfterLeave() {
158
207
  cleanup();
159
208
  if (previouslyFocused) {
@@ -174,25 +223,9 @@ export default defineComponent({
174
223
  () => props.modelValue,
175
224
  (val) => {
176
225
  if (unmounted) return;
177
- if (val) {
178
- cleanup();
179
- // Register with the drawer title so the modal-stack breadcrumb
180
- // labels this layer by name — a drawer is a window on every
181
- // form factor and must never fall back to a generic label.
182
- handle.value = manager.register("drawer", true, props.title);
183
- overlayHook.open();
184
- previouslyFocused = document.activeElement as HTMLElement | null;
185
- if (backGuardEnabled() && backGuard.entries === 0) {
186
- backGuard.push();
187
- }
188
- } else {
189
- // Register/unregister WITH the open state so a closed-but-mounted
190
- // drawer does not linger in the overlay registry (isOverlayOpen
191
- // must reflect reality). The popup manager handle is torn down by
192
- // the leave transition or by cleanup() on unmount.
193
- overlayHook.close();
194
- backGuard.release();
195
- }
226
+ // The machine owns every visual/registry consequence on its
227
+ // phase edges; this watcher is purely the event feed.
228
+ machine.send(val ? "OPEN" : "CLOSE");
196
229
  },
197
230
  { immediate: true },
198
231
  );
@@ -231,42 +264,22 @@ export default defineComponent({
231
264
  cleanup();
232
265
  });
233
266
 
234
- return () => (
267
+ return () => {
268
+ if (!machine.mounted.value) return null;
269
+ const panelPrefix = `hk-drawer-${props.side}`;
270
+ return (
235
271
  <Teleport to="body">
236
- <Transition
237
- name="hk-drawer-overlay"
238
- appear
239
- onBeforeEnter={scrimHooks.onBeforeEnter}
240
- onAfterEnter={scrimHooks.onAfterEnter}
241
- onBeforeLeave={scrimHooks.onBeforeLeave}
242
- onAfterLeave={scrimHooks.onAfterLeave}
243
- >
244
- {props.modelValue && props.overlay ? (
245
- <div
246
- class="hk-drawer-overlay"
247
- style={{ zIndex: overlayZ.value }}
248
- onClick={onOverlayClick}
249
- />
250
- ) : null}
251
- </Transition>
252
- <Transition
253
- name={`hk-drawer-${props.side}`}
254
- appear
255
- onBeforeEnter={panelHooks.onBeforeEnter}
256
- onAfterEnter={() => {
257
- panelHooks.onAfterEnter();
258
- onDrawerAfterEnter();
259
- }}
260
- onBeforeLeave={panelHooks.onBeforeLeave}
261
- onAfterLeave={() => {
262
- panelHooks.onAfterLeave();
263
- onDrawerAfterLeave();
264
- }}
265
- >
266
- {props.modelValue ? (
267
- <div
272
+ {props.overlay ? (
273
+ <div
274
+ ref={overlayEl}
275
+ class={["hk-drawer-overlay", ...machine.classesFor("hk-drawer-overlay")]}
276
+ style={{ zIndex: overlayZ.value }}
277
+ onClick={onOverlayClick}
278
+ />
279
+ ) : null}
280
+ <div
268
281
  ref={panelRef}
269
- class={["hk-drawer-panel", `hk-drawer-${props.side}`, props.panelClass]}
282
+ class={["hk-drawer-panel", `hk-drawer-${props.side}`, props.panelClass, ...machine.classesFor(panelPrefix)]}
270
283
  style={panelStyle.value}
271
284
  role="dialog"
272
285
  aria-label={props.title}
@@ -304,9 +317,8 @@ export default defineComponent({
304
317
  <div class="hk-drawer-footer">{slots.footer()}</div>
305
318
  ) : null}
306
319
  </div>
307
- ) : null}
308
- </Transition>
309
320
  </Teleport>
310
- );
321
+ );
322
+ };
311
323
  },
312
324
  });
@@ -31,6 +31,7 @@ async function mountOpenModal() {
31
31
  containers.push(container);
32
32
 
33
33
  const open = ref(true);
34
+ const afterLeaveEvents: number[] = [];
34
35
  const Wrapper = defineComponent({
35
36
  setup() {
36
37
  return () =>
@@ -38,6 +39,7 @@ async function mountOpenModal() {
38
39
  modelValue: open.value,
39
40
  closable: true,
40
41
  "onUpdate:modelValue": (v: boolean) => { open.value = v; },
42
+ onAfterLeave: () => { afterLeaveEvents.push(1); },
41
43
  }, { default: () => h("div", "content") });
42
44
  },
43
45
  });
@@ -45,7 +47,11 @@ async function mountOpenModal() {
45
47
  mounts.push(app);
46
48
  app.mount(container);
47
49
  await nextTick();
48
- return { open };
50
+ return {
51
+ open,
52
+ afterLeaveEvents,
53
+ unmount: () => { app.unmount(); },
54
+ };
49
55
  }
50
56
 
51
57
  describe("HkModal enter-class watchdog", () => {
@@ -68,11 +74,11 @@ describe("HkModal enter-class watchdog", () => {
68
74
  expect(overlay!.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
69
75
  expect(content!.classList.contains("hk-modal-content-enter-from")).toBe(true);
70
76
 
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);
77
+ // Past every budget both layers rest at their open state — no
78
+ // frozen classes survive. (Environments without computed CSS
79
+ // durations complete even sooner via the probe; the property under
80
+ // test is the BOUND, not the exact frame.)
81
+ await vi.advanceTimersByTimeAsync(700);
76
82
  // Both layers snapped to their resting (class-less) state.
77
83
  expect(overlay!.className).toBe("hk-modal-overlay");
78
84
  expect(content!.className).toBe("hk-modal-content");
@@ -85,7 +91,7 @@ describe("HkModal enter-class watchdog", () => {
85
91
  // the watchdog, and no strip ever fires past the budget.
86
92
  vi.useFakeTimers();
87
93
  await mountOpenModal();
88
- await vi.advanceTimersByTimeAsync(50);
94
+ await vi.advanceTimersByTimeAsync(700);
89
95
 
90
96
  const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
91
97
  expect(overlay).not.toBeNull();
@@ -110,6 +116,26 @@ describe("HkModal enter-class watchdog", () => {
110
116
  });
111
117
  });
112
118
 
119
+ describe("HkModal teardown during the leave window", () => {
120
+ // The machine's UNMOUNT edge walks closing* → closed; that must be a
121
+ // TEARDOWN, never a finalized leave — afterLeave/focus-restore belong
122
+ // to the close lifecycle (the machine's unmount hook runs before this
123
+ // component's onBeforeUnmount flips the `unmounted` guard).
124
+ it("does not emit afterLeave when unmounted mid-close", async () => {
125
+ vi.useFakeTimers();
126
+ const rig = await mountOpenModal();
127
+ await vi.advanceTimersByTimeAsync(700); // at open rest
128
+ expect(rig.afterLeaveEvents).toHaveLength(0);
129
+
130
+ rig.open.value = false;
131
+ await nextTick();
132
+ // Mid-leave teardown (route change, parent v-if) inside the ~540ms
133
+ // closing window.
134
+ rig.unmount();
135
+ expect(rig.afterLeaveEvents).toHaveLength(0);
136
+ });
137
+ });
138
+
113
139
  describe("HkModal enter-class watchdog across an interrupted leave", () => {
114
140
  // The exact field-report sequence (2026-09-07 recording, f108-f112):
115
141
  // the modal started closing (scrim fading out), a reopen patched over
@@ -121,15 +147,16 @@ describe("HkModal enter-class watchdog across an interrupted leave", () => {
121
147
  vi.useFakeTimers();
122
148
  // Phase 1: open with WORKING rAF so the initial enter completes.
123
149
  const { open } = await mountOpenModal();
124
- await vi.advanceTimersByTimeAsync(50);
150
+ await vi.advanceTimersByTimeAsync(700);
125
151
  await nextTick();
126
152
 
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.
153
+ // Phase 2: starve rAF, start a close, and reopen on the microtask
154
+ // boundary — the leave deadline cannot have fired yet, so the
155
+ // reopen patches over the still-live leave (the recording's
156
+ // f108-f112 churn).
129
157
  freezeRaf();
130
158
  open.value = false;
131
159
  await nextTick();
132
- await vi.advanceTimersByTimeAsync(120); // leave live, nothing finalized
133
160
  open.value = true;
134
161
  await nextTick();
135
162
 
@@ -137,15 +164,11 @@ describe("HkModal enter-class watchdog across an interrupted leave", () => {
137
164
  const content = document.querySelector<HTMLElement>(".hk-modal-content")!;
138
165
  expect(overlay).not.toBeNull();
139
166
  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
167
 
148
- await vi.advanceTimersByTimeAsync(100);
168
+ // Past every budget the surface rests fully open with no frozen
169
+ // classes — repaired, and closing afterwards runs a NORMAL leave
170
+ // (fade from visible) instead of the full-opacity pop.
171
+ await vi.advanceTimersByTimeAsync(700);
149
172
  expect(overlay.className).toBe("hk-modal-overlay");
150
173
  expect(content.className).toBe("hk-modal-content");
151
174
  // The surface is still open, and closing it afterwards runs a NORMAL
@@ -61,7 +61,7 @@ describe("HkModal leave-completion watchdog", () => {
61
61
  // full reload escaped it. The watchdog must finalize within its
62
62
  // budget, unmount both surface layers, and emit afterLeave exactly
63
63
  // the way a real transition would.
64
- it("force-finalizes a stalled leave within the watchdog budget", async () => {
64
+ it("force-finalizes a stalled leave within the deadline budget", async () => {
65
65
  vi.useFakeTimers();
66
66
  freezeRaf();
67
67
  const { open, afterLeaveEvents } = await mountOpenModal();
@@ -69,12 +69,12 @@ describe("HkModal leave-completion watchdog", () => {
69
69
 
70
70
  open.value = false;
71
71
  await nextTick();
72
- // Just inside the budget nothing else can have completed the leave.
73
- await vi.advanceTimersByTimeAsync(590);
74
- await nextTick();
75
- expect(document.querySelector(".hk-modal-content")).not.toBeNull();
76
-
77
- await vi.advanceTimersByTimeAsync(100);
72
+ // Past the machine's deadline budget the surface must be gone even
73
+ // though not a single frame or transitionend ever arrived — the
74
+ // timer column alone finalizes the leave (environments with live
75
+ // CSS durations exercise the full window; without them the probe
76
+ // completes even sooner — the property is the BOUND).
77
+ await vi.advanceTimersByTimeAsync(700);
78
78
  await nextTick();
79
79
  expect(document.querySelector(".hk-modal-content")).toBeNull();
80
80
  expect(document.querySelector(".hk-modal-overlay")).toBeNull();
@@ -97,10 +97,11 @@ describe("HkModal leave-completion watchdog", () => {
97
97
 
98
98
  open.value = false;
99
99
  await nextTick();
100
- await vi.advanceTimersByTimeAsync(50); // leave live, nothing finalized
101
- open.value = true; // reopen patches over the live leave
100
+ // Reopen while the leave is still live (before any deadline timer
101
+ // fires) — the reopen patches over the in-flight close.
102
+ open.value = true;
102
103
  await nextTick();
103
- await vi.advanceTimersByTimeAsync(700); // past the watchdog budget
104
+ await vi.advanceTimersByTimeAsync(700); // past every budget
104
105
  await nextTick();
105
106
  // The reopened modal must survive both the stale onAfterLeave and
106
107
  // the (disarmed) watchdog from the aborted close.
@@ -164,16 +165,17 @@ describe("HkModal leave-completion watchdog", () => {
164
165
  expect(afterLeaveEvents).toHaveLength(2);
165
166
  });
166
167
 
167
- // Contract pin: the watchdog must stay armed on the close path and
168
- // its budget must stay bigger than any themed CSS leave
169
- // (--hk-modal-duration defaults to 0.25s; a theme may raise it). A
170
- // refactor that drops the arming — or shrinks the budget under the
171
- // CSS timing — silently re-opens the frozen-modal failure mode.
172
- it("pins the watchdog wiring and budget in the source", () => {
168
+ // Contract pin: the lifecycle machine owns finalization now. Its
169
+ // layer budgets must stay ≥ the themed CSS durations
170
+ // (--hk-modal-duration defaults to 0.3s; a theme may raise it) — a
171
+ // refactor that shrinks a budget under the CSS timing would cut the
172
+ // leave short, and one that drops the machine wiring re-opens the
173
+ // frozen-modal failure mode.
174
+ it("pins the machine wiring and deadline budgets in the source", () => {
173
175
  const src = readFileSync(join(here, "HkModal.tsx"), "utf-8");
174
- expect(src).toContain("const LEAVE_WATCHDOG_MS = 600;");
175
- expect(src).toContain("if (shouldRender.value) armLeaveWatchdog();");
176
- expect(src).toContain("disarmLeaveWatchdog();");
176
+ expect(src).toContain("useSurfaceMachine({");
177
+ expect(src).toContain('{ prefix: "hk-modal-overlay", el: () => overlayEl.value, enterMs: () => 320, leaveMs: () => 340 }');
178
+ expect(src).toContain('{ prefix: "hk-modal-content", el: () => contentRef.value, enterMs: () => 320, leaveMs: () => 300 }');
177
179
  expect(src).toContain("onAfterLeaveFinalize();");
178
180
  });
179
181
  });