@celestia-island/hikari 0.55.0 → 0.55.2

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.
@@ -0,0 +1,58 @@
1
+ import { computed, watch, type ComputedRef } from "vue";
2
+
3
+ import type { SurfacePhase } from "../runtime/surfaceMachine";
4
+
5
+ /**
6
+ * useSurfaceContentHold — keep a surface's last live content on screen
7
+ * for the whole close fold.
8
+ *
9
+ * Consumers routinely tear down the very state that feeds a window's body
10
+ * the moment its v-model flips false (clear-on-close watchers, store
11
+ * resets, socket disconnects). Without a hold, the leave transition then
12
+ * plays on an emptied shell: the frame collapses to its chrome BEFORE the
13
+ * fold starts (chest TodoLogModal, 2026-09-16 field report — the 697px
14
+ * conversation window became a 200px stub in the same tick as the close).
15
+ *
16
+ * While the machine is in a closing phase this serves the most recent
17
+ * live-rendered children instead of re-invoking the slots, so the fold
18
+ * always plays over the content the user was actually looking at. Vue's
19
+ * patch skips identical vnode references, so the held DOM is never
20
+ * re-patched during the close; child components with their own store
21
+ * subscriptions may still self-update (harmless — the failure mode under
22
+ * repair is content VANISHING, not content ticking).
23
+ *
24
+ * The hold releases the moment the surface leaves a closing phase: a
25
+ * reopen interrupt (closing → openingFrom) serves live slots again, and
26
+ * `closed` drops the cache so a closed surface retains nothing.
27
+ */
28
+ export function useSurfaceContentHold(phase: ComputedRef<SurfacePhase>) {
29
+ const closing = computed(
30
+ () => phase.value === "closingFrom" || phase.value === "closingTo",
31
+ );
32
+
33
+ let held: unknown = null;
34
+
35
+ // A closed surface renders nothing — the cache would only pin a dead
36
+ // vnode tree. Drop it on the closed edge.
37
+ watch(phase, (p) => {
38
+ if (p === "closed") held = null;
39
+ });
40
+
41
+ /**
42
+ * Wrap one render-region producer: live while opening/open, frozen
43
+ * while closing. The first closing render falls through to live
44
+ * production when no snapshot exists (the surface opened directly into
45
+ * a close is not a real path, but the guard keeps the contract total).
46
+ */
47
+ function hold<T>(produce: () => T): T {
48
+ if (closing.value) {
49
+ if (held !== null) return held as T;
50
+ return produce();
51
+ }
52
+ const next = produce();
53
+ held = next;
54
+ return next;
55
+ }
56
+
57
+ return { hold, closing };
58
+ }
@@ -240,6 +240,56 @@ describe("useSurfaceMachine driver", () => {
240
240
  expect(rig.edges.length).toBe(edgesAtRest);
241
241
  });
242
242
 
