@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.
- package/package.json +1 -1
- package/src/components/HkDrawer.tsx +13 -5
- package/src/components/HkModal.contenthold.test.tsx +156 -0
- package/src/components/HkModal.leavepin.test.tsx +169 -0
- package/src/components/HkModal.tsx +61 -11
- package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +47 -2
- package/src/components/HkModalBreadcrumb.scss +52 -11
- package/src/components/HkModalBreadcrumb.test.tsx +254 -5
- package/src/components/HkModalBreadcrumb.tsx +119 -10
- package/src/composables/useSizeMorph.test.ts +75 -3
- package/src/composables/useSizeMorph.ts +51 -4
- package/src/composables/useSurfaceContentHold.ts +58 -0
- package/src/composables/useSurfaceMachine.test.ts +50 -0
- package/src/composables/useSurfaceMachine.ts +35 -1
|
@@ -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);
|