@celestia-island/hikari 0.40.16 → 0.40.18
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/HkColorSchemeEditor.tsx +4 -2
- package/src/components/HkModal.enter-watchdog.test.tsx +160 -0
- package/src/components/HkModal.tsx +82 -8
- package/src/components/HkSelectPanel.test.tsx +56 -0
- package/src/components/HkSelectPanel.tsx +116 -10
- package/src/components/HkThemeToggle.scss +9 -1
- package/src/components/HkThemeToggle.test.ts +35 -0
- package/src/components/HkThemeToggle.tsx +12 -1
- package/src/components/scrim-fade.contract.test.ts +3 -1
- package/src/composables/useSizeMorph.test.ts +100 -1
- package/src/composables/useSizeMorph.ts +62 -1
- package/src/runtime/transitionWatchdog.test.ts +79 -0
- package/src/runtime/transitionWatchdog.ts +73 -0
package/package.json
CHANGED
|
@@ -145,6 +145,8 @@ 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: "" },
|
|
148
150
|
},
|
|
149
151
|
setup(props, { expose }) {
|
|
150
152
|
const { t } = useI18n();
|
|
@@ -153,7 +155,7 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
153
155
|
// through a computed so config-file labels follow the live locale.
|
|
154
156
|
const activeLocale = computed(() => useI18n().locale);
|
|
155
157
|
const modeTab = ref<string>("dark");
|
|
156
|
-
const themeName = ref("");
|
|
158
|
+
const themeName = ref(props.initialName ?? "");
|
|
157
159
|
|
|
158
160
|
const dark = reactive<ThemeSchemeTokens>({ ...defaultDark });
|
|
159
161
|
const light = reactive<ThemeSchemeTokens>({ ...defaultLight });
|
|
@@ -196,7 +198,7 @@ export const HkColorSchemeEditor = defineComponent({
|
|
|
196
198
|
|
|
197
199
|
function reset(): void {
|
|
198
200
|
modeTab.value = useTheme().effectiveMode.value;
|
|
199
|
-
themeName.value = t("hikari::theme.customThemeName");
|
|
201
|
+
themeName.value = props.initialName ?? t("hikari::theme.customThemeName");
|
|
200
202
|
Object.assign(dark, props.initialDark ?? defaultDark);
|
|
201
203
|
Object.assign(light, props.initialLight ?? defaultLight);
|
|
202
204
|
// Optional slots: a legacy prefill omitting them must reset to white
|
|
@@ -0,0 +1,160 @@
|
|
|
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 Wrapper = defineComponent({
|
|
35
|
+
setup() {
|
|
36
|
+
return () =>
|
|
37
|
+
h(HkModal, {
|
|
38
|
+
modelValue: open.value,
|
|
39
|
+
closable: true,
|
|
40
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
41
|
+
}, { default: () => h("div", "content") });
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const app = createApp(Wrapper);
|
|
45
|
+
mounts.push(app);
|
|
46
|
+
app.mount(container);
|
|
47
|
+
await nextTick();
|
|
48
|
+
return { open };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("HkModal enter-class watchdog", () => {
|
|
52
|
+
// Regression for the 2026-09 mobile report: a starved enter froze the
|
|
53
|
+
// scrim's enter-from pair (opacity: 0) while the panel stayed open —
|
|
54
|
+
// the dim curtain vanished, and the eventual close flashed it back at
|
|
55
|
+
// full opacity (the "black rectangle"). The watchdog must strip the
|
|
56
|
+
// stuck classes on BOTH layers within its budget while the modal stays
|
|
57
|
+
// open and functional.
|
|
58
|
+
it("strips frozen enter classes on overlay and content within the budget", async () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
freezeRaf();
|
|
61
|
+
await mountOpenModal();
|
|
62
|
+
|
|
63
|
+
const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
|
|
64
|
+
const content = document.querySelector<HTMLElement>(".hk-modal-content");
|
|
65
|
+
expect(overlay).not.toBeNull();
|
|
66
|
+
expect(content).not.toBeNull();
|
|
67
|
+
// The frozen enter left its from-pair on both layers.
|
|
68
|
+
expect(overlay!.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
|
|
69
|
+
expect(content!.classList.contains("hk-modal-content-enter-from")).toBe(true);
|
|
70
|
+
|
|
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);
|
|
76
|
+
// Both layers snapped to their resting (class-less) state.
|
|
77
|
+
expect(overlay!.className).toBe("hk-modal-overlay");
|
|
78
|
+
expect(content!.className).toBe("hk-modal-content");
|
|
79
|
+
// …and the surface is still open and intact.
|
|
80
|
+
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("leaves a healthy enter untouched (watchdog disarmed on completion)", async () => {
|
|
84
|
+
// Real rAF: the enter completes on its own, the after-enter disarms
|
|
85
|
+
// the watchdog, and no strip ever fires past the budget.
|
|
86
|
+
vi.useFakeTimers();
|
|
87
|
+
await mountOpenModal();
|
|
88
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
89
|
+
|
|
90
|
+
const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay");
|
|
91
|
+
expect(overlay).not.toBeNull();
|
|
92
|
+
expect(Array.from(overlay!.classList).some((c) => c.startsWith("hk-modal-overlay-"))).toBe(
|
|
93
|
+
false,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("a repaired modal still closes normally afterwards", async () => {
|
|
98
|
+
vi.useFakeTimers();
|
|
99
|
+
freezeRaf();
|
|
100
|
+
const { open } = await mountOpenModal();
|
|
101
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
102
|
+
expect(document.querySelector(".hk-modal-content")).not.toBeNull();
|
|
103
|
+
|
|
104
|
+
open.value = false;
|
|
105
|
+
await nextTick();
|
|
106
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
107
|
+
await nextTick();
|
|
108
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
109
|
+
expect(document.querySelector(".hk-modal-overlay")).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("HkModal enter-class watchdog across an interrupted leave", () => {
|
|
114
|
+
// The exact field-report sequence (2026-09-07 recording, f108-f112):
|
|
115
|
+
// the modal started closing (scrim fading out), a reopen patched over
|
|
116
|
+
// the still-live leave on the SAME element, and rAF starvation froze
|
|
117
|
+
// the re-enter's from-pair — the scrim stayed invisible for seconds
|
|
118
|
+
// and the eventual close flashed it back at full opacity. The
|
|
119
|
+
// watchdog must repair both layers of the reopened surface.
|
|
120
|
+
it("repairs a reopen that froze mid-re-enter on the same element", async () => {
|
|
121
|
+
vi.useFakeTimers();
|
|
122
|
+
// Phase 1: open with WORKING rAF so the initial enter completes.
|
|
123
|
+
const { open } = await mountOpenModal();
|
|
124
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
125
|
+
await nextTick();
|
|
126
|
+
|
|
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.
|
|
129
|
+
freezeRaf();
|
|
130
|
+
open.value = false;
|
|
131
|
+
await nextTick();
|
|
132
|
+
await vi.advanceTimersByTimeAsync(120); // leave live, nothing finalized
|
|
133
|
+
open.value = true;
|
|
134
|
+
await nextTick();
|
|
135
|
+
|
|
136
|
+
const overlay = document.querySelector<HTMLElement>(".hk-modal-overlay")!;
|
|
137
|
+
const content = document.querySelector<HTMLElement>(".hk-modal-content")!;
|
|
138
|
+
expect(overlay).not.toBeNull();
|
|
139
|
+
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
|
+
|
|
148
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
149
|
+
expect(overlay.className).toBe("hk-modal-overlay");
|
|
150
|
+
expect(content.className).toBe("hk-modal-content");
|
|
151
|
+
// The surface is still open, and closing it afterwards runs a NORMAL
|
|
152
|
+
// leave (fade from visible) instead of the full-opacity pop.
|
|
153
|
+
open.value = false;
|
|
154
|
+
await nextTick();
|
|
155
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
156
|
+
await nextTick();
|
|
157
|
+
expect(document.querySelector(".hk-modal-content")).toBeNull();
|
|
158
|
+
expect(document.querySelector(".hk-modal-overlay")).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -19,6 +19,7 @@ import { useOverlay } from "../runtime/useOverlay";
|
|
|
19
19
|
import { usePopupManager } from "../runtime/usePopupManager";
|
|
20
20
|
import { createBackGuard } from "../runtime/backStack";
|
|
21
21
|
import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
22
|
+
import { armTransitionClassWatchdog, stripTransitionClasses } from "../runtime/transitionWatchdog";
|
|
22
23
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
23
24
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
24
25
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
@@ -180,6 +181,7 @@ export default defineComponent({
|
|
|
180
181
|
});
|
|
181
182
|
|
|
182
183
|
const handle = ref<{ id: string; zIndex: number } | null>(null);
|
|
184
|
+
const overlayRef = ref<HTMLElement>();
|
|
183
185
|
const bodyRef = ref<HTMLElement>();
|
|
184
186
|
const contentRef = ref<HTMLElement>();
|
|
185
187
|
/** Natural-height probe inside the scroll container: the body's
|
|
@@ -229,6 +231,24 @@ export default defineComponent({
|
|
|
229
231
|
}
|
|
230
232
|
}
|
|
231
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
|
+
|
|
232
252
|
const overlayZ = computed(() => handle.value?.zIndex ?? 0);
|
|
233
253
|
const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
|
|
234
254
|
const resolvedWidth = computed(() => resolveModalWidth(props.width));
|
|
@@ -319,6 +339,7 @@ export default defineComponent({
|
|
|
319
339
|
if (unmounted || props.modelValue || leaveFinalized) return;
|
|
320
340
|
leaveFinalized = true;
|
|
321
341
|
disarmLeaveWatchdog();
|
|
342
|
+
disarmEnterGuards();
|
|
322
343
|
if (handle.value) {
|
|
323
344
|
manager.unregister(handle.value.id);
|
|
324
345
|
handle.value = null;
|
|
@@ -639,6 +660,9 @@ export default defineComponent({
|
|
|
639
660
|
// (e.g. immediate), clean up now.
|
|
640
661
|
overlay.close();
|
|
641
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();
|
|
642
666
|
// Bound the Transition leave: if rAF starvation froze the
|
|
643
667
|
// class flip, the watchdog finalizes in the watchdog's place
|
|
644
668
|
// (see LEAVE_WATCHDOG_MS above). Only an actually-mounted
|
|
@@ -678,6 +702,7 @@ export default defineComponent({
|
|
|
678
702
|
onBeforeUnmount(() => {
|
|
679
703
|
unmounted = true;
|
|
680
704
|
disarmLeaveWatchdog();
|
|
705
|
+
disarmEnterGuards();
|
|
681
706
|
detachBodyScrollbar();
|
|
682
707
|
teardownWindowed();
|
|
683
708
|
teardownAutoFollow();
|
|
@@ -731,13 +756,40 @@ export default defineComponent({
|
|
|
731
756
|
<Transition
|
|
732
757
|
name="hk-modal-overlay"
|
|
733
758
|
appear
|
|
734
|
-
onBeforeEnter={
|
|
735
|
-
|
|
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
|
+
}}
|
|
736
786
|
onBeforeLeave={overlayHooks.onBeforeLeave}
|
|
737
787
|
onAfterLeave={overlayHooks.onAfterLeave}
|
|
788
|
+
onLeaveCancelled={overlayHooks.onLeaveCancelled}
|
|
738
789
|
>
|
|
739
790
|
{props.modelValue && (
|
|
740
791
|
<div
|
|
792
|
+
ref={overlayRef}
|
|
741
793
|
class="hk-modal-overlay"
|
|
742
794
|
onClick={onOverlayClick}
|
|
743
795
|
/>
|
|
@@ -746,13 +798,34 @@ export default defineComponent({
|
|
|
746
798
|
<Transition
|
|
747
799
|
name="hk-modal-content"
|
|
748
800
|
appear
|
|
749
|
-
onBeforeEnter={
|
|
750
|
-
|
|
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) => {
|
|
751
812
|
contentHooks.onAfterEnter();
|
|
752
|
-
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
|
|
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;
|
|
756
829
|
}}
|
|
757
830
|
onBeforeLeave={() => {
|
|
758
831
|
contentHooks.onBeforeLeave();
|
|
@@ -763,6 +836,7 @@ export default defineComponent({
|
|
|
763
836
|
contentHooks.onAfterLeave();
|
|
764
837
|
onAfterLeaveFinalize();
|
|
765
838
|
}}
|
|
839
|
+
onLeaveCancelled={contentHooks.onLeaveCancelled}
|
|
766
840
|
>
|
|
767
841
|
{props.modelValue && (
|
|
768
842
|
<div
|
|
@@ -664,3 +664,59 @@ describe("HkSelectPanel desktop popout motion", () => {
|
|
|
664
664
|
expect(hosts.at(-1)!.textContent).toContain("a");
|
|
665
665
|
});
|
|
666
666
|
});
|
|
667
|
+
|
|
668
|
+
describe("HkSelectPanel enter-class watchdog (frozen-rAF repair)", () => {
|
|
669
|
+
afterEach(() => {
|
|
670
|
+
vi.unstubAllGlobals();
|
|
671
|
+
vi.useRealTimers();
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Mirror of HkModal.enter-watchdog: a starved enter freezes the from
|
|
675
|
+
// pair on the sheet's scrim and panel (2026-09 mobile report — the
|
|
676
|
+
// scrim died invisible and flashed back at full opacity on close).
|
|
677
|
+
// The watchdog must strip the stuck classes on both layers while the
|
|
678
|
+
// panel stays open.
|
|
679
|
+
it("strips frozen enter classes on the sheet scrim and panel within the budget", async () => {
|
|
680
|
+
vi.useFakeTimers();
|
|
681
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
682
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
683
|
+
setViewport(375);
|
|
684
|
+
const { open } = mountPanel();
|
|
685
|
+
await nextTick();
|
|
686
|
+
open.value = true;
|
|
687
|
+
await nextTick();
|
|
688
|
+
|
|
689
|
+
const scrim = document.body.querySelector<HTMLElement>(".hk-select-sheet-scrim")!;
|
|
690
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-select-sheet-panel")!;
|
|
691
|
+
expect(scrim).toBeTruthy();
|
|
692
|
+
expect(panel).toBeTruthy();
|
|
693
|
+
// The frozen enter left its from-pair on both layers.
|
|
694
|
+
expect(scrim.classList.contains("hk-select-sheet-scrim-enter-from")).toBe(true);
|
|
695
|
+
expect(panel.classList.contains("hk-select-sheet-enter-from")).toBe(true);
|
|
696
|
+
|
|
697
|
+
await vi.advanceTimersByTimeAsync(590);
|
|
698
|
+
expect(scrim.classList.contains("hk-select-sheet-scrim-enter-from")).toBe(true);
|
|
699
|
+
|
|
700
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
701
|
+
expect(scrim.classList.contains("hk-select-sheet-scrim-enter-from")).toBe(false);
|
|
702
|
+
expect(panel.classList.contains("hk-select-sheet-enter-from")).toBe(false);
|
|
703
|
+
// …and the surface is still open and intact.
|
|
704
|
+
expect(document.body.querySelector(".hk-select-sheet-panel")).not.toBeNull();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("a repaired sheet still closes normally through its scrim click", async () => {
|
|
708
|
+
vi.useFakeTimers();
|
|
709
|
+
vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
|
|
710
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
711
|
+
setViewport(375);
|
|
712
|
+
const { open } = mountPanel();
|
|
713
|
+
await nextTick();
|
|
714
|
+
open.value = true;
|
|
715
|
+
await nextTick();
|
|
716
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
717
|
+
|
|
718
|
+
(document.body.querySelector<HTMLElement>(".hk-select-sheet-scrim"))!.click();
|
|
719
|
+
await nextTick();
|
|
720
|
+
expect(open.value).toBe(false);
|
|
721
|
+
});
|
|
722
|
+
});
|
|
@@ -14,6 +14,7 @@ import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
|
14
14
|
import { useOverlay } from "../runtime/useOverlay";
|
|
15
15
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
16
16
|
import { createBackGuard } from "../runtime/backStack";
|
|
17
|
+
import { armTransitionClassWatchdog, stripTransitionClasses } from "../runtime/transitionWatchdog";
|
|
17
18
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
18
19
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
19
20
|
import { useSizeMorph } from "../composables/useSizeMorph";
|
|
@@ -120,7 +121,11 @@ export default defineComponent({
|
|
|
120
121
|
const popoutAnim = useSurfaceTransition(250);
|
|
121
122
|
const sheetScrimAnim = useSurfaceTransition(320);
|
|
122
123
|
const sheetPanelAnim = useSurfaceTransition(320);
|
|
124
|
+
// Stable hook sets (one object per track, hoisted out of handlers).
|
|
125
|
+
const popoutHooks = popoutAnim.hooks();
|
|
126
|
+
const sheetScrimHooks = sheetScrimAnim.hooks("scrim");
|
|
123
127
|
const panelRef = ref<HTMLElement>();
|
|
128
|
+
const sheetScrimRef = ref<HTMLElement>();
|
|
124
129
|
const sheetListRef = ref<HTMLElement>();
|
|
125
130
|
/** Natural-height probe inside the sheet list: the content wrapper
|
|
126
131
|
* (slot children), whose height is the content's intrinsic height
|
|
@@ -131,19 +136,57 @@ export default defineComponent({
|
|
|
131
136
|
// content growth (a language list gaining rows, filtered options)
|
|
132
137
|
// with the height transition instead of snapping.
|
|
133
138
|
const morph = useSizeMorph(panelRef, sheetContentRef);
|
|
139
|
+
// Enter-class watchdogs (runtime/transitionWatchdog): a starved enter
|
|
140
|
+
// freezes the from-pair on a layer — a stuck scrim at opacity: 0 with
|
|
141
|
+
// the panel floating above it, resurrected as a full-opacity flash on
|
|
142
|
+
// close (the 2026-09 mobile report). One guard per transition track;
|
|
143
|
+
// normal enters disarm them.
|
|
144
|
+
let scrimEnterGuard: (() => void) | null = null;
|
|
145
|
+
let sheetPanelEnterGuard: (() => void) | null = null;
|
|
146
|
+
let popoutEnterGuard: (() => void) | null = null;
|
|
147
|
+
|
|
148
|
+
function disarmEnterGuards(): void {
|
|
149
|
+
scrimEnterGuard?.();
|
|
150
|
+
scrimEnterGuard = null;
|
|
151
|
+
sheetPanelEnterGuard?.();
|
|
152
|
+
sheetPanelEnterGuard = null;
|
|
153
|
+
popoutEnterGuard?.();
|
|
154
|
+
popoutEnterGuard = null;
|
|
155
|
+
}
|
|
134
156
|
// Sheet panel transition hooks: the surface transition's own set plus
|
|
135
157
|
// the size-morph lifecycle (arm after enter — pinning during the
|
|
136
|
-
// slide-up would fight it — and release before the leave). The
|
|
137
|
-
//
|
|
138
|
-
|
|
158
|
+
// slide-up would fight it — and release before the leave). The base
|
|
159
|
+
// hook set shares the same reported track, so merging is safe.
|
|
160
|
+
const sheetPanelBaseHooks = sheetPanelAnim.hooks("panel");
|
|
139
161
|
const sheetPanelHooks = {
|
|
140
|
-
...
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
162
|
+
...sheetPanelBaseHooks,
|
|
163
|
+
onBeforeEnter: (el: Element) => {
|
|
164
|
+
sheetPanelBaseHooks.onBeforeEnter();
|
|
165
|
+
stripTransitionClasses(el as HTMLElement, "hk-select-sheet");
|
|
166
|
+
sheetPanelEnterGuard?.();
|
|
167
|
+
sheetPanelEnterGuard = armTransitionClassWatchdog(
|
|
168
|
+
el as HTMLElement,
|
|
169
|
+
"hk-select-sheet",
|
|
170
|
+
() => props.open,
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
onEnterCancelled: () => {
|
|
174
|
+
sheetPanelBaseHooks.onEnterCancelled();
|
|
175
|
+
sheetPanelEnterGuard?.();
|
|
176
|
+
sheetPanelEnterGuard = null;
|
|
177
|
+
},
|
|
178
|
+
onAfterEnter: (el: Element) => {
|
|
179
|
+
sheetPanelBaseHooks.onAfterEnter();
|
|
180
|
+
// Identity gate: a stale after-enter from an interrupted cycle
|
|
181
|
+
// must neither disarm the live guard nor re-arm the morph.
|
|
182
|
+
if (el === panelRef.value) {
|
|
183
|
+
sheetPanelEnterGuard?.();
|
|
184
|
+
sheetPanelEnterGuard = null;
|
|
185
|
+
morph.start();
|
|
186
|
+
}
|
|
144
187
|
},
|
|
145
188
|
onBeforeLeave: () => {
|
|
146
|
-
|
|
189
|
+
sheetPanelBaseHooks.onBeforeLeave();
|
|
147
190
|
morph.stop();
|
|
148
191
|
},
|
|
149
192
|
};
|
|
@@ -388,6 +431,9 @@ export default defineComponent({
|
|
|
388
431
|
window.removeEventListener("resize", onResize);
|
|
389
432
|
detachPanelScrollbar();
|
|
390
433
|
stopDupTitleSync();
|
|
434
|
+
// Enter cycles died with the open state — their guards must
|
|
435
|
+
// not fire into the leave transitions.
|
|
436
|
+
disarmEnterGuards();
|
|
391
437
|
backGuard.release();
|
|
392
438
|
if (handle.value) {
|
|
393
439
|
// Unregister immediately (stacking/breadcrumb must forget the
|
|
@@ -412,6 +458,7 @@ export default defineComponent({
|
|
|
412
458
|
window.removeEventListener("resize", onResize);
|
|
413
459
|
detachPanelScrollbar();
|
|
414
460
|
stopDupTitleSync();
|
|
461
|
+
disarmEnterGuards();
|
|
415
462
|
overlay.close();
|
|
416
463
|
backGuard.destroy();
|
|
417
464
|
if (handle.value) {
|
|
@@ -525,9 +572,38 @@ export default defineComponent({
|
|
|
525
572
|
panel's `hk-select-sheet` name, so the panel's
|
|
526
573
|
translateY(100%) enter pair slid the dim curtain up from
|
|
527
574
|
the bottom edge on phones (2026-09-06 report). */}
|
|
528
|
-
<Transition
|
|
575
|
+
<Transition
|
|
576
|
+
name="hk-select-sheet-scrim"
|
|
577
|
+
appear
|
|
578
|
+
onBeforeEnter={(el: Element) => {
|
|
579
|
+
sheetScrimHooks.onBeforeEnter();
|
|
580
|
+
stripTransitionClasses(el as HTMLElement, "hk-select-sheet-scrim");
|
|
581
|
+
scrimEnterGuard?.();
|
|
582
|
+
scrimEnterGuard = armTransitionClassWatchdog(
|
|
583
|
+
el as HTMLElement,
|
|
584
|
+
"hk-select-sheet-scrim",
|
|
585
|
+
() => props.open,
|
|
586
|
+
);
|
|
587
|
+
}}
|
|
588
|
+
onAfterEnter={(el: Element) => {
|
|
589
|
+
sheetScrimHooks.onAfterEnter();
|
|
590
|
+
if (el === sheetScrimRef.value) {
|
|
591
|
+
scrimEnterGuard?.();
|
|
592
|
+
scrimEnterGuard = null;
|
|
593
|
+
}
|
|
594
|
+
}}
|
|
595
|
+
onEnterCancelled={() => {
|
|
596
|
+
sheetScrimHooks.onEnterCancelled();
|
|
597
|
+
scrimEnterGuard?.();
|
|
598
|
+
scrimEnterGuard = null;
|
|
599
|
+
}}
|
|
600
|
+
onBeforeLeave={sheetScrimHooks.onBeforeLeave}
|
|
601
|
+
onAfterLeave={sheetScrimHooks.onAfterLeave}
|
|
602
|
+
onLeaveCancelled={sheetScrimHooks.onLeaveCancelled}
|
|
603
|
+
>
|
|
529
604
|
{props.open ? (
|
|
530
605
|
<div
|
|
606
|
+
ref={sheetScrimRef}
|
|
531
607
|
class="hk-select-sheet-scrim"
|
|
532
608
|
style={{ zIndex: popoutZ.value - 1 }}
|
|
533
609
|
onClick={close}
|
|
@@ -595,7 +671,37 @@ export default defineComponent({
|
|
|
595
671
|
// after an auto-flip).
|
|
596
672
|
return (
|
|
597
673
|
<Teleport to="body">
|
|
598
|
-
<Transition
|
|
674
|
+
<Transition
|
|
675
|
+
name="hk-select-popout"
|
|
676
|
+
appear
|
|
677
|
+
onBeforeEnter={(el: Element) => {
|
|
678
|
+
popoutHooks.onBeforeEnter();
|
|
679
|
+
stripTransitionClasses(el as HTMLElement, "hk-select-popout");
|
|
680
|
+
popoutEnterGuard?.();
|
|
681
|
+
popoutEnterGuard = armTransitionClassWatchdog(
|
|
682
|
+
el as HTMLElement,
|
|
683
|
+
"hk-select-popout",
|
|
684
|
+
() => props.open,
|
|
685
|
+
);
|
|
686
|
+
}}
|
|
687
|
+
onAfterEnter={(el: Element) => {
|
|
688
|
+
popoutHooks.onAfterEnter();
|
|
689
|
+
// Identity-gated disarm: a stale after-enter from an
|
|
690
|
+
// interrupted cycle must not disarm the live cycle's guard.
|
|
691
|
+
if (el === popoutHostRef.value) {
|
|
692
|
+
popoutEnterGuard?.();
|
|
693
|
+
popoutEnterGuard = null;
|
|
694
|
+
}
|
|
695
|
+
}}
|
|
696
|
+
onEnterCancelled={() => {
|
|
697
|
+
popoutHooks.onEnterCancelled();
|
|
698
|
+
popoutEnterGuard?.();
|
|
699
|
+
popoutEnterGuard = null;
|
|
700
|
+
}}
|
|
701
|
+
onBeforeLeave={popoutHooks.onBeforeLeave}
|
|
702
|
+
onAfterLeave={popoutHooks.onAfterLeave}
|
|
703
|
+
onLeaveCancelled={popoutHooks.onLeaveCancelled}
|
|
704
|
+
>
|
|
599
705
|
{props.open ? (
|
|
600
706
|
<div
|
|
601
707
|
ref={popoutHostRef}
|
|
@@ -13,13 +13,21 @@
|
|
|
13
13
|
height: 1.75rem;
|
|
14
14
|
min-width: 1.75rem;
|
|
15
15
|
padding: 0 var(--space-6, 0.375rem);
|
|
16
|
-
|
|
16
|
+
/* Host/theme-tunable: borderless chrome sets --hk-theme-toggle-btn-border:
|
|
17
|
+
* transparent (e.g. the BA brand look). */
|
|
18
|
+
border: 1px solid var(--hk-theme-toggle-btn-border, var(--border-faint, rgb(var(--color-border) / 12%)));
|
|
17
19
|
background: rgb(var(--color-surface));
|
|
18
20
|
color: rgb(var(--color-text-secondary, var(--color-muted)));
|
|
19
21
|
cursor: pointer;
|
|
20
22
|
transition: background 0.15s ease, color 0.15s ease;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/* Host strip under the mode group (mode-extra slot). */
|
|
26
|
+
.s-theme-mode-extra {
|
|
27
|
+
margin-top: var(--space-8, 0.5rem);
|
|
28
|
+
width: 100%;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
.s-theme-toggle-btn:hover {
|
|
24
32
|
background: rgb(var(--color-primary) / 12%);
|
|
25
33
|
color: rgb(var(--color-text));
|
|
@@ -228,6 +228,41 @@ describe("HkThemeToggle color-mode group", () => {
|
|
|
228
228
|
const activeBtn = document.body.querySelector<HTMLButtonElement>(".s-theme-item-btn[data-active]");
|
|
229
229
|
expect(activeBtn?.querySelector(".s-theme-item-check")).toBeTruthy();
|
|
230
230
|
});
|
|
231
|
+
it("renders the mode-extra strip under the mode group when provided", async () => {
|
|
232
|
+
const container = document.createElement("div");
|
|
233
|
+
document.body.appendChild(container);
|
|
234
|
+
const app = createApp({
|
|
235
|
+
render: () =>
|
|
236
|
+
h(HkThemeToggle, { externalCustomize: true }, {
|
|
237
|
+
"mode-extra": () => h("div", { class: "dpi-mark", "data-x": "1" }, "dpi"),
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
app.mount(container);
|
|
241
|
+
mounts.push({ app, container });
|
|
242
|
+
await settle();
|
|
243
|
+
openMenu(container);
|
|
244
|
+
await settle();
|
|
245
|
+
|
|
246
|
+
const strip = document.body.querySelector(".s-theme-menu .s-theme-mode-extra");
|
|
247
|
+
expect(strip).toBeTruthy();
|
|
248
|
+
expect(strip!.querySelector(".dpi-mark")).toBeTruthy();
|
|
249
|
+
// It sits inside the menu, after the mode row and before the divider.
|
|
250
|
+
const menu = document.body.querySelector(".s-theme-menu")!;
|
|
251
|
+
const children = [...menu.children].map((c) => c.className);
|
|
252
|
+
const extraIdx = children.indexOf("s-theme-mode-extra");
|
|
253
|
+
expect(extraIdx).toBeGreaterThan(children.indexOf("s-theme-mode-row"));
|
|
254
|
+
expect(children[extraIdx + 1]).toContain("hk-divider");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("omits the mode-extra strip when no slot is provided", async () => {
|
|
258
|
+
let container: HTMLElement;
|
|
259
|
+
const mounted = mountToggle(true);
|
|
260
|
+
container = mounted.container;
|
|
261
|
+
await settle();
|
|
262
|
+
openMenu(container);
|
|
263
|
+
await settle();
|
|
264
|
+
expect(document.body.querySelector(".s-theme-menu .s-theme-mode-extra")).toBeNull();
|
|
265
|
+
});
|
|
231
266
|
});
|
|
232
267
|
|
|
233
268
|
describe("HkThemeToggle item slots", () => {
|
|
@@ -52,6 +52,10 @@ export interface ThemeItemScope {
|
|
|
52
52
|
* it entirely; the built-in custom-delete overlay is suppressed, so
|
|
53
53
|
* the host renders delete/edit itself where it wants them.
|
|
54
54
|
*
|
|
55
|
+
* `mode-extra` — a host strip rendered directly under the color-mode
|
|
56
|
+
* group (before the divider). Mode-adjacent host chrome that is NOT a
|
|
57
|
+
* theme-editor concern lives here (e.g. display-scale control).
|
|
58
|
+
*
|
|
55
59
|
* Color-mode group: the unified HTabs strip in segmented (radiogroup)
|
|
56
60
|
* working mode (Auto | Light | Dark) — same pill chrome as every other
|
|
57
61
|
* group. In AUTO mode the Light/Dark halves merge into the strip's
|
|
@@ -253,9 +257,16 @@ export const HkThemeToggle = defineComponent({
|
|
|
253
257
|
</button>
|
|
254
258
|
),
|
|
255
259
|
}}
|
|
256
|
-
|
|
260
|
+
</HTabs>
|
|
257
261
|
</div>
|
|
258
262
|
|
|
263
|
+
{/* Host extension point directly under the mode group (P98: the
|
|
264
|
+
chest DPI control lives here — mode-adjacent chrome, not a theme
|
|
265
|
+
editor tab). Rendered only when the host provides the slot. */}
|
|
266
|
+
{slots["mode-extra"] ? (
|
|
267
|
+
<div class="s-theme-mode-extra">{slots["mode-extra"]()}</div>
|
|
268
|
+
) : null}
|
|
269
|
+
|
|
259
270
|
<HDivider spacing="md" />
|
|
260
271
|
|
|
261
272
|
<div class="s-theme-menu-label">{t("hikari::theme.themes")}</div>
|
|
@@ -64,7 +64,9 @@ describe("window-layer scrim fade contract", () => {
|
|
|
64
64
|
|
|
65
65
|
it("the select sheet scrim rides a transition name distinct from the panel's", () => {
|
|
66
66
|
const src = read("HkSelectPanel.tsx");
|
|
67
|
-
|
|
67
|
+
// \s+ across the attribute list: the tag may be formatted with name
|
|
68
|
+
// on its own line.
|
|
69
|
+
const names = Array.from(src.matchAll(/<Transition\s+name="([^"]+)"/g), (m) => m[1]);
|
|
68
70
|
expect(names).toContain("hk-select-sheet-scrim");
|
|
69
71
|
expect(names).toContain("hk-select-sheet");
|
|
70
72
|
expect(src).toContain('sheetScrimAnim.hooks("scrim")');
|
|
@@ -32,17 +32,19 @@ interface Harness {
|
|
|
32
32
|
frame: HTMLElement;
|
|
33
33
|
content: HTMLElement;
|
|
34
34
|
setNatural(height: number): void;
|
|
35
|
+
setContentNatural(height: number): void;
|
|
35
36
|
start(): void;
|
|
36
37
|
stop(): void;
|
|
37
38
|
remeasure(): void;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
function mountHarness(initialHeight: number): Harness {
|
|
41
|
+
function mountHarness(initialHeight: number, initialContentHeight = 0): Harness {
|
|
41
42
|
const container = document.createElement("div");
|
|
42
43
|
document.body.appendChild(container);
|
|
43
44
|
let frameEl: HTMLElement | null = null;
|
|
44
45
|
let contentEl: HTMLElement | null = null;
|
|
45
46
|
let natural = initialHeight;
|
|
47
|
+
let contentNatural = initialContentHeight;
|
|
46
48
|
let morph: ReturnType<typeof useSizeMorph> | null = null;
|
|
47
49
|
const app = createApp({
|
|
48
50
|
setup() {
|
|
@@ -68,6 +70,12 @@ function mountHarness(initialHeight: number): Harness {
|
|
|
68
70
|
ref: (el: unknown) => {
|
|
69
71
|
content.value = (el as HTMLElement | null) ?? null;
|
|
70
72
|
contentEl = content.value;
|
|
73
|
+
if (contentEl) {
|
|
74
|
+
Object.defineProperty(contentEl, "offsetHeight", {
|
|
75
|
+
configurable: true,
|
|
76
|
+
get: () => contentNatural,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
71
79
|
},
|
|
72
80
|
class: "content",
|
|
73
81
|
}, "content"),
|
|
@@ -83,6 +91,9 @@ function mountHarness(initialHeight: number): Harness {
|
|
|
83
91
|
setNatural: (h: number) => {
|
|
84
92
|
natural = h;
|
|
85
93
|
},
|
|
94
|
+
setContentNatural: (h: number) => {
|
|
95
|
+
contentNatural = h;
|
|
96
|
+
},
|
|
86
97
|
start: () => morph!.start(),
|
|
87
98
|
stop: () => morph!.stop(),
|
|
88
99
|
remeasure: () => morph!.remeasure(),
|
|
@@ -153,4 +164,92 @@ describe("useSizeMorph", () => {
|
|
|
153
164
|
h.remeasure();
|
|
154
165
|
expect(h.frame.style.height).toBe("");
|
|
155
166
|
});
|
|
167
|
+
|
|
168
|
+
// ── Contamination guard (2026-09 mobile report) ─────────────────────
|
|
169
|
+
// A remeasure taken under transition-class flex pollution (e.g. a
|
|
170
|
+
// frozen enter's `flex: 0 0 auto` uncapping the scroll body) reads a
|
|
171
|
+
// "natural" height far past the content — pinning it locked a 600px
|
|
172
|
+
// form at 1728px with ~1100px of blank shell. The guard must release
|
|
173
|
+
// to auto instead of pinning.
|
|
174
|
+
|
|
175
|
+
it("releases to auto when the measurement exceeds content plus chrome", async () => {
|
|
176
|
+
// Rest: 600px frame around 560px content → chrome allowance ≈128px.
|
|
177
|
+
const h = mountHarness(600, 560);
|
|
178
|
+
h.start();
|
|
179
|
+
expect(h.frame.style.height).toBe("600px");
|
|
180
|
+
|
|
181
|
+
// Contaminated probe: the frame "naturally" 1728px, content unmoved.
|
|
182
|
+
h.setNatural(1728);
|
|
183
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
184
|
+
await settle();
|
|
185
|
+
expect(h.frame.style.height).toBe("");
|
|
186
|
+
expect(h.frame.style.transition).toBe("");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("recovers and pins again once a clean measurement returns", async () => {
|
|
190
|
+
const h = mountHarness(600, 560);
|
|
191
|
+
h.start();
|
|
192
|
+
|
|
193
|
+
h.setNatural(1728);
|
|
194
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
195
|
+
await settle();
|
|
196
|
+
expect(h.frame.style.height).toBe("");
|
|
197
|
+
|
|
198
|
+
h.setNatural(520);
|
|
199
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
200
|
+
await settle();
|
|
201
|
+
expect(h.frame.style.height).toBe("520px");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("still pins when the content probe reads zero (no layout engine)", async () => {
|
|
205
|
+
// happy-dom-style degenerate probe: the guard cannot validate
|
|
206
|
+
// anything, so it must not block the pin (pre-guard behavior).
|
|
207
|
+
const h = mountHarness(160, 0);
|
|
208
|
+
h.start();
|
|
209
|
+
expect(h.frame.style.height).toBe("160px");
|
|
210
|
+
|
|
211
|
+
h.setNatural(300);
|
|
212
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
213
|
+
await settle();
|
|
214
|
+
expect(h.frame.style.height).toBe("300px");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("pins legitimate overflow growth (frame capped under content height)", async () => {
|
|
218
|
+
// Overflow: content taller than the (capped) frame — the delta goes
|
|
219
|
+
// negative at rest, the allowance floors, and later capped pins
|
|
220
|
+
// must never trip the guard.
|
|
221
|
+
const h = mountHarness(700, 900);
|
|
222
|
+
h.start();
|
|
223
|
+
expect(h.frame.style.height).toBe("700px");
|
|
224
|
+
|
|
225
|
+
h.setNatural(720);
|
|
226
|
+
h.setContentNatural(1200);
|
|
227
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
228
|
+
await settle();
|
|
229
|
+
expect(h.frame.style.height).toBe("720px");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("recalibrates the chrome allowance across stop/start cycles", async () => {
|
|
233
|
+
const h = mountHarness(600, 560);
|
|
234
|
+
h.start();
|
|
235
|
+
h.stop();
|
|
236
|
+
|
|
237
|
+
// A new open cycle with real chrome of ~200px (frame 700, content 500).
|
|
238
|
+
h.setNatural(700);
|
|
239
|
+
h.setContentNatural(500);
|
|
240
|
+
h.start();
|
|
241
|
+
expect(h.frame.style.height).toBe("700px");
|
|
242
|
+
|
|
243
|
+
// 730px is within content+chrome (500+232) → pins.
|
|
244
|
+
h.setNatural(730);
|
|
245
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
246
|
+
await settle();
|
|
247
|
+
expect(h.frame.style.height).toBe("730px");
|
|
248
|
+
|
|
249
|
+
// 900px is past it → releases.
|
|
250
|
+
h.setNatural(900);
|
|
251
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
252
|
+
await settle();
|
|
253
|
+
expect(h.frame.style.height).toBe("");
|
|
254
|
+
});
|
|
156
255
|
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { onBeforeUnmount, type Ref } from "vue";
|
|
2
2
|
|
|
3
|
+
/** Chrome-allowance calibration constants (px): the floor covers a
|
|
4
|
+
* standard header+footer+borders stack (and bodies that overflow at
|
|
5
|
+
* arm time, where the resting delta goes negative and says nothing
|
|
6
|
+
* about chrome); the slack absorbs subpixel/border noise so the guard
|
|
7
|
+
* never trips on a legitimate measurement. */
|
|
8
|
+
const CHROME_ALLOWANCE_FLOOR = 96;
|
|
9
|
+
const CHROME_ALLOWANCE_SLACK = 32;
|
|
10
|
+
|
|
3
11
|
export interface SizeMorph {
|
|
4
12
|
/** Arm the morph: observe the content and pin the frame's natural
|
|
5
13
|
* height on every change. Call once the surface finished its open
|
|
@@ -54,6 +62,12 @@ export function useSizeMorph(
|
|
|
54
62
|
let armed = false;
|
|
55
63
|
/** Last pinned height (px) — the transition's "from" value. */
|
|
56
64
|
let pinned = 0;
|
|
65
|
+
/** Contamination allowance (px): how far the frame's natural height may
|
|
66
|
+
* exceed the content probe at rest — its own chrome (header, footer,
|
|
67
|
+
* borders, CSS min-height floors), captured at arm time, floored for
|
|
68
|
+
* bodies that overflow at rest, plus subpixel slack. See the guard in
|
|
69
|
+
* remeasure(). */
|
|
70
|
+
let chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
|
|
57
71
|
|
|
58
72
|
function release(): void {
|
|
59
73
|
const f = frame.value;
|
|
@@ -61,6 +75,22 @@ export function useSizeMorph(
|
|
|
61
75
|
pinned = 0;
|
|
62
76
|
}
|
|
63
77
|
|
|
78
|
+
/** Calibrate the chrome allowance from the resting (unpinned, enter
|
|
79
|
+
* finished) frame — the only moment guaranteed free of transition-class
|
|
80
|
+
* flex rules. Never calibrate from a remeasure sample: the first
|
|
81
|
+
* remeasure can itself be the contaminated one (a frozen enter leaves
|
|
82
|
+
* the flex rules behind when the surface is mid-repair). */
|
|
83
|
+
function calibrate(): void {
|
|
84
|
+
const f = frame.value;
|
|
85
|
+
const c = content.value;
|
|
86
|
+
if (!f || !c) return;
|
|
87
|
+
const frameH = f.offsetHeight;
|
|
88
|
+
const contentH = c.offsetHeight;
|
|
89
|
+
chromeAllowance = frameH > 0 && contentH > 0 && frameH >= contentH
|
|
90
|
+
? Math.max(frameH - contentH, CHROME_ALLOWANCE_FLOOR) + CHROME_ALLOWANCE_SLACK
|
|
91
|
+
: CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
|
|
92
|
+
}
|
|
93
|
+
|
|
64
94
|
function remeasure(): void {
|
|
65
95
|
if (!armed) return;
|
|
66
96
|
const f = frame.value;
|
|
@@ -74,7 +104,7 @@ export function useSizeMorph(
|
|
|
74
104
|
// 3. Flip to the NEW pin under the live transition — the computed
|
|
75
105
|
// value changes old→new, so the height transition animates.
|
|
76
106
|
// No paint happens between the steps: they run in one task and the
|
|
77
|
-
//
|
|
107
|
+
// layout flushes are invisible to the screen.
|
|
78
108
|
const inlineTransition = f.style.transition;
|
|
79
109
|
f.style.transition = "none";
|
|
80
110
|
f.style.height = "";
|
|
@@ -85,12 +115,36 @@ export function useSizeMorph(
|
|
|
85
115
|
f.style.transition = inlineTransition;
|
|
86
116
|
return;
|
|
87
117
|
}
|
|
118
|
+
// Contamination guard: at rest the frame can only be taller than the
|
|
119
|
+
// content probe by its own chrome. A natural height past that bound
|
|
120
|
+
// describes a mid-transition state (e.g. enter/leave-class
|
|
121
|
+
// `flex: 0 0 auto` rules uncapping the scroll body) that never
|
|
122
|
+
// exists at rest — pinning it would lock a blank shell at the
|
|
123
|
+
// max-height cap (2026-09 mobile report: a 600px form pinned at
|
|
124
|
+
// 1728px with ~1100px of empty body). A zero-height probe (no layout
|
|
125
|
+
// engine, hidden content) validates nothing — fall through and pin.
|
|
126
|
+
// On a trip, release to auto; the observer re-fires on the next
|
|
127
|
+
// genuine content change.
|
|
128
|
+
if (c.offsetHeight > 0 && natural > c.offsetHeight + chromeAllowance) {
|
|
129
|
+
release();
|
|
130
|
+
f.style.transition = inlineTransition;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
88
133
|
if (pinned > 0) f.style.height = `${pinned}px`;
|
|
89
134
|
// Flush the old-pin state before re-enabling the transition.
|
|
90
135
|
void f.offsetHeight;
|
|
91
136
|
f.style.transition = inlineTransition;
|
|
92
137
|
f.style.height = `${Math.round(natural)}px`;
|
|
93
138
|
pinned = Math.round(natural);
|
|
139
|
+
// Self-heal the allowance on every VALIDATED pin: chrome that grew
|
|
140
|
+
// after calibration (an async footer, a header slot mounting
|
|
141
|
+
// mid-open) updates the baseline instead of tripping the guard on
|
|
142
|
+
// the next change and silently disabling the morph for the cycle.
|
|
143
|
+
const probeH = c.offsetHeight;
|
|
144
|
+
if (probeH > 0 && natural >= probeH) {
|
|
145
|
+
chromeAllowance =
|
|
146
|
+
Math.max(natural - probeH, CHROME_ALLOWANCE_FLOOR) + CHROME_ALLOWANCE_SLACK;
|
|
147
|
+
}
|
|
94
148
|
}
|
|
95
149
|
|
|
96
150
|
function onResize(): void {
|
|
@@ -114,6 +168,10 @@ export function useSizeMorph(
|
|
|
114
168
|
function start(): void {
|
|
115
169
|
if (armed) return;
|
|
116
170
|
armed = true;
|
|
171
|
+
// Calibrate before the first pin: at this point the surface finished
|
|
172
|
+
// its enter (callers arm in after-enter) and sits at rest, so the
|
|
173
|
+
// frame-vs-content delta is pure chrome.
|
|
174
|
+
calibrate();
|
|
117
175
|
if (typeof ResizeObserver === "undefined" || !content.value) {
|
|
118
176
|
remeasure();
|
|
119
177
|
return;
|
|
@@ -136,6 +194,9 @@ export function useSizeMorph(
|
|
|
136
194
|
cancelAnimationFrame(raf);
|
|
137
195
|
raf = 0;
|
|
138
196
|
}
|
|
197
|
+
// The next start() re-calibrates against whatever chrome that open
|
|
198
|
+
// cycle carries.
|
|
199
|
+
chromeAllowance = CHROME_ALLOWANCE_FLOOR + CHROME_ALLOWANCE_SLACK;
|
|
139
200
|
release();
|
|
140
201
|
}
|
|
141
202
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { armTransitionClassWatchdog, stripTransitionClasses } from "./transitionWatchdog";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.useRealTimers();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function makeEl(...classes: string[]): HTMLElement {
|
|
10
|
+
const el = document.createElement("div");
|
|
11
|
+
el.className = classes.join(" ");
|
|
12
|
+
return el;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("stripTransitionClasses", () => {
|
|
16
|
+
it("removes every name-prefixed transition class and keeps the rest", () => {
|
|
17
|
+
const el = makeEl(
|
|
18
|
+
"hk-modal-overlay",
|
|
19
|
+
"hk-modal-overlay-enter-from",
|
|
20
|
+
"hk-modal-overlay-enter-active",
|
|
21
|
+
"other-class",
|
|
22
|
+
);
|
|
23
|
+
stripTransitionClasses(el, "hk-modal-overlay");
|
|
24
|
+
expect(el.className).toBe("hk-modal-overlay other-class");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("never strips a class merely containing the name mid-word", () => {
|
|
28
|
+
const el = makeEl("hk-modal-overlay-v2");
|
|
29
|
+
stripTransitionClasses(el, "hk-modal-overlay");
|
|
30
|
+
expect(el.className).toBe("hk-modal-overlay-v2");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("is a no-op on a class-less element", () => {
|
|
34
|
+
const el = makeEl("hk-modal-overlay");
|
|
35
|
+
stripTransitionClasses(el, "hk-modal-overlay");
|
|
36
|
+
expect(el.className).toBe("hk-modal-overlay");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("armTransitionClassWatchdog", () => {
|
|
41
|
+
it("strips stuck transition classes once the budget lapses while open", () => {
|
|
42
|
+
vi.useFakeTimers();
|
|
43
|
+
const el = makeEl(
|
|
44
|
+
"hk-modal-overlay",
|
|
45
|
+
"hk-modal-overlay-enter-from",
|
|
46
|
+
"hk-modal-overlay-enter-active",
|
|
47
|
+
);
|
|
48
|
+
armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
|
|
49
|
+
vi.advanceTimersByTime(599);
|
|
50
|
+
expect(el.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
|
|
51
|
+
vi.advanceTimersByTime(2);
|
|
52
|
+
expect(el.className).toBe("hk-modal-overlay");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("leaves the element alone once the surface closed (leave owns it)", () => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
const el = makeEl("hk-modal-overlay", "hk-modal-overlay-enter-from");
|
|
58
|
+
armTransitionClassWatchdog(el, "hk-modal-overlay", () => false);
|
|
59
|
+
vi.advanceTimersByTime(1000);
|
|
60
|
+
expect(el.classList.contains("hk-modal-overlay-enter-from")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("disarming cancels the strip (a normal enter finalized)", () => {
|
|
64
|
+
vi.useFakeTimers();
|
|
65
|
+
const el = makeEl("hk-modal-overlay", "hk-modal-overlay-enter-active");
|
|
66
|
+
const disarm = armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
|
|
67
|
+
disarm();
|
|
68
|
+
vi.advanceTimersByTime(1000);
|
|
69
|
+
expect(el.classList.contains("hk-modal-overlay-enter-active")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("does nothing when no transition class is stuck", () => {
|
|
73
|
+
vi.useFakeTimers();
|
|
74
|
+
const el = makeEl("hk-modal-overlay");
|
|
75
|
+
armTransitionClassWatchdog(el, "hk-modal-overlay", () => true);
|
|
76
|
+
vi.advanceTimersByTime(1000);
|
|
77
|
+
expect(el.className).toBe("hk-modal-overlay");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Transition-class repair kit — the enter-side counterpart of HkModal's
|
|
2
|
+
// leave watchdog.
|
|
3
|
+
//
|
|
4
|
+
// A Vue <Transition> flips enter-from → enter-to through rAF and waits on
|
|
5
|
+
// transitionend. When rAF starves (occluded/backgrounded webview — the
|
|
6
|
+
// same pathology the leave watchdog in HkModal bounds), the enter classes
|
|
7
|
+
// FREEZE on the element: the layer keeps `*-enter-from` (opacity: 0,
|
|
8
|
+
// translateY(100%), …) while the surface is logically open. The modal's
|
|
9
|
+
// scrim then stays invisible with the panel floating above it, and the
|
|
10
|
+
// eventual close flashes the resurrected curtain at full opacity — the
|
|
11
|
+
// 2026-09 mobile "black rectangle" report (the leave pair
|
|
12
|
+
// leave-from → leave-to snaps the scrim to opacity: 1 before fading).
|
|
13
|
+
//
|
|
14
|
+
// Two primitives:
|
|
15
|
+
// stripTransitionClasses — remove every `name-*` transition class so the
|
|
16
|
+
// element snaps to its resting state (no fade — this is a repair
|
|
17
|
+
// path, the animation already failed).
|
|
18
|
+
// armTransitionClassWatchdog — bounded safety net: if the element still
|
|
19
|
+
// carries `name-enter-*` classes once the budget lapses (larger than
|
|
20
|
+
// any themed duration), strip them. Normal enters disarm it; a
|
|
21
|
+
// completed enter is a no-op (the classes are already gone).
|
|
22
|
+
|
|
23
|
+
/** The exact set of classes Vue's <Transition name=…> stamps on an
|
|
24
|
+
* element — matched precisely so a hand-written class that merely
|
|
25
|
+
* starts with the name (e.g. `name-v2`) is never touched. */
|
|
26
|
+
const TRANSITION_CLASS_SUFFIXES = [
|
|
27
|
+
"-enter-from",
|
|
28
|
+
"-enter-active",
|
|
29
|
+
"-enter-to",
|
|
30
|
+
"-leave-from",
|
|
31
|
+
"-leave-active",
|
|
32
|
+
"-leave-to",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
function transitionClassesOf(el: HTMLElement, name: string): string[] {
|
|
36
|
+
const names = TRANSITION_CLASS_SUFFIXES.map((suffix) => `${name}${suffix}`);
|
|
37
|
+
return Array.from(el.classList).filter((cls) => names.includes(cls));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Strip all `name-*` transition classes off `el`, snapping it to its
|
|
41
|
+
* resting (non-transition) state. Safe on class-less elements. */
|
|
42
|
+
export function stripTransitionClasses(el: HTMLElement, name: string): void {
|
|
43
|
+
for (const cls of transitionClassesOf(el, name)) el.classList.remove(cls);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Bound a stuck transition: if the element still carries ANY `name-*`
|
|
48
|
+
* transition class after `budgetMs` while the surface is open (the enter
|
|
49
|
+
* never finalized — or an interrupted leave left its pair behind — rAF
|
|
50
|
+
* starvation), strip them so the layer renders in its open state instead
|
|
51
|
+
* of staying frozen at a from/to pair. The budget is larger than any
|
|
52
|
+
* themed CSS duration, so a legitimate animation never sees the strip.
|
|
53
|
+
*
|
|
54
|
+
* `isStillOpen` gates the repair: once the surface closed, the leave owns
|
|
55
|
+
* the element and a late strip must not fight it.
|
|
56
|
+
*
|
|
57
|
+
* @returns the disarm function (call on after-enter / enter-cancelled /
|
|
58
|
+
* close / unmount).
|
|
59
|
+
*/
|
|
60
|
+
export function armTransitionClassWatchdog(
|
|
61
|
+
el: HTMLElement,
|
|
62
|
+
name: string,
|
|
63
|
+
isStillOpen: () => boolean,
|
|
64
|
+
budgetMs = 600,
|
|
65
|
+
): () => void {
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
if (!isStillOpen()) return;
|
|
68
|
+
if (transitionClassesOf(el, name).length > 0) {
|
|
69
|
+
stripTransitionClasses(el, name);
|
|
70
|
+
}
|
|
71
|
+
}, budgetMs);
|
|
72
|
+
return () => clearTimeout(timer);
|
|
73
|
+
}
|