@celestia-island/hikari 0.40.16 → 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.16",
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
  });
@@ -145,6 +145,14 @@ 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: "" },
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 },
148
156
  },
149
157
  setup(props, { expose }) {
150
158
  const { t } = useI18n();
@@ -153,7 +161,7 @@ export const HkColorSchemeEditor = defineComponent({
153
161
  // through a computed so config-file labels follow the live locale.
154
162
  const activeLocale = computed(() => useI18n().locale);
155
163
  const modeTab = ref<string>("dark");
156
- const themeName = ref("");
164
+ const themeName = ref(props.initialName ?? "");
157
165
 
158
166
  const dark = reactive<ThemeSchemeTokens>({ ...defaultDark });
159
167
  const light = reactive<ThemeSchemeTokens>({ ...defaultLight });
@@ -196,7 +204,7 @@ export const HkColorSchemeEditor = defineComponent({
196
204
 
197
205
  function reset(): void {
198
206
  modeTab.value = useTheme().effectiveMode.value;
199
- themeName.value = t("hikari::theme.customThemeName");
207
+ themeName.value = props.initialName ?? t("hikari::theme.customThemeName");
200
208
  Object.assign(dark, props.initialDark ?? defaultDark);
201
209
  Object.assign(light, props.initialLight ?? defaultLight);
202
210
  // Optional slots: a legacy prefill omitting them must reset to white
@@ -212,7 +220,11 @@ export const HkColorSchemeEditor = defineComponent({
212
220
 
213
221
  onMounted(() => reset());
214
222
 
215
- expose({ reset, getDraft });
223
+ function setThemeName(name: string): void {
224
+ themeName.value = name;
225
+ }
226
+
227
+ expose({ reset, getDraft, setThemeName });
216
228
 
217
229
  watch(
218
230
  () => [
@@ -355,12 +367,14 @@ export const HkColorSchemeEditor = defineComponent({
355
367
 
356
368
  return () => (
357
369
  <div class="s-scheme-dialog">
358
- <HInput
359
- modelValue={themeName.value}
360
- onUpdate:modelValue={(v: string) => { themeName.value = v; }}
361
- label={t("hikari::theme.themeName")}
362
- placeholder={t("hikari::theme.customThemeName")}
363
- />
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
+ )}
364
378
  <HTabs
365
379
  variant="segmented"
366
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
  });
@@ -0,0 +1,183 @@
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 afterLeaveEvents: number[] = [];
35
+ const Wrapper = defineComponent({
36
+ setup() {
37
+ return () =>
38
+ h(HkModal, {
39
+ modelValue: open.value,
40
+ closable: true,
41
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
42
+ onAfterLeave: () => { afterLeaveEvents.push(1); },
43
+ }, { default: () => h("div", "content") });
44
+ },
45
+ });
46
+ const app = createApp(Wrapper);
47
+ mounts.push(app);
48
+ app.mount(container);
49
+ await nextTick();
50
+ return {
51
+ open,
52
+ afterLeaveEvents,
53
+ unmount: () => { app.unmount(); },
54
+ };
55
+ }
56
+
57
+ describe("HkModal enter-class watchdog", () => {
58
+ // Regression for the 2026-09 mobile report: a starved enter froze the
59
+ // scrim's enter-from pair (opacity: 0) while the panel stayed open —
60
+ // the dim curtain vanished, and the eventual close flashed it back at
61
+ // full opacity (the "black rectangle"). The watchdog must strip the
62
+ // stuck classes on BOTH layers within its budget while the modal stays
63
+ // open and functional.
64
+ it("strips frozen enter classes on overlay and content within the budget", async () => {
65
+ vi.useFakeTimers();
66
+ freezeRaf();
67
+ await mountOpenModal();
68
+
69
+ const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
70
+ const content = document.querySelector<HTMLElement>(".hk-modal-content");
71
+ expect(overlay).not.toBeNull();
72
+ expect(content).not.toBeNull();
73
+ // The frozen enter left its from-pair on both layers.
74
+ expect(overlay!.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
75
+ expect(content!.classList.contains("hk-modal-content-enter-from")).toBe(true);
76
+
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);
82
+ // Both layers snapped to their resting (class-less) state.
83
+ expect(overlay!.className).toBe("hk-modal-overlay");
84
+ expect(content!.className).toBe("hk-modal-content");
85
+ // …and the surface is still open and intact.
86
+ expect(document.querySelector(".hk-modal-content")).not.toBeNull();
87
+ });
88
+
89
+ it("leaves a healthy enter untouched (watchdog disarmed on completion)", async () => {
90
+ // Real rAF: the enter completes on its own, the after-enter disarms
91
+ // the watchdog, and no strip ever fires past the budget.
92
+ vi.useFakeTimers();
93
+ await mountOpenModal();
94
+ await vi.advanceTimersByTimeAsync(700);
95
+
96
+ const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
97
+ expect(overlay).not.toBeNull();
98
+ expect(Array.from(overlay!.classList).some((c) => c.startsWith("hk-modal-overlay-"))).toBe(
99
+ false,
100
+ );
101
+ });
102
+
103
+ it("a repaired modal still closes normally afterwards", async () => {
104
+ vi.useFakeTimers();
105
+ freezeRaf();
106
+ const { open } = await mountOpenModal();
107
+ await vi.advanceTimersByTimeAsync(700);
108
+ expect(document.querySelector(".hk-modal-content")).not.toBeNull();
109
+
110
+ open.value = false;
111
+ await nextTick();
112
+ await vi.advanceTimersByTimeAsync(700);
113
+ await nextTick();
114
+ expect(document.querySelector(".hk-modal-content")).toBeNull();
115
+ expect(document.querySelector(".hk-modal-overlay")).toBeNull();
116
+ });
117
+ });
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
+
139
+ describe("HkModal enter-class watchdog across an interrupted leave", () => {
140
+ // The exact field-report sequence (2026-09-07 recording, f108-f112):
141
+ // the modal started closing (scrim fading out), a reopen patched over
142
+ // the still-live leave on the SAME element, and rAF starvation froze
143
+ // the re-enter's from-pair — the scrim stayed invisible for seconds
144
+ // and the eventual close flashed it back at full opacity. The
145
+ // watchdog must repair both layers of the reopened surface.
146
+ it("repairs a reopen that froze mid-re-enter on the same element", async () => {
147
+ vi.useFakeTimers();
148
+ // Phase 1: open with WORKING rAF so the initial enter completes.
149
+ const { open } = await mountOpenModal();
150
+ await vi.advanceTimersByTimeAsync(700);
151
+ await nextTick();
152
+
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).
157
+ freezeRaf();
158
+ open.value = false;
159
+ await nextTick();
160
+ open.value = true;
161
+ await nextTick();
162
+
163
+ const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay")!;
164
+ const content = document.querySelector<HTMLElement>(".hk-modal-content")!;
165
+ expect(overlay).not.toBeNull();
166
+ expect(content).not.toBeNull();
167
+
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);
172
+ expect(overlay.className).toBe("hk-modal-overlay");
173
+ expect(content.className).toBe("hk-modal-content");
174
+ // The surface is still open, and closing it afterwards runs a NORMAL
175
+ // leave (fade from visible) instead of the full-opacity pop.
176
+ open.value = false;
177
+ await nextTick();
178
+ await vi.advanceTimersByTimeAsync(700);
179
+ await nextTick();
180
+ expect(document.querySelector(".hk-modal-content")).toBeNull();
181
+ expect(document.querySelector(".hk-modal-overlay")).toBeNull();
182
+ });
183
+ });
@@ -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
  });