243
+ it("transitionstart re-arms the deadline once so a late-latched transition completes", async () => {
244
+ vi.useFakeTimers();
245
+ vi.stubGlobal("requestAnimationFrame", () => 0 as unknown as number);
246
+ vi.stubGlobal("cancelAnimationFrame", () => {});
247
+ const realGCS = window.getComputedStyle.bind(window);
248
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
249
+ const style = realGCS(el as Element, ...(rest as []));
250
+ return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
251
+ });
252
+ const rig = mountRig(true);
253
+ rig.send("OPEN");
254
+ await nextTick();
255
+ await vi.advanceTimersByTimeAsync(130); // flip timer forced the FLIP
256
+ expect(rig.phase()).toBe("openingTo");
257
+ // Flip-relative deadline: 300 + 80 → settles at t≈500 unless the
258
+ // browser reports the transition ACTUALLY started late (frame
259
+ // starvation latches the transition on the first post-flip frame).
260
+ const panel = document.querySelector<HTMLElement>(".panel")!;
261
+
262
+ // t=330: the transition latches 200ms after the flip.
263
+ await vi.advanceTimersByTimeAsync(200);
264
+ panel.dispatchEvent(new Event("transitionstart", { bubbles: true }));
265
+ // → deadline re-armed to 330 + 300 + 80 = 710.
266
+
267
+ // t=390: a bubbled report from a NON-layer descendant (target filter)
268
+ // and a duplicate report on the panel (one re-arm per phase) are
269
+ // both ignored — either would push the deadline to 770.
270
+ await vi.advanceTimersByTimeAsync(60);
271
+ const child = document.createElement("span");
272
+ panel.appendChild(child);
273
+ child.dispatchEvent(new Event("transitionstart", { bubbles: true }));
274
+ panel.dispatchEvent(new Event("transitionstart", { bubbles: true }));
275
+
276
+ // The flip-relative deadline (t=500) passes without settling — the
277
+ // re-arm is real…
278
+ await vi.advanceTimersByTimeAsync(300); // t=690
279
+ expect(rig.phase()).toBe("openingTo");
280
+ // …and the latch-relative deadline completes the open (t=710), ahead
281
+ // of where a second re-arm would have put it (t=770).
282
+ await vi.advanceTimersByTimeAsync(30); // t=720
283
+ expect(rig.phase()).toBe("open");
284
+ await nextTick();
285
+ expect(scrimClasses(rig)).toEqual([]);
286
+ // No late timers fire afterwards.
287
+ const edgesAtRest = rig.edges.length;
288
+ await vi.advanceTimersByTimeAsync(5000);
289
+ expect(rig.phase()).toBe("open");
290
+ expect(rig.edges.length).toBe(edgesAtRest);
291
+ });
292
+
243
293
  it("UNMOUNT from any phase clears every clock and walks to closed", async () => {
244
294
  vi.useFakeTimers();
245
295
  const rig = mountRig();
@@ -58,7 +58,10 @@ export interface SurfaceMachine {
58
58
  * - The ONLY correctness clock is setTimeout: every animation phase arms
59
59
  * exactly one deadline timer; the frame flip is double-rAF (smooth)
60
60
  * with a flip timer as the starved fallback. Duplicate FLIPs are
61
- * absorbed by the table (idempotent self-loops).
61
+ * absorbed by the table (idempotent self-loops). transitionend (TEND)
62
+ * settles a phase early; transitionstart re-arms the deadline once so
63
+ * a frame-starved, late-latched transition still completes — both are
64
+ * advisory optimizations, never the correctness clock.
62
65
  * - Phase changes clear every pending timer/rAF before arming the next
63
66
  * phase's — at most one flip + one deadline exist at any moment, so
64
67
  * timers cannot leak or fire stale (the machine has no "late
@@ -257,6 +260,7 @@ export function useSurfaceMachine(options: SurfaceMachineOptions): SurfaceMachin
257
260
  if (measured > 0) {
258
261
  rearmDeadline(measured + slack);
259
262
  armTend(measured);
263
+ armStart(measured);
260
264
  } else {
261
265
  completeNow();
262
266
  }
@@ -264,6 +268,36 @@ export function useSurfaceMachine(options: SurfaceMachineOptions): SurfaceMachin
264
268
  }
265
269
  }
266
270
 
271
+ /** transitionstart re-arm: under frame starvation the CSS transition
272
+ * only LATCHES when the first frame after the flip is produced, while
273
+ * this phase's deadline was armed at the flip — a transition starting
274
+ * 300ms late would be cut mid-flight by its own deadline, snapping the
275
+ * surface to rest (2026-09-16 chest field report: the desktop modal
276
+ * enter freezes at its low/transparent from-pair, then jumps). When
277
+ * the browser reports the transition actually started, re-arm the
278
+ * deadline from that moment so a late-started transition still runs
279
+ * to completion. A never-started transition (frames never come) fires
280
+ * nothing — the flip-relative deadline stays the bound (A2), so this
281
+ * changes settle latency only when the animation genuinely runs.
282
+ * One re-arm per layer per phase: every animated property fires its
283
+ * own transitionstart on the same frame. */
284
+ function armStart(expectedMs: number): void {
285
+ const seen = new Set<HTMLElement>();
286
+ for (const layer of options.layers) {
287
+ const el = layer.el?.();
288
+ if (!el || !el.isConnected || seen.has(el)) continue;
289
+ seen.add(el);
290
+ let rearmed = false;
291
+ const onStart = (e: TransitionEvent) => {
292
+ if (e.target !== el || rearmed) return;
293
+ rearmed = true;
294
+ rearmDeadline(expectedMs + slack);
295
+ };
296
+ el.addEventListener("transitionstart", onStart);
297
+ tendCleanups.push(() => el.removeEventListener("transitionstart", onStart));
298
+ }
299
+ }
300
+
267
301
  function send(event: SurfaceEventType): void {
268
302
  const from = phase.value;
269
303
  const to = surfaceTransition(from, event);