@celestia-island/hikari 0.40.18 → 0.40.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,6 @@ import {
7
7
  ref,
8
8
  shallowRef,
9
9
  Teleport,
10
- Transition,
11
10
  watch,
12
11
  type PropType,
13
12
  } from "vue";
@@ -19,9 +18,9 @@ import { useOverlay } from "../runtime/useOverlay";
19
18
  import { usePopupManager } from "../runtime/usePopupManager";
20
19
  import { createBackGuard } from "../runtime/backStack";
21
20
  import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
22
- import { armTransitionClassWatchdog, stripTransitionClasses } from "../runtime/transitionWatchdog";
23
21
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
24
22
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
23
+ import { useSurfaceMachine } from "../composables/useSurfaceMachine";
25
24
  import { useSizeMorph } from "../composables/useSizeMorph";
26
25
  import HButton from "./HkButton";
27
26
  import HFab from "./HkFab";
@@ -167,11 +166,10 @@ export default defineComponent({
167
166
  const { t } = useI18n();
168
167
  const manager = usePopupManager();
169
168
  // Open/close motion reported into the unified animation context
170
- // (animationBus) — scrim and content on separate tracks so each
171
- // layer's report arms/cancels independently.
169
+ // (animationBus) — one track for the whole surface, armed on the
170
+ // machine's animation-phase edges.
172
171
  const surf = useSurfaceTransition(320);
173
- const overlayHooks = surf.hooks("overlay");
174
- const contentHooks = surf.hooks("content");
172
+ const surfTrack = surf.track("surface");
175
173
  const overlay = useOverlay({
176
174
  name: "hk-modal",
177
175
  // A global closeAll() must be able to actually close this modal
@@ -181,7 +179,6 @@ export default defineComponent({
181
179
  });
182
180
 
183
181
  const handle = ref<{ id: string; zIndex: number } | null>(null);
184
- const overlayRef = ref<HTMLElement>();
185
182
  const bodyRef = ref<HTMLElement>();
186
183
  const contentRef = ref<HTMLElement>();
187
184
  /** Natural-height probe inside the scroll container: the body's
@@ -192,63 +189,9 @@ export default defineComponent({
192
189
  // Content-driven size morphing: the frame follows content growth with
193
190
  // the height transition instead of snapping (see useSizeMorph).
194
191
  const morph = useSizeMorph(contentRef, innerRef);
195
- const shouldRender = ref(false);
196
192
  let previouslyFocused: HTMLElement | null = null;
197
193
  let unmounted = false;
198
194
 
199
- // ── Leave-completion watchdog ─────────────────────────────────────
200
- // The close path hands unmounting to <Transition>'s leave, whose
201
- // engine is rAF-driven: it double-raf's the leave-from → leave-to
202
- // class flip and only THEN arms its transitionend wait. In an
203
- // occluded/backgrounded webview rAF can starve for the whole leave
204
- // window, the classes freeze in leave-from/leave-active, and the
205
- // modal stays over the page forever — undismissable, only a full
206
- // reload escaped it (field-reported 2026-09). The watchdog bounds
207
- // the wait: if the leave has not finalized within a budget larger
208
- // than any themed CSS leave (--hk-modal-duration defaults to 0.25s,
209
- // themes may raise it), onAfterLeaveFinalize runs the exact same
210
- // finalization the Transition would have. Normal closes disarm it;
211
- // late or stale completions (post-watchdog real onAfterLeave, or
212
- // the open-interrupted leave Vue resolves without a cancelled flag)
213
- // are no-ops through the finalize and modelValue guards.
214
- const LEAVE_WATCHDOG_MS = 600;
215
- let leaveWatchdog: ReturnType<typeof setTimeout> | null = null;
216
- let leaveFinalized = false;
217
-
218
- function armLeaveWatchdog(): void {
219
- disarmLeaveWatchdog();
220
- leaveWatchdog = setTimeout(() => {
221
- leaveWatchdog = null;
222
- if (unmounted || props.modelValue || !shouldRender.value) return;
223
- onAfterLeaveFinalize();
224
- }, LEAVE_WATCHDOG_MS);
225
- }
226
-
227
- function disarmLeaveWatchdog(): void {
228
- if (leaveWatchdog !== null) {
229
- clearTimeout(leaveWatchdog);
230
- leaveWatchdog = null;
231
- }
232
- }
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
-
252
195
  const overlayZ = computed(() => handle.value?.zIndex ?? 0);
253
196
  const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
254
197
  const resolvedWidth = computed(() => resolveModalWidth(props.width));
@@ -331,20 +274,15 @@ export default defineComponent({
331
274
  }
332
275
 
333
276
  function onAfterLeaveFinalize() {
334
- // Finalizing while the surface is OPEN means this is a stale
335
- // completion: Vue fires the interrupted leave's onAfterLeave (no
336
- // cancelled flag) when a reopen patches over a still-live leave,
337
- // and the watchdog callback separately guards on props.modelValue.
338
- // Finalizing either of those would tear down the reopened modal.
339
- if (unmounted || props.modelValue || leaveFinalized) return;
340
- leaveFinalized = true;
341
- disarmLeaveWatchdog();
342
- disarmEnterGuards();
277
+ // The machine reaches `closed` from a closing phase exactly once
278
+ // per close cycle (its table cannot re-enter or double-fire), so
279
+ // the old finalize-once flag and stale-completion guards reduce
280
+ // to a defense-in-depth modelValue check.
281
+ if (unmounted || props.modelValue) return;
343
282
  if (handle.value) {
344
283
  manager.unregister(handle.value.id);
345
284
  handle.value = null;
346
285
  }
347
- shouldRender.value = false;
348
286
  if (previouslyFocused) {
349
287
  previouslyFocused.focus();
350
288
  previouslyFocused = null;
@@ -354,6 +292,71 @@ export default defineComponent({
354
292
  emit("afterLeave");
355
293
  }
356
294
 
295
+ // ── Surface lifecycle machine ─────────────────────────────────────
296
+ // One machine drives BOTH layers (scrim + panel) — the layer outputs
297
+ // are pure functions of the shared phase, so the divergent-layer
298
+ // states of the two-<Transition> era (scrim gone while the panel
299
+ // floats; the full-opacity close flash) are unrepresentable. The
300
+ // correctness clock is the machine's deadline timers; rAF is an
301
+ // optimization for the class flip. See runtime/surfaceMachine.ts
302
+ // for the axioms, the total transition table, and the invariants.
303
+ const overlayEl = ref<HTMLElement>();
304
+ const machine = useSurfaceMachine({
305
+ layers: [
306
+ // Budgets are the starvation-era upper bound; the driver probes
307
+ // the layers' live CSS durations (themes, reduced motion) and
308
+ // tightens the deadlines accordingly.
309
+ { prefix: "hk-modal-overlay", el: () => overlayEl.value, enterMs: () => 320, leaveMs: () => 340 },
310
+ { prefix: "hk-modal-content", el: () => contentRef.value, enterMs: () => 320, leaveMs: () => 300 },
311
+ ],
312
+ onPhase: (from, to, event) => {
313
+ if (to === "openingFrom") {
314
+ // Open-request bookkeeping (was the modelValue watcher's open
315
+ // arm): registration, overlay registry, back-guard, focus
316
+ // capture.
317
+ previouslyFocused = document.activeElement as HTMLElement | null;
318
+ if (handle.value) {
319
+ manager.unregister(handle.value.id);
320
+ }
321
+ handle.value = manager.register("modal", true, resolvedSurfaceName.value);
322
+ overlay.open();
323
+ if (backGuardEnabled() && backGuard.entries === 0) {
324
+ backGuard.push();
325
+ }
326
+ surfTrack.run();
327
+ } else if (to === "open") {
328
+ surfTrack.cancel();
329
+ onAfterEnter();
330
+ // Size morphs arm once the open choreography finished —
331
+ // pinning during enter would override its height reveal.
332
+ morph.start();
333
+ } else if (to === "closingFrom") {
334
+ // Close-request bookkeeping (was the watcher's close arm): the
335
+ // registries forget the surface at request time; the finalize
336
+ // edge below completes the teardown at leave end.
337
+ surfTrack.run();
338
+ overlay.close();
339
+ backGuard.release();
340
+ // Release the pinned height so the leave owns the frame.
341
+ morph.stop();
342
+ } else if (
343
+ to === "closed" &&
344
+ (from === "closingFrom" || from === "closingTo") &&
345
+ // UNMOUNT mid-close walks the same edge but is a TEARDOWN, not
346
+ // a finalized leave — afterLeave/focus-restore belong to the
347
+ // close lifecycle only (the machine's own unmount hook runs
348
+ // before this component's onBeforeUnmount sets `unmounted`).
349
+ event !== "UNMOUNT"
350
+ ) {
351
+ surfTrack.cancel();
352
+ onAfterLeaveFinalize();
353
+ }
354
+ // to === "closed" via UNMOUNT: teardown is owned by
355
+ // onBeforeUnmount (the machine's own unmount hook already
356
+ // cleared its clocks and walked the phase to `closed`).
357
+ },
358
+ });
359
+
357
360
  // --- Windowed mode ---
358
361
 
359
362
  function setupWindowed(container: HTMLElement) {
@@ -613,7 +616,7 @@ export default defineComponent({
613
616
  // --- Lifecycle ---
614
617
 
615
618
  // Overlay scrollbar on the scrolling body (shared chrome). The body
616
- // mounts with the modal surface (shouldRender) and survives through
619
+ // mounts with the modal surface (machine.mounted) and survives through
617
620
  // the leave transition; attach after the DOM lands, detach on close
618
621
  // and unmount so nothing leaks inside the Teleport portal.
619
622
  let bodyScrollbar: OverlayScrollbarHandle | null = null;
@@ -623,10 +626,10 @@ export default defineComponent({
623
626
  bodyScrollbar = null;
624
627
  }
625
628
 
626
- watch(shouldRender, (render) => {
629
+ watch(machine.mounted, (render) => {
627
630
  if (render) {
628
631
  void nextTick(() => {
629
- if (!shouldRender.value || !scrollContainerRef.value) return;
632
+ if (!machine.mounted.value || !scrollContainerRef.value) return;
630
633
  detachBodyScrollbar();
631
634
  bodyScrollbar = attachOverlayScrollbars(scrollContainerRef.value, { axis: "vertical" });
632
635
  });
@@ -639,37 +642,9 @@ export default defineComponent({
639
642
  () => props.modelValue,
640
643
  (val) => {
641
644
  if (unmounted) return;
642
- if (val) {
643
- previouslyFocused = document.activeElement as HTMLElement | null;
644
- if (handle.value) {
645
- manager.unregister(handle.value.id);
646
- }
647
- leaveFinalized = false;
648
- disarmLeaveWatchdog();
649
- shouldRender.value = true;
650
- // Windows always block, so the kind alone lists this layer in
651
- // the modal-stack breadcrumb on every form factor.
652
- handle.value = manager.register("modal", true, resolvedSurfaceName.value);
653
- overlay.open();
654
- if (backGuardEnabled() && backGuard.entries === 0) {
655
- backGuard.push();
656
- }
657
- } else {
658
- // Close happens via Transition onAfterLeave,
659
- // but if modelValue flips to false without Transition
660
- // (e.g. immediate), clean up now.
661
- overlay.close();
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();
666
- // Bound the Transition leave: if rAF starvation froze the
667
- // class flip, the watchdog finalizes in the watchdog's place
668
- // (see LEAVE_WATCHDOG_MS above). Only an actually-mounted
669
- // surface can stall — a never-opened modal has nothing to
670
- // finalize.
671
- if (shouldRender.value) armLeaveWatchdog();
672
- }
645
+ // The machine owns every visual/registry consequence on its
646
+ // phase edges; this watcher is purely the event feed.
647
+ machine.send(val ? "OPEN" : "CLOSE");
673
648
  },
674
649
  { immediate: true },
675
650
  );
@@ -701,8 +676,9 @@ export default defineComponent({
701
676
 
702
677
  onBeforeUnmount(() => {
703
678
  unmounted = true;
704
- disarmLeaveWatchdog();
705
- disarmEnterGuards();
679
+ // The machine's own unmount hook (registered first) already sent
680
+ // UNMOUNT — phase `closed`, clocks cleared. This is the surface's
681
+ // own teardown, idempotent with the finalize edge.
706
682
  detachBodyScrollbar();
707
683
  teardownWindowed();
708
684
  teardownAutoFollow();
@@ -711,7 +687,6 @@ export default defineComponent({
711
687
  manager.unregister(handle.value.id);
712
688
  handle.value = null;
713
689
  }
714
- shouldRender.value = false;
715
690
  });
716
691
 
717
692
  // --- Render helpers ---
@@ -742,7 +717,7 @@ export default defineComponent({
742
717
  }
743
718
 
744
719
  return () => {
745
- if (!shouldRender.value) return null;
720
+ if (!machine.mounted.value) return null;
746
721
 
747
722
  const headerShown = props.title || props.closable || slots.header || slots.headerLead;
748
723
 
@@ -753,95 +728,14 @@ export default defineComponent({
753
728
  style={{ zIndex: overlayZ.value }}
754
729
  onKeydown={onKeydown}
755
730
  >
756
- <Transition
757
- name="hk-modal-overlay"
758
- appear
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
- }}
786
- onBeforeLeave={overlayHooks.onBeforeLeave}
787
- onAfterLeave={overlayHooks.onAfterLeave}
788
- onLeaveCancelled={overlayHooks.onLeaveCancelled}
789
- >
790
- {props.modelValue && (
791
- <div
792
- ref={overlayRef}
793
- class="hk-modal-overlay"
794
- onClick={onOverlayClick}
795
- />
796
- )}
797
- </Transition>
798
- <Transition
799
- name="hk-modal-content"
800
- appear
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) => {
812
- contentHooks.onAfterEnter();
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;
829
- }}
830
- onBeforeLeave={() => {
831
- contentHooks.onBeforeLeave();
832
- // Release the pinned height so the leave owns the frame.
833
- morph.stop();
834
- }}
835
- onAfterLeave={() => {
836
- contentHooks.onAfterLeave();
837
- onAfterLeaveFinalize();
838
- }}
839
- onLeaveCancelled={contentHooks.onLeaveCancelled}
840
- >
841
- {props.modelValue && (
842
- <div
731
+ <div
732
+ ref={overlayEl}
733
+ class={["hk-modal-overlay", ...machine.classesFor("hk-modal-overlay")]}
734
+ onClick={onOverlayClick}
735
+ />
736
+ <div
843
737
  ref={contentRef}
844
- class={["hk-modal-content", props.contentClass]}
738
+ class={["hk-modal-content", props.contentClass, ...machine.classesFor("hk-modal-content")]}
845
739
  role="dialog"
846
740
  aria-modal="true"
847
741
  aria-label={resolvedSurfaceName.value}
@@ -927,8 +821,6 @@ export default defineComponent({
927
821
  </div>
928
822
  {renderFooter()}
929
823
  </div>
930
- )}
931
- </Transition>
932
824
  </div>
933
825
  </Teleport>
934
826
  );
@@ -10,6 +10,20 @@
10
10
  .hk-popover-panel {
11
11
  min-width: 0;
12
12
  max-height: min(70vh, calc(100vh - 2 * 8px));
13
+ /* Width MUST be position-independent (max-content, viewport-capped).
14
+ * The anchored panel is fixed-positioned with shrink-to-fit sizing,
15
+ * and computePosition places it as `left = anchorEnd - panelWidth`
16
+ * (bottom-end). With shrink-to-fit the width itself derives from the
17
+ * available space (`viewport - left`) — so any elastically-wide
18
+ * content (percentage-width rows) creates a geometric feedback: every
19
+ * ResizeObserver → rAF reposition adds (viewport - anchorEnd) px of
20
+ * width until the clamp saturates, and the menu visibly ratchets
21
+ * wider frame by frame (demo.dev field report 2026-09-07). Pinning
22
+ * the width to max-content breaks the loop: the panel sizes to its
23
+ * content wherever it sits, and positioning converges in one pass.
24
+ * VIEWPORT_PAD is 8 (script side). */
25
+ width: max-content;
26
+ max-width: calc(100vw - 2 * 8px);
13
27
  }
14
28
 
15
29
  /* Pop motion family tokens (--hk-pop-* in theme.scss) — the reference
@@ -91,6 +105,10 @@
91
105
  }
92
106
 
93
107
  .hk-popover-panel.hk-is-sheet {
108
+ /* The sheet spans the viewport via inline left/right — the anchored
109
+ * rule's max-content width would over-constrain it (left+right+width
110
+ * drops `right` in LTR) and dock it at content width instead. */
111
+ width: auto;
94
112
  border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))
95
113
  var(--hk-modal-radius, var(--hi-radius-lg, 12px))
96
114
  0 0;
@@ -17,4 +17,18 @@ describe("HkPopover glass layer surface hooks", () => {
17
17
  expect(block).toContain("var(--hk-popover-bg, rgb(var(--color-surface)))");
18
18
  expect(block).toContain("var(--hk-popover-blur,");
19
19
  });
20
+
21
+ // Width must stay position-independent: computePosition places the
22
+ // anchored panel at `left = anchorEnd - panelWidth` (bottom-end), so
23
+ // a shrink-to-fit (position-dependent) width creates a ResizeObserver
24
+ // feedback that ratchets the menu wider every frame with elastically-
25
+ // wide content (demo.dev field report 2026-09-07). The sheet branch
26
+ // must keep spanning via its inline left/right (width: auto).
27
+ it("pins the anchored panel width to max-content and resets the sheet", () => {
28
+ const base = src.match(/\.hk-popover-panel\s*{[^}]*}/)![0];
29
+ expect(base).toContain("width: max-content");
30
+ expect(base).toContain("max-width: calc(100vw - 2 * 8px)");
31
+ const sheet = src.match(/\.hk-popover-panel\.hk-is-sheet\s*{[^}]*}/)![0];
32
+ expect(sheet).toContain("width: auto");
33
+ });
20
34
  